12k
All articles

Исправление старых коммитов с помощью git history

git history в Git 2.55 переписывает старые коммиты через fixup, reword или split, обновляет stacked branches и показывает безопасный dry-run.

OpenReplay Team
OpenReplay Team
Исправление старых коммитов с помощью git history

Экспериментальная команда git history перезаписывает один старый коммит — сворачивая в него проиндексированные изменения, заменяя его сообщение или разделяя его на два — и перемещает каждую локальную ветку, построенную поверх него, ни разу не запуская интерактивный rebase.

Если вы держите несколько ветвей, наложенных друг на друга (stacked branches), вам, вероятно, знаком тот сценарий сбоя, который эта команда призвана заменить: вы замечаете опечатку тремя коммитами ниже, запускаете rebase -i, натыкаетесь на конфликт в коммите, который вообще не собирались трогать, и вот вы уже посреди rebase с detached HEAD и необходимостью принимать решение. git history создана для того, чтобы вы туда не попадали. Её подкоманды объединяет одна гарантия: операция либо завершается успешно, либо прерывается с ошибкой и ничего не меняет.

В этой статье мы разберём три подкоманды в Git 2.55 с примерами вывода, покажем флаг --dry-run для предпросмотра перезаписи до того, как что-либо будет изменено, и рассмотрим ограничения: никаких историй со слияниями, никаких конфликтующих операций, никаких хуков.

Ключевые выводы

  • В Git 2.55 у git history есть три подкоманды: fixup сворачивает проиндексированные изменения в старый коммит, reword заменяет его сообщение, а split делит его на два.
  • Каждая операция атомарна: если перезапись привела бы к конфликту в любом месте воспроизводимой истории, команда прерывается и ничего не меняет.
  • По умолчанию обновляются все локальные ветки, являющиеся потомками перезаписанного коммита; --update-refs=head перемещает только текущий HEAD.
  • --dry-run ничего не перемещает, а вместо этого печатает обновления ссылок для последующего применения через git update-ref — это безопасный способ предварительно оценить перезапись.
  • Команда экспериментальная, не запускает хуки и отказывается работать с историями, содержащими слияния; для них используйте git rebase --rebase-merges.

Зачем нужна команда git history?

git history существует для того, чтобы исправить один старый коммит без церемоний и рисков интерактивного rebase. Официальное руководство противопоставляет её git rebase: меньше вариантов выбора и более простая команда, к которой удобнее обратиться, когда правка узкая. Она также помечена как экспериментальная, поэтому флаги и поведение ещё могут меняться между релизами.

Два свойства делают её достойной изучения. Первое — атомарность: любая операция, которая могла бы закончиться конфликтом слияния, просто не предлагается. Это сознательное решение, поскольку команда трактует перезапись как единичное действие, а не как сессию, через которую вы проходите шаг за шагом. Здесь нет --continue, нет --abort и нет незавершённого состояния, из которого нужно выбираться. Второе — охват: по умолчанию она обновляет все локальные ветки, указывающие на потомков перезаписанного коммита, а это именно то, что нужно при работе с наложенными ветками.

Команда появилась в два этапа. reword и split вышли в Git 2.54; в Git 2.55 добавили fixup. Итого три подкоманды на момент версии 2.55, которую и рассматривает эта статья:

Подкоманда (Git 2.55)Что она делаетРаботает в bare-репозитории
git history fixupСворачивает проиндексированные изменения в старый коммитНет, она читает индекс
git history rewordЗаменяет сообщение старого коммитаДа
git history splitДелит один коммит на дваДа

Ожидайте, что этот список будет расширяться. В находящемся в разработке руководстве Git по git history уже описана четвёртая подкоманда — drop, которая удаляет коммит и воспроизводит его потомков поверх его родителя. Так что прежде чем считать, что три — это всё, сверьтесь с git history -h в вашей версии.

fixup: свернуть проиндексированные изменения в старый коммит

git history fixup <commit> берёт всё, что проиндексировано, и встраивает это в целевой коммит. Под капотом это трёхстороннее слияние, где тремя входами выступают HEAD, целевой коммит и дерево, построенное из ваших проиндексированных изменений. Целевой коммит сохраняет исходное сообщение и автора, если вы не запросите редактор через --reedit-message. По смыслу это эквивалент git commit --fixup с последующим git rebase --autosquash, сжатый в один шаг, который не может оставить вас в подвешенном состоянии.

Допустим, загрузчик конфигурации попал в историю двумя коммитами ранее без значения по умолчанию, а поверх наложена ветка с фичей:

$ git log --oneline --branches
c41f9e2 (feature/retries) add retry logic
7b2d8a0 (HEAD -> main) add http client
3e59c11 add config loader

$ git add src/config.js
$ git history fixup 3e59c11

$ git log --oneline --branches
f8a01d3 (feature/retries) add retry logic
92c6b7e (HEAD -> main) add http client
5d40e19 add config loader

Все три хеша изменились, и теперь main и feature/retries указывают в перезаписанную историю. Это работа значения по умолчанию --update-refs=branches: перемещается каждая локальная ветка, являющаяся потомком целевого коммита, а не только та, на которую вы переключены. Передайте --update-refs=head, чтобы переместить только HEAD. Это охватывает больше, чем git rebase --update-refs, который обновляет только ссылки, указывающие на коммиты внутри перебазируемого диапазона.

Прежде чем запускать это на стеке, который вам дорог, добавьте --dry-run. Ни одна ссылка не переместится. Вместо этого вы получите распечатанный список перемещений, которые были бы выполнены, оформленный так, что его можно позже передать git update-ref. Git всё же записывает необходимые ему новые объекты — именно поэтому последующее воспроизведение этого списка обычно проходит нормально. Это честный способ увидеть, какие именно ветки затронет перезапись, до того как вы на неё решитесь.

Обратите внимание на разницу в охвате по сравнению с amend: git commit --amend достаёт только до HEAD, тогда как fixup достаёт до любого коммита в вашей линейной истории. Это также единственная подкоманда, которой нужен индекс, поэтому она не может работать в bare-репозитории, в отличие от двух остальных.

reword: заменить сообщение старого коммита

git history reword <commit> меняет в целевом коммите одну вещь — его сообщение. Всё остальное в коммите переносится без изменений, каждый потомок воспроизводится поверх, а ссылки ветвей следуют за ними.

$ git history reword 7b2d8a0

Открывается редактор с уже подставленным текущим сообщением add http client. Сохраните более удачное — и стек пересобирается:

$ git log --oneline --branches
3ba90cf (feature/retries) add retry logic
a1d27e8 (HEAD -> main) add http client with timeout handling
5d40e19 add config loader

Поскольку reword не трогает ни индекс, ни рабочее дерево, она прекрасно работает в bare-репозитории. Вы также можете исправить сообщение в ветке, на которую не переключены, не нарушая то, чем заняты в данный момент.

split: превратить один коммит в два

git history split <commit> проводит вас ханк за ханком по диффу, который внёс этот коммит. Всё, что вы выберете, попадает в новый коммит, вставляемый под оригинальным в качестве его нового родителя. Оригинал сохраняет ханки, которые вы оставили. Выбрать все ханки или ни одного нельзя: в любом из этих случаев один из двух коммитов остался бы пустым.

Возьмём коммит, в котором смешались rate limiter и не связанный с ним код метрик:

$ git history split 91b04c7

Ответьте y на ханки метрик и n на ханки лимитера. Редактор запросит оба сообщения коммитов, авторство сохраняется от оригинала, и в результате получается аккуратная пара:

$ git log --oneline
e7d3f21 (HEAD -> main) add rate limiter
b19c8a4 add request metrics

Завершающий pathspec (git history split 91b04c7 -- src/metrics.js) сужает разделение до указанных вами файлов. Всё, что вне этого списка, остаётся на месте, в исходном коммите. Как и reword, split работает исключительно с графом коммитов и запускается в bare-репозитории.

Чего git history делать не будет?

Раздел LIMITATIONS в руководстве короткий, и его стоит воспринимать буквально. Коммиты слияния вне области применения: если история, которую вы хотите перезаписать, содержит хоть один, руководство направляет вас к git rebase --rebase-merges. Всё, что может закончиться конфликтом, отклоняется — ограничение, которое может быть ослаблено, если в Git когда-нибудь появятся полноценные (first-class) конфликты. Хуки на сегодня тоже не запускаются, и руководство оставляет возможность того, что это изменится.

У fixup есть ещё один граничный случай, обрабатываемый через --empty=(drop|keep|abort). К пустому коммиту здесь ведут два пути. Ваше проиндексированное исправление может полностью нейтрализовать целевой коммит, либо более поздний коммит уже может содержать то же изменение, которое вы только что опустили в предка. drop — значение по умолчанию — отбрасывает такие коммиты при пересборке истории; keep оставляет их на месте; abort останавливает команду с ошибкой, вместо того чтобы решать за вас. Удаление корневого коммита пока не поддерживается.

Когда стоит взяться за rebase вместо этого?

git history правит один коммит; git rebase остаётся инструментом для всего более широкого. Rebase — это ответ, когда целому диапазону коммитов нужна новая база, а интерактивный rebase — когда вы хотите проработать несколько коммитов за один раз. А если вам на самом деле нужен инструмент, который проносит конфликтные состояния через rebase для последующего разрешения, то это модель first-class конфликтов в jj, которую git history сознательно не пытается реализовать.

Подведём итоги

Для самой распространённой правки истории — исправления одного коммита, поверх которого наложены ветки, — git history заменяет рискованный интерактивный rebase операцией, которая либо полностью удаётся, либо отказывается выполняться. Возьмите реальный стек, запустите на нём git history fixup <commit> --dry-run и прочитайте, какие обновления ссылок были бы сделаны. Этот пятиминутный эксперимент подскажет, место ли этой команде в вашем повседневном рабочем процессе. Только не забывайте об экспериментальном статусе, пока принимаете решение, — флаги и поведение всё ещё могут меняться от релиза к релизу.

Часто задаваемые вопросы

В чём разница между git history fixup и git commit --fixup с autosquash?

git history fixup сворачивает проиндексированные изменения в целевой коммит одним атомарным шагом. git commit --fixup лишь записывает коммит fixup!, который затем должен быть сжат последующим git rebase --autosquash, а этот интерактивный rebase может остановиться на полпути из-за конфликтов. Кроме того, git history fixup по умолчанию перемещает все дочерние локальные ветки и прерывается, ничего не изменив, если перезапись привела бы к конфликту.

Обновляет ли git history удалённые ветки или коммиты, которые я уже отправил?

Нет. git history обновляет только локальные ветки; remote-tracking ссылки остаются нетронутыми. Поскольку перезаписанные коммиты получают новые хеши, для уже отправленных коммитов потребуется force-push (git push --force-with-lease) и согласование со всеми, кто забрал старую историю, — как и при любой перезаписи. Безопаснее всего применять её к коммитам, которые ещё не отправлены, или к ветвям, над которыми работаете только вы.

Как отменить перезапись, выполненную git history?

Воспользуйтесь reflog. git history перемещает ссылки ветвей на новые коммиты, но исходные коммиты остаются в репозитории, и в обычном (не bare) репозитории каждая перемещённая ветка фиксирует это обновление в своём reflog. Запустите git reflog show с именем ветки, чтобы найти хеш до перезаписи, а затем восстановите его через git reset --hard на переключённой ветке или через git update-ref в остальных случаях.

Может ли git history полностью удалить коммит?

В Git 2.55 — нет. Её три подкоманды (fixup, reword, split) изменяют или делят коммит, но не могут его удалить. Чтобы удалить коммит сегодня, используйте git rebase -i и отметьте коммит как 'drop' либо git rebase --onto, чтобы его пропустить. Подкоманда drop уже присутствует в находящейся в разработке документации Git по git history, так что штатная возможность может появиться в одном из будущих релизов.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.