voidlearn
 

1.4Хранение и клоны

Увидишь, как loose objects собираются в pack и почему удаление ссылки не сразу освобождает объект. Сравнишь shallow, partial и sparse checkout и разберёшь последствия больших бинарников в истории.

Что сможешь объяснить после этой главы
  • как объект лежит в .git/objects до упаковки и после и чем дельта в пачке отличается от диффа;
  • что запускают git gc и автоматическое обслуживание и что поменялось в git 2.54;
  • когда недостижимый объект действительно исчезает и что его до этого держит;
  • что отрезают --depth, --filter и sparse-checkout и где каждый из них подводит;
  • почему бинарник нельзя убрать из репозитория новым коммитом.

Отдельный объект: файл, сжатый zlib

В главе 1.1 объект получал имя по хешу содержимого вместе с заголовком blob <size>\0. На диск он попадает сразу, ещё при git add: первые два знака хеша становятся каталогом в .git/objects, остальные 38 дают имя файла. Такой объект называют отдельным объектом: один объект, один файл.

Возьмём живой файл, net/http/server.go из исходников Go 1.27.1 (4292 строки, 139 414 байт), и закоммитим его в пустой репозиторий:

$ cp "$(go env GOROOT)/src/net/http/server.go" .
$ git add server.go && git commit -q -m "Добавить server.go"
$ find .git/objects -type f | sort
.git/objects/16/58854e5145a61966765c300b483814461daa06
.git/objects/6e/2dcb9efc557234c650acc43c736d44124f43ff
.git/objects/af/f62f9a8dc595e7baaa266d3cbded0946ff8506
$ python3 -c 'import sys, zlib; print(zlib.decompress(open(sys.argv[1], "rb").read())[:40])' .git/objects/af/f62f9a8dc595e7baaa266d3cbded0946ff8506
b'blob 139414\x00// Copyright 2009 The Go Aut'

Коммит оставил три файла: blob, tree и commit. Распакованный zlib-ом blob начинается с того самого заголовка из главы 1.1. Сжимает git здесь с уровнем 1, самым быстрым (core.looseCompression): отдельные объекты пишутся на каждый add и commit, и скорость тут важнее места.

Поменяем в файле две строки, по одной на коммит: TimeFormat и DefaultMaxHeaderBytes.

$ git diff --stat HEAD~2
 server.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
$ git rev-parse HEAD~2:server.go HEAD~1:server.go HEAD:server.go
aff62f9a8dc595e7baaa266d3cbded0946ff8506
e7f15ca956c41e675452d4c7dc5d787af039378a
92a35610b7f32ec13ca40e3183cbb6da485dcbc4
$ wc -c .git/objects/af/f62f9a* .git/objects/e7/f15ca9* .git/objects/92/a35610*
   52913 .git/objects/af/f62f9a8dc595e7baaa266d3cbded0946ff8506
   52915 .git/objects/e7/f15ca956c41e675452d4c7dc5d787af039378a
   52915 .git/objects/92/a35610b7f32ec13ca40e3183cbb6da485dcbc4
  158743 total
$ git count-objects -v
count: 9
size: 180
in-pack: 0
packs: 0
size-pack: 0
prune-packable: 0
garbage: 0
size-garbage: 0

Изменились две строки из 4292, а на диске три почти одинаковых файла по 52,9 КБ. Отдельный объект ничего не знает о прошлых версиях: это снимок, сжатый сам по себе. count-objects -v показывает число таких файлов и место под ними в КиБ, с округлением до блоков файловой системы. Нули в in-pack и size-pack значат, что пачек пока нет.

Packfile: объекты в одном файле и индекс к нему

История настоящего проекта в таком виде превратилась бы в сотни тысяч мелких файлов. Поэтому git время от времени собирает объекты в packfile, пачку, где они идут подряд. Вручную это делает git gc:

$ git gc -q
$ find .git/objects -type f | sort
.git/objects/info/commit-graph
.git/objects/info/packs
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.idx
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.pack
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.rev
$ git count-objects -v | grep pack
in-pack: 9
packs: 1
size-pack: 44
prune-packable: 0

В .pack лежат сами объекты: у каждого короткий заголовок с типом и размером, дальше сжатые данные. По .idx объект находят, не читая пачку: в нём отсортированные хеши и смещение каждого объекта. Перед списком стоит таблица на 256 ячеек, где в ячейке N записано, сколько хешей начинается с байта не больше N, так что git по первому байту сразу получает узкий диапазон и ищет в нём двоичным поиском. .rev отвечает на обратный вопрос, какой объект лежит по смещению; с git 2.41 он пишется по умолчанию.

Было 180 КиБ, стало 44. Клон получает объекты с сервера сразу пачкой, и отдельных объектов в свежем клоне нет совсем. В полном клоне prometheus (ветка main на 16 сентября 2026 года) 156 835 объектов, и вот сколько они занимают в разном виде:

Объекты prometheusОбъём
Содержимое без сжатия2,98 ГиБ
Отдельными файлами (git unpack-objects)947 МиБ, на диске 1,36 ГиБ из-за блоков
Одной пачкой с индексом287,5 МиБ

Пачка выигрывает на сжатии посильнее (уровень 6 вместо 1) и на дельтах: из 65 622 блобов prometheus дельтами записаны 49 744.

Дельты: между похожими объектами, а не между коммитами

Заглянем внутрь пачки:

$ git verify-pack -v .git/objects/pack/pack-*.idx
d565fbbe7a1e04de1bee9c05d60fe49bffebb64a commit 235 181 12
6361bfd6c8adb0ba6dd9a89ed2be3c98b605cb0f commit 226 178 193
1658854e5145a61966765c300b483814461daa06 commit 177 144 371
92a35610b7f32ec13ca40e3183cbb6da485dcbc4 blob   139414 43684 515
bed668ac0f78de195c147dc4e3b931c0fce6d313 tree   37 48 44199
df9d70b971e30e1c775d4806306703d64184b653 tree   37 48 44247
e7f15ca956c41e675452d4c7dc5d787af039378a blob   31 45 44295 1 92a35610b7f32ec13ca40e3183cbb6da485dcbc4
6e2dcb9efc557234c650acc43c736d44124f43ff tree   37 48 44340
aff62f9a8dc595e7baaa266d3cbded0946ff8506 blob   21 33 44388 2 e7f15ca956c41e675452d4c7dc5d787af039378a
non delta: 7 objects
chain length = 1: 1 object
chain length = 2: 1 object
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.pack: ok

Колонки: хеш, тип, размер, размер в пачке, смещение, а у двух блобов ещё глубина цепочки и хеш базы. Последняя версия server.go (92a3561) лежит целиком, 43 684 байта. Предыдущая (e7f15ca) записана дельтой: это 31 байт инструкций, как собрать её из базового объекта. Самая старая (aff62f9) хранится дельтой от предыдущей, на глубине 2. Чтобы её прочитать, git распакует 92a3561 и применит обе дельты по очереди. Глубина цепочки ограничена 50 (pack.depth).

До gc: отдельные объекты каждый файл в .git/objects сжат zlib сам по себе aff62f9 blob, server.go v1 52 913 байт e7f15ca blob, server.go v2 52 915 байт 92a3561 blob, server.go v3 52 915 байт ещё 3 tree и 3 commit, от 54 до 191 байта count: 9, size: 180 КиБ git gc После gc: packfile pack-820e9ee.pack, 44 441 байт commit ×3 92a3561 v3 целиком 43 684 байта tree ×2 e7f15ca v2: дельта от 92a3561 45 байт tree aff62f9 v1: дельта от e7f15ca 33 байта Чтение v1 из пачки 1. распаковать 92a3561 целиком 2. применить дельту e7f15ca: получится v2 3. применить дельту aff62f9: получится v1 pack-820e9ee.idx fanout по первому байту → хеши по порядку → смещения 1658854 → 371 92a3561 → 515 aff62f9 → 44388 e7f15ca → 44295
Упаковка трёх версий. Слева каждая версия лежит полным сжатым файлом. Справа целиком осталась одна, две другие стали дельтами в несколько десятков байт, а индекс по хешу сразу даёт смещение объекта в пачке. У объектов в пачке подписано, сколько места они там занимают.

С патчем из git diff у дельты общего мало. В ней инструкции двух видов: «скопировать из базы столько-то байт с такого-то смещения» и «вставить эти байты», строк она не знает. А git diff, log -p и show дельт не касаются: они сравнивают два снимка целиком в момент вызова.

И базу упаковка выбирает без оглядки на историю. git pack-objects сортирует объекты по типу, хешу имени файла и размеру и для каждого пробует в роли базы соседей по списку, в окне из 10 объектов (pack.window). К чему это приводит, видно по двум опытам:

ОпытЦеликомДельтой
Во второй версии server.go оставили 2000 строк из 4292старая, большая версияновая: 9 байт
api/server.go и admin/server_copy.go в одном коммите отличаются строкой packageadmin/server_copy.goapi/server.go: 23 байта

Порядок «последняя версия целиком, старые дельтами» держится на сортировке: сначала по размеру, а при равном размере новые версии идут раньше старых. Код чаще растёт, чем худеет. Стоит файлу уменьшиться, и целиком останется старая версия, а базой может оказаться вообще другой файл.

gc, repack и maintenance: кто упаковывает и когда

git gc запускает целый набор команд, и GIT_TRACE=1 их показывает (служебные pack-objects отфильтрованы):

$ GIT_TRACE=1 git gc 2>&1 | grep -o 'run_command: .*' | grep -v pack-objects
run_command: git pack-refs --all --prune
run_command: git reflog expire --all
run_command: git repack -d -l --cruft --cruft-expiration=2.weeks.ago
run_command: git prune --expire 2.weeks.ago
run_command: git worktree prune --expire 3.months.ago
run_command: git rerere gc

pack-refs складывает ссылки в packed-refs (глава 1.2), reflog expire чистит reflog, repack --cruft собирает достижимое в одну пачку, а недостижимое в cruft-пачку (о ней ниже), prune стирает отдельные недостижимые объекты старше двух недель. Только пачки, без остальной уборки, пересобирает git repack -a -d, а с -f он заново подбирает дельты. git gc --aggressive берёт окно 250 вместо 10, и документация советует не запускать его без замеров: времени уходит намного больше, а выигрыш может не стоить того.

Руками gc почти никто не зовёт. После commit, merge, rebase, am и fetch git сам запускает обслуживание:

$ GIT_TRACE=1 git commit -q -m 'Добавить doc.go' 2>&1 | grep -o 'run_command: .*'
run_command: git maintenance run --auto --quiet --detach

--auto значит «проверь пороги и выйди, если делать нечего», а пороги зависят от версии. С git 2.29, где появился git maintenance, по 2.53 по умолчанию работала задача gc: она срабатывала, когда отдельных объектов больше 6700 (gc.auto) или пачек больше 50 (gc.autoPackLimit). В git 2.54 её сменила стратегия geometric. Всё с нуля она не пересобирает: сливает пачки так, чтобы каждая следующая по размеру содержала хотя бы вдвое больше объектов, чем предыдущая, и отдельные объекты упаковывает уже от 100 (maintenance.geometric-repack.auto).

Почему порог приблизительный

Git не пересчитывает все отдельные объекты после каждого коммита. Он считает файлы в одном каталоге, .git/objects/17, и умножает на 256, а порог округляет вверх до кратного 256. В git 2.54 на тысяче отдельных объектов в 17/ нашёлся один файл, оценка вышла 256, и git maintenance is-needed --auto ответил, что обслуживание не нужно. На двух тысячах объектов файлов там было пять, и пачка собралась.

Когда недостижимое удаляется на самом деле

Объект достижим, если до него можно дойти по ссылкам: от веток, тегов, remote-tracking веток, refs/stash, записей reflog и индекса. Недостижимое git удаляет не сразу. Проверим: заведём ветку, закоммитим в неё архив исходников net/http на 708 КиБ и удалим её.

$ git switch -q -c experiment
$ mkdir testdata && cp ../http.tar.gz testdata/
$ git add testdata && git commit -q -m "Положить архив в testdata"
$ git rev-parse --short HEAD:testdata/http.tar.gz
4756bea
$ git switch -q main && git branch -D experiment
Deleted branch experiment (was 7d86a1f).
$ git gc -q
$ git cat-file -t 4756bea
blob
$ git count-objects -vH | grep size-pack
size-pack: 753.32 KiB
$ git reflog
d565fbb HEAD@{0}: checkout: moving from experiment to main
7d86a1f HEAD@{1}: commit: Положить архив в testdata
d565fbb HEAD@{2}: checkout: moving from main to experiment
d565fbb HEAD@{3}: commit: Поднять DefaultMaxHeaderBytes
6361bfd HEAD@{4}: commit: Поменять TimeFormat
1658854 HEAD@{5}: commit (initial): Добавить server.go

Ветки нет, а архив пережил gc. Держит его reflog HEAD с записью о коммите 7d86a1f: удаление ветки стирает её журнал, но не журнал HEAD. Документация обещает записям reflog 90 дней (gc.reflogExpire), а записям о коммитах, недостижимых из текущей вершины, 30 дней (gc.reflogExpireUnreachable). В git 2.54 без явных настроек и те и другие стираются через 30 дней: умолчания в коде стоят наоборот. Нужны 90 дней - задай оба срока в конфиге. Промотаем эти 30 дней вручную (про reflog подробно в главе 5.2):

