🆕

【翻訳】 jj init (バージョン管理システム Jujutsu の紹介)

2024/02/15に公開

まえがき

この記事は、Chris Krycho 氏による Jujutsu の 解説記事 “jj init” の翻訳です。公式ドキュメントを除き、現時点で存在するほぼ唯一の Jujutsu に関する詳細な解説だと思います。

実例は全部 macOS のものですが、コンセプトや、比較や、実際の使い勝手がわかって、とても勉強になりました(小並感)。


jj init

副題: Git に代わるものがあったら? Jujutsu がそれに一番近いかもしれない。

Jujutsu という新たなバージョン管理システムを Google で働くソフトウェアエンジニアが作っていて、Google の既存のバージョン管理システム(Perforce や Piper や Mercurial があった)を置き換えることを目指している。このソフトは設計思想の面でも、実装の落とし込みや UI の練られ具合の面でも興味深いもので、私がひと昔ほど前に考え始めたある疑問に一つの解を与えてくれる。「Mercurial, Git, Darcs, Fossil といった現行世代のバージョン管理システムのいいとこ取りをした次世代のシステムが現れたとしたら、どんなものになるだろうか?」

この疑問に答えるには、まず教訓が何なのかを認識しなければいけない。これは言うほど一筋縄では行かない。Git は現世代における断トツの「ブランド」であって、多くのソフトウェアエンジニアが Git を使うのは、なにも他の選択肢と比較検討したうえではなく、単にデファクトスタンダードだからだ。そしてそれは GitHub なる「キラーアプリ」の存在と無関係であるはずがない。10 年以上業界にいる人なら、他のツールを触ったこともおありだろうが……世の数多くの開発者にとって Git は最初の、そして今のところ、最後のバージョン管理システム (VCS) なのだ。

しかしながら、Git の問題点は少なくない。まず何といっても、その劣悪で知られるコマンドラインインターフェイスがもたらす最悪のユーザー体験だ。私の観測範囲には、Git のまともなメンタルモデルを構築できている開発者などめったにいない。みんな、知っているのは長年付け焼き刃で覚えたなけなしのコマンドに毛が生えた程度だ。こんなことを言うと、「開発者なら Git の内部動作を理解しろ。そうすれば全部分かるようになる」と返されがちだ。

そんなバカな。Git の内部動作は確かに実装レベルでは「面白い」が、ユーザーのメンタルモデルにとっては、正直言って混乱の上塗りである。こういう考え方は開発者にありがちな間違いで、私も何度となくはまった罠だから、別に Git 開発者たちを責めようとは思わない。だが、システムを使いこなすために内部動作を理解する必要があるわけがない。そうだとしたら単に設計の失敗だ。それに、その内部動作ですら大してつじつまが合っていない。インデックス (index) やら、用語集に出てくる「-ish」(擬似~)概念の数やら、「detached HEAD」とブランチ (branch) の相互作用の仕方やら、タグ (tag) とブランチの区別やら、コミット (commit)、参照 (ref)、オブジェクト (object) を区別する重要性やら……。どれも単体では何もおかしくはないのだが、全部ごった煮にされると、悪口の一つも言いたくなるようなメンタルモデルが爆誕する。プログラミング言語の用語で言うなら、Git の「表層構文」が難しい一因は、意味論にまぎれこんだ混乱が、ユーザーとの接点に避けがたく現れているからだ。

それでも、ソフトウェア開発のエコシステムに深く根を張ったシステムを取り替えるには、莫大なコストがかかる。それに見合う価値が本当にあるのだろうか?実は、Jujutsu は魔法を使って、導入コストを無にできるのだ。インストール(macOS なら brew install jj で一発)する。今ある Git リポジトリ内でコマンドを 1 行走らせる。終わり。(「ステップ 3 は存在しない」) このやり方はずっと通用するはずだが、いずれは移行作業がもうワンステップ必要になるだろう。Git によらない Jujutsu 自身のバックエンドが実用化され、ゆくゆくは標準になったあかつきにはだが。おっと、先走りすぎてしまった。まずは、Jujutsu が何であり、何でないか知ることから始めよう。

Jujutsu には 2 つの顔がある。

  1. Git の新しいフロントエンド。これはもう片方と比べると圧倒的につまらないが、実質、現時点での使い途の大部分がこれだろう。この意味では、gitoxide などと同じ地位を占めている。だが、Jujutsu の jj は gitoxide の gixein よりもずっと手になじみやすいし、目指すところは全く別物だ。それは……

  2. 分散型バージョン管理の斬新な設計。はるかに魅力的なのはこちらだ。具体的には、Jujutsu は次のようなキーコンセプトを持ち込んでいる。どれも単独では目新しくないが、組み合わせると実務では非常にうれしいものとなる。

    • 「変更」(change) と「リビジョン」(revision) の区別: Mercurial から借りてきたアイディアで、Git のモデルとは大きく異なる。
    • 第一級アイテムとしての競合 (conflict): PijulDarcs から借りてきたアイディアだ。
    • 合理的なだけでなく使いやすい UI: Git……以外、文字通りすべての VCS から借りてきたアイディアだろう。

以上の合わせ技によって、今日からでも既存の Git リポジトリで Jujutsu を使い始めることができる。私はもう半年使っているが、使い心地は抜群だ。(Git よりもいい。)そのうえ、Google が現行のカスタム VCS 群を置き換えるべく精力的に開発しているものなので、将来も明るい。要するに、最低でも Git 以上の使い勝手が得られ、うまくすれば VCS の未来(であってほしいと私が願うもの)に通じる超絶ラクなエスカレーターを上ることができるということだ。

Jujutsu は、なにも他の Git ライクな分散型 VCS (DVCS) の面白い機能を全部取り込もうとしているわけではない。例えば、Pijul のような順序に関係なく適用できるパッチ理論 (theory of patches) には則っていない。しかし、上にも書いたし下でも詳しく述べるが、jj には「変更」と「リビジョン」の区別もあれば、競合の第一級サポートもあるので、Pijul を使ってうれしいことの多くは達成できる。また、Fossil のように万能ツールを目指しているわけでもない。つまり、GitHub のような「開発プラットフォーム」(forge) は付属していない。バグトラッカーもない。サポートチャットやフォーラムやウィキ機能もない。今のところは、VCS としての基本機能の開発に注力している。

もう一つ、Jujutsu はまだ、Git に依存しない一人前の VCS ではない。将来の開発予定地として「ネイティブ」バックエンドはサポートしており、テストケースも Git と「ネイティブ」バックエンドの両方をカバーしているが、そちらはまだ実用には程遠い。とはいえ、今後にはぜひとも期待したい。

Jujutsu に接して非常に面白かったことの 1 つは、自分がいかに Git 脳に毒されていたか気づき、そして VCS の別の可能性について再認識したことだ。Git の UI 設計がいかにダメか(私はそれはもう深く)確信するかたわら、VCS の UI がどれほど良いものになりうるのかを体験できる(しかも基底のモデルを変更せずに!)。

Yoda saying “You must unlearn what you have learned.”
(訳: これまで学んだことを捨てなさい)

さあ、ジェダイの騎士になる時だ。いや、Jujutsu の騎士?呪術師?呪術高専生からか?ともかく始めよう。

Jujutsu を使う

これまでの話は理屈としてはたいそう面白いが、ツールとして、それもうまく行けば開発者の人気ツールの仲間入りをするかもしれない存在にとって、より大事な質問とは、「使い勝手はどうか」だ。

インストールは造作もない。brew install jj 一発で終わった。現代的な Rust ベースのコマンドラインインターフェイス (CLI) ツールの常として、[1] Jujutsu は便利な補完機能を標準搭載している。唯一、既存の Git プロジェクトに適用するために行った後処理は、~/.gitignore_global をいじってディスク上の全 .jj をディレクトリを無視させたことだ。[2]

既存の Git プロジェクトで Jujutsu を使うのもとても簡単だ。[3] jj git init --git-repo <リポジトリのパス> [4]と打てば終わりだ。そうすればリポジトリ内で gitjj も同じように使え、.gitignore ファイルの処理に至るまで、何もかもよしなにやってくれる。それから私は開発で使っているすべての Git リポジトリに jj git init をかけたが、これまで何ヶ月も問題は起きていない。また、既存の Git リポジトリがない状態から Git プロジェクトのコピーを Jujutsu で作成することもできる。私がやったように jj git clone を使えば問題ない。

asciicast-635735
(動画: true-myth をクローンして Jujutsu リポジトリとして初期化する

プロジェクトの初期化が終わってしまえば、あとは普通に使うことができる。とはいっても、Git に慣れ切ってしまった方にとってはそれなりに頭の切り替えが必要だ。

リビジョンと revset

Jujutsu を使い始めるにあたって最初に理解すべきことの 1 つが、「リビジョン」と「revset」(つまり、リビジョンのセット)に対する扱いだ。Jujutsu では変更の基礎となる単位はリビジョンであり、Git の「コミット」(commit) ではない。[5]そして revset とはリビジョンの集合を抽出する関数型言語の式だ。アイディアと用語は Mercurial から直接借用しているが、その中身は全く新しいものだ。(Jujutsu はたくさんの概念を Mercurial から借りているが、それは大正解だったと思う。)Jujutsu コマンドの圧倒的多数は --revision/-r をつけてリビジョンを絞り込める。これだけでは、Git のコミットやコミット範囲 (commit range) と何が違うのかよく分からないだろう。実際、表面上はかなりよく似ている。だが、リビジョンは扱い方の点でも、どんな「変更」を表しているかの点でも、Git のコミットとの違いはすぐにあらわになる。

リビジョンや revset の何が違うのか(そして素晴らしいのか)を最初に実感できる場所はきっと log コマンドだろう。なぜならコミットログは新しい VCS を使い出してすぐに見るものだからだ。(少なくとも私はそうだった。)リポジトリをクローンして Jujutsu で初期化し、jj log を走らせると、git log とは全然違う見た目のものが出力される。違いすぎて、git log に何を渡せば対応がつくのか見当もつかないくらいだ。例えば、私が今日 Jujutsu のリポジトリで jj log を走らせて、最新 10 件のリビジョンを取得した結果がこれだ。

> jj log --limit 10
@  ukvtttmt hello@chriskrycho.com 2024-02-03 09:37:24.000 -07:00 1a0b8773
│  (empty) (no description set)
◉  qppsqonm essiene@google.com 2024-02-03 15:06:09.000 +00:00 main* HEAD@git bcdb9beb
·  cli: Move git_init() from init.rs to git.rs
· ◉  rzwovrll ilyagr@users.noreply.github.com 2024-02-01 14:25:17.000 -08:00
┌─┘  ig/contributing@origin 01e0739d
│    Update contributing.md
◉  nxskksop 49699333+dependabot[bot]@users.noreply.github.com 2024-02-01 08:56:08.000
·  -08:00 fb6c834f
·  cargo: bump the cargo-dependencies group with 3 updates
· ◉  tlsouwqs jonathantanmy@google.com 2024-02-02 21:26:23.000 -08:00
· │  jt/missingop@origin missingop@origin 347817c6
· │  workspace: recover from missing operation
· ◉  zpkmktoy jonathantanmy@google.com 2024-02-02 21:16:32.000 -08:00 2d0a444e
· │  workspace: inline is_stale()
· ◉  qkxullnx jonathantanmy@google.com 2024-02-02 20:58:21.000 -08:00 7abf1689
┌─┘  workspace: refactor for_stale_working_copy
◉  yyqlyqtq yuya@tcha.org 2024-01-31 09:40:52.000 +09:00 976b8012
·  index: on reinit(), delete all segment files to save disk space
· ◉  oqnvqzzq martinvonz@google.com 2024-01-23 10:34:16.000 -08:00
┌─┘  push-oznkpsskqyyw@origin 54bd70ad
│    working_copy: make reset() take a commit instead of a tree
◉  rrxuwsqp stephen.g.jennings@gmail.com 2024-01-23 08:59:43.000 -08:00 57d5abab
·  cli: display which file's conflicts are being resolved

次にこれが、Git の同じ基本コマンドの出力だ。ただし、内容を似せようとはせず、あくまでデフォルトの結果を表示させただけだ。(長文ログ注意!)

> git log -10
commit: bcdb9beb6ce5ba625ae73d4839e4574db3d9e559     HEAD -> main, origin/main
date:   Mon, 15 Jan 2024 22:31:33 +0000
author: Essien Ita Essien <essiene@gmail.com>

    cli: Move git_init() from init.rs to git.rs

    * Move git_init() to cli/src/commands/git.rs and call it from there.
    * Move print_trackable_remote_branches into cli_util since it's not git specific,
      but would apply to any backend that supports remote branches.
    * A no-op change. A follow up PR will make use of this.

commit: 31e4061bab6cfc835e8ac65d263c29e99c937abf
date:   Mon, 8 Jan 2024 10:41:07 +0000
author: Essien Ita Essien <essiene@gmail.com>

    cli: Refactor out git_init() to encapsulate all git related work.

    * Create a git_init() function in cli/src/commands/init.rs where all git related work is done.
      This function will be moved to cli/src/commands/git.rs in a subsequent PR.

commit: 8423c63a0465ada99c81f87e06f833568a22cb48
date:   Mon, 8 Jan 2024 10:41:07 +0000
author: Essien Ita Essien <essiene@gmail.com>

    cli: Refactor workspace root directory creation

    * Add file_util::create_or_reuse_dir() which is needed by all init
      functionality regardless of the backend.

commit: b3c47953e807bef202d632c4e309b9a8eb814fde
date:   Wed, 31 Jan 2024 20:53:23 -0800
author: Ilya Grigoriev <ilyagr@users.noreply.github.com>

    config.md docs: document `jj config edit` and `jj config path`

    This changes the intro section to recommend using `jj config edit` to
    edit the config instead of looking for the files manually.

commit: e9c482c0176d5f0c0c28436f78bd6002aa23a5e2
date:   Wed, 31 Jan 2024 20:53:23 -0800
author: Ilya Grigoriev <ilyagr@users.noreply.github.com>

    docs: mention in `jj help config edit` that the command can create a file


commit: 98948554f72d4dc2d5f406da36452acb2868e6d7
date:   Wed, 31 Jan 2024 20:53:23 -0800
author: Ilya Grigoriev <ilyagr@users.noreply.github.com>

    cli `jj config`: add `jj config path` command


commit: 8a4b3966a6ff6b9cc1005c575d71bfc7771bced1
date:   Fri, 2 Feb 2024 22:08:00 -0800
author: Ilya Grigoriev <ilyagr@users.noreply.github.com>

    test_global_opts: make test_version just a bit nicer when it fails


commit: 42e61327718553fae6b98d7d96dd786b1f050e4c
date:   Fri, 2 Feb 2024 22:03:26 -0800
author: Ilya Grigoriev <ilyagr@users.noreply.github.com>

    test_global_opts: extract --version to its own test


commit: 42c85b33c7481efbfec01d68c0a3b1ea857196e0
date:   Fri, 2 Feb 2024 15:23:56 +0000
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

    cargo: bump the cargo-dependencies group with 1 update

    Bumps the cargo-dependencies group with 1 update: [tokio](https://github.com/tokio-rs/tokio).


    Updates `tokio` from 1.35.1 to 1.36.0
    - [Release notes](https://github.com/tokio-rs/tokio/releases)
    - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.35.1...tokio-1.36.0)

    ---
    updated-dependencies:
    - dependency-name: tokio
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: cargo-dependencies
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
commit: 32c6406e5f04d2ecb6642433b0faae2c6592c151
date:   Fri, 2 Feb 2024 15:22:21 +0000
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

    github: bump the github-dependencies group with 1 update

    Bumps the github-dependencies group with 1 update: [DeterminateSystems/magic-nix-cache-action](https://github.com/determinatesystems/magic-nix-cache-action).


    Updates `DeterminateSystems/magic-nix-cache-action` from 1402a2dd8f56a6a6306c015089c5086f5e1ca3ef to eeabdb06718ac63a7021c6132129679a8e22d0c7
    - [Release notes](https://github.com/determinatesystems/magic-nix-cache-action/releases)
    - [Commits](https://github.com/determinatesystems/magic-nix-cache-action/compare/1402a2dd8f56a6a6306c015089c5086f5e1ca3ef...eeabdb06718ac63a7021c6132129679a8e22d0c7)

    ---
    updated-dependencies:
    - dependency-name: DeterminateSystems/magic-nix-cache-action
      dependency-type: direct:production
      dependency-group: github-dependencies
    ...

    Signed-off-by: dependabot[bot] <support@github.com>

Jujutsu の吐くログは何を言っているのか?チュートリアルlog コマンドの解説にはこうある。

By default, jj log lists your local commits, with some remote commits added for context. The ~ indicates that the commit has parents that are not included in the graph. We can use the -r flag to select a different set of revisions to list.

【訳】デフォルトでは、jj log はあなたのローカルコミットを列挙し、一部のリモートコミットを文脈として付け加えます。~ はそのコミットがグラフ外の親を持つことを示しています。-r フラグで列挙するリビジョンの集合を変更することができます。

これを読んでも、jj log がデフォルトでを表示しているのかはちょっとわかりにくい。どのリモートコミットを、なぜ、付け加えているのか?その答えは jj log-r/--revisions オプションの help に書いてある。

Which revisions to show. Defaults to the ui.default-revset setting, or @ | ancestors(immutable_heads().., 2) | heads(immutable_heads()) if it is not set

【訳】表示するリビジョン。デフォルトでは ui.default-revset の設定、または未設定なら @ | ancestors(immutable_heads().., 2) | heads(immutable_heads())

この revset についてはもう少し後で詳しく説明しよう。今はまず、ここに Jujutsu の revsets に対するアプローチ、ひいては log コマンドの特長が垣間見えることに注目したい。第一に、一部の操作が関数ancestors(), immutable_heads() など)として扱われている。なんと関数のリストがこんなにあるのだ!まあ、「関数型言語の式」と書いてある以上、当たり前なのだが、私はドキュメントをそこまで読む前だったのでとても驚いた。第二に、これらの「演算子」は第一級の概念なのだ。演算子なら Git にもあるが、こちらはさらに進化している。

  • 親を意味する - と子を意味する + があり、しかも繰り返し・合成して使える。つまり履歴が線形である限り、@-+-+@ と同義になる。(ただし重要な区別がある!

  • 集合の和 |、積 &、差 ~ の演算子がある。

  • 先頭の :: は「祖先」を意味する。末尾の :: は「子孫」を意味する。コミットの間に :: を挟むことで 2 コミット間にある有向非巡回グラフの区間を呼び出せる。ちなみに <id1>::<id2> は実は <id1>:: & ::<id2> だ。

  • .. 演算子もあり、これも正しく(そしてスマートに)合成できる(Git で 2 コミットに挟んで使う <id1>..<id2> と同じ意味)。後置バージョン <id>.. は面白いことに「<id> の先祖ではないリビジョン」を指す。同様に前置の ..<id><id> の祖先である全リビジョンを指す。

ここでは <id> と書いたが、これらはすべて revset を取るので、どんな revset でも使える。例えば、..tags() はすべてのタグの祖先を返す。これは極めて魅力的だ。通常の集合論理を組み立てて履歴を問い合わせることができれば、Git で履歴をいじるような辛さがかなり減るだろう。具体例を挙げると、去年の 10 月に Jujutsu のコントリビューター @aseipp が、この機能を使えば gh-pages 以外のログをあっと言う間に取得できると話していた。(Git で gh-pages ブランチのあるリポジトリを触ったことのある人なら、他の履歴にまぎれこむ苛立たしさをご存じだろう。)まず、gh-pages ブランチだけを含む revset の別名を定義する: 'gh-pages' = 'remote_branches(exact:"gh-pages")'。そして否定演算子 ~ で他のクエリからそれを除外すればいい: jj log -r "all() ~ ancestors(gh-pages)"。これですべてのリビジョン all() から gh-pages ブランチのすべての祖先を除いたものが表示される。

Jujutsu には、「コマンドの出力をカスタマイズする関数型言語」を使ったとても強力なテンプレートシステムもある。この言語はリビジョンを記述するのに使われる関数型言語(上で簡単に説明したもの)を基にしているため、リポジトリを検索したり操作したりするのに使った同じ演算子をテンプレートでも使えるのだ。テンプレートの書式は鋭意開発中だが、現時点でも出力のカスタマイズには使える。ただし今後更新する必要が出てくるかもしれないので注意しよう。キーワードには description(説明文)や change_id(変更 ID)などが使え、Jujutsu の設定で変更できる。例えば、私は自分用に以下の設定を加え、標準の format_short_id を上書きしている。

[template-aliases]
'format_short_id(id)' = 'id.shortest()'

これは変更やコミットに超短い名前をつけてくれるので、出力ログを読んだり操作したりするのがずいぶんと楽になる。Jujutsu の出す最短の一意な識別子を、そのまま jj new などに使い回せる。その他にも、数多くの標準テンプレートがある。例えば、Git の log --pretty に相当するのは Jujutsu の log -T builtin_log_detailed だ(-T は「テンプレート」の略。長い表記の --template も使える)。[templates] 項目の下や、[template-aliases] ブロックを追加して、テンプレート言語や自前の関数を駆使しながら自由にテンプレートを定義できる。

これらは実に素晴らしいのだが、revset 言語やテンプレート言語のドキュメントを読んでみても、デフォルト出力が何を表しているのか私にはすぐにのみこめなかった。カスタマイズ方法はなおさらだ。jj log の説明などはその典型だが、今のドキュメントはどうも「既に VCS 上級者の人向けの説明」といった香りがする。プロジェクトが軌道に乗れば、より初歩的な情報も必要になってくるだろう。しかし今の段階にしてはまあまあよくやっているのではないかと思う。それに Jujutsu の名誉のために言えば、revset 言語もテンプレート言語も Git の同じ機能とは比べ物にならないほどわかりやすく、使いやすい。

話を戻して、jj loggit log のデフォルト出力の違いはどこかというと、端的には、-r をつけない限り、Jujutsu は ui.default-revset セレクタを使って git log よりも役立つ結果を返してくるのだ。おさらいになるが、そのデフォルトは @ | ancestors(immutable_heads().., 2) | heads(immutable_heads())だ。一つ一つ見ていくと、

  • 演算子 @ は現在のヘッド (head) リビジョンを取得する。
  • 和集合演算子 | は「あるいは以下の revset」を表す。つまりこれは @ そのものおよび次の 2 つのクエリの結果を返す。
  • 関数 immutable_heads() は、当たり前だが、ヘッドであって不変 (immutable) なもののリストを返す。デフォルトでは trunk() | tags() となり、トランクになっているブランチ(普通は main とか master とか)およびリポジトリ内の全タグを意味する。
  • 最初の immutable_heads().. をつけることで、どの不変ヘッドの祖先でもないリビジョンを抽出する。要するにトランクでもなくタグに行き着きもしないブランチが返る。
  • さらに ancestors(immutable_heads().., 2) とすると、そのようなブランチの 2 つ前までの祖先を要求する。
  • 最後に、heads() は与えられた revset が属する全ブランチの終端を取得する(ヘッドとは子のないコミットのことなので)。従って、heads(immutable_heads())immutable_heads() が求めたリビジョンのリストの中からブランチ終端だけを返す。[6]

すべて組み合わせると、ログは常に、あなたの現在先頭にある変更、トランクにマージされていないすべての行き止まりのブランチ、不変に指定されたものすべて(標準ではトランクとすべてのタグ)を出力することになる。これは初めて見るとびっくりするが、git log のデフォルトと比べると雲泥の差で、かつ単一の git log コマンドでは再現できない。逆に、git log と同等の物を得るのは簡単だ。

任意の変更の全履歴を見るには、祖先演算子 :: を使えばいい。jj log は常にリビジョンの識別子を返してくれるので、それを使って今度は jj log --revision ::<変更 ID>、あるいは略して jj log -r ::<変更 ID> と打つ。例えば、私の手元にあるリポジトリでは、最新のコミットの ID が mwoq で始まっている(Jujutsu は親切にも変更 ID として使える部分をハイライトしてくれる)ので、jj log -r ::mwoq と打てば mwoq の祖先を一覧できるし、jj log -r ..mwoq とすれば初回 (root) コミット以外の全祖先を返してくれる。(最初のコミットは何も見所がない。)まとめると、「現在のコミットの全履歴を表示」するためのコマンドは、

$ jj log -r ..@

このように、revset はとても強力かつ柔軟で、それでいて Git の演算子よりずっと使いやすい。それは使われている言語のおかげでもあり、また、Git コミットとは全く違う世界観、「Jujutsu の変更の概念」に根差しているからでもある。


変更 (change)

Git にしろ Subversion にしろ Mercurial にしろそれ以前の VCS にしろ、変更の最後には必ずコミットすることになっていた。Jujutsu では、コードを「コミットする」という第一級の概念はない。私はこのことを理解するのにだいぶかかってしまった。Jujutsu にはその代わりに 2 つの操作、describenew がある。jj describe は変更に説明文をつけられ、jj new は新しい変更を開始する。git commit --message "変更内容"jj describe --message "変更内容" && jj new に当たると考えていい。こうなっているのは jj describejj new が直交する操作だからで、それゆえ git commit よりも多くのことができるのだ。

describe コマンドはあらゆるコミットに適用できる。デフォルトでは現在の作業コピー (working copy) であるコミットにつくが、過去のコミットのメッセージを書き換えたい時でも、Git のように対話式リベースを立ち上げたりするような特別な操作は必要ない。ただ jj describe--revision(あるいは略して -r。Jujutsu を通して共通)引数を加えて呼べばいい。例えば、

# 長い書き方
$ jj describe --revision abcd --message "更新後のメッセージ"

# 短い書き方
$ jj describe -r abcd -m "更新後のメッセージ"

で済んでしまう。もちろん、これをワークフローに組み込むかどうかはあなたやチームの方針しだいだ。なお、Jujutsu は履歴を書き換えたくないブランチがあることも承知している。その場合は適宜「不変ヘッド」 (immutable head) revset を指定できる。ツール自体にこの機能がなく、強制プッシュ (force push) 対策のためにプラットフォーム上でブランチを保護しなければいけない Git よりも安全ということになる。

new コマンドは新しい変更を作成する要となる。しかも親が単一である必要はない。必要とあれば、いくらでも親を持った変更を作れるのだ。識別子 a, b, c, d という 4 つの変更に論理的に依存する変更がある?jj new a b c d と書けばいい。これにはうれしい効果もある。Jujutsu におけるマージとは、必ず 2 つ以上の親を持つ jj new にすぎないのだ。(「2 つ以上の親」というのは、複数の親を持つことは Git の “octopus” マージのように特別なことではないからだ。)そして、commit コマンドは必要ない。なぜならいつでもそのコミットに describe でき、いつでも new で新しい変更を作れるからだ。もし予めすべきことが決まっているなら、変更を作成する際に new-m/--message を渡せばいいのだ。[7]

asciicast-635739
(動画: jj new で 3 つの親を持つマージを作成するデモ

Git で変更をコミットする時、私はよく次のどちらかのことをしている。

  • 作業コピー内のすべてをコミットする: git commit --all[8]極めてよく使う操作だ。
  • そのうちの一部をコミットする: Git の -p の筆舌に尽くしがたいインターフェイスではなく、Fork を起動してそのステージング UI 上で行う。

前者の場面では、Jujutsu は Git の「インデックス」(index) を通さないという選択をしていて、筋がよさそうに思える。後者の場面では、私もあまり期待していなかったが、一度覚えてしまうと評価は 180 度変わった。Fork でやっていたワークフローと Jujutsu の差分ツールを通すやり方はそっくりで、しかも Jujutsu ならどんなツールでも使えるのだ。Vim がいい?どうぞどうぞ。

そのうえ、Jujutsu の作業コピーに対するアプローチがもたらす発想の転換は大変魅力的だ。これまで使ってきたどんなバージョン管理システム(CVS, PVCS, SVN など)でも、おおよそ次のような手順に従っていた。

  • いろいろ変更を加える。
  • コミットを作成してメッセージを添える。

Mercurial と Git では、履歴をさまざまな方法で書き換えることもできるようになった。私は巨大な変更を処理するのに Git の rebase --interactive コマンドを鬼のように使い倒していた。(10 年ほど前に Mercurial の履歴も同様に書き換えたことがある。)つまりもう 2 種類の作業がリストに付け加わることになる。

  • 必要なら、その変更箇所や説明文を直接修正する。
  • 必要なら、履歴を再構成する: 変更を分割したり、順番を入れ替えたり、メッセージを書き換えたり、来歴情報を差し替えたりなどなど。

Jujutsu はこれらすべてを転倒させた。理論上も作業上も、モデルの根幹となるのはコミットではなく変更だ。これはつまり、「作業中」の変更をそのまま表現できるということだ。私は今月後半に公開予定の記事に使うコードをいじっていて気がついたのだが、今している最中の変更に説明文をつけ (describe) て、そのまま作業を続行できるのだ。変更に説明文をつけることは「コミットする」ことからも新しい変更を作成することからも独立している。これが可能なのは作業コピーの状態を直接操作できるからだ。Git のインデックスに近いが、落とし穴だらけではない。(これから解説するにあたって、この単純化はいろいろと影響するが、これは特に初学者にとっては大事な点だ。インデックスを理解するのは 10 年前に Git をやり始めた時に難しかったことの 1 つだ。)

新しい変更を開始する時、jj commit でメッセージを添えてこのコミットを「完結」させてもいいし、jj new で「まっさらな変更を作成して作業コピー上で編集」してもいい。実は jj commit の正体は、jj describe した後に jj new をするだけだ。ここでお気づきだろうか。履歴を遡ってメッセージを変更するのにリベースは必要ないということだ。ただ jj describe --revision <target> と打つだけでいい。

さらに、jj new は履歴のどの箇所にでも新しいコミットを難なく作成できる。

-A, --insert-after
      Insert the new change between the target commit(s) and their children

      [aliases: after]

-B, --insert-before
      Insert the new change between the target commit(s) and their parents

      [aliases: before]

【訳】
-A, --insert-after
対象の(各)コミットとその(それぞれの)子の間にこの新しい変更を挿入する[別名: after]

-B, --insert-before
対象の(各)コミットとその(それぞれの)親の間にこの新しい変更を挿入する[別名: before]

Git でも、対話式リベースを使えば同じことはできる(Mercurial の履歴書き換えでもできると思うが、hg にブランクがありすぎてやり方は思い出せない)。Git でできないのは例えば、「x の位置で新しい変更を開始する」ことだ。リベース中ならできるが、どうしても壊れやすくなる。わかりやすく言うと、Git ではグラフの任意の位置でチェックアウトして新しい変更を作ることができるが、そこにブランチを作成することになり、明示的にリベースしない限り元のコミットグラフにあった子孫は移動してこない。しかも、たとえ明示的にリベースしてコミットを cherry-pick しても、元のコミットが取り残されたままになるので、そのブランチを削除する必要が出てくる。jj new -A <適当な変更 ID> を使えば、その変更を直に履歴に挿入できる。Jujutsu が履歴にあるすべての子をリベースしたり、必要ならばマージを加え、「よしなに」やってくれるのだ。もちろん、競合が発生しないとは限らないが、その時も後に述べる通り、Jujutsu は Git より(はるかに)うまく競合を処理できる。

私は対話式リベースを行う時 git reflog に頼りきりだったが、一度 Jujutsu のどこでも jj new に慣れてしまった後は、先に述べた Jujutsu の「第一級の競合」サポートとあいまって、Git の対話式リベースモードを必要とする場面はほぼなくなってしまった。しかし万が一の失敗に備えることも必要だ。そんな時は、jj op log でリポジトリに加えたすべての操作を表示できる。ぶっちゃけ git reflog よりもよほど便利で強力だ。「すべて」というのは、新しいリビジョンをリモートから取得する際に、Jujutsu が jj status であなたの作業コピーの情報を毎回更新するのを含む。

その他にも、Jujutsu ではある変更の経時変化をたどることができる。これは Git のいくつもの不便を解消してくれる。例えば、作業コピーに変更を加えた後、それを複数の変更に分割したくなっても、Git はステージ済み (staged) とそれ以外の 2 分割しか許さない。そのため、この種の操作はよくても大変か、最悪不可能になりかねない。Jujutsu の obslog コマンド[9]を使えば、その変更が時間とともにどう変わったかを確認できる。作業コピーも「変更」の一種にすぎないので、以前の状態(jj status、あるいはほとんどの場合リポジトリの状態を写し取るコマンドを使った時点)を簡単に取り戻せる。これは以前の変更にも使える。例えば、リベースした後、コードに加えた変更の一部が間違ったリビジョンに入ってしまったことに気づいた時は、obslognewrestore(か move)を組み合わせ、正しい順序に差し替え直すことができる。(言葉じゃ伝わりにくいから、後で動画を作るかも!)

分割 (split)

以上のことは、現在ディスク上に存在する変更の集合をどう分割するかをめぐる、Git とのもう一つの大きな違いに行き着く。先に述べた通り、Jujutsu は Git のように「インデックス」を持つのではなく、作業コピー自体をコミットとして扱う。Git は git add --patch を使って、一部の変更をインデックスに分離することしかできない。一方 Jujutsu は split コマンドによって差分エディタを起動し、git add --patch と似たような感覚で、まとめたい変更を選択できる。Jujutsu の他のコマンドと同じく、jj splitどんなコミットでも分け隔てなく使えるので、作業コピーでもそのまま動く。

理念的には、これはとても素晴らしい。しかし実用的には、今のところ Git のやり方よりもややもたつく感触がある。上で私は git add --patch を直接触らず、Fork のような GUI ツールで Git のインデックスに変更をステージしていると言ったが、こちらの方が、(少なくとも今の Jujutsu のような方式で)差分を編集するよりも一枚上だと思っている。Fork(や類似ツール)では、変更がの状態から好きな変更を乗せていけるが、jj split は現在のコミットの変更が乗った状態で差分を表示する。つまり、コミットを分割する時は右側の画面から変更を取り除いていき、1 つめのコミットに反映したい変更だけが残るようにする必要がある。右画面の最終版に残らなかったものは、差分画面を閉じると 2 つめのコミットに入るのだ。

何だか複雑なことをしていると思われるかもしれないが、その通りである……今のところは。最後の留保には意味がある。なぜならこういった問題は結局道具づくりの話であり、今の Jujutsu は 2007 年頃の Git と同じくらいしか道具が充実していない――つまり、ないに等しいからだ。とはいえ、留保を割り引いても、理念的な美しさを割り引いても、2024 年初現在では、やはり問題は切実だ。現状では主に 2 つの問題点がある。1 つは、認知的負荷が高いことだ。足し算ではなく引き算の思考を迫られるし、「2 つめのコミット」は 1 つめから取り除いていくにつれ存在が見えにくくなっていく。2 つめは、3 つ以上のコミットに分割したい時に、何度も同じ操作を繰り返さないといけないことだ。私は定期的にディスク上にある変更の山を 2 では到底済まない数のコミットに切り分ける作業をしているが、これだと認知的なオーバーヘッドが何倍にもなる。

私が Jujutsu の開発に協力し始めたことで、チームはこのような差分を処理する際のデフォルトを scm-diff-editor に変えてくれた。このようなワークフローのための第一級サポートを持つテキストユーザーインターフェイス (TUI) だ。[10]これは問題なく使えるが、ForkTower の素晴らしい GUI と比べるとかなり見劣りがする。

まとめると、変更を分割しようとした時、私は今のところ Fork や Git のインデックスに戻りたい気持ちがかなり強い。この問題は解決不能というわけではないし、jj split の「アイディア」は正しいと思う。ただ少し、「ほんの少し」、丁寧なデザインが必要なだけだ。理想を言えば、split は元のコミットから任意の数のコミットを直感的に生成できて欲しいし、「前のコミット」を起点として積み上げ的にコミットを作成できて欲しい。これは Git のインデックスの長所だ。曲がりなりにも 3 つの「箱」: すべての変更より前の状態・すべての変更の集合・コミットに含めたい集合、の区別が必要だという現実を反映している。既存の差分ツールでこれを扱えるものは少ない。例外はインデックスに対応する各 Git クライアント付属のものだが、Jujutsu とはあまり相性が良くない。Jujutsu はインデックスを無視するからだ。

第一級の競合

Jujutsu の次なる目玉機能は第一級の競合だ。解決するまで行く手に立ちはだかり続ける悪夢の競合の代わりに、Jujutsu はマージをその解決(手動・自動を問わず)とともにコミット履歴に直接埋め込めるのだ。競合を履歴に入れるだけなら大したことはない。ああ、Git の競合マーカーごとコミットしたのね、ってわけだ。しかし、競合と解決を履歴に保持でき、リベース処理の途中なんかで Jujutsu が自動的に解決方法を判断してくれるとしたら?そいつはヤバいな。

少し前、私は自分の保守[11]するあるライブラリをいじっていて、package.json に加えた 2 つの変更の順番を入れ替えようとしていた。具合の悪いことに、それらの変更は前後に隣接していて、入れ替えるのが途方もなく難しそうに思われた。ところがどっこい、まずワークフローが素晴らしかった。私は対話式リベース用のエディタを立ち上げる代わりに、Jujutsu を呼び出してリベースを行わせた: jj rebase --revision <source> --destination <target>。これを入れ替えが必要なアイテム分繰り返すと、作業が終わっていた。(他にもたくさんのコミットをリベースしてもよかったが、この時は必要なかった。)本当にこれだけだ。Jujutsu は JSON がいかにこうした変更を扱いにくい形式か熟知しているので、マージの競合をコミットし、次のリベースコマンドでそれを解決して、先に進むのだ。

技術的には、Jujutsu は Git の競合時と同じように、競合マーカーをファイルに追加する。しかし Git と違って、それらは単なるファイル内のマーカーではない。競合を意味的に理解し、従って意味的にどう解決するか知っているシステムの一端なのだ。これは単に、私のライブラリの例みたいに便利な自動処理ができるだけの話ではない。解決をどう行うか、競合をどう扱うかの選択肢を増やしてくれるのだ。Git に慣れた頭には、ブランチ間の競合は「問題」に見える。問題を解決しない限り、先には進めない。だが Jujutsu では、競合を解決すべき問題として扱うこともできるし、そうしなくてもいい。Git でマージの競合を解決するのは結構カオスで、リベース中ならなおさらだ。私はこれまで何時間分、マージしようと粘ったあげく、諦めて git reset --hard <マージ前> したかわからない。いや、リベースを諦めて git rebase --abort するまでに費した時間の方が長いかもしれない。Jujutsu を使えば、競合を残したままマージを作り、次のコミットによって、前後の脈絡に合うよう解決を逆算することができる。

asciicast-Uyfv9qcPTfVeNyoVCINpq5Qfq
(動画: マージ時の競合解決

リベースでも同様だ。すべての中間リビジョンを実行可能な状態にしたいか、それとも競合を含んだ履歴を確認したいかに応じて、リベースしたり、中間の変更を競合したままにしたり、最後でまとめて解決したりできる。

asciicast-k5pFEM07wX1F9ZxcQsLnMTGCd
(動画: リベース時の競合解決

一定数以上の人間が同じリポジトリで作業していれば、競合は必ず発生する。もっと言うと、上のエピソードから分かるように、私が一人で作業していても発生する。競合した状態のまま作業を続行でき、かつ競合をより対話的に一つ一つ解決させてくれる機能を、私はいまや手放せなくなった。

変更の変更

Jujutsu の変更とコミットの区別のおかげで、うれしいことはまだいくつかある。特に第一級の競合と組み合わせると効果的だ。

指定したコミット内の全変更を取ってきて、親コミット内に圧縮 (squash) できる jj squash というものがある。[12]作業コピー内にたくさんの変更があっても、jj squash と打てば全部親の中に移動でき、編集中のものではない別の変更を圧縮したい時は、他の Jujutsu コマンドと同様、-r/--revision フラグをつければいい。jj squash -r abc なら abc という識別子のついた変更がその親の中に入る。また、引数 --interactive(省略して -i)を渡せば変更の中の一部だけを親に送り込める。このフラグを付けると、jj split と同じく、あなたが設定した差分エディタが開き、どの部分を親に入れどの部分を残すか選ぶことができる。さらに、特定のファイルの中身だけ移動したい場合は、その中の全内容を対象にしてよければ、コマンドの最後の引数として指定する jj squash ./path/a ./path/c のが手っ取り早い。

このような 1 つの変更の一部を他の変更に移す機能をどこでも使えることは、非常に有用だということが分かる。私が特に便利だと感じたのは、例えば他人にレビューしてもらう際に分かりやすいよう、変更を一貫性を持った集合に振り分ける時である。もちろん、jj splitjj new --after <何かの変更 ID> をかけてから jj rebase で変更を移し替えてもいいのだが……Jujutsu には普通もっとうまいやり方がある。squash コマンドは、実は Jujutsu の move に特定の引数を足したショートカットで、move コマンドは --from--to を指定してどこからどこのリビジョンに動かすかを決める。引数なしで jj squash を走らせた時は、jj move --from @ --to @- と同等、jj squash -r abc なら jj move --from abc --to abc- と同等だ。move にはこれらの引数を明示的に与えるので、任意の変更間で受け渡しができる。履歴上で近接している必要もない。

asciicast-634399
(動画: jj move の使用デモ

これによって、私がかつて git rebase --interactive に頼らざるを得なかった領域がまた 1 つ消滅することになる。確かに Jujutsu にも Git の対話式リベースモードがあればいいなと思ったことはなくはないが、不満を覚えるほどめったにはない。その多くはコミットを一括並べ替えしたい場面で、年に数回ほどしかないだろうとは言っておく。

ブランチ

ブランチも Jujutsu の Git の違いが大きく現れるところだ。Jujutsu の挙動が Mercurial に近い、とも言える。Git では、すべてが名前つきブランチの上で行われる。匿名ブランチ上で作業することもできるにはできるが、何かするたびに「detached HEAD」にいると怒られ続ける。Jujutsu では真逆だ。通常の作業モードではひたすら変更を加えていけばいい。その時は当然変更グラフ上で「ブランチ」を形成するが、名札は一切いらないのだ。ブランチにはいつでも jj branch create で名前をつけられるが、それはあくまでその時点の変更を指示するだけで、jj new で新しい変更を作った時のように自動で追跡したりしない。(Mercurial に慣れた方ならブックマークのようなものだと思えばいい。ただし「アクティブ」 (active) と「非アクティブ」 (inactive) の区別はない。)

ブランチ名の指示対象を変更するには branch set コマンドを使う。ブランチをプッシュ先の全リモートを含め完全に削除するには branch delete コマンドを使う。ローカルのブランチ操作をすべて忘れさせる(対象となる変更自体は消えない)branch forget というすぐれものもある。ローカルのコピーがリモートからかけ離れすぎて、その変更を反映せずにブランチをリモートの状態に戻したい時に便利だ。git reset --hard origin/<ブランチ名> など必要なく、jj branch forget <ブランチ名> するだけで、次回リモートからプルした時に以前のブランチの状態に戻せるのだ。

(↑ 欲しがっているのは私だけじゃなかった!

10 年間も Git に従って名前つきブランチで作業をしていると、Jujutsu のデフォルトで匿名ブランチになる仕様に慣れるのにちょっと時間がかかった。だが例によって、今では大変素晴らしく思っている。特に、このアプローチは、変更を他人に共有するに至る前の作業工程に非常に適していると思う。たとえ共有しようとする時でさえ、ブランチ名を必ず求める Git が時々馬鹿らしく思えてくる。とりわけ、小さな単発の変更を加える時には、ブランチ名はどうせコミットメッセージを _ で連結しただけの短いものになることが多い。デフォルトのログ表示には今あるブランチの一覧が出るので、コミットメッセージを見れば済む話なのだ。

とはいえ、実用上は、少なくとも現在のエコシステムの中では、このアプローチの欠点もある。まず、「現在の (current) ブランチ」という概念がないことで、GitHub, GitLab, Gitea などのツールと折り合いが悪くなる。GitHub のモデル(を模倣したツール)ではブランチが作業の基盤となっている。GitHub はブランチに乗っていないコミットには警告を出すし、匿名ブランチからプルリクエストを送ることもできない。元をたどれば、これは Git 自身がブランチを特別視していることが大きい。GitHub はただ Git を見習って「detached HEAD」の警告をデカデカと出しているだけなのだ。

これがもたらす実際の影響は、GitHub その他のプラットフォームに変更をプッシュする時に、余計な一手間がかかるということだ。Git なら、単に変更を git push するだけでいい(相互運用については後述)。Git は現在のブランチを現在の HEAD に同期させるので、引数のない git pushgit push <現在のブランチにひもづいたリモート> <現在のブランチ> に書き換えられる。Jujutsu はこれをしないし、名前つきブランチが操作を追跡しない現在のブランチモデルに則ればできないので、代わりに手動でブランチをプッシュしたいコミットに置き直す必要がある。通常は、最新の変更をプッシュしたいなら jj branch set <ブランチ名> と打てば自動的に現在の変更を指す。これがないと jj git push で更新が反映されない。たかが一手間、されど一手間。変更を公開するためにプッシュする時も、他のマシンに移す時も[13]毎回入力するとなると、一回一回は小さくても、積もれば山となる。

これは設計思想に根差すジレンマでもある。私が Jujutsu でブランチを使うのは、今のところ主に GitHub のような Git プラットフォームに上げるためだ。jj logjj new <リビジョン> で何でもできるので、変更を操作するためにブランチを必要とすることはほとんどない。その意味では、ブランチが作業内容を「後追い」してくれる方が自然だ。わざわざブランチ名をつけてやってリモートにプッシュするからには、そのブランチに加えた変更を随時反映して欲しいに決まっている。一方で、変更をプッシュするのは意図的な行為だと考えると、そうしない大きな利点もある。Git リポジトリの中で実験まがいのことをしていただけなのに、ブランチ名を foo-feature から foo-feature-experiment に付け替え忘れて git push してしまったことは数知れない。他の人と foo-feature で共同作業をしていた日には、強制的にプッシュ (force push) し直して以前の状態に戻し、それまでみんなに待ってもらうはめになったりする。Jujutsu のモデルではこれは起きない。あえて操作しなければ名前つきブランチを更新されないので、思う存分実験を繰り返しても、そのブランチをうっかりプッシュすることとは無縁だ。煮え切らない言い方だが、本当にブランチをプッシュしたい時に余計な手間がかかったとしても、公開するはずではなかった変更をプッシュして取り消すのと比べればマシと言えるかもしれない。

(ご想像の通り、匿名ブランチのデフォルトの挙動は Git 用のツールに若干の副作用を引き起こすが、それについては後述する。)

ちなみに Jujutsu には、匿名ブランチで大量の作業を行ったものを Git プラットフォームにプッシュする時のための便利機能がある。jj git push サブコマンドで --change/-c フラグを使えば、現在の変更 ID に基づいてブランチを作成してくれる。変更を 1 つだけプッシュして作業を続ける場合や、今の変更がブランチの先頭に残ってもいい場合はこれでバッチリだ。もし後で更新を付け加えることになった場合は、そのブランチ名を使って jj branch set push/<変更 ID> -r <リビジョン> とする必要があり、やや面倒になる。

それでも大きな視点で見ると、Jujutsu でブランチを扱うのは総じて快適だ。branch コマンドはよく設計された CLI がどれほど仕事を楽にしてくれるかのお手本だ。これまでに jj branch <何々> という形のコマンドが出てきたことにお気づきだろうか。branch サブコマンドには他にも list, rename, track, untrack がある。ここ数年は Git の設計もだんだん良くなってきているが、Jujutsu のようなすっきりした一貫性には程遠い。というのは、まず Jujutsu では上のようなものはすべてサブコマンドだ。Git のように状況によって組み合わせできたりできなかったり、意味が変わったりするフラグの寄せ集めではない。もう一つは、Jujutsu の CLI 構造全体を通じて、同じオプションは同じ意味を表す。もし特定のリビジョン(群)を指すブランチをすべて列挙したければ、-r/--revisions フラグが使える。これは Jujutsu の他のコマンドでリビジョン関連の操作をする時も共通だ。Jujutsu は全体を通して、「コマンド」 (command) (サブコマンドを含む)と「オプション」 (option)[14] を注意深く厳密に区別している。Git ではそうはいかない。trackuntrack サブコマンドがいい例だ。Jujutsu では、リモートブランチを追跡したい時は jj branch track <ブランチ>@<リモート> とする。対応する Git コマンドは git branch --set-upstream-to <リモート>/<ブランチ> だ。しかし Git でブランチを一覧したり絞り込んだりする時にもフラグが使われる。git branch --alljj branch list --all に対応する。Git の方が確かに短いが、一貫性が驚くほどなく、メンタルモデルを構築しようがない。Jujutsu なら、モデルは明確で一貫している。jj <コマンド> <オプション>jj <コンテキスト> <コマンド> <オプション> かで、<コンテキスト> には branchworkspaceop(操作 (operation) のこと)などが入る。

Git との相互運用

Jujutsu にはネイティブのバックエンドがあり、すべての機能はその上でも動くことになっているので、いつかそれが本来の力を発揮する日が来るだろう。しかし現時点で使うべきは Git バックエンドである。jj init--git なしで実行しようとしても、Jujutsu はデフォルトで拒否することからもわかる。

> jj init
Error: The native backend is disallowed by default.
Hint: Did you mean to pass `--git`?
Set `ui.allow-init-native` to allow initializing a repo with the native backend.

【訳】
エラー: ネイティブバックエンドはデフォルトで許可されていません。
ヒント: --git をつけ忘れましたか?
ui.allow-init-native を設定するとネイティブバックエンドでリポジトリを初期化できます。

現実には、Git バックエンドを使うことになる。現実に、私は Git バックエンドをこの 7 ヶ月間、フルタイムで、個人リポジトリから、参加しているオープンソースプロジェクトに至るまですべてに使っているが、私とペアプログラミングをした人以外、誰にも気づかれていない。Git との統合はそれほど磐石なのだ。この高い相互運用性は移行の障害を大きく低減する。誰でも Git リポジトリの中で jj git init --git-repo . とするだけで、Git に代えて Jujutsu を使い始めることができる。すべての操作はそのまま Git リポジトリの操作に変換される。

Git と相互運用することは、Jujutsu と Git の間を行き来することでもある。jj コマンドでたくさん作業を行った後、Jujutsu でどうやるか分からない問題に出くわしたら、慣れ親しんだgit コマンドに戻ればいい。次に jj コマンド(jj status など)を走らせた時、Git 側で行われた更新を(迅速に!)取り込み、そのまま作業を続けることができる。jj git fetch のようなコマンドで Git リモートから更新を取り込んだ時も同様だ。Git 互換コマンドはすべてそのまま git サブコマンドから使える(jj git push, jj git fetch など)、Git リポジトリと明示的に同期させるものを含め一通り揃っているが、私が毎日使っているのは jj git pushjj git fetch だけだ。なお jj git pull はない。なぜなら Jujutsu は最新の更新を取得することとローカルコピーの状態を変更することを区別するからだ。git pull がなくても困ったことはない。

しかしこのすっきりとした相互運用性は、Git が Jujutsu 側の全情報を把握していることを意味しない。Jujutsu でリポジトリを初期化すると、プロジェクトにはメタデータ用の .jj ディレクトリが加わる。ここに Jujutsu は例えば基底のリビジョンからみた変更の時系列データなど、それ自身の形式の変更情報を格納する。Git リポジトリでは、そのリビジョンとは Git コミットそのものである。あなたが直接操作したり指定したりする必要はまずないが、それらの SHA は一致しているので、ある Git コミットを指し示す名前がそのまま Jujutsu のリビジョンとして参照できる。(これは jj コマンドと Jujutsu の変更 ID について何も知らない Git 対応ツールを併用する際にとても便利だ。).jj ディレクトリには操作ログも保存され、まっさらな Jujutsu リポジトリ(既存 Git リポジトリから作られたのではない)の場合には、Git リポジトリのバックアップも入る。

Git 関連機能は libgit2 を利用しているので、Jujutsu–Git 間の問題でリポジトリが壊れるリスクはほとんどない。念のため、Jujutsu 自身のバグもあるだろうし、Jujutsu を操作した結果おかしなことになる可能性もあるが、それは Git リポジトリを操作するどんなツールにもありえることだ。Jujutsu はリポジトリに異なる意味論をかぶせているので、平均的な GUI Git クライアントよりもわずかにリスクは高いだろうが、私は今このプロジェクトに厚い信頼を寄せているし、あなたもすぐにそう思えるようになるだろう。

今すぐ使える?

当然ながら、これだけ大規模な課題領域ともなれば、荒削りな点はまだまだ残っている。例えば、GPG や SSH によるコミット署名はまだ動作しない。機能の土台となる GPG 対応にはまだ入っていないプルリクがあるし、SSH 対応は土台ができていれば簡単だが、まだ完成していない。[15]それでも不足機能はだんだん少なくなっている。2023 年の 7 月に Jujutsu を使い始めた時にはできていなかった sparse checkout も workspace(Git の作業ツリー (worktree) に相当)もこの間に実装され、Google 内外のコントリビューターによって継続的に開発が進んでいる。実のところ、常用者からみて Jujutsu 自身に欠けている部分の多くは、ネイティブバックエンドの開発が本格化すれば出来上がってくる類の機能だろうと思う。

現時点で実感する不足や不便さは、結局 Jujutsu を取り巻くツール環境の未整備、そして既存の Git ツールと Jujutsu が持つ Git 互換機能の間の関係性に起因する。ツールの欠如は言うまでもない。誰もまだ ForkTower のようなものを作っていないし、IntelliJ や Visual Studio といった統合開発環境 (IDE) にも、VS Code や Vim といったエディタにも拡張機能がない。現在 Jujutsu は主に Git 上で動いているので、有意義な応答を返してくれることもあるが、そのようなツールはみな Jujutsu 式の作業コピーではなく、Git のインデックスを認識しているだけだ。しかも、それらの多くは(当たり前だが)Git と同じように四六時中 detached HEAD について文句を言い続ける。楽観的に見れば、一部のツールが手動でチェックアウトしない限り匿名ブランチや detached HEAD を表示しないのを除けば、リポジトリの履歴は概ね正しく表示される。Detached head は GitHub の gh のようなツールには苦手らしく、しばしば手動で引数を足さなければ動かないことがある。(そのため私のコマンド履歴には gh pr create --web --head <name> のようなものが頻出する。)

Jujutsu の素晴らしい機能の中にも、主流の Git プラットフォーム上でうまく動かない一因となるものがある。例えば、次の各操作に共通するものは何だろうか。

  • 任意の位置に変更を挿入する。
  • 変更の説明文を書き換える。
  • 一連の変更をリベースする。
  • コミットを分割する。
  • 既存のコミットを結合する。

これらはすべて履歴を書き換える。もしブランチをリモートにプッシュした後、そのブランチでこのいずれかの操作を行って再度プッシュし直すと強制プッシュ (force push) となる。主流の Git プラットフォームの多くはこれに弱い。具体例で言うと、GitHub は強制プッシュを挟む差分にある程度対応しているが、とても初歩的なもので、やりとりの文脈はすべて失われてしまう。そのため、強制プッシュを多用するワークフローはスムーズに回らない。この場合、Jujutsu は悪くなく、こうしたツールの機能不足を可視化しているのだ。[16]しかし私も相互運用上の誤動作で GitHub を責める気はない。どのみち JujutsuLab ではないのだし、Jujutsu も Git のモデルに収まらないことをやっているからだ。それでも OSS 開発の多くが GitHub や GitLab のようなプラットフォームで行われる以上、このような現象には頻繁に悩まされるだろう。

今の時点でとりわけ足りないと感じるのは、Jujutsu の分割、移動、その他変更に対する対話的な編集をサポートするツールだ。Jujutsu コマンドから呼び出される組み込みの差分編集用 TUI である、@arxanas の優秀な scm-diff-editor を除けば、これらの操作がまともに使えるツールは皆無だ。scm-diff-editor が優秀とは言ったが、私はそもそもこういう用途に TUI を使うのは好まない。自分では Kaleidoscope と BBEdit を改造して騙し騙し使っているが、jj split の解説でも述べた通り、あまり使いやすいとは言えない。結局このようなワークフローのために作られていないのだ。インデックスは理解するが、変更の分割は理解してくれない。つまるところ、Jujutsu を理解する新しいツールが必要だということだ。

それによって広がる可能性は、世の多くのエディタや、IDE や、専用の VCS ビューアが Git のために提供しているような機能を実装する以上のものだ。リベース、マージ、説明の書き換えなどを Jujutsu は普段使いのものに変えたが、GUI ツールがあれば今度は楽にできる。Git GUI がどう頑張っても、Git の基底モデルに縛られてしまうが、Jujutsu なら問題ない。Jujutsu の操作ログや変更の時系列ログを UI に落とし込むのも Git reflog よりずっと簡単だろう。そうすれば過去の仕事を探し出したり方針を立て直したりするのも楽になる。

結論

去年の夏に出会って以来、Jujutsu は私のお気に入りのバージョン管理ツールだ。上に書いたさまざまな不満点を差し引いても、Git を直接叩くより格段に好きだ。あなたも個人プロジェクトやオープンソースプロジェクトで使ってみて欲しい。私はぜひともおすすめしたい、いや、する。この 7 ヶ月間ほぼ Jujutsu 一本で使ってきたが、Git に戻ろうとはさらさら思えない。Jujutsu の開発が終了したりすれば別だが、Google 内での有望そうな未来を考えれば、その可能性は低いだろう。[17]そのうえ、既存の Git リポジトリを透過的に扱えるので、個人であれチームであれ採用を妨げる理由はない。(あなたの会社のセキュリティポリシーを別にすれば。)

Jujutsu はもう Fortune 500 大企業でも採用できるレベルか、と言われるとさすがに疑問だ。着実に改良が加えられている(2023 年半ばに遭遇した問題点はほとんど直っている)ものの、まだまだ設計変更もところどころなされているし、使い方の説明もないに等しい。(そんな現状を変えたかったのも、この文章を書いた動機の 1 つだ!)Jujutsu そのもの以外にも、エコシステムを整備する道のりは長い。Jujutsu を理解しないツールとどううまくやっていくか、課題も多く残っている。プロジェクトが一歩一歩 1.0 リリースに近づいているのは確かだが、それがいつになるかは私にも分からない。やるべきことは山積みだ。特に、Jujutsu のネイティブバックエンドが動作する姿をぜひ見てみたい。現時点では、Git リポジトリに被さったより良いモデル「止まり」だからだ。このフロントエンドと同じくらい賢いバックエンドが登場する世界こそが、本当に楽しみにしているものだ。







脚注
  1. そう、これは Rust で書かれていて、死ぬほど速い。ただ、Git も C で書かれていて、やはり死ぬほど速い。もちろん、Rust を使う安全性のメリットはあるが、そこは別に Jujutsu の「売り」ではない。単に今このようなプロジェクトにとっては当然の選択というだけだ。私が昔から理想としてきた Rust の使われ方だ! ↩︎

  2. Mac ユーザーのための豆知識: .DS_Store~/.gitignore_global に加えると QoL が爆上がりだぞ!Git でも Jujutsu でもだ。 ↩︎

  3. ただし、1 回だけディレクトリの初期化中に Jujutsu が失敗を処理し損ねるバグ(修正済)に起因する変な詰まり方に遭遇した。困ったが、次のリリースでは修正されていた。開発初期のソフトにはつきものだ……。 ↩︎

  4. 単純な jj init コマンドはネイティブバックエンドの初期化用に予約されている……が、現時点では無効になっている。ネイティブバックエンドが出来上がるまでは全く正しい判断だが、あれっと思う人もいるだろう(そしてそれまでは、この文章のタイトルも若干残念な感じになっているわけだが……)。 ↩︎

  5. 【訳注】こう書いてあるが、少なくとも現時点では Jujutsu でも commitrevision は同義で使われていて、しかも用語集では commit の方が主見出しである。以下の文章にも、Jujutsu の概念を指して「コミット」と呼んでいる箇所がよく出てくる。 ↩︎

  6. これは Git の HEAD でも Mercurial の「tip」でもない。この 2 つはどちらも 1 つしか持てないし、それぞれ別のものを指している! ↩︎

  7. jj help の内容を見ると、Jujutsu に checkout, merge, commit といったコマンドがあることに気づくだろう。しかしこれらは newdescribe または両方を用いた操作の別名だ。

    • checkout は単なる new の別名
    • commitjj describe -m "<some message>" && jj new の別名
    • mergejj new の第 1 引数として @ を与えたもの

    これらは中長期的にはドキュメントからも消え、CLI の出力では new を使うように指導される見込みだ。 ↩︎

  8. 本当はいつも git ci -am "<message>" のように「すべて」を表す -a (--all) とメッセージを表す -m をくっつけて、タイプ数を節約している。 ↩︎

  9. この名前は Mercurial の evolution 拡張に由来する。すでに obsolescent(過去の物となった)な変更を対象とすることから、“obsolescent changes log” で obslog なのだ。毎日使っていたのにこの神機能を見つけるまで 6 ヶ月かかった私は、名前を変えた方がいいのではないかと Jujutsu メンテナに提案中だ。 ↩︎

  10. Meld も 3 ペイン表示をサポートし始めたのは、一歩前進だといえる。ただ、Meld は macOS ではうまく動かない(GTK アプリは軒並みそうだ)し、現時点では原因不明の理由によって起動時間が恐ろしく遅いので、そもそもあまり使いやすくはない。……しかも Meld は今のバージョンの macOS では起動時にクラッシュしてしまう。 ↩︎

  11. そう、これに私は余暇をつぎ込んでいる。それだけとは限らないが。 ↩︎

  12. Git から来た人のために、別名の amend も用意されている。なので jj amend としてもいいが、squash と何も変わらず、jj amend のヘルプにもただの squash であると明記されている。 ↩︎

  13. 神経質すぎると思われるかもしれないが、一度喫茶店で誰かにカップの水をパソコンにこぼされてデータを全部失う経験をすると、あなたも血眼で遠隔バックアップを取るようになるだろう。私は決して git push を欠かさない。 ↩︎

  14. 【訳注】おそらくこの文章全体にわたって、「フラグ」と「オプション」は同義である。 ↩︎

  15. 私はこの機能が欲しいと思っていて、自分でも実装に協力している。2024 年 2 月中に完成したらいいなと思っているが、気長に待って欲しい。 ↩︎

  16. GitHub の共同作業デザインについては面白い議論が多くなされていて、Phabricator や Gerrit のレビューモデルなどの対抗馬もある。長らく欠けていたものだ。 ↩︎

  17. Google は「製品」をダメにすることで知られているが、開発者用ツールはさほどでもない。 ↩︎

Discussion