Zostań zawodowcem w GIT, poznając architekturę GIT w 15 minut
Git może wydawać się skomplikowanym systemem. Wystarczy spytać Google. Oto kilka tytułów, które wyrzuca szybkie wyszukiwanie:
Dlaczego, do licha, Git jest taki trudny… Git jest po prostu zbyt trudny… Czy możemy wreszcie przestać udawać, że Git jest prosty i łatwy do nauczenia… Dlaczego Git jest taki złożony i mylący…
Na pierwszy rzut oka te stwierdzenia mogą wydawać się słuszne, ale gdy raz zrozumiesz leżące pod spodem koncepcje, praca z Gitem staje się czystą przyjemnością. Problem z Gitem polega na tym, że jest bardzo elastyczny. A nieodłączną cechą każdego elastycznego systemu jest złożoność. Jestem głęboko przekonany, że jedynym sposobem walki z tą złożonością jest zejście do podstaw ukrytych pod dostarczonym interfejsem użytkownika i zrozumienie leżącego pod nim modelu mentalnego oraz architektury. Gdy to zrobisz, nie będzie już magii ani niespodziewanych wyników, a przy pracy z tymi złożonymi narzędziami poczujesz się jak u siebie.
Z materiału w tym artykule skorzystają zarówno programiści, którzy już pracowali z Gitem, jak i ci, którzy tylko zaczynają pracę z tym wspaniałym narzędziem kontroli wersji. Jeśli jesteś doświadczonym użytkownikiem GIT, znacznie lepiej zrozumiesz cykl życia checkout -> modify -> commit. Jeśli dopiero zaczynasz z Gitem, ten artykuł da Ci świetny start.
W tym wpisie będę używać niskopoziomowych, tak zwanych „hydraulicznych” (plumbing) komend, by pokazać, jak Git działa pod spodem. Nie musisz ich pamiętać, bo w zwykłej pracy używa się ich rzadko, ale są nieodzowne przy wyjaśnianiu architektury Gita.
Artykuł jest trochę długi i sądzę, że można go studiować tak:
- przelecieć artykuł od góry do dołu, by zrozumieć ogólny tok
- przeczytać uważnie i podążać za tekstem, wykonując ćwiczenia, których używam w artykule
Praktykując, utrwalisz wiedzę, którą tu zdobędziesz. W ćwiczeniach używam komend bash, więc jeśli masz system oparty na Uniksie, jesteś w domu. Jeśli używasz Windowsa, możesz uruchomić git-bash, który instaluje się razem z Gitem i jest dostępny przez opcję menu kontekstowego Git Bash here.
Git jako folder
Gdy uruchomisz git init w folderze, Git tworzy katalog .git. Otwórzmy więc terminal, utwórzmy nowy katalog, w którym będziemy pracować, i zainicjujmy tam pustego gita:
$ mkdir git-playground && cd git-playground
$ git init
Initialized empty Git repository in path/to/git-playground/.git/
$ ls .git
HEAD config description hooks info objects refsTo właśnie tu Git przechowuje wszystkie Twoje commity i inne informacje potrzebne do manipulowania nimi. Gdy klonujesz repozytorium, Git kopiuje ten jeden katalog do Twojego folderu, tworzy gałęzie śledzące dla każdej gałęzi klonowanego repozytorium oraz tworzy i wymeldowuje początkową gałąź wskazaną przez plik HEAD. Jaką rolę plik HEAD odgrywa w architekturze Gita, zobaczymy później, ale wniosek na teraz jest taki, że klonowanie repozytorium to w istocie po prostu kopiowanie katalogu .git z innej lokalizacji.
Git jako baza danych
Git to prosty magazyn danych klucz-wartość. Wkładasz wartość do repozytorium i dostajesz klucz, którym można się do tej wartości dostać. Komendą, która wkłada wartość do bazy, jest hash-object, i zwraca ona 40-znakową sumę kontrolną, która posłuży jako klucz. Komenda tworzy w repozytorium gita zwykły obiekt zwany blob. Zapiszmy do bazy prosty napis f1 content:
$ F1CONTENT_BLOB_HASH=$( \
echo 'f1 content' | git hash-object -w --stdin
)
$ echo $F1CONTENT_BLOB_HASH
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0Dla tych, którzy nie są zbyt zaznajomieni z shellem: główną komendą w powyższym fragmencie jest:
echo 'f1 content' | git hash-object -w --stdinKomenda echo wypisuje napis f1 content, a używając operatora potoku |, przekierowujemy to wyjście do komendy git hash-object. Parametr -w przekazany komendzie hash-object mówi jej, by zapisała obiekt; w przeciwnym razie komenda po prostu powie Ci, jaki byłby klucz. --stdin mówi komendzie, by czytała treść ze standardowego wejścia; jeśli tego nie podasz, hash-object oczekuje na końcu ścieżki do pliku. Jak wspomniano wcześniej, komenda git hash-object zwraca hash, który następnie zapisuję do zmiennej F1CONTENT_BLOB_HASH. Moglibyśmy też rozdzielić główne wykonanie i przypisanie do zmiennej tak:
$ echo 'f1 content' | git hash-object -w --stdin
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
$ F1CONTENT_BLOB_HASH=a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0Ale dla wygody w kolejnych fragmentach kodu będę używać krótszej wersji przypisywania wyjścia do zmiennych. Tych zmiennych używa się tam, gdzie oczekiwany jest napis z hashem, a żeby odczytać wartość, przed nazwą zmiennej stawia się $.
Aby odczytać wartość po kluczu, możemy użyć komendy cat-file z opcją -p. Komenda oczekuje klucza-hasha obiektu, który ma pobrać:
$ git cat-file -p $F1CONTENT_BLOB_HASH
f1 contentJak mówiłem wcześniej, .git to folder i wszystkie zapisane wartości/obiekty trzymane są w tym folderze. Możemy więc wejść do .git/objects i zobaczysz utworzony przez Gita folder o nazwie a1, czyli dwie pierwsze litery klucza-hasha:
$ ls .git/objects/ -l
a1/
info/
pack/Tak Git zwykle przechowuje obiekty — jeden folder na jeden blob. Git może jednak także scalać wiele blobów w jeden plik, tworząc pliki pack, a katalog pack, który widzisz powyżej, to miejsce, gdzie te pliki są przechowywane. Informacje związane z tymi obiektami pack Git trzyma w katalogu info. Git generuje hash dla blobów na podstawie ich treści, dlatego obiekty w Gicie są niezmienne — zmiana treści zmieniłaby hash.
Zapiszmy do repozytorium kolejny napis, f2 content:
$ F2CONTENT_BLOB_HASH=$( \
echo 'f2 content' | git hash-object -w --stdin )Zgodnie z oczekiwaniami widzisz, że folder \.git\objects zawiera teraz dwa rekordy: 9b/ i a1/:
$ ls .git/objects/ -l
9b/
a1/
info/
pack/Drzewo jako integralna część
Obecnie mamy w repozytorium dwa bloby:
F1CONTENT_BLOB_HASH -> 'f1 content'
F2CONTENT_BLOB_HASH -> 'f2 content'Potrzebujemy sposobu, by jakoś je zgrupować, a także powiązać każdy blob z nazwą pliku. I tu do gry wchodzi drzewo. Drzewo można utworzyć komendą git mktree, używając następującej składni dla każdego powiązania plik/blob:
[file-mode object-type object-hash file-name]
Wyjaśnienie trybu pliku znajdziesz w tej odpowiedzi. Użyjemy trybu 100644, który definiuje blob jako zwykły plik z prawem czytania i zapisu dla użytkownika. Te uprawnienia są używane przy wymeldowywaniu plików do katalogu roboczego, by poprawnie ustawić prawa na plikach i katalogach.
Aby więc powiązać dwa bloby z dwoma plikami, zrobimy tak:
$ INITIAL_TREE_HASH=$( \
printf '%s %s %s\t%s\n' \
100644 blob $F1CONTENT_BLOB_HASH f1.txt \
100644 blob $F2CONTENT_BLOB_HASH f2.txt |
git mktree )Tak jak przy hash-object, komenda mktree zwraca klucz-hash utworzonego obiektu drzewa:
$ echo $INITIAL_TREE_HASH
e05d9daa03229f7a7f6456d3d091d0e685e6a9dbOto więc, co mamy teraz:

