В этом эссе описана схема работы Git. Предполагается, что вы знакомы с Git достаточно, чтобы использовать его для контроля версий своих проектов.
Эссе концентрируется на структуре графа, на которой основан Git, и на том, как свойства этого графа определяют поведение Git. Изучая основы, вы строите своё представление на достоверной информации, а не на гипотезах, полученных из экспериментов с API. Правильная модель позволит вам лучше понять, что сделал Git, что он делает и что он собирается сделать.
Текст разбит на серии команд, работающих с единым проектом. Иногда встречаются наблюдения по поводу структуры данных графа, лежащего в основе Git. Наблюдения иллюстрируют свойство графа и поведение, основанное на нём.
После прочтения для ещё более глубокого погружения можно обратиться к обильно комментируемому исходному коду моей реализации Git на JavaScript.
Пользователь создаёт директорию alpha
Он перемещается в эту директорию и создаёт директорию data. Внутри он создаёт файл letter.txt с содержимым «а». Директория alpha выглядит так:
git init вносит текущую директорию в Git-репозиторий. Для этого он создаёт директорию .git и создаёт в ней несколько файлов. Они определяют всю конфигурацию Git и историю проекта. Это обычные файлы – никакой магии. Пользователь может их читать и редактировать. То есть – пользователь может читать и редактировать историю проекта так же просто, как файлы самого проекта.
Теперь директория alpha выглядит так:
Директория .git с её содержимым относится с Git. Все остальные файлы называются рабочей копией и принадлежат пользователю.
Пользователь запускает git add на data/letter.txt. Происходят две вещи.
Во-первых, создаётся новый блоб-файл в директории .git/objects/. Он содержит сжатое содержимое data/letter.txt. Его имя – сгенерированный хэш на основе содержимого. К примеру, Git делает хэш от а и получает 2e65efe2a145dda7ee51d1741299f848e5bf752e. Первые два символа хэша используются для имени директории в базе объектов: .git/objects/2e/. Остаток хэша – это имя блоб-файла, содержащего внутренности добавленного файла: .git/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e.
Заметьте, что простое добавление файла в Git приводит к сохранению его содержимого в директории objects. Оно будет храниться там, если пользователь удалит data/letter.txt из рабочей копии.
Во-вторых, git add добавляет файл в индекс. Индекс – это список, содержащий все файлы, за которыми Git было наказано следить. Он хранится в .git/index. Каждая строка даёт в соответствие отслеживаемому файлу хэш его содержимого и время добавления. Вот таким получается содержимое индекса после команды git add:
Пользователь создаёт файл data/number.txt содержащий 1234.
Рабочая копия выглядит так:
Пользователь добавляет файл в Git.
Команда git add создаёт блоб-файл, в котором хранится содержимое data/number.txt. Он добавляет в индекс запись для data/number.txt, указывающую на блоб. Вот содержимое индекса после повторного запуска git add:
Заметьте, что в индексе перечислены только файлы из директории data, хотя пользователь давал команду git add data. Сама директория data отдельно не указывается.
Когда пользователь создал data/number.txt, он хотел написать 1, а не 1234. Он вносит изменение и снова добавляет файл к индексу. Эта команда создаёт новый блоб с новым содержимым. Она обновляет запись в индексе для data/number.txt с указанием на новый блоб.
Пользователь делает коммит a1. Git выводит данные о нём. Мы вскоре объясним их.
У команды commit есть три шага. Она создаёт граф, представляющий содержимое версии проекта, которую коммитят. Она создаёт объект коммита. Она направляет текущую ветку на новый объект коммита.
Git запоминает текущее состояние проекта, создавая древовидный граф из индекса. Этот граф записывает расположение и содержимое каждого файла в проекте.
Граф состоит из двух типов объектов: блобы и деревья. Блобы сохраняются через git add. Они представляют содержимое файлов. Деревья сохраняются при коммите. Дерево представляет директорию в рабочей копии. Ниже приведён объект дерева, записавший содержимое директории data при новом коммите.
Первая строка записывает всё необходимое для воспроизводства data/letter.txt. Первая часть хранит права доступа к файлу. Вторая – что содержимое файла хранится в блобе, а не в дереве. Третья – хэш блоба. Четвёртая – имя файла.
Вторая строчка тем же образом относится к data/number.txt.
Ниже указан объект-дерево для alpha, корневой директории проекта:
Единственная строка указывает на дерево data.
В приведённом графе дерево root указывает на дерево data. Дерево data указывает на блобы для data/letter.txt и data/number.txt.
git commit создаёт объект коммита после создания графа. Объект коммита – это ещё один текстовый файл в .git/objects/:
Первая строка указывает на дерево графа. Хэш – для объекта дерева, представляющего корень рабочей копии. То бишь, директории alpha. Последняя строчка – комментарий коммита.
Наконец, команда коммита направляет текущую ветку на новый объект коммита.
Какая у нас текущая ветка? Git идёт в файл HEAD в .git/HEAD и видит:
Это значит, что HEAD указывает на master. master – текущая ветка.
HEAD и master – это ссылки. Ссылка – это метка, используемая Git, или пользователем, для идентификации определённого коммита.
Файла, представляющего ссылку master, не существует, поскольку это первый коммит в репозитории. Git создаёт файл в .git/refs/heads/master и задаёт его содержимое – хэш объекта коммит:
(Если вы параллельно чтению вбиваете в Git команды, ваш хэш a1 будет отличаться от моего. Хэш объектов содержимого – блобов и деревьев – всегда получаются теми же. А коммитов – нет, потому что они учитывают даты и имена создателей).
Добавим в граф Git HEAD и master:
HEAD указывает на master, как и до коммита. Но теперь master существует и указывает на новый объект коммита.
Ниже приведён граф после коммита a1. Включены рабочая копия и индекс.
Заметьте, что у рабочей копии, индекса и коммита одинаковое содержимое data/letter.txt и data/number.txt. Индекс и HEAD используют хэши для указания на блобы, но содержимое рабочей копии хранится в виде текста в другом месте.
Пользователь меняет содержимое data/number.txt на 2. Это обновляет рабочую копию, но оставляет индекс и HEAD без изменений.
Пользователь добавляет файл в Git. Это добавляет блоб, содержащий 2, в директорию objects. Указатель записи индекса для data/number.txt указывает на новый блоб.
Пользователь делает коммит. Шаги у него такие же, как и раньше.
Во-первых, создаётся новый древовидный граф для представления содержимого индекса.
Запись в индексе для data/number.txt изменилась. Старое дерево data уже не отражает проиндексированное состояние директории data. Нужно создать новый объект-дерево data.
Хэш у нового объекта отличается от старого дерева data. Нужно создать новое дерево root для записи этого хэша.
Во-вторых, создаётся новый объект коммита.
Первая строка объекта коммита указывает на новый объект root. Вторая строка указывает на a1: родительский коммит. Для поиска родительского коммита Git идёт в HEAD, проходит до master и находит хэш коммита a1.
В третьих, содержимое файла-ветви master меняется на хэш нового коммита.
Коммит a2
Граф Git без рабочей копии и индекса
Свойства графа:
• Содержимое хранится в виде дерева объектов. Это значит, что в базе хранятся только изменения. Взгляните на граф сверху. Коммит а2 повторно использует блоб, сделанный до коммита а1. Точно так же, если вся директория от коммита до коммита не меняется, неё дерево и все блобы и нижележащие деревья можно использовать повторно. Обычно изменения между коммитами небольшие. Это значит, что Git может хранить большие истории коммитов, занимая немного места.
• У каждого коммита есть предок. Это значит, что в репозитории можно хранить историю проекта.
• Ссылки – входные точки для той или иной истории коммита. Это значит, что коммитам можно давать осмысленные имена. Пользователь организовывает работу в виде родословной, осмысленной для своего проекта, с ссылками типа fix-for-bug-376. Git использует символьные ссылки вроде HEAD, MERGE_HEAD и FETCH_HEAD для поддержки команды редактирования истории коммитов.
• Узлы в директории objects/ неизменны. Это значит, что содержимое редактируется, но не удаляется. Каждый кусочек содержимого, добавленный когда-либо, каждый сделанный коммит хранится где-то в директории objects.
• Ссылки изменяемы. Значит, смысл ссылки может измениться. Коммит, на который указывает master, может быть наилучшей версией проекта на текущий момент, но вскоре его может сменить новый коммит.
• Рабочая копия и коммиты, на которые указывают ссылки, доступны сразу. Другие коммиты – нет. Это значит, что недавнюю историю легче вызвать, но и меняется она чаще. Можно сказать, что память Git постоянно исчезает, и её надо стимулировать всё более жёсткими тычками.
Рабочую копию легче всего вызвать из истории, поскольку она находится в корне репозитория. Её вызов даже не требует команды Git. Но также это и самая непостоянная точка в истории. Пользователь может сделать десяток версий файла, но Git не запишет ни одну из них, пока они не добавлены.
Коммит, на который указывает HEAD, очень легко вызвать. Он находится на конце подтверждённой ветви. Чтобы просмотреть его содержимое, пользователь может просто сохранить и затем изучить рабочую копию. И в то же время, HEAD – самая часто изменяющаяся ссылка.
Коммит, на который указывает конкретная ссылка, легко вызвать. Пользователь просто подтвердит эту ветку. Конец ветки меняется реже, чем HEAD, но достаточно для того, чтобы смысл названия ветки можно было менять.
Сложно вызвать коммит, на который не указывают ссылки. Чем дальше пользователь уходит от ссылки, тем тяжелее ему воссоздать смысл коммита. Но чем дальше в прошлое он идёт, тем меньше вероятность, что кто-либо изменил историю с момента их предыдущего просмотра.
Пользователь подтверждает коммит а2, используя его хэш. (Если вы запускаете эти команды, то данная команда у вас не сработает. Используйте git log, чтобы выяснить хэш вашего коммита а2).
Подтверждение состоит из четырёх шагов.
Во-первых, Git получает коммит а2 и граф, на который тот указывает.
Во-вторых, он делает записи о файлах в графе в рабочей копии. В результате изменений не происходит. В рабочей копии уже есть содержимое графа, поскольку HEAD уже указывал на коммит а2 через master.
В-третьих, Git делает записи о файлах в граф в индексе. Тут тоже изменений не происходит. В индексе уже содержится коммит а2.
В-четвёртых, содержимому HEAD присваивается хэш коммита а2:
Запись хэша в HEAD приводит репозиторий в состоянии с отделённым HEAD. Обратите внимание на графе ниже, что HEAD указывает непосредственно на коммит а2, а не на master.
Пользователь записывает в содержимое data/number.txt значение 3 и коммитит изменение. Git идёт к HEAD для получения родителя коммита а3. Вместо того, чтобы найти и последовать по ссылке на ветвь, он находит и возвращает хэш коммита а2.
Git обновляет HEAD, чтобы он указывал на хэш нового коммита а3. Репозиторий по-прежнему находится в состоянии с отделённым HEAD. Он не на ветви, поскольку ни один коммит не указывает ни на а3, ни на любой из его потомков. Его легко потерять.
Далее мы будем опускать деревья и блобы из диаграмм графов.
Создать ответвление (branch)
Пользователь создаёт новую ветвь под названием deputy. В результате появляется новый файл в .git/refs/heads/deputy, содержащий хэш, на который указывает HEAD: хэш коммита а3.
Ветви – это только ссылки, а ссылки – это только файлы. Это значит, что ветви Git весят очень немного.
Создание ветви deputy размещает новый коммит а3 на ответвлении. HEAD всё ещё отделён, поскольку всё ещё показывает непосредственно на коммит.
Пользователь подтверждает ответвление master.
Во-первых, Git получает коммит а2, на который указывает master, и получает граф, на который указывает коммит.
Во-вторых, Git вносит файловые записи в граф в файлы рабочей копии. Это меняет контент data/number.txt на 2.
В-третьих, Git вносит файловые записи в граф индекса. Это обновляет вхождение для data/number.txt на хэш блоба 2.
В-четвёртых, Git направляет HEAD на master, меняя его содержимое с хэша на
Подтвердить ветвь, несовместимую с рабочей копией
Пользователь случайно присваивает содержимому data/number.txt значение 789. Он пытается подтвердить deputy. Git препятствует подтверждению.
HEAD указывает на master, указывающий на a2, где в data/number.txt записано 2. deputy указывает на a3, где в data/number.txt записано 3. В версии рабочей копии для data/number.txt записано 789. Все эти версии различны, и разницу нужно как-то устранить.
Git может заменить версию рабочей копии версией из подтверждаемого коммита. Но он всеми силами избегает потери данных.
Git может объединить версию рабочей копии с подтверждаемой версией. Но это сложно.
Поэтому Git отклоняет подтверждение.
Пользователь замечает, что он случайно отредактировал data/number.txt и присваивает ему обратно 2. Затем он успешно подтверждает deputy.
Пользователь включает master в deputy. Объединение двух ветвей означает объединение двух коммитов. Первый коммит – тот, на который указывает deputy: принимающий. Второй коммит – тот, на который указывает master: дающий. Для объединения Git ничего не делает. Он сообщает, что он уже «Already up-to-date».
Серия объединений в графе интерпретируется как серия изменений содержимого репозитория. Это значит, что при объединении если коммит дающего получается предком коммита принимающего, Git ничего не делает. Эти изменения уже внесены.
Пользователь подтверждает master.
Он включает deputy в master. Git обнаруживает, что коммит принимающего, а2, является предком коммита дающего, а3. Он может сделать объединение с быстрой перемоткой вперёд.
Он берёт коммит дающего и граф, на который он указывает. Он вносит файловые записи в графы рабочей копии и индекса. Затем он «перематывает» master, чтобы тот указывал на а3.
Серия коммитов на графе интерпретируется как серия изменений содержимого репозитория. Это значит, что при объединении, если дающий является потомком принимающего, история не меняется. Уже существует последовательность коммитов, описывающих нужные изменения: последовательность коммитов между принимающим и дающим. Но, хотя история Git не меняется, граф Git меняется. Конкретная ссылка, на которую указывает HEAD, обновляется, чтобы указывать на коммит дающего.
Пользователь записывает 4 в содержимое number.txt и коммитит изменение в master.
Пользователь подтверждает deputy. Он записывает «b» в содержимое data/letter.txt и коммитит изменение в deputy.
У коммитов могут быть общие родители. Это значит, что в истории коммитов можно создавать новые родословные.
У коммитов может быть несколько родителей. Это значит, что разные родословные можно объединить коммитом с двумя родителями: объединяющим коммитом.
Пользователь объединяет master с deputy.
Git обнаруживает, что принимающий, b3, и дающий, a4, находятся в разных родословных. Он выполняет объединяющий коммит. У этого процесса восемь шагов.
1. Git записывает хэш дающего коммита в файл alpha/.git/MERGE_HEAD. Наличие этого файла сообщает Git, что он находится в процессе объединения.
2. Во-вторых, Git находит базовый коммит: самый новый предок, общий для дающего и принимающего коммитов.
У коммитов есть родители. Это значит, что можно найти точку, в которой разделились две родословных. Git отслеживает цепочку назад от b3, чтобы найти всех его предков, и назад от a4 с той же целью. Он находит самого нового из их общих предков, а3. Это базовый коммит.
3. Git создают индексы для базового, дающего и принимающего коммита из их древовидных графов.
4. Git создаёт diff, объединяющий изменения, которые принимающий и дающий коммиты произвели с базовым. Этот diff – список путей файлов, указывающих на изменения: добавление, удаление, модификацию или конфликты.
Git получает список всех файлов, имеющихся в индексах базового коммита, получающего и дающего. Для каждого он сравнивает записи в индексах и решает, каким образом нужно менять файл. Он пишет соответствующую запись в diff. В нашем случае в diff попадают две записи.
Первая запись для data/letter.txt. Содержимое этого файла – «a» в базовом, «b» в получающем и «a» в дающем. Содержимое у базового и получающего различается. Но у базового и дающего – совпадает. Git видит, что содержимое изменено получающим, а не дающим. Запись в diff для data/letter.txt – это изменение, а не конфликт.
Вторая запись в diff – для data/number.txt. В этом случае, содержимое совпадает у базового и получающего, а у дающего оно другое. Запись в diff для data/number.txt — также изменение.
Есть возможность найти базовый коммит объединения. Это значит, что если файл отличается от базового только в получающем или дающем, Git может автоматически провести объединение файла. Это уменьшает работу, которую должен проделать пользователь.
5. Изменения, описанные записями в diff, применяются к рабочей копии. Содержимому data/letter.txt присваивается значение b, а содержимому data/number.txt – 4.
6. Изменения, описанные записями в diff, применяются к индексу. Запись, относящаяся к data/letter.txt, указывает на блоб b, а запись для data/number.txt – на блоб 4.
7. Подтверждается обновлённый индекс:
Обратите внимание, что у коммита два родителя.
8. Git направляет текущую ветвь, deputy, на новый коммит.
Пользователь подтверждает master. Он объединяет deputy и master. Это приводит к перемотке master до коммита b4. Теперь master и deputy указывают на один и тот же коммит.
Пользователь подтверждает deputy. Он устанавливает содержимое data/number.txt в 5 и подтверждает изменение в deputy.
Пользователь подтверждает master. Он устанавливает содержимое data/number.txt в 6 и подтверждает изменение в master.
Пользователь объединяет deputy и master. Выявляется конфликт и объединение приостанавливается. Процесс объединения с конфликтом делает те же шесть первых шагов, что и процесс объединения без конфликта: определить .git/MERGE_HEAD, найти базовый коммит, создать индексы для базового, принимающего и дающего коммитов, создать diff, обновить рабочую копию и обновить индекс. Из-за конфликта седьмой шаг с коммитом и восьмой с обновлением ссылки не выполняются. Пройдём по ним заново и посмотрим, что получается.
1. Git записывает хэш дающего коммита в файл .git/MERGE_HEAD.
2. Git находит базовый коммит, b4.
3. Git генерирует индексы для базового, принимающего и дающего коммитов.
4. Git генерирует diff, комбинирующий изменения базового коммита, произведённые получающим и дающим коммитами. Этот diff – список путей к файлам, указывающим на изменения: добавление, удаление, модификацию или конфликт.
В данном случае diff содержит только одну запись: data/number.txt. Она отмечена как конфликт, поскольку содержимое data/number.txt отличается в дающем, принимающем и базовом коммитах.
5. Изменения, определяемые записями в diff, применяются к рабочей копии. В области конфликта Git выводит обе версии в файл рабочей копии. Содержимое файла ata/number.txt становится следующим:
6. Изменения, определённые записями в diff, применяются к индексу. Записи в индексе имеют уникальный идентификатор в виде комбинации пути к файлу и этапа. У записи файла без конфликта этап равен 0. Перед объединением индекс выглядел так, где нули – это номера этапов:
После записи diff в индекс тот выглядит уже так:
Запись data/letter.txt на этапе 0 такая же, какая и была до объединения. Запись для data/number.txt с этапом 0 исчезла. Вместо неё появились три новых. У записи с этапом 1 хэш от базового содержимого data/number.txt. У записи с этапом 3 хэш от содержимого дающего data/number.txt. Присутствие трёх записей говорит Git, что для data/number.txt возник конфликт.
Объединение приостанавливается.
Пользователь интегрирует содержимое двух конфликтующих версий, устанавливая содержимое data/number.txt равным 11. Он добавляет файл в индекс. Git добавляет блоб, содержащий 11. Добавление конфликтного файла говорит Git о том, что конфликт решён. Git удаляет вхождения data/number.txt для этапов 1, 2 и 3 из индекса. Он добавляет запись для data/number.txt на этапе 0 с хэшем от нового блоба. Теперь индекс содержит записи:
7. Пользователь коммитит. Git видит в репозитории .git/MERGE_HEAD, что говорит ему о том, что объединение находится в процессе. Он проверяет индекс и не обнаруживает конфликтов. Он создаёт новый коммит, b11, для записи содержимого разрешённого объединения. Он удаляет файл .git/MERGE_HEAD. Это и завершает объединение.
8. Git направляет текущую ветвь, master, на новый коммит.
Диаграмма для графа Git включает историю коммитов, деревья и блобы последнего коммита, рабочую копию и индекс:
Пользователь указывает Git удалить data/letter.txt. Файл удаляется из рабочей копии. Из индекса удаляется запись.
Пользователь делает коммит. Как обычно при коммите, Git строит граф, представляющий содержимое индекса. data/letter.txt не включён в граф, поскольку его нет в индексе.
Пользователь копирует содержимое репозитория alpha/ в директорию bravo/. Это приводит к следующей структуре:
Теперь в директории bravo есть новый граф Git:
Пользователь возвращается в репозиторий alpha. Он назначает bravo удалённым репозиторием для alpha. Это добавляет несколько строк в файл alpha/.git/config:
Строки говорят, что существует удалённый репозиторий bravo в директории ../bravo.
Пользователь переходит к репозиторию bravo. Он присваивает содержимому data/number.txt значение 12 и коммитит изменение в master на bravo.
Пользователь переходит в репозиторий alpha. Он копирует master из bravo в alpha. У этого процесса четыре шага.
1. Git получает хэш коммита, на который в bravo указывает master. Это хэш коммита 12.
2. Git составляет список всех объектов, от которых зависит коммит 12: сам объект коммита, объекты в его графе, коммиты предка коммита 12, и объекты из их графов. Он удаляет из этого списка все объекты, которые уже есть в базе alpha. Остальное он копирует в alpha/.git/objects/.
3. Содержимому конкретного файла ссылки в alpha/.git/refs/remotes/bravo/master присваивается хэш коммита 12.
4. Содержимое alpha/.git/FETCH_HEAD становится следующим:
Это значит, что самая последняя команда fetch достала коммит 12 из master с bravo.
Объекты можно копировать. Это значит, что у разных репозиториев может быть общая история.
Репозитории могут хранить ссылки на удалённые ветви типа alpha/.git/refs/remotes/bravo/master. Это значит, что репозиторий может локально записывать состояние ветви удалённого репозитория. Он корректен во время его копирования, но станет устаревшим в случае изменения удалённой ветви.
Пользователь объединяет FETCH_HEAD. FETCH_HEAD – это просто ещё одна ссылка. Она указывает на коммит 12, дающий. HEAD указывает на коммит 11, принимающий. Git делает объединение-перемотку и направляет master на коммит 12.
Пользователь переносит master из bravo в alpha. Pull – это сокращение для «скопировать и объединить FETCH_HEAD». Git выполняет обе команды и сообщает, что master «Already up-to-date».
Пользователь переезжает в верхний каталог. Он клонирует alpha в charlie. Клонирование приводит к результатам, сходным с командой cp, которую пользователь выполнил для создания репозитория bravo. Git создаёт новую директорию charlie. Он инициализирует charlie в качестве репозитория, добавляет alpha в качестве удалённого под именем origin, получает origin и объединяет FETCH_HEAD.
Пользователь возвращается в репозиторий alpha. Присваивает содержимому data/number.txt значение 13 и коммитит изменение в master на alpha.
Он назначает charlie удалённым репозиторием alpha.
Он размещает master на charlie. Все объекты, необходимые для коммита 13, копируются в charlie.
В этот момент процесс размещения останавливается. Git, как всегда, сообщает пользователю, что пошло не так. Он отказывается размещать на ветви, которая подтверждена удалённо. Это имеет смысл. Размещение обновило бы удалённый индекс и HEAD. Это привело бы к путанице, если бы кто-то ещё редактировал рабочую копию на удалённом репозитории.
В этот момент пользователь может создать новую ветвь, объединить коммит 13 с ней и разместить эту ветвь на charlie. Но пользователям требуется репозиторий, на котором можно размещать, что угодно. Им нужен центральный репозиторий, на котором можно размещать, и с которого можно получать (pull), но на который напрямую никто не коммитит. Им нужно нечто типа удалённого GitHub. Им нужен чистый (bare) репозиторий.
Пользователь переходит в верхнюю директорию. Он создаёт клон delta в качестве чистого репозитория. Это обычный клон с двумя особенностями. Файл config говорит о том, что репозиторий чистый. И файлы, обычно хранящиеся в директории .git, хранятся в корне репозитория:
Пользователь возвращается в репозиторий alpha. Он назначает delta удалённым репозиторием для alpha.
Он присваивает содержимому значение 14 и коммитит изменение в master на alpha.
Он размещает master в delta. Размещение проходит в три этапа.
1. Все объекты, необходимые для коммита 14 на ветви master, копируются из alpha/.git/objects/ в delta/objects/.
2. delta/refs/heads/master обновляется до коммита 14.
3. alpha/.git/refs/remotes/delta/master направляют на коммит 14. В alpha находится актуальная запись состояния delta.
Git построен на графе. Почти все команды Git манипулируют этим графом. Чтобы понять Git, сконцентрируйтесь на свойствах графа, а не процедурах или командах.
Чтобы узнать о Git ещё больше, изучайте директорию .git. Это не страшно. Загляните вовнутрь. Измените содержимое файлов и посмотрите, что произойдёт. Создайте коммит вручную. Попробуйте посмотреть, как сильно вы сможете сломать репозиторий. Затем почините его.
Эссе концентрируется на структуре графа, на которой основан Git, и на том, как свойства этого графа определяют поведение Git. Изучая основы, вы строите своё представление на достоверной информации, а не на гипотезах, полученных из экспериментов с API. Правильная модель позволит вам лучше понять, что сделал Git, что он делает и что он собирается сделать.
Текст разбит на серии команд, работающих с единым проектом. Иногда встречаются наблюдения по поводу структуры данных графа, лежащего в основе Git. Наблюдения иллюстрируют свойство графа и поведение, основанное на нём.
После прочтения для ещё более глубокого погружения можно обратиться к обильно комментируемому исходному коду моей реализации Git на JavaScript.
Создание проекта
~ $ mkdir alpha
~ $ cd alpha
Пользователь создаёт директорию alpha
~/alpha $ mkdir data
~/alpha $ printf 'a' > data/letter.txt
Он перемещается в эту директорию и создаёт директорию data. Внутри он создаёт файл letter.txt с содержимым «а». Директория alpha выглядит так:
alpha
└── data
└── letter.txt
Инициализируем репозиторий
~/alpha $ git init
Initialized empty Git repository
git init вносит текущую директорию в Git-репозиторий. Для этого он создаёт директорию .git и создаёт в ней несколько файлов. Они определяют всю конфигурацию Git и историю проекта. Это обычные файлы – никакой магии. Пользователь может их читать и редактировать. То есть – пользователь может читать и редактировать историю проекта так же просто, как файлы самого проекта.
Теперь директория alpha выглядит так:
alpha
├── data
| └── letter.txt
└── .git
├── objects
etc...
Директория .git с её содержимым относится с Git. Все остальные файлы называются рабочей копией и принадлежат пользователю.
Добавляем файлы
~/alpha $ git add data/letter.txt
Пользователь запускает git add на data/letter.txt. Происходят две вещи.
Во-первых, создаётся новый блоб-файл в директории .git/objects/. Он содержит сжатое содержимое data/letter.txt. Его имя – сгенерированный хэш на основе содержимого. К примеру, Git делает хэш от а и получает 2e65efe2a145dda7ee51d1741299f848e5bf752e. Первые два символа хэша используются для имени директории в базе объектов: .git/objects/2e/. Остаток хэша – это имя блоб-файла, содержащего внутренности добавленного файла: .git/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e.
Заметьте, что простое добавление файла в Git приводит к сохранению его содержимого в директории objects. Оно будет храниться там, если пользователь удалит data/letter.txt из рабочей копии.
Во-вторых, git add добавляет файл в индекс. Индекс – это список, содержащий все файлы, за которыми Git было наказано следить. Он хранится в .git/index. Каждая строка даёт в соответствие отслеживаемому файлу хэш его содержимого и время добавления. Вот таким получается содержимое индекса после команды git add:
data/letter.txt 2e65efe2a145dda7ee51d1741299f848e5bf752e
Пользователь создаёт файл data/number.txt содержащий 1234.
~/alpha $ printf '1234' > data/number.txt
Рабочая копия выглядит так:
alpha
└── data
└── letter.txt
└── number.txt
Пользователь добавляет файл в Git.
~/alpha $ git add data
Команда git add создаёт блоб-файл, в котором хранится содержимое data/number.txt. Он добавляет в индекс запись для data/number.txt, указывающую на блоб. Вот содержимое индекса после повторного запуска git add:
data/letter.txt 2e65efe2a145dda7ee51d1741299f848e5bf752e
data/number.txt 274c0052dd5408f8ae2bc8440029ff67d79bc5c3
Заметьте, что в индексе перечислены только файлы из директории data, хотя пользователь давал команду git add data. Сама директория data отдельно не указывается.
~/alpha $ printf '1' > data/number.txt
~/alpha $ git add data
Когда пользователь создал data/number.txt, он хотел написать 1, а не 1234. Он вносит изменение и снова добавляет файл к индексу. Эта команда создаёт новый блоб с новым содержимым. Она обновляет запись в индексе для data/number.txt с указанием на новый блоб.
Делаем коммит
~/alpha $ git commit -m 'a1'
[master (root-commit) 774b54a] a1
Пользователь делает коммит a1. Git выводит данные о нём. Мы вскоре объясним их.
У команды commit есть три шага. Она создаёт граф, представляющий содержимое версии проекта, которую коммитят. Она создаёт объект коммита. Она направляет текущую ветку на новый объект коммита.
Создание графа
Git запоминает текущее состояние проекта, создавая древовидный граф из индекса. Этот граф записывает расположение и содержимое каждого файла в проекте.
Граф состоит из двух типов объектов: блобы и деревья. Блобы сохраняются через git add. Они представляют содержимое файлов. Деревья сохраняются при коммите. Дерево представляет директорию в рабочей копии. Ниже приведён объект дерева, записавший содержимое директории data при новом коммите.
100664 blob 2e65efe2a145dda7ee51d1741299f848e5bf752e letter.txt
100664 blob 56a6051ca2b02b04ef92d5150c9ef600403cb1de number.txt
Первая строка записывает всё необходимое для воспроизводства data/letter.txt. Первая часть хранит права доступа к файлу. Вторая – что содержимое файла хранится в блобе, а не в дереве. Третья – хэш блоба. Четвёртая – имя файла.
Вторая строчка тем же образом относится к data/number.txt.
Ниже указан объект-дерево для alpha, корневой директории проекта:
040000 tree 0eed1217a2947f4930583229987d90fe5e8e0b74 data
Единственная строка указывает на дерево data.
В приведённом графе дерево root указывает на дерево data. Дерево data указывает на блобы для data/letter.txt и data/number.txt.
Создание объекта коммита
git commit создаёт объект коммита после создания графа. Объект коммита – это ещё один текстовый файл в .git/objects/:
tree ffe298c3ce8bb07326f888907996eaa48d266db4
author Mary Rose Cook <mary@maryrosecook.com> 1424798436 -0500
committer Mary Rose Cook <mary@maryrosecook.com> 1424798436 -0500
a1
Первая строка указывает на дерево графа. Хэш – для объекта дерева, представляющего корень рабочей копии. То бишь, директории alpha. Последняя строчка – комментарий коммита.
Направить текущую ветку на новый коммит
Наконец, команда коммита направляет текущую ветку на новый объект коммита.
Какая у нас текущая ветка? Git идёт в файл HEAD в .git/HEAD и видит:
ref: refs/heads/master
Это значит, что HEAD указывает на master. master – текущая ветка.
HEAD и master – это ссылки. Ссылка – это метка, используемая Git, или пользователем, для идентификации определённого коммита.
Файла, представляющего ссылку master, не существует, поскольку это первый коммит в репозитории. Git создаёт файл в .git/refs/heads/master и задаёт его содержимое – хэш объекта коммит:
74ac3ad9cde0b265d2b4f1c778b283a6e2ffbafd
(Если вы параллельно чтению вбиваете в Git команды, ваш хэш a1 будет отличаться от моего. Хэш объектов содержимого – блобов и деревьев – всегда получаются теми же. А коммитов – нет, потому что они учитывают даты и имена создателей).
Добавим в граф Git HEAD и master:
HEAD указывает на master, как и до коммита. Но теперь master существует и указывает на новый объект коммита.
Делаем не первый коммит
Ниже приведён граф после коммита a1. Включены рабочая копия и индекс.
Заметьте, что у рабочей копии, индекса и коммита одинаковое содержимое data/letter.txt и data/number.txt. Индекс и HEAD используют хэши для указания на блобы, но содержимое рабочей копии хранится в виде текста в другом месте.
~/alpha $ printf '2' > data/number.txt
Пользователь меняет содержимое data/number.txt на 2. Это обновляет рабочую копию, но оставляет индекс и HEAD без изменений.
~/alpha $ git add data/number.txt
Пользователь добавляет файл в Git. Это добавляет блоб, содержащий 2, в директорию objects. Указатель записи индекса для data/number.txt указывает на новый блоб.
~/alpha $ git commit -m 'a2'
[master f0af7e6] a2
Пользователь делает коммит. Шаги у него такие же, как и раньше.
Во-первых, создаётся новый древовидный граф для представления содержимого индекса.
Запись в индексе для data/number.txt изменилась. Старое дерево data уже не отражает проиндексированное состояние директории data. Нужно создать новый объект-дерево data.
100664 blob 2e65efe2a145dda7ee51d1741299f848e5bf752e letter.txt
100664 blob d8263ee9860594d2806b0dfd1bfd17528b0ba2a4 number.txt
Хэш у нового объекта отличается от старого дерева data. Нужно создать новое дерево root для записи этого хэша.
040000 tree 40b0318811470aaacc577485777d7a6780e51f0b data
Во-вторых, создаётся новый объект коммита.
tree ce72afb5ff229a39f6cce47b00d1b0ed60fe3556
parent 774b54a193d6cfdd081e581a007d2e11f784b9fe
author Mary Rose Cook <mary@maryrosecook.com> 1424813101 -0500
committer Mary Rose Cook <mary@maryrosecook.com> 1424813101 -0500
a2
Первая строка объекта коммита указывает на новый объект root. Вторая строка указывает на a1: родительский коммит. Для поиска родительского коммита Git идёт в HEAD, проходит до master и находит хэш коммита a1.
В третьих, содержимое файла-ветви master меняется на хэш нового коммита.
Коммит a2
Граф Git без рабочей копии и индекса
Свойства графа:
• Содержимое хранится в виде дерева объектов. Это значит, что в базе хранятся только изменения. Взгляните на граф сверху. Коммит а2 повторно использует блоб, сделанный до коммита а1. Точно так же, если вся директория от коммита до коммита не меняется, неё дерево и все блобы и нижележащие деревья можно использовать повторно. Обычно изменения между коммитами небольшие. Это значит, что Git может хранить большие истории коммитов, занимая немного места.
• У каждого коммита есть предок. Это значит, что в репозитории можно хранить историю проекта.
• Ссылки – входные точки для той или иной истории коммита. Это значит, что коммитам можно давать осмысленные имена. Пользователь организовывает работу в виде родословной, осмысленной для своего проекта, с ссылками типа fix-for-bug-376. Git использует символьные ссылки вроде HEAD, MERGE_HEAD и FETCH_HEAD для поддержки команды редактирования истории коммитов.
• Узлы в директории objects/ неизменны. Это значит, что содержимое редактируется, но не удаляется. Каждый кусочек содержимого, добавленный когда-либо, каждый сделанный коммит хранится где-то в директории objects.
• Ссылки изменяемы. Значит, смысл ссылки может измениться. Коммит, на который указывает master, может быть наилучшей версией проекта на текущий момент, но вскоре его может сменить новый коммит.
• Рабочая копия и коммиты, на которые указывают ссылки, доступны сразу. Другие коммиты – нет. Это значит, что недавнюю историю легче вызвать, но и меняется она чаще. Можно сказать, что память Git постоянно исчезает, и её надо стимулировать всё более жёсткими тычками.
Рабочую копию легче всего вызвать из истории, поскольку она находится в корне репозитория. Её вызов даже не требует команды Git. Но также это и самая непостоянная точка в истории. Пользователь может сделать десяток версий файла, но Git не запишет ни одну из них, пока они не добавлены.
Коммит, на который указывает HEAD, очень легко вызвать. Он находится на конце подтверждённой ветви. Чтобы просмотреть его содержимое, пользователь может просто сохранить и затем изучить рабочую копию. И в то же время, HEAD – самая часто изменяющаяся ссылка.
Коммит, на который указывает конкретная ссылка, легко вызвать. Пользователь просто подтвердит эту ветку. Конец ветки меняется реже, чем HEAD, но достаточно для того, чтобы смысл названия ветки можно было менять.
Сложно вызвать коммит, на который не указывают ссылки. Чем дальше пользователь уходит от ссылки, тем тяжелее ему воссоздать смысл коммита. Но чем дальше в прошлое он идёт, тем меньше вероятность, что кто-либо изменил историю с момента их предыдущего просмотра.
Подтверждение (check out) коммита
~/alpha $ git checkout 37888c2
You are in 'detached HEAD' state...
Пользователь подтверждает коммит а2, используя его хэш. (Если вы запускаете эти команды, то данная команда у вас не сработает. Используйте git log, чтобы выяснить хэш вашего коммита а2).
Подтверждение состоит из четырёх шагов.
Во-первых, Git получает коммит а2 и граф, на который тот указывает.
Во-вторых, он делает записи о файлах в графе в рабочей копии. В результате изменений не происходит. В рабочей копии уже есть содержимое графа, поскольку HEAD уже указывал на коммит а2 через master.
В-третьих, Git делает записи о файлах в граф в индексе. Тут тоже изменений не происходит. В индексе уже содержится коммит а2.
В-четвёртых, содержимому HEAD присваивается хэш коммита а2:
f0af7e62679e144bb28c627ee3e8f7bdb235eee9
Запись хэша в HEAD приводит репозиторий в состоянии с отделённым HEAD. Обратите внимание на графе ниже, что HEAD указывает непосредственно на коммит а2, а не на master.
~/alpha $ printf '3' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'a3'
[detached HEAD 3645a0e] a3
Пользователь записывает в содержимое data/number.txt значение 3 и коммитит изменение. Git идёт к HEAD для получения родителя коммита а3. Вместо того, чтобы найти и последовать по ссылке на ветвь, он находит и возвращает хэш коммита а2.
Git обновляет HEAD, чтобы он указывал на хэш нового коммита а3. Репозиторий по-прежнему находится в состоянии с отделённым HEAD. Он не на ветви, поскольку ни один коммит не указывает ни на а3, ни на любой из его потомков. Его легко потерять.
Далее мы будем опускать деревья и блобы из диаграмм графов.
Создать ответвление (branch)
~/alpha $ git branch deputy
Пользователь создаёт новую ветвь под названием deputy. В результате появляется новый файл в .git/refs/heads/deputy, содержащий хэш, на который указывает HEAD: хэш коммита а3.
Ветви – это только ссылки, а ссылки – это только файлы. Это значит, что ветви Git весят очень немного.
Создание ветви deputy размещает новый коммит а3 на ответвлении. HEAD всё ещё отделён, поскольку всё ещё показывает непосредственно на коммит.
Подтвердить ответвление
~/alpha $ git checkout master
Switched to branch 'master'
Пользователь подтверждает ответвление master.
Во-первых, Git получает коммит а2, на который указывает master, и получает граф, на который указывает коммит.
Во-вторых, Git вносит файловые записи в граф в файлы рабочей копии. Это меняет контент data/number.txt на 2.
В-третьих, Git вносит файловые записи в граф индекса. Это обновляет вхождение для data/number.txt на хэш блоба 2.
В-четвёртых, Git направляет HEAD на master, меняя его содержимое с хэша на
ref: refs/heads/master
Подтвердить ветвь, несовместимую с рабочей копией
~/alpha $ printf '789' > data/number.txt
~/alpha $ git checkout deputy
Your changes to these files would be overwritten
by checkout:
data/number.txt
Commit your changes or stash them before you
switch branches.
Пользователь случайно присваивает содержимому data/number.txt значение 789. Он пытается подтвердить deputy. Git препятствует подтверждению.
HEAD указывает на master, указывающий на a2, где в data/number.txt записано 2. deputy указывает на a3, где в data/number.txt записано 3. В версии рабочей копии для data/number.txt записано 789. Все эти версии различны, и разницу нужно как-то устранить.
Git может заменить версию рабочей копии версией из подтверждаемого коммита. Но он всеми силами избегает потери данных.
Git может объединить версию рабочей копии с подтверждаемой версией. Но это сложно.
Поэтому Git отклоняет подтверждение.
~/alpha $ printf '2' > data/number.txt
~/alpha $ git checkout deputy
Switched to branch 'deputy'
Пользователь замечает, что он случайно отредактировал data/number.txt и присваивает ему обратно 2. Затем он успешно подтверждает deputy.
Объединение с предком
~/alpha $ git merge master
Already up-to-date.
Пользователь включает master в deputy. Объединение двух ветвей означает объединение двух коммитов. Первый коммит – тот, на который указывает deputy: принимающий. Второй коммит – тот, на который указывает master: дающий. Для объединения Git ничего не делает. Он сообщает, что он уже «Already up-to-date».
Серия объединений в графе интерпретируется как серия изменений содержимого репозитория. Это значит, что при объединении если коммит дающего получается предком коммита принимающего, Git ничего не делает. Эти изменения уже внесены.
Объединение с потомком
~/alpha $ git checkout master
Switched to branch 'master'
Пользователь подтверждает master.
~/alpha $ git merge deputy
Fast-forward
Он включает deputy в master. Git обнаруживает, что коммит принимающего, а2, является предком коммита дающего, а3. Он может сделать объединение с быстрой перемоткой вперёд.
Он берёт коммит дающего и граф, на который он указывает. Он вносит файловые записи в графы рабочей копии и индекса. Затем он «перематывает» master, чтобы тот указывал на а3.
Серия коммитов на графе интерпретируется как серия изменений содержимого репозитория. Это значит, что при объединении, если дающий является потомком принимающего, история не меняется. Уже существует последовательность коммитов, описывающих нужные изменения: последовательность коммитов между принимающим и дающим. Но, хотя история Git не меняется, граф Git меняется. Конкретная ссылка, на которую указывает HEAD, обновляется, чтобы указывать на коммит дающего.
Объединить два коммита из разных родословных
~/alpha $ printf '4' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'a4'
[master 7b7bd9a] a4
Пользователь записывает 4 в содержимое number.txt и коммитит изменение в master.
~/alpha $ git checkout deputy
Switched to branch 'deputy'
~/alpha $ printf 'b' > data/letter.txt
~/alpha $ git add data/letter.txt
~/alpha $ git commit -m 'b3'
[deputy 982dffb] b3
Пользователь подтверждает deputy. Он записывает «b» в содержимое data/letter.txt и коммитит изменение в deputy.
У коммитов могут быть общие родители. Это значит, что в истории коммитов можно создавать новые родословные.
У коммитов может быть несколько родителей. Это значит, что разные родословные можно объединить коммитом с двумя родителями: объединяющим коммитом.
~/alpha $ git merge master -m 'b4'
Merge made by the 'recursive' strategy.
Пользователь объединяет master с deputy.
Git обнаруживает, что принимающий, b3, и дающий, a4, находятся в разных родословных. Он выполняет объединяющий коммит. У этого процесса восемь шагов.
1. Git записывает хэш дающего коммита в файл alpha/.git/MERGE_HEAD. Наличие этого файла сообщает Git, что он находится в процессе объединения.
2. Во-вторых, Git находит базовый коммит: самый новый предок, общий для дающего и принимающего коммитов.
У коммитов есть родители. Это значит, что можно найти точку, в которой разделились две родословных. Git отслеживает цепочку назад от b3, чтобы найти всех его предков, и назад от a4 с той же целью. Он находит самого нового из их общих предков, а3. Это базовый коммит.
3. Git создают индексы для базового, дающего и принимающего коммита из их древовидных графов.
4. Git создаёт diff, объединяющий изменения, которые принимающий и дающий коммиты произвели с базовым. Этот diff – список путей файлов, указывающих на изменения: добавление, удаление, модификацию или конфликты.
Git получает список всех файлов, имеющихся в индексах базового коммита, получающего и дающего. Для каждого он сравнивает записи в индексах и решает, каким образом нужно менять файл. Он пишет соответствующую запись в diff. В нашем случае в diff попадают две записи.
Первая запись для data/letter.txt. Содержимое этого файла – «a» в базовом, «b» в получающем и «a» в дающем. Содержимое у базового и получающего различается. Но у базового и дающего – совпадает. Git видит, что содержимое изменено получающим, а не дающим. Запись в diff для data/letter.txt – это изменение, а не конфликт.
Вторая запись в diff – для data/number.txt. В этом случае, содержимое совпадает у базового и получающего, а у дающего оно другое. Запись в diff для data/number.txt — также изменение.
Есть возможность найти базовый коммит объединения. Это значит, что если файл отличается от базового только в получающем или дающем, Git может автоматически провести объединение файла. Это уменьшает работу, которую должен проделать пользователь.
5. Изменения, описанные записями в diff, применяются к рабочей копии. Содержимому data/letter.txt присваивается значение b, а содержимому data/number.txt – 4.
6. Изменения, описанные записями в diff, применяются к индексу. Запись, относящаяся к data/letter.txt, указывает на блоб b, а запись для data/number.txt – на блоб 4.
7. Подтверждается обновлённый индекс:
tree 20294508aea3fb6f05fcc49adaecc2e6d60f7e7d
parent 982dffb20f8d6a25a8554cc8d765fb9f3ff1333b
parent 7b7bd9a5253f47360d5787095afc5ba56591bfe7
author Mary Rose Cook <mary@maryrosecook.com> 1425596551 -0500
committer Mary Rose Cook <mary@maryrosecook.com> 1425596551 -0500
b4
Обратите внимание, что у коммита два родителя.
8. Git направляет текущую ветвь, deputy, на новый коммит.
Объединение двух коммитов из разных родословных, изменяющих один и тот же файл
~/alpha $ git checkout master
Switched to branch 'master'
~/alpha $ git merge deputy
Fast-forward
Пользователь подтверждает master. Он объединяет deputy и master. Это приводит к перемотке master до коммита b4. Теперь master и deputy указывают на один и тот же коммит.
~/alpha $ git checkout deputy
Switched to branch 'deputy'
~/alpha $ printf '5' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'b5'
[deputy bd797c2] b5
Пользователь подтверждает deputy. Он устанавливает содержимое data/number.txt в 5 и подтверждает изменение в deputy.
~/alpha $ git checkout master
Switched to branch 'master'
~/alpha $ printf '6' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m 'b6'
[master 4c3ce18] b6
Пользователь подтверждает master. Он устанавливает содержимое data/number.txt в 6 и подтверждает изменение в master.
~/alpha $ git merge deputy
CONFLICT in data/number.txt
Automatic merge failed; fix conflicts and
commit the result.
Пользователь объединяет deputy и master. Выявляется конфликт и объединение приостанавливается. Процесс объединения с конфликтом делает те же шесть первых шагов, что и процесс объединения без конфликта: определить .git/MERGE_HEAD, найти базовый коммит, создать индексы для базового, принимающего и дающего коммитов, создать diff, обновить рабочую копию и обновить индекс. Из-за конфликта седьмой шаг с коммитом и восьмой с обновлением ссылки не выполняются. Пройдём по ним заново и посмотрим, что получается.
1. Git записывает хэш дающего коммита в файл .git/MERGE_HEAD.
2. Git находит базовый коммит, b4.
3. Git генерирует индексы для базового, принимающего и дающего коммитов.
4. Git генерирует diff, комбинирующий изменения базового коммита, произведённые получающим и дающим коммитами. Этот diff – список путей к файлам, указывающим на изменения: добавление, удаление, модификацию или конфликт.
В данном случае diff содержит только одну запись: data/number.txt. Она отмечена как конфликт, поскольку содержимое data/number.txt отличается в дающем, принимающем и базовом коммитах.
5. Изменения, определяемые записями в diff, применяются к рабочей копии. В области конфликта Git выводит обе версии в файл рабочей копии. Содержимое файла ata/number.txt становится следующим:
<<<<<<< HEAD
6
=======
5
>>>>>>> deputy
6. Изменения, определённые записями в diff, применяются к индексу. Записи в индексе имеют уникальный идентификатор в виде комбинации пути к файлу и этапа. У записи файла без конфликта этап равен 0. Перед объединением индекс выглядел так, где нули – это номера этапов:
0 data/letter.txt 63d8dbd40c23542e740659a7168a0ce3138ea748
0 data/number.txt 62f9457511f879886bb7728c986fe10b0ece6bcb
После записи diff в индекс тот выглядит уже так:
0 data/letter.txt 63d8dbd40c23542e740659a7168a0ce3138ea748
1 data/number.txt bf0d87ab1b2b0ec1a11a3973d2845b42413d9767
2 data/number.txt 62f9457511f879886bb7728c986fe10b0ece6bcb
3 data/number.txt 7813681f5b41c028345ca62a2be376bae70b7f61
Запись data/letter.txt на этапе 0 такая же, какая и была до объединения. Запись для data/number.txt с этапом 0 исчезла. Вместо неё появились три новых. У записи с этапом 1 хэш от базового содержимого data/number.txt. У записи с этапом 3 хэш от содержимого дающего data/number.txt. Присутствие трёх записей говорит Git, что для data/number.txt возник конфликт.
Объединение приостанавливается.
~/alpha $ printf '11' > data/number.txt
~/alpha $ git add data/number.txt
Пользователь интегрирует содержимое двух конфликтующих версий, устанавливая содержимое data/number.txt равным 11. Он добавляет файл в индекс. Git добавляет блоб, содержащий 11. Добавление конфликтного файла говорит Git о том, что конфликт решён. Git удаляет вхождения data/number.txt для этапов 1, 2 и 3 из индекса. Он добавляет запись для data/number.txt на этапе 0 с хэшем от нового блоба. Теперь индекс содержит записи:
0 data/letter.txt 63d8dbd40c23542e740659a7168a0ce3138ea748
0 data/number.txt 9d607966b721abde8931ddd052181fae905db503
~/alpha $ git commit -m 'b11'
[master 251a513] b11
7. Пользователь коммитит. Git видит в репозитории .git/MERGE_HEAD, что говорит ему о том, что объединение находится в процессе. Он проверяет индекс и не обнаруживает конфликтов. Он создаёт новый коммит, b11, для записи содержимого разрешённого объединения. Он удаляет файл .git/MERGE_HEAD. Это и завершает объединение.
8. Git направляет текущую ветвь, master, на новый коммит.
Удаление файла
Диаграмма для графа Git включает историю коммитов, деревья и блобы последнего коммита, рабочую копию и индекс:
~/alpha $ git rm data/letter.txt
rm 'data/letter.txt'
Пользователь указывает Git удалить data/letter.txt. Файл удаляется из рабочей копии. Из индекса удаляется запись.
~/alpha $ git commit -m '11'
[master d14c7d2] 11
Пользователь делает коммит. Как обычно при коммите, Git строит граф, представляющий содержимое индекса. data/letter.txt не включён в граф, поскольку его нет в индексе.
Копировать репозиторий
~/alpha $ cd ..
~ $ cp -R alpha bravo
Пользователь копирует содержимое репозитория alpha/ в директорию bravo/. Это приводит к следующей структуре:
~
├── alpha
| └── data
| └── number.txt
└── bravo
└── data
└── number.txt
Теперь в директории bravo есть новый граф Git:
Связать репозиторий с другим репозиторием
~ $ cd alpha
~/alpha $ git remote add bravo ../bravo
Пользователь возвращается в репозиторий alpha. Он назначает bravo удалённым репозиторием для alpha. Это добавляет несколько строк в файл alpha/.git/config:
[remote "bravo"]
url = ../bravo/
Строки говорят, что существует удалённый репозиторий bravo в директории ../bravo.
Получить ветвь с удалённого репозитория
~/alpha $ cd ../bravo
~/bravo $ printf '12' > data/number.txt
~/bravo $ git add data/number.txt
~/bravo $ git commit -m '12'
[master 94cd04d] 12
Пользователь переходит к репозиторию bravo. Он присваивает содержимому data/number.txt значение 12 и коммитит изменение в master на bravo.
~/bravo $ cd ../alpha
~/alpha $ git fetch bravo master
Unpacking objects: 100%
From ../bravo
* branch master -> FETCH_HEAD
Пользователь переходит в репозиторий alpha. Он копирует master из bravo в alpha. У этого процесса четыре шага.
1. Git получает хэш коммита, на который в bravo указывает master. Это хэш коммита 12.
2. Git составляет список всех объектов, от которых зависит коммит 12: сам объект коммита, объекты в его графе, коммиты предка коммита 12, и объекты из их графов. Он удаляет из этого списка все объекты, которые уже есть в базе alpha. Остальное он копирует в alpha/.git/objects/.
3. Содержимому конкретного файла ссылки в alpha/.git/refs/remotes/bravo/master присваивается хэш коммита 12.
4. Содержимое alpha/.git/FETCH_HEAD становится следующим:
94cd04d93ae88a1f53a4646532b1e8cdfbc0977f branch 'master' of ../bravo
Это значит, что самая последняя команда fetch достала коммит 12 из master с bravo.
Объекты можно копировать. Это значит, что у разных репозиториев может быть общая история.
Репозитории могут хранить ссылки на удалённые ветви типа alpha/.git/refs/remotes/bravo/master. Это значит, что репозиторий может локально записывать состояние ветви удалённого репозитория. Он корректен во время его копирования, но станет устаревшим в случае изменения удалённой ветви.
Объединение FETCH_HEAD
~/alpha $ git merge FETCH_HEAD
Updating d14c7d2..94cd04d
Fast-forward
Пользователь объединяет FETCH_HEAD. FETCH_HEAD – это просто ещё одна ссылка. Она указывает на коммит 12, дающий. HEAD указывает на коммит 11, принимающий. Git делает объединение-перемотку и направляет master на коммит 12.
Получить ветвь удалённого репозитория
~/alpha $ git pull bravo master
Already up-to-date.
Пользователь переносит master из bravo в alpha. Pull – это сокращение для «скопировать и объединить FETCH_HEAD». Git выполняет обе команды и сообщает, что master «Already up-to-date».
Клонировать репозиторий
~/alpha $ cd ..
~ $ git clone alpha charlie
Cloning into 'charlie'
Пользователь переезжает в верхний каталог. Он клонирует alpha в charlie. Клонирование приводит к результатам, сходным с командой cp, которую пользователь выполнил для создания репозитория bravo. Git создаёт новую директорию charlie. Он инициализирует charlie в качестве репозитория, добавляет alpha в качестве удалённого под именем origin, получает origin и объединяет FETCH_HEAD.
Разместить (push) ветвь на подтверждённой ветви на удалённом репозитории
~ $ cd alpha
~/alpha $ printf '13' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m '13'
[master 3238468] 13
Пользователь возвращается в репозиторий alpha. Присваивает содержимому data/number.txt значение 13 и коммитит изменение в master на alpha.
~/alpha $ git remote add charlie ../charlie
Он назначает charlie удалённым репозиторием alpha.
~/alpha $ git push charlie master
Writing objects: 100%
remote error: refusing to update checked out
branch: refs/heads/master because it will make
the index and work tree inconsistent
Он размещает master на charlie. Все объекты, необходимые для коммита 13, копируются в charlie.
В этот момент процесс размещения останавливается. Git, как всегда, сообщает пользователю, что пошло не так. Он отказывается размещать на ветви, которая подтверждена удалённо. Это имеет смысл. Размещение обновило бы удалённый индекс и HEAD. Это привело бы к путанице, если бы кто-то ещё редактировал рабочую копию на удалённом репозитории.
В этот момент пользователь может создать новую ветвь, объединить коммит 13 с ней и разместить эту ветвь на charlie. Но пользователям требуется репозиторий, на котором можно размещать, что угодно. Им нужен центральный репозиторий, на котором можно размещать, и с которого можно получать (pull), но на который напрямую никто не коммитит. Им нужно нечто типа удалённого GitHub. Им нужен чистый (bare) репозиторий.
Клонируем чистый (bare) репозиторий
~/alpha $ cd ..
~ $ git clone alpha delta --bare
Cloning into bare repository 'delta'
Пользователь переходит в верхнюю директорию. Он создаёт клон delta в качестве чистого репозитория. Это обычный клон с двумя особенностями. Файл config говорит о том, что репозиторий чистый. И файлы, обычно хранящиеся в директории .git, хранятся в корне репозитория:
delta
├── HEAD
├── config
├── objects
└── refs
Разместить ветвь в чистом репозитории
~ $ cd alpha
~/alpha $ git remote add delta ../delta
Пользователь возвращается в репозиторий alpha. Он назначает delta удалённым репозиторием для alpha.
~/alpha $ printf '14' > data/number.txt
~/alpha $ git add data/number.txt
~/alpha $ git commit -m '14'
[master cb51da8] 14
Он присваивает содержимому значение 14 и коммитит изменение в master на alpha.
~/alpha $ git push delta master
Writing objects: 100%
To ../delta
3238468..cb51da8 master -> master
Он размещает master в delta. Размещение проходит в три этапа.
1. Все объекты, необходимые для коммита 14 на ветви master, копируются из alpha/.git/objects/ в delta/objects/.
2. delta/refs/heads/master обновляется до коммита 14.
3. alpha/.git/refs/remotes/delta/master направляют на коммит 14. В alpha находится актуальная запись состояния delta.
Итог
Git построен на графе. Почти все команды Git манипулируют этим графом. Чтобы понять Git, сконцентрируйтесь на свойствах графа, а не процедурах или командах.
Чтобы узнать о Git ещё больше, изучайте директорию .git. Это не страшно. Загляните вовнутрь. Измените содержимое файлов и посмотрите, что произойдёт. Создайте коммит вручную. Попробуйте посмотреть, как сильно вы сможете сломать репозиторий. Затем почините его.