Как облачная синхронизация тихо ломает git и как это чинить

Привет, %username%! Обычный день, обычный git push. И вдруг вместо привычного «всё улетело на remote» терминал выдаёт:
error: garbage at end of loose object '2699e4e6…'
fatal: loose object 2699e4e6… is corruptПервая мысль — «ну ладно, запущу git gc, он приберётся». Запускаешь. gc падает ровно с той же ошибкой. Теперь у тебя не пушится и не собирается мусор — репозиторий выглядит как труп.
Спойлер: он живой. Данные целы, всё чинится за десять минут — но не той командой, на которую думаешь первым делом. git gc тебе тут не друг, а первый, кто спотыкается о поломку. А ещё, пока я разгребал эту историю, вскрылась вторая беда, никак не связанная с первой на первый взгляд: .git раздулся на сотни мегабайт мусором, который сидел в истории годами. Так что рецептов будет два.
Главный тезис статьи: порча объектов git — это не потеря данных, если жив origin. А раздутый .git лечится только переписью истории, и никак иначе. Разберём оба случая до винтика — с теорией, диагностикой и командами, которые можно копировать.
Что ты унесёшь из статьи#
- Если ты держишь репозиторий под облачной синхронизацией (iCloud, Dropbox, OneDrive, Яндекс.Диск — неважно) — узнаешь, почему это тикающая бомба под
.git/objectsи как разминировать уже случившийся взрыв. - Если у тебя раздулся
.gitиgit gcне помогает — получишь рабочий рецепт чистки истории черезgit-filter-repo, вместе с обязательной страховкой, чтобы не потерять репозиторий по дороге. - Если просто хочешь понять, что такое loose-объект и почему git вообще способен «побиться» — тут есть теория zlib-сжатия и внутренностей Git LFS, без которой команды выглядят как шаманство.
TL;DR (если чинить надо прямо сейчас)#
Порча loose-объектов (garbage at end of loose object … is corrupt):
- Не пытайся чинить битый объект на месте — garbage-байты неустранимы.
git reset --mixed origin/main— вернуть HEAD на здоровую базу с remote, рабочие файлы не трогаем.git add -A && git commit— пересобрать свои правки одним чистым коммитом, мимо битых блобов.rmбитых объектов →git reflog expire --expire=now --all→git gc --prune=now. Порядок именно такой.git push— обычный fast-forward,--forceне нужен.
Раздутый .git (балласт в истории):
git bundle create backup.bundle --all— страховка ПЕРЕД любой переписью.git filter-repo --invert-paths --path <мусорный путь>— вырезать мусор из всей истории.git gc --prune=now && git lfs prune→ вернутьremote→git push --force.- Переклонировать репозиторий на всех устройствах свежим
git clone.
А теперь по порядку и с объяснениями.
Симптомы: что видно в терминале#
Полный набор ошибок, который сыпется в таком инциденте, выглядит примерно так:
error: garbage at end of loose object '2699e4e6…'
fatal: loose object 2699e4e6… is corrupt
remote: fatal: early EOF
remote: index-pack failed
fatal: failed to run repackВажно уметь читать эту простыню, потому что тут причина и следствие вперемешку:
garbage at end of loose objectиloose object … is corrupt— это и есть корень. Локальный объект в.git/objectsпобит.remote: early EOF/index-pack failed— это следствие. Ты пытался запушить, git начал слать пак-файл, но подавился на битом объекте и оборвал поток. До remote порча не доехала — сервер просто получил обрубленные данные и отказался их принимать. Это хорошая новость: на origin всё цело.fatal: failed to run repack— это ужеgit gcспотыкается о тот же самый битый объект, когда пытается перепаковать локальную базу.
Главный аргумент: поломка локальная. Origin здоров, рабочие файлы на диске здоровы. Побит только промежуточный слой — объектная база в
.git. Именно это делает починку без потери данных возможной.
Что такое loose-объект и почему «garbage»#
Чтобы команды дальше не выглядели как заклинания, нужно понимать, что именно побилось.
Git хранит содержимое в объектах. Пока объект «свежий» и ещё не упакован в pack-файл, он лежит отдельным файлом — это и есть loose-объект, по пути .git/objects/XX/YYYY…, где XX — первые два символа SHA-1, а YYYY… — остальные 38. Внутри такого файла лежит содержимое, сжатое zlib (deflate), с коротким заголовком вида blob <размер>\0 перед сжатием.
Ключевой факт, из которого растёт вся диагностика: сжатый объект обязан быть меньше исходника. Это же компрессия — на то она и нужна. Даже плохо сжимаемый бинарник zlib ужмёт хотя бы чуть-чуть, а текст — в разы.
Поэтому первый же трюк для поиска порчи — просто посмотреть на размер файла:
ls -la .git/objects/26/99e4…Если loose-объект на диске в разы больше исходного файла, который он должен хранить, — всё, он раздут мусором. Внутрь zlib-потока налезли лишние байты, которых там быть не должно. Именно об этом git и говорит: garbage at end of loose object — «после валидного сжатого потока идёт мусор». Распаковщик zlib дошёл до конца легитимных данных, а файл ещё не кончился — дальше идёт то, что git честно называет garbage.
Откуда берётся порча#
Причина №1, и в моём случае главная — одновременная запись в .git/objects с нескольких устройств через облачную синхронизацию. Представь: у тебя репозиторий (в моём случае — целое хранилище заметок) лежит в папке, которую синхронизирует облако — iCloud, Dropbox, что угодно. На одном устройстве настроен авто-commit/push, оно пишет в .git/objects. В этот же момент облачный клиент тянет свою версию тех же файлов с другого устройства и кладёт поверх. Git писал объект атомарно, но синхронизатор про атомарность git ничего не знает — он видит два конфликтующих состояния одного файла и склеивает их по-своему. Результат — loose-объект, у которого к валидному zlib-потоку прилеплен хвост от другой версии. Тот самый garbage.
Облачные хранилища не проектировались под то, чтобы под ними жила объектная база git с конкурентной записью. Для них .git/objects/ab/cdef… — обычный файл, а не часть транзакционной базы данных. Отсюда и мораль (к ней вернёмся в профилактике): .git под живой облачной синхронизацией с нескольких устройств — это гонка, которая рано или поздно порвёт объект.
Причина №2 — бинарники, закоммиченные сырьём мимо Git LFS. Тут стоит на минуту нырнуть во внутренности LFS, потому что без этого диагностика ниже не сложится.
Git LFS (Large File Storage) работает как подмена через clean/smudge-фильтры. Когда файл подпадает под правило в .gitattributes, при коммите LFS перехватывает его (clean-фильтр) и вместо самого бинарника кладёт в историю крошечный текстовый указатель:
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a2…
size 1048576Сам бинарник уезжает в отдельное LFS-хранилище, а в git-истории живёт вот эта табличка на три строки. При checkout smudge-фильтр делает обратное — подтягивает бинарник по oid. Изящно и легковесно.
А теперь ломаем: если устройство коммитит без работающего LFS (плагин авто-бэкапа не умеет LFS, фильтр не настроен, .gitattributes не подхватился), то бинарник уходит в историю как есть — целиком, сырыми байтами. Большой, плохо сжимаемый, лежит loose-объектом. И вот такой жирный loose-объект под конкурентной записью через облако бьётся особенно охотно — просто потому, что он большой и запись его дольше.
Диагностика#
Прежде чем чинить, надо понять масштаб: какие объекты побиты и чему они соответствуют.
Найти битые объекты:
git fsck --fullfsck (file system check) пройдёт по всей объектной базе и покажет corrupt, missing и прочие проблемы. Он же вывалит кучу dangling — это недостижимые, но валидные объекты (нормальный побочный продукт работы git), они шумят и к порче отношения не имеют. Отфильтруем:
git fsck --full | grep -vE "dangling"Остаётся то, что реально болит — строки про corrupt-объекты с их хешами.
Понять, какому файлу соответствует битый блоб:
git ls-tree -r -l <commit> | grep -E "<hash1>|<hash2>"ls-tree -r -l разворачивает дерево коммита рекурсивно с размерами и хешами блобов. Грепом по хешам битых объектов находишь имя файла — теперь ты знаешь, что именно побилось (у меня это оказались PDF, которые уехали в историю мимо LFS).
Проверить, совпадает ли рабочий файл с тем, что записано:
git hash-object "<path>"Тонкость: в репозитории с настроенным LFS git hash-object прогонит файл через clean-фильтр и выдаст хеш LFS-указателя, а не самого бинарника. Если ты этого не ждёшь — легко запутаться. Это не баг, это ровно тот самый механизм подмены из предыдущего раздела.
Посмотреть, что лежит на здоровом origin:
git cat-file -p origin/main:"<path>"Для файла под LFS здесь ты должен увидеть те самые 133 байта указателя version https://git-lfs.github.com/spec/v1 …. Если на origin лежит корректный указатель, а локально — раздутый бинарник, диагноз окончательный: локальная база побита, remote здоров, лечим сбросом на него.
Рецепт 1: починка порчи без потери данных#
Соблазн — начать «чинить» битый объект: распаковать, обрезать хвост, запаковать обратно. Забудь. Garbage-байты неотделимы от валидных без гадания, где кончается одно и начинается другое. Реанимация битого объекта — тупиковый путь.
Правильная идея другая и куда проще: не чини битый объект — обойди его. Сбрось ветку на здоровый origin и пересобери своё рабочее дерево одним чистым коммитом. Это работает, потому что сходятся три вещи: на remote история цела, твои рабочие файлы на диске целы, а LFS при новом коммите заново обработает бинарники правильно. Битым объектам после такого сброса просто перестаёт кто-либо адресоваться — они становятся мусором, который остаётся физически удалить.
И сразу про то, почему git gc бесполезен как первая реакция. Интуиция «что-то побилось — запущу сборку мусора» здесь обманывает. gc первым делом идёт перепаковывать объекты — и натыкается на тот самый битый, падая с failed to run repack. Инструмент, которым ты собрался чинить, сам спотыкается о поломку. Сначала надо убрать битьё из-под ног, и только потом звать gc.
Полная последовательность:
# 1. HEAD → здоровая база с remote. --mixed НЕ трогает рабочее дерево:
# все твои файлы на диске остаются как есть, сбрасывается только указатель ветки и индекс.
git reset --mixed origin/main
# 2. (опционально) поправить рабочее дерево — удалить/восстановить что нужно
# 3. Пересобрать реальные правки заново, одним чистым коммитом — мимо битых блобов.
# Здесь же корректно отработает LFS, если он настроен.
git add -A && git commit -m "Пересборка рабочего дерева после порчи .git"
# 4. Физически удалить битые loose-объекты. После reset на них никто не ссылается.
rm ".git/objects/26/99e4…" ".git/objects/9a/06ce…"
# 5. Выкинуть битые коммиты из reflog, чтобы они стали недостижимы
git reflog expire --expire=now --all
# 6. Теперь перепаковка не спотыкается — мусор недостижим и удалён
git gc --prune=now
# 7. Проверка — должно быть чисто
git fsck --full
# 8. Обычный fast-forward. force НЕ нужен: мы не переписывали общую историю,
# а до-собрали её поверх здорового origin.
git push origin mainОтдельно подчеркну про порядок шагов 4–6, потому что переставить их местами — значит снова получить падение:
- Сначала
reflog expire— пока reflog помнит битые коммиты, они «достижимы», иgcих не тронет (аrmбез этого оставит висячие ссылки). - Потом
rmбитых блобов — убираем сами испорченные файлы с диска. - И только потом
gc— ему больше не обо что спотыкаться, вся недостижимая труха вычищается штатно.
Если запустить gc до того, как битьё стало недостижимым, — он снова упадёт на нём же. Дисциплина порядка тут не педантизм, а условие того, что рецепт вообще сработает.
После этого git fsck --full показывает чистоту, push проходит обычным fast-forward. Данные на месте: рабочие файлы ты не трогал ни разу, а история пересобралась поверх здоровой базы.
Вторая беда: раздутый .git#
Пока я разбирался с порчей, вскрылось, что .git неприлично разбух — сотни мегабайт на репозитории, где рабочих файлов от силы на десятки. Причина оказалась не связана с порчей напрямую, но поучительна.
Балласт сидел в истории. Когда-то в репозиторий закоммитилась машинно-генерируемая папка плагина .obsidian/plugins/vault-full-statistics — индекс, кэш, что-то в этом духе. Потом её убрали из HEAD и добавили в .gitignore. В рабочем дереве её больше нет, git status чист. Но в истории она осталась навсегда — каждый её старый снапшот всё ещё лежит в объектной базе и честно занимает место. Плюс те самые PDF, уехавшие сырьём мимо LFS, — они тоже раздували .git, а не LFS-хранилище.
И вот тут легко забуксовать:
Главный аргумент:
git gcиgit lfs pruneисторию не переписывают. Они убирают недостижимое и осиротевшее, но объект, который достижим из старого коммита в истории, — не мусор с их точки зрения. Он «нужен». Пока он в истории, ниgc, ниlfs pruneего не тронут.
То есть балласт, вшитый в историю, стандартными средствами не убрать в принципе. Нужна перепись истории — операция, которая физически перестраивает коммиты так, будто мусорных путей там никогда не было.
Рецепт 2: чистка истории через git-filter-repo#
Инструмент для переписи — git-filter-repo (официальная замена устаревшему git filter-branch, быстрее и безопаснее). Порядок такой.
Сначала найти, что вообще весит. Топ тяжёлых объектов в истории с путями:
git rev-list --objects --all | \
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize:disk) %(rest)' | \
awk '/^blob/ {print $3, $2, substr($0, index($0,$4))}' | sort -rn | head -20Разберём конвейер: rev-list --objects --all перечисляет все объекты во всей истории вместе с их путями; cat-file --batch-check дописывает каждому тип, хеш, размер на диске и путь; awk оставляет только блобы и переставляет поля; sort -rn | head -20 даёт двадцатку самых жирных. Ты сразу видишь, какие пути жрут место.
Обязательная страховка ПЕРЕД переписью. Перепись истории необратима — сделай полный bundle всего репозитория, это твоя точка отката:
git bundle create /tmp/repo-backup-$(git rev-parse --short HEAD).bundle --allИз такого bundle репозиторий разворачивается целиком (git clone repo-backup.bundle), если что-то пойдёт не так.
Собственно перепись — вырезаем мусорные пути из всей истории:
brew install git-filter-repo # или pip install git-filter-repo
git filter-repo --invert-paths \
--path ".obsidian/plugins/vault-full-statistics/" \
--path "_Система/2. Статика/pz-3_zadacha_koshi_dlja_uravnenija_teploprovodnos.pdf" \
--force--invert-paths инвертирует смысл --path: перечисленное — не «оставить только это», а «удалить это из всей истории». Каждый коммит пересобирается так, будто указанных путей там не было.
После переписи — прибраться и вернуть remote:
git gc --prune=now # теперь недостижимые старые объекты реально удаляются
git lfs prune # сбросить осиротевшие LFS-блобы из локального кэша
# filter-repo НАРОЧНО удаляет remote — защита от случайного push. Возвращаем:
git remote add origin git@github.com:<user>/<repo>.git
git push --force origin mainОбрати внимание на два момента. Первый: filter-repo сам удаляет origin после работы — это не баг, а предохранитель, чтобы ты случайно не запушил переписанную историю до того, как всё проверил. Второй: push --force тут неизбежен — история переписана, fast-forward невозможен по определению.
По порядку величин на моём репозитории вышло примерно так: .git ужался с ~560 МБ до ~317 МБ. Основную долю дали два куска — вырезанная папка плагина (сама объектная база просела почти вдвое) и очистка LFS-кэша от сирот после PDF. Цифры у тебя будут свои, но масштаб «в полтора-два раза» — вполне типичный, если в истории годами копился генерируемый мусор.
Критично после переписи истории#
Тут кроется грабля, на которую наступают все, кто впервые переписывает историю.
Перепись меняет все SHA коммитов. Раз содержимое коммитов пересобрано, их хеши другие — а значит, старые клоны репозитория несовместимы с новой историей от слова совсем. И если у тебя, как у меня, есть автоматический бэкап (сервер, второе устройство, cron с git push), который держит старый клон, произойдёт вот что: при следующем автопуше он вернёт старую историю со всем мусором обратным push’ем. Вся твоя чистка обнулится.
Поэтому железное правило: до следующего автобэкапа/автопуша переклонировать репозиторий заново на всех устройствах. Именно git clone начисто, а не git pull — pull попытается смёржить несовместимые истории и сделает только хуже. Снёс старый клон, склонировал свежий — и только тогда включай обратно автоматику.
И ещё одно про квоту: LFS-хранилище на GitHub перепись истории НЕ освобождает. Объекты остаются в remote-LFS, даже если ты вырезал ссылки на них из истории. Если тебе важна именно квота LFS — это отдельная операция (git lfs со стороны remote-инструментов провайдера), не путать с чисткой .git.
Профилактика#
Всё вышеописанное лечит последствия. Но правильнее — не доводить. Три правила, каждое из которых закрывает конкретную причину из разбора:
- Не держи
.gitпод одновременной записью с нескольких устройств через облако. Это корень порчи loose-объектов. Пусть коммитить и пушить будет что-то одно — одно устройство или один сервер с настроенным LFS. Остальные пусть только читают/синхронизируют рабочие файлы, но не воюют за.git/objects. - Бинарники — через Git LFS, а не сырьём в историю. Настрой
.gitattributesи убедись, что LFS реально работает на каждом устройстве, которое коммитит. Один коммит мимо LFS — и жирный объект уже в истории навсегда (см. рецепт 2). - Машинно-генерируемые папки — в
.gitignoreс самого начала. Индексы, кэши плагинов, артефакты сборки — им не место в истории. Их дёшево перегенерировать и дорого потом вырезать переписью.
Заключение#
Если свести всё к паре мыслей, которые стоит унести:
- git ломается тихо и чинится не тем, чем кажется.
garbage at end of loose objectвыглядит как приговор, аgit gc— как очевидное лекарство. На деле объект побит локально, аgc— первый, кто об него спотыкается. - Порча объектов — это не потеря данных, пока жив origin. Не реанимируй битьё — сбрось ветку на здоровую базу и пересобери рабочее дерево одним коммитом. Данные в рабочих файлах и на remote к порче объектной базы не имеют отношения.
- Раздутый
.gitлечится только переписью истории.gcиlfs pruneне трогают то, что достижимо из старых коммитов. Балласт из истории убираетgit-filter-repo— и то под страховкой bundle и с обязательным переклонированием после.
Что сделать прямо сейчас#
- Проверь, не синхронизируется ли
.gitтвоего репозитория через облако с нескольких устройств. Если да — оставь коммиты за одним источником. - Убедись, что Git LFS реально работает там, где ты коммитишь бинарники:
git lfs status,git lfs track, актуальный.gitattributes. - Прогони
git fsck --fullна репозиториях, которые давно живут под синхронизацией, — лучше найти порчу заранее, чем в момент срочного пуша. - Загляни в топ тяжёлых объектов истории (команда из рецепта 2) — вдруг у тебя тоже годами копится генерируемый мусор.
Ловил такое? Через что синхронизируешь свои репозитории и не прилетала ли порча именно из-за облака между устройствами?