Megamerges в Jujutsu: как работать с 5 ветками сразу через один octopus merge

Один octopus merge со всеми вашими ветками сразу, absorb вместо squash и автоматический restack на trunk. Собрали перевод гайда, который объясняет, почему megamerge-воркфлоу в Jujutsu экономит часы на переключении между задачами.

Обложка: Megamerges в Jujutsu: как работать с 5 ветками сразу через один octopus merge

Если вы каждый день переключаетесь между 3–5 ветками в Git — фичи, багфиксы, PR на ревью, чужие ветки, от которых зависит ваш код — и каждое переключение ломает темп разработки, у Jujutsu есть рабочий процесс, который убирает эту боль. Он называется megamerge. Айзек Корбри описывает его так: один большой octopus merge (merge-коммит с тремя и более родителями), в который вы включаете все рабочие ветки сразу. Вы всегда работаете поверх суммы всех ваших изменений.

Jujutsu (сокращённо jj) — система контроля версий, совместимая с Git по протоколу, но с другой моделью коммитов: конфликты — сущность первого класса, commit и rebase — базовые операции, immutable и mutable коммиты отделены. В посте — перевод статьи Корбри с HN (249 points): зачем нужны megamerges, как их собирать и как поддерживать в актуальном состоянии через алиасы jj.

Пост написан для пользователей Jujutsu среднего уровня и для гитовых пользователей, которым любопытен jj. Специальные команды приведены в оригинальном виде (их не локализуют), пояснения — в комментариях к коду. Ключевые термины jj: revset — язык запросов к графу коммитов (аналог Git-revisions, но расширенный); bookmark — аналог ветки в Git, но не двигается автоматически за рабочей копией; WIP — work in progress, незавершённые изменения.

Ключевые выводы
Megamerge за минуту
Что это и зачем
  • Megamerge — octopus merge (коммит с тремя и более родителями), куда вы включаете все рабочие ветки: фичи, багфиксы, PR, чужие ветки-зависимости, локальные инструменты. Вы работаете поверх него.
  • Выгода: всегда видите сумму всех изменений разом; почти не ловите сюрприз-конфликты на пуше; быстро переключаетесь между задачами (просто редактируете код); можно легко делать drive-by-фиксы.
  • Собрать просто: jj new x y z + jj commit -m megamerge. Megamerge в remote не пушится — пушатся только ветки, из которых он собран.
  • Сабмит WIP-изменений: jj absorb (автоматически разбрасывает строки по «родным» коммитам) и jj squash --to X --interactive (вручную выбирать куски).
  • Поддержка актуальности — алиас restack: ребейзит только ваши mutable-коммиты на trunk, оставляя в покое чужие ветки.

Merge-коммиты в jj: обычный коммит с несколькими родителями

Если вы обычный гит-пользователь (или пользователь Jujutsu, который ещё не дошёл до продвинутых процессов), возможно, вы удивитесь: в merge-коммите нет ничего особенного. Это не специальный случай с отдельными правилами — это обычный коммит, у которого несколько родителей. Он даже не обязан быть пустым.

Легенда вывода jj log: @ — текущая рабочая копия; — mutable-коммит; — immutable-коммит (обычно trunk); ├─╮ — граф связей.

			@ my zpxsys Isaac Corbrey 12 seconds ago 6 34e82e2
│ (empty) (no description set)
○ ml lmtkmv Isaac Corbrey 12 seconds ago git_head() 9 47a52fd
├─╮ (empty) Merge the things
│ ○ v qsqmtlu Isaac Corbrey 12 seconds ago f 41c796e
│ │  deps: Pin quantum manifold resolver
○ │ t qqymrkn Isaac Corbrey 19 seconds ago 04 26baba
├─╯  storage: Align transient cache manifolds
◆ z zzzzzzz root() 00 000000
		

Удивит и второе: merge-коммиты не ограничены двумя родителями. Мерджи с тремя и более родителями в сообществе неофициально называют octopus merge. И если вы думаете: «в каком мире мне может понадобиться слить больше двух веток?» — на практике эта идея очень мощная. Именно octopus merges дают весь megamerge-воркфлоу.

Так что же такое megamerge

