Внутреннее устройство

Эта страница написана следующими людьми:

Мы люди уважающие себя, умеем уже пользоваться терминалом (пожалуйста, не коммитьте через веб-интерфейс), давайте заглянем в .git/. Понимание внутренностей сильно упрощает жизнь: становится ясно, почему checkout быстрый, что такое хэш и как reflog спасает после reset --hard.

.git/objects/

Здесь лежат все данные репозитория: коммиты, деревья, содержимое файлов. Каждый объект это gzip-сжатый файл, названный своим SHA-1 хэшем. Первые 2 символа хэша  —  имя папки, остальные  —  имя файла:

$ ls .git/objects
3f/ c4/ d5/ ...
$ ls .git/objects/3f
2a1b0e5d8f4a9c...
Возникает вопрос, а зачем раскладывать все по дополнительным папкам. Оказывается, что большие репозитории оперируют десятками тысяч файлов, что приводит к медленному поиску в директории. Из-за свойств хэш-функции файлы более-менее равно распределяются по папкам и вместо одного долгого поиска мы сделаем два медленных.

Посмотреть содержимое объекта:

$ git cat-file -t 3f2a1b0     (1)
$ git cat-file -p 3f2a1b0     (2)
1 Тип объекта: blob, tree, commit, tag.
2 Содержимое (уже разжатое).

Всего 4 типа объектов:

  • blob  —  содержимое файла (без имени файла  —  имя лежит в дереве);

  • tree  —  список файлов и подпапок (имена + хэши blob’ов и поддеревьев);

  • commit  —  указатель на tree корня + автор + сообщение + хэши родителей;

  • tag  —  аннотированный тег.

Обычная ветка main при этом это просто файл .git/refs/heads/main, содержащий хэш коммита. Все, никакой магии.

Pack-файлы и git gc

Если хранить по отдельному файлу на каждый объект, репозиторий разрастется до миллионов файлов. Git время от времени пакует объекты в компактные pack-файлы (.git/objects/pack/) и удаляет свободные копии. Такие файлы вместо хранения всех версий файлов, хранят кусочки файлов и информацию о том, как их надо объединить, чтобы получить файл для конкретного коммита  —  похоже на то, как файлы хранятся в файловых системах.

Вручную запустить упаковку и уборку мусора:

$ git gc

gc заодно удаляет недостижимые объекты  —  те, до которых нельзя добраться ни от одной ветки, тега или reflog. Например, коммиты, которые вы сбросили через git reset --hard, еще какое-то время лежат в .git/objects (и их можно восстановить), но потом gc их окончательно удалит.

.git/refs/

Все ссылки: ветки, теги, remote-ветки.

$ ls .git/refs
heads/    remotes/    tags/
$ cat .git/refs/heads/main
3f2a1b0e5d8f4a9c...

Файл .git/HEAD содержит либо ref: refs/heads/branch-name (когда HEAD указывает на ветку), либо сразу хэш коммита (detached HEAD).

.git/logs/ и reflog

Здесь лежит reflog  —  лог всех движений HEAD и веток. Каждый раз, когда вы делаете commit, checkout, reset, merge, rebase, в reflog записывается строчка.

$ git reflog
c4d5e6f HEAD@{0}: commit: Add login
3f2a1b0 HEAD@{1}: reset: moving to HEAD~1
5a6b7c8 HEAD@{2}: commit: Broken commit

Reflog локальный, живет около 90 дней и это ваша страховка от катастроф. Даже если вы сделали git reset --hard и «потеряли» коммиты, они еще в reflog  —  можно восстановить:

$ git reset --hard HEAD@{2}

Ваня, потерявший свой detached HEAD, скорее всего мог все вернуть через reflog. Знал бы  —  не потерял бы полдня.

Внимание: git push -f не гарантирует удаление данных на сервере

Есть распространенное заблуждение: «я запушил секретный токен в репозиторий, сейчас сделаю git reset + git push --force, и все, следов не останется». Это не так, и на этом периодически горят даже опытные ребята, их потом сливают в даркнет.

