Rozdział 8. Uchylenie tajemnicy

Rzućmy spojrzenie pod maskę silnika i wytłumaczymy w jaki sposób Git realizuje swoje cuda. Nie będę wchodził w szczegóły. Dla pogłębienia tematu odsyłam na angielskojęzyczny podręcznik użytkownika.

Niewidzialność

Jak to możliwe, że Git jest taki niepostrzeżony? Zapominając na chwilę o sporadycznych commits i merges, możesz pracować w sposób, jakby kontrola wersji w ogóle nie istniała. Chciałem powiedzieć, do czasu aż będzie ci potrzebna. A oto chodzi, byś był zadowolony z tego, że Git cały czas czuwa nad twoją pracą.

Inne systemy kontroli wersji ciągle zmuszają cię do ciągłego borykania się z zagadnieniem samej kontroli i związanej z tym biurokracji. Pliki mogą być zabezpieczone przed zapisem, aż do momentu gdy uda ci się poinformować centralny serwer o tym, że chciałabyś nad nimi popracować. Przy wzroście liczby użytkowników nawet najprostsze polecenia stają się wolne jak ślimak. Gdy tylko zniknie sieć lub centralny serwer praca staje.

W przeciwieństwie do tego, Git posiada kronikę całej swojej historii w podkatalogu .git twojego katalogu roboczego. Jest to twoja własna kopie całej historii, z którą mogłabyś pracować offline, aż do momentu gdy zechcesz wymienić dane z innymi. Posiadasz absolutną kontrolę nad losem twoich danych, ponieważ Git potrafi dla ciebie w każdej chwili odtworzyć zapamiętany poprzednio stan z właśnie podkatalogu .git.

Integralność

Z kryptografią przez większość ludzi łączona jest poufność informacji, jednak równie ważnym jej celem jest zabezpieczenie danych. Właściwe zastosowanie kryptograficznych funkcji hashujących (funkcji skrótu) może uchronić przed nieumyślnym lub celowym zniszczeniem danych.

Klucz hashujący SHA1 mogłabyś wyobrazić sobie jako składający się ze 160 bitów numer identyfikacyjny jednoznacznie opisujący dowolny łańcuch znaków, i który spotkasz w sowim życiu jeden jedyny raz. Nawet i więcej niż to: wszystkie łańcuchy znaków, jakie ludzkość przez wiele generacji stworzyła.

Sama suma kontrolna SHA1 też jest łańcuchem znaków w formie bajtów. Możemy generować hashe SHA1 z łańcuchów samych zawierających inne hashe SHA1. Ta prosta obserwacja okazała się niesamowicie pożyteczna: jeśli cię to zainteresowało poszukaj informacji na temat hash chains. Zobaczymy później w jaki sposób wykorzystuje je Git dla zapewnienia produktywności i integralności danych.

Krótko mówiąc, Git przechowuje twoje dane w podkatalogu .git/objects, gdzie zamiast nazw plików znajdziesz numery identyfikacyjne. Poprzez wykorzystanie tych numerów identyfikacyjnych jako nazwy plików razem z kilkoma innymi trikami związanymi z plikami blokującymi i znacznikami czasu, Git zamienia twój prosty system plików na produktywną i solidną bazę danych.

Inteligencja

Skąd Git wie o tym, że zmieniłaś nazwę jakiegoś pliku, jeśli nigdy go o tym wyraźnie nie poinformowałaś? Oczywiście, być może użyłaś polecenia git mv, jest to jednak to samo jakbyś użyła git rm, a następnie git add.

Git poszukuje heurystycznie zmian nazw w następujących po sobie wersjach kopii. Dodatkowo potrafi czasami nawet znaleźć całe bloki z kodem przenoszonym tam i z powrotem między plikami! Mimo iż wykonuje kawał dobrej roboty, a ta właściwość staje się coraz lepsza, nie potrafi niestety jeszcze poradzić sobie z wszystkimi możliwymi przypadkami. Jeśli to u ciebie nie działa, spróbuj poszukać opcji rozszerzonego rozpoznawania kopii, aktualizacja samego Gita, też może pomóc.

Indeksowanie

Dla każdego kontrolowanego pliku, Git zapamiętuje informacje o jego wielkości, czasie utworzenia i czasie ostatniej edycji w pliku znanym nam jako indeks. By ustalić, czy nastąpiła jakaś zmiana, Git porównuje stan aktualny ze stanem zapamiętanym w indeksie. Jeśli dane te nie różnią się, Git może pominąć czytanie zawartości pliku.

Ponieważ sprawdzenie statusu pliku trwa dużo krócej niż jego całkowite wczytanie, to jeśli dokonałaś zmian tylko na kilku plikach Git zaktualizuje swój stan w mgnieniu oka.

Stwierdziliśmy już wcześniej, że indeks jest przechowalnią (ang. staging area). Jak to możliwe, że stos informacji o statusie danych może być przechowalnią? Ponieważ polecenie add transportuje pliki do bazy danych Git i aktualizuje informacje o ich statusie, podczas gdy polecenie commit (bez opcji) tworzy commit tylko wyłącznie na podstawie informacji o statusie plików, ponieważ pliki te już się w tej bazie znajdują.

Korzenie Git

Ten Linux Kernel Mailing List post opisuje cały łańcuch zdarzeń, które inicjowały powstanie Git. Cały post jest archeologicznie fascynującą stroną dla historyków zajmujących się Gitem.

Obiektowa baza danych

Każda wersja twoich danych jest przechowywana w obiektowej bazie danych, która znajduje się w podkatalogu .git/objects. Inne miejsca w .git/ posiadają mniej ważne dane, jak indeks, nazwy gałęzi (branch), tagi, logi, konfigurację, aktualną pozycję HEAD i tak dalej. Obiektowa baza danych jest prosta, mimo to jednak elegancka i jest źródłem siły Gita.

Każdy plik w .git/objects jest obiektem. Istnieją trzy rodzaje obiektów, które nas interesują: blob, tree i commit.

Bloby

Na początek magiczna sztuczka. Wymyśl jakąś nazwę pliku, jakąkolwiek. W pustym katalogu:

$ echo sweet > TWOJA_NAZWA

$ git init
$ git add .
$ find .git/objects -type f
$ find .git/objects -type f

Zobaczysz coś takiego: .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d.

Skąd mogłem to wiedzieć, mimo iż nie znałem nazwy pliku? Ponieważ suma kontrolna SHA1 dla:

"blob" SP "6" NUL "sweet" LF

wynosi właśnie: aa823728ea7d592acc69b36875a482cdf3fd5c8d. Przy czym SP to spacja, NUL - to bajt zerowy, a LF to znak nowej linii (newline). Możesz to skontrolować wpisując:

$ printf "blob 6\000sweet\n" | sha1sum

Git pracuje asocjacyjnie (skojarzeniowo): dane nie są zapamiętywane na podstawie ich nazwy, tylko wartości ich własnego hasha SHA1 w pliku, który określamy mianem obiektu blob. Sumę kontrolną SHA1 możemy sobie wyobrazić jako niepowtarzalny numer identyfikacyjny zawartości pliku, co oznacza, że pliki adresowane są na podstawie ich zawartości. Początkowe blob 6, to jedynie adnotacja, która określa tylko rodzaj obiektu i jego wielkość w bajtach, pozwala to na uproszczenie zarządzania wewnętrznego.

Przez to właśnie mogłem przepowiedzieć wynik. Nazwa pliku nie ma znaczenia, jedynie jego zawartość służy do utworzenia obiektu blob.

Pytasz się, a co w przypadku identycznych plików? Spróbuj dodać kopie twojej danej pod jakąkolwiek nazwą. Zawartość .git/objects nie zmieni się, niezależnie ile kopii dodałaś. Git zapamięta zawartość pliku wyłącznie jeden raz.

Na marginesie, dane w .git/objects są spakowane poprzez zlib, nie powinieneś otwierać ich bezpośrednio. Przefiltruj je najpierw przez zpipe -d, albo wpisz:

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

polecenie to pokaże ci zawartość obiektu jako tekst.

Trees

Gdzie są więc nazwy plików? Przecież muszą być gdzieś zapisane. Podczas wykonywania commit Git troszczy się o nazwy plików:

$ git commit # dodaj jakiś opis.
$ find .git/objects -type f
$ find .git/objects -type f

Powinieneś ujrzeć teraz 3 obiekty. Tym razem nie jestem w stanie powiedzieć, jak nazywają się te dwa nowe pliki, ponieważ częściowo są zależne od nazwy jaką nadałaś plikom. Pójdźmy dalej, zakładając, że jedną z tych danych nazwałaś “rose”. Jeśli nie, możesz zmienić opis, by wyglądał jakby był twój:

$ git filter-branch --tree-filter 'mv TWOJA_NAZWA rose'
$ find .git/objects -type f

Powinnaś zobaczyć teraz plik .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9, ponieważ jest to suma kontrolna SHA1 jego zawartości.

"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

Sprawdź, czy plik na prawdę odpowiada powyższej zawartości przez polecenie:

$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch

Za pomocą zpipe łatwo sprawdzić hash SHA1:

$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum

Sprawdzanie za pomocą cat-file jest troszeczkę kłopotliwe, bo jego output zawiera więcej niż tylko nieskomprymowany obiekt pliku.

Nasz plik to tak zwany obiekt tree: lista wyrażeń, na którą składają się rodzaj pliku, jego nazwa i jego suma kontrolna SHA1. W naszym przykładzie typ pliku to 100644, co oznacza, że rose jest plikiem zwykłym, natomiast hash SHA1 odpowiada sumie kontrolnej SHA1 obiektu blob zawierającego zawartość rose. Inne możliwe rodzaje plików to programy, linki symboliczne i katalogi. W ostatnim przypadku hash SHA1 wskazuje na obiekt tree.

Jeśli użyjesz polecenia filter-branch, otrzymasz stare objekty, które nie są już używane. Mimo iż automatycznie zostaną usunięte po upłynięciu okresu karencji, chcemy się ich pozbyć od zaraz, aby lepiej prześledzić następne przykłady.

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

W prawdziwych projektach powinnaś unikać takich komend, ponieważ zniszczą zabezpieczone dane. Jeśli chcesz posiadać czyste repozytorium, to najlepiej załóż nowy klon. Bądź też ostrożna przy bezpośredniej manipulacji .git: gdy równocześnie wykonywane jest polecenie Git i zgaśnie światło? Generalnie do kasowania referencji powinnaś używać git update-ref -d, nawet gdy ręczne usunięcie ref/original jest dość bezpieczne.

Commits

Wytłumaczyliśmy dwa z trzech obiektów. Ten trzeci to obiekt commit Jego zawartość jest zależna od opisu commit jak i czasu jego wykonania. By wszystko do naszego przykładu pasowało, musimy trochę pokombinować.

$ git commit --amend -m Shakespeare # Zmień ten opis.
$ git filter-branch --env-filter 'export GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800" GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800" GIT_COMMITTER_NAME="Bob" GIT_COMMITTER_EMAIL="bob@example.com"' # Zmanipuluj znacznik czasowy i nazwę autora.
$ find .git/objects -type f
$ find .git/objects -type f

Powinieneś znaleźć .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187, co odpowiada sumie kontrolnej SHA1 jego zawartości:

"commit 158" NUL "tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9" LF "author Alice <alice@example.com> 1234567890 -0800" LF "committer Bob <bob@example.com> 1234567890 -0800" LF LF "Shakespeare" LF

Jak i w poprzednich przykładach możesz użyć zpipe albo cat-file by to sprawdzić.

To jest pierwszy commit, przez to nie posiada matczynych commits. Następujące commits będą zawsze zawierać przynajmniej jedną linikę identyfikującą rodzica.

Nie do odróżnienia od magii

Tajemnice Gita wydają się być proste. Wygląda to jak połączenie kilku skryptów, troszeczkę kodu C i w ciągu kilku godzin jesteśmy gotowi: zmiksowanie podstawowych operacji na systemie danych, obliczenia SHA1, przyprawienie plikami blokującymy i synchronizacją dla stabilności. W sumie można by tak opisać najwcześniejsze wersje Gita. Tym niemniej, abstrahując od udanych trików pakujących, by oszczędnie odnosić się z pamięcią i udanych trików indeksujących by zaoszczędzić czas, wiemy jak Git sprawnie przemienia system danych w objektową bazę danych, co jest optymalne dla kontroli wersji.

Przyjmijmy, gdy jakikolwiek plik w obiektowej bazie danych ulegnie zniszczeniu poprzez błąd nośnika, to jego SHA1 nie będzie zgadzać się z jego zawartością, co od razu wskaże nam problem. Poprzez tworzenie kluczy SHA1 z kluczy SHA1 innych objektów, osiągniemy integralność danych na wszystkich poziomach. Commits są elementarne, to znaczy, commit nie potrafi zapamiętać jedynie części zmian: hash SHA1 commit możemy obliczyć i zapamiętać dopiero po tym gdy zapamiętane zostały wszystkie obiekty tree, blob i rodziców commit. Obiektowa baza dynch jest odporna na nieoczekiwane przerwy, jak na przykład przerwanie dostawy prądu.

Możemy przetrwać nawet podstępnego przeciwnika. Wyobraź sobie, ktoś ma zamiar zmienić treść jakiegoś pliku, która leży w jakiejś starszej wersji projektu. By sprawić pozory, że baza danych wygląda nienaruszona musiałby zmienić sumy kontrolne SHA1 korespondujących obiektów, ponieważ plik zawiera teraz zmieniony sznur znaków. To znaczy również, że musiałby zmienić każdy hash obiektu tree, które ją referują oraz w wyniku tego wszystkie sumy kontrolne commits zawierające obiekty tree dodatkowo do pochodnych tych commits. Oznacza to również, że suma kontrolna oficjalnego HEAD różni się od sumy kontrolnej HEAD manipulowanego repozytorium. Wystarczy teraz prześledzić ścieżkę różniących się hashy SHA1, odnaleźć okaleczony plik, jak i commit w którym po raz pierwszy wystąpił.

Krótko mówiąc, dopóki reprezentujące ostatni commit 20 bajtów są zabezpieczone, sfałszowanie repozytorium Gita nie jest możliwe.

A co ze sławnymi możliwościami Gita? Branching? Merging? Tags? To szczegół. Aktualny HEAD przetrzymywany jest w pliku .git/HEAD, która posiada hash SHA1 ostatniego commit. Hash SHA1 zostaje aktualizowany podczas wykonania commit, tak samo jak i przy wielu innych poleceniach. branches to prawie to samo, są plikami zapamiętanymi w .git/refs/heads. Tags również, znajdziemy je w .git/refs/tags, są one jednak aktualizowane poprzez serię innych poleceń.