git history: встроенная альтернатива jj уже в Git

Работать в git с большим числом параллельных изменений бывает мучительно. Приходится жонглировать ветками и коммитами, запускать страшные команды rebase -i, после которых дерево рискует оказаться в полусломанном состоянии — стоит лишь чихнуть.

jj — альтернатива git — в последнее время активно обсуждается (1, 2, 3, 4) и нередко преподносится как решение. Я полностью согласен с проблемами, которые jj пытается решить, но сам подход к их решению мне так и не зашёл. Последние полтора года каждые три месяца я пробую jj несколько дней, искренне стараясь встроить его в рабочий процесс, — и неизменно сдаюсь и возвращаюсь к git.

Именно здесь в игру вступает git history. Это экспериментальная команда, появившаяся в двух релизах: 2.54 (апрель, подкоманды reword и split) и 2.55 (июнь, подкоманда fixup). В день каждого релиза она вызывала волну обсуждений, а затем, насколько я могу судить, почти исчезала из поля зрения сообщества. Жаль — на мой взгляд, команда уже сейчас даёт многое из того, за что люди хвалят jj, не требуя полностью переходить на другой инструмент. И что приятно: она входит в стандартный дистрибутив git, так что попробовать её можно без каких-либо дополнительных установок.

У команды три подкоманды: fixup, reword и split.

fixup

git history fixup исправляет старый коммит и автоматически переосновывает (rebase) все ваши ветки с учётом этого изменения.

Исправление индексируется как обычно — через git add, — а затем запускается git history fixup <коммит>, чтобы вложить проиндексированные изменения в целевой коммит. По сути это git commit --fixup плюс autosquash-rebase, но с дополнительной магией: команда также обновляет все остальные ветки, которые содержат этот коммит.

Последнее отличает её от git rebase --update-refs, который перемещает только те ссылки (refs), что находятся внутри активно перебазируемого диапазона. git history, напротив, находит и переписывает все локальные ветки, потомки данного коммита (с возможностью ограничиться только текущей веткой). С другой стороны, команда не работает при наличии merge-коммитов — для некоторых сценариев использования git это может оказаться принципиальным ограничением.

Как это выглядит на практике.

До — исправление проиндексировано для коммита B:

После git history fixup B:

B* — это B со встроенным исправлением. Переписанный коммит получает новый хэш, поэтому C и D автоматически пересоздаются поверх него как C* и D*, а указатели веток feat-1 и feat-2 следуют за ними.

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

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

«Это ограничение намеренно: операции переписывания истории не предполагаются состоятельными (stateful). Ограничение может быть снято, если (и когда) Git освоит конфликты первого класса.»

Иными словами, в будущем это может измениться — и я с интересом жду, что будет дальше!

reword

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

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

Поскольку reword меняет только сообщение, эта подкоманда (как и split, о которой ниже) вообще не трогает индекс и рабочее дерево; она работает исключительно с графом коммитов. Это позволяет переписать коммит в ветке, которая у вас не выгружена (not checked out), не нарушая текущую работу.

До:

После git history reword B:

Меняется только сообщение B, но это всё равно даёт ему новый хэш, поэтому C пересобирается поверх как C*, а feat-1 следует за ним.

split

git history split берёт один коммит и разбивает его на два, позволяя интерактивно выбрать, что войдёт в каждый. Это аналог git add -p, но без акробатики с git rebase. Из трёх подкоманд эта — самая специализированная, однако когда она нужна, она незаменима.

Конкретно: git history split <коммит> переводит вас в режим пошагового просмотра хранков (hunks) диффа этого коммита. Хранки, которые вы оставляете, формируют первый коммит; оставшиеся попадают во второй.

До — B объединяет два несвязанных изменения:

После git history split B:

B становится B1 и B2, а C пересобирается поверх пары как C*.

Заключение

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

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

© 2026 meganuke