Станьте професіоналом GIT, вивчивши архітектуру GIT за 15 хвилин
Git може здатися складною системою. Просто запитайте в Google. Ось деякі з назв, які відкриває швидкий пошук:
Чому Git такий важкий… Git надто складний… Чи можемо ми припинити вдавати, що Git простий і легкий для вивчення… Чому Git такий складний і заплутаний…
На перший погляд ці твердження можуть здатися правдивими, але як тільки ви зрозумієте основні концепції, робота з Git стане чудовим досвідом. Проблема Git полягає в тому, що він дуже гнучкий. І невід’ємною характеристикою всіх гнучких систем є складність. Я твердо переконаний, що єдиний спосіб подолати цю складність — це приступити до основ, що знаходяться під наданим інтерфейсом користувача, і зрозуміти базову ментальну модель і архітектуру. Коли ви це зробите, магії та неочікуваних результатів не буде, і ви почуватиметеся як удома, працюючи з цими складними інструментами.
Як розробники, які раніше працювали з Git, так і ті, хто тільки починають працювати з цим чудовим інструментом контролю версій, отримають користь від матеріалу цієї статті. Якщо ви досвідчений користувач GIT, ви зрозумієте життєвий цикл checkout -> modify -> commit набагато краще. Якщо ви тільки починаєте працювати з Git, ця стаття дасть вам чудову перевагу.
У цьому дописі я використовуватиму так звані «сантехнічні» команди низького рівня, щоб продемонструвати, як Git працює під капотом. Вам не потрібно запам’ятовувати їх, оскільки вони рідко використовуються під час звичайного робочого процесу, але є незамінними при поясненні основної архітектури Git.
Стаття трохи об’ємна, і я вважаю, що ви можете вивчити її наступним чином:
- прогляньте статтю зверху вниз, щоб зрозуміти загальний хід статті
- уважно прочитайте та виконуйте вправи, які я використовую в статті
Практикуючись, ви закріпите знання, які отримаєте тут. Я використовую команди bash під час вправ, тому ви в порядку, якщо ви використовуєте ОС на базі Unix. Якщо ви користуєтеся Windows, ви можете запустити git-bash, який інсталюється разом із Git, а потім доступ до нього можна отримати за допомогою пункту контекстного меню Git Bash here.
Git як папка
Коли ви запускаєте git init у теці, Git створює каталог .git. Отже, давайте відкриємо термінал, створимо новий каталог, де ми будемо працювати, і ініціалізуємо там порожній git:
$ 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 refsУ звичайному репозиторії .git містить базу даних об’єктів, посилання, індекс і конфігурацію. Клонування передає необхідні об’єкти та створює локальні посилання та конфігурацію; він не просто копіює кожен файл із вихідного каталогу .git. Звичайний клон також перевіряє гілку, вибрану пультом HEAD.
Git як база даних
Git — це просте сховище даних ключ-значення. Ви поміщаєте значення в репозиторій і отримуєте ключ, за допомогою якого можна отримати доступ до цього значення. Команда, яка додає значення в базу даних, — hash-object і повертає 40-символьний хеш контрольної суми, який використовуватиметься як ключ. Ця команда створює звичайний об’єкт у сховищі git, який називається blob. Давайте запишемо простий рядок f1 content в базу даних:
$ F1CONTENT_BLOB_HASH=$( \
echo 'f1 content' | git hash-object -w --stdin
)
$ echo $F1CONTENT_BLOB_HASH
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0Для тих із вас, хто не дуже знайомий із оболонкою, основна команда у наведеному вище фрагменті:
echo 'f1 content' | git hash-object -w --stdinКоманда echo виводить рядок f1 content, і за допомогою оператора каналу | ми перенаправляємо вихідні дані на команду git hash-object. Параметр -w, переданий команді hash-object, вказує їй зберегти об’єкт; інакше команда просто повідомляє вам, яким буде ключ. --stdin вказує команді читати контент зі стандартного введення; якщо ви не вкажете це, hash-object очікує шлях до файлу в кінці. Як згадувалося раніше, команда git hash-object повертає хеш, який я потім зберігаю у змінній F1CONTENT_BLOB_HASH. Ми також могли б розділити основне виконання та призначення змінних таким чином:
$ echo 'f1 content' | git hash-object -w --stdin
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
$ F1CONTENT_BLOB_HASH=a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0Але для зручності я буду використовувати коротшу версію в наступних фрагментах коду для призначення виходів у змінні. Ці змінні використовуються там, де очікується хеш-рядок, а для читання значення змінна має префікс $.
Щоб прочитати значення за ключем, ми можемо використати команду cat-file з опцією -p. Команда очікує хеш-ключа об’єкта, який потрібно отримати:
$ git cat-file -p $F1CONTENT_BLOB_HASH
f1 contentЯк я вже сказав, .git — це папка, і всі збережені значення/об’єкти зберігаються в цій папці. Отже, ми можемо зайти в .git/objects, і ви побачите папку, створену Git, з назвою a1, яка є першими двома літерами хеш-ключа:
$ ls .git/objects/ -l
a1/
info/
pack/Вільні об’єкти зберігаються у вигляді файлів у каталозі, названому за першими двома символами решетки. Кілька об’єктів можуть спільно використовувати цей каталог. Git також може об’єднувати об’єкти у файли пакувань під objects/pack. Ідентифікатор об’єкта хешує заголовок типу та довжини разом із вмістом, тому зміна вмісту створює інший ідентифікатор.
Давайте запишемо інший рядок f2 content у репозиторій:
$ F2CONTENT_BLOB_HASH=$( \
echo 'f2 content' | git hash-object -w --stdin )Як і очікувалося, ви бачите, що папка \.git\objects тепер містить два записи 9b/ і a1/:
$ ls .git/objects/ -l
9b/
a1/
info/
pack/Дерево як невід’ємна частина
На даний момент у нашому сховищі є два блоки:
F1CONTENT_BLOB_HASH -> 'f1 content'
F2CONTENT_BLOB_HASH -> 'f2 content'Нам потрібен спосіб якось згрупувати їх разом, а також пов’язати кожну краплю з назвою файлу. І тут в гру вступає дерево. Дерево можна створити за допомогою команди git mktree із таким синтаксисом для кожної асоціації файлів/блобів:
[file-mode object-type object-hash file-name]
Перегляньте цю відповідь для пояснення режиму файлу. Ми будемо використовувати режим 100644, який визначає blob як звичайний файл, який користувач може читати та записувати. Ці дозволи використовуються під час перевірки файлів у робочий каталог, щоб правильно встановити дозволи для файлів/каталогів.
Отже, щоб зв’язати два блоби з двома файлами, ми зробимо наступне:
$ 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 )Подібно до hash-object, команда mktree повертає хеш-ключ для створеного об’єкта дерева:
$ echo $INITIAL_TREE_HASH
e05d9daa03229f7a7f6456d3d091d0e685e6a9dbОтже, ось що ми маємо зараз:

Після виконання команди git створює третій об’єкт у сховищі типу tree. Давайте подивимося:
$ 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)При використанні mktree ми можемо вказати інший об’єкт дерева як параметр замість blob. Це дерево буде пов’язано з каталогом замість звичайного файлу. Наприклад, наступна команда створить дерево з піддеревом, пов’язаним з каталогом nested-folder:
printf '%s %s %s\t%s\n' 040000 tree "$INITIAL_TREE_HASH" nested-folder | git mktreeФайловий режим 040000 позначає каталог, і ми використовуємо тип tree замість blob. Ось як git зберігає вкладені каталоги в структурі проекту.
Індекс - це місце, де збираються дерева
Усі, хто працює з GIT, повинні бути знайомі з поняттям індексу або проміжної області та, напевно, бачили таку діаграму:

Праворуч ви можете побачити репозиторій git, який зберігає об’єкти git: blobs, дерева, коміти та теги. Ми використовували команди hash-object і mktree, щоб безпосередньо додати об’єкти blob і дерево до цього сховища. Робочий каталог ліворуч — це ваша локальна файлова система/каталог, де ви можете отримати всі файли проекту. У цьому розділі пояснюється середній етап, який ми будемо називати файлом індексу або просто індексом. Це бінарний файл (зазвичай зберігається у .git/index), який нагадує структуру об’єкта дерева. Він містить відсортований список імен шляхів, кожен з дозволами та SHA1 об’єкта blob/дерева.
Це місце, де git готує перед ним дерево:
- записує його в репозиторій або
- перевіряє його в робочий каталог
Тепер у сховищі є одне дерево, яке ми створили в попередньому розділі. Ми можемо прочитати це дерево у файл індексу зі сховища за допомогою команди read-tree:
$ git read-tree $INITIAL_TREE_HASHОтже, тепер ми очікуємо, що індексний файл матиме два файли. Ми можемо перевірити структуру поточного файлу індексу за допомогою команди git ls-files -s:
$ git ls-files -s
100644 a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0 0 f1.txt
100644 9b96e21cb748285ebec53daec4afb2bdcb9a360a 0 f2.txtОскільки ми не вносили жодних змін у файл індексу, він точно такий же, як дерево, яке ми використовували для заповнення файлу індексу. Коли ми маємо правильну структуру у файлі індексу, давайте перевіримо його в робочому каталозі за допомогою команди checkout-index з опцією -a:
$ git checkout-index -a
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 contentдобре! Ми щойно перевірили файли, які додали вручну в репозиторій git без жодних комітів. Як це круто?
Але індексний файл зазвичай не залишається в стані початкового дерева. Ви, мабуть, знаєте, що його можна змінити за допомогою команд git add [file path] і git rm --cached [file path] для окремого файлу або git add -A і git reset для набору змінених/видалених файлів. Отже, давайте застосуємо ці знання на практиці та створимо нове дерево в сховищі, яке містить один файловий блок, пов’язаний з новим текстовим файлом f3.txt. Вмістом файлу буде рядок f3 content. Але замість створення дерева вручну, як ми робили в попередньому розділі, ми використаємо для цього файл індексу.
Наразі ми маємо таку структуру файлу індексу на основі дерева initial tree, яке ми використовували для заповнення індексу:

Зміни в індексі залишаються поетапними, доки не будуть прийняті або скасовані. git add негайно записує поетапний вміст як об’єкти, тому перезаписану поетапну версію можна відновити, доки не буде видалено недоступні об’єкти. Зміни, які ніколи не були інсценовані або фіксовані, не зберігаються в Git. Збір сміття – це обслуговування, а не надійний термін відновлення.
Почнемо з видалення двох файлів у робочому каталозі:
$ rm f1.txt f2.txtЯкщо ми зараз запустимо git status, ми побачимо наступне:
$ 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.txtЦе багато інформації. Він повідомляє про два видалених файли та два нових файли, а також говорить про «початковий запис». І ось чому. Коли ви запускаєте git status, git робить два порівняння:
- він порівнює файл індексу з поточним робочим каталогом — зміни повідомляються як “не підготовлені для фіксації”
- він порівнює файл індексу з комітом HEAD - зміни, повідомлені як “буде прийнято”
Тож у нашому випадку ми бачимо, що git повідомляє про два видалені файли для «Зміни, не призначені для внесення», і ми знаємо, як він це отримав — він порівняв поточний робочий каталог із файлом індексу та виявив, що два файли відсутні в робочому каталозі (оскільки ми їх видалили).
Git повідомляє проіндексовані файли як нові, оскільки це сховище ще не має комітів. HEAD вказує на ненароджену гілку та не вирішує до коміту. Для статусу Git порівнює індекс із порожнім деревом; це не означає, що сам файл HEAD вказує на порожнє дерево.
Тепер, якщо ми запустимо git add ., він змінить індексний файл, видаливши два файли, а коли ми знову запустимо git status, він не повідомить про жодні зміни, оскільки у нас немає файлів ні в робочому дереві, ні в індексному файлі:
$ git add .
$ git status
On branch master
Initial commit
nothing to commit (create/copy files and use "git add" to track)Ми почали із завдання створити нове дерево з одним новим файлом f3.txt. Давайте створимо цей файл і додамо його до індексу:
$ echo 'f3 content' > f3.txt
$ git add f3.txtЯкщо ми зараз запустимо git status:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txtМи бачимо, що виявлено один новий файл. Знову ж таки, зміни повідомляються в розділі «буде прийнято», тому ми тепер Git порівняли файл індексу з «порожнім деревом». Отже, ми очікуємо, що файл індексу матиме цей новий файловий блок. Перевіримо:
$ git ls-files -s
100644 5927d85c2470d49403f56ce27afd8f74b1a42589 0 f3.txt
# Save the hash of the f3.txt file blob
$ F3CONTENT_BLOB_HASH=5927d85c2470d49403f56ce27afd8f74b1a42589Гаразд, індекс має правильну структуру, і ми готові створити з нього дерево в репозиторії. Давайте зробимо це за допомогою команди write-tree:
$ LATEST_TREE_HASH=$( git write-tree )Чудово, ми щойно створили дерево за допомогою index. І ми помістили хеш нового дерева в змінну LATEST_TREE_HASH. Ми могли б зробити це вручну, записавши блоб f3 content у репозиторій, а потім створивши дерево за допомогою mktree, але використання індексу набагато зручніше.
Що цікаво, якщо ви запустите git status зараз, ви все одно побачите, що git все ще вважає, що є новий файл f3.txt:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txtwrite-tree зберіг об’єкт, але не створив коміту або не перемістив HEAD. HEAD все ще називає ненароджену гілку, тому проміжний файл усе ще повідомляється як новий.
Отже, з цим щойно створеним деревом ми маємо такі об’єкти в репозиторії:

Коміт — це обгортка навколо дерева
У цьому розділі стає ще цікавіше. Під час нашої повсякденної роботи з Git ми зазвичай не зустрічаємо дерев чи крапель. Ми працюємо з об’єктами комітів. Отже, що таке комміт у git? Насправді, як би це не було просто, це просто обгортка навколо об’єкта дерева, який:
- дозволяє прикріпити повідомлення до дерева (групи файлів)
- дозволяє вказувати батьківського (коміту)
Тепер у нашому репозиторії git є два дерева — initial tree і latest tree. Давайте загорнемо перший об’єкт дерева в коміт за допомогою команди commit-tree, яка отримує хеш дерева для створення коміту для:
Перш ніж створювати коміти, переконайтеся, що user.name і user.email Git налаштовано. Ваші хеші комітів відрізнятимуться від прикладів, оскільки коміти містять інформацію про автора та позначки часу.
$ INITIAL_COMMIT_HASH=$( \
echo 'initial commit' | git commit-tree $INITIAL_TREE_HASH
)Після виконання наведеної вище команди ми матимемо наступне:

І ми можемо перевірити цю фіксацію в робочий каталог:
$ git checkout $INITIAL_COMMIT_HASH
A f3.txt
HEAD is now at a27a75a... initial commitОтримання відновлює f1.txt і f2.txt, але також зберігає поетапний f3.txt, оскільки він не конфліктує з цільовим комітом. Зніміть сцену та видаліть цей файл вправи зараз; його вміст і дерево вже зберігаються в базі даних об’єктів:
$ git rm -f f3.txt
rm 'f3.txt'
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 contentКоли ви запускаєте git checkout [commit-hash], git виконує наступне:
- читає дерево, на яке вказує фіксація, у файл індексу
- перевіряє індексний файл у робочий каталог
- оновлює файл HEAD хешем фіксації
Це операції, які ми виконували вручну в попередньому розділі.
Історія Git — це ланцюжок комітів
Отже, тепер ми знаємо, що комміт — це просто обгортка навколо дерева. Я також згадав, що він може мати батьківський комміт. Спочатку у нас було два дерева, і одне з них загорнули в коміт у попередньому розділі, тому у нас залишилося одне дерево-сирота. Давайте тепер загорнемо це в комміт, використовуючи ту саму операцію commit-tree, яку я використовував вище, але з опцією -p для визначення батьківського елемента:
$ LATEST_COMMIT_HASH=$( \
echo 'latest commit' |
git commit-tree $LATEST_TREE_HASH -p $INITIAL_COMMIT_HASH )І ось що ми маємо зараз:

Тож тепер, якщо ви запустите git log, щоб переглянути історію та передати хеш «останнього» коміту, ви побачите два коміти:
$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commitІ ми можемо перемикатися між ними. Ось початковий комміт:
$ git checkout $INITIAL_COMMIT_HASH
$ ls
f1.txt f2.txtОстанній комміт:
$ git checkout $LATEST_COMMIT_HASH
$ ls
f3.txtHEAD є посиланням на взятий комміт
HEAD — це простий текстовий файл, розташований за адресою .git/HEAD, який посилається на поточний вилучений комміт. Оскільки ми перевірили «останній» коміт із хеш-комітом $LATEST_COMMIT_HASH у попередньому розділі, це саме те, що містить файл HEAD:
$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ "$LATEST_COMMIT_HASH" = "$(git rev-parse HEAD)" ] && echo 'equal'
equalОднак зазвичай файл HEAD посилається на поточний вилучений комміт через посилання на гілки. Коли він посилається на комміт безпосередньо, він знаходиться в detached state. Але навіть якщо HEAD містить посилання на таку гілку:
ref: refs/heads/masterвін все ще вирішується до хешу фіксації.
Ви вже знаєте, що Git використовує комміт, на який посилається HEAD, під час виконання git status для створення набору змін між файлом індексу та поточним вилученим деревом/комітом. Іншим використанням HEAD є вирішення коміту, який використовуватиметься як батьківський для майбутнього коміту.
Цікаво, що файл HEAD настільки важливий для більшості операцій, що якщо ви вручну очистите його вміст, Git подумає, що це не сховище git, і повідомить про помилку:
fatal: Not a git repository (or any of the parent directories): .gitГілка — це текстовий файл, що вказує на фіксацію
Отже, тепер у нашому репозиторії є два коміти, які складають таку історію:

$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commitДавайте введемо якусь вилку в існуючу історію. Ми перевіримо початковий комміт і змінимо вміст файлу f1.txt. Після цього ми зробимо новий комміт за допомогою команди 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(-)Наведений вище фрагмент коду:
- перевіряє
"initial commit", який додаєf1.txtіf2.txtдо робочого каталогу - замінює вміст
f1.txtрядкомI am modified f1 content - оновлює файл індексу за допомогою
git add
Остання команда git commit, як ми вже знаємо, виконує кілька внутрішніх операцій:
- створює дерево з індексного файлу
- записує це дерево до сховища
- створює об’єкт коміту, який обгортає це дерево
- встановлює
initial commitяк батьківський для нового коміту, оскільки це коміт, який ми зараз маємо у файліHEAD
Нам також потрібно зберегти хеш цього коміту у змінній. Оскільки Git оновлює HEAD поточним файлом коміту, ми можемо прочитати його звідти:
FORKED_COMMIT_HASH=$( cat .git/HEAD )Отже, тепер у нашому репозиторії git є такі об’єкти:

Що створює таку історію комітів:

Завдяки наявності вилки ми маємо дві лінії роботи. А це означає, що нам потрібно ввести дві гілки, щоб відстежувати кожен напрямок роботи незалежно. Давайте створимо гілку master, яка відстежує лінійну історію, починаючи з latest commit, і гілку forked, яка відстежує історію від forked commit.
Гілка — це іменоване посилання на коміт. З вільними посиланнями це текстовий файл під .git/refs/heads; refs також можуть бути упаковані або збережені за допомогою іншої серверної частини. У цьому свіжому репозиторії ми можемо продемонструвати вільні посилання, написавши безпосередньо ідентифікатори комітів. Звичайні сценарії повинні використовувати замість git branch або git update-ref.
Отже, це буде вказувати на головну гілку з «останнім комітом»:
$ echo $LATEST_COMMIT_HASH > .git/refs/heads/masterІ це вкаже на «розгалужену» гілку з «розгалуженим комітом»:
$ echo $FORKED_COMMIT_HASH > .git/refs/heads/forked
Отже, нарешті ми підійшли до звичайного робочого процесу, до якого ви звикли – тепер ми можемо перемикатися між гілками:
$ git checkout master
Switched to branch 'master'
$ git log --pretty=oneline
[some hash] latest commit
[some hash] initial commit
$ ls -l
f3.txtІ давайте подивимося іншу гілку 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 contentТег — це текстовий файл, що вказує на комміт
Ви, напевно, знаєте, що замість лінії роботи, яку ми відстежуємо за допомогою гілок, ми можемо відстежувати окремі коміти за допомогою тегів. Теги зазвичай використовуються для позначення важливих етапів розвитку, наприклад випусків. Зараз у нашому репозиторії є 3 коміти. І ми можемо дати їм імена за допомогою тегу. Як і гілка, тег — це текстовий файл, який містить хеш коміту та є частиною групи посилань git.
Як ви вже знаєте, git зберігає всі посилання в папці .git/refs, а теги зберігаються у підтеці .git/refs/tags. Оскільки це простий текстовий файл, ми можемо створити файл і помістити в нього хеш коміту.
Цей тег вказує на розгалужену фіксацію:
$ echo $FORKED_COMMIT_HASH > .git/refs/tags/forkedІ це вкаже на початковий комміт:
$ echo $INITIAL_COMMIT_HASH > .git/refs/tags/initialПісля цього ми зможемо перемикатися між комітами за допомогою тегів. Ось початковий комміт:
$ git checkout tags/initial
HEAD is now at [initial commit hash]... initial commit
$ cat f1.txt
f1 contentІ розгалужений комміт:
$ git checkout tags/forked
$ cat f1.txt
I am modified f1 contentІснує також “анотований тег”, який відрізняється від цього полегшеного тегу. Це фактичний об’єкт, який може містити повідомлення, як і комміт, і зберігається в сховищі разом з іншими об’єктами.
Висновок
Це досить довга стаття, але я намагався зробити її максимально прозорою та зрозумілою. Після того, як ви пропрацюєте статтю та зрозумієте всі концепції, які я тут показав, ви зможете працювати з Git ефективніше, і ви ніколи не повинні боятися неочікуваних результатів.
Якщо ви хочете дізнатися більше про Git, я настійно рекомендую прочитати цю чудову книгу, яка також є безкоштовною в Інтернеті.