jj — CLI поверх Git без staging и с откатом любой операции
Адаптированный перевод учебника Стива Клабника (co-author «The Rust Programming Language»): разбираем ключевые концепции jj — change ID, first-class конфликты, operation log и два workflow.
Если вас бесит 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, поэтому ниже в примерах показана именно она, чтобы совпадал вывод команд.
Альтернативы — Homebrew, пакеты для Linux, бинарники с страницы установки. После установки надо указать имя и почту:
Инициализация нового репозитория выглядит так:
Обратите внимание на jj git init, а не просто jj init. У jj есть собственный формат репозитория, но он пока экспериментальный. На практике все используют Git-бэкенд: изнутри это обычный .git/, который без проблем читает git log, IDE и CI-скрипты.
Рабочая копия — это коммит
Первое, что сбивает с толку git-пользователя: в jj нет «рабочей копии» и «индекса» как отдельных сущностей. Любое изменение файла сразу становится частью текущего коммита (помечается символом @).
Посмотрим статус свежего репо:
Даже пустой репозиторий уже содержит коммит yyrsmnoo. У него два идентификатора:
- Change ID (
yyrsmnoo) — стабильный идентификатор «идеи изменения». Это не hex, а короткая случайная строка из букв — jj специально выбирает алфавит так, чтобы ID читались и набирались проще, чем git-хеши. - Commit ID (
e7cfc43b) — конкретная реализация: набор файлов + описание.
Это центральная концепция. Когда вы редактируете файл или меняете описание — commit ID обновляется, а change ID остаётся прежним. Change ID — это как будто номер задачи в трекере: ссылка на идею, не на конкретный снимок.
Основной цикл: describe → work → new
В Git вы сначала пишете код, потом коммитите с сообщением. В jj — наоборот: сначала описываете, что собираетесь сделать, потом работаете.
Без флага -m откроется редактор. Описание можно менять в любой момент и сколько угодно раз — change ID не поменяется, но commit ID обновится. Это удобно: пишете начальное описание, уточняете по мере работы.
Когда изменение готово, создаём следующее поверх него:
Всё. Коммит сформирован, можно продолжать работать в новой пустой рабочей копии. Никаких git add, git commit и git status перед коммитом не нужно. jj сам отслеживает, какие файлы поменялись.
Иногда в jj всё ощущается так же, как в git, но наоборот. В git мы завершаем работу коммитом. В jj мы начинаем работу, создавая change, а потом правим код. Гораздо полезнее сначала написать, что ты собираешься сделать, и уточнять по ходу, чем придумывать commit message постфактум.
Squash vs Edit: два рабочих процесса
У jj-сообщества сложились два устойчивых паттерна. Squash — любимый у Мартина, создателя jj. Edit — у тех, кому squash не зашёл.
Squash workflow
Аналог Git-индекса: у нас есть «главный» коммит с описанием работы, а поверх — пустой change, в который сыплются все текущие правки. По готовности части работы переносим изменения в основной коммит командой jj squash.
jj new -m "feat: CSV parser"— создаём коммит с описанием работы.jj new— поверх него создаём пустой коммит, в котором идёт работа.- Редактируем файлы. Всё попадает в текущий change (
@). 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 этого достаточно.
В 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 идёт до конца и ничего не останавливает.
Что это даёт на практике:
- Rebase никогда не застревает. Пересобираете 20 коммитов поверх нового main — получаете 20 коммитов, из которых помечены конфликтами только те, где реально столкнулись правки.
- Конфликт можно отложить. Создали новую работу поверх конфликтного коммита — jj корректно протаскивает конфликт через все потомки.
- Автоматический rebase потомков. Когда вы фиксите конфликт в
@, все дочерние change автоматически перестраиваются — и, если их содержимое не пересекалось с конфликтным местом, они выходят из конфликтного состояния без ручного вмешательства.
Пример из учебника: есть конфликтный коммит, поверх него — ещё один change (тоже в конфликте из-за наследования). Исправляете файл в родителе — и jj пишет:
Потомок вышел из конфликта сам. В Git такое разруливают вручную или через git rerere (опциональная фича «Reuse Recorded Resolution», запоминающая решения конфликтов для повторного применения).
Operation log: почему в jj сложно потерять работу
Git хранит историю коммитов, но не историю операций. Если вы случайно сделали git reset --hard и коммит не попал в reflog (или reflog протух), он пропал. В jj иначе: каждая команда, меняющая состояние репо, пишется в operation log и хранится по умолчанию 30 дней.
Любую операцию откатывает 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
Стоит ли переходить прямо сейчас?
jj ещё до 1.0 (на 2026 год — версия 0.23), но Git-совместимость делает риск минимальным: откатиться обратно можно в любой момент. Крупные пользователи: Google (часть инфраструктуры), сам Мартин фон Цвайгберг (Martin von Zweigbergk) (автор jj, работает в Google), многие rust-разработчики. Для личных проектов и экспериментов — да. Для команды — сначала убедитесь, что с Git на сервере остальные продолжат работать без изменений (они продолжат).
Будут ли коллеги видеть какие-то артефакты jj в коммитах?
Нет. jj хранит свои метаданные (change ID, operation log) в .jj/, а в сам Git-репозиторий пишет обычные коммиты. Коллеги, использующие Git, не увидят никакой разницы — только ваши коммиты с обычными сообщениями.
Как быть с GitHub, Pull Requests, CI?
Работает ровно как с Git. Для push нужно создать именованную ветку через jj bookmark create и запушить её: jj git push. CI и PR-ревью ничего не замечают — это всё тот же Git-коммит.
Что с IDE-интеграцией?
Поскольку под капотом Git, подсветка изменений в VS Code, JetBrains, vim-fugitive и т. д. работает как обычно. Для jj-специфичных операций (change ID, op log) пока нужно использовать CLI или сторонние TUI. Стабильного плагина для больших IDE нет — это минус, если вы сильно завязаны на графический UI Git.
Чем 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 удаётся их избежать за счёт меньшего количества более мощных примитивов.
Оригинал — учебник Стива Клабника «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 и по учебнику.