Ты делал git add - и не задумывался, куда улетел файл. Ты делал git push - и репозиторий на сервере однажды раздулся до десятков гигабайт, хотя кода там на сотню мегабайт. Ты запускал git status в большом проекте и ждал несколько секунд на ровном месте. Все три боли растут из одного места: из того, КАК Git физически хранит индекс и объекты на диске.
В прошлых уроках мы разобрали объектную модель: blob, tree, commit, tag, адресация по хешу. Теперь спустимся ещё на уровень ниже - туда, где живут бинарный файл индекса, упакованные объекты (packfiles) и машинерия обслуживания: gc, repack, prune, commit-graph, maintenance. Это не абстрактная теория. Это ровно те механизмы, которые ты будешь чинить, когда репозиторий станет тормозить или распухать. Поехали внутрь.

git index file: что такое staging на самом деле
Когда говорят "staging area" или "индекс", имеют в виду один конкретный бинарный файл: .git/index. Это не абстракция, это файл, который можно прочитать побайтово. В нём лежит плоский отсортированный список записей - по одной на каждый отслеживаемый путь. Каждая запись хранит не сам контент, а ссылку: SHA blob-объекта плюс метаданные файла.
Зачем метаданные? Чтобы git status работал быстро. В каждой записи индекса лежит stat-кеш: ctime, mtime, размер, inode, device, режим прав. Когда ты делаешь git status, Git не читает и не хеширует каждый файл рабочего дерева заново - это было бы дорого. Он берёт stat-данные файла на диске и сравнивает с тем, что записано в индексе. Совпали mtime и размер - файл считается неизменным, его контент даже не трогается. Вот почему индекс - это кеш, а не просто список: он экономит тебе тысячи операций хеширования на каждом status.
Посмотрим на индекс глазами Git:
Код: Выделить всё
$ git ls-files --stage
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 README.md
100644 a1b2c3d4e5f6071829abcdef0123456789abcdef 0 src/main.go
100755 0f1e2d3c4b5a69788796a5b4c3d2e1f009182736 0 scripts/run.sh
Хочешь увидеть сырые stat-данные, которые делают status быстрым:
Код: Выделить всё
$ git ls-files --debug README.md
README.md
ctime: 1718200000:0
mtime: 1718200000:0
dev: 16777220 ino: 12943219
uid: 501 gid: 20
size: 0 flags: 0
Stage 1/2/3: индекс во время конфликта
То самое число 0 в выводе ls-files - это номер слота. В обычной жизни у пути ровно один слот - stage 0. Но во время конфликта слияния Git временно расширяет индекс: один путь занимает три записи с одинаковым именем, но разными номерами:
- stage 1 - общий предок (base), как было в точке расхождения
- stage 2 - "наша" версия (ours, текущая ветка)
- stage 3 - "их" версия (theirs, та, что вливаем)
Код: Выделить всё
$ git ls-files --stage
100644 d670460b4b4aece5915caf5c68d12f560a9fe3e4 1 config.yml
100644 8a9f0c2b1e3d4a5f6071829a0b1c2d3e4f506172 2 config.yml
100644 f1e2d3c4b5a697887966a5b4c3d2e1f009182736 3 config.yml
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 README.md
Низкоуровневый рычаг управления индексом - git update-index. Им можно добавить запись, убрать, переключить assume-unchanged или skip-worktree. Полезный приём: пометить локально правленый, но не коммитимый конфиг.
Код: Выделить всё
$ git update-index --skip-worktree local.conf
packfile git: дельта-сжатие вместо россыпи объектов
Каждый git add и git commit создаёт loose-объекты - отдельные файлы в .git/objects/ab/cdef..., каждый сжат zlib. Это удобно для записи: добавить объект - просто положить файл. Но катастрофично для масштаба. Сделай 50 коммитов в файл на мегабайт - получишь 50 почти одинаковых blob, каждый сжат независимо, суммарно десятки мегабайт. Loose-хранилище не умеет замечать, что версии похожи.
Решение - packfile. Это два файла рядом в .git/objects/pack/: pack-<hash>.pack и pack-<hash>.idx. В .pack лежат объекты, но не просто сжатые - многие хранятся как дельта, то есть как разница относительно другого, похожего объекта. Git берёт похожие объекты (две версии одного файла) и пишет один полностью, а остальные - как "возьми тот объект и примени вот эти правки". Похожесть Git ищет эвристиками по имени/пути объекта (об этом ниже). Дельта плюс zlib дают сжатие в разы.
Файл .idx - это индекс пакфайла (не путать с индексом staging). Без него .pack бесполезен: .idx позволяет за O(log n) найти, по какому смещению (offset) внутри .pack лежит объект с заданным SHA. Структура .idx: отсортированный список всех SHA в паке, для каждого - его offset в .pack и CRC. Заголовок начинается с магии \377tOc и версии формата (обычно v2).
ofs-delta vs ref-delta: как объект читается по цепочке дельт
Дельта в паке ссылается на свою базу двумя способами. ref-delta хранит полный 20-байтный (или 32 для SHA-256) SHA базового объекта. ofs-delta хранит относительное смещение назад внутри того же пака - это компактнее и быстрее, потому что не надо искать базу через .idx, она тут же рядом. Современный Git по умолчанию пишет ofs-delta; ref-delta нужна, когда база в другом паке (например, при thin pack во время передачи по сети).
Что физически происходит, когда ты просишь объект, лежащий как дельта? Git раскручивает цепочку дельт: находит дельту, по offset/ref идёт к её базе, та база сама может быть дельтой - идём дальше, пока не упрёмся в полный (undeltified) объект. Затем накладывает дельты в обратном порядке и восстанавливает контент. Глубину цепочки ограничивает pack.depth (по умолчанию 50): чем длиннее цепочка, тем меньше места, но дороже распаковка. Увидеть это руками:
Код: Выделить всё
$ git verify-pack -v .git/objects/pack/pack-3f9a....idx | head
3f9a8c... commit 245 167 12
a17bd2... tree 119 88 179
e4c0f1... blob 8421 211 267
77b9aa... blob 34 29 478 1 e4c0f1...
^тип ^размер ^в паке ^offset ^глубина ^база
Reachability: что делает объект достижимым и когда gc его удаляет
Git не удаляет объекты сразу. Объект достижим (reachable), если до него можно дойти, стартовав от любого ref (ветки в refs/heads, теги в refs/tags, remote-ветки, HEAD) и от reflog, шагая по ссылкам: ветка -> коммит -> его tree -> blob-ы и поддеревья -> родительские коммиты. Всё, до чего так дойти нельзя, - недостижимо (unreachable). Это кандидаты на удаление.
Когда становятся недостижимыми? Ты сделал git commit --amend - старый коммит больше не в ветке. Сделал git reset --hard назад - отброшенные коммиты повисли. Удалил ветку - её эксклюзивные коммиты осиротели. Но Git их не выбрасывает мгновенно: их держит reflog (по умолчанию 90 дней для достижимых записей, 30 для недостижимых) - вот почему git reflog спасает после "потерянных" коммитов. Только когда и из reflog запись выпала, объект становится по-настоящему мусором.
git gc, repack, prune и революция cruft packs
git gc - "garbage collection" - делает несколько вещей: упаковывает loose-объекты в пак (repack), строит/обновляет commit-graph, чистит просроченный reflog и удаляет недостижимые объекты старше grace-периода (по умолчанию 2 недели, gc.pruneExpire).
Исторически тут была болезненная проблема. Когда gc удаляет недостижимый объект из пака, он не может просто стереть его из .pack - там лежат и нужные объекты. Поэтому раньше Git распаковывал недостижимые объекты обратно в loose-файлы, чтобы их mtime отсчитывал grace-период. Большой gc на распухшем репо порождал сотни тысяч loose-объектов-сирот, забивал inode и сам тормозил.
Современное решение - cruft packs. Недостижимые ("cruft") объекты пакуются в отдельный пак, рядом с которым лежит файл .mtimes - таблица времён последнего обращения для каждого объекта в этом паке. Теперь grace-период считается не по mtime россыпи файлов, а по записям в .mtimes одного компактного пака. Когда объект пересидел grace - он просто не попадает в следующий cruft pack. Никакой россыпи loose-сирот.
Код: Выделить всё
$ ls .git/objects/pack/
pack-2a1f....idx pack-2a1f....pack
pack-9b3c....idx pack-9b3c....mtimes pack-9b3c....pack
$ git gc --cruft
commit-graph и multi-pack-index: скорость log и merge-base
Чтобы построить git log --graph или найти merge-base, Git должен обойти граф коммитов: для каждого коммита распаковать его, прочитать родителей, дату, дерево. На больших историях это тысячи распаковок. commit-graph (файл .git/objects/info/commit-graph) кеширует это: в нём в бинарном виде лежат родители, даты и - ключевое - generation numbers, позволяющие отсекать ветви обхода без чтения коммитов. Включается обычно автоматически (fetch.writeCommitGraph), вручную - git commit-graph write --reachable.
Код: Выделить всё
$ git commit-graph write --reachable
Expanding reachable commits in commit graph: 48213, done.
Writing out commit graph in 3 passes: 100% (144639/144639), done.
name-hash v2 и path-walk: почему новые паки меньше
Качество дельта-сжатия зависит от того, какие объекты Git выбирает кандидатами друг для друга. Долго эта эвристика строилась на хеше имени файла, причём непропорционально полагалась на последние 16 символов пути - из-за чего файлы вроде CHANGELOG в разных каталогах путались, а длинные пути сравнивались плохо. С Git 2.49 завезли name-hash v2: хеш учитывает больше иерархии пути, кандидаты подбираются точнее.
С Git 2.50/2.51 пошли дальше - режим path-walk: вместо хеш-эвристики Git группирует и выдаёт упаковщику все версии одного и того же пути подряд. Логично: лучшая дельта-база для файла - это его же предыдущая версия. На многих репозиториях path-walk даёт ощутимо меньший packfile при сравнимом времени. По данным GitHub на большом монорепо это дало уменьшение MIDX примерно на 38% и более быструю запись. Подёргать руками:
Код: Выделить всё
$ git repack -ad --path-walk
git maintenance: geometric-стратегия - дефолт с 2.54
git gc исторически был тяжёлым: его "all-into-one" repack периодически переупаковывает весь репозиторий в один пак - дорого и блокирует работу на больших проектах. Современная замена - git maintenance: набор мелких задач (commit-graph, incremental-repack, prefetch, loose-objects, pack-refs), которые можно гонять по расписанию, не упирая всё в один тяжёлый проход.
Ключевой сдвиг 2026 года: с Git 2.54 ручное обслуживание по умолчанию использует geometric-стратегию вместо gc. Geometric repack комбинирует паки инкрементально, поддерживая их размеры в геометрической прогрессии (каждый следующий заметно больше предыдущего), и сваливается в полный repack только если это всё равно схлопнуло бы весь репозиторий в один пак. То есть дорогие тотальные переупаковки случаются гораздо реже, а данные остаются свежими. git gc теперь - легаси-путь: работает, но это не то, на что Git опирается по умолчанию.
Код: Выделить всё
$ git maintenance start
$ git maintenance run --task=incremental-repack
$ git config maintenance.strategy
geometric
FSMonitor: чтобы status не сканировал миллион файлов
В огромном рабочем дереве даже со stat-кешем git status обходит все файлы, чтобы найти изменённые. FSMonitor переворачивает логику: фоновый демон слушает события файловой системы и сообщает Git, какие пути менялись с прошлого раза. Git проверяет только их.
Код: Выделить всё
$ git config core.fsmonitor true
$ git config core.untrackedcache true
$ git status
Типичные грабли
- git gc не уменьшает репо - почти всегда объекты ещё достижимы через reflog. Пока reflog держит коммит, gc его не тронет. Проверяй git reflog expire --expire=now --all перед агрессивным gc (осознанно: ты теряешь страховку отмены).
- filter-branch для удаления секрета - не надо. Он официально не рекомендован и печатает варнинг (путь к удалению в Git 3.0). Используй сторонний git filter-repo или BFG, затем force-with-lease и обязательную ротацию утёкшего секрета - история перезаписана, но старый объект мог утечь.
- Раздулся .pack после клона зеркала - проверь git count-objects -vH: поле size-pack и in-pack. Большой пак - норма; проблема, если огромен size (loose) - тогда нужен repack/maintenance.
- Удалил skip-worktree-файл и сломал чужой pull - skip-worktree/assume-unchanged локальны и не очевидны. Документируй их в команде, не используй как замену .gitignore.
- prune --expire=now на живом сервере - может выбить объекты, на которые опирается параллельный fetch/push. На shared-репо доверяй grace-периоду, не форсируй.
- Создай репо: git init lab && cd lab && git config init.defaultBranch main (или просто работай в master).
- Сделай файл и закоммить дважды с правкой: echo a > f.txt && git add f.txt && git commit -m one, потом echo b >> f.txt && git commit -am two.
- Посмотри индекс: git ls-files --stage и git ls-files --debug f.txt. Найди SHA blob и stat-кеш.
- Глянь loose-объекты: ls .git/objects. Их пока россыпь по двухсимвольным каталогам.
- Упакуй: git repack -ad. Теперь смотри ls .git/objects/pack - появились .pack и .idx, а россыпь исчезла.
- Прочитай дельты: git verify-pack -v .git/objects/pack/*.idx. Найди строку с ненулевой глубиной - это объект, хранимый как дельта.
- Сделай конфликт (две ветки правят одну строку, merge), затем git ls-files --stage - увидишь stage 1/2/3 для конфликтного файла. Разреши, git add - слоты схлопнутся в stage 0.
- Включи обслуживание: git maintenance start && git config maintenance.strategy - убедись, что geometric.
- Чем stat-кеш в .git/index помогает git status и что сломается, если ты руками сменишь mtime файла, не трогая контент?
- В чём разница ofs-delta и ref-delta и почему Git предпочитает первую внутри одного пака?
- Зачем нужны cruft packs с .mtimes и какую конкретную проблему старого gc они устранили?
- Почему с Git 2.54 geometric-стратегия дешевле, чем "all-into-one" repack у git gc?
Индекс - это бинарный кеш со stat-данными и слотами 0/1/2/3, а не магическая "промежуточная зона". Объекты живут либо россыпью loose, либо упакованными в .pack с дельтами (ofs/ref) и адресуются через .idx и MIDX. Достижимость решает, что мусор, reflog даёт grace, cruft packs с .mtimes убрали россыпь сирот, а commit-graph ускоряет обход истории. И главный практический вывод 2026: забудь привычку дёргать git gc руками - geometric-maintenance с 2.54 делает это умнее и дешевле сам. Теперь, когда репо затормозит или распухнет, ты будешь не гадать, а смотреть в верные файлы.