$ git reflog expire --expire-unreachable=now --all
$ git gc -q
$ git cat-file -t 4756bea
blob
$ ls .git/objects/pack/*.mtimes
.git/objects/pack/pack-549aa0f36791d522afbf7c36bd101d136c11b4c4.mtimes
$ git gc -q --prune=now
$ git cat-file -t 4756bea
fatal: Not a valid object name 4756bea
$ git count-objects -vH | grep size-pack
size-pack: 44.69 KiB

Ссылок на архив уже нет, но gc снова его оставил, переложив недостижимые объекты в cruft-пачку, пачку для мусора. Рядом с ней лежит .mtimes со временем изменения каждого объекта, и объект удалит тот gc, который застанет его старше двух недель (gc.pruneExpire). Cruft-пачки появились в git 2.37 и включены по умолчанию с 2.41; до этого недостижимое выкладывалось из пачки отдельными файлами и ждало тех же двух недель.

Отсрочка защищает от гонки: параллельный commit или fetch может уже записать объект, но ещё не поставить на него ссылку. --prune=now защиту снимает, и документация git gc предупреждает, что при параллельной записи так можно испортить репозиторий. Если коммит выпал из всех веток, сначала истекает запись в reflog, потом отсрочка до двух недель, и место освобождает следующий gc. На сервере и в клонах коллег объекты живут по своим правилам (глава 5.2).

Клоны: shallow, partial и sparse-checkout

Полный клон приносит все объекты, достижимые с сервера. Взять меньше можно тремя способами, и режут они разное. Shallow clone, мелкий клон (--depth), обрезает историю: коммиты глубже границы не приходят. Partial clone, частичный (--filter), приносит всю историю, но часть объектов оставляет на сервере и докачивает по требованию. Sparse-checkout решает, какие каталоги попадут в рабочую копию, и сам по себе сеть не трогает.

Prometheus, ветка main, коммит 50461b6, 16 сентября 2026 года:

КлонОбъектовПачкиКоммитов в историиФайлов на диске
git clone URL156 835287,5 МиБ18 6421687
--depth 119366,2 МиБ11687
--filter=blob:none92 88728,4 МиБ18 6421687
--filter=tree:023 35917,0 МиБ18 6421687
--filter=blob:none --sparse91 24222,3 МиБ18 64228
то же после sparse-checkout set tsdb91 40925,3 МиБ18 642196

Коммиты посчитаны git rev-list --count HEAD. --depth подразумевает --single-branch, остальные клоны взяли все ветки.

коммиты блобы рабочая копия полный клон 287,5 МиБ tsdb/ web/ 156 835 объектов --depth 1 6,2 МиБ grafted tsdb/ web/ 1936 объектов --filter=blob:none 28,4 МиБ tsdb/ web/ 92 887 объектов blob:none + sparse 25,3 МиБ, только tsdb/ tsdb/ web/ 91 409 объектов есть локально на сервере: не пришло или придёт по запросу
Что режет каждый клон. Мелкий клон отрезает старые коммиты вместе с их объектами. Частичный берёт всю историю, но без старых блобов. Sparse-checkout в паре с ним сужает рабочую копию, а с ней и то, что приходит по сети.

Shallow. Граница истории записана в .git/shallow: для этих коммитов git считает, что родителей нет. Вот копия репозитория из начала главы с тегами v0.1.0 и v0.2.0:

$ git clone -q --depth 1 file://$PWD/origin shallow && cd shallow
$ git log --oneline --decorate
d565fbb (grafted, HEAD -> main, origin/main, origin/HEAD) Поднять DefaultMaxHeaderBytes
$ cat .git/shallow
d565fbbe7a1e04de1bee9c05d60fe49bffebb64a
$ git describe --tags
fatal: No names found, cannot describe anything.
$ git blame -L 1,1 server.go
^d565fbb (Аня 2026-09-03 10:00:00 +0300 1) // Copyright 2009 The Go Authors. All rights reserved.
$ git fetch -q --unshallow
$ git describe --tags
v0.2.0-1-gd565fbb

grafted значит, что у коммита отрезаны родители. describe не видит тегов, их коммиты не пришли, а blame приписывает строку из первого коммита граничному (знак ^). Если ветки разошлись глубже границы, git merge-base молча вернёт код 1, пример в вопросе 3. Историю догружает git fetch --unshallow или --deepen=N. Локальный путь без file:// git клонирует копированием и --depth игнорирует.

Мелкий клон в CI

actions/checkout по умолчанию забирает один коммит, GitLab в новых проектах клонирует на глубину 20. Сборке и тестам хватает, а шаг с git describe, changelog с прошлого тега или поиском файлов, изменённых относительно main, падает. Ему нужны fetch-depth: 0 в GitHub Actions, 0 в настройке Git shallow clone проекта GitLab или git fetch --unshallow.

Partial. С --filter=blob:none сервер присылает коммиты и деревья без блобов, а блобы рабочей копии git докачивает при checkout. Сервер становится promisor remote: git считает, что любой недостающий объект можно у него попросить.

$ git -C origin config uploadpack.allowFilter true
$ git clone -q --filter=blob:none file://$PWD/origin partial && cd partial
$ git config --get-regexp "^remote\.origin\.(promisor|partialclonefilter)"
remote.origin.promisor true
remote.origin.partialclonefilter blob:none
$ git rev-list --objects --all --missing=print | grep "^?"
?e7f15ca956c41e675452d4c7dc5d787af039378a
?aff62f9a8dc595e7baaa266d3cbded0946ff8506
$ GIT_TRACE=1 git log -p --oneline 2>&1 | grep -c 'built-in: git fetch'
2

Первая команда нужна только локальному источнику, иначе git пишет filtering not recognized by server, ignoring и клонирует всё; GitHub и GitLab фильтры поддерживают. Двух старых версий server.go в клоне не было, и git log -p дважды сходил за ними на сервер.

Эти походы и есть цена partial clone. git diff между двумя коммитами просит нужные блобы одним запросом, а log -p и blame по одному. В клоне prometheus с blob:none команда git log -p -10 -- go.sum обратилась к серверу 10 раз, а git blame README.md 145 раз и шла почти две минуты (время зависит от сети). --filter=tree:0 не присылает и деревьев, и там даже git log --oneline -3 -- go.mod сделал 44 запроса за 35 секунд. --filter=blob:limit=1m оставляет на сервере только блобы от мегабайта.

Sparse-checkout. В режиме cone, включённом по умолчанию, перечисляются каталоги, а файлы из корня репозитория на диске есть всегда. git clone --sparse начинает с одного корня:

$ git clone -q --filter=blob:none --sparse https://github.com/prometheus/prometheus sparse2 && cd sparse2
$ find . -path ./.git -prune -o -type f -print | wc -l
      28
$ git sparse-checkout set tsdb
$ find . -path ./.git -prune -o -type f -print | wc -l
     196
$ git ls-files -t | cut -c1 | sort | uniq -c
 196 H
1491 S

Остальные 1491 файл остались в индексе с пометкой skip-worktree (буква S): git о них знает, но на диск не пишет. С partial clone это сочетается хорошо: set tsdb докачал блобы только этого каталога, 167 объектов одной пачкой.

go build не знает про sparse-checkout
$ git sparse-checkout set cmd
$ go build ./cmd/api
cmd/api/main.go:6:2: no required module provides package example.com/shop/internal/store; to add it:
	go get example.com/shop/internal/store
$ git sparse-checkout add internal/store
$ go build ./cmd/api

Пакет internal/store лежит в том же модуле, просто его каталога нет на диске. Подсказка про go get уводит в сторону, чинит это git sparse-checkout add.

Почему бинарник раздувает репозиторий навсегда

Частая ошибка: сервис на Go собрали и закоммитили вместе с бинарником server, потом дважды поправили код (ответ в JSON, логирование через slog), каждый раз пересобирая и коммитя:

$ go build -o server . && git add . && git commit -q -m "Сервер и собранный бинарник"
$ go build -o server . && git commit -q -am "Отвечать JSON"
$ go build -o server . && git commit -q -am "Логировать запросы"
$ git gc -q
$ git verify-pack -v .git/objects/pack/pack-*.idx | grep blob
39c5ef80ac37708bfc5a6de808d737b8d9091e19 blob   34 44 532
82b333c2c99870257d2cd5ac1dbe30d729c74ff8 blob   309 252 576
c494755436fb972c6f4d79c0a6dc968caf8277aa blob   9365330 5279746 828
a92c77efd7fefa3ae3d1704727981e7d68a44eaf blob   12 25 5280796 1 82b333c2c99870257d2cd5ac1dbe30d729c74ff8
682a90155d19debeb8af46e1c8c8fed68a51705f blob   9241506 5207136 5280821
ea6fbdc8783d1790ce309472848a596262bf2343 blob   48 62 10488067 1 82b333c2c99870257d2cd5ac1dbe30d729c74ff8
4c04ce5c62953429498f728d716cc06cfa2f6a64 blob   8200578 4631593 10488129
$ git count-objects -vH | grep size-pack
size-pack: 14.42 MiB
$ git rm -q --cached server && echo server > .gitignore
$ git add .gitignore && git commit -q -m "Убрать бинарник из репозитория"
$ git gc -q && git count-objects -vH | grep size-pack
size-pack: 14.42 MiB
$ cd .. && git clone -q --no-local bin bin-clone && cd bin-clone
$ ls
go.mod
main.go
$ git count-objects -vH | grep size-pack
size-pack: 14.42 MiB

Сборки весят 7,8 МиБ, 8,8 МиБ и 8,9 МиБ. Zlib ужал каждую почти вдвое, а выгодной дельты между ними упаковка не нашла, и все три лежат целиком. Сам main.go хранится одной версией на 309 байт и двумя дельтами, в 12 и 48 байт.

Коммит «Убрать бинарник» ничего не освободил. Он добавил дерево без server, но старые коммиты по-прежнему указывают на свои деревья, а те на блобы бинарника. Хеш коммита зависит от хеша дерева (глава 1.1), поэтому прошлый коммит не поменять, не поменяв все следующие. Новый клон получает всю достижимую историю: бинарника в рабочей копии нет, а пачка всё те же 14,42 МиБ.

Со сжатыми форматами ещё хуже. Архив исходников net/http в .tar.gz (708 КиБ) после правки одной строки упаковка сохранила второй раз целиком, и две версии заняли 1,38 МиБ. Те же исходники файлами после такой же правки подросли на 0,4 КиБ. А файлы крупнее core.bigFileThreshold (512 МиБ) git пакует, даже не пытаясь искать дельту.

Поэтому артефакты сборки держат в .gitignore. Неотправленный коммит с бинарником достаточно переписать локально (вопрос 4), а отправленный убирают, только переписав историю у всех (глава 5.2). Большие файлы, которые правда нужны, хранят в Git LFS: в git коммитится маленький файл-указатель, а содержимое лежит на отдельном сервере и скачивается, когда версия попадает в рабочую копию (глава 5.3).

Вопросы

4
Суть: по модели это снимки. Коммит указывает на полное дерево, дерево на полные блобы. Физически объекты сначала лежат отдельными файлами в zlib, а потом упаковываются в packfile, где похожие объекты хранятся дельтами. Дельты к истории коммитов не привязаны, а дифф в git log -p считается на лету.

Модель

Поменял строку, и появились новый блоб на весь файл, новые деревья по пути к нему и новый коммит, а всё неизменённое коммит берёт по старым хешам. Checkout старого коммита не проигрывает патчи от начала истории: git читает дерево коммита и раскладывает его блобы.

Хранение

Отдельными файлами три версии server.go, отличающиеся двумя строками, заняли по 52,9 КБ. В пачке одна осталась целиком (43 684 байта), две стали дельтами в 45 и 33 байта. У prometheus это 2,98 ГиБ содержимого против 287,5 МиБ пачки.

Чем дельта не похожа на дифф

  • Дельта работает с байтами: «скопировать отрезок из базы» и «вставить байты», строк в ней нет.
  • Базу выбирает не родительский коммит, а сортировка по типу, имени и размеру в окне из 10 объектов. Целиком обычно лежит самая большая версия, и дельта может ссылаться на другой файл.
  • Дифф в log -p, show и diff строится из двух снимков в момент вызова и от упаковки не зависит.
Как ответить в двух фразах

«Git хранит снимки. Внутри packfile их сжимают дельтами, подобранными эвристикой по похожести объектов, а diff и checkout о дельтах не знают.»

Суть: объект держат не одни ветки. Пока о коммите есть запись в reflog HEAD, он достижим, а записи о недостижимых коммитах живут 30 дней. Потом gc переносит объекты в cruft-пачку и удаляет, когда они старше двух недель. Сразу удаляют reflog expire и gc --prune=now, но так пропадает шанс вернуть работу.

Кто держит объект

Кроме веток это теги, remote-tracking ветки вроде origin/feature, refs/stash, индекс и reflog. Кто держит, покажет git fsck: без флагов он считает reflog источником ссылок, с --no-reflogs нет. Удалённая ветка с архивом из раздела выше:

$ git fsck --unreachable
$ git fsck --unreachable --no-reflogs
unreachable blob 4756bea960ec3501e2e437caba8087c227c66657
unreachable tree 3073216cd491edb16c27eaec3402a57941681acb
unreachable tree 5a4d0626aa7808c039b17e084544c3403386e31b
unreachable commit 7d86a1f80bdb3caa09b1aeb26c4c2f0a48f8e513
$ git branch -a --contains 7d86a1f
$ git reflog | grep 7d86a1f
7d86a1f HEAD@{1}: commit: Положить архив в testdata

Пустой первый вывод и четыре объекта во втором значат, что коммит и архив держит только reflog.

Сроки

ЧтоСрокНастройка
Запись reflog о коммите, достижимом из вершины90 дней по документации, в 2.54 на деле 30gc.reflogExpire
Запись reflog о недостижимом коммите (после reset, --amend, удаления ветки)30 днейgc.reflogExpireUnreachable
Недостижимый объект, отдельный или в cruft-пачке2 неделиgc.pruneExpire

Сроки проверяет gc. Автоматическое обслуживание в git 2.54 работает по стратегии geometric и до них может не дойти долго: reflog оно чистит, только когда накопится сотня устаревших записей, а недостижимые объекты удаляет, только когда сводит все пачки в одну. Место надёжно освобождает ручной git gc после того, как истекут оба срока.

Как удалить сейчас

git reflog expire --expire-unreachable=now --all, затем git gc --prune=now: в том же опыте пачка ужалась с 753 до 45 КиБ. Но после такой чистки reflog уже не вернёт потерянный коммит (глава 5.2), а --prune=now снимает защиту от гонки, так что запускай его, когда с репозиторием никто больше не работает.

Локальная чистка не трогает сервер

Если ветку успели отправить, её объекты остаются на сервере и в клонах коллег, и твой gc на них не влияет. Для утёкшего секрета удалить ветку мало, разбор в главе 5.2.

Суть: shallow обрезает историю по глубине, partial оставляет часть объектов на сервере и докачивает их по требованию, sparse-checkout ограничивает рабочую копию. Сборке в CI хватает --depth 1, пока шагам не нужна история. Для работы в большом репозитории удобнее --filter=blob:none, при нужде вместе со sparse-checkout.

Что режет каждый способ

СпособЧего нет локальноЧто ломается или тормозитКак вернуть
--depth Nкоммитов глубже N и их объектовdescribe, merge-base, blame, changelogfetch --unshallow, --deepen=N
--filter=blob:noneблобов вне рабочей копииlog -p и blame ходят за каждой версиейдокачивается само
--filter=tree:0деревьев и блобовдаже log -- путьсамо, медленно
sparse-checkoutфайлов вне выбранных каталоговgo build не видит их пакетыsparse-checkout add

CI

Сборке и тестам нужен один снимок, и --depth 1 на prometheus даёт 6,2 МиБ вместо 287,5. Но стоит шагу заглянуть в историю, и одного коммита мало. Здесь ветка feature отошла от main два коммита назад:

$ git clone -q --depth 1 --no-single-branch file://$PWD/mb-origin mb-shallow && cd mb-shallow
$ git merge-base origin/main origin/feature; echo "exit $?"
exit 1
$ git diff --name-only origin/main...origin/feature; echo "exit $?"
fatal: origin/main...origin/feature: no merge base
exit 128
$ git fetch -q --unshallow
$ git diff --name-only origin/main...origin/feature
f.txt

Таким шагам нужна полная история. Если важен объём, берут --filter=blob:none: коммиты и деревья на месте, а git diff --name-only на двухстах коммитах prometheus обошёлся одним запросом за блобами.

Большой репозиторий

Для ежедневной работы удобен --filter=blob:none: git log по коммитам и путям работает без сети, старые версии файлов приходят, когда понадобятся. В монорепо к нему добавляют sparse-checkout. Treeless-клон для работы не годится, там git log --oneline -3 -- go.mod сделал 44 запроса. А если blame нужен постоянно, вспомни про 145 запросов на один README.md: полный клон обойдётся дешевле по нервам.

Суть: не поможет, старые коммиты ссылаются на блоб, и он уходит в каждый новый клон. Неотправленный коммит переписывают, и старая версия исчезает с диска, когда истекут сроки и пройдёт git gc. Отправленный можно убрать, только переписав историю у всех (глава 5.2), а на будущее такие файлы хранят в LFS (глава 5.3).

Почему новый коммит не помогает

Старые коммиты по-прежнему достижимы из ветки вместе со своими деревьями и блобами, а поменять их нельзя: хеш коммита вычисляется из содержимого. В опыте из раздела выше пачка после «Убрать бинарник» весила те же 14,42 МиБ, как в свежем клоне.

Коммит ещё не отправлен

$ git show --stat --oneline HEAD
706e02b HTTP-сервер
 main.go |  14 +++++++++++++-
 server  | Bin 0 -> 9365330 bytes
 2 files changed, 13 insertions(+), 1 deletion(-)
$ git gc -q && git count-objects -vH | grep size-pack
size-pack: 5.04 MiB
$ git rm -q --cached server && echo server > .gitignore && git add .gitignore
$ git commit -q --amend --no-edit
$ git gc -q && git count-objects -vH | grep size-pack
size-pack: 5.04 MiB
$ git reflog expire --expire-unreachable=now --all && git gc -q --prune=now
$ git count-objects -vH | grep size-pack
size-pack: 2.12 KiB

--amend заменил коммит новым, без бинарника, но старый 706e02b остался в reflog, и первый gc ничего не освободил. Это не страшно: на сервер бинарник не попал, а с диска его уберут сроки из вопроса 2. Две последние команды нужны, только если место нужно прямо сейчас. Если бинарник глубже последнего коммита, а ветка не отправлена, ту же правку делают интерактивным rebase (глава 3.3).

Коммит уже на сервере

Файл уже есть у всех, кто сделал fetch. Убрать его можно, только переписав коммиты начиная с того, что его добавил: git filter-repo, принудительный push, и каждый участник выбрасывает старую историю. Хеши меняются, ветки коллег придётся переносить. Подвохи, включая копии на сервере, разобраны в главе 5.2.

Чтобы не пришлось переписывать историю

Артефакты сборки в .gitignore, проверка размера в хуке или на сервере, большие файлы в Git LFS. На лимиты GitHub не надейся: он предупреждает о файлах от 50 МиБ и не принимает файлы больше 100 МиБ, а до этого репозиторий успевает распухнуть.

Что стоит запомнить

  • Packfile упаковывает объекты и может хранить похожие версии через delta representation.
  • Недостижимые объекты могут оставаться до обслуживания и истечения сроков хранения.
  • Shallow ограничивает историю, partial откладывает часть объектов, sparse ограничивает рабочие пути.

Частые ошибки

  • Новый commit с удалённым бинарником не стирает старый blob из истории.
  • Sparse checkout сам по себе не означает, что все лишние объекты не скачаны.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Определи shallow clone

Какой режим клонирования отражает этот результат?

$ git rev-parse --is-shallow-repository
true
Ответ и разбор

Это shallow clone, например созданный с --depth. История обрезана и границы отмечены в shallow-файле. Partial clone откладывает получение части объектов, а sparse checkout выбирает пути рабочей копии.

Код-ревью

2. Удали большой файл из истории

В проекте закоммитили большой архив. Предлагают удалить путь новым commit, чтобы уменьшить размер клона. Почему этого недостаточно?

git rm archive.zip
git commit -m "remove archive"
Ответ и разбор

Новый commit удаляет путь только из нового снимка; старый commit всё ещё ссылается на blob. Для переписывания истории нужен специализированный history filter, согласованный force push и обновление клонов. Если архив нужен, вынеси его в LFS или внешнее хранилище.

Применение

3. Выбери ограничение истории

CI нужно получить последние 20 коммитов и собрать текущий checkout. Какой режим подходит и какие операции могут потребовать догрузки?

Ответ и разбор

Подойдёт shallow clone с глубиной 20. Операции, которым нужен более старый предок - часть merge-base, release notes или bisect - могут потребовать углубить историю. Partial clone решает другую задачу и может откладывать получение объектов.

Применение

4. Объясни размер .git после удаления ветки

Удалили feature-ветку с большим бинарником, но .git не уменьшился. Какие источники могут удерживать объект и что проверить до prune?

Ответ и разбор

Другие refs, reflog или достижимый коммит могут удерживать blob; Git также не удаляет недостижимые объекты сразу, чтобы дать возможность восстановить ссылку. Проверь refs, reflog и git count-objects -v. Не запускай агрессивный prune до проверки, что потерянные коммиты больше не нужны.