Суть в том, что в megamerge-воркфлоу вы редко работаете прямо поверх тика одной ветки. Вместо этого вы создаёте octopus merge (его и называем megamerge) как дочерний коммит от каждой рабочей ветки, которая вам важна. Это могут быть багфиксы, фича-ветки, ветки, ждущие PR, чужие ветки, от которых зависит ваш код, локальные окружения и даже приватные коммиты, которые не принадлежат ни к одной ветке. Всё, что вам важно, входит в megamerge. Главное помнить: megamerge не пушится в remote — пушатся только ветки, из которых он собран.

			@ mn rxpywt Isaac Corbrey 25 seconds ago f 1eb374e
│ (empty) (no description set)
○ wu xuwlox Isaac Corbrey 25 seconds ago git_head() c 40c2d9c
├─┬─╮ (empty) megamerge
│ │ ○ tt nyuntn Isaac Corbrey 57 seconds ago 7d 656676
│ │ │  storage: Align transient cache manifolds
│ ○ │ p tpvnsnx Isaac Corbrey 25 seconds ago 8 97d21c7
│ │ │  parser: Deobfuscate fleem tokens
│ ○ │ zw pzvxmv Isaac Corbrey 37 seconds ago 1 4971267
│ │ │  infra: Refactor blob allocator
│ ○ │ tq xoxrwq Isaac Corbrey 57 seconds ago 9 0bf43e4
│ ├─╯  io: Unjam polarity valves
○ │ mo slkvzr Isaac Corbrey 50 seconds ago 75 3ef2e7
│ │  deps: Pin quantum manifold resolver
○ │ q upprxtz Isaac Corbrey 57 seconds ago 53 32c1fd
├─╯  ui: Defrobnicate layout heuristics
○ ww tmlyss Isaac Corbrey 57 seconds ago 58 04d1fd
│  test: Add hyperfrobnication suite
◆ zz zzzzzz root() 0 0000000
		

Если звучит как много — нормально. Вы же знаете, сколько сил уходит на смену контекста при возврате к старому PR. Но такой подход даёт несколько реально ценных вещей:

  • Вы всегда работаете поверх суммы всех своих изменений. Если ваша рабочая копия собирается и запускается — вся ваша работа гарантированно согласуется друг с другом.
  • Почти не встречаются сюрприз-конфликты. В Jujutsu конфликты и так first-class (их не нужно «решать прямо сейчас»), а в megamerge-воркфлоу вы постоянно сливаете свои изменения — поэтому на стороне удалённого хостинга (GitHub/GitLab) конфликтов не возникает. Иногда проблемы случаются с изменениями контрибьюторов, но на практике это редкость.
  • Намного меньше трения при переключении задач. Поскольку вы всегда работаете поверх megamerge, не нужно ходить в VCS — можно просто пойти править нужный код. Легко делать маленькие PR для попутных рефакторов и фиксов.
  • Проще держать ветки актуальными. Небольшой алиас — и весь megamerge обновляется относительно trunk одной командой rebase. Разберём ниже.

Как собрать megamerge

Старт — простой: новый коммит с каждой нужной веткой в качестве родителя. Автор любит давать этому коммиту имя и оставлять пустым:

			jj new x y z
jj commit --message "megamerge"
		

Получаете пустой коммит поверх всего. Вот тут и идёт работа. Всё, что выше megamerge, считается WIP. Можно сплитить, создавать несколько веток из megamerge — всё, что хотите. Всё, что вы напишете, будет опираться на сумму содержимого megamerge — как и задумывалось.

В какой-то момент вы будете довольны результатом — и встанет вопрос:

Как затем сабмитить изменения

Как дотащить изменения до megamerge — зависит от того, куда они должны попасть. Основной инструмент — команда absorb: она сама определяет, в какой mutable-коммит ниже по дереву должен пойти каждый хунк или каждая строка, и автоматически разбрасывает их по нужным коммитам. «Каждый раз выглядит как фокус — логика absorb прозрачна: команда смотрит, в каком коммите ниже по дереву впервые появилась каждая изменяемая строка, и отправляет её туда». Это одна из ключевых фич Jujutsu, благодаря которой megamerge-воркфлоу работает плавно.

			# Сквошнуть весь WIP-коммит (--from @ по умолчанию)
jj squash --to x --from y

# Интерактивно — выбрать часть WIP-коммита
jj squash --to x --from y --interactive
		

Но Jujutsu — красивый кусок софта, и у него есть автоматика. Команда absorb сделает бо́льшую часть работы за вас: определит, в какой mutable-коммит ниже по дереву должен пойти каждый ваш хунк или каждая строка, и автоматически их туда сквошит. «Каждый раз ощущается как магия — и не та чёрная магия, в которой ничего не разобрать, а хорошая, понятная». Это одна из ключевых фич Jujutsu, благодаря которой megamerge-воркфлоу вообще работает плавно.

			# Автоматический autosquash изменений (--from @ по умолчанию)
