Git и командная работа Восстановление: reflog, потерянные коммиты, испорченный репозиторий
0%

Восстановление: reflog, потерянные коммиты, испорченный репозиторий

Восстановление: 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: от несохранённой правки до зеркального бэкапа

Второй эшелон: 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 физически не может изменить объект — он создаёт новый коммит с тем же родителем и переставляет ветку. Старый остаётся в базе:

Стрелка означает «ссылка на родителя» — так, как это записано в самом объекте коммита. Оба 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   # включить по умолчанию

Восстановление на стороне пострадавшего — по порядку убывания надёжности:

  1. Свой локальный reflog remote-tracking-ссылки: git reflog show origin/feature — прежнее серверное значение записано у вас на диске.
  2. Локальная ветка любого коллеги, кто успел сделать fetch до затирания: git fetch <его-remote> '+refs/*:refs/rescue/*'.
  3. Reflog на сервере, если это ваш bare-репозиторий с включённым core.logAllRefUpdates.
  4. UI хостинга. GitHub позволяет восстановить удалённую ветку со страницы PR (документация); события push с парой «до/после» видны через Events API. Объект, известный по SHA, обычно открывается по прямой ссылке …/commit/<sha> даже без ветки — до серверной сборки мусора.
  5. Зеркало, если оно у вас есть.

Профилактика дешевле любого пункта из списка: защита веток на хостинге плюс серверные настройки для собственного Git:

git config receive.denyNonFastForwards true   # запретить перезапись истории
git config receive.denyDeletes true           # запретить удаление веток
git config receive.fsckObjects true           # проверять приходящие объекты

Испорченный репозиторий

До сих пор речь шла о потерянных ссылках. Теперь — о случае, когда повреждены сами файлы в .git. Типичные причины: закончилось место на диске посреди записи, внезапное выключение, .git внутри синхронизируемой папки облачного диска, антивирус, rsync живого репозитория, сетевая ФС без нормальных блокировок.

Карта каталога .git: четыре зоны и способ починки каждой

Ключевая мысль карты: данные лежат только в 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.

Источники

Что дальше

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

Хуки и автоматизация: pre-commit, CI-интеграция, подпись коммитов

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов