Перейти к содержимому

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).

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 # узнать ТИП объекта: blob
git 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:

Окно терминала
mkdir sandbox && cd sandbox && git init
echo "v1" > app.txt
git add app.txt
find .git/objects -type f
# .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

Объект хранится в файле по пути .git/objects/<первые 2 символа>/<оставшиеся 38>. Первые два символа — просто «папка-распределитель», чтобы в одной директории не лежало миллионов файлов. Сам файл — содержимое объекта, сжатое zlib. Распаковать можно хоть Python-ом из трёх строк:

Окно терминала
python3 -c "
import zlib, sys
print(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) — изменяемые указатели на объекты. Все refs лежат в .git/refs/, а HEAD — в .git/HEAD. Это обычные текстовые файлы:

refs/heads/main
cat .git/HEAD
cat .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 упаковывает их в один файл. Формат строки: <хэш> <путь>.

Третья часть устройства Git — index (или staging area, или cache — три имени одной сущности). Файл .git/index — бинарная структура, описывающая снимок следующего коммита: список записей с путями файлов, их режимами, SHA-1 из HEAD-дерева и SHA-1 «добавленного» содержимого, плюс метаданные (время, inode — для быстрой проверки git status).

Команда git add file.txt делает ровно три вещи:

  1. Сжимает содержимое file.txt в blob и пишет его в .git/objects — объект уже в базе, до коммита.
  2. Считает хэш этого blob.
  3. Записывает в 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.

Две «высоты» команд 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 строим tree
git update-index --add --cacheinfo 100644,$BLOB,app.txt
TREE=$(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 объясняют, почему «удалённые» объекты ещё живы.
  1. Удалять .git, чтобы «починить» репозиторий. Ты уничтожаешь всю историю, все ветки, reflog — всё. Почти любая проблема решается через git reflog и git reset, не трогая .git.
  2. Думать, что переименование файла создаёт новый объект. Содержимое то же — blob тот же, меняется только запись в tree. Поэтому git log --follow и легковесные rename-диффы работают.
  3. Редактировать объекты в .git/objects руками. Одно изменение байта — и хэш не сходится: Git заподозрит повреждение базы. Правь только refs (текст) и пользуйся plumbing-командами.
  4. Путать remote-refs с сервером. origin/main — это локальный файл, снимок на момент последнего fetch. Сервер мог давно уйти вперёд; актуальность проверяется только git fetch.
  5. Бояться git gc. Он не удаляет достижимые объекты и не трогает refs/reflog. Единственный риск — если ты вручную чистил reflog и оставил «висячие» объекты, которые хотел сохранить.
  6. Хранить большие бинарники в Git, потому что «Git всё сожмёт». Дельта-сжатие в packfile плохо работает со случайными данными (архивы, картинки, видео). Репозиторий раздувается навсегда — объекты остаются в истории даже после удаления файла.
  1. Что такое content-addressable storage и почему Git на нём построен? Хранилище, где ключ объекта — хэш его содержимого. Даёт бесплатную дедупликацию, неизменяемость объектов и целостность: любое повреждение данных детектируется несходством хэша.
  2. Какие объекты существуют в Git и как они связаны? Blob (содержимое файла), tree (список имён→хэшей+режимов), commit (tree + родители + метаданные), tag (указатель на commit с аннотацией). Commit→tree→blobs/деревья рекурсивно.
  3. Чем ветка отличается от коммита? Коммит — неизменяемый объект в базе. Ветка — изменяемый ref, файл со строкой хэша коммита; двигается при новых коммитах.
  4. Что происходит при git add физически? Содержимое сжимается в blob и пишется в .git/objects, в index добавляется запись путь→хэш blob. Коммит ещё не создан.
  5. Что такое packfile и когда создаётся? Бинарный файл .pack со множеством объектов, сжатых дельтами друг от друга. Создаётся git gc (или автогц) для экономии места и ускорения клонирования.
  6. Зачем нужен index, если есть HEAD и рабочая директория? Index — снимок будущего коммита, позволяет собрать коммит частями (add -p), разделяет «что я сделал» (workdir) от «что я зафиксировал для коммита» (index) от «что уже в истории» (HEAD).
  7. Откуда git status знает, что ты ahead of origin/main, без сети? Сравнивает refs/heads/main с refs/remotes/origin/main — локальным снимком состояния remote на момент последнего fetch/push.
  1. В песочном репозитории создай файл, посчитай git hash-object --stdin, потом добавь его и найди файл в .git/objects по первым двум символам хэша. Распакуй содержимое через python3 + zlib и сверь с ожидаемым заголовком blob <размер>\0.
  2. Собери коммит вручную из четырёх plumbing-команд (hash-object -w, update-index, write-tree, commit-tree, update-ref). Критерий: git log показывает его как обычный коммит, а git fsck не ругается.
  3. Создай файл, закоммить, измени один байт. Объясни, какие объекты появились в .git/objects и почему старый blob остался нетронутым. Проверь git cat-file -p обоих хэшей.
  4. Напиши alias или скрипт, который выводит «содержимое» index (git ls-files --stage) в человекочитаемом виде: путь, короткий хэш, stage. Критерий: после git add новая запись видна, после git commit хэш в index совпадает с хэшем blob из HEAD:путь.
  5. Сделай git gc в репозитории с несколькими коммитами и посмотри на .git/objects/pack/. Найди соответствующий .idx файл и объясни, зачем он нужен. Критерий: git verify-pack -v перечисляет объекты пака.
  6. Создай ветку двумя способами: git branch и руками через создание файла в .git/refs/heads/. Проверь git branch --list и git for-each-ref. Объясни, почему второй способ «легален».