Внутреннее устройство III: индекс, packfiles, gc и обслуживание

Рейтинг: 52.9% · 8 голосов
Самый подробный курс по Git - от первого коммита до объектной базы и packfiles. Терминал и первый репозиторий, ветки и слияние, конфликты, удалённая работа и форджи (GitHub, GitLab), rebase и переписывание истории, спасение кода через reflog и fsck, stash и worktree, bisect и blame, подмодули и монорепо, хуки и подпись коммитов, внутреннее устройство и масштаб. Для новичков и профи. Актуально на 2026 (Git 2.54, дорожная карта 3.0).
Ответить
Аватара пользователя
Pavel_Git
Сообщения: 52
Зарегистрирован: 11 май 2026, 05:31

Внутреннее устройство III: индекс, packfiles, gc и обслуживание

Сообщение Pavel_Git »

Оглавление курса (52)
  1. Что такое контроль версий и почему победил Git
  2. Установка Git на Windows, macOS и Linux и первая настройка
  3. Терминал и как спрашивать у Git: выживание в командной строке
  4. Ментальная модель Git: три состояния, коммит-снимок и граф
  5. Первый репозиторий: init, add, commit, status
  6. Просмотр изменений: status, diff и индексация по частям
  7. История проекта: git log, диапазоны и поиск
  8. .gitignore: что не должно попасть в репозиторий
  9. .gitattributes: окончания строк, бинарники и атрибуты
  10. Ветки как указатели: branch, switch и detached HEAD
  11. Слияние веток: fast-forward против трёхстороннего merge
  12. Конфликты слияния: анатомия и уверенное разрешение
  13. Практикум: твой первый день с Git от нуля до push
  14. Удалённые репозитории: remote, fetch, базовый pull и refspec
  15. git push: безопасная отправка и защита от перезаписи
  16. Клонирование: shallow, partial clone, bundle и backfill
  17. GitHub, GitLab и форки: pull request, rulesets и защита веток
  18. Рабочие процессы команды: GitFlow, GitHub Flow, trunk-based
  19. Merge queue и merge trains: безопасное вливание в trunk
  20. Культура code review и Conventional Commits
  21. git reset: три дерева и отмена индексации и коммитов
  22. git revert: безопасная отмена в общей истории
  23. Правка последнего коммита: git commit --amend
  24. git rebase: пересадка истории и золотое правило
  25. Интерактивный rebase: переписать серию коммитов
  26. git cherry-pick: перенос отдельных коммитов
  27. Stacked diffs: цепочка зависимых PR
  28. Массовое переписывание истории: git filter-repo и git replace
  29. git reflog: журнал, который помнит всё
  30. Восстановление утерянных коммитов, веток и файлов
  31. Откат опасных операций: reset --hard, сломанный rebase, плохой merge
  32. git stash: тайник для незавершённой работы
  33. git worktree: несколько рабочих деревьев одного репозитория
  34. git bisect: бинарный поиск регрессии
  35. git blame, mailmap и археология кода
  36. Теги, релизы, archive и bundle
  37. Подмодули: внешние репозитории внутри проекта
  38. Subtree, монорепозитории и sparse-checkout
  39. Git LFS и большие файлы
  40. Вспомогательные команды: clean, mv, rm, restore
  41. Псевдонимы и кастомизация .gitconfig
  42. Хуки Git: автоматизация на клиенте и сервере
  43. Подпись коммитов и тегов: SSH, GPG и Sigstore
  44. Внутреннее устройство I: объектная база Git
  45. Внутреннее устройство II: ссылки, HEAD, notes и reftable
  46. Внутреннее устройство III: индекс, packfiles, gc и обслуживание (вы здесь)
  47. От SHA-1 к SHA-256: переход на новый хеш
  48. Огромные репозитории: partial clone, FSMonitor, Scalar
  49. Git в CI/CD: shallow, кеш, merge_group и безопасность
  50. Куда движется Git: дорожная карта 3.0 и экосистема
  51. Профессиональный setup: собираем рабочее окружение Git
  52. 30+ типичных ошибок Git и как из них выбираться
Боль, которую мы лечим

Ты делал 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
Разберём колонки. Первая - режим: 100644 это обычный файл, 100755 - исполняемый (бит +x), 120000 был бы симлинк, 160000 - gitlink на сабмодуль. Вторая - SHA того blob, на который указывает запись. Третья - число 0, это stage (о нём ниже). Четвёртая - путь. Обрати внимание: в индексе нет содержимого файла, только адрес объекта в .git/objects. Сам blob уже лежит в базе - его записал git add.

