Что такое хэш git
Вся информация требуемая чтобы представить историю проекта хранится в особым образом организованных файлах. Все файлы хранят ссылаются друг на друга с помощью 40-значного "имени объекта" и это имя выглядит так:
Вы увидите эти 40-значные строки повсюду в Git. В каждом случае имя вычисляется как SHA1 значение содержимого объекта. SHA1 хэш это криптографическая хэш-функция. Для нас это значит то, что практически нереально найти два разных объекта с одинаковым именем. Это дает огромную выгоду; такую как:
- Git может быстро определить идентичны ли два объекта или нет, просто сравнивая их имена.
- Так как имена объектов вычисляются одинаково во всех репозиториях, то объекты с одинаковым содержимым в двух репозиториях всегда будут хранится под одинаковыми именами.
- Git может находить ошибки когда читает объект, для этого нужно просто сравнить хэш значение содержимого объекта с его именем.
Объекты
Каждый объект состоит из трех частей - тип, размер , содержимое. Размер это просто объем содержимого, а содержимое зависит от типа объекта. Существуют 4 разных типа объекта: "блоб", "дерево", "коммит", и "таг".
- "блоб" используется чтобы хранить содержимое файла - обычно это просто файл.
- "дерево" это что то вроде директории - оно ссылается на группу других деревьев и/или блобов (т.е. файлов и директорий)
- "коммит" указывает на отдельное дерево, от по сути отмечает дерево фиксируя в истории каким образом оно выглядет в момент выполнения коммита. Он содержит метаинфомацию фиксируя момент времени и автора изменений внесенных с последднего коммита, указатель на предыдущий коммит, и т.д.
- "таг" это способ маркировать некоторым образом определенный комит. Обычно это используется чтобы маркировать(по сути дать какое либо легко запоминающеся имя) определенные комиты как специфические, чтобы впоследствии было легче их найти.
Почти все в Git построено вокруг манипуляций этой простой структурой состоящей из четырех различных типов объектов. Это что-то вроде своеобразной файловой-системы надстроенной над файловой-системой компьютера.
Различия с SVN
Объект типа блоб
Блоб обычно хранит содержимое файла.
Вы можете использовать git show чтобы исследовать содержимое блоба. Предположим у нас есть SHA-значение блоба, таким образом чтобы просмотреть его содержимое выполните выполнить:
Объект "блоб" это всего лишь некоторая порция бинарных данных. Он ни на что не ссылается у него нет каких либо атрибутов, нет даже имени файла.
Поскольку блоб полностью определяется его собственным содержимым, то если два файла в директории или даже в разных версиях репозитория имеют одинаковое содержимое, они будут разделять один и тот же блоб объект. Объект полностью независит от его расположения в дереве каталогов, и переименование файла не изменит объект с которым этом файл связан.
Объект дерево
Дерево это простой объект который заключает в себе группу указателей на блобы и другие деревья - обычно представляет содержимое директорий или поддиректорий.
Команда git show более общая, и также может быть использована чтобы исследовать дерево объектов, но git ls-tree даст вам больше подробностей. Предположим у нас есть SHA значение дерева, тогда мы можем исследовать его следующим образом:
Как вы можете видеть, объект дерево содержит список записей. Каждая запись состоит из вида, типа объекта, SHA1 значения, и имени соответственно. Записи отсортированы по имени. Так выглядит содержимое одной директории дерева.
Ссылка на объект в дереве может быть как блобом (файлом по сути) так и деревом (поддиректорией). Поскольку имена всех объектов, деревьев и блобов, совпадает с SHA хэш-значением их содержимого, то SHA значения двух деревьев будут идентичны только если их содержимое (включая, рекурсивно, содержимое всех поддиректорий) идентично.
Это свойство позволяет git быстро найти отличия между двумя родственными объектами типа дерево, так как git может игнорировать объекты с одинаковыми именами.
Замечание: деревья могут также содержать записи коммитов. Более продробно от этом в секции Подмодули.)
Отметьте для себя, что все файлы имеют права 644 или 755: фактически git обращает внимание только на бит исполнения.
Объекты коммит
Объект "коммит" связывает физическое состояние дерева с описпнием того каким образом мы пришли к этому и почему.
Вы можете использовать параметр --pretty=raw с git show или git log чтобы исследовать коммит:
Как вы можете это видеть, коммит определяется:
- дерево: SHA1 имя объекта дерево (как определено ниже), представляющее содержимое директории в определенный момент времени.
- родитель(и): SHA1 имя некоторого числа коммитов которые представляют собой предыдущий шаг(и) в истории проекта. Пример выше имеет одного родителя; хотя коммиты слияния могут иметь более чем одного родителя. Коммит без родителей называется "root (корневой)" коммит, и представляет собой начальное состояние проекта. Каждый проект должен иметь по крайней мере один корневой коммит. Проект может также иметь множество корней, однако это не общий случай (и не обязательно хорошая идея).
- автор: Имя разработчика ответственного за эти изменения, вместе с датой.
- коммитер: имя разработчика который создал этот коммит, вместе с датой этого события. Оно(имя) может отличаться от имени автора; например в случае, если автор написал патч и отправил его по эл.почте другому разработчику который наложил патч и выполнил коммит.
- комментарий описывающий этот коммит.
Заметьте что коммит сам по себе не содержит никакой информации о том что изменилось; все изменения вычисляются при сравнении содержимого дерева на которое ссылается создаваемый коммит и дерева на которое ссылается его родитель. Git не пытается явно регистрировать переименования файлов хотя может идентифицировать случаи где существование одниковых файловых данных в измененном пути предложит переименовать. (Просмотрите, например параметр -M к команде git diff).
Коммит обычно создается git commit. Эта команда создает коммит - родитель которого текущая ветка HEAD, и чье дерево взято из содержимого сохраненного в данный момент в индексе.
Объектная модель
Теперь когда мы рассмотрели 3 главных объекта (блоб, дерево и коммит), давайте теперь посмотрим как они объединяются.
Если у нас есть простой проект со след. структурой директории:
И мы выполнили коммит всего этого в репозиторий Git, это будет выглядеть след. образом:
Вы можете видеть что мы создали объект дерево для каждой директории (включая корневую) и объект блоб для каждого файла. Затем, мы имеем объект коммит указывающий на кореневую директорию, и мы можем отследить как наш проект выглядел в момент коммита.
Объект таг
Просмотрите документацию команды git tag чтобы изучить как создавать и проверять объекты таг. (Заметьте что git tag может также использоваться чтобы создавать "легковесные таги", которые не являеются объектами таг вообще, это просто ссылки чьи имена начинаются с "refs/tags/").
Git имеет репутацию запутывающего инструмента. Пользователи натыкаются на терминологию и формулировки, которые вводят в заблуждение. Это более всего проявляется в "перезаписывающих" историю командах, таких как git cherry-pick или git rebase. По моему опыту, первопричина путаницы — интерпретация коммитов как различий, которые можно перетасовать. Однако коммиты — это не различия, а снимки! Я считаю, что Git станет понятным, если поднять занавес и посмотреть, как он хранит данные репозитория. Изучив модель хранения данных мы посмотрим, как новый взгляд помогает понять команды, такие как git cherry-pick и git rebase.
Если хочется углубиться по-настоящему, читайте главу о внутренней работе Git (Git internals) книги Pro Git. Я буду работать с репозиторием git/git версии v2.29.2. Просто повторяйте команды за мной, чтобы немного попрактиковаться.
Хеши — идентификаторы объектов
Самое важное, что нужно знать о Git-объектах, — это то, что Git ссылается на каждый из них по идентификатору объекта (OID для краткости), даёт объекту уникальное имя.
Чтобы найти OID, воспользуемся командой git rev-parse. Каждый объект, по сути, — простой текстовый файл, его содержимое можно проверить командой git cat-file -p.
Мы привыкли к тому, что OID даны в виде укороченной шестнадцатеричной строки. Строка рассчитана так, чтобы только один объект в репозитории имел совпадающий с ней OID. Если запросить объект слишком коротким OID, мы увидим список соответствующих подстроке OID.
Блобы — это содержимое файлов
На нижнем уровне объектной модели блобы — содержимое файла. Чтобы обнаружить OID файла текущей ревизии, запустите git rev-parse HEAD:, а затем, чтобы вывести содержимое файла — git cat-file -p .
Если я отредактирую файл README.md на моём диске, то git status предупредит, что файл недавно изменился, и хэширует его содержимое. Когда содержимое файла не совпадает с текущим OID в HEAD:README.md, git status сообщает о файле как о "модифицированном на диске". Таким образом видно, совпадает ли содержимое файла в текущей рабочей директории с ожидаемым содержимым в HEAD.
Деревья — это списки каталогов
Обратите внимание, что блобы хранят содержание файла, но не его имя. Имена берутся из представления каталогов Git — деревьев. Дерево — это упорядоченный список путей в паре с типами объектов, режимами файлов и OID для объекта по этому пути. Подкаталоги также представлены в виде деревьев, поэтому деревья могут указывать на другие деревья!
Воспользуемся диаграммами, чтобы визуализировать связи объектов между собой. Красные квадраты — наши блобы, а треугольники — деревья.
Деревья дают названия каждому подпункту и также содержат такую информацию, как разрешения на файлы в Unix, тип объекта (blob или tree) и OID каждой записи. Мы вырезаем выходные данные из 15 верхних записей, но можем использовать grep, чтобы обнаружить, что в этом дереве есть запись README.md, которая указывает на предыдущий OID блоба.
При помощи путей деревья могут указывать на блобы и другие деревья. Имейте в виду, что эти отношения идут в паре с именами путей, но мы не всегда показываем эти имена на диаграммах.
Само дерево не знает, где внутри репозитория оно находится, то есть указывать на дерево — роль объектов. Дерево, на которое ссылается ^, особое — это корневое дерево. Такое обозначение основано на специальной ссылке из вашего коммита.
Коммиты — это снапшоты
Коммит — это снимок во времени. Каждый содержит указатель на своё корневое дерево, представляющее состояние рабочего каталога на момент снимка.
В коммите есть список родительских коммитов, соответствующих предыдущим снимкам. Коммит без родителей — это корневой коммит, а коммит с несколькими родителями — это коммит слияния.
Например, коммит в v2.29.2 в Git-репозитории описывает этот релиз, также он авторизован, а его автор — член команды разработки Git.
Круги на диаграммах будут представлять коммиты:
Квадраты — это блобы. Они представляют содержимое файла.
Треугольники — это деревья. Они представляют каталоги.
Круги — это коммиты. Снапшоты во времени.
Ветви — это указатели
В Git мы перемещаемся по истории и вносим изменения, в основном не обращаясь к OID. Это связано с тем, что ветви дают указатели на интересующие нас коммиты. Ветка с именем main — на самом деле ссылка в Git, она называется refs/heads/main. Файлы ссылок буквально содержат шестнадцатеричные строки, которые ссылаются на OID коммита. В процессе работы эти ссылки изменяются, указывая на другие коммиты.
Это означает, что ветки существенно отличаются от Git-объектов. Коммиты, деревья и блобы неизменяемы (иммутабельны), это означает, что вы не можете изменить их содержимое. Изменив его, вы получите другой хэш и, таким образом, новый OID со ссылкой на новый объект!
Ветки именуются по смыслу, например, trunk [ствол] или my-special-object. Ветки используются, чтобы отслеживать работу и делиться её результатами. Специальная ссылка HEAD указывает на текущую ветку. Когда коммит добавляется в HEAD, HEAD автоматически обновляется до нового коммита ветки. Создать новую ветку и обновить HEAD можно при помощи флага git -c:
Обратите внимание: когда создавалась my-branch, также был создан файл (.git/refs/heads/my-branch) с текущим OID коммита, а файл .git/HEAD был обновлён так, чтобы указывать на эту ветку. Теперь, если мы обновим HEAD, создав новые коммиты, ветка my-branch обновится так, что станет указывать на этот новый коммит!
Общая картина
Посмотрим на всю картину. Ветви указывают на коммиты, коммиты — на другие коммиты и их корневые деревья, деревья указывают на блобы и другие деревья, а блобы не указывают ни на что. Вот диаграмма со всеми объектами сразу:
Время на диаграмме отсчитывается слева направо. Стрелки между коммитом и его родителями идут справа налево. У каждого коммита одно корневое дерево. HEAD указывает здесь на ветку main, а main указывает на самый недавний коммит.
Корневое дерево у этого коммита раскинулось полностью под ним, у остальных деревьев есть указывающие на эти объекты стрелки, потому что одни и те же объекты доступны из нескольких корневых деревьев! Эти деревья ссылаются на объекты по их OID (их содержимое), поэтому снимкам не нужно несколько копий одних и тех же данных. Таким образом, объектная модель Git образует дерево хешей.
Рассматривая объектную модель таким образом, мы видим, почему коммиты — это снимки: они непосредственно ссылаются на полное представление рабочего каталога коммита!
Вычисление различий
Чтобы сравнить два коммита, сначала рассмотрите их корневые деревья, которые почти всегда отличаются друг от друга. Затем в поддеревьях выполните поиск в глубину, следуя по парам, когда пути для текущего дерева имеют разные OID.
В примере ниже корневые деревья имеют разные значения для docs, поэтому мы рекурсивно обходим их. Эти деревья имеют разные значения для M.md, таким образом, два блоба сравниваются построчно и отображается их различие. Внутри docs N.md по-прежнему тот же самый, так что пропускаем их и возвращаемся к корневому дереву. После этого корневое дерево видит, что каталоги things имеют одинаковые OID, так же как и записи README.md.
На диаграмме выше мы заметили, что дерево things не посещается никогда, а значит, не посещается ни один из его достижимых объектов. Таким образом, стоимость вычисления различий зависит от количества путей с разным содержимым.
Теперь, когда понятно, что коммиты — это снимки, можно динамически вычислять разницу между любыми двумя коммитами. Почему тогда этот факт не общеизвестен? Почему новые пользователи натыкаются на идею о том, что коммит — это различие?
Одна из моих любимых аналогий — дуализм коммитов как дуализм частиц, при котором иногда коммиты рассматриваются как снимки, а иногда — как различия. Суть дела в другом виде данных, которые не являются Git-объектами — в патчах.
Подождите, а что такое патч?
Патч — это текстовый документ, где описывается, как изменить существующую кодовую базу. Патчи — это способ самых разрозненных команд делиться кодом без коммитов в Git. Видно, как патчи перетасовываются в списке рассылки Git.
Патч содержит описание изменения и причину ценности этого изменения, сопровождаемые выводом diff. Идея такова: некий разработчик может рассматривать рассуждение как оправдание применения патча, отличающегося от копии кода нашего разработчика.
Git может преобразовать коммит в патч командой git format-patch. Затем патч может быть применён к Git-репозиторию командой git apply. В первые дни существования открытого исходного кода такой способ обмена доминировал, но большинство проектов перешли на обмен коммитами непосредственно через пул-реквесты.
Самая большая проблема с тем, чтобы делиться исправлениями, в том, что патч теряет родительскую информацию, а новый коммит имеет родителя, который одинаков с вашим HEAD. Более того, вы получаете другой коммит, даже если работаете с тем же родителем, что и раньше, из-за времени коммита, но при этом коммиттер меняется! Вот основная причина, по которой в объекте коммита Git есть разделение на "автора", и "коммиттера".
Самая большая проблема в работе с патчами заключается в том, что патч трудно применить, когда ваш рабочий каталог не совпадает с предыдущим коммитом отправителя. Потеря истории коммитов затрудняет разрешение конфликтов.
Идея перемещения патчей с места на место перешла в несколько команд Git как "перемещение коммитов". На самом же деле различие коммитов воспроизводится, создавая новые коммиты.
Если коммиты — это не различия, что делает git cherry-pick?
Команда git cherry-pick создаёт новый коммит с идентичным отличием от , родитель которого — текущий коммит. Git в сущности выполняет такие шаги:
Вычисляет разницу между коммита и его родителя.
Применяет различие к текущему HEAD.
Создаёт новый коммит, корневое дерево которого соответствует новому рабочему каталогу, а родитель созданного коммита — HEAD.
Перемещает ссылку HEAD в этот новый коммит.
После создания нового коммита вывод git log -1 -p HEAD должен совпадать с выводом git log -1 -p .
Важно понимать, что мы не "перемещали" коммит так, чтобы он был поверх нашего текущего HEAD, мы создали новый коммит, и его вывод diff совпадает со старым коммитом.
А что делает git rebase?
Команда git rebase — это способ переместить коммиты так, чтобы получить новую историю. В простой форме это на самом деле серия команд git cherry-pick, которая воспроизводит различия поверх другого, отличного коммита.
Самое главное: git rebase обнаружит список коммитов, доступных из HEAD, но недоступных из . С помощью команды git log --online . HEAD вы можете отобразить их самостоятельно.
Затем команда rebase просто переходит в местоположению и выполняет команды git cherry-pick в этом диапазоне коммитов, начиная со старых. В конце мы получили новый набор коммитов с разными OID, но схожих с первоначальным диапазоном.
Для примера рассмотрим последовательность из трёх коммитов в текущей ветке HEAD с момента разветвления target. При запуске git rebase target, чтобы определить список коммитов A, B, и C, вычисляется общая база P. Затем поверх target они выбираются cherry-pick, чтобы создать новые коммиты A', B' и C'.
Коммиты A', B' и C' — это совершенно новые коммиты с общим доступом к большому количеству информации через A, B и C, но они представляют собой отдельные новые объекты. На самом деле старые коммиты существуют в вашем репозитории до тех пор, пока не начнётся сбор мусора.
С помощью команды git range-diff мы даже можем посмотреть на различие двух диапазонов коммитов! Я использую несколько примеров коммитов в репозитории Git, чтобы сделать rebase на тег v2.29.2, а затем слегка изменю описание коммита.
Если пройти вдоль дерева, вы увидите, что история коммитов всё ещё существует у обоих наборов коммитов. Новые коммиты имеют тег v2.29.2 — в истории это третий коммит, тогда как старые имеют тег v2.28.0 — болеее ранний, а в истории он также третий.
Если коммиты – не отличия, тогда как Git отслеживает переименования?
Внимательно посмотрев на объектную модель, вы заметите, что Git никогда не отслеживает изменения между коммитами в сохранённых объектных данных. Можно задаться вопросом: "Откуда Git знает, что произошло переименование?"
Git не отслеживает переименования. В нём нет структуры данных, которая хранила бы запись о том, что между коммитом и его родителем имело место переименование.
Вместо этого Git пытается обнаружить переименования во время динамического вычисления различий. Есть два этапа обнаружения переименований: именно переименования и редактирования.
После первого вычисления различий Git исследует внутренние различия, чтобы обнаружить, какие пути добавлены или удалены. Естественно, что перемещение файла из одного места в другое будет выглядеть как удаление из одного места и добавление в другое. Git попытается сопоставить эти действия, чтобы создать набор предполагаемых переименований.
На первом этапе этого алгоритма сопоставления рассматриваются OID добавленных и удалённых путей и проверяется их точное соответствие. Такие точные совпадения соединяются в пары.
Вторая стадия — дорогая часть вычислений: как обнаружить файлы, которые были переименованы и отредактированы? Посмотреть каждый добавленный файл и сравните этот файл с каждым удалённым, чтобы вычислить показатель схожести в процентах к общему количеству строк. По умолчанию что-либо, что превышает 50 % общих строк, засчитывается как потенциальное редактирование с переименованием. Алгоритм сравнивает эти пары до момента, пока не найдёт максимальное совпадение.
Вы заметили проблему? Этот алгоритм прогоняет A * D различий, где A — количество добавлений и D — количество удалений, то есть у него квадратичная сложность! Чтобы избежать слишком долгих вычислений по переименованию, Git пропустит часть с обнаружением редактирований с переименованием, если A + D больше внутреннего лимита. Ограничение можно изменить настройкой опции diff.renameLimit в конфигурации. Вы также можете полностью отказаться от алгоритма, просто отключив diff.renames.
Я воспользовался знаниями о процессе обнаружения переименований в своих собственных проектах. Например, форкнул VFS for Git, создал проект Scalar и хотел повторно использовать большое количество кода, но при этом существенно изменить структуру файла. Хотелось иметь возможность следить за историей версий в VFS for Git, поэтому рефакторинг состоял из двух этапов:
Эти два шага позволили мне быстро выполнить git log --follow -- , чтобы посмотреть историю переименовывания.
Я сократил вывод: два этих последних коммита на самом деле не имеют пути, соответствующего Scalar/CommandLine/ScalarVerb.cs, вместо этого отслеживая предыдущий путь GVSF/GVFS/CommandLine/GVFSVerb.cs, потому что Git распознал точное переименование содержимого из коммита fb3a2a36 [RENAME] Rename all files.
Не обманывайтесь больше
Теперь вы знаете, что коммиты — это снапшоты, а не различия! Понимание этого поможет вам ориентироваться в работе с Git.
И теперь мы вооружены глубокими знаниями объектной модели Git. Не важно, какая у вас специализация, frontend, backend, или вовсе fullstack — вы можете использовать эти знания, чтобы развить свои навыки работы с командами Git'а или принять решение о рабочих процессах в вашей команде. А к нам можете приходить за более фундаментальными знаниями, чтобы иметь возможность повысить свою ценность как специалиста или вовсе сменить сферу.
Git — контентно-адресуемая файловая система. Здорово. Что это означает? А означает это, по сути, что Git — простое хранилище ключ-значение. Можно добавить туда любые данные, в ответ будет выдан ключ по которому их можно извлечь обратно.
В качестве примера, воспользуемся служебной командой git hash-object , которая берёт некоторые данные, сохраняет их в виде объекта в каталоге .git/objects (база данных объектов) и возвращает уникальный ключ, который является ссылкой на созданный объект.
Для начала создадим новый Git-репозиторий и убедимся, что каталог objects пуст:
Git проинициализировал каталог objects и создал в нём пустые подкаталоги pack и info . Теперь с помощью git hash-object создадим объект и вручную добавим его в базу Git:
В простейшем случае git hash-object берёт переданный контент и возвращает уникальный ключ, который будет использоваться для хранения данных в базе Git. Параметр -w указывает команде git hash-object не просто вернуть ключ, а ещё и сохранить объект в базе данных. Последний параметр --stdin указывает, что git hash-object должна использовать данные, переданные на стандартный потока ввода; в противном случае команда ожидает путь к файлу в качестве аргумента.
Результат выполнения команды — 40-символьная контрольная сумма. Это SHA-1 хеш — контрольная сумма содержимого и заголовка, который будет рассмотрен позднее. Теперь можно посмотреть как Git хранит ваши данные:
Мы видим новый файл в каталоге objects . Это и есть начальное внутреннее представление данных в Git — один файл на единицу хранения с именем, являющимся контрольной суммой содержимого и заголовка. Первые два символа SHA-1 определяют подкаталог файла внутри objects , остальные 38 — его имя.
Извлечь содержимое объекта можно при помощи команды cat-file . Она подобна швейцарскому ножу для анализа объектов Git. Ключ -p указывает команде cat-file автоматически определять тип объекта и выводить результат в соответствующем виде:
Теперь вы умеете добавлять данные в Git и извлекать их обратно. То же самое можно делать и с файлами. Например, можно проверсионировать один файл. Для начала, создадим новый файл и сохраним его в базе данных Git:
Теперь изменим файл и сохраним его в базе ещё раз:
Теперь в базе содержатся две версии файла, а также самый первый сохранённый объект:
Теперь можно откатить файл к его первой версии:
Однако запоминать хеш для каждой версии неудобно, к тому же теряется имя файла, сохраняется лишь содержимое. Объекты такого типа называют блобами (англ. blob — binary large object). Имея SHA-1 объекта, можно попросить Git показать нам его тип с помощью команды cat-file -t :
Деревья
Следующий тип объектов, который мы рассмотрим, — деревья — решают проблему хранения имён файлов, а также позволяют хранить группы файлов вместе. Git хранит данные сходным с файловыми системами UNIX способом, но в немного упрощённом виде. Содержимое хранится в деревьях и блобах, где дерево соответствует каталогу на файловой системе, а блоб более или менее соответствует inode или содержимому файла. Дерево может содержать одну или более записей, содержащих SHA-1 хеш, соответствующий блобу или поддереву, права доступа к файлу, тип и имя файла. Например, дерево последнего коммита в проекте может выглядеть следующим образом:
Запись master^ указывает на дерево, соответствующее последнему коммиту ветки master . Обратите внимание, что подкаталог lib — не блоб, а указатель на другое дерево:
Вы можете столкнуться с различными ошибками при использовании синтаксиса master^ в зависимости от того, какую оболочку используете.
В Windows CMD символ ^ используется для экранирования, поэтому для исключения ошибок следует использовать двойной символ: git cat-file -p master^^ . В PowerShell параметры, использующие символы <>, должны быть заключены в кавычки: git cat-file -p 'master^' .
В ZSH символ ^ используется для подстановки, поэтому выражение следует помещать в кавычки: git cat-file -p "master^" .
Концептуально, данные хранятся в Git примерно так:
Можно создать дерево самому. Обычно, Git создаёт дерево путём создания набора объектов из состояния области подготовленных файлов или индекса. Поэтому для создания дерева необходимо проиндексировать какие-нибудь файлы. Для создания индекса из одной записи — первой версии файла test.txt — воспользуемся низкоуровневой командой git update-index . Данная команда может искусственно добавить более раннюю версию test.txt в новый индекс. Необходимо передать опции --add , так как файл ещё не существует в индексе (да и самого индекса ещё нет), и --cacheinfo , так как добавляемого файла нет в рабочем каталоге, но он есть в базе данных. Также необходимо передать права доступа, хеш и имя файла:
В данном случае права доступа 100644 — означают обычный файл. Другие возможные варианты: 100755 — исполняемый файл, 120000 — символическая ссылка. Права доступа в Git сделаны по аналогии с правами доступа в UNIX, но они гораздо менее гибки: указанные три режима — единственные доступные для файлов (блобов) в Git (хотя существуют и другие режимы, используемые для каталогов и подмодулей).
Теперь можно воспользоваться командой git write-tree для сохранения индекса в объект дерева. Здесь опция -w не требуется — команда автоматически создаст дерево из индекса, если такого дерева ещё не существует:
Используя ту же команду git cat-file , можно проверить, что созданный объект действительно является деревом:
Давайте создадим новое дерево со второй версией файла test.txt и ещё одним файлом:
Теперь в области подготовленных файлов содержится новая версия файла test.txt и новый файл new.txt. Зафиксируем изменения, сохранив состояние индекса в новое дерево, и посмотрим, что из этого вышло:
Обратите внимание, что в данном дереве находятся записи для обоих файлов, а также, что хеш файла test.txt это хеш «второй версии» этого файла ( 1f7a7a ). Для интереса, добавим первое дерево как подкаталог текущего. Добавлять деревья в область подготовленных файлов можно с помощью команды git read-tree . В нашем случае, чтобы включить уже существующее дерево в индекс и сделать его поддеревом, необходимо использовать опцию --prefix :
Если бы вы сейчас добавили только что сохранённое дерево в рабочий каталог, вы бы увидели два файла в его корне и подкаталог bak с первой версией файла test.txt . В таком случае хранимые структуры данных можно представить следующим образом:
Объекты коммитов
У вас есть три дерева, соответствующих разным состояниям проекта, но предыдущая проблема с необходимостью запоминать все три значения SHA-1, чтобы иметь возможность восстановить какое-либо из этих состояний, ещё не решена. К тому же у нас нет никакой информации о том, кто, когда и почему сохранил их. Такие данные — основная информация, хранимая в объекте коммита.
Для создания коммита необходимо вызвать команду commit-tree и задать SHA-1 нужного дерева и, если необходимо, родительские коммиты. Начнём с создания коммита для самого первого дерева:
Полученный вами хеш будет отличаться, так как отличается дата создания и информация об авторе. Далее в этой главе используйте собственные хеши коммитов и тегов. Просмотреть созданный объект коммита можно командой cat-file :
Далее, создадим ещё два объекта коммита, каждый из которых будет ссылаться на предыдущий:
Каждый из созданных объектов коммитов указывает на одно из созданных ранее деревьев состояния проекта. Вы не поверите, но теперь у нас есть полноценная Git история, которую можно посмотреть командой git log , указав хеш последнего коммита:
Здорово, правда? Мы только что выполнили несколько низкоуровневых операций и получили Git репозиторий с историей без единой высокоуровневой команды. Именно так и работает Git, когда выполняются команды git add и git commit — сохраняет блобы для изменённых файлов, обновляет индекс, создаёт деревья и фиксирует изменения в объекте коммита, ссылающемся на дерево верхнего уровня и предшествующие коммиты. Эти три основных вида объектов Git — блоб, дерево и коммит — сохраняются в виде отдельных файлов в каталоге .git/objects . Вот как сейчас выглядит список объектов в этом каталоге, в комментарии указано чему соответствует каждый из них:
Если пройти по всем внутренним ссылкам, получится граф объектов, представленный на рисунке:
Хранение объектов
Ранее мы упоминали, что вместе с содержимым объекта сохраняется дополнительный заголовок. Давайте посмотрим, как Git хранит объекты на диске. Мы рассмотрим как происходит сохранение блоб объекта — в данном случае это будет строка «what is up, doc?» — в интерактивном режиме на языке Ruby.
Для запуска интерактивного интерпретатора воспользуйтесь командой irb :
Git создаёт заголовок, начинающийся с типа объекта, в данном случае это блоб. Далее идут пробел, размер содержимого в байтах и в конце нулевой байт:
Git объединяет заголовок и оригинальный контент, а затем вычисляет SHA-1 сумму от полученного результата. В Ruby значение SHA-1 для строки можно получить, подключив соответствующую библиотеку командой require и затем вызвав Digest::SHA1.hexdigest() :
Давайте сравним полученный результат с выводом команды git hash-object . Здесь используется echo -n для предотвращения автоматического добавления переноса строки.
Git сжимает новые данные при помощи zlib, в Ruby это можно сделать с помощью одноимённой библиотеки. Сперва необходимо подключить её, а затем вызвать Zlib::Deflate.deflate() :
После этого сохраним сжатую строку в объект на диске. Определим путь к файлу, который будет записан (первые два символа хеша используются в качестве названия каталога, оставшиеся 38 — в качестве имени файла в ней). В Ruby для безопасного создания нескольких вложенных каталогов можно использовать функцию FileUtils.mkdir_p() . Далее, откроем файл вызовом File.open() и запишем сжатые данные вызовом write() для полученного файлового дескриптора:
Теперь проверим содержимое объекта с помощью git cat-file :
Вот и всё, мы создали корректный блоб объект для Git.
Все другие объекты создаются аналогичным образом, меняется лишь запись о типе в заголовке: «blob», «commit» либо «tree». Стоит добавить, что блоб может иметь практически любое содержимое, однако содержимое объектов деревьев и коммитов записывается в очень строгом формате.
Если вас интересует история репозитория начиная с определенного коммита, например 1a410e , то для её отображения вы можете воспользоваться командой git log 1a410e , однако при этом вам всё ещё необходимо помнить хеш коммита 1a410e , который является начальной точкой истории. Было бы неплохо, если бы существовал файл, в который можно было бы сохранить значение SHA-1 под простым именем, а затем использовать это имя вместо хеша SHA-1.
В Git такие файлы называются ссылками («references» или, сокращённо, «refs») и расположены в каталоге .git/refs . В нашем проекте этот каталог пока пуст, но в ней уже прослеживается некая структура:
Чтобы создать новую ссылку, которая поможет вам запомнить SHA-1 последнего коммита, технически, достаточно выполнить примерно следующее:
Теперь в командах Git вместо SHA-1 можно использовать только что созданную ссылку:
Тем не менее, редактировать файлы ссылок вручную не рекомендуется, вместо этого Git предоставляет более безопасную команду update-ref на случай, если вам потребуется изменить ссылку:
Вот что такое, по сути, ветка в Git — простой указатель или ссылка на последний коммит в цепочке. Для создания ветки, соответствующей предыдущему коммиту, можно выполнить следующее:
Данная ветка будет содержать лишь коммиты по указанный, но не те, что были созданы после него:
Теперь база данных Git схематично выглядит так, как показано на рисунке:
При выполнении команды git branch , в действительности Git запускает команду update-ref , которая добавляет SHA-1 хеш последнего коммита текущей ветки в файл с именем указанной ветки.
Как же Git получает хеш последнего коммита при выполнении git branch ? Ответ кроется в файле HEAD.
Файл HEAD — это символическая ссылка на текущую ветку. Символическая ссылка отличается от обычной тем, что она содержит не сам хеш SHA-1, а указатель на другую ссылку.
В некоторых случаях файл HEAD может содержать SHA-1 хеш какого-либо объекта. Это происходит при извлечении тега, коммита или удалённой ветки, что приводит репозиторий в состояние "detached HEAD".
Если вы заглянете внутрь HEAD, то увидите следующее:
Если выполнить git checkout test , Git обновит содержимое файла:
При выполнении git commit Git создаёт коммит, указывая его родителем объект, SHA-1 которого содержится в файле, на который ссылается HEAD.
При желании, можно вручную редактировать этот файл, но лучше использовать команду symbolic-ref . Получить значение HEAD этой командой можно так:
Изменить значение HEAD можно так:
Символическую ссылку на файл вне .git/refs поставить нельзя:
Как мы знаем из главы Основы Git, теги бывают двух типов: аннотированные и легковесные. Легковесный тег можно создать следующей командой:
Вот и всё, легковесный тег — это ветка, которая никогда не перемещается. Аннотированный тег имеет более сложную структуру. При создании аннотированного тега Git создаёт специальный объект и указывающую на него ссылку, а не просто указатель на коммит. Мы можем увидеть это, создав аннотированный тег, используя опцию -a :
В этой статье мы на реальном примере погрузимся во внутренние процессы Git. Если у вас еще не открыт терминал, то сделайте это, пристегните ремни и поехали!
Вам наверняка уже доводилось инициализировать пустой репозиторий с помощью команды git init , но задумывались ли вы: что именно делает эта команда?
Создадим пустую папку, а в ней пустой проект Git. Вот как описывает git init официальная документация Git:
“Эта команда создает пустой репозиторий Git — каталог .git с подкаталогами objects , refs/heads , refs/tags и файлами шаблонов. Также создается начальный файл HEAD , который ссылается на HEAD основной ветви”.
Если мы проверим содержимое папки, то увидим следующую структуру:
Некоторые объекты отсюда мы рассмотрим позже.
В сущности, Git — это контентно-адресуемая файловая система. А если проще — то база данных “ключ-значение”. Вы помещаете любое содержимое в репозиторий, и Git возвращает вам уникальный идентификатор (ключ), которым потом можно воспользоваться для извлечения этого содержимого.
Для хранения значений в базе данных Git применяет команду hash-object :
“Вычисляет значение ID для объекта указанного типа с содержимым именованного файла (который может находиться вне рабочего дерева) и при необходимости записывает полученный объект в базу данных объектов. Сообщает ID объекта в стандартный вывод. Если не указан, то по умолчанию используется значение blob”.
“Blob” — не что иное, как последовательность байтов. Большой двоичный объект (так расшифровывается blob) содержит точные данные в виде файла, но располагается в хранилище данных Git “ключ-значение", в то время как “настоящий” файл хранится в файловой системе.
Создадим такой объект:
Мы воспользовались флагом -w , чтобы действительно записать объект в базу данных объектов, а не только отобразить его (достигается флагом --stdin ).
Значение “hello” — это “значение” в хранилище данных Git, а хэш, возвращаемый функцией hash-object , это ключ. Теперь нам доступна противоположная операция — прочитать значение по его ключу с помощью команды git cat-file :
Можно проверить тип с помощью флага -t :
git hash-object размещает данные в папке .git/objects/ (она же — база данных объектов). Убедимся в этом:
Хэш-суффикс (в каталоге ce ) такой же, как и тот, который мы получили из функции hash-object , но префикс здесь другой. Почему? Дело в том, что имя родительской папки содержит первые два символа нашего ключа. А это уже из-за того, что некоторые файловые системы ограничивают количество возможных подкаталогов. Введение промежуточного слоя смягчает эту проблему.
Сохраним еще один объект:
Как и ожидалось, теперь внутри .git/objects/ есть два каталога:
И опять же, каталог cc , содержащий префикс ключа, содержит остальную часть ключа в имени файла.
Следующий объект Git, который мы рассмотрим, — дерево. Этот тип объекта решает проблему хранения имени файла и позволяет хранить группу файлов вместе.
Древовидный объект содержит записи. Каждая запись — это SHA-1 большого двоичного объекта (blob) или поддерева с соответствующим режимом, типом и именем файла. Определение git-mktree в документации гласит:
“Считывает данные стандартного ввода в нерекурсивном формате вывода ls-tree и создает древовидный объект. mktree нормализует порядок записей внутри дерева, поэтому предварительная сортировка входных данных не требуется. Имя построенного древовидного объекта записывается в стандартный вывод”.
Если вам интересно, что представляет собой формат ls-tree , то он выглядит так:
Давайте теперь свяжем два blob:
mktree возвращает ключ для вновь созданного древовидного объекта.
На этом этапе наше дерево визуализируется следующим образом:
Посмотрим на содержимое дерева:
И конечно, содержимое .git/objects обновилось соответственно:
До сих пор мы еще не обновляли индекс. Для этого воспользуемся командой git-read-tree :
“Считывает информацию о дереве, заданную , в индекс, но фактически не обновляет ни один из файлов, которые “кэширует” (см.: git-checkout-index[1])”.
Обратите внимание — в нашей файловой системе все еще нет файлов, так как значения пишутся непосредственно в хранилище данных Git. Чтобы “проверить” файлы, используем команду git-checkout-index , которая копирует файлы из индекса в рабочее дерево:
-а означает “все”. Теперь у нас появилась возможность увидеть файлы:
git-hash-object выводит не такой SHA, как openssl SHA-1. В чем дело? В том, что для вычислений SHA-1 применяется следующая формула:
Поэтому чтобы получить тот же SHA-1, нужно проделать вот такой трюк:
По ходу статьи мы сохранили два файла непосредственно в хранилище Git. Файлы еще не были видны нашей локальной файловой системе. Мы создали дерево и связали с ним два “больших двоичных объекта”, а затем перенесли файлы в рабочий каталог при помощи команды git-checkout-index .
Читайте также: