Переписывание истории: amend, interactive rebase, filter-repo и риски
Фраза «переписать историю» звучит зловеще и слегка мошеннически: будто вы подделываете документы. На практике это самая обычная операция, которую хороший инженер делает по несколько раз в день, — и одновременно операция, которой можно за одну команду сломать работу десяти человек. Разница между этими двумя случаями не в команде и не в смелости, а в одном вопросе: опирался ли кто-то на те коммиты, которые вы сейчас заменяете.
Начну с главного тезиса, из которого выводится всё остальное. Git не умеет переписывать историю. Объекты в нём неизменяемы: имя объекта — это хеш его содержимого, поэтому «изменить коммит» физически невозможно — изменённое содержимое даст другой хеш и, значит, будет другим объектом. Всё, что называется переписыванием, — это три шага: создать новые объекты, похожие на старые, но с нужными правками; переставить ссылку (ветку, HEAD, тег) на новую цепочку; оставить старые объекты валяться в хранилище, пока их не приберёт сборщик мусора. Ровно поэтому переписывание безопасно локально (старое лежит рядом, reflog помнит адрес) и опасно публично (у других уже записан адрес старого, и он больше никуда не ведёт).
Если модель объектов и DAG пока не улеглась в голове — прочитайте «Внутри Git»; дальше я опираюсь на неё без оговорок. Базовые операции отмены (reset, restore, revert) разобраны в «Ежедневной работе», здесь мы идём глубже.
1. Что физически происходит при amend
Возьмём безобидный случай: последний коммит с опечаткой в сообщении.
$ git log --oneline -2
a1b2c3d добавь валидацю промокода
9c2b7e4 вынеси расчёт скидки в сервис
$ git commit --amend -m "добавь валидацию промокода"
[feature/cart 7f4e9a2] добавь валидацию промокода
Date: Mon Jul 14 11:00:00 2026 +0300
1 file changed, 12 insertions(+)
$ git cat-file -t a1b2c3d
commit # старый коммит никуда не делся
$ git cat-file -p 7f4e9a2 | head -4 # а рядом появился новый
tree 8d3e1f0ab4c9d2e5f7a1b3c6d8e0f2a4b6c8d0e2 # то же дерево: содержимое файлов не менялось
parent 9c2b7e4f1a3c5d7e9f0b2d4a6c8e0f2a4b6c8d # тот же родитель
author Ivan Petrov <ivan@example.com> 1752480000 +0300 # дата автора СОХРАНЕНА
committer Ivan Petrov <ivan@example.com> 1752483751 +0300 # дата коммиттера ОБНОВЛЕНА
Строка Date: в выводе git commit появилась именно потому, что дата автора теперь расходится с датой коммиттера. Это встроенный в формат коммита механизм «кто написал» против «кто в последний раз применил»: rebase и amend меняют вторую и уважают первую — поэтому после переноса патча вы видите правильное авторство, но новый хеш.
| Что делаете | Дата автора | Дата коммиттера | Автор |
|---|---|---|---|
git commit --amend |
сохраняется | обновляется | сохраняется |
git commit --amend --reset-author |
становится текущей | обновляется | становится вами |
git commit --amend --date=now |
становится текущей | обновляется | сохраняется |
git rebase (любой) |
сохраняется у каждого коммита | обновляется | сохраняется |
git commit --amend --no-edit # доложить staged-правки, не трогая сообщение
git commit --amend --only -m "..." # игнорировать индекс: точно ничего не подмешается
git commit --amend --author="Anna Ivanova <anna@example.com>"
git commit --amend -S # переподписать: при amend подпись теряется
Три ловушки: --amend не спрашивает, где вы находитесь — случайный amend на main вместо commit переписывает чужой коммит на общей ветке, и ORIG_HEAD при этом не ставится, спасает только git reset --hard HEAD@{1}; --amend втягивает индекс целиком — «поправлю опечатку в сообщении» при наполовину заstage-ленных правках уносит в коммит мусор, поэтому --only; amend merge-коммита остаётся слиянием, но переписывает вершину, и все, кто уже слил вашу ветку, получат расхождение.
2. Почему меняется хеш у всех потомков
В коммите записан хеш родителя. Поменяли коммит — изменился его хеш — изменилось содержимое ребёнка (в поле parent) — изменился хеш ребёнка, и так до самой вершины. Цепная реакция, ровно как в блокчейне (Git старше на четыре года).
$ git log --oneline -5 # правим четвёртый снизу коммит через rebase -i 3f1a9c2
d4e5f6a обнови тесты → 0a1b2c3 новый хеш, содержимое то же
c3d4e5f поправь округление → 5f6a7b8 новый хеш
b2c3d4e добавь валидацию → 4c7e6f1 новый хеш
9c2b7e4 вынеси расчёт скидки → b8a1d02 изменённый коммит
3f1a9c2 базовая корзина → 3f1a9c2 ниже точки правки — не менялся
Отсюда практическое следствие: стоимость переписывания линейна по числу коммитов над точкой правки, а риск — по числу людей, у которых эти коммиты уже есть. Правка последнего коммита не задевает никого; правка корневого коммита в репозитории на 12 000 коммитов задевает всех и всё.
3. Карта инструментов: масштаб решает
Половина катастроф — от применения кувалды там, где хватало пинцета. Спускайтесь по этой карте сверху вниз и останавливайтесь на первом инструменте, который решает задачу.
4. Интерактивный rebase: todo-файл — это программа
Если задачу решает git revert — не трогайте rebase; если решает rebase своей ветки — не трогайте filter-repo. git rebase -i <база> открывает в редакторе список коммитов от старых к новым (в обратном порядке относительно git log) и предлагает написать программу их пересборки.
pick 9c2b7e4 вынеси расчёт скидки в сервис
pick a1b2c3d добавь валидацию промокода
pick d4e5f6a fixup! вынеси расчёт скидки в сервис
pick c3d4e5f отладочный print, убрать
# Rebase 3f1a9c2..c3d4e5f onto 3f1a9c2 (4 commands)
# p, pick / r, reword / e, edit / s, squash / f, fixup [-C|-c] / d, drop
# x, exec / b, break / l, label / t, reset / m, merge
Ключевое: этот файл не «применяется к истории», он её порождает. Git отматывает HEAD к базе и выполняет список строка за строкой, создавая новые коммиты. Удалили строку — коммита не будет. Поменяли строки местами — коммиты пойдут в новом порядке (и, скорее всего, конфликтнут, если правят один код).
| Команда | Когда применяется | Что важно помнить |
|---|---|---|
pick |
по умолчанию | коммит пересоздаётся: хеш новый даже без правок |
reword |
плохое сообщение | редактор откроется, содержимое не трогается |
edit |
разбить коммит или доложить правки | rebase остановится после применения коммита |
squash / fixup |
объединить с предыдущим | squash открывает редактор с обоими сообщениями, fixup выбрасывает второе (fixup -C — наоборот) |
drop |
выбросить коммит | явнее, чем удаление строки, и заметно в диффе |
exec / break |
проверка на каждом шаге или ручная пауза | git rebase -i --exec 'make test' origin/main |
Настройки, которые превращают rebase из лотереи в инструмент
git config --global rebase.missingCommitsCheck error # ошибка, если строка пропала «случайно»
git config --global rebase.autoSquash true # всегда --autosquash
git config --global rebase.autoStash true # спрятать грязное дерево и вернуть после
git config --global rebase.updateRefs true # двигать зависимые ветки (Git 2.38+)
git config --global rerere.enabled true # запоминать разрешённые конфликты
git config --global alias.pushf 'push --force-with-lease --force-if-includes' # безопасный force
rebase.missingCommitsCheck=error — самая недооценённая строка в списке: по умолчанию Git молча выбрасывает коммит, если вы случайно удалили строку из todo-файла (промахнулись по dd в vim). rerere (reuse recorded resolution) ценен именно при переписывании: перебазируя ветку на движущийся main третий раз, вы третий раз разрешаете те же конфликты — с rerere Git применит запомненное решение сам. Подробности — в статье про конфликты.
Жизненный цикл интерактивного rebase
Разница между --abort и --quit спасает нервы: первый возвращает ветку в исходное состояние, второй просто прекращает операцию, оставляя HEAD там, где он есть. Пока rebase идёт, его состояние лежит на диске обычными файлами — это не магия, а текст:
$ ls .git/rebase-merge/
done end git-rebase-todo head-name interactive msgnum onto orig-head
$ cat .git/rebase-merge/orig-head # куда вернёт --abort
$ git rebase --edit-todo # поправить план на лету
Разбить один коммит на два
Самая полезная операция, которую почти никто не знает: помечаем коммит как edit, расформировываем и собираем заново.
$ git rebase -i HEAD~3 # ставим: edit b2c3d4e
Stopped at b2c3d4e... добавь валидацию и логирование
$ git reset HEAD^ # коммит расформирован, правки в рабочем каталоге
$ git add -p app/promo.py && git commit -m "добавь валидацию промокода"
$ git add . && git commit -m "добавь логирование отказов промокода"
$ git rebase --continue # остальные коммиты пересобираются поверх
5. Что rebase делает механически
Распространённое заблуждение: «rebase — это применение патчей». Так было до Git 2.26; сейчас по умолчанию работает merge-бэкенд. Для каждого переносимого коммита Git выполняет полноценное трёхпутевое слияние, где базой служит родитель исходного коммита, «нашим» — текущая вершина новой цепочки, «их» — сам коммит. Поэтому rebase корректно переносит переименования и конфликтует так же, как merge, — не «хуже» и не «лучше».
для каждого коммита C из списка:
base = parent(C); ours = HEAD; theirs = C # ДО изменения / новая вершина / ПОСЛЕ
tree = three_way_merge(base, ours, theirs)
если конфликт: остановиться и ждать человека
HEAD = commit(tree, parent=HEAD, автор и сообщение от C, committer=я)
Сложность: O(N) слияний для N переносимых коммитов, каждое — O(размера изменённых деревьев), плюс однократный поиск merge-base, O(коммитов между ветками). Практический вывод: перебазировать 5 коммитов дёшево, 500 — долго и почти наверняка через череду конфликтов, потому что один и тот же конфликт вы будете разрешать в контексте каждого промежуточного коммита, а не один раз, как при merge. Это, а вовсе не «красота истории», — главный технический аргумент в споре merge против rebase; разбор — в статье 04.
Почему коммиты «исчезают» сами
Rebase считает patch-id — хеш нормализованного диффа без номеров строк — и молча выбрасывает коммиты, чей патч уже есть в апстриме. Это не баг, а лечение частой боли: ваш коммит уже приняли через cherry-pick или squash-merge, и повторное применение дало бы конфликт с самим собой.
$ git rebase origin/main
Successfully rebased and updated refs/heads/feature/cart.
$ git log --oneline origin/main..HEAD # пусто: всё уже в main под другими хешами
$ git cherry -v origin/main # диагностика ДО запуска rebase
- 9c2b7e4 вынеси расчёт скидки в сервис # "-" = патч уже наверху
+ a1b2c3d добавь валидацию промокода # "+" = ещё нет
Управляют этим флаги --reapply-cherry-picks (применить всё заново) и --empty=drop|keep|ask (судьба коммитов, ставших пустыми).
Хирургические формы rebase
# Перенести ТОЛЬКО коммиты ветки, отрезав их от старой базы. Читается как
# «куда, откуда-исключительно, что» — единственная форма, которую стоит выучить наизусть:
git rebase --onto main old-base feature
git rebase --onto HEAD~5 HEAD~3 # выбросить нижние 3 коммита, сохранив верхние
git rebase --onto main feature/api feature/ui # ветка отведена не от того места
git rebase -i --root # переписать историю с самого первого коммита
git rebase -i --rebase-merges origin/main # сохранить структуру слияний
Стеки веток и --update-refs
Если вы работаете стеком PR-ов (feature-a → feature-b), перебазирование верхней ветки раньше оставляло нижние висеть на старых коммитах. С Git 2.38 есть --update-refs: rebase двигает все ветки, чьи вершины попали в переносимый диапазон.
$ git switch feature-b && git rebase --update-refs origin/main
Successfully rebased and updated refs/heads/feature-b.
Updated the following refs with --update-refs: refs/heads/feature-a
Без флага feature-a осталась бы на старых A1/A2, а feature-b — на новых A1'/A2'/B1'. Симптом узнаваемый: «после ребейза в моём PR внезапно 40 чужих файлов».
6. Autosquash: рабочий цикл код-ревью
Самый частый легитимный повод переписать историю — правки по замечаниям ревьюера. Плохой вариант: коммиты «fix review», «fix review 2», «наконец-то». Хороший — привязать правку к тому коммиту, к которому она относится, и схлопнуть перед мержем.
$ git add app/discount.py && git commit --fixup 9c2b7e4 # проблема была в коммите 9c2b7e4
[feature/cart e1f2a3b] fixup! вынеси расчёт скидки в сервис
$ git commit --fixup=amend:9c2b7e4 # (Git 2.32+) правка + возможность изменить сообщение
$ git commit --fixup=reword:9c2b7e4 # только сообщение, без изменений в коде
$ git commit --squash 9c2b7e4 # схлопнуть, объединив сообщения
$ git rebase -i --autosquash origin/main # перед мержем todo-файл собирается сам
Ключевая ценность: ревью не ломается. Если схлопнуть сразу, ревьюер при следующем заходе увидит совершенно другую историю и не поймёт, что изменилось с прошлого раза. Поэтому: --fixup во время ревью, --autosquash перед мержем. Требования к оформлению — в статье «Pull Request» и в «Совместной работе».
7. range-diff: доказать, что вы ничего не сломали
После переписывания возникает законный вопрос: «а точно ли новая ветка делает то же, что старая?». Диффом веток это не проверить — они одинаковы по итоговому состоянию, но разные по составу коммитов. Для этого есть git range-diff (Git 2.19+) — дифф между двумя сериями коммитов.
$ git range-diff origin/main...@{u} origin/main...HEAD
1: 9c2b7e4 = 1: b8a1d02 вынеси расчёт скидки в сервис
2: a1b2c3d ! 2: 4c7e6f1 добавь валидацию промокода
@@ app/promo.py: def apply_promo(code, total)
- if code is None:
+ if not code:
3: d4e5f6a < -: ------- временный отладочный print
-: ------- > 3: 0a1b2c3 обнови тесты на промокоды
Читается так: = — коммит идентичен, ! — тот же коммит, но патч изменился (и показано, чем), < — коммит был и пропал, > — появился. Это ровно тот отчёт, который стоит приложить к PR-у после force-push. Второй способ убедиться — сравнить итоговые деревья: git diff ORIG_HEAD HEAD должен быть пуст, если вы чистили только форму истории.
8. Массовое переписывание: filter-repo вместо filter-branch
Всё описанное выше работает с десятками коммитов. Когда нужно пройти всю историю — удалить файл с секретом, вырезать 400-мегабайтный дамп, поправить email во всех коммитах, разрезать монолит на два репозитория — нужны другие инструменты.
git filter-branch использовать не надо. С Git 2.24 команда официально помечена устаревшей и печатает предупреждение при запуске. Причины перечислены прямо в её документации: катастрофическая производительность, множество неочевидных способов испортить репозиторий (незамеченные ошибки в фильтрах, поломка тегов и подписей, оставленные original-ссылки) и почти всегда неправильный результат с первого раза.
| Инструмент | Как работает | Сложность | 12k коммитов, 100k объектов |
|---|---|---|---|
git filter-branch |
checkout каждого коммита + запуск шелла на коммит | O(N) процессов ОС × стоимость checkout | часы |
| BFG Repo-Cleaner | JVM, многопоточно, только замена блобов | O(объектов), вершину не трогает | минуты |
git filter-repo |
fast-export → фильтр в памяти → fast-import, один проход |
O(объектов) | десятки секунд |
git-filter-repo написан Элайджей Ньюреном (мейнтейнер Git, автор нынешнего merge-движка) и рекомендован официальной документацией как замена filter-branch. Технически это один Python-файл.
$ pip install git-filter-repo # либо apt/brew install git-filter-repo
$ git clone --mirror git@github.com:acme/shop.git shop.git && cd shop.git
$ git filter-repo --analyze # разведка: что занимает место, где живут файлы
Processed 98241 objects; writing reports to .git/filter-repo/analysis...
$ git filter-repo --path secrets/prod.env --invert-paths
Parsed 12483 commits
New history written in 4.71 seconds; now repacking/cleaning...
HEAD is now at 5b19c0a обнови зависимости
Completely finished after 21.36 seconds.
# Рабочий набор рецептов
git filter-repo --strip-blobs-bigger-than 10M # вырезать тяжёлые блобы: дампы, архивы, видео
git filter-repo --replace-text patterns.txt # заменить секреты по образцу во всей истории
git filter-repo --subdirectory-filter services/billing # подкаталог как корень: разрезание монорепо
git filter-repo --to-subdirectory-filter services/billing # обратное: перед слиянием в монорепо
git filter-repo --path libs/core --path libs/utils # оставить только эти пути: выделение библиотеки
Файл patterns.txt для --replace-text (строка без ==> заменяется на ***REMOVED***):
literal:AKIAIOSFODNN7EXAMPLE==>AWS_KEY_REMOVED
regex:ghp_[A-Za-z0-9]{36}==>GITHUB_TOKEN_REMOVED
glob:*.pem==>REMOVED
password_prod_2024
--replace-text меняет содержимое блобов, а не удаляет файлы: история остаётся связной, но токены превращаются в заглушки.
Две вещи, которые filter-repo делает нарочно и которые пугают в первый раз. Во-первых, он удаляет origin — защита от пуша переписанной истории «по мышечной памяти»; remote придётся добавить руками. Во-вторых, он требует свежий клон (иначе просит --force): в рабочем клоне валяются старые ссылки, stash, reflog и незакоммиченные правки, которые тихо вернут вырезанные объекты обратно. После работы остаётся карта соответствия хешей .git/filter-repo/commit-map (строка из нулей в колонке new значит, что коммит стал пустым и исчез) — она бесценна, если в задачах и чейнджлоге есть ссылки на коммиты.
BFG Repo-Cleaner — альтернатива на JVM, проще в базовых сценариях («удали файл с таким именем», «замени эти строки»), очень быстрая, но умеет меньше и по умолчанию не трогает последний коммит каждой ветки. Если секрет лежит в вершине — сначала удалите его обычным коммитом, потом чистите историю.
9. Секрет попал в репозиторий: честный порядок действий
Это тот случай, когда переписывание обязательно — и когда оно не решает проблему целиком. Порядок важнее инструментов.
сменить пароль, перевыпустить ключ"] B --> C{"Репозиторий
публичный?"} C -->|Да| D["Считать скомпрометированным:
боты сканируют GitHub за минуты"] C -->|Нет| E["Проверить логи доступа:
кто клонировал и когда"] D --> F["Оценить радиус: форки, зеркала, CI-кэши,
локальные клоны, бэкапы"] E --> F F --> G{"Секрет только в
последнем коммите?"} G -->|Да, и не запушен| H["git rm --cached + commit --amend"] G -->|Нет| I["Свежее зеркало + git filter-repo:
--replace-text или --path --invert-paths"] I --> J["Окно работ: команда останавливается,
снята защита ветки, push --force --all и --tags"] J --> L["Попросить хостинг удалить недостижимые объекты и кэши;
команда пересоздаёт клоны или делает fetch + reset --hard"] L --> N["Добавить в CI сканер секретов:
gitleaks, trufflehog, pre-commit хук"] H --> N
Три пункта, о которых обычно молчат:
- Ротация — шаг ноль, а не последний. Пока вы аккуратно переписываете историю, утёкший токен продолжает работать. Публичные репозитории сканируются автоматически, и время до эксплуатации утёкшего облачного ключа измеряется минутами, а не днями.
- Хостинг помнит. GitHub и GitLab держат недостижимые объекты и отдают их по полному SHA ещё долго после force-push; ссылки в комментариях PR продолжают работать, а для удаления кэшированных представлений GitHub требует обращения в поддержку.
- Форк — это чужой репозиторий. Переписать историю в чужом клоне вы не можете: всё, что попало в публичный форк, останется там навсегда.
Управление секретами и их сканирование подробно разобраны в «Управлении секретами», встраивание сканеров в пайплайн — в «Безопасности в пайплайне», а хуки, не дающие закоммитить ключ, — в статье 08.
10. Радиус поражения: что именно ломает force-push
Механика поломки у коллеги выглядит так — он ничего плохого не делал, просто сделал git pull:
C1 C2 C3 и C1' C2' B->>R: git push Note over R: удалённые коммиты вернулись,
каждая правка теперь в истории дважды
Аня делает fetch и не понимает: «я же всё почистил, откуда C3?». Это не выдуманный сценарий, а самый частый способ «воскресить» вычищенный секрет. Поэтому force-push на общую ветку — всегда согласованная операция, а не техническая.
--force-with-lease и его дыра
Никогда не используйте голый --force. --force-with-lease проверяет, что удалённая ветка сейчас там же, где ваша remote-tracking-ссылка, то есть что вы не затираете чужой push, появившийся после вашего fetch:
$ git push --force-with-lease origin feature/cart
! [rejected] feature/cart -> feature/cart (stale info)
# Перевод: пока вы работали, кто-то запушил в эту ветку. Разберитесь.
Теперь честно про дыру, о которой знают немногие. --force-with-lease сравнивает с вашей remote-tracking-ссылкой, а её обновляет любой git fetch — в том числе фоновый fetch вашей IDE или git fetch --all из привычки. После такого fetch «лиза» становится актуальной, защита испаряется, и вы затрёте чужие коммиты, которых даже не видели. Лечение — --force-if-includes (Git 2.30+): он дополнительно требует, чтобы вершина удалённой ветки была достижима из вашего reflog, то есть чтобы вы её реально видели локально.
git push --force-with-lease --force-if-includes origin feature/cart
git push --force-with-lease=feature/cart:d4e5f6a origin feature/cart # ещё строже: явное значение
Что теряется при переписывании, помимо хешей
| Что теряется | Почему | Что делать |
|---|---|---|
| GPG/SSH-подписи коммитов | подпись покрывает содержимое, включая родителя | git rebase -S или commit.gpgSign=true; см. статью 08 |
git notes и аннотированные теги |
привязаны к хешу и указывают на старый объект | задать notes.rewriteRef=refs/notes/* (по умолчанию не задано), теги пересоздать и переподписать |
| Ссылки на SHA в задачах, changelog, деплой-логах | старые хеши ничему не соответствуют | сохранить commit-map и опубликовать таблицу соответствия |
| Комментарии ревью и результаты CI | привязаны к хешу: GitHub помечает их outdated | не переписывать во время активного ревью, перезапустить пайплайн |
11. Неразрушающие альтернативы, о которых забывают
Половина запросов «надо переписать историю» решается вообще без переписывания. git revert — отмена уже опубликованного изменения новым коммитом: история растёт, но остаётся честной и никого не ломает. Это единственный допустимый способ откатить что-то в main, на который все смотрят.
.mailmap — если задача звучит как «у меня в истории три разных email и кривое имя», не надо переписывать 12 000 коммитов. Положите в корень репозитория файл, и git log, git blame и статистика начнут показывать правильные имена, не меняя ни одного хеша:
# .mailmap — формат: Правильное Имя <правильный@email> <старый@email>
Ivan Petrov <ivan@example.com> <ivan@localhost>
Ivan Petrov <ivan@example.com> ivan.p <ivan.p@old-corp.com>
Проверить результат — git shortlog -sne; начиная с Git 2.26 настройка log.mailmap включена по умолчанию, так что git log и git blame учитывают файл сами.
git replace — подмена объекта на уровне чтения: git replace --graft <корневой-коммит> <вершина-архива> пришивает архивную историю к укороченному репозиторию, не меняя ни одного существующего объекта (git --no-replace-objects log покажет «настоящую» историю, git replace -d отменит подмену). Оговорка: replace-ссылки живут в refs/replace/* и по умолчанию не отправляются git push — нужен явный refspec. Это инструмент для локального анализа и подготовки к настоящему переписыванию (filter-repo умеет «запечь» графты в реальную историю), а не для повседневной командной работы.
12. Восстановление: почти всё возвращается
Главное успокаивающее знание: переписывание почти никогда не удаляет данные немедленно. Старые коммиты остаются в .git/objects и достижимы через reflog до истечения срока (gc.reflogExpire — 90 дней для достижимых записей, gc.reflogExpireUnreachable — 30 дней для остальных).
$ git reflog # после неудачного rebase -i HEAD~5
0a1b2c3 HEAD@{0}: rebase (finish): returning to refs/heads/feature/cart
5f6a7b8 HEAD@{2}: rebase (squash): поправь округление
3f1a9c2 HEAD@{4}: rebase (start): checkout 3f1a9c2
d4e5f6a HEAD@{5}: commit: обнови тесты <- состояние ДО rebase
$ git reset --hard HEAD@{5} # вернулись; то же короче — git reset --hard ORIG_HEAD
Ищите в reflog строку rebase (start): запись непосредственно перед ней и есть состояние до операции. Три дополнительных приёма:
$ git reflog origin/main # у remote-tracking-веток reflog тоже есть: чужой force-push откатывается
$ git fsck --lost-found --no-progress # если reflog не помог — ищем висячие коммиты
dangling commit a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
$ git branch rescue/lost a1b2c3d # а перед рискованной операцией — git branch backup/before-rebase
Единственное, что действительно необратимо: git gc --prune=now или git reflog expire --expire-unreachable=now --all после потери. Отсюда первое правило паники: ничего не «чистить», пока не восстановили. Полная методика, включая испорченные объекты и разобранный .git, — в статье 07.
13. Почему спор о переписывании — не про Git
«Никогда не переписывай публичную историю» — правило верное, но сформулированное неточно, и потому бесполезное. Точная формулировка: не переписывайте коммиты, на которые кто-то мог опереться, без явной договорённости. Разница принципиальная. Ветка PR-а «публична» в смысле «лежит на сервере», но никто на неё не опирается — force-push туда абсолютно нормален и является частью здорового ревью. А main, релизная ветка или ветка, от которой кто-то уже отвёл свою, — это фундамент, и его двигать нельзя, даже если формально у вас есть права.
Отсюда практический вывод: договорённости важнее команд, а техническая защита дешевле спора. На своём bare-репозитории это receive.denyNonFastForwards = true и receive.denyDeletes = true, на GitHub/GitLab — одна галочка в защите ветки main (force-push и удаление запрещены). Плюс три правила, снимающие 90% боли:
- Свою ветку переписываю свободно, чужую — никогда. «Своя» = никто не отвёл от неё ветку и не ждёт стабильных хешей.
- Force-push только с
--force-with-lease --force-if-includes. Голый--forceв алиасах и скриптах запрещён. - Переписывание общей ветки — инцидент с окном. Объявление в чате, все останавливают работу, после — инструкция
git fetch && git reset --hard @{u}(а неgit pull).
Выбор между «мы линеаризуем историю rebase-ом» и «мы сохраняем merge-коммиты» — это выбор процесса и способа читать историю, а не технический спор о превосходстве команды; аргументы обеих сторон — в «Merge и rebase» и в «Стратегиях ветвления».
Типичные ошибки
git push --forceв общую ветку «чтобы быстрее». Цена — рабочий день команды и, скорее всего, воскрешение того, что вы чистили.- Rebase ветки во время активного ревью. Комментарии становятся outdated, а разница «что изменилось с прошлого раза» — невычислимой. Используйте
--fixupи схлопывайте в конце. git pullвместоgit fetch && git reset --hard @{u}после чужого переписывания. Это тот самый merge двух версий истории, который всё возвращает обратно. И filter-repo в рабочем клоне: stash, reflog и локальные ветки вернут вырезанное — толькоgit clone --mirror.- Чистка секрета без ротации. Утёкший ключ остаётся действующим, а вычищенная история создаёт ложное чувство безопасности.
git rebaseдлинной ветки на давно ушедший main. Каждый конфликт разрешается заново на каждом коммите: или merge, илиrebase --ontoдо промежуточной точки.- Удаление строк в todo-файле вместо
dropбезrebase.missingCommitsCheck=error— пропажу коммита никто не заметит. Иgit gc --prune=nowв панике — шаг, превращающий восстановимую ситуацию в невосстановимую.
Мини-итог
- Переписывания не существует: есть создание новых объектов и перестановка ссылок. Старые коммиты живут в
.git/objects, пока их не соберёт GC. Хеш коммита включает хеш родителя, поэтому правка одного коммита меняет хеши всех потомков: стоимость линейна, риск — по числу людей с этими коммитами. amend— для последнего коммита,rebase -i— для своего диапазона,filter-repo— для всей истории. Не берите инструмент крупнее задачи.- Интерактивный rebase — программа в todo-файле. Включите
rebase.missingCommitsCheck=error,rebase.autoSquash,rerere.enabled. - Rebase выполняет трёхпутевое слияние на каждый коммит: O(N) слияний и до N разрешений одного и того же конфликта.
git range-diffдоказывает, что переписанная серия эквивалентна старой. --force-with-leaseобманывается фоновым fetch — добавляйте--force-if-includes. Секрет в истории: сначала ротация, потом чистка; форки, зеркала и кэши хостинга вам не подчиняются.- Почти всё возвращается:
ORIG_HEAD,git reflog,git fsck --lost-found. Не запускайтеgc --prune=nowдо восстановления. - Правило не «не переписывай публичное», а «не переписывай то, на что опираются, без договорённости». Остальное — вопрос процесса команды, а не Git.
Источники
- Pro Git, глава 7.6 «Git Tools — Rewriting History» — https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History
- Документация
git rebase(merge-бэкенд,--onto,--update-refs) — https://git-scm.com/docs/git-rebase - Документация
git commit(--amend,--fixup,--reset-author) — https://git-scm.com/docs/git-commit - Документация
git filter-branchс разделом «WARNING» и обоснованием отказа — https://git-scm.com/docs/git-filter-branch git-filter-repo: репозиторий и подробное руководство — https://github.com/newren/git-filter-repo, и BFG Repo-Cleaner — https://rtyley.github.io/bfg-repo-cleaner/- Документация
git pushпро--force-with-leaseи--force-if-includes— https://git-scm.com/docs/git-push - Документация
git range-diff— https://git-scm.com/docs/git-range-diff - Документация
git replace— https://git-scm.com/docs/git-replace, формат.mailmap— https://git-scm.com/docs/gitmailmap - GitHub Docs, «Removing sensitive data from a repository» — https://docs.github.com/en/authentication/keeping-your-account-secure/removing-sensitive-data-from-a-repository
- Highlights from Git 2.38 (появление
--update-refs) — https://github.blog/2022-10-03-highlights-from-git-2-38/
Что дальше
Переписывание истории почти всегда упирается в конфликты: rebase заставляет разрешать их по одному на каждый переносимый коммит, и именно здесь ломается большинство попыток «просто перебазировать ветку». В следующей статье — «Конфликты слияния: почему возникают, как разрешать, как предотвращать»: откуда берутся маркеры, что такое стадии индекса, как читать diff3, чем помогает rerere и как проектировать код так, чтобы конфликтов было меньше.