Станьте профи в GIT, разобравшись в его архитектуре за 15 минут

Git может показаться сложной системой. Просто спросите Google. Вот несколько заголовков, которые выдаёт быстрый поиск:

Почему Git, чёрт возьми, такой трудный… Git просто слишком сложен… Может, хватит делать вид, что Git прост и легко изучается… Почему Git такой сложный и запутанный…

На первый взгляд эти утверждения могут показаться справедливыми, но как только вы поймёте лежащие в основе концепции, работа с Git станет одним удовольствием. Проблема Git в том, что он очень гибкий. А неотъемлемая черта любой гибкой системы — сложность. Я твёрдо убеждён, что единственный способ побороть эту сложность — спуститься к основам под предоставленным пользовательским интерфейсом и понять лежащую в основе мысленную модель и архитектуру. Как только вы это сделаете, никакой магии и неожиданных результатов не останется, и вы будете чувствовать себя как дома, работая с этими сложными инструментами.

Материал этой статьи будет полезен и разработчикам, которые уже работали с Git, и тем, кто только начинает работать с этим замечательным инструментом контроля версий. Если вы опытный пользователь GIT, вы гораздо лучше поймёте жизненный цикл checkout -> modify -> commit. Если вы только начинаете с Git, эта статья даст вам отличный старт.

В этом посте я буду использовать низкоуровневые, так называемые «сантехнические» (plumbing) команды, чтобы показать, как 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
Initialized empty Git repository in path/to/git-playground/.git/
$ ls .git
HEAD config description hooks info objects refs

Именно здесь Git хранит все ваши коммиты и прочую информацию, нужную для работы с ними. Когда вы клонируете репозиторий, Git копирует этот единственный каталог в вашу папку, создаёт отслеживающие ветки для каждой ветки клонируемого репозитория, а также создаёт и переключается на начальную ветку, указанную в файле HEAD. Какую роль файл HEAD играет в архитектуре Git, мы увидим позже, но главный вывод здесь в том, что клонирование репозитория — это, по сути, просто копирование каталога .git из другого места.

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 обычно хранит объекты — одна папка на один blob. Однако Git может также объединять несколько blob’ов в один файл, создавая pack-файлы, и каталог pack, который вы видите выше, — это место, где эти файлы хранятся. Информацию, относящуюся к этим pack-объектам, Git держит в каталоге info. Git генерирует хеш для blob’ов на основе их содержимого, поэтому объекты в Git неизменяемы: изменение содержимого изменило бы хеш.

Запишем в репозиторий ещё одну строку — 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/

Дерево как неотъемлемая часть

Сейчас в нашем репозитории два blob’а:

F1CONTENT_BLOB_HASH -> 'f1 content'
F2CONTENT_BLOB_HASH -> 'f2 content'

Нам нужен способ как-то сгруппировать их вместе, а также связать каждый blob с именем файла. Вот здесь в игру и вступает дерево. Дерево можно создать командой git mktree, используя следующий синтаксис для каждой связки файл/blob:

[file-mode object-type object-hash file-name]

Объяснение файлового режима смотрите в этом ответе. Мы будем использовать режим 100644, который задаёт blob как обычный файл, доступный пользователю для чтения и записи. Эти права применяются при выгрузке файлов в рабочий каталог, чтобы правильно выставить права на файлы и каталоги.

Итак, чтобы связать два 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 e05d9da nested-folder | git mktree

Файловый режим 040000 помечает каталог, и мы используем тип tree вместо blob. Именно так git хранит вложенные каталоги в структуре проекта.

Индекс — место, где собираются деревья

Каждый, кто работает с GIT, должен быть знаком с понятием индекса, или области подготовки (staging area), и, вероятно, видел следующую схему:

Справа вы видите репозиторий git, в котором хранятся объекты git: blob’ы, деревья, коммиты и теги. Мы использовали команды hash-object и mktree, чтобы напрямую добавить в этот репозиторий объекты blob и tree. Рабочий каталог слева — это ваша локальная файловая система/каталог, куда вы выгружаете все файлы проекта. Этот раздел объясняет средний этап, который мы будем называть индексным файлом или просто индексом. Это бинарный файл (обычно лежит в .git/index), структура которого напоминает объект-дерево. Он содержит отсортированный список путей, каждый с правами доступа и SHA1 объекта blob/tree.

Это место, где 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 для набора изменённых/удалённых файлов. Применим это знание на практике и создадим в репозитории новое дерево, содержащее один файловый blob, связанный с новым текстовым файлом f3.txt. Содержимым файла будет строка f3 content. Но вместо того чтобы создавать дерево вручную, как мы делали в предыдущем разделе, воспользуемся для этого индексным файлом.

Сейчас структура индексного файла такова — она основана на дереве initial tree, которым мы заполнили индекс:

Это база, к которой мы будем применять изменения. Все изменения, которые вы вносите в индексный файл, остаются предварительными до того момента, когда вы действительно запишете дерево в репозиторий. Однако сами объекты, которые вы добавляете, записываются в репозиторий git немедленно. Если вы отбросите текущие изменения дерева, они позже будут подхвачены сборщиком мусора (GC) и удалены. Это также означает, что обычно, если вы случайно отбросили изменения файла, их всё ещё можно восстановить, пока git не запустит GC. А запускает он его, как правило, только когда вокруг накапливается слишком много «висячих» объектов, ни на что не ссылающихся.

Начнём с удаления двух файлов в рабочем каталоге:

$ 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

Это довольно много информации. Сообщается о двух удалённых файлах и двух новых, а также говорится «initial commit». И вот почему. Когда вы выполняете git status, git делает два сравнения:

  • сравнивает индексный файл с текущим рабочим каталогом — изменения сообщаются как «not staged for commit»
  • сравнивает индексный файл с коммитом HEAD — изменения сообщаются как «to be committed»

Итак, в нашем случае мы видим, что git сообщает о двух удалённых файлах в разделе «Changes not staged for commit», и мы знаем, откуда он это взял: он сравнил текущий рабочий каталог с индексным файлом и обнаружил, что двух файлов в рабочем каталоге не хватает (потому что мы их удалили).

Мы также видим, что в разделе «Changes to be committed» git сообщает о двух новых файлах. Это потому, что сейчас в репозитории ещё нет ни одного коммита, поэтому файл HEAD (о нём позже) разрешается в так называемый объект «пустого дерева» без файлов. Так что Git считает, что мы только что начали с нового, свежего репозитория, — вот почему он показывает «initial commit» и трактует каждый файл в индексном файле как новый.

Теперь, если выполнить 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

Видим, что обнаружен один новый файл. Опять же, изменения сообщаются в разделе «to be committed», значит теперь Git сравнил индексный файл с «пустым деревом». Так что мы ожидаем, что в индексном файле будет этот один новый файловый blob. Проверим:

$ git ls-files -s
100644 5927d85c2470d49403f56ce27afd8f74b1a42589 0       f3.txt
# Сохраняем хеш blob'а файла f3.txt
$ F3CONTENT_BLOB_HASH=5927d85c2470d49403f56ce27afd8f74b1a42589

Отлично, теперь у индекса правильная структура, и мы готовы создать из него дерево в репозитории. Сделаем это командой write-tree:

$ LATEST_TREE_HASH=$( git write-tree )

Здорово, мы только что создали дерево с помощью индекса. И положили хеш нового дерева в переменную LATEST_TREE_HASH. Мы могли бы сделать это вручную, записав blob 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.txt

Это потому, что хотя мы создали и сохранили наше дерево в репозитории, мы не обновили файл HEAD, который используется для сравнения. Поскольку в файл HEAD можно положить только хеш коммита или ссылку на ветку, а ни того, ни другого у нас сейчас нет, оставим файл HEAD пока как есть.

Итак, с этим новым деревом в репозитории у нас следующие объекты:

Коммит — это обёртка вокруг дерева

В этом разделе всё становится ещё интереснее. В повседневной работе с Git мы обычно не сталкиваемся с деревьями или blob’ами. Мы работаем с объектами-коммитами. Так что же такое коммит в git? На самом деле, если совсем просто, это всего лишь обёртка вокруг объекта-дерева, которая:

  • позволяет прикрепить к дереву (группе файлов) сообщение
  • позволяет указать родителя (коммит)

Сейчас в нашем репозитории git два дерева — initial tree и latest tree. Обернём первый объект-дерево в коммит командой commit-tree, которая принимает хеш дерева, для которого создаётся коммит:

$ 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

Теперь мы видим два наших файла в рабочем каталоге:

$ 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, чтобы посмотреть историю, и передать хеш коммита «latest», вы увидите два коммита:

$ 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.txt

HEAD — это ссылка на выгруженный коммит

HEAD — это простой текстовый файл, расположенный по пути .git/HEAD, который ссылается на текущий выгруженный коммит. Поскольку в предыдущем разделе мы выгрузили коммит «latest» с хешем $LATEST_COMMIT_HASH, именно это и содержит файл HEAD:

$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ $LATEST_COMMIT_HASH == "88d3b9901d62..." ]; 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 — группы объектов, которые ссылаются на коммит. Другой тип ссылки — легковесный тег. Все ссылки Git хранит в папке .git/refs, а ветки — в каталоге .git/refs/heads. Поскольку ветка — это простой текстовый файл, мы можем просто создать файл с содержимым в виде хеша коммита.

Вот это будет указывать на основную ветку с «latest commit»:

$ echo $LATEST_COMMIT_HASH > .git/refs/heads/master

А это будет указывать на ветку «forked» с «forked commit»:

$ 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] first commit
$ ls -l
f3.txt

А теперь посмотрим на другую ветку, 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 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 285aec7... second commit
$ cat f1.txt
f1 content

А вот коммит с развилки:

$ git checkout tags/forked
$ cat f1.txt
I am modified f1 content

Существует также «аннотированный тег», который отличается от этого легковесного. Это настоящий объект, который может содержать сообщение, как и коммит, и хранится в репозитории наряду с другими объектами.

Заключение

Статья получилась довольно длинной, но я старался сделать её как можно понятнее и прозрачнее. Как только вы проработаете её и разберётесь во всех показанных здесь концепциях, вы сможете работать с Git гораздо эффективнее и вам больше не придётся бояться неожиданных результатов.

Если хотите узнать о Git больше, я очень рекомендую эту замечательную книгу, которая к тому же бесплатно доступна онлайн.