Хочешь увидеть сырые 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
Это и есть тот самый stat-кеш внутри одной записи. Поменяй mtime файла (даже touch без правки контента) - и Git на следующем status пересчитает хеш, чтобы убедиться, что контент реально не изменился. Если хеш совпал - запись обновится, файл останется "чистым".

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
Три строки config.yml - это и есть незавершённый merge. Stage 0 отсутствует, потому что Git не знает, какой контент финальный. Команда git status покажет такой путь как "both modified". Когда ты руками разрешишь конфликт и сделаешь git add config.yml - три слота схлопнутся в один stage 0 с новым blob. Именно поэтому "разрешить конфликт" технически означает "записать stage 0 поверх 1/2/3". Понимание трёх слотов снимает магию: ты не угадываешь, ты вручную закрываешь незаполненный stage 0.

Низкоуровневый рычаг управления индексом - git update-index. Им можно добавить запись, убрать, переключить assume-unchanged или skip-worktree. Полезный приём: пометить локально правленый, но не коммитимый конфиг.

Код: Выделить всё

$ git update-index --skip-worktree local.conf
С этого момента Git не будет показывать изменения local.conf в status. Но осторожно: это локальный флаг индекса, он не передаётся при push, и про него легко забыть. Классические грабли - "почему мой коллега видит изменения, а я нет". Снять: git update-index --no-skip-worktree.

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 ^глубина ^база
Последняя строка - дельта: тип blob, глубина 1, база e4c0f1. Видишь: "в паке" всего 29 байт против исходного blob на 8421 - вот сила дельта-сжатия. Колонка глубины показывает длину цепочки до полного объекта.

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
Пак с .mtimes рядом - это и есть cruft pack. Хочешь форсировать удаление прямо сейчас, без grace - git prune --expire=now (на пустом репо безопасно, на рабочем - крайне осторожно, это необратимо и может выбить объекты, на которые опирается чужой fetch).

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.
Когда паков становится много, поиск объекта требует заглянуть в каждый .idx. multi-pack-index (MIDX, .git/objects/pack/multi-pack-index) - один индекс поверх всех паков: по SHA сразу говорит, в каком паке и по какому offset объект. С Git 2.47 MIDX стал инкрементальным: при добавлении нового пака не переписывается целиком, а дополняется слоем - дешевле обновлять на активных монорепо. Записать: git multi-pack-index write.

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
maintenance start регистрирует фоновое расписание (через cron/systemd/launchd в зависимости от ОС), и репозиторий обслуживает себя сам: коммит-граф свежий, паки консолидированы, недостижимое подчищается. Для большинства команд это правильный дефолт вместо ручных gc.

FSMonitor: чтобы status не сканировал миллион файлов

В огромном рабочем дереве даже со stat-кешем git status обходит все файлы, чтобы найти изменённые. FSMonitor переворачивает логику: фоновый демон слушает события файловой системы и сообщает Git, какие пути менялись с прошлого раза. Git проверяет только их.

Код: Выделить всё

$ git config core.fsmonitor true
$ git config core.untrackedcache true
$ git status
На монорепозиториях это разница между "мгновенно" и "несколько секунд". untrackedCache дополнительно кеширует список неотслеживаемых файлов. Оба флага безопасны и почти всегда стоит включить на больших проектах.

Типичные грабли
  • 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 делает это умнее и дешевле сам. Теперь, когда репо затормозит или распухнет, ты будешь не гадать, а смотреть в верные файлы.
👍4 ❤️2 🔥1 😄 🤔1
Аватара пользователя
casey3
Сообщения: 1
Зарегистрирован: 01 июн 2026, 20:45

Re: Внутреннее устройство III: индекс, packfiles, gc и обслуживание

Сообщение casey3 »

Вот это про stage 1/2/3 наконец-то щёлкнуло - я думал конфликт это какая-то магия, а это просто три записи в индексе с одним именем. Спасибо!
👍1 ❤️1 🔥 😄 🤔
Аватара пользователя
Esp32Chan
Сообщения: 1
Зарегистрирован: 31 май 2026, 11:42

Re: Внутреннее устройство III: индекс, packfiles, gc и обслуживание

Сообщение Esp32Chan »

А path-walk и name-hash v2 надо как-то включать руками или maintenance сам подхватит на свежем гите? У меня монорепо на пару гигов, хочу ужать.
👍3 ❤️1 🔥 😄 🤔
Ответить
← Предыдущая глава
Внутреннее устройство II: ссылки, HEAD, notes и reftable
Следующая глава →
От SHA-1 к SHA-256: переход на новый хеш

Все главы курса «Git профессионально: от первого коммита до внутреннего устройства»

Поделиться темой: ✈ Telegram VK
Похожие запросы: Внутреннее устройство Git

Вернуться в «Git профессионально: от первого коммита до внутреннего устройства»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость