jj — CLI поверх Git без staging и с откатом любой операции

Адаптированный перевод учебника Стива Клабника (co-author «The Rust Programming Language»): разбираем ключевые концепции jj — change ID, first-class конфликты, operation log и два workflow.

Обложка: jj — CLI поверх Git без staging и с откатом любой операции

Если вас бесит git reset --hard, staging area и rebase-конфликты, которые приходится решать трижды — посмотрите на jj (CLI для Jujutsu). Он работает поверх вашего Git-репозитория, не требует миграции и предлагает другую модель: рабочая копия — это коммит, у конфликтов нет маркеров в файлах, а любую операцию можно отменить одной командой.

Это адаптированный перевод учебника Стива Клабника (автор «The Rust Programming Language») про jj — с объяснением ключевых концепций и тем, чем они отличаются от Git. Автор сам пришёл к jj, попробовав его после статьи Криса Крайко, и признаётся: это редкий случай, когда инструмент одновременно проще и мощнее старого.

КЛЮЧЕВЫЕ ВЫВОДЫ
Почему jj интересен

Git-совместимый бэкенд. jj использует Git-репозиторий как хранилище, работает с вашими .git/ и удалёнными репо. Коллегам ничего менять не надо.

Рабочая копия — это коммит. Нет отдельного staging area. Любое изменение файла сразу формирует новую версию коммита с тем же change ID.

Change ID ≠ commit ID. Change — это «идея изменения», commit — её конкретное воплощение. Описание и содержимое можно менять, change ID остаётся стабильной ссылкой.

Anonymous branches. Ветки не обязаны иметь имена: граф коммитов сам по себе задаёт структуру. Имена нужны только для push на GitHub.

First-class conflicts. Конфликты хранятся в истории как часть коммита, а не как маркеры в файле. Rebase не останавливается на конфликте — продолжается, и потомки автоматически перестраиваются, когда вы фиксите исходник.

Operation log и jj undo. Любая операция — в том числе rebase и abandon — откатывается одной командой. Потерять работу в jj сложнее, чем в Git.

Установка и первый репозиторий

jj написан на Rust. Самый простой способ — через cargo. На апрель 2026 актуальная версия — 0.40.x; учебник Клабника написан по 0.23.0, поэтому ниже в примерах показана именно она, чтобы совпадал вывод команд.

			$ cargo install jj-cli@0.23.0 --locked   # версия из учебника
$ cargo install jj-cli --locked            # или последняя стабильная
		

Альтернативы — Homebrew, пакеты для Linux, бинарники с страницы установки. После установки надо указать имя и почту:

			$ jj config set --user user.name "Имя Фамилия"
$ jj config set --user user.email "email@example.com"
		

Инициализация нового репозитория выглядит так:

			$ jj git init
Initialized repo in "."
		

Обратите внимание на jj git init, а не просто jj init. У jj есть собственный формат репозитория, но он пока экспериментальный. На практике все используют Git-бэкенд: изнутри это обычный .git/, который без проблем читает git log, IDE и CI-скрипты.

Рабочая копия — это коммит

Первое, что сбивает с толку git-пользователя: в jj нет «рабочей копии» и «индекса» как отдельных сущностей. Любое изменение файла сразу становится частью текущего коммита (помечается символом @).

Посмотрим статус свежего репо:

			$ jj st
The working copy is clean
Working copy : yyrsmnoo e7cfc43b (empty) (no description set)
Parent commit: zzzzzzzz 00000000 (empty) (no description set)
		

Даже пустой репозиторий уже содержит коммит yyrsmnoo. У него два идентификатора:

  • Change ID (yyrsmnoo) — стабильный идентификатор «идеи изменения». Это не hex, а короткая случайная строка из букв — jj специально выбирает алфавит так, чтобы ID читались и набирались проще, чем git-хеши.
  • Commit ID (e7cfc43b) — конкретная реализация: набор файлов + описание.

Это центральная концепция. Когда вы редактируете файл или меняете описание — commit ID обновляется, а change ID остаётся прежним. Change ID — это как будто номер задачи в трекере: ссылка на идею, не на конкретный снимок.

Основной цикл: describe → work → new

В Git вы сначала пишете код, потом коммитите с сообщением. В jj — наоборот: сначала описываете, что собираетесь сделать, потом работаете.

			$ jj describe -m "починить парсинг CSV"
Working copy now at: yyrsmnoo 524d2bf4 починить парсинг CSV
Parent commit      : zzzzzzzz 00000000 (empty)
		

Без флага -m откроется редактор. Описание можно менять в любой момент и сколько угодно раз — change ID не поменяется, но commit ID обновится. Это удобно: пишете начальное описание, уточняете по мере работы.

Когда изменение готово, создаём следующее поверх него:

			$ jj new
Working copy now at: puomrwxl 01a35aad (empty) (no description set)
Parent commit      : yyrsmnoo ac691d85 починить парсинг CSV
		

Всё. Коммит сформирован, можно продолжать работать в новой пустой рабочей копии. Никаких git add, git commit и git status перед коммитом не нужно. jj сам отслеживает, какие файлы поменялись.

Иногда в jj всё ощущается так же, как в git, но наоборот. В git мы завершаем работу коммитом. В jj мы начинаем работу, создавая change, а потом правим код. Гораздо полезнее сначала написать, что ты собираешься сделать, и уточнять по ходу, чем придумывать commit message постфактум.
Стив Клабникавтор «The Rust Programming Language»

Squash vs Edit: два рабочих процесса

У jj-сообщества сложились два устойчивых паттерна. Squash — любимый у Мартина, создателя jj. Edit — у тех, кому squash не зашёл.

Squash workflow

Аналог Git-индекса: у нас есть «главный» коммит с описанием работы, а поверх — пустой change, в который сыплются все текущие правки. По готовности части работы переносим изменения в основной коммит командой jj squash.

  1. jj new -m "feat: CSV parser" — создаём коммит с описанием работы.
  2. jj new — поверх него создаём пустой коммит, в котором идёт работа.
  3. Редактируем файлы. Всё попадает в текущий change (@).
  4. jj squash -i — интерактивно выбираем, какие куски из @ переехать в родительский change. Это как git add -p, только работает и задним числом.

Edit workflow

Противоположный подход: редактируем существующие коммиты напрямую. Создаём цепочку пустых коммитов с описаниями будущих кусочков работы, а потом командой jj edit <change-id> переключаемся между ними и дописываем содержимое. jj автоматически перестраивает потомков при каждом изменении.

Это встроенный interactive rebase без отдельной «фазы rebase»: вы просто редактируете средние коммиты истории, как если бы они были текущими. Ни тодо-файлов, ни «continue/abort» — всё делается обычными командами.

Анонимные ветки и revsets

Одно из самых непривычных для git-пользователя решений: ветки в jj не обязаны иметь имена. Вы можете начать эксперимент с новой идеей, не придумывая ничего вроде feature/experimental-cache-v2: если у двух коммитов один родитель — это уже ветвление, и для jj этого достаточно.

			              ┌───┐ ┌───┐
         ┌───┤ F ◄─┤ G │
         │   └───┘ └───┘
         │
 ┌───┐ ┌─▼─┐ ┌───┐ ┌───┐
 │ B ◄─┤ C ◄─┤ D ◄─┤ E │
 └───┘ └───┘ └───┘ └───┘
		

В Git всё, что не находится на именованной ветке, считается мусором и рано или поздно удаляется сборщиком («detached HEAD»). В jj мусора в этом смысле не бывает — все изменения равноправны, пока вы явно не сделаете jj abandon.

Имена в jj нужны только для push в Git-репозитории (GitHub, GitLab, etc.). Внутри Meta, где используют похожий VCS, по словам Стива, «почти никто не заморачивается именами веток, когда привыкает».

Revsets: язык запросов к истории

Чтобы ссылаться на конкретные коммиты без имён, в jj есть revsets — DSL для запросов к графу истории. Примеры:

  • @ — текущий change.
  • @- — родитель текущего change.
  • @+ — все прямые потомки.
  • trunk()..@ — всё, что было после ветвления от основной ветки (удобно смотреть свою работу).
  • description(bug) — все коммиты, где в описании есть слово «bug».
  • author("steve") — все коммиты Стива.
  • heads(all()) — все «концы» ветвлений в репозитории.

Revsets комбинируются операторами: & (пересечение), | (объединение), ~ (отрицание). Практический сценарий: нужно найти, где сломался тест, — пишете jj log -r "description(bug) & author(me)", получаете список своих баг-фиксов. В Git для такого каждый пишет свой скрипт поверх git log --grep --author.

First-class конфликты: как jj хранит их в истории

Это нетривиальное архитектурное решение jj. В Git, если rebase упал на конфликт, процесс останавливается — вы фиксите файл, потом git rebase --continue, и так до конца цепочки. Если конфликт в середине, приходится разруливать его отдельно.

В jj конфликт — это нормальное состояние коммита. Он сохраняется в истории, не мешает дальнейшей работе, rebase идёт до конца и ничего не останавливает.

Что это даёт на практике:

  1. Rebase никогда не застревает. Пересобираете 20 коммитов поверх нового main — получаете 20 коммитов, из которых помечены конфликтами только те, где реально столкнулись правки.
  2. Конфликт можно отложить. Создали новую работу поверх конфликтного коммита — jj корректно протаскивает конфликт через все потомки.
  3. Автоматический rebase потомков. Когда вы фиксите конфликт в @, все дочерние change автоматически перестраиваются — и, если их содержимое не пересекалось с конфликтным местом, они выходят из конфликтного состояния без ручного вмешательства.

Пример из учебника: есть конфликтный коммит, поверх него — ещё один change (тоже в конфликте из-за наследования). Исправляете файл в родителе — и jj пишет:

			Rebased 1 descendant commits onto updated working copy
		

Потомок вышел из конфликта сам. В Git такое разруливают вручную или через git rerere (опциональная фича «Reuse Recorded Resolution», запоминающая решения конфликтов для повторного применения).

Operation log: почему в jj сложно потерять работу

