5.2Спасение
Восстановишь commit или ветку по reflog и найдёшь недостижимые объекты через fsck. Отдельно разберёшь, почему Git может вернуть содержимое файла, но не может отменить утечку уже опубликованного секрета.
- Что записывает reflog, где он лежит и чем
HEAD@{1}отличается отmain@{1}и@{1}. - Как вернуть коммиты после
reset --hard, удалённую ветку и ветку до неудачного rebase. - Как
git fsckнаходит выброшенный stash и файл, который успел побывать в индексе. - Что не вернуть ничем и сколько в git 2.54 живут записи журнала и недостижимые объекты.
- Почему после
git filter-repoи force push токен всё ещё достаётся и что делать по порядку.
Reflog: журнал каждой ссылки
У главы свой демо-репозиторий, Go-сервис платежей pay. Сервер поднят на
http://localhost:8766 тем же способом, что в главе 4.1. Первого сентября Аня создала модуль и
клиента шлюза и отправила их, Боря склонировал репозиторий в 11:00. Второго сентября Аня закоммитила в
main таймаут и отправила его, завела ветку refund с возвратом платежа и
вернулась в main:
$ git reflog
0f9b137 (HEAD -> main, origin/main) HEAD@{0}: checkout: moving from refund to main
ed99e9c (refund) HEAD@{1}: commit: Возврат платежа
0f9b137 (HEAD -> main, origin/main) HEAD@{2}: checkout: moving from main to refund
0f9b137 (HEAD -> main, origin/main) HEAD@{3}: commit: Таймаут клиента 5 секунд
9b2e236 HEAD@{4}: commit: Клиент платёжного шлюза
348428b HEAD@{5}: commit (initial): Создать модуль pay
$ git reflog show main
0f9b137 (HEAD -> main, origin/main) main@{0}: commit: Таймаут клиента 5 секунд
9b2e236 main@{1}: commit: Клиент платёжного шлюза
348428b main@{2}: commit (initial): Создать модуль pay
Reflog (журнал ссылки) хранит прежние значения ссылки. Когда ссылка меняется, git
дописывает строку: старый и новый хеш, кто, когда и почему. git reflog без аргументов
показывает журнал HEAD, свежие записи сверху, и HEAD@{n} читается как «куда HEAD указывал
n перемещений назад». HEAD сдвигается при каждом коммите и каждом переключении, а ветка main
только когда меняется сама, поэтому походов в refund в её журнале нет. Документация называет
git reflog show псевдонимом git log -g --abbrev-commit --pretty=oneline: тот же
log, только идёт по журналу, а не по родителям.
$ find .git/logs -type f
.git/logs/HEAD
.git/logs/refs/heads/main
.git/logs/refs/heads/refund
.git/logs/refs/remotes/origin/main
$ tail -1 .git/logs/refs/heads/main
9b2e2366e66c2ab0c6f100126d75f4605b1d6659 0f9b137938c1e188b617223b857f3ccfba47c5b2 Аня <anya@example.com> 1788332400 +0300 commit: Таймаут клиента 5 секунд
Свой журнал есть у HEAD, у каждой локальной ветки и у remote-tracking веток: у
origin/main записи вида «update by push» и «fetch: …». Только те из них, что завёл clone,
живут без журнала, пока их не сдвинет fetch или push. В формате хранения files
это текстовые файлы в .git/logs, в формате reftable журналы лежат в тех же таблицах, что и
ссылки (глава 1.2), а команды одни и те же.
Журнал локальный, его не переносят ни push, ни clone. У Бори в журнале одна запись, о клонировании, а в репозитории на сервере журналов нет вовсе:
$ git -C ../borya reflog
9b2e236 (HEAD -> main, origin/main, origin/HEAD) HEAD@{0}: clone: from http://localhost:8766/pay.git
$ ls ../srv/pay.git
config HEAD info refs
description hooks objects
Каталога logs там нет, потому что core.logAllRefUpdates по умолчанию включён в
репозитории с рабочей копией и выключен в bare. Коммит, который потерял коллега, в твоём журнале не
найти, и в bare-репозитории на сервере тоже.
Старые записи стирает git reflog expire, его запускает git gc. Документация
обещает записям 90 дней (gc.reflogExpire), а записям о коммитах, недостижимых из нынешней
вершины ветки, 30 (gc.reflogExpireUnreachable). В git 2.54 без настроек через 30 дней
уходят любые записи, и о достижимых коммитах тоже (глава 1.4). Хочешь запас побольше, пропиши сроки в
конфиге:
[gc]
reflogExpire = 90.days
reflogExpireUnreachable = 30.days
Журнал пропадает и без всяких сроков. git branch -D удаляет журнал ветки, но не журнал
HEAD, git fetch --prune убирает remote-tracking ветку вместе с журналом (глава 4.1), а
git reflog drop из git 2.50 стирает журнал ссылки целиком.
Адреса: @{n}, @{-n} и @{дата}
Суффикс @{…} после имени ссылки читает её журнал:
$ git rev-parse --short HEAD@{1}
ed99e9c
$ git rev-parse --short main@{1}
9b2e236
$ git rev-parse --short @{1}
9b2e236
$ git rev-parse --abbrev-ref @{-1}
refund
HEAD@{1} и main@{1} разошлись, хотя HEAD указывает на main. Прежде
чем вернуться в main, HEAD стоял на коммите refund, а сама main до
последнего коммита указывала на 9b2e236. @{1} без имени читает журнал
текущей ветки, а не HEAD. @{-1} устроен по-другому: это ветка, на которой ты был перед
текущей. Git находит её по записям «checkout: moving from» в журнале HEAD, и на этом работает
git switch -.
Вместо номера можно указать время: main@{yesterday}, main@{2.hours.ago},
main@{2026-09-02 09:00}. Git найдёт в журнале, куда указывала ветка в тот момент:
$ git reflog show --date=iso main
0f9b137 (HEAD -> main, origin/main) main@{2026-09-02 10:00:00 +0300}: commit: Таймаут клиента 5 секунд
9b2e236 main@{2026-09-01 10:30:00 +0300}: commit: Клиент платёжного шлюза
348428b main@{2026-09-01 10:00:00 +0300}: commit (initial): Создать модуль pay
$ git rev-parse --short 'main@{2026-09-02 09:00}'
9b2e236
$ git -C ../borya rev-parse --short 'main@{2026-09-01 10:40}'
warning: log for 'main' only goes back to Tue, 1 Sep 2026 11:00:00 +0300
9b2e236
В девять утра второго числа main у Ани стояла на 9b2e236, таймаут появился в
десять. Журнал Бори начинается с клонирования в 11:00, о более раннем времени он ничего не знает, поэтому
git предупреждает и отдаёт самое старое известное значение.
@{yesterday} не значит «вчерашние коммиты»
Дата в скобках спрашивает, где стояла ссылка в этом клоне. Если ты неделю не двигал main,
main@{yesterday} вернёт её нынешнюю вершину, сколько бы коммитов ни появилось за это время на
сервере. Коммиты за период ищут через git log --since (глава 2.3).
Коммиты после reset --hard
Третьего сентября Аня добавила в main два коммита с повтором запросов, положила в индекс новый
тест и взялась править retry.go. Потом решила, что коммиты уже на сервере, и выровняла ветку
по origin/main:
$ git status --short
M retry.go
A retry_test.go
$ git log --oneline -3
8a812cd (HEAD -> main) Логировать отказ шлюза
55a06ec Повторять запрос к шлюзу
0f9b137 (origin/main) Таймаут клиента 5 секунд
$ git reset --hard origin/main
HEAD is now at 0f9b137 Таймаут клиента 5 секунд
$ git status --short
$ git reflog -3
0f9b137 (HEAD -> main, origin/main) HEAD@{0}: reset: moving to origin/main
8a812cd HEAD@{1}: commit: Логировать отказ шлюза
55a06ec HEAD@{2}: commit: Повторять запрос к шлюзу
main на
0f9b137, и на два последних коммита не ведёт ни одна ветка. Объекты целы, а записи журнала
HEAD хранят их хеши, поэтому ветку можно вернуть на прежнюю вершину.
reset --hard переставил ветку и переписал индекс и рабочую копию (режимы разобраны в главе 5.1).
Объекты коммитов он не трогал: 8a812cd лежит в базе, ветки на него не ведут, но ведёт запись
HEAD@{1}. Сразу после reset ветку вернёт и ORIG_HEAD, а журнал помнит коммит и
через неделю:
$ git reset --hard 8a812cd
HEAD is now at 8a812cd Логировать отказ шлюза
$ git reflog -2
8a812cd (HEAD -> main) HEAD@{0}: reset: moving to 8a812cd
0f9b137 (origin/main) HEAD@{1}: reset: moving to origin/main
$ git status --short
$ grep 'maxAttempts =' retry.go
const maxAttempts = 3
Коммиты вернулись, а тест и правка нет. retry_test.go побывал в индексе, значит
git add уже записал его содержимое блобом (глава 1.3), и его найдёт git fsck.
Правку maxAttempts = 5 в индекс не добавляли, в базе её нет, и никакая команда git её не вернёт.
В команде стоит хеш, а не номер: возврат сам стал записью, и HEAD@{1} теперь указывает на
0f9b137 (об этом предупреждала глава 1.2). Не уверен, что делать дальше, сначала повесь на
найденный коммит ветку, git branch rescue 8a812cd: коммит, на который ведёт ветка, не удалит
никакой срок. Через журнал отменяют и случайный git commit --amend: git reset --soft
HEAD@{1} ставит ветку на коммит до amend, а всё, что amend добавил, остаётся в индексе.
Удалённая ветка
Четвёртого сентября Аня убирала лишние ветки и заодно удалила refund, которую ещё никуда не
отправляла:
$ git branch -D refund
Deleted branch refund (was ed99e9c).
$ git reflog show refund
fatal: ambiguous argument 'refund': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
$ git reflog | grep -A1 'moving from refund'
0f9b137 HEAD@{4}: checkout: moving from refund to main
ed99e9c HEAD@{5}: commit: Возврат платежа
$ git branch refund ed99e9c
Хеш вершины git печатает сам, в скобках (was ed99e9c). Если строка уже уехала из терминала,
журнал ветки не поможет: branch -D удалил его вместе с веткой. Остаётся журнал HEAD. Запись
«moving from refund» git сделал, когда HEAD уходил с ветки, а строка под ней показывает, где HEAD стоял
перед этим, то есть вершину refund. Заходил на ветку несколько раз, и grep найдёт
несколько таких мест: нужно самое верхнее.
Журнал HEAD знает только ветки, на которые ты переключался. Коммит remote-tracking ветки, которую убрал
fetch --prune, а ты её не открывал, ищут через git fsck: в проверочном прогоне он
сразу стал висячим.
Неудачный rebase: журнал ветки
В тот же день Аня завела ветку limits с тремя проверками платежа, перенесла её на свежий
origin/main через git rebase -i и в плане случайно удалила строку «Проверять
валюту». Rebase отчитался об успехе. Наутро Аня сделала ещё коммит и только тогда заметила, что
currency.go пропал:
$ git log --oneline origin/main..limits
960f174 (HEAD -> limits) Подключить проверки
de977fc Ключ идемпотентности
36e7dfd Ограничить сумму платежа
$ git reflog show limits
960f174 (HEAD -> limits) limits@{0}: commit: Подключить проверки
de977fc limits@{1}: rebase (finish): refs/heads/limits onto 788a5ad0b5533afad46e5888a0307d8b92a515d5
ff1a058 limits@{2}: commit: Ключ идемпотентности
762752a limits@{3}: commit: Проверять валюту
b6f22ca limits@{4}: commit: Ограничить сумму платежа
8a812cd (main) limits@{5}: branch: Created from HEAD
В журнале ветки весь rebase занял одну запись, «rebase (finish)», и строка под ней хранит ветку до
переноса. В журнале HEAD тот же перенос расписан по шагам, с записью «rebase (pick)» на каждый коммит, и на
длинной ветке нужное место тонет в десятках строк.
ORIG_HEAD сейчас тоже указывает на ff1a058, но его перезапишет первый же
reset, merge, stash или следующий rebase (глава 3.3), а журнал ветки
прежние вершины помнит.
$ git log --oneline origin/main..limits@{2}
ff1a058 Ключ идемпотентности
762752a Проверять валюту
b6f22ca Ограничить сумму платежа
$ git cherry-pick 762752a
[limits c95233e] Проверять валюту
Date: Fri Sep 4 11:30:00 2026 +0300
1 file changed, 12 insertions(+)
create mode 100644 currency.go
Старая версия ветки цела, выброшенный коммит Аня забрала через cherry-pick. Не будь нового коммита,
хватило бы git reset --hard limits@{1}: ветка встала бы туда, где была до rebase. После
такого reset номера в журнале ветки съедут так же, как у HEAD.
git fsck: объекты без ссылок
Журнал помогает, пока в нём есть строка. git stash drop убирает запись вместе со строкой в
журнале refs/stash, а у блоба из индекса строки не было вовсе. Пятого сентября Аня спрятала
правку README и по ошибке выбросила запись:
$ git stash
Saved working directory and index state WIP on limits: c95233e Проверять валюту
$ git stash drop
Dropped refs/stash@{0} (6652b810034279c5763c88e9f421894cd18392c7)
$ git fsck
Checking ref database: 100% (1/1), done.
Checking object directories: 100% (256/256), done.
dangling blob 02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
dangling commit 6652b810034279c5763c88e9f421894cd18392c7
git fsck проверяет целостность базы и обходит всё, до чего можно дойти от ссылок, индекса
и журналов. По умолчанию fsck печатает только висячие объекты, верхушки потерянного, а все недостижимые покажет
--unreachable:
$ git fsck --unreachable --no-progress
unreachable blob 02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
unreachable tree 8eef48ebd857d6866c17d0a2caf05d1da72ef819
unreachable blob 201b37652a04d502f048a3755de5e2dda7c85064
unreachable commit c7fbd1546ec7ecaf6f137ba11dd044914c153b44
unreachable commit 6652b810034279c5763c88e9f421894cd18392c7
Запись stash состоит из нескольких коммитов (глава 2.2). Коммит рабочей копии 6652b81
ссылается на коммит индекса c7fbd15 и на своё дерево 8eef48e с новым блобом
README, поэтому висячий из них один. Второй висячий объект, блоб 02b9e4b, остался от теста из
раздела про reset. Когда висячих коммитов много, записи stash отбирают по рецепту из документации
git stash: у записи больше одного родителя, а в сообщении есть «WIP»:
$ git fsck --unreachable --no-progress | grep commit | cut -d' ' -f3 | xargs git log --merges --no-walk --grep=WIP
commit 6652b810034279c5763c88e9f421894cd18392c7
Merge: c95233e c7fbd15
Author: Аня <anya@example.com>
Date: Sat Sep 5 11:00:00 2026 +0300
WIP on limits: c95233e Проверять валюту
$ git stash apply 6652b81 # вывод git status опущен
git stash apply принимает хеш так же, как имя записи, а git stash store вернёт
коммит в список. С блобом сложнее, имени у него нет (глава 1.1):
$ git fsck --lost-found
Checking ref database: 100% (1/1), done.
Checking object directories: 100% (256/256), done.
dangling blob 02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
dangling commit 6652b810034279c5763c88e9f421894cd18392c7
dangling commit ff1a0586d1dbd2710e3bfd7710cd20326bde153c
$ grep -l TestRetry .git/lost-found/other/*
.git/lost-found/other/02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
$ cp .git/lost-found/other/02b9e4bf907f4e255d4d65b7f0796ced6299bdc2 retry_test.go
$ go test ./...
ok example.com/pay 0.397s
--lost-found раскладывает висячие объекты по каталогам: хеши коммитов в
.git/lost-found/commit, содержимое блобов файлами в .git/lost-found/other.
Нужный файл узнают по содержимому, и grep тут удобнее всего.
--lost-found не смотрит в журналы
Третий висячий объект, коммит ff1a058, простой git fsck не показывал. Это вершина
limits до rebase, её держат только журналы. С --lost-found fsck
перестаёт считать журналы ссылками, как с --no-reflogs: в builtin/fsck.c так
записано явно, а документация молчит. Лишний коммит в выводе ещё не значит, что он потерян: сначала
поищи его в журналах.
Срок зависит от того, кто держит объект. Коммит из журнала живёт, пока жива запись. Выброшенный stash,
блоб из индекса и коммит ветки, убранной fetch --prune, в журналах не значатся, и их бережёт
только отсрочка gc.pruneExpire: git gc удаляет недостижимое старше двух недель
(глава 1.4). В проверочном прогоне блоб с временем изменения 15 дней назад исчез после первого же
git gc, а свежий остался.
Что не спасти
| Что потеряно | Где искать | Когда поздно |
|---|---|---|
Коммит, с которого ушла ветка: reset, rebase, --amend, branch -D | журнал HEAD или ветки | запись старше 30 дней (git 2.54), и прошёл gc |
Выброшенный stash, ветка после fetch --prune | fsck --unreachable | объект старше двух недель, и прошёл gc |
| Файл, который побывал в индексе | fsck --lost-found и поиск по содержимому | так же |
| Правка, которую ни разу не добавляли в индекс | нигде | сразу |
Неотслеживаемый файл после git clean -f | нигде | сразу |
| Коммит, который был только в чужом клоне | в том клоне | когда клон удалят |
Правку maxAttempts = 5 из раздела про reset не находит даже перебор всех объектов базы:
$ git cat-file --batch-all-objects --batch | grep -c 'maxAttempts = 5'
0
Правка попадает в базу, когда проходит через add, commit или stash.
Отсюда привычка: перед reset --hard, restore и checkout . сделать
git stash, и правка станет коммитом. Свежий клон не
выручит. С сервера, через file:// или с --no-local приезжает только достижимое, а
локальный git clone путь копирует базу целиком, с теми же висячими объектами. А после
git reflog expire --expire=now --all и git gc --prune=now (глава 1.4) искать уже
нечего: уходят и журналы, и всё недостижимое.
Утёкший секрет: сначала ключ
Седьмого сентября Аня закоммитила в limits README и тест, завела от origin/main ветку
gateway для настроек шлюза, и git add . забрал в коммит .env с боевым
токеном:
$ git show --stat --oneline HEAD
764065c (HEAD -> gateway) Настройки шлюза из окружения
.env | 2 ++
config.go | 8 ++++++++
2 files changed, 10 insertions(+)
Ветка ушла на сервер. Merge request №1 на неё изображает ссылка, которую там создали руками: наш сервер не GitLab. Боря забрал ветку на ревью, и токен теперь есть и у него, и у всех, кто может читать репозиторий:
$ git show gateway:.env
PAY_URL=https://gw.example.com
PAY_TOKEN=pt_live_7Qk2mX9vR4sT8wZ1
Начинать надо не с git: отозвать токен в кабинете шлюза, выпустить новый и проверить журнал
шлюза, не пользовался ли кто-то старым. Отозванный токен ничего не открывает, сколько бы копий ни
осталось. Документация GitHub и руководство git filter-repo пишут то же и добавляют, что после
ротации переписывать историю бывает уже незачем.
Если чистить всё же надо (например, в файле персональные данные, их ротацией не отзовёшь), берут
git filter-repo: отдельный скрипт на Python, который ставят пакетным менеджером, через pip
или копированием файла. Флаг --sensitive-data-removal есть с версии 2.47. Встроенный
git filter-branch документация git не рекомендует и сама отправляет к filter-repo. А
filter-repo без --force работает только в свежем клоне:
$ git filter-repo --invert-paths --path .env
Aborting: Refusing to destructively overwrite repo history since
this does not look like a fresh clone.
(expected at most one entry in the reflog for HEAD)
Please operate on a fresh clone instead. If you want to proceed
anyway, use --force.
Одна из проверок смотрит в тот же журнал HEAD: у свежего клона в нём одна запись. Переписывание необратимо, и в свежем клоне это не страшно: оригинал цел на сервере.
$ git clone -q http://localhost:8766/pay.git pay-clean
$ cd pay-clean
$ git filter-repo --sensitive-data-removal --invert-paths --path .env
NOTICE: Fetching all refs from origin to make sure we rewrite
all history that may reference the sensitive data, via
git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*
Parsed 8 commits
New history written in 0.11 seconds; now repacking/cleaning...
You rewrote 2 (of 8) commits.
NOTE: First Changed Commit(s) is/are:
764065c75528c02fa4f03960f46a7b8a9ccec33c
NOTE: LFS object orphaning not checked (LFS not in use)
# дальше вывод repack и список следующих шагов
$ git log --all --oneline -- .env
$ git for-each-ref
ff75a81305a231c90529ef9b37b376731adc139e commit refs/heads/gateway
788a5ad0b5533afad46e5888a0307d8b92a515d5 commit refs/heads/main
ff75a81305a231c90529ef9b37b376731adc139e commit refs/merge-requests/1/head
--invert-paths --path .env выбрасывает файл из каждого коммита. --sensitive-data-removal
сначала забирает с сервера все ссылки, +refs/*:refs/*, и вместе с ветками приезжает
refs/merge-requests/1/head, которую GitLab заводит на каждый merge request (глава 4.1).
Переписаны два коммита из восьми, .env добавил первый из них, и хеши у обоих новые
(глава 1.1). Старый хеш первого, 764065c, filter-repo называет First Changed Commit, он
понадобится поддержке сервера и коллегам. Журналы и старые объекты своего клона filter-repo стирает сам, а пустой
git log --all -- .env подтверждает, что файла нет ни в одной ссылке.
$ git push --force --mirror origin # строки прогресса опущены
To http://localhost:8766/pay.git
+ d12e555...ff75a81 gateway -> gateway (forced update)
! [remote rejected] refs/merge-requests/1/head -> refs/merge-requests/1/head (deny updating a hidden ref)
error: failed to push some refs to 'http://localhost:8766/pay.git'
--mirror отправляет все ссылки разом и удаляет на сервере те, которых в клоне нет;
--force при нём лишний, руководство filter-repo пишет его как напоминание, что чужие коммиты
на сервере затрутся. Ветку сервер принял, ссылку merge request нет. Этот отказ мы имитируем. Ссылку создали через
update-ref, и без настроек push перезаписал бы и её (проверено), поэтому в серверном репозитории
включена receive.hideRefs = refs/merge-requests/. Так делает GitLab: его хранилище Gitaly прячет
служебные ссылки, когда принимает push, и читать их можно, а менять нельзя. GitHub так же держит
refs/pull/* только для чтения, и его документация предупреждает, что push --mirror
на них споткнётся.
Где токен остался после force push
Ветка на сервере чистая, но старые коммиты по-прежнему достаются. Первый путь: ссылку merge request заберёт любой, у кого есть доступ на чтение, а удалить её push не даёт:
$ git ls-remote origin
788a5ad0b5533afad46e5888a0307d8b92a515d5 HEAD
ff75a81305a231c90529ef9b37b376731adc139e refs/heads/gateway
788a5ad0b5533afad46e5888a0307d8b92a515d5 refs/heads/main
d12e5558ed66f78eabee7a5171d1e453759d0ccf refs/merge-requests/1/head
$ git fetch -q origin refs/merge-requests/1/head
$ git show FETCH_HEAD:.env
PAY_URL=https://gw.example.com
PAY_TOKEN=pt_live_7Qk2mX9vR4sT8wZ1
$ git push origin --delete refs/merge-requests/1/head
To http://localhost:8766/pay.git
! [remote rejected] refs/merge-requests/1/head (deny deleting a hidden ref)
error: failed to push some refs to 'http://localhost:8766/pay.git'
Второй путь: клон Бори. Fetch честно передвинул origin/gateway, но журнал remote-tracking
ветки сохранил прежнюю вершину, а локальная gateway Бори так и стоит на старых коммитах:
$ git fetch # строки прогресса опущены
From http://localhost:8766/pay
+ d12e555...ff75a81 gateway -> origin/gateway (forced update)
$ git reflog show origin/gateway
ff75a81 (origin/gateway) refs/remotes/origin/gateway@{0}: fetch: forced-update
d12e555 refs/remotes/origin/gateway@{1}: fetch: storing head
$ git show origin/gateway@{1}:.env
PAY_URL=https://gw.example.com
PAY_TOKEN=pt_live_7Qk2mX9vR4sT8wZ1
Этим же журналом спасают коммиты, которые затёр чужой force push: origin/ветка@{1} хранит
вершину сервера до него. Третий путь опаснее всех: старые коммиты возвращаются на сервер сами. У Бори в
gateway лежит свой коммит поверх старой истории, и он делает то, что делает каждый день:
$ git pull --no-rebase --no-edit
Merge made by the 'ort' strategy.
$ git log --oneline --graph -6
* 3208bcc (HEAD -> gateway) Merge branch 'gateway' of http://localhost:8766/pay into gateway
|\
| * ff75a81 (origin/gateway) Создавать клиента шлюза при старте
| * 620b106 Настройки шлюза из окружения
* | a890ec9 Описать настройки шлюза
* | d12e555 Создавать клиента шлюза при старте
* | 764065c Настройки шлюза из окружения
|/
$ git push # строки прогресса опущены
To http://localhost:8766/pay.git
ff75a81..3208bcc gateway -> gateway
Слияние соединило старую и новую историю, и .env остался в дереве: в одной ветви файл
добавили, в другой его не было вовсе. Push прошёл перемоткой, без force, и запрет force push на
сервере его бы не остановил. В клоне Ани git show origin/gateway:.env снова печатает токен.
Поэтому GitHub требует от коллег rebase вместо merge, а руководство filter-repo советует удалить старые
клоны и склонировать заново.
.env, а служебная ссылка merge request осталась на старом. В клоне Бори старый коммит держат
журнал origin/gateway и локальная ветка: Боря сделал в ней a890ec9 поверх старого. Форки, кэши веб-интерфейса и логи git не
чистит.И это только копии в git. По документации GitHub коммит останется доступен в форках, чистить их придётся с владельцами. Кэш веб-интерфейса и ссылки pull request убирает только поддержка GitHub, и берётся она за это, лишь когда риск нельзя снять ротацией ключа. GitLab хранит данные коммитов, включая содержимое файлов, ещё и в своей базе, поэтому для секретов советует не filter-repo с push, а встроенные Remove blobs и Redact text (роль Owner), а после них housekeeping и Prune unreachable objects. В проекте с форками они не работают. Публичные репозитории, как предупреждает GitHub, ещё и индексируют поисковики и сканеры утечек.
Вопросы
4git reflog покажет прежнюю вершину, git reset --hard <хеш> или
git branch rescue <хеш> вернёт её. Файл, который был в индексе, найдёт
git fsck --lost-found. Правка, которую ни разу не добавляли, пропала.Почему коммиты целы
reset --hard меняет ссылку, индекс и рабочую копию, объектов в базе он не трогает.
Каждое перемещение HEAD записано в журнал, и пока запись жива, gc считает коммит
достижимым. В git 2.54 без настроек это 30 дней (глава 1.4).
Как вернуть
$ git reflog -3
0f9b137 (HEAD -> main, origin/main) HEAD@{0}: reset: moving to origin/main
8a812cd HEAD@{1}: commit: Логировать отказ шлюза
55a06ec HEAD@{2}: commit: Повторять запрос к шлюзу
$ git reset --hard 8a812cd
HEAD is now at 8a812cd Логировать отказ шлюза
Сразу после reset то же сделает git reset --hard ORIG_HEAD. В команду лучше ставить хеш:
HEAD@{1} сдвигается с каждой новой записью.
Что было в индексе и в рабочей копии
Файл, прошедший через git add, лежит в базе блобом без имени. git fsck --lost-found
выложит содержимое висячих блобов в .git/lost-found/other, нужный узнают через
grep. Правку, которую в индекс не добавляли, git не хранил нигде: перебор всех объектов
через git cat-file --batch-all-objects --batch её не находит.
git gc --prune=now «на всякий случай» сразу удаляет висячие объекты. Коммит после
reset это переживёт, его держит журнал, а блоб из индекса пропадёт. Вместе с
git reflog expire --expire=now --all пропали бы и коммиты.
refs/stash, лежит в
.git/logs и никуда не передаётся. В git 2.54 записи живут 30 дней. Потерянный коллегой
коммит ищут в его клоне.Что записано
В .git/logs/refs/heads/main каждая строка хранит старый и новый хеш, коммитера (того, кто сдвинул ссылку), время и причину.
Журнал HEAD видит и коммиты, и переключения,
журнал ветки только её собственные сдвиги. Отсюда разные ответы у HEAD@{1},
main@{1} и @{1}: последний читает журнал текущей ветки.
Сколько живёт
Записи стирает git reflog expire, его вызывает git gc. По документации срок 90 дней
и 30 для записей о недостижимых коммитах, а в git 2.54 без настроек 30 дней для всех
(глава 1.4). Сразу журнал пропадает при git branch -D,
git fetch --prune для remote-tracking ветки и git reflog drop.
Почему не найдёт чужое
Push и clone журналов не переносят: в свежем клоне одна запись «clone: from …». В bare-репозитории
core.logAllRefUpdates по умолчанию выключен, так что на обычном сервере журнала нет. Если
ветку на сервере затёр force push, прежняя вершина есть у всех, кто получил её раньше: в
origin/ветка, а после fetch в её журнале.
@{yesterday} спрашивает про твою ветку
main@{yesterday} говорит, где стояла main в этом клоне сутки назад, а не
какие коммиты сделаны вчера. Не двигал ветку неделю, получишь её нынешнюю вершину.
git fsck --unreachable с фильтром по merge-коммитам и сообщению «WIP». Висячие объекты
git gc удаляет, когда им больше двух недель.Ветка
$ git reflog | grep -A1 'moving from refund'
0f9b137 HEAD@{4}: checkout: moving from refund to main
ed99e9c HEAD@{5}: commit: Возврат платежа
$ git branch refund ed99e9c
branch -D удаляет журнал ветки, но журнал HEAD остаётся. Если на ветку не переключались
(например, remote-tracking ветку убрал fetch --prune), её коммит окажется среди висячих
в git fsck.
Stash
$ git fsck --unreachable --no-progress | grep commit | cut -d' ' -f3 | xargs git log --merges --no-walk --grep=WIP
commit 6652b810034279c5763c88e9f421894cd18392c7
Merge: c95233e c7fbd15
Author: Аня <anya@example.com>
Date: Sat Sep 5 11:00:00 2026 +0300
WIP on limits: c95233e Проверять валюту
Запись stash устроена как merge-коммит рабочей копии с родителями HEAD и коммитом индекса (глава 2.2),
отсюда --merges. Найденный хеш принимает git stash apply, а
git stash store вернёт запись в список. Спешить стоит: такой коммит держит только отсрочка
gc.pruneExpire.
git stash push -m 'черновик' пишет сообщение «On main: черновик», и фильтр
--grep=WIP такую запись пропускает. Убери --grep и смотри все
merge-коммиты. И учти, что fsck --lost-found игнорирует журналы: коммиты, которые ещё
держит reflog, он тоже покажет висячими.
git filter-repo --sensitive-data-removal в свежем клоне,
git push --force --mirror, поддержка GitHub или Remove blobs в GitLab, новые клоны у всех. Без
ротации переписывание не помогает: токен остаётся в ссылках merge request, клонах, форках и кэшах.Почему мало удалить файл
Коммит с удалением ложится поверх старых, и git show <старый коммит>:.env выведет токен.
Force push переписанной истории чистит ветки сервера, но не refs/merge-requests/* в GitLab и
refs/pull/* в GitHub: push их не меняет. У коллег старые коммиты лежат в ветках и в журнале
origin/ветка@{1}, а git pull со слиянием и git push вернут токен на
сервер без force.
По порядку
- Отозвать токен, выпустить новый, проверить журнал доступа у того, кто токен выдал.
- Решить, нужна ли чистка истории: по словам GitHub, после ротации она может не понадобиться.
- Если нужна: остановить все push, в свежем клоне запустить
git filter-repo --sensitive-data-removal(без флага он удалитorigin), проверитьgit log --all -- .env, отправитьgit push --force --mirror origin. - GitHub: отдать поддержке число затронутых pull request и First Changed Commit, она уберёт ссылки PR и кэш и соберёт мусор. GitLab: Remove blobs или Redact text, housekeeping, Prune unreachable objects.
- Всем склонировать заново. Или перенести свои ветки через rebase, выполнить
reflog expire --expire=now --allиgc --prune=nowи проверить First Changed Commit черезgit cat-file -t.
Как не допустить
.env в .gitignore и проверка секретов в хуке pre-commit (как устроены хуки - глава 5.3). У GitHub push protection
для пользователей включена по умолчанию и не пускает известные форматы ключей в публичные репозитории, а
secret scanning публичных репозиториев сообщает о ключах партнёров прямо выпустившим их сервисам. В GitLab
secret push protection есть на тарифе Ultimate, pipeline secret detection на всех. Сканеры надёжнее всего
ловят известные форматы ключей, самодельный токен может проскочить.
Кто склонировал репозиторий до чистки, уже унёс токен, и до его диска никакая команда не дотянется. Поэтому ключ отзывают первым, а не последним.
Что стоит запомнить
- Reflog записывает локальные перемещения HEAD и branch refs.
- Недостижимый object может оставаться доступным до обслуживания и истечения сроков хранения.
- При утечке секрета сначала отзывают credential, затем чистят историю и распространённые клоны.
Частые ошибки
- HEAD@{1} и main@{1} - журналы разных ссылок.
- Перепись истории не удаляет копии, уже имеющиеся у других участников или систем.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Найди старое положение branch
Branch была сброшена, и предыдущий commit больше не виден в обычном log. Какой hash взять для восстановления ссылки?
$ git reflog show main -2
91ac0d1 main@{0}: reset: moving to HEAD~1
4bb782e main@{1}: commit: add retry budget
Ответ и разбор
Старое положение branch - 4bb782e в main@{1}. Перед восстановлением осмотри его через git show 4bb782e, затем создай rescue branch или передвинь main только после проверки. Reflog локальный и со временем очищается.
Код-ревью
2. Отзови токен до переписи истории
После публикации token в config файл инженеры предлагают сначала переписать Git history. Какой риск в этом плане и как изменить порядок?
git filter-repo --path config/token.txt --invert-paths
git push --force --all
Ответ и разбор
Перепись убирает путь из текущих refs, но копии commit уже могли уйти в клоны и зеркала; сам token останется действующим. Сначала отзови token и проверь его использование, затем согласуй очистку всех нужных refs и обновление клонов. У force push должны быть план, права и проверка защищённых веток; после фильтра отдельно проверь, что секрет больше не находится в доступной истории.
Применение
3. Верни удалённую ветку
Ветку удалили локально, но reflog ещё показывает последний commit 4bb782e. Как вернуть её для продолжения работы и что проверить?
Ответ и разбор
Создай ветку на найденном commit, например git branch rescue 4bb782e, затем проверь log, status и нужные файлы. Если удалённая ветка тоже удалена, согласуй восстановление с командой перед push; не используй повторно имя, если remote ref уже занят чужой работой.
Применение
4. Найди dangling stash
Stash исчез из списка, но после сбоя он мог остаться как недостижимый объект. Как искать его и безопасно вернуть?
Ответ и разбор
Запусти git fsck --no-reflogs --unreachable, осмотри найденные commit objects через git show или git log, затем создай ветку на подходящем объекте. Не запускай prune до осмотра: он может удалить недостижимые объекты после срока хранения.