Розділ 8. Розкриваємо таємниці

Ми заглянемо під капот і пояснимо, як Git творить свої чудеса. Я опущу зайві деталі. За більш детальними описами зверніться до посібник користувача.

Невидимість

Як Git може бути таким ненав'язливим? За винятком періодичних коммітов і злиттів, ви можете працювати так, як ніби й не підозрюєте про якесь управління версіями. Так відбувається до того моменту, коли Git вам знадобиться, і тоді ви з радістю побачите, що він спостерігав за вами весь цей час.

Інші системи управління версіями змушують вас постійно боротися з загородками і бюрократією. Файли можуть бути доступні тільки для читання, поки ви явно не вкажете центральному серверу, які файли ви маєте намір редагувати. Із збільшенням кількості користувачів більшість базових команд починають виконуватися все повільніше. Неполадки з мережею або з центральним сервером повністю зупиняють роботу.

На противагу цьому, Git просто зберігає історію проекту в підкаталозі.git вашого робочого каталогу. Це ваша особиста копія історії, тому ви можете залишатися поза мережею, поки не захочете взаємодіяти з іншими. У вас є повний контроль над долею ваших файлів, оскільки Git в будь-який час може легко відновити збережений стан з.git.

Цілісність

Більшість людей асоціюють криптографію з утриманням інформації в таємниці, але іншим настільки ж важливим завданням є утримання її в цілості. Правильне використання криптографічних хеш-функцій може запобігти випадковому або зловмисному пошкодженню даних.

SHA1 хеш можна розглядати як унікальний 160-бітний ідентифікатор для кожного рядка байт, з яким ви стикаєтеся у вашому житті. Навіть більше того: для кожного рядка байтів, який будь-яка людина коли-небудь буде використовувати протягом багатьох життів.

Оскільки SHA1 хеш сам є послідовністю байтів, ми можемо отримати хеш рядка байтів, що містить інші хеши. Це просте спостереження на подив корисно: шукайте hash chains (ланцюги хешів). Пізніше ми побачимо, як Git використовує їх для ефективного забезпечення цілісності даних.

Кажучи коротко, Git зберігає ваші дані в підкаталозі .git/objects, де замість нормальних імен файлів ви знайдете тільки ідентифікатори. Завдяки використанню ідентифікаторів в якості імен файлів, а також деяких хитрощів з файлами блокувань і тимчасовими мітками, Git перетворює будь-яку скромну файлову систему в ефективну і надійну базу даних.

Інтелект

Як Git дізнається, що ви перейменували файл, навіть якщо ви ніколи не згадували про це явно? Звичайно, ви можете запустити git mv; але це те ж саме, що git rm, а потім git add.

Git евристично знаходить файли, які були перейменовані або скопійовані між сусідніми версіями. Насправді він може виявити, що ділянки коду були переміщені або скопійовані між файлами! Хоча Git не може охопити всі випадки, він все ж робить гідну роботу, і ця функція постійно покращується. Якщо вона не спрацювала, спробуйте опції, що включають більш ресурсномістке виявлення копіювання і подумайте про оновлення.

Індексація

Для кожного відстежуваного файлу, Git записує таку інформацію, як розмір, час створення і час останньої зміни, у файлі, відомому як index. Щоб визначити, чи був файл змінений, Git порівнює його поточні характеристики із збереженими в індексі. Якщо вони збігаються, то Git не стане перечитувати файл заново.

Оскільки зчитування цієї інформації значно швидше, ніж читання всього файлу, то якщо ви редагували лише кілька файлів, Git може оновити свій індекс майже миттєво.

Ми відзначали раніше, що індекс – це буферна зона. Чому набір властивостей файлів виступає таким буфером? Тому що команда add поміщає файли в базу даних Git і у відповідності з цим оновлює ці властивості; тоді як команда commit без опцій створює комміт, заснований тільки на цих властивостях і файлах, які вже в базі даних.

Походження Git

Це повідомлення в поштовій розсилці ядра Linux описує послідовність подій, які призвели до появи Git. Весь цей тред – приваблива археологічна розкопка для істориків Git.

База даних об'єктів

Кожна версія ваших даних зберігається в «базі даних об’єктів», що живе в підкаталозі .git/objects. Інші „жителі“ .git/ містять вторинні дані: індекс, імена гілок, теги, параметри налаштування, журнали, нинішнє розташування „головного“ комміта і так далі. База об’єктів проста і елегантна, і в ній джерело сили Git.

Кожен файл всередині .git/objects — це „об’єкт“. Нас цікавлять три типи об’єктів: об’єкти „блоб“, об’єкти „дерева“ і об’єкти „комміти“.

Блоби

Для початку один фокус. Виберіть ім'я файлу – будь-яке ім'я файлу. У порожньому каталозі:

$ echo sweet > ВАШЕ_ІМ’Я_ФАЙЛУ
$ git init
$ git add .
$ find .git/objects -type f

Ви побачите .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d.

Звідки я знаю це, не знаючи імені файлу? Це тому, що SHA1 хеш рядка

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

дорівнює aa823728ea7d592acc69b36875a482cdf3fd5c8d, де SP – це пробіл, NUL — нульовий байт і LF — переведення строки. Ви можете перевірити це, набравши

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

Git використовує „адресацію по вмісту“: файли зберігаються у відповідності не з іменами, а з хешамі вмісту, — у файлі, який ми називаємо „блоб-об’єктом“. Хеш можна розуміти як унікальний ідентифікатор вмісту файлу, що означає звернення до файлів за їх вмістом. Початковий blob 6 — лише заголовок, що складається з типу об'єкта і його довжини в байтах і спрощує внутрішній облік.

Таким чином, я можу легко передбачити, що ви побачите. Ім'я файлу не має значення: для створення блоб-об’єкта використовується тільки його вміст.

Вам може бути цікаво, що відбувається з однаковими файлами. Спробуйте додати копії свого файлу з якими завгодно іменами. Зміст .git/objects залишиться тим же незалежно від того, скільки копій ви додасте. Git зберігає дані лише одного разу.

Доречі, файли в каталозі .git/objects стискаються за допомогою zlib тому ви не зможете переглянути їх безпосередньо. Пропустіть їх через фільтр zpipe -d, або наберіть

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

що виведе зазначений об’єкт у вигляді, придатному для читання.

Дерева

Але де ж імена файлів? Вони повинні зберігатися на якомусь рівні. Git звертається за іменами під час комміта:

$ git commit  # Введіть який-небудь опис
$ find .git/objects -type f

Тепер ви повинні побачити три об'єкти. На цей раз я не можу сказати вам, що з себе представляють два нових файли, оскільки це частково залежить від обраного вами імені файлу. Далі будемо припускати, що ви назвали його «rose». Якщо це не так, то ви можете переписати історію, щоб вона виглядала як ніби ви це зробили:

$ git filter-branch --tree-filter 'mv ВАШЕ_ІМ’Я_ФАЙЛУ rose'
$ find .git/objects -type f

Тепер ви повинні побачити файл .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9, оскільки це SHA1 хеш його вмісту:

«tree» SP «32» NUL «100644 rose» NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

Перевірте, що цей файл дійсно містить зазначений рядок, набравши

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

З zpipe легко перевірити хеш:

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

Перевірка хешу за допомогою cat-file складніша, оскільки її висновок містить не тільки „сирий“ розпакований файл об’єкта.

Цей файл — обєкт „дерево“ ('tree, прим. пер.): перелік ланцюгів, що складаються з типу, імені файлу та його хешу. У нашому прикладі: тип файлу — 100644, що означає, що rose це звичайний файл; а хеш — блоб-об’єкт, в якому міститься вміст rose. Інші можливі типи файлів: виконувані файли, символічні посилання або каталоги. В останньому випадку, хеш вказує на об’єкт „дерево“.

Якщо ви запускали filter-branch, у вас є старі об’єкти які вам більше не потрібні. Хоча після закінчення терміну зберігання вони будуть викинуті автоматично, ми видалимо їх зараз, щоб було легше стежити за нашим іграшковим прикладом:

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

Для реальних проектів зазвичай краще уникати таких команд, оскільки ви знищуєте резервні копії. Якщо ви хочете мати чисте сховище, то звичайно краще зробити свіжий клон. Крім того, будьте обережні при безпосередньому втручанні в каталог .git: що якщо інша команда Git працює в цей же час, або раптово збій в електропостачанні? Взагалі кажучи, посилання потрібно видаляти за допомогою git update-ref -d, хоча зазвичай ручне видалення refs/original безпечне.

Комміти

Ми розглянули два з трьох об'єктів. Третій об’єкт — „комміт“ (commit). Його вміст залежить від опису комміта, як і від дати і часу його створення. Для відповідповідності тому, що ми маємо, ми повинні трохи „підкрутити“ Git:

$ git commit --amend -m Shakespeare  # Змінимо опис комміта.
$ 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"'  # Підробимо тимчасові мітки і авторів.
$ find .git/objects -type f

Тепер ви повинні побачити .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187 який є SHA1 хешем його вмісту:

«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

Як і раніше, ви самі можете запустити zpipe або cat-file, щоб побачити це.

Це перший комміт, тому тут немає батьківських коммітів, але подальші комміти завжди будуть містити хоча б один рядок, що ідентифікує батьківський комміт.

Невідрізнено від чаклунства

Секрети Git виглядають занадто простими. Схоже, що ви могли б об’єднати кілька shell-скриптів і додати трохи коду на C, щоб зробити все це в лічені години: суміш базових операцій з файлами і SHA1-хешування, приправлена ​​блокувальними файлами і fsync для надійності. По суті, це точний опис ранніх версій Git. Тим не менше, крім геніальних трюків з упаковкою для економії місця і з індексацією для економії часу, ми тепер знаємо, як спритно Git перетворює файлову систему в базу даних, що ідеально підходить для керування версіями.

Наприклад, якщо який-небудь файл у базі даних об’єктів пошкоджений через помилку диска, то його хеш тепер не співпаде, що приверне нашу увагу до проблеми. За допомогою хешування хешів інших об’єктів, ми підтримуємо цілісність на всіх рівнях. Комміти атомарні, так що в них ніколи не можна записати лише частину змін: ми можемо обчислити хеш комміта і зберегти його в базу даних тільки зберігши всі відповідні дерева, блоби і батьківські комміти. База даних об’єктів нечутлива до непередбачених переривань роботи, таких як перебої з живленням.

Ми завдаємо поразки навіть найбільш хитрим супротивникам. Припустимо, хтось намагається таємно змінити вміст файлу в давньої версії проекту. Щоб база об’єктів виглядала неушкодженою, він також повинен змінити хеш відповідного блоб-об'єкта, оскільки це тепер інша послідовність байтів. Це означає, що потрібно поміняти хеши всіх об’єктів дерев, що посилаються на цей файл; що в свою чергу змінить хеши всіх об’єктів коммітів за участю таких дерев; а також і хеши всіх нащадків цих коммітів. Внаслідок цього хеш офіційної головної ревізії буде відрізнятися від аналогічного хешу в цьому зіпсованому сховищі. По ланцюжку незбіжних хешів ми можемо точно обчислити спотворений файл, як і комміт, де він спочатку був пошкоджений.

Одним словом, неможливо підробити сховище Git, залишивши непошкодженими двадцять байт, що відповідають останньому комміту.

Як щодо відомих характерних особливостей Git? Галуження? Злиття? Теги? Очевидні подробиці. Поточна «голова» зберігається у файлі .git/HEAD, що містить хеш об’єкта комміта. Хеш оновлюється під час комміта, а також при виконанні багатьох інших команд. З гілками все аналогічно: це файли в .git/refs/heads. Те ж і з тегами: вони живуть у .git/refs/tags, але їх оновлює інший набір команд.