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).
С патчем из 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 в одном коммите отличаются строкой package | admin/server_copy.go | api/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 URL | 156 835 | 287,5 МиБ | 18 642 | 1687 |
--depth 1 | 1936 | 6,2 МиБ | 1 | 1687 |
--filter=blob:none | 92 887 | 28,4 МиБ | 18 642 | 1687 |
--filter=tree:0 | 23 359 | 17,0 МиБ | 18 642 | 1687 |
--filter=blob:none --sparse | 91 242 | 22,3 МиБ | 18 642 | 28 |
то же после sparse-checkout set tsdb | 91 409 | 25,3 МиБ | 18 642 | 196 |
Коммиты посчитаны git rev-list --count HEAD. --depth подразумевает
--single-branch, остальные клоны взяли все ветки.
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 игнорирует.
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 объектов одной пачкой.
$ 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).
Вопросы
4git 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 о дельтах не знают.»
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 на деле 30 | gc.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.
--depth 1, пока шагам не нужна история. Для работы в большом репозитории удобнее
--filter=blob:none, при нужде вместе со sparse-checkout.Что режет каждый способ
| Способ | Чего нет локально | Что ломается или тормозит | Как вернуть |
|---|---|---|---|
--depth N | коммитов глубже N и их объектов | describe, merge-base, blame, changelog | fetch --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 до проверки, что потерянные коммиты больше не нужны.