Git хранит историю коммитов, но не историю операций. Если вы случайно сделали git reset --hard и коммит не попал в reflog (или reflog протух), он пропал. В jj иначе: каждая команда, меняющая состояние репо, пишется в operation log и хранится по умолчанию 30 дней.

			$ jj op log
@ a1b2c3d4 steve@... 2 minutes ago
│ rebase
◉ 9e8f7a6b steve@... 5 minutes ago
│ describe
◉ 6d5c4b3a steve@... 10 minutes ago
│ new
		

Любую операцию откатывает jj undo. В отличие от git reflog, который показывает только движения HEAD, jj лог — это полный журнал изменений репозитория, и любая точка в нём — валидное состояние, к которому можно откатиться.

Случайно сделали jj abandon на важном change? jj undo. Случайно перебазировали ветку не туда? jj undo. Хотите посмотреть, как репо выглядел час назад? jj op restore <op-id>.

Что это меняет на практике

Stacked diffs без боли

Stacked diffs — это практика, когда большую фичу разбивают на цепочку мелких PR, каждый поверх предыдущего. В Git это требует сторонних инструментов (graphite, git-branchless) или ручного жонглирования с --onto. В jj — естественный режим: каждый change в цепочке — самостоятельный коммит, при изменении родителя все потомки перестраиваются автоматически. Для push на GitHub каждому коммиту цепочки достаётся имя через jj bookmark create.

Interactive rebase — просто редактирование

В Git git rebase -i — отдельный режим с тодо-файлом, в котором pick/squash/drop/reword. В jj это обычные команды: jj squash, jj abandon, jj describe <change>. Не нужен отдельный «режим rebase» — вы просто редактируете историю как данные.

Staging area уходит — и не жалко

Squash workflow воспроизводит поведение Git-индекса (отбор кусков правок), но делает это задним числом. Не нужно заранее решать, что попадёт в коммит — можно сначала написать код, потом через jj squash -i распределить куски по нужным change.

FAQ
1
Стоит ли переходить прямо сейчас?

jj ещё до 1.0 (на 2026 год — версия 0.23), но Git-совместимость делает риск минимальным: откатиться обратно можно в любой момент. Крупные пользователи: Google (часть инфраструктуры), сам Мартин фон Цвайгберг (Martin von Zweigbergk) (автор jj, работает в Google), многие rust-разработчики. Для личных проектов и экспериментов — да. Для команды — сначала убедитесь, что с Git на сервере остальные продолжат работать без изменений (они продолжат).

2
Будут ли коллеги видеть какие-то артефакты jj в коммитах?

Нет. jj хранит свои метаданные (change ID, operation log) в .jj/, а в сам Git-репозиторий пишет обычные коммиты. Коллеги, использующие Git, не увидят никакой разницы — только ваши коммиты с обычными сообщениями.

3
Как быть с GitHub, Pull Requests, CI?

Работает ровно как с Git. Для push нужно создать именованную ветку через jj bookmark create и запушить её: jj git push. CI и PR-ревью ничего не замечают — это всё тот же Git-коммит.

4
Что с IDE-интеграцией?

Поскольку под капотом Git, подсветка изменений в VS Code, JetBrains, vim-fugitive и т. д. работает как обычно. Для jj-специфичных операций (change ID, op log) пока нужно использовать CLI или сторонние TUI. Стабильного плагина для больших IDE нет — это минус, если вы сильно завязаны на графический UI Git.

5
Чем jj отличается от Sapling (ex-Mercurial)?

Sapling от Meta — тоже система с анонимными ветками, унаследованная от Mercurial, с поддержкой stacked diffs. Главное отличие jj: более агрессивное переосмысление CLI (рабочая копия = коммит), статус полноценного pet-проекта Google и активное Rust-сообщество. Sapling заточен под монорепы Meta, jj — под любые размеры.

Выводы

jj — редкий случай, когда инструмент можно попробовать без риска: Git-бэкенд оставляет вам путь назад в любой момент, а новые концепции (change ID, first-class конфликты, op log) не требуют от команды ничего менять. Вы буквально можете работать с репозиторием через jj, пока коллега рядом пишет git commit.

Главная архитектурная ставка jj — перенести «операции с историей» из особых режимов (rebase, cherry-pick, reflog) в обычное редактирование графа коммитов. Если вы когда-либо путались в git rebase -i или теряли работу в git reset --hard — jj решает обе проблемы на уровне модели.

Это и проще, и легче git — и при этом мощнее. Обычно в программировании нас учат, что есть компромиссы между этими свойствами. jj удаётся их избежать за счёт меньшего количества более мощных примитивов.
Стив Клабникco-author «The Rust Programming Language»

Оригинал — учебник Стива Клабника «Steve's Jujutsu Tutorial». В полной версии — ещё главы про named branches, работу с GitHub, кастомизацию templating-движка и продвинутые workflow (workspaces, colocated repositories). Документация проекта — jj-vcs.github.io/jj.

Если хочется попробовать — возьмите любой свой Git-репозиторий и выполните jj git init --colocate. Флаг --colocate создаёт jj-репозиторий рядом с существующим .git/, не трогая историю: можно параллельно работать обоими CLI. Дальше — jj log и по учебнику.