Восстановление: reflog, потерянные коммиты, испорченный репозиторий
Есть жанр историй, которые рассказывают друг другу разработчики: «я сделал git reset --hard и потерял два дня работы». В подавляющем большинстве случаев эта история неправдива — не потому, что человек врёт, а потому что он не знал, где смотреть. Работа лежала на месте, в .git/objects, ещё как минимум месяц после «потери».
Это не магия и не удача. Это прямое следствие модели данных, которую мы разбирали в статье про внутреннее устройство: объекты Git неизменяемы и адресуются по хешу содержимого, а ссылки — это отдельная, тонкая надстройка сверху. Отсюда главный тезис всей статьи:
В Git вы почти никогда не теряете данные. Вы теряете ссылку на данные. Восстановление — это задача найти адрес, а не воскресить содержимое.
Разница принципиальна. Воскрешать удалённые байты — задача форензики с непредсказуемым результатом. Искать сорокабайтовый хеш в журнале, который Git ведёт за вас автоматически, — задача на две команды. Эта статья про то, как устроен этот журнал, где Git прячет «потерянное», в каких ровно случаях данные действительно исчезают (такие случаи есть, и их надо знать поимённо) и что делать, когда испортился не ваш рабочий процесс, а сам репозиторий.
Достижимость: единственное понятие, которое нужно усвоить
Объектная база Git — это ориентированный граф. Коммит ссылается на дерево и на родителей, дерево — на поддеревья и блобы, аннотированный тег — на коммит. Корни этого графа — ссылки: ветки в refs/heads/, теги в refs/tags/, remote-tracking в refs/remotes/, плюс псевдоссылки вроде HEAD, ORIG_HEAD, MERGE_HEAD.
Объект называется достижимым (reachable), если из какого-нибудь корня до него можно дойти по стрелкам. Всё остальное — недостижимое (unreachable). Термин «висячий» (dangling) чуть уже: это недостижимый объект, на который не ссылается вообще ни один другой объект, то есть верхушка осиротевшей ветки графа.
Сборщик мусора удаляет недостижимое — но не сразу и не молча. Между «стал недостижимым» и «физически удалён» лежат два независимых механизма отсрочки: reflog и grace-период prune. Вот полный жизненный цикл объекта:
Три числа, которые стоит запомнить наизусть, потому что они задают ваш запас времени на панику:
| Настройка | Значение по умолчанию | Что удерживает |
|---|---|---|
gc.reflogExpire |
90 дней | записи reflog о коммитах, которые всё ещё достижимы |
gc.reflogExpireUnreachable |
30 дней | записи reflog о коммитах, ставших недостижимыми |
gc.pruneExpire |
2 недели | недостижимые объекты, уже вылетевшие из reflog |
Записи под refs/stash Git по умолчанию не истекает никогда — специальный случай в коде git reflog expire. Ваша заначка не растворится через месяц.
reflog: журнал движения каждой ссылки
Reflog — это не «история коммитов». Это журнал изменений значения ссылки. Каждый раз, когда refs/heads/main перестаёт указывать на один коммит и начинает указывать на другой, Git дописывает строку в .git/logs/refs/heads/main. То же для HEAD — в .git/logs/HEAD.
Посмотрим сырой файл, без всякого фарфора:
$ cat .git/logs/HEAD
0000000000000000000000000000000000000000 9f1c0d2b8a... Ada L <ada@ex.io> 1721113200 +0300 commit (initial): скелет проекта
9f1c0d2b8a... 4e7a1b8c3d... Ada L <ada@ex.io> 1721116800 +0300 commit: парсер конфига из YAML
4e7a1b8c3d... c3d9e0217f... Ada L <ada@ex.io> 1721120400 +0300 commit: валидация входных параметров
c3d9e0217f... 71a4f6b5e9... Ada L <ada@ex.io> 1721124000 +0300 commit: кэш ответов для /api/v2/search
71a4f6b5e9... 9f1c0d2b8a... Ada L <ada@ex.io> 1721127600 +0300 reset: moving to HEAD~3
Формат до неприличия прост: <старый SHA> <новый SHA> <автор операции> <unix-время> <часовой пояс>\t<сообщение>. Первая запись имеет старым значением сорок нулей — «ссылки не существовало». Никакой отдельной базы, никакого бинарного формата: обычный append-only текстовый лог, по строке на операцию.
Из этой схемы сразу следуют неочевидные для многих свойства.
Reflog локален и не клонируется. Его нет в протоколе передачи. Свежий git clone имеет ровно одну запись — «clone: from …». Поэтому CI-раннер с чистым клоном лишён этой страховки полностью, и поэтому «спроси у коллеги» — реальная стратегия восстановления: у коллеги свой reflog, где может лежать нужный вам SHA.
В bare-репозиториях reflog по умолчанию выключен. core.logAllRefUpdates равен true для обычных репозиториев и false для bare. Если у вас собственный Git-сервер — включите его явно, это самая дешёвая страховка от чужого force-push:
git config --global core.logAllRefUpdates true # на сервере, в шаблоне репозиториев
Записи ведутся и для remote-tracking-ссылок. git reflog show origin/main покажет, какие значения refs/remotes/origin/main принимала на вашей машине. Это спасательный круг после чужого force-push: даже если на сервере старой ветки уже нет, ваш локальный журнал помнит её хеш.
У новых репозиториев с бэкендом reftable (экспериментальный формат, документация в исходниках Git) журналы хранятся не файлами в .git/logs/, а внутри reftable-таблиц. Команда git reflog работает так же, но cat .git/logs/HEAD — уже нет. Не пугайтесь, если файла не окажется.
Синтаксис @{…} и чем HEAD@{2} отличается от main@{2}
Полностью описан в gitrevisions(7), и разница между двумя формами регулярно стоит людям нервов:
git show HEAD@{2} # значение, которое HEAD имел ДВЕ операции назад
git show main@{2} # значение, которое ВЕТКА main имела две свои операции назад
git show main@{yesterday} # где была main вчера в это же время
git show 'main@{2 hours ago}'
git checkout @{-1} # предыдущая checkout-нутая ветка, аналог "cd -"
git rev-parse @{u} # upstream текущей ветки, то же что @{upstream}
git log main@{1}..main # что приехало последней операцией над main
HEAD@{n} считает любые движения HEAD, включая переключения веток. main@{n} считает только изменения самой ветки main — переключение на другую ветку в её журнал не попадает. Отсюда практическое правило: для «что я вообще делал» смотрите HEAD, для «куда уезжала конкретная ветка» — саму ветку.
И главное предостережение: числовые индексы плавают. HEAD@{1} сегодня и через три команды — разные коммиты. Как только нашли нужное — немедленно скопируйте сам SHA и работайте с ним. Ещё лучше — сразу поставьте на него ветку: ветка удерживает объект от сборки мусора, индекс reflog — нет.
Сеанс восстановления целиком
Соберём разрозненные команды в один реальный сеанс. Ситуация: три коммита разработки, потом «случайный» git reset --hard HEAD~3.
$ git log --oneline
71a4f6b (HEAD -> main) кэш ответов для /api/v2/search
c3d9e02 валидация входных параметров
4e7a1b8 парсер конфига из YAML
9f1c0d2 скелет проекта
$ git reset --hard HEAD~3
HEAD is now at 9f1c0d2 скелет проекта
$ git log --oneline
9f1c0d2 (HEAD -> main) скелет проекта
Лог показывает, что трёх коммитов нет. Объектная база утверждает обратное:
$ git reflog
9f1c0d2 (HEAD -> main) HEAD@{0}: reset: moving to HEAD~3
71a4f6b HEAD@{1}: commit: кэш ответов для /api/v2/search
c3d9e02 HEAD@{2}: commit: валидация входных параметров
4e7a1b8 HEAD@{3}: commit: парсер конфига из YAML
9f1c0d2 HEAD@{4}: commit (initial): скелет проекта
$ git cat-file -t 71a4f6b
commit
$ git show --stat 71a4f6b | head -5
commit 71a4f6b5e9c1d0a8f2b3c4d5e6f70819a2b3c4d5
Author: Ada L <ada@ex.io>
Date: Tue Jul 16 12:20:00 2026 +0300
кэш ответов для /api/v2/search
Объект на месте, читается, показывает diff. Дальше — три способа вернуть его, от осторожного к решительному:
# 1. Самый безопасный: поставить ветку и посмотреть, ничего не ломая
git branch rescue/cache 71a4f6b
git log --oneline rescue/cache
# 2. Ещё безопаснее: отдельный рабочий каталог, текущий не трогаем вообще
git worktree add /tmp/rescue 71a4f6b
ls /tmp/rescue
# 3. Решительный: вернуть main туда, где она была
git reset --hard 71a4f6b
И обязательный шаг, который почти все пропускают, — проверить, что восстановили именно то:
$ git diff 71a4f6b HEAD # пусто = содержимое совпадает побайтово
$ git range-diff 9f1c0d2..71a4f6b 9f1c0d2..HEAD # сравнение серий коммитов, а не снимков
git range-diff сравнивает две последовательности коммитов и показывает, какие патчи совпали, какие изменились и какие пропали. После любого восстановления и после любого rebase это правильная финальная проверка: git diff скажет, что снимки одинаковы, а range-diff — что одинаковы и шаги.
Что именно пишет в reflog, а что нет
Единственная таблица, которую стоит распечатать. Она честно отвечает на вопрос «спасёт ли меня reflog» для каждой команды.
| Операция | Двигает HEAD | Пишет в reflog | Спасает ли reflog |
|---|---|---|---|
git commit |
да | да | да |
git commit --amend |
да | да | да, прежний коммит в предыдущей записи |
git reset --hard <c> |
да | да | коммиты — да; незакоммиченные правки — нет |
git checkout / git switch |
да | да | да |
git rebase |
да | да, много записей | да, плюс ORIG_HEAD |
git merge, git pull |
да | да | да, плюс ORIG_HEAD |
git cherry-pick, git revert |
да | да | да |
git fetch |
нет | да, для refs/remotes/* |
да, помнит прежние значения серверных веток |
git branch -D |
нет | нет; журнал ветки удаляется вместе с ней | только через reflog HEAD или fsck |
git tag -d |
нет | нет | только fsck |
git stash push |
нет | да, в refs/stash |
да |
git stash drop / clear |
нет | удаляет запись | только fsck |
git add |
нет | нет | blob жив, ищется через fsck |
git restore <файл> |
нет | нет | нет, если файла не было в индексе или коммите |
git clean -fd |
нет | нет | нет — эти файлы Git никогда не видел |
git gc --prune=now |
нет | нет | ничего не спасает, это и есть удаление |
Три строчки в этой таблице выделены не случайно. Именно они описывают реальные способы потерять работу в Git, и ни один из них не связан с историей коммитов.
Диагностическое дерево
git cat-file -p SHA > файл"] Q1 -- "нет, и git add не было" --> A2["Git не поможет.
Local History IDE, снапшот ФС, бэкап"] Q1 -- "да" --> Q2{"Коммит уходил на сервер?"} Q2 -- "да" --> A3["git fetch origin
восстановление ветки в UI, Events API, зеркало"] Q2 -- "нет" --> Q3{"Есть запись в reflog?"} Q3 -- "да" --> A4["git reflog
git branch rescue SHA"] Q3 -- "нет" --> Q4{"git gc --prune уже отработал?"} Q4 -- "нет" --> A5["git fsck --unreachable --no-reflogs
git stash apply SHA"] Q4 -- "да" --> A6["Объектов нет.
Клон коллеги, зеркало, бэкап сервера"]
Второй эшелон: git fsck и висячие объекты
Когда reflog не помог — ветку удалили и её журнал ушёл вместе с ней, запись о stash сброшена, — остаётся прямой обход объектной базы. Этим занимается git fsck.
# всё, до чего нельзя дойти от ссылок; reflog считается корнем по умолчанию
git fsck --unreachable
# то же, но reflog корнем НЕ считать — покажет и то, что ещё держится журналом
git fsck --unreachable --no-reflogs
# «висячие» верхушки: недостижимые объекты, на которые не ссылается ничто
git fsck --dangling
# то же плюс раскладка находок по каталогу .git/lost-found/
git fsck --lost-found
Вывод выглядит так:
Checking object directories: 100% (256/256), done.
Checking objects: 100% (1843/1843), done.
dangling commit 3a9f1c2e7b40d5c1a9e8f7b6c5d4e3f201928374
dangling blob 6f0a2c9e1b3d5f7a9c0e2b4d6f8a0c2e4b6d8f01
dangling tree 8c1d3e5f70921a3b5c7d9e1f3a5b7c9d1e3f5a70
Про сложность: fsck строит полное множество достижимых объектов, то есть обходит весь граф — время O(N) по числу объектов, память O(N) на битовую карту достижимости плюс словарь. В репозитории на миллион объектов это десятки секунд, не мгновение. Ключ --connectivity-only пропускает распаковку и проверку содержимого и работает в разы быстрее, когда вам нужны только адреса.
git fsck --lost-found раскладывает найденное по каталогам:
.git/lost-found/commit/3a9f1c2e7b40d5c1a9e8f7b6c5d4e3f201928374
.git/lost-found/other/6f0a2c9e1b3d5f7a9c0e2b4d6f8a0c2e4b6d8f01
Файлы в commit/ — пустышки-маркеры (имя есть SHA), в other/ — реальное содержимое блобов, которое можно просто открыть текстовым редактором.
Скрипт: инвентаризация потерянного
Голый список SHA бесполезен. Полезен список коммитов с датой, автором и темой, отсортированный по времени. Псевдокод:
найти множество U = недостижимые объекты типа commit
если U пусто → выйти
одним вызовом git log получить для всех SHA из U: дату, автора, тему
вывести отсортированным по дате убывания
подсказать команды спасения
Реализация. Существенная деталь — один вызов git log --no-walk на весь список вместо процесса на коммит: разница между секундой и минутой на сотне находок.
#!/usr/bin/env python3
"""Инвентаризация потерянных коммитов: что можно спасти прямо сейчас.
Запуск: python3 rescue.py [путь-к-репозиторию]
"""
from __future__ import annotations
import subprocess
import sys
def git(repo: str, *args: str) -> str:
"""Запускает git и возвращает stdout. Прогресс и ошибки git пишет в stderr."""
res = subprocess.run(
["git", "-C", repo, *args],
capture_output=True, text=True, check=False,
)
# fsck возвращает 1, если нашёл проблемы, — для нас это нормальный результат
if res.returncode not in (0, 1):
raise RuntimeError(res.stderr.strip())
return res.stdout
def unreachable_commits(repo: str) -> list[str]:
"""SHA коммитов, до которых нельзя дойти ни от ссылок, ни от reflog.
Сложность: git fsck обходит весь граф объектов — O(N) по времени
и O(N) по памяти на множество достижимых объектов.
"""
out = git(repo, "fsck", "--unreachable", "--no-reflogs", "--no-progress")
return [
line.split()[2]
for line in out.splitlines()
if line.startswith("unreachable commit ")
]
def describe(repo: str, shas: list[str]) -> str:
"""Один git log на весь список: O(len(shas)) чтений объектов, один процесс."""
if not shas:
return ""
fmt = "%h %cd %<(20,trunc)%an %s"
return git(repo, "log", "--no-walk", "--date=short", f"--format={fmt}", *shas)
def main() -> int:
repo = sys.argv[1] if len(sys.argv) > 1 else "."
shas = unreachable_commits(repo)
if not shas:
print("Недостижимых коммитов нет: reflog всё ещё держит историю.")
return 0
print(f"Найдено недостижимых коммитов: {len(shas)}\n")
print(describe(repo, shas))
print("\nСпасти: git branch rescue/<имя> <sha>")
print("Посмотреть: git worktree add /tmp/rescue <sha>")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Тот же результат одной строкой, когда Python под рукой нет:
git fsck --unreachable --no-reflogs 2>/dev/null \
| awk '$2 == "commit" { print $3 }' \
| xargs -r git log --no-walk --date=short --format='%h %cd %an %s'
Разбор реальных сценариев
Удалили ветку
Git сам печатает спасательный SHA в момент удаления — прочитайте вывод, прежде чем закрывать терминал:
$ git branch -D feature/search-cache
Deleted branch feature/search-cache (was 71a4f6b).
Терминал уже закрыт? Собственный журнал ветки удалён вместе с ней, но журнал HEAD помнит, что вы на неё переключались:
$ git reflog | grep -i search-cache
71a4f6b HEAD@{9}: checkout: moving from feature/search-cache to main
3c8b1d0 HEAD@{12}: checkout: moving from main to feature/search-cache
$ git branch feature/search-cache 71a4f6b
Ветку никогда не чекаутили локально (например, она приехала через git fetch)? Тогда только git fsck --dangling плюс git log по находкам.
git commit --amend съел предыдущий коммит
Не съел. amend физически не может изменить объект — он создаёт новый коммит с тем же родителем и переставляет ветку. Старый остаётся в базе:
ни одна ветка не указывает"] --> C2 C3n["c3' — результат amend
HEAD, main"] --> C2 C2 --> C1 RL["reflog HEAD: прежнее значение = c3"] -.-> C3
Стрелка означает «ссылка на родителя» — так, как это записано в самом объекте коммита. Оба c3 и c3' существуют одновременно и делят одни и те же деревья и блобы для неизменившихся файлов.
$ git reflog -3
5b2e9f1 (HEAD -> main) HEAD@{0}: commit (amend): исправленное сообщение
71a4f6b HEAD@{1}: commit: кэш ответов для /api/v2/search
c3d9e02 HEAD@{2}: commit: валидация входных параметров
$ git diff 71a4f6b 5b2e9f1 # что реально изменилось при amend
$ git reset --hard 71a4f6b # передумали — вернулись
Rebase пошёл не туда
Rebase — главный поставщик паники и при этом самая безобидная операция с точки зрения сохранности. Он не переписывает объекты, он создаёт копии: для каждого коммита исходной серии появляется новый объект с другим родителем и другим временем коммита, а старые остаются недостижимыми, но живыми. Подробный разбор механики — в «Merge и rebase» и «Переписывании истории».
Три уровня отката:
# 1. Rebase ещё идёт (конфликт, вы в состоянии "rebase in progress")
git rebase --abort # читает .git/rebase-merge/orig-head, возвращает всё как было
git rebase --show-current-patch # посмотреть, на каком патче застряли
git rebase --edit-todo # переписать оставшийся план
# 2. Rebase завершился, результат не нравится
git reset --hard ORIG_HEAD # ORIG_HEAD выставлен в начале операции
# 3. ORIG_HEAD уже затёрт следующим merge/reset — идём в reflog
git reflog | grep -n 'rebase'
Честно про ORIG_HEAD: это одноместный слот, который переписывают reset, merge, pull, rebase и am. Достаточно одного git pull после неудачного rebase — и он указывает уже не туда. Reflog надёжнее, потому что он append-only.
Отдельно про споры «rebase опаснее merge». С точки зрения сохранности данных — нет, обе операции ничего не удаляют, обе полностью откатываются одной строкой. Реальная опасность rebase не техническая, а процессная: переписанная и force-запушенная публичная ветка ломает работу всем, кто на неё уже опирался, и восстановление становится задачей на несколько человек вместо одной команды. То есть спор идёт не про Git, а про соглашение «кто и когда имеет право переписывать общие ветки» — ровно то же различие, что мы разбирали в стратегиях ветвления.
Потерянный stash
Стек stash — это reflog ссылки refs/stash. stash@{0} не «нулевой элемент массива», а запись журнала номер ноль. Сам stash — коммит с двумя родителями (HEAD и коммит состояния индекса), а при -u — с тремя.
$ git stash list
stash@{0}: WIP on main: 71a4f6b кэш ответов
stash@{1}: On feature: эксперимент с параллельным парсингом
$ git cat-file -p refs/stash | head -4
tree a0b1c2d3...
parent 71a4f6b5e9... # HEAD на момент stash
parent 0d9e8c7b6a... # состояние индекса
author Ada L <ada@ex.io> 1721127600 +0300
git stash drop удаляет запись журнала, а не коммит. Коммит становится недостижимым — и находится:
$ git fsck --unreachable --no-reflogs 2>/dev/null | awk '$2=="commit"{print $3}' \
| xargs -r git log --no-walk --merges --format='%h %ci %s'
3a9f1c2e 2026-07-16 14:31:00 +0300 WIP on main: 71a4f6b кэш ответов
$ git stash apply 3a9f1c2e # применить, не трогая текущий стек
Фильтр --merges здесь не случаен: stash-коммит всегда имеет минимум двух родителей, поэтому он отсекает обычные потерянные коммиты и оставляет именно заначки.
Потерянные staged-изменения
Самый недооценённый трюк. git add уже записал блоб в объектную базу — до всякого коммита. Значит, даже такая последовательность обратима:
$ git add src/parser.py # блоб записан в .git/objects
$ git reset --hard HEAD # индекс и рабочий каталог откатились
$ git fsck --lost-found
dangling blob 6f0a2c9e1b3d5f7a9c0e2b4d6f8a0c2e4b6d8f01
$ git cat-file -p 6f0a2c9e > src/parser.py # содержимое вернулось
Оговорка честная: у блоба нет имени файла и нет даты — эта информация живёт в дереве, которого нет. Если находок много, различать придётся по содержимому. Помогает сортировка loose-объектов по времени записи на диск:
ls -lt .git/objects/??/* | head -20
Force-push затёр чужие коммиты
Здесь важна не только процедура спасения, но и понимание, почему --force-with-lease не всегда спасает. Классическая гонка:
Суть проблемы: --force-with-lease сравнивает не «то, что вы видели», а текущее значение remote-tracking-ссылки. Любой фоновый fetch — а его делают IDE, оболочки с подсказками веток, git maintenance — обновляет её и делает lease бессмысленным. Лечится ключом --force-if-includes (Git 2.30+), который дополнительно требует, чтобы новые коммиты были построены поверх того, что вы действительно наблюдали:
git push --force-with-lease --force-if-includes
git config --global push.useForceIfIncludes true # включить по умолчанию
Восстановление на стороне пострадавшего — по порядку убывания надёжности:
- Свой локальный reflog remote-tracking-ссылки:
git reflog show origin/feature— прежнее серверное значение записано у вас на диске. - Локальная ветка любого коллеги, кто успел сделать
fetchдо затирания:git fetch <его-remote> '+refs/*:refs/rescue/*'. - Reflog на сервере, если это ваш bare-репозиторий с включённым
core.logAllRefUpdates. - UI хостинга. GitHub позволяет восстановить удалённую ветку со страницы PR (документация); события push с парой «до/после» видны через Events API. Объект, известный по SHA, обычно открывается по прямой ссылке
…/commit/<sha>даже без ветки — до серверной сборки мусора. - Зеркало, если оно у вас есть.
Профилактика дешевле любого пункта из списка: защита веток на хостинге плюс серверные настройки для собственного Git:
git config receive.denyNonFastForwards true # запретить перезапись истории
git config receive.denyDeletes true # запретить удаление веток
git config receive.fsckObjects true # проверять приходящие объекты
Испорченный репозиторий
До сих пор речь шла о потерянных ссылках. Теперь — о случае, когда повреждены сами файлы в .git. Типичные причины: закончилось место на диске посреди записи, внезапное выключение, .git внутри синхронизируемой папки облачного диска, антивирус, rsync живого репозитория, сетевая ФС без нормальных блокировок.
Ключевая мысль карты: данные лежат только в objects/. Всё остальное — адреса, состояние операций и настройки — либо восстанавливается из объектов, либо пересоздаётся. Поэтому диагностику всегда начинают с одной команды:
$ git fsck --full --strict
Checking object directories: 100% (256/256), done.
error: object file .git/objects/4e/7a1b8c3d... is empty
fatal: loose object 4e7a1b8c3d... is corrupt
missing blob 6f0a2c9e1b3d...
broken link from commit 71a4f6b5e9c1
to tree c0ffee1234ab
error: refs/heads/main: invalid sha1 pointer 0000000000000000000000000000000000000000
Читать надо по типу сообщения — они означают принципиально разное:
| Сообщение | Что сломано | Насколько плохо |
|---|---|---|
dangling … |
ничего; просто недостижимый объект | норма, не ошибка |
invalid sha1 pointer в ref |
ссылка испорчена, объект цел | легко, зона 2 |
broken link from … to … |
объект есть, а тот, на который он ссылается, — нет | средне |
missing blob/tree/commit |
объекта нет в базе | серьёзно, зона 1 |
object file … is empty |
файл создан, содержимое не записалось | серьёзно, но типично |
loose object … is corrupt |
zlib не распаковывается | серьёзно |
Починка по зонам
Индекс. Самое частое и самое безобидное. Индекс полностью выводится из HEAD — его можно удалить:
rm -f .git/index
git reset # перечитать индекс из HEAD, рабочий каталог не трогается
git status # незакоммиченные правки файлов на месте, staging сброшен
Забытые блокировки. .git/index.lock, .git/refs/heads/main.lock, .git/shallow.lock остаются после убитого процесса. Удалять можно, предварительно убедившись, что ни один git не работает:
pgrep -a git || rm -f .git/index.lock
Если файлы появляются снова и снова — виноват фоновый процесс IDE или git maintenance, а не «сломанный Git».
Незавершённая операция. Каталоги .git/rebase-merge/, .git/rebase-apply/, файл .git/sequencer/todo и псевдоссылки MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD описывают операцию «в процессе». Удалять их руками не надо — есть штатные выходы:
git rebase --abort ; git merge --abort ; git cherry-pick --abort ; git am --abort
Испорченный HEAD или ссылка. .git/HEAD — одна строка, чинится текстовым редактором:
$ cat .git/HEAD
ref: refs/heads/main
$ git update-ref refs/heads/main 71a4f6b # восстановить значение ветки
$ tail -1 .git/logs/refs/heads/main # где взять правильный SHA
Пустые объектные файлы. Классика после отключения питания. Пустой файл ничем не лучше отсутствующего, поэтому его удаляют и восстанавливают объект из другого источника:
find .git/objects -type f -empty -print -delete
git fsck --full # посмотреть, чего теперь не хватает
Восстановление объектов из другого клона. Самый надёжный путь — не копировать файлы руками, а дать Git самому дотянуть недостающее:
# 1. Прямо из соседнего клона, включая все служебные ссылки
git fetch /path/to/good/clone '+refs/*:refs/rescue/*'
# 2. Если под рукой только pack-файл — распаковать его в текущую базу
git unpack-objects -r < /path/to/good/clone/.git/objects/pack/pack-abc123.pack
# 3. Проверить целостность конкретного пака
git verify-pack -v .git/objects/pack/pack-abc123.idx | tail -3
git unpack-objects добавляет только отсутствующие объекты и не трогает существующие, поэтому его безопасно натравливать на всё подряд. Обратная операция — git repack -ad — соберёт всё обратно в один пак.
Если содержимое известно, а объекта нет. Файл лежит в рабочем каталоге или в бэкапе — можно записать блоб обратно, его SHA совпадёт автоматически, потому что адрес вычисляется из содержимого:
git hash-object -w src/parser.py # вернёт тот же SHA, что был раньше
Именно здесь особенно наглядно, зачем нужна контентная адресация: восстановление проверяемо. Не «похоже, тот файл», а «тот же самый, иначе хеш не сошёлся бы».
Последняя линия: git replace. Если коммит безвозвратно потерян, а история должна ходить дальше, можно подменить его суррогатом:
git replace --graft <коммит-с-битым-родителем> <новый-родитель>
Это локальная надстройка (refs/replace/*), которая не меняет объекты и по умолчанию не передаётся при клонировании. Честная оценка: костыль для чтения истории, а не починка. Подробности — в документации git-replace.
Когда проще склонировать заново. Если сломана объектная база, а всё нужное запушено — быстрее и безопаснее взять свежий клон и перенести туда локальные ветки, чем чинить:
git clone git@github.com:org/repo.git repo-new
cd repo-new
git fetch ../repo-broken '+refs/heads/*:refs/heads/local/*' || true
Если fetch из битого репозитория падает, вытаскивайте ветки по одной — часть почти наверняка приедет.
Профилактика повреждений
# Проверять объекты при передаче — ловит порчу до записи в базу
git config --global transfer.fsckObjects true
git config --global fetch.fsckObjects true
git config --global receive.fsckObjects true
# Долговечность записи. По умолчанию Git не fsync-ит каждый объект ради скорости
git config --global core.fsync objects,derived-metadata,reference
git config --global core.fsyncMethod batch # быстрый режим для ext4/NTFS
# Плановое обслуживание вместо стихийного gc
git maintenance start
Настройки core.fsync и core.fsyncMethod появились в Git 2.36 взамен старого core.fsyncObjectFiles; подробности — в обзоре релиза. Это ровно тот случай, когда стандартный компромисс «скорость против долговечности» стоит сдвинуть в сторону долговечности: разница в производительности заметна на искусственных тестах, а разница при отключении питания — на всей вашей работе.
И правило, которое дороже всех настроек: .git не должен лежать внутри Dropbox, OneDrive, iCloud или сетевой шары с ленивой синхронизацией. Эти системы синхронизируют файлы независимо и в произвольном порядке, что для базы с внутренними ссылками означает гарантированную рассогласованность. Нужен репозиторий на двух машинах — заведите bare-репозиторий и пушьте, это и быстрее, и надёжнее.
Что действительно удаляет данные
Полный список, ради интеллектуальной честности. Всё остальное обратимо.
# 1. Явное сжигание мостов: сначала обнуляем reflog, потом собираем мусор
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now
# 2. Прунинг без reflog-а — например, в свежем клоне на CI
git prune --expire=now
# 3. Переписывание истории инструментом с последующей чисткой
git filter-repo --invert-paths --path secrets.env # см. статью про переписывание истории
# 4. Файлы, которых Git никогда не видел
git clean -ffdx # untracked + ignored, включая node_modules и .env
# 5. Откат незакоммиченного
git restore --worktree --staged .
git reset --hard
git checkout -- .
Пункты 1–3 — это осознанное действие, и оно вам иногда нужно: именно так удаляют утёкший в историю секрет (сама процедура — в «Переписывании истории», а что делать с самим секретом — в «Управлении секретами»). Пункты 4–5 — самые частые бытовые потери, и от них Git не страхует вообще: он не хранит того, о чём не знает.
Отдельно про сборку мусора «сама по себе». git gc --auto запускается неявно после многих команд, когда накопилось больше gc.auto = 6700 loose-объектов или gc.autoPackLimit = 50 паков. Но автоматический gc уважает grace-периоды: он не удаляет недостижимые объекты моложе двух недель. В современных версиях недостижимое складывается в отдельные «cruft packs» с сохранением времени жизни каждого объекта — механика описана в посте GitHub про масштабирование сборки мусора. Иначе говоря, случайно потерять данные автоматическим gc практически невозможно; для этого нужно осознанно написать --prune=now.
Дисциплина, которая экономит все эти команды
Восстановление — навык, но лучше, когда он не нужен. Что реально работает в командах:
- Коммитить часто, даже мусор. WIP-коммит переводит вас из зоны «Git ничего не видел» в зону, где всё обратимо. Историю потом причешет interactive rebase — см. «Переписывание истории».
- Перед опасной операцией ставить метку. Одна команда, ноль стоимости, полная страховка:
git branch backup/before-rebase. Ветка удерживает объекты отgcв отличие от reflog. - Не использовать
git checkout .как «отменить всё». Это ровно та команда, после которой reflog не поможет.git stash pushвместо неё сохраняет то же самое, но в объектной базе. --force-with-lease --force-if-includesвместо--force. Настройкойpush.useForceIfIncludes = trueэто делается один раз и навсегда.- Включить
rerere, чтобы повторные конфликты при откатах и повторных rebase не приходилось разрешать заново:git config --global rerere.enabled true(подробнее — в «Конфликтах слияния»). - Иметь зеркало для критичных репозиториев. Это буквально три строки в cron:
git clone --mirror git@github.com:org/repo.git /backup/repo.git
cd /backup/repo.git && git remote update --prune
git bundle create /backup/repo-$(date +%F).bundle --all # один файл со всей историей
git bundle verify /backup/repo-2026-07-16.bundle # проверка перед тем, как поверить
git bundle даёт самодостаточный однофайловый снимок репозитория, который можно клонировать напрямую: git clone repo.bundle repo. Для офлайн-переноса и для «холодного» бэкапа это лучший формат, чем tar от .git, — он проверяемый.
- Не полагаться на страховку хостинга. Удалённая организация, отозванный доступ, ошибка биллинга, региональная блокировка — всё это выносит «бэкап в облаке» одновременно у всей команды. Зеркало на своей машине стоит десять минут настройки.
Мини-итог
- В Git теряются ссылки, а не данные. Объекты неизменяемы и живут, пока достижимы или пока их удерживает reflog и grace-период prune.
- Reflog — журнал изменения значения каждой ссылки:
.git/logs/HEAD,.git/logs/refs/**. Локальный, не клонируется, в bare по умолчанию выключен. - Окна времени: 90 дней достижимым записям, 30 — недостижимым, 2 недели grace для prune,
refs/stashне истекает никогда. HEAD@{n}считает движения HEAD,main@{n}— движения ветки. Индексы плавают: нашли SHA — сразу ставьте на него ветку.- Второй эшелон —
git fsck --lost-found/--unreachable --no-reflogs. Он находит и удалённые ветки, и сброшенные stash, и блобы отgit addбез коммита. ORIG_HEADудобен, но это одноместный слот, легко затирается следующимmergeилиpull. Reflog надёжнее.- Rebase ничего не удаляет — он создаёт копии. Его опасность процессная (переписанные общие ветки), а не связанная с сохранностью.
- После force-push первым делом смотрите свой
git reflog show origin/<branch>: он помнит прежнее серверное значение. - Испорченный репозиторий почти всегда чинится:
index— удалить иgit reset, ссылки —git update-refпо последней строке reflog, объекты —git fetchилиgit unpack-objectsиз здорового клона. Данные есть только вobjects/, и они продублированы в каждом клоне. - Реально удаляют данные: осознанный
reflog expire --expire=now+gc --prune=now,git clean -ffdxи любой откат незакоммиченного. Всё остальное обратимо. - Дешёвая профилактика: частые коммиты,
git branch backup/...перед опасной операцией,transfer.fsckObjects,core.fsync,push.useForceIfIncludes, зеркало черезclone --mirrorиgit bundle.
Источники
- Pro Git, глава Maintenance and Data Recovery — канонический разбор
fsck,gcи восстановления. - Документация: git-reflog, git-fsck, git-gc, git-prune, gitrevisions, git-stash, git-push, git-bundle, git-replace, git-worktree, git-range-diff, git-maintenance.
- Taylor Blau. Scaling Git’s garbage collection — cruft packs и почему
gcперестал быть страшным. - Highlights from Git 2.36 — переход от
core.fsyncObjectFilesкcore.fsyncиcore.fsyncMethod. - Reftable format — новый бэкенд ссылок и reflog.
- John Wiegley. Git from the Bottom Up — короткий текст, после которого достижимость перестаёт быть абстракцией.
- Документация хостингов: восстановление удалённой ветки на GitHub, Events API.
- На портале: базовое введение — «Git»; оформление изменений — «Pull Request»; карта трека — обзор; объектная модель — «Внутри Git»; ежедневные отмены — «Ежедневная работа».
Что дальше
Мы прошли путь от «всё пропало» до «вот SHA, вот ветка, вот проверка range-diff». Следующий логичный шаг — сделать так, чтобы часть таких ситуаций просто не возникала: чтобы репозиторий сам отказывался принимать коммит с забытым секретом или сломанным форматированием, чтобы проверки запускались до того, как код уедет на сервер, и чтобы авторство коммитов можно было доказать криптографически.
Хуки и автоматизация: pre-commit, CI-интеграция, подпись коммитов