Любой, кто работает с Git дольше пары месяцев, рано или поздно делает движение, после которого холодеет спина: git reset --hard не туда, удаление ветки с незапушенной работой, rebase, закончившийся не так, как планировалось. История в git log словно обрывается, коммиты исчезают, и кажется, что труд нескольких дней растворился безвозвратно. На деле всё почти всегда обратимо. Git ведёт скрытый журнал каждого движения указателя HEAD, и этот журнал называется reflog. Понимание того, как он устроен, как в нём искать и как откатиться к найденной точке, отличает спокойного инженера от того, кто переписывает неделю работы заново. Ниже разобран полный workflow восстановления: от чтения журнала до поиска SHA через grep, сроки хранения записей, восстановление удалённой ветки и крайнее средство в виде git fsck.

Как устроен reflog и почему потерянные коммиты на самом деле не удалены

Git хранит объекты: коммиты, деревья, блобы. Указатели вроде HEAD и веток просто ссылаются на SHA коммитов. Когда выполняется git reset --hard, объект коммита никуда не девается, просто указатель ветки перепрыгивает назад, а старый коммит становится недостижимым. Сборщик мусора удалит его не сразу, а только после истечения срока годности записей. А пока запись жива, коммит остаётся в объектной базе и доступен по хешу.

Reflog это этот самый журнал. Для HEAD он лежит в файле .git/logs/HEAD, для каждой ветки в .git/logs/refs/heads/имя-ветки. Каждый раз, когда HEAD сдвигается (commit, checkout, reset, rebase, merge, pull, stash), Git дописывает строку: старый SHA, новый SHA, автор, время и причина перехода. Записи доступны через синтаксис HEAD@{n}, где n это число шагов назад по журналу, или через даты вроде HEAD@{2.hours.ago}.

Типичный вывод:

$ git reflog
9fceb02 (HEAD -> main) HEAD@{0}: reset: moving to HEAD@{1}
1a410ef HEAD@{1}: commit: add payment validation logic
3f8a2c1 HEAD@{2}: commit: add cart controller
9fceb02 HEAD@{3}: commit: initial project skeleton

Разбор построчно. Первая строка показывает самое свежее действие: HEAD@{0}, reset, который перенёс указатель на состояние HEAD@{1}, то есть на тот момент текущий коммит уже был перезаписан. Вторая строка, HEAD@{1}, и есть потерянный коммит 1a410ef с сообщением "add payment validation logic". Обратите внимание на антипаттерн-ловушку: SHA в первой строке равен SHA в четвёртой, потому что указателю вернули старое значение, но запись о потерянном шаге в журнале осталась. Именно это и спасает.

Полезные опции чтения: git reflog show main покажет журнал конкретной ветки, git reflog --date=iso отобразит точные метки времени вместо относительных, git log -g -1 выведет последнюю запись в привычном формате лога с полным сообщением. Для ситуации, когда мозг после инцидента соображает туго, честно говоря, удобнее именно git reflog --date=iso: видно, что случилось в 14:32, и проще вспомнить, что же делалось руками.

Пошаговый workflow восстановления после случайного reset --hard

Сценарий из жизни: разработчик хотел сбросить один нестейдженный файл, а по привычке набрал git reset --hard без аргументов и снёс три последних коммита вместе с незакоммиченными правками в индексе. Порядок действий, проверенный на десятках таких инцидентов:

  1. Остановиться и ничего больше не коммитить, не делать checkout, не запускать gc, чтобы не затереть записи;
  2. Выполнить git reflog --date=iso и найти строку, где HEAD находился на нужном коммите перед reset;
  3. Проверить найденный SHA через git show 1a410ef или git log -1 1a410ef, убедиться, что это именно та работа;
  4. Восстановить состояние командой git reset --hard HEAD@{1} или git reset --hard 1a410ef;
  5. Сверить результат через git log --oneline -5 и git status.

Ключевая команда здесь четвёртая. git reset --hard HEAD@{1} возвращает и указатель ветки, и рабочее дерево, и индекс на состояние, зафиксированное на один шаг назад в reflog. Вывод будет сухим:

HEAD is now at 1a410ef add payment validation logic

Одна строка, а за ней спасённый день работы. Альтернатива для осторожных: git branch rescue 1a410ef, затем git checkout rescue. Так состояние поднимется в отдельной ветке, рабочая main при этом не трогается, и можно спокойно сравнить diff, прежде чем сливать обратно. Многие разработчики предпочитают именно второй путь, потому что он оставляет пространство для ошибки в самом восстановлении.

Тонкость с незакоммиченными изменениями. Reflog хранит только движения HEAD, то есть зафиксированные коммиты и точки индекса после stash. Изменения в рабочем дереве, которые не попали ни в один коммит и ни в один stash, после reset --hard восстановить из reflog нельзя, их Git просто не запоминал. Частичное исключение: git stash перед экспериментами создаёт объект, и его тоже видно в reflog через git stash list, а если список очищен, через git fsck --unreachable и поиск commit-объектов с типичным сообщением "WIP on". Отсюда практическая привычка: перед любым разрушительным действием сделать git stash -u или хотя бы быстрый коммит "wip", который потом легко поправить через amend.

Поиск нужного SHA через grep по журналу reflog

Когда журнал длинный, глазами по сто записям не пробежаться. Здесь reflog сочетается с обычным grep, потому что вывод git reflog это просто текст. Пара приёмов, которые реально экономят время.

Поиск по сообщению коммита:

$ git reflog | grep -i "payment"
1a410ef HEAD@{1}: commit: add payment validation logic

Поиск по типу действия, например все merge за историю журнала:

$ git reflog | grep "merge"
7b3d9aa HEAD@{14}: merge feature/login: Merge made by the 'ort' strategy.

Поиск по дате в связке с --date=iso:

$ git reflog --date=iso | grep "2026-09-3"
3f8a2c1 HEAD@{2}: commit: 2026-09-30 11:14:05 +0300: add cart controller

Построчный разбор первого примера: 1a410ef это сокращённый SHA искомого коммита, HEAD@{1} его позиция в журнале относительно текущего HEAD, commit это действие, породившее запись, а следом полное сообщение. Уже с сокращённым хешем можно работать: git show 1a410ef откроет дифф, и станет ясно, тот ли это коммит.

Более мощный вариант, когда сообщений коммитов мало и хочется смотреть сразу содержимое: git log -g --grep="payment" --oneline. Флаг -g заставляет git log ходить по reflog вместо обычной истории, а --grep фильтрует по сообщению. Для поиска по содержимому диффа, когда помнится кусок кода, но не сообщение, помогает git log -g -S"validateCard" --oneline: опция -S ищет изменения, где добавлялась или удалялась строка с этим текстом. Честно говоря, именно -S чаще всего вытаскивает из ямы, когда сообщения коммитов были написаны на бегу и ничего не значат.

Типичная ошибка на этом шаге: искать grep-ом по SHA, который уже известен наполовину. Записи reflog показывают сокращённые хеши, и сокращение зависит от размера репозитория. Надёжнее матчить по сообщению или по дате, а полный SHA затем получить через git rev-parse 1a410ef или взять из вывода git log -g --format=%H.

Восстановление удалённой ветки и сроки жизни записей reflog

Ветка в Git это файл из одного хеша. Удаление ветки через git branch -D feature/login стирает файл refs/heads/feature/login, но не стирает ни коммиты, ни её reflog сразу. Журнал удалённой ветки пропадает вместе с веткой, поэтому SHA последнего коммита достают из reflog HEAD: в моменты checkout и commit на той ветке HEAD двигался вместе с ней.

$ git reflog --date=iso | grep -B2 -A2 "checkout: moving from main to feature/login"
7b3d9aa HEAD@{5}: checkout: 2026-09-29 16:02:11 +0300: moving from main to feature/login
d42e9f1 HEAD@{4}: commit: 2026-09-29 17:40:33 +0300: integrate oauth provider

Найденный d42e9f1 это вершина удалённой ветки. Восстановление одной строкой:

$ git branch feature/login d42e9f1

После этого git log feature/login показывает всю историю ветки целиком. Если ветка была удалена давно и записей HEAD уже не хватает, помогает более тяжёлая артиллерия, о ней следующий раздел.

Теперь про сроки. По умолчанию записи reflog живут 90 дней для достижимых коммитов (параметр gc.reflogExpire) и 30 дней для недостижимых (gc.reflogExpireUnreachable). Проверить и изменить можно так:

$ git config gc.reflogExpire
90 days
$ git config gc.reflogExpire "180 days"
$ git config gc.reflogExpireUnreachable "60 days"

Команда git reflog expire --expire=now --all принудительно чистит журнал, её используют при сжатии репозитория или при удалении секретов из истории, и после неё reflog уже не спасательный круг, а пустой файл. Запускать её "для профилактики" категорически не стоит. Обратная ситуация: на серверных bare-репозиториях reflog обычно выключен (core.logAllRefUpdates по умолчанию true только для не-bare), поэтому надеяться на журнал можно только локально.

Ещё один подводный камень: git gc --aggressive и git prune уважают те же сроки, так что в обычных условиях 90 дней достаточно. А вот склонированный заново репозиторий reflog старой машины не содержит: журнал локален, через push и clone не передаётся.

Крайнее средство git fsck --lost-found и подводные камни восстановления

Когда reflog очищен, истёк или коммит был создан в состоянии detached HEAD и успел стать совсем сиротой, остаётся git fsck. Утилита сканирует объектную базу и находит объекты, на которые никто не ссылается:

$ git fsck --lost-found
Checking object directories: 100% (256/256), done.
dangling commit 4c7b1e9a2f0d5c6e8b9a1f2d3c4b5a6e7f8091a2
dangling blob 8f3c2a1b9d0e4f5a6b7c8d9e0f1a2b3c4d5e6f70

Разбор вывода: "dangling commit" это коммит без ссылок, самый ценный тип находки; "dangling blob" это потерянное содержимое файла без привязки к дереву. Флаг --lost-found дополнительно пишет ссылки на найденные объекты в .git/lost-found/commit/ и .git/lost-found/other/, откуда их можно забрать по имени файла. Дальше рутина: git show 4c7b1e9a проверить содержимое, git branch rescue 4c7b1e9a закрепить, git merge rescue влить. Коммитов-сирот может быть десятки, и выбирать среди них нужный помогают дата и сообщение: git log --all --oneline откажется их показывать, поэтому перебор через цикл git show --no-patch --format="%h %ci %s" SHA для каждого кандидата вполне рабочая практика.

Типичные ошибки при восстановлении заслуживают отдельного перечня, потому что именно они превращают спасённое в потерянное окончательно. Паника и спешный git checkout на другую ветку перетирает незакоммиченные остатки состояния. Запуск git gc или git reflog expire до восстановления сжимает окно спасения до нуля. Путаница между HEAD@{1} и branch@{1}: у HEAD и у ветки разные журналы, и reset --hard HEAD@{1} на ветке, которая двигалась иначе, откатит не туда. Попытка восстановления через git revert, который не возвращает удалённые коммиты, а создаёт новые обратные, это другая задача. Наконец, вера в то, что push зафиксировал всё: reflog не реплицируется, и на удалённой стороне нет журнала движений локального HEAD.

Профилактика и настройки которые сводят число инцидентов к нулю

Лучшее восстановление то, которое не понадобилось. Несколько настроек и привычек закрывают почти все сценарии потери. Включить более длинный срок reflog на рабочей машине: git config --global gc.reflogExpire "180 days". Добавить алиасы для безопасного сброса, например git config --global alias.unstage-all "reset --hard @{u}" бесполезен, а вот git config --global alias.snapshot "!git add -A && git commit -m snapshot" реально спасает перед рискованными rebase. Перед rebase и filter-branch создавать страховочную ветку: git branch backup-before-rebase. Пушить чаще, даже черновики в личный форк: удалённая копия это второй, независимый от reflog слой защиты. И перестроить мышечную память: вместо git reset --hard под пальцами должен сначала стоять git stash, а уж потом, при чистой совести, всё остальное.

Reflog заслуживает репутации самого недооценённого механизма Git. Это не хитрость и не костыль, а продуманный журнал, который Git ведёт сам, бесплатно, на каждом шаге. Стоит один раз спокойно, без паники, отработать связку reflog, grep, reset --hard SHA, fsck на тренировочном репозитории, и следующий случайный reset --hard перестанет быть катастрофой. Он станет рутиной на пять минут: открыл журнал, нашёл строку, проверил хеш, откатил указатель. Работа спасена, нервы целы, а уверенность в инструменте, честно говоря, стоит дороже любого бэкапа.