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 puste repozytorium Gita:
$ mkdir git-playground && cd git-playground
$ git init --object-format=sha1 --initial-branch=master
Initialized empty Git repository in path/to/git-playground/.git/
$ ls .git
HEAD config description hooks info objects refsW zwykłym repozytorium .git zawiera bazę obiektów, referencje, indeks i konfigurację. Klonowanie przesyła potrzebne obiekty oraz tworzy lokalne referencje i konfigurację; nie kopiuje po prostu wszystkich plików źródłowego .git. Zwykły klon odtwarza też gałąź wskazaną przez zdalny HEAD.
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 dwa pierwsze znaki hasha:
$ ls .git/objects/ -l
a1/
info/
pack/Luźne obiekty są plikami w katalogu nazwanym pierwszymi dwoma znakami hasha. Wiele obiektów może współdzielić ten katalog. Git może też łączyć obiekty w packfile w objects/pack. Identyfikator obejmuje nagłówek z typem i długością oraz treść, więc zmiana treści daje inny identyfikator.
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 odtwarzaniu 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 "$INITIAL_TREE_HASH" 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, w którym odtwarzasz 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
- odtworzy jego pliki w katalogu roboczym
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ę, odtwórzmy zapisane w nim pliki w katalogu roboczym 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 odtworzyliś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:

Zmiany w indeksie pozostają przygotowane do zatwierdzenia, dopóki ich nie zatwierdzisz lub nie odrzucisz. git add od razu zapisuje treść jako obiekty, więc nadpisaną wersję z indeksu można czasem odzyskać przed usunięciem nieosiągalnych obiektów. Zmian nigdy niedodanych do indeksu ani niezatwierdzonych Git nie przechowuje. Garbage collection jest mechanizmem utrzymania repozytorium, a nie pewnym terminem odzyskiwania danych.
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).
Git zgłasza pliki z indeksu jako nowe, ponieważ repozytorium nie ma jeszcze commitów. HEAD wskazuje gałąź bez pierwszego commitu i nie rozwiązuje się do commitu. Przy obliczaniu statusu Git porównuje indeks z pustym drzewem; sam plik HEAD nie wskazuje jednak tego drzewa.
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.txtwrite-tree zapisało obiekt, ale nie utworzyło commitu ani nie przesunęło HEAD. HEAD nadal wskazuje gałąź bez pierwszego commitu, więc plik z indeksu wciąż jest zgłaszany jako nowy.
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:
Przed tworzeniem commitów upewnij się, że Git ma ustawione user.name i user.email. Twoje hashe commitów będą inne niż w przykładach, ponieważ commity zawierają dane autora i znaczniki czasu.
$ 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 odtworzyć pliki tego commitu w katalogu roboczym:
$ git checkout $INITIAL_COMMIT_HASH
A f3.txt
HEAD is now at a27a75a... initial commitCheckout odtwarza f1.txt i f2.txt, ale zachowuje też przygotowany f3.txt, ponieważ nie koliduje z docelowym commitem. Usuń go teraz z indeksu i katalogu roboczego; jego treść i drzewo są już zapisane w bazie obiektów:
$ git rm -f f3.txt
rm 'f3.txt'
$ 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
- odtwarza pliki wskazane przez indeks w katalogu roboczym
- 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 wybranego commitu
HEAD to prosty plik tekstowy położony w .git/HEAD, który odwołuje się do aktualnie wybranego commitu. Ponieważ w poprzedniej sekcji wybraliśmy commit „latest” o hashu $LATEST_COMMIT_HASH, to dokładnie to zawiera plik HEAD:
$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ "$LATEST_COMMIT_HASH" = "$(git rev-parse HEAD)" ] && echo 'equal'
equalZwykle jednak plik HEAD odwołuje się do aktualnie wybranego commitu przez referencję 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 ustalić zestaw zmian między plikiem indeksu a aktualnie wybranym 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. Przełączymy się na 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:
- przełącza na
"initial commit", odtwarzającf1.txtif2.txtw katalogu roboczym - 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łąź jest nazwaną referencją do commitu. Przy luźnych referencjach jest plikiem tekstowym w .git/refs/heads; referencje mogą też być spakowane lub zapisane przez inny backend. W tym świeżym repozytorium pokażemy luźne referencje, zapisując identyfikatory commitów bezpośrednio. W zwykłych skryptach używaj git branch lub git update-ref.
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] initial commit
$ ls -l
f3.txtA zobaczmy tę drugą gałąź, forked:
$ git checkout forked
Switched to branch 'forked'
$ git log --pretty=oneline
[forked commit hash] forked commit
[initial commit hash] initial commit
$ 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.
Ten tag wskazuje na commit z rozwidlonej historii:
$ 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 [initial commit hash]... initial 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.