Git и командная работа Переписывание истории: amend, interactive rebase, filter-repo и риски
0%

Переписывание истории: amend, interactive rebase, filter-repo и риски

Переписывание истории: 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-afeature-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. Секрет попал в репозиторий: честный порядок действий

Это тот случай, когда переписывание обязательно — и когда оно не решает проблему целиком. Порядок важнее инструментов.

Три пункта, о которых обычно молчат:

  • Ротация — шаг ноль, а не последний. Пока вы аккуратно переписываете историю, утёкший токен продолжает работать. Публичные репозитории сканируются автоматически, и время до эксплуатации утёкшего облачного ключа измеряется минутами, а не днями.
  • Хостинг помнит. GitHub и GitLab держат недостижимые объекты и отдают их по полному SHA ещё долго после force-push; ссылки в комментариях PR продолжают работать, а для удаления кэшированных представлений GitHub требует обращения в поддержку.
  • Форк — это чужой репозиторий. Переписать историю в чужом клоне вы не можете: всё, что попало в публичный форк, останется там навсегда.

Управление секретами и их сканирование подробно разобраны в «Управлении секретами», встраивание сканеров в пайплайн — в «Безопасности в пайплайне», а хуки, не дающие закоммитить ключ, — в статье 08.

10. Радиус поражения: что именно ломает force-push

Радиус поражения переписанной истории

Механика поломки у коллеги выглядит так — он ничего плохого не делал, просто сделал git pull:

Аня делает 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% боли:

  1. Свою ветку переписываю свободно, чужую — никогда. «Своя» = никто не отвёл от неё ветку и не ждёт стабильных хешей.
  2. Force-push только с --force-with-lease --force-if-includes. Голый --force в алиасах и скриптах запрещён.
  3. Переписывание общей ветки — инцидент с окном. Объявление в чате, все останавливают работу, после — инструкция 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.

Источники

Что дальше

Переписывание истории почти всегда упирается в конфликты: rebase заставляет разрешать их по одному на каждый переносимый коммит, и именно здесь ломается большинство попыток «просто перебазировать ветку». В следующей статье — «Конфликты слияния: почему возникают, как разрешать, как предотвращать»: откуда берутся маркеры, что такое стадии индекса, как читать diff3, чем помогает rerere и как проектировать код так, чтобы конфликтов было меньше.

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

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

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

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