Внутреннее устройство
Мы люди уважающие себя, умеем уже пользоваться терминалом (пожалуйста, не коммитьте через веб-интерфейс), давайте заглянем в .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, и все, следов не останется». Это не так, и на этом периодически горят даже опытные ребята, их потом сливают в даркнет.
Что происходит на самом деле:
-
Вы сбрасываете локальную ветку и делаете force-push. Да, ветка на сервере теперь указывает на другой коммит, без ваших секретов.
-
Но старые коммиты никуда не деваются: они остаются в
.git/objectsна сервере, потому что на них могут еще указывать pull request’ы, кэши CI, форки, чей-то reflog. -
GitHub и GitLab держат «висячие» коммиты доступными по прямому URL с хэшем еще долгое время. Условный
github.com/user/repo/commit/3f2a1b0продолжит открываться. -
Если кто-то успел склонировать репозиторий или сделать fork — у него все ваши секреты и в объектах, и в reflog. И он о них скорее всего даже не знает.
-
Боты и сервисы вроде GitHub secret scanning мониторят публичные репозитории в реальном времени и могли скачать секрет за те несколько минут, что он висел в открытом доступе.
Вывод: если утек ключ, токен или пароль — немедленно отзывайте / меняйте сам ключ, а не пытайтесь стереть его из истории. Все считаем данные утекшими, ротируем, идем дальше.
Отдельно почистить историю можно инструментами вроде git filter-repo или BFG Repo-Cleaner и попросить поддержку GitHub удалить кэши — но это уже post-mortem, а не защита. Так что тип все, данные утекли.
Как работает git commit
Рассмотрим такой проект:
Итак, сделаем 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 | Описание |
|---|---|---|
|
|
Представляет директорию |
|
|
Регулярный файл (не исполняемый) |
|
|
Регулярный файл (исполняемый) |
|
|
Симлинка |
|
|
Ссылка на другой вложенный git проект |
| Сама директория не знает о своем имени, поэтому переименование корневой папки репозитория никак не влияет на сам репозиторий |
Да, каждый запуск git add дампит все изменения во внутреннее хранилище: это еще один источник "висячих" коммитов.
|
Теперь запустим git commit. Эта команда создаст еще один сходный прошлым файл в .git/objects — файл с метаинформацией коммита (хэш tree-объекта, соответствующий текущему состоянию корня репозитория, хэши родителей, автор, коммитер, сообщение и тело)
Так как коммиты мы делаем на какой-то ветке, то ее ref файл обновится. HEAD же не меняется: там только ссылка на какой ветке мы находим в данный момент.
Давайте изменим файл f0 и запустим git add: он обновит только измененные файлы, а старые переиспользует.
Ну и комманда git commit, как и раньше, создаст файл коммита и подвинет указатели.
Осталось упомянуть только индекс. Он хранится в файле .git/index. Там содержится информация о сохраненных изменениях:
-
Метаинформация файла (
stat) — ее быстро проверить и если она не изменилась, то и файл тоже (иначе бы поменялось время последнего изменения) -
Хэш содержимого — если метаинформация изменилась, то гит сравнит хэш текущего файла с хэшом в индексе, чтобы проверить наличие изменений