Git под капотом: объекты, хэши, refs, индекс
В краткой версии учебника Git появлялся как «папка с историей»: коммить, пушь, не мешай другим. Этого хватает, чтобы не сломать чужой код, но не хватает, чтобы чувствовать себя уверенно, когда что-то пошло не так. А в Git что-то «пошло не так» постоянно: конфликт при rebase, потерянный коммит после reset, ветка, которая «внезапно» указывает не туда.
Хорошая новость: у Git нет скрытого состояния. Всё, что Git знает о твоём проекте, лежит в обычной директории .git/ внутри репозитория — в виде обычных файлов. Ты можешь открыть их текстовым редактором и прочитать. Поняв устройство этой директории, ты перестанешь бояться Git и начнёшь им управлять.
В этой главе разбираем фундамент: объектную модель, хэши SHA-1, refs и index-файл. Это самая теоретическая глава раздела — и самая важная. Дальнейшие главы про staging, ветки и merge/rebase будут опираться на термины отсюда.
Content-addressable storage: база данных по содержимому
Заголовок раздела «Content-addressable storage: база данных по содержимому»Git — это не система контроля версий файлов. Это ключ-значение база данных, где:
- ключ — SHA-1 хэш (40 hex-символов, например
ce013625030ba8dba906f756967f9e9ca394464a); - значение — объект: кусок данных (файл), дерево директории, коммит или тег.
Ключевая идея: адрес объекта вычисляется из его содержимого. Изменил файл на один байт — получил совершенно другой хэш, то есть другой объект. Хэш одинакового содержимого всегда один и тот же, в любом репозитории мира. Это называется content-addressable storage (первоисточник по объектной модели — Pro Git, глава «Git изнутри»).
┌──────────────────────────────────────────────────────┐│ РЕПОЗИТОРИЙ = БАЗА ОБЪЕКТОВ ││ ││ "hello" ──sha1──▶ b6fc4c620b67d95f953a5c1c1230aa ││ │ ││ ▼ ││ ┌───────────┐ ││ │ blob │ ││ └───────────┘ ││ ││ хэш = sha1(заголовок + содержимое) ││ заголовок = "blob 5\0" (тип + размер + \0) │└──────────────────────────────────────────────────────┘Проверь руками — Git считает хэш, не создавая объект:
echo -n "hello" | git hash-object --stdin# b6fc4c620b67d95f953a5c1c1230aa9785ebb8bd... (полный хэш)
printf 'blob 5\0hello' | sha1sum# тот же хэш — Git просто добавляет заголовок "тип размер\0"Из-за этой модели дедупликация бесплатна: десять одинаковых файлов под разными именами — один объект; сто версий проекта, где изменился один файл — один новый blob плюс новая цепочка tree/commit. Никаких дельт на уровне хранения — только снапшоты, а сжатие и дельты появляются позже на этапе упаковки (packfiles).
Четыре объекта: blob, tree, commit, tag
Заголовок раздела «Четыре объекта: blob, tree, commit, tag»Git хранит ровно четыре типа объектов. Схема их связей — это сердце всей системы:
┌──────────────┐ │ commit │ кто, когда, сообщение, │ (автор, │ указатель на дерево │ дата, msg) │ и на родителя(ов) └──────┬───────┘ │ tree ▼ ┌──────────────┐ ┌─────────▶│ tree │ список записей: │ │ (имя + hash +│ имя файла/папки, │ │ тип + режим)│ хэш blob/tree, режим │ └──────┬───────┘ │ │ ┌─────┴─────┐ │ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │ blob │ │ blob │ │ tree │──▶ подпапки — │ "..." │ │ "..." │ │(src/…) │ рекурсивно! └────────┘ └────────┘ └────────┘
┌──────────────┐ │ tag │ аннотированный тег: имя, сообщение, │ ──▶ commit │ указатель на коммит └──────────────┘Blob — просто содержимое файла без имени. Имя живёт в дереве, не в blob: переименование файла не создаёт новый blob, если содержимое то же.
Tree — аналог директории: список записей вида режим тип хэш\tимя. Режим 100644 — обычный файл, 100755 — исполняемый, 040000 — поддиректория (вложенное tree), 160000 — gitlink (подмодуль).
Commit — снапшот (указатель на корневое tree) плюс метаданные: автор, коммиттер, дата, сообщение и список родителей. Один родитель — обычный коммит, два — merge commit, ноль — начальный.
Tag — легковесный тег — это просто ref (файл со строкой хэша). Аннотированный тег — полноценный объект, который хранит имя, сообщение и указатель на коммит; именно его подписывают GPG-подписью релизы.
Итого цепочка достижимости выглядит так:
refs/heads/main (файл со строкой) │ ▼ commit A ──parent──▶ commit B ──parent──▶ commit C │ │ │ ▼ ▼ ▼ tree A tree B tree C │ │ │ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ ▼ ▼ ▼ ▼ ▼ ▼ blob a blob b blob a' blob b blob a blob c (неизменённые blob общие!)Смотреть объекты можно без «волшебства», команда git cat-file (см. git-cat-file(1)):
git cat-file -t ce01362 # узнать ТИП объекта: blobgit cat-file -p ce01362 # напечатать СОДЕРЖИМОЕ: "hello"git cat-file -p HEAD # посмотреть текущий коммитgit cat-file -p HEAD^{tree} # дерево этого коммитаgit cat-file -p HEAD:src/index.js # blob файла из коммитаЧто лежит в .git/objects и зачем packfiles
Заголовок раздела «Что лежит в .git/objects и зачем packfiles»Создай тестовый репозиторий и посмотри, что появляется в .git/objects:
mkdir sandbox && cd sandbox && git initecho "v1" > app.txtgit add app.txtfind .git/objects -type f# .git/objects/ce/013625030ba8dba906f756967f9e9ca394464aОбъект хранится в файле по пути .git/objects/<первые 2 символа>/<оставшиеся 38>. Первые два символа — просто «папка-распределитель», чтобы в одной директории не лежало миллионов файлов. Сам файл — содержимое объекта, сжатое zlib. Распаковать можно хоть Python-ом из трёх строк:
python3 -c "import zlib, sysprint(zlib.decompress(open('.git/objects/ce/013625...','rb').read()))"# blob 2\0v1Если бы объекты всегда лежали «по одному файлу на объект», большие репозитории бы разрослись до неприличных размеров: тысячи версий больших файлов, каждая — отдельным файлом на диске. Поэтому Git периодически упаковывает объекты: git gc (см. git-gc(1)) (или автоматический gc при push/fetch) собирает объекты в packfile — один бинарный файл .git/objects/pack/pack-*.pack, где объекты хранатся дельтами друг от друга (как цепочки «объект → база + дельта»), плюс .idx — индекс для быстрого поиска по хэшу.
ДО gc: ПОСЛЕ gc:.git/objects/ .git/objects/├── 3b/18e512... blob v1 ├── pack/├── ce/013625... blob v2 │ ├── pack-a1b2c3.pack ← все объекты,├── d8/29f0a2... tree │ │ сжатые дельтами├── 41/aa1c02... commit A │ └── pack-a1b2c3.idx ← индекс хэш→offset├── 9c/2d5a8f... commit B └── info/└── 71/bf2c1d... commit C └── refs ← список, что «доступно» (для gc)git gc не удаляет объекты, до которых можно дойти по refs — только «мусор», недостижимый ни из одной ветки, ни из reflog (а reflog держит почти всё, см. главу про merge/rebase). Запускать gc вручную почти никогда не нужно — Git делает это сам, когда число «рыхлых» объектов переваливает порог.
Refs: HEAD, ветки, теги, remote-refs
Заголовок раздела «Refs: HEAD, ветки, теги, remote-refs»Объекты неизменяемы, но жизнь идёт: код меняется. Изменяемым «верхушкам» истории соответствуют ссылки (refs) — изменяемые указатели на объекты. Все refs лежат в .git/refs/, а HEAD — в .git/HEAD. Это обычные текстовые файлы:
cat .git/HEADcat .git/refs/heads/main# 9c2d5a8f3b3e4d5c6b7a8091a2b3c4d5e6f70819 ← 41 байт: 40 hex + \nВетка — это ровно этот 41-байтовый файл. Создать ветку вручную = создать файл со строкой хэша:
git branch feature # командаecho "9c2d5a8f..." > .git/refs/heads/feature # то же самое рукамиПолная картина refs:
.git/├── HEAD # "ref: refs/heads/main" ← где мы сейчас├── refs/│ ├── heads/│ │ ├── main # "9c2d5a8f..." ветки: двигаются с commit'ами│ │ └── feature # "71bf2c1d..."│ ├── tags/│ │ └── v1.0.0 # аннотир. тег → объект tag → commit│ └── remotes/│ └── origin/│ ├── main # "41aa1c02..." где main БЫЛ на сервере│ └── HEAD # куда смотрит origin по умолчанию└── packed-refs # устаревшие refs после gc — одним файломКлючевые свойства:
- HEAD — указатель «где мы сейчас». Обычно это символическая ссылка на ветку (
ref: refs/heads/main), то есть HEAD — «указатель на указатель». Приgit commitдвигается ветка, на которую ссылается HEAD, а HEAD остаётся на месте. В detached HEAD HEAD содержит напрямую хэш коммита — об этом подробно в главе про ветвление. - Ветки (
refs/heads/*) — локальные истории разработки. Перемещаются при каждом коммите в эту ветку. - Теги (
refs/tags/*) — неподвижные имена для релизов. Git никогда не двигает их сам. - Remote-refs (
refs/remotes/origin/*) — «память» о том, где ветки находились на сервере в момент последнегоfetch/push. Именно поэтомуgit statusможет сказать «your branch is ahead of origin/main by 2 commits» — он сравниваетrefs/heads/mainсrefs/remotes/origin/main, не делая сетевых запросов. - packed-refs — когда refs становится много (десятки тысяч веток на серверах), Git упаковывает их в один файл. Формат строки:
<хэш> <путь>.
Index: как устроена staging-зона
Заголовок раздела «Index: как устроена staging-зона»Третья часть устройства Git — index (или staging area, или cache — три имени одной сущности). Файл .git/index — бинарная структура, описывающая снимок следующего коммита: список записей с путями файлов, их режимами, SHA-1 из HEAD-дерева и SHA-1 «добавленного» содержимого, плюс метаданные (время, inode — для быстрой проверки git status).
Команда git add file.txt делает ровно три вещи:
- Сжимает содержимое
file.txtв blob и пишет его в.git/objects— объект уже в базе, до коммита. - Считает хэш этого blob.
- Записывает в index запись: путь
file.txt, режим100644, хэш нового blob.
рабочая директория index (.git/index) HEAD (последний коммит) ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ app.txt "v2" │ │ app.txt 100644 │ │ app.txt 100644 │ │ (твои правки) │ ──▶ │ ce01362… │ │ 3b18e51… │ └─────────────────────┘ git │ (готов к коммиту) │ │ (снимок коммита) │ add └──────────────────────┘ └──────────────────────┘ commit пишет: tree из index → commit → двигает веткуПри этом index не обязан совпадать ни с рабочей директорией, ни с HEAD — это самостоятельная третья область. Ты можешь добавить в index половину правок (построчно, через git add -p), закоммитить, а вторую половину оставить в рабочей директории для следующего коммита. Всё это разбираем по командам в следующей главе.
Проверить index можно через plumbing-команду git ls-files (см. git-ls-files(1)):
git ls-files --stage# 100644 ce013625030ba8dba906f756967f9e9ca394464a 0 app.txt# режим хэш blob stage путьКолонка «stage» — про конфликты слияния: в обычном состоянии всегда 0; при конфликте в index одновременно живут stage 1 (база), 2 (наша версия) и 3 (ихняя) — эту механику ты увидишь в действии в главе про merge/rebase.
Plumbing vs porcelain
Заголовок раздела «Plumbing vs porcelain»Две «высоты» команд Git:
- Porcelain — команды для людей:
add,commit,status,log,merge. Удобные, с флагами, с цветным выводом. - Plumbing — команды для скриптов и для понимания внутренностей:
hash-object,cat-file,update-ref,write-tree,commit-tree,ls-files,for-each-ref. Простые, предсказуемые, стабильный вывод.
Вся «магия» porcelain раскладывается на plumbing. Демо-сессия — создадим коммит вручную, как это делает сам Git:
# 1. пишем blob в базуBLOB=$(echo "v1" | git hash-object -w --stdin)
# 2. из index строим treegit update-index --add --cacheinfo 100644,$BLOB,app.txtTREE=$(git write-tree)
# 3. создаём commit-объектCOMMIT=$(echo "ручной коммит" | git commit-tree $TREE)
# 4. двигаем ветку на новый коммитgit update-ref refs/heads/main $COMMITЧетыре команды вместо одной git commit — но теперь ты знаешь, что происходит внутри. По такому же принципу устроены хуки, скрипты CI и инструменты вроде Git LFS.
Связь с соседними темами
Заголовок раздела «Связь с соседними темами»- Ветвление — «ветка = файл со строкой хэша» из этой главы превращается в полноценную механику HEAD, detached HEAD и switch/restore.
- Staging и коммиты — index-файл и write-путь
git addздесь; там — все команды манипуляции им. - Merge/rebase — конфликты читаются прямо из index (stage 1/2/3), reflog спасает от любых передвижений refs, packfiles и gc объясняют, почему «удалённые» объекты ещё живы.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Удалять
.git, чтобы «починить» репозиторий. Ты уничтожаешь всю историю, все ветки, reflog — всё. Почти любая проблема решается черезgit reflogиgit reset, не трогая.git. - Думать, что переименование файла создаёт новый объект. Содержимое то же — blob тот же, меняется только запись в tree. Поэтому
git log --followи легковесные rename-диффы работают. - Редактировать объекты в
.git/objectsруками. Одно изменение байта — и хэш не сходится: Git заподозрит повреждение базы. Правь только refs (текст) и пользуйся plumbing-командами. - Путать remote-refs с сервером.
origin/main— это локальный файл, снимок на момент последнего fetch. Сервер мог давно уйти вперёд; актуальность проверяется толькоgit fetch. - Бояться
git gc. Он не удаляет достижимые объекты и не трогает refs/reflog. Единственный риск — если ты вручную чистил reflog и оставил «висячие» объекты, которые хотел сохранить. - Хранить большие бинарники в Git, потому что «Git всё сожмёт». Дельта-сжатие в packfile плохо работает со случайными данными (архивы, картинки, видео). Репозиторий раздувается навсегда — объекты остаются в истории даже после удаления файла.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое content-addressable storage и почему Git на нём построен? Хранилище, где ключ объекта — хэш его содержимого. Даёт бесплатную дедупликацию, неизменяемость объектов и целостность: любое повреждение данных детектируется несходством хэша.
- Какие объекты существуют в Git и как они связаны? Blob (содержимое файла), tree (список имён→хэшей+режимов), commit (tree + родители + метаданные), tag (указатель на commit с аннотацией). Commit→tree→blobs/деревья рекурсивно.
- Чем ветка отличается от коммита? Коммит — неизменяемый объект в базе. Ветка — изменяемый ref, файл со строкой хэша коммита; двигается при новых коммитах.
- Что происходит при
git addфизически? Содержимое сжимается в blob и пишется в.git/objects, в index добавляется запись путь→хэш blob. Коммит ещё не создан. - Что такое packfile и когда создаётся?
Бинарный файл
.packсо множеством объектов, сжатых дельтами друг от друга. Создаётсяgit gc(или автогц) для экономии места и ускорения клонирования. - Зачем нужен index, если есть HEAD и рабочая директория?
Index — снимок будущего коммита, позволяет собрать коммит частями (
add -p), разделяет «что я сделал» (workdir) от «что я зафиксировал для коммита» (index) от «что уже в истории» (HEAD). - Откуда
git statusзнает, что ты ahead of origin/main, без сети? Сравниваетrefs/heads/mainсrefs/remotes/origin/main— локальным снимком состояния remote на момент последнего fetch/push.
Практика
Заголовок раздела «Практика»- В песочном репозитории создай файл, посчитай
git hash-object --stdin, потом добавь его и найди файл в.git/objectsпо первым двум символам хэша. Распакуй содержимое черезpython3 + zlibи сверь с ожидаемым заголовкомblob <размер>\0. - Собери коммит вручную из четырёх plumbing-команд (
hash-object -w,update-index,write-tree,commit-tree,update-ref). Критерий:git logпоказывает его как обычный коммит, аgit fsckне ругается. - Создай файл, закоммить, измени один байт. Объясни, какие объекты появились в
.git/objectsи почему старый blob остался нетронутым. Проверьgit cat-file -pобоих хэшей. - Напиши alias или скрипт, который выводит «содержимое» index (
git ls-files --stage) в человекочитаемом виде: путь, короткий хэш, stage. Критерий: послеgit addновая запись видна, послеgit commitхэш в index совпадает с хэшем blob изHEAD:путь. - Сделай
git gcв репозитории с несколькими коммитами и посмотри на.git/objects/pack/. Найди соответствующий.idxфайл и объясни, зачем он нужен. Критерий:git verify-pack -vперечисляет объекты пака. - Создай ветку двумя способами:
git branchи руками через создание файла в.git/refs/heads/. Проверьgit branch --listиgit for-each-ref. Объясни, почему второй способ «легален».
Что почитать
Заголовок раздела «Что почитать»- Pro Git, глава 10: Git Internals — главный первоисточник по объектной модели, refs и index.
- Git Internals — демистификация структуры объектов (freeCodeCamp) — короткий практический тур по
.git/objectsс примерами. - The Git Object Model (GitHub Blog, старая но точная серия) — исторический контекст и ментальные модели.
- gitrepository-layout(5) — man-страница — официальное описание каждого файла и папки в
.git/. - Git Revisions (документация) — полный синтаксис
HEAD~2^{tree}:path, который используетcat-file. - zlib RFC 1950 — формат сжатия, в котором хранятся loose-объекты, если захочешь распаковывать без Git.