Что происходит на самом деле:

  1. Вы сбрасываете локальную ветку и делаете force-push. Да, ветка на сервере теперь указывает на другой коммит, без ваших секретов.

  2. Но старые коммиты никуда не деваются: они остаются в .git/objects на сервере, потому что на них могут еще указывать pull request’ы, кэши CI, форки, чей-то reflog.

  3. GitHub и GitLab держат «висячие» коммиты доступными по прямому URL с хэшем еще долгое время. Условный github.com/user/repo/commit/3f2a1b0 продолжит открываться.

  4. Если кто-то успел склонировать репозиторий или сделать fork  —  у него все ваши секреты и в объектах, и в reflog. И он о них скорее всего даже не знает.

  5. Боты и сервисы вроде GitHub secret scanning мониторят публичные репозитории в реальном времени и могли скачать секрет за те несколько минут, что он висел в открытом доступе.

Вывод: если утек ключ, токен или пароль  —  немедленно отзывайте / меняйте сам ключ, а не пытайтесь стереть его из истории. Все считаем данные утекшими, ротируем, идем дальше.

Отдельно почистить историю можно инструментами вроде git filter-repo или BFG Repo-Cleaner и попросить поддержку GitHub удалить кэши  —  но это уже post-mortem, а не защита. Так что тип все, данные утекли.

Как работает git commit

Рассмотрим такой проект:

commit 0
Figure 1. Начальное состояние проекта

Итак, сделаем git add. Гит сохранит дамп всей папки в .git/objects:

Файлы

Превратятся в blob объекты: к содержимому файлу добавится хедер blob <SIZE>\0, где вместо <SIZE> сохранится информация о размере содержимого файла, после чего от полученного содержимого будет вычислен sha-1 хэш. По полученному пути (хэш) будет сохранен файл, содержащий сжатую информацию выше.

Папки

Превратятся в tree объекты: файлы аналогичные блобам, но содержимым является последовательность строк, каждая из которых имеет формат mode-padding-hash-name. mode  —  тип файла (040000, 100755, 100644, 120000, 160000), padding  —  просто набор байт для выравнивания, hash  —  хэш объекта, name  —  его имя.

Тип файла Mode Описание

tree

040000

Представляет директорию

blob

100644

Регулярный файл (не исполняемый)

blob

100755

Регулярный файл (исполняемый)

link

120000

Симлинка

ref

160000

Ссылка на другой вложенный git проект

Сама директория не знает о своем имени, поэтому переименование корневой папки репозитория никак не влияет на сам репозиторий
commit 1
Figure 2. Гит сохранил файлы во внутреннем хранилище
Да, каждый запуск git add дампит все изменения во внутреннее хранилище: это еще один источник "висячих" коммитов.

Теперь запустим git commit. Эта команда создаст еще один сходный прошлым файл в .git/objects  —  файл с метаинформацией коммита (хэш tree-объекта, соответствующий текущему состоянию корня репозитория, хэши родителей, автор, коммитер, сообщение и тело)

commit 2
Figure 3. Появился коммит-файл, указывающий на корень

Так как коммиты мы делаем на какой-то ветке, то ее ref файл обновится. HEAD же не меняется: там только ссылка на какой ветке мы находим в данный момент.

commit 3
Figure 4. Добавились указатели

Давайте изменим файл f0 и запустим git add: он обновит только измененные файлы, а старые переиспользует.

commit 4
Figure 5. Обновили содержимое

Ну и комманда git commit, как и раньше, создаст файл коммита и подвинет указатели.

commit 5
Figure 6. Обновление ветки

Осталось упомянуть только индекс. Он хранится в файле .git/index. Там содержится информация о сохраненных изменениях:

  • Метаинформация файла (stat)  —  ее быстро проверить и если она не изменилась, то и файл тоже (иначе бы поменялось время последнего изменения)

  • Хэш содержимого  —  если метаинформация изменилась, то гит сравнит хэш текущего файла с хэшом в индексе, чтобы проверить наличие изменений

Read more

Ссыль на код из лекций, где работаем с бин форматами