jj absorb --from x
		

Absorb не всегда ловит всё, но обычно укладывает около 90% изменений. Остальное — либо ручной squash --to (отправляет изменения в конкретный коммит), либо squash --to X --interactive (для выборочного переноса кусков). Если WIP-коммит содержит изменения для нескольких целей — сначала сплит командой split, либо тот же --interactive.

Если изменения должны попасть в новый коммит — не сильно сложнее. Если коммит относится к одной из ваших веток, просто ребейзим и двигаем bookmark:

			jj commit
jj rebase --revision x --after y --before megamerge
jj bookmark move --from y --to x
		

Разложим этот rebase, чтобы было понятнее:

			# Собираемся подвинуть коммиты!
jj rebase
  # Двигаем WIP-коммит(ы) x...
  --revision x
  # ...так, чтобы они шли после y (например, trunk())...
  --after y
  # ...и стали родителем megamerge.
  --before megamerge
		

А если вы начали работу над совершенно новой фичей или наткнулись на баг — ещё проще. С парой алиасов можно легко добавить новый материал в megamerge:

			[revset-aliases]
# Ближайший merge-коммит к `to`
"closest_merge(to)" = "heads(::to & merges())"

[aliases]
# Вставляет указанный revset как новую ветку под megamerge.
stack = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]
		

Короткое пояснение, что делает closest_merge(to):

			heads(        # Вернуть только топологически вершинный коммит из...
  ::to        # ...набора всех предков `to`...
  & merges()) # ...которые ещё и merge-коммиты.
		

С этим revset-алиасом stack берёт произвольный revset и вставляет его между trunk() (основной веткой разработки) и megamerge:

			jj stack x::y
		

Это полезнее, если несколько стеков изменений хочется включить параллельно. А если стек один, есть ещё один алиас, который подхватывает весь стек после megamerge:

			[aliases]
stage = ["stack", "closest_merge(@)+:: ~ empty()"]
		
			closest_merge(@)+::  # Все потомки ближайшего merge-коммита
                     # к рабочей копии...
~ empty()            # ...кроме пустых коммитов.
		

Этот алиас не требует аргументов. Достаточно накоммитить и ввести stage:

			jj stage
		

Последний кусок пазла — неприятная реальность: есть ещё другие люди.

Как держать всё это в актуальном состоянии

Хороший вопрос — автор потратил пару месяцев, чтобы ответить на него в общем виде. У Jujutsu есть простой способ ребейзить всю рабочую ветку на основную ветку:

			jj rebase --onto trunk()
		

Но это работает, только если всё ваше рабочее дерево (worktree) состоит только из ваших изменений. Когда в графе есть коммиты, которые вам не принадлежат (untracked bookmark или чужие ветки), Jujutsu остановится, чтобы защитить их от перезаписи.

Решение — ребейзить только те коммиты, которыми вы реально управляете. Спасибо Стивену Дженнингсу за отличный revset:

			[aliases]
restack = [
    "rebase",
    "--onto", "trunk()",
    "--source", "roots(trunk()..) & mutable()",
    "--simplify-parents",
]
		
			roots(         # Вернуть самые ранние (корневые) коммиты...
  trunk()..)   # ...среди всех потомков ::trunk()...
  & mutable()  # ...только те, что мы вправе модифицировать.
		

Вместо того чтобы пытаться ребейзить всё дерево (как делает jj rebase --onto trunk()), этот алиас трогает только коммиты, которые можно двигать. Чужие ветки и работа поверх них остаются на месте. Флаг --simplify-parents заодно убирает лишние рёбра, которые могут остаться после ребейза. У автора эта команда не ломалась ни разу — даже на мегамерджах с девятью родителями разных контрибьюторов.

TL;DR: рабочая конфигурация

Megamerges в Jujutsu — рабочий способ уложить несколько параллельных задач в одно рабочее дерево. Для полноценной эргономики добавьте в конфиг через jj config edit --user:

			[revset-aliases]
"closest_merge(to)" = "heads(::to & merges())"

[aliases]
# `jj stack <revset>` — включить конкретные ревизии
stack = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]

# `jj stage` — включить весь стек после megamerge
stage = ["stack", "closest_merge(@)+:: ~ empty()"]

# `jj restack` — ребейзнуть ваши изменения на trunk()
restack = [
    "rebase",
    "--onto", "trunk()",
    "--source", "roots(trunk()..) & mutable()",
    "--simplify-parents",
]
		

Краткая шпаргалка по командам:

			# Изменения, которые должны лечь в существующие коммиты
jj absorb
jj squash --to x --interactive

# Изменения, которые должны быть в новых коммитах
jj rebase --revision y --after x

# Засунуть всё, что поверх megamerge, внутрь него
jj stage

# Засунуть конкретные ревсеты внутрь megamerge
jj stack w::z
		

Ещё раз: megamerge не задуман для пуша в remote — это удобный способ увидеть всю картину целиком. Ветки всё равно публикуются отдельно, как обычно.

Megamerges подходят не всем — автор рассказывает, что получал испуганные взгляды, показывая своё рабочее дерево. Но, попробовав их один раз, вы с большой вероятностью обнаружите, что переключаетесь между задачами почти без усилий.

Дополнительно: замечания автора

  • Флаг --simplify-parents в restack важен — он вычищает избыточные рёбра (если есть A→B→C и A→C, simplify-parents удалит A→C).
  • stage использует closest_merge(@)+::, а не closest_merge(@).. — оператор x.. эквивалентен ~::x и включает всё, что не является предком x. Это может затянуть лишнее.
  • В Git merge-коммиты, куда вносят изменения поверх разрешения конфликтов, называют evil merge. В Jujutsu такие мерджи уже не «злые» — модель Jujutsu устроена более консистентно.
  • Алиасы Jujutsu. Есть несколько типов: revset-алиасы (кастомные функции, которые возвращают коммиты через revset language); command-алиасы (расширяют стандартные команды); template-алиасы (меняют формат вывода jj в терминале через templating language); fileset-алиасы (работают с файлами через fileset language).
  • Концепция mutable/immutable. Mutable-коммиты — те, что можно менять на регулярной основе. Это в основном lint (есть флаг --ignore-immutable), но он помогает не влипнуть. Алиасы mutable() и immutable() выбирают соответствующие коммиты.

FAQ

FAQ
1
Это заменяет Git?

Нет. Jujutsu — Git-совместимая VCS: использует те же remote-протоколы и репозитории, но с собственной моделью коммитов (conflicts как first-class, более простой rebase, workspaces). Рабочая копия может лежать поверх существующего Git-репо. Migration — опциональная.

2
Зачем octopus merge, если достаточно rebase на trunk?

Octopus merge объединяет несколько рабочих веток в один вершинный коммит, сохраняя родительские связи. Rebase «выпрямляет» историю и ломает связь с исходными ветками. Megamerge позволяет работать поверх суммы всех веток и при этом пушить их отдельно без конфликтов. Подробный разбор — в Git rebase vs merge.

3
Можно ли использовать это с GitHub/GitLab?

Да. Megamerge остаётся локальным — в remote уходят только отдельные ветки. GitHub/GitLab видят их как обычные PR/MR. Никакой дополнительной настройки форджа не требуется.

4
Что может сломать megamerge?

Любая операция с флагом --ignore-immutable на immutable-коммитах — это сознательный «рискованный» путь. Без него — absorb безопасен (работает только с mutable-коммитами), squash --to тоже. stage и stack — алиасы поверх rebase: если megamerge включает immutable-коммиты чужих веток, rebase остановится, чтобы их защитить. На практике это и есть «защита от дурака».

5
Jujutsu доступен в РФ?

Да. Это open-source инструмент (лицензия Apache 2.0), установка через cargo, homebrew, пакетные менеджеры Linux. Не требует облачных сервисов. Документация и репозиторий — на GitHub.

Выводы

Megamerge-воркфлоу убирает одну конкретную боль: необходимость помнить, какие ветки с чем конфликтуют, и постоянно переключать контекст. Базовая схема: octopus merge как «рабочая площадка», absorb и squash для отправки изменений в нужные коммиты, restack для поддержания актуальности. Ничего магического — обычные коммиты и revsets, собранные в удобный процесс. Кто работает в воркфлоу Trunk-Based Development, увидит знакомые паттерны — megamerge хорошо с ними сочетается.

Оригинал статьи: isaaccorbrey.com/notes/jujutsu-megamerges-for-fun-and-profit. Обсуждение на Hacker News: news.ycombinator.com/item?id=47841129. Официальная документация Jujutsu: jj-vcs.github.io/jj.