Po wykonaniu komendy git tworzy w repozytorium trzeci obiekt, typu tree. Spójrzmy na niego:
$ ls .git/objects -l
e0 <--- initial tree object (INITIAL_TREE_HASH)
9b <--- 'f2 content' blob (F2CONTENT_BLOB_HASH)
a1 <--- 'f1 content' blob (F1CONTENT_BLOB_HASH)Przy użyciu mktree jako parametr możemy podać inny obiekt drzewa zamiast bloba. Takie drzewo zostanie powiązane z katalogiem, a nie ze zwykłym plikiem. Na przykład następująca komenda utworzyłaby drzewo z poddrzewem powiązanym z katalogiem nested-folder:
printf '%s %s %s\t%s\n' 040000 tree e05d9da nested-folder | git mktreeTryb pliku 040000 oznacza katalog, a my używamy typu tree zamiast blob. Właśnie tak git przechowuje zagnieżdżone katalogi w strukturze projektu.
Indeks to miejsce, w którym składa się drzewa
Każdy, kto pracuje z GIT, powinien znać pojęcie indeksu albo poczekalni (staging area) i prawdopodobnie widział następujący diagram:

Po prawej stronie widzisz repozytorium gita, które przechowuje obiekty gita: bloby, drzewa, commity i tagi. Użyliśmy komend hash-object i mktree, by bezpośrednio dodać do tego repozytorium obiekty blob i tree. Katalog roboczy po lewej to Twój lokalny system plików/katalog, do którego wymeldowujesz wszystkie pliki projektu. Ta sekcja wyjaśnia środkowy etap, który będziemy nazywać plikiem indeksu albo po prostu indeksem. To plik binarny (zwykle trzymany w .git/index), którego struktura przypomina obiekt drzewa. Zawiera posortowaną listę ścieżek, każdą z uprawnieniami i SHA1 obiektu blob/tree.
To miejsce, w którym git przygotowuje drzewo, zanim:
- zapisze je do repozytorium albo
- wymelduje je do katalogu roboczego
Mamy teraz w repozytorium jedno drzewo, utworzone w poprzednim rozdziale. Możemy wczytać to drzewo z repozytorium do pliku indeksu komendą read-tree:
$ git read-tree $INITIAL_TREE_HASHOczekujemy więc, że plik indeksu będzie miał dwa pliki. Strukturę bieżącego pliku indeksu możemy sprawdzić komendą git ls-files -s:
$ git ls-files -s
100644 a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0 0 f1.txt
100644 9b96e21cb748285ebec53daec4afb2bdcb9a360a 0 f2.txtPonieważ nie wprowadziliśmy żadnych zmian do pliku indeksu, jest on dokładnie taki sam jak drzewo, którym go zapełniliśmy. Skoro mamy w pliku indeksu poprawną strukturę, wymeldujmy go do katalogu roboczego komendą checkout-index z opcją -a:
$ git checkout-index -a
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 contentŚwietnie! Właśnie wymeldowaliśmy pliki, które ręcznie dodaliśmy do repozytorium gita, bez żadnych commitów. Czyż to nie fajne?
Ale plik indeksu zwykle nie pozostaje w stanie początkowego drzewa. Prawdopodobnie wiesz, że można go modyfikować komendami git add [file path] i git rm --cached [file path] dla pojedynczego pliku albo git add -A i git reset dla zbioru zmodyfikowanych/usuniętych plików. Wykorzystajmy więc tę wiedzę w praktyce i utwórzmy w repozytorium nowe drzewo zawierające jeden blob pliku powiązany z nowym plikiem tekstowym f3.txt. Treścią pliku będzie napis f3 content. Ale zamiast tworzyć drzewo ręcznie, jak w poprzedniej sekcji, użyjemy do tego pliku indeksu.
Obecnie mamy następującą strukturę pliku indeksu, opartą na drzewie initial tree, którym zapełniliśmy indeks:

To baza, do której będziemy stosować zmiany. Wszystkie zmiany, które wprowadzasz do pliku indeksu, są tymczasowe, dopóki faktycznie nie zapiszesz drzewa do repozytorium. Same obiekty, które dodajesz, są jednak zapisywane do repozytorium gita natychmiast. Jeśli odrzucisz bieżące zmiany drzewa, zostaną one później podchwycone przez odśmiecanie (GC) i usunięte. Oznacza to również, że zwykle, jeśli przypadkiem odrzucisz zmiany w pliku, wciąż da się je odzyskać, dopóki git nie uruchomi GC. A robi to zazwyczaj tylko wtedy, gdy krąży zbyt wiele luźnych obiektów, do których nic się nie odwołuje.
Zacznijmy od usunięcia dwóch plików w katalogu roboczym:
$ rm f1.txt f2.txtJeśli teraz uruchomimy git status, zobaczymy następujące:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f1.txt
new file: f2.txt
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
deleted: f1.txt
deleted: f2.txtTo dużo informacji. Raportuje dwa usunięte pliki i dwa nowe pliki, a także mówi „initial commit”. A oto dlaczego. Gdy uruchamiasz git status, git wykonuje dwa porównania:
- porównuje plik indeksu z bieżącym katalogiem roboczym — zmiany raportowane jako „not staged for commit”
- porównuje plik indeksu z commitem HEAD — zmiany raportowane jako „to be committed”
W naszym przypadku widzimy więc, że git raportuje dwa usunięte pliki w sekcji „Changes not staged for commit”, i wiemy, skąd to wziął — porównał bieżący katalog roboczy z plikiem indeksu i wykrył brak dwóch plików w katalogu roboczym (bo je usunęliśmy).
Widzimy też, że w sekcji „Changes to be committed” git raportuje dwa nowe pliki. To dlatego, że obecnie nie mamy jeszcze w repozytorium żadnych commitów, więc plik HEAD (wyjaśniony później) rozwiązuje się do tak zwanego obiektu „pustego drzewa” bez plików. Git myśli więc, że właśnie zaczęliśmy ze świeżym, nowym repozytorium — i dlatego pokazuje „initial commit” i traktuje każdy plik w pliku indeksu jako nowy.
Jeśli teraz uruchomimy git add ., zmodyfikuje to plik indeksu, usuwając z niego dwa pliki, a gdy ponownie uruchomimy git status, nie zaraportuje żadnych zmian, bo nie mamy plików ani w drzewie roboczym, ani w pliku indeksu:
$ git add .
$ git status
On branch master
Initial commit
nothing to commit (create/copy files and use "git add" to track)Zaczęliśmy od zadania utworzenia nowego drzewa z jednym nowym plikiem f3.txt. Utwórzmy ten plik i dodajmy go do indeksu:
$ echo 'f3 content' > f3.txt
$ git add f3.txtJeśli uruchomimy teraz git status:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txtWidzimy, że wykryto jeden nowy plik. Znowu zmiany raportowane są w sekcji „to be committed”, więc teraz Git porównał plik indeksu z „pustym drzewem”. Oczekujemy zatem, że plik indeksu będzie zawierał ten jeden nowy blob pliku. Sprawdźmy:
$ git ls-files -s
100644 5927d85c2470d49403f56ce27afd8f74b1a42589 0 f3.txt
# Zapisujemy hash bloba pliku f3.txt
$ F3CONTENT_BLOB_HASH=5927d85c2470d49403f56ce27afd8f74b1a42589Dobrze, indeks ma teraz poprawną strukturę i jesteśmy gotowi utworzyć z niego drzewo w repozytorium. Zrobimy to komendą write-tree:
$ LATEST_TREE_HASH=$( git write-tree )Świetnie, właśnie utworzyliśmy drzewo z pomocą indeksu. A hash nowego drzewa umieściliśmy w zmiennej LATEST_TREE_HASH. Moglibyśmy to zrobić ręcznie, zapisując blob f3 content do repozytorium i tworząc następnie drzewo przez mktree, ale z indeksem jest znacznie wygodniej.
Co ciekawe, jeśli uruchomisz teraz git status, wciąż zobaczysz, że git nadal uważa plik f3.txt za nowy:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txtTo dlatego, że choć utworzyliśmy i zapisaliśmy nasze drzewo do repozytorium, nie zaktualizowaliśmy pliku HEAD, który służy do porównania. Ponieważ do pliku HEAD możemy wstawić tylko hash commita albo referencję gałęzi, a nie mamy teraz ani jednego, ani drugiego, zostawmy plik HEAD na razie tak, jak jest.
Z tym nowo utworzonym drzewem mamy więc w repozytorium następujące obiekty:

Commit to opakowanie na drzewo
W tej sekcji staje się jeszcze ciekawiej. W codziennej pracy z Gitem zwykle nie spotykamy drzew ani blobów. Pracujemy z obiektami commitów. Czym więc jest commit w gicie? W istocie, mówiąc najprościej, to tylko opakowanie na obiekt drzewa, które:
- pozwala dołączyć wiadomość do drzewa (grupy plików)
- pozwala wskazać rodzica (commit)
Mamy teraz w naszym repozytorium gita dwa drzewa — initial tree i latest tree. Owińmy pierwszy obiekt drzewa w commit komendą commit-tree, która przyjmuje hash drzewa, dla którego ma utworzyć commit:
$ INITIAL_COMMIT_HASH=$( \
echo 'initial commit' | git commit-tree $INITIAL_TREE_HASH
)Po uruchomieniu powyższej komendy będziemy mieć następujące:

I możemy wymeldować ten commit do katalogu roboczego:
$ git checkout $INITIAL_COMMIT_HASH
A f3.txt
HEAD is now at a27a75a... initial commitTeraz widzimy nasze dwa pliki w katalogu roboczym:
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 contentGdy uruchamiasz git checkout [commit-hash], git robi następujące rzeczy:
- wczytuje drzewo, na które wskazuje commit, do pliku indeksu
- wymeldowuje plik indeksu do katalogu roboczego
- aktualizuje plik HEAD hashem commita
To dokładnie te operacje, które ręcznie wykonaliśmy w poprzedniej sekcji.
Historia Gita to łańcuch commitów
Wiemy więc już, że commit to tylko opakowanie na drzewo. Wspomniałem też, że może mieć commit rodzica. Początkowo mieliśmy dwa drzewa i w poprzedniej sekcji owinęliśmy jedno z nich w commit, więc zostało nam jedno osierocone drzewo. Owińmy je teraz w commit tą samą operacją commit-tree, której użyłem powyżej, ale z opcją -p, by wskazać rodzica:
$ LATEST_COMMIT_HASH=$( \
echo 'latest commit' |
git commit-tree $LATEST_TREE_HASH -p $INITIAL_COMMIT_HASH )I oto co mamy teraz:

Jeśli więc uruchomisz teraz git log, by zobaczyć historię, i przekażesz hash commita „latest”, zobaczysz dwa commity:
$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commitI możemy się między nimi przełączać. Oto commit początkowy:
$ git checkout $INITIAL_COMMIT_HASH
$ ls
f1.txt f2.txtCommit najnowszy:
$ git checkout $LATEST_COMMIT_HASH
$ ls
f3.txtHEAD to referencja do wymeldowanego commita
HEAD to prosty plik tekstowy położony w .git/HEAD, który odwołuje się do aktualnie wymeldowanego commita. Ponieważ w poprzedniej sekcji wymeldowaliśmy commit „latest” o hashu $LATEST_COMMIT_HASH, to dokładnie to zawiera plik HEAD:
$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ $LATEST_COMMIT_HASH == "88d3b9901d62..." ]; echo 'equal'
equalZwykle jednak plik HEAD odwołuje się do aktualnie wymeldowanego commita przez referencje gałęzi. Gdy odwołuje się do commita bezpośrednio, znajduje się w stanie odłączonym (detached state). Ale nawet gdy HEAD trzyma referencję do gałęzi w ten sposób:
ref: refs/heads/masterwciąż rozwiązuje się do hasha commita.
Wiesz już, że Git używa commita wskazywanego przez HEAD przy wykonywaniu git status, by wyprodukować zestaw zmian między plikiem indeksu a aktualnie wymeldowanym drzewem/commitem. Innym zastosowaniem HEAD jest wyznaczenie commita, który posłuży jako rodzic przyszłego commita.
Co ciekawe, plik HEAD jest tak ważny dla większości operacji, że jeśli ręcznie wyczyścisz jego treść, Git uzna, że to nie jest repozytorium gita, i zgłosi błąd:
fatal: Not a git repository (or any of the parent directories): .gitGałąź to plik tekstowy wskazujący na commit
Mamy więc teraz w naszym repozytorium dwa commity, które tworzą następującą historię:

$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commitWprowadźmy do istniejącej historii rozwidlenie. Wymeldujemy commit początkowy i zmodyfikujemy treść pliku f1.txt. Następnie zrobimy nowy commit znaną Ci już komendą git commit:
$ git checkout $INITIAL_COMMIT_HASH
$ echo 'I am modified f1 content' > f1.txt
$ git add f1.txt
$ git commit -m "forked commit"
1 file changed, 1 insertion(+), 1 deletion(-)Powyższy fragment kodu:
- wymeldowuje
"initial commit", który dodajef1.txtif2.txtdo katalogu roboczego - zamienia treść
f1.txtna napisI am modified f1 content - aktualizuje plik indeksu przez
git add
Ostatnia komenda git commit, jak już wiemy, wykonuje pod spodem kilka operacji:
- tworzy drzewo z pliku indeksu
- zapisuje to drzewo do repozytorium
- tworzy obiekt commita, który owija to drzewo
- ustawia
initial commitjako rodzica nowego commita, bo to ten commit mamy obecnie w plikuHEAD
Musimy też zapisać hash tego commita do zmiennej. Ponieważ Git aktualizuje HEAD bieżącym commitem, możemy go stamtąd odczytać:
FORKED_COMMIT_HASH=$( cat .git/HEAD )Mamy więc teraz w naszym repozytorium gita następujące obiekty:

Co tworzy następującą historię commitów:

Z powodu obecności rozwidlenia mamy tu dwie linie pracy. A to znaczy, że musimy wprowadzić dwie gałęzie, by śledzić każdą linię niezależnie. Utwórzmy gałąź master, która śledzi liniową historię zaczynającą się od latest commit, oraz gałąź forked, która śledzi historię od forked commit.
Gałąź to plik tekstowy zawierający hash commita. Jest częścią referencji gita — grupy obiektów, które odwołują się do commita. Drugim typem referencji jest lekki tag. Git przechowuje wszystkie referencje w folderze .git/refs, a gałęzie w katalogu .git/refs/heads. Ponieważ gałąź to prosty plik tekstowy, możemy po prostu utworzyć plik z treścią w postaci hasha commita.
To będzie wskazywać na gałąź główną z „latest commit”:
$ echo $LATEST_COMMIT_HASH > .git/refs/heads/masterA to będzie wskazywać na gałąź „forked” z „forked commit”:
$ echo $FORKED_COMMIT_HASH > .git/refs/heads/forked
Dotarliśmy więc w końcu do zwykłego przepływu pracy, do którego jesteś przywykły — możemy teraz przełączać się między gałęziami:
$ git checkout master
Switched to branch 'master'
$ git log --pretty=oneline
[some hash] latest commit
[some hash] first commit
$ ls -l
f3.txtA zobaczmy tę drugą gałąź, forked:
$ git checkout forked
Switched to branch 'forked'
$ git log --pretty=oneline
f30305a8a23312f70ba985c8c644fcdca19dab95 forked commit
f30305a8a23312f70ba985c8c644fcdca19dab95 initial commit
$ git ls
f1.txt f2.txt
$ cat f1.txt
I am modified f1 contentTag to plik tekstowy wskazujący na commit
Prawdopodobnie wiesz, że zamiast linii pracy, którą śledzimy gałęziami, możemy śledzić pojedyncze commity za pomocą tagów. Tagi używane są zwykle do oznaczania ważnych kamieni milowych, takich jak wydania. Właśnie teraz mamy w repozytorium 3 commity. I możemy nadać im nazwy za pomocą tagu. Tak jak gałąź, tag to plik tekstowy zawierający hash commita i jest częścią grupy referencji gita.
Jak już wiesz, git przechowuje wszystkie referencje w folderze .git/refs, a tagi w podfolderze .git/refs/tags. Ponieważ to prosty plik tekstowy, możemy utworzyć plik i wstawić do niego hash commita.
To będzie wskazywać na commit najnowszy:
$ echo $FORKED_COMMIT_HASH > .git/refs/tags/forkedA to na commit początkowy:
$ echo $INITIAL_COMMIT_HASH > .git/refs/tags/initialGdy to zrobimy, możemy przełączać się między commitami za pomocą tagów. Oto commit początkowy:
$ git checkout tags/initial
HEAD is now at 285aec7... second commit
$ cat f1.txt
f1 contentA oto commit z rozwidlenia:
$ git checkout tags/forked
$ cat f1.txt
I am modified f1 contentIstnieje też „tag adnotowany”, który różni się od tego lekkiego. To prawdziwy obiekt, który może zawierać wiadomość tak jak commit, i jest przechowywany w repozytorium obok innych obiektów.
Podsumowanie
Artykuł wyszedł dość długi, ale starałem się, by był możliwie przejrzysty i jasny. Gdy przepracujesz go i zrozumiesz wszystkie pokazane tu koncepcje, będziesz w stanie pracować z Gitem znacznie skuteczniej i nigdy nie powinieneś obawiać się niespodziewanych wyników.
Jeśli chcesz dowiedzieć się więcej o Gicie, gorąco polecam tę znakomitą książkę, która jest też darmowa online.