Jujutsu Megamerge でプライベートな変更をリモートにプッシュしないようにする

English translation is available.

はじめに

先日は Jujutsu の Megamerge Workflow を簡単に紹介した。 1

そこではプライベートな変更を管理する例を取り上げたが、今回はそれをどのように Megamerge Workflow と組み合わせているかの具体例を紹介したい。 Megamerge Workflow がどのようなものかはある程度理解しているが、どのようなシチュエーションで便利なのかがイメージできていない人が想定読者だ。 Megamerge Workflow について詳しく知りたい方はまず Jujutsu を用いた開発ワークフロー: Squash Workflow と Megamerge Workflow1 や記事中のリンク先を参考にしていただきたい。

Megamerge Workflow を導入することで「作業中の汚いコミット」や「ローカル専用の変更」を保持したまま、リモートには整理されたキレイな変更だけをプッシュすることができるようになる。 Git では複雑な状態管理が必要になる一方、 Jujutsu を用いればこの煩雑さを一気になくすことができる。

Megamerge Workflow とプライベートな変更

先日の記事 1 でも触れたように、私はよく「ワーキングコピーには反映しておきたいが、リモートには反映したくない(しなくてもいい)変更」を「プライベートな変更」としてリポジトリにコミットすることがある。

具体的には Nix の Flake 関連ファイル (flake.nix, flake.lock) や Harper のリポジトリローカルな辞書ファイル (.harper-dictionary) だ。 特に flake.nix, flake.lock に関しては Git で追跡されている状態でないと評価ができないという Nix 側の制約がある。 2 私は開発に必要なツールを Nix の devShell で用意することが多い。 一方、他のチームメンバーは Nix を利用していないため、自分しか利用しないファイルをわざわざレビューしてもらってまでチームのリポジトリに導入するのはコミュニケーションコストが高すぎる。 そのため、これらを「プライベートな変更」としてローカル環境に閉じてコミットしておけるとうれしい。

Git のみを利用している場合、このような変更はプッシュ前に git stash で退避させるなどの必要がある。 Megamerge Workflow を用いれば、すべての変更をワーキングコピー上に残したまま、プッシュしたい部分だけを切り出してリモートへプッシュできる。

Megamerge Workflow では、ワーキングコピーは次のような変更がすべて含まれた状態となる。

これなら flake.nix, flake.lock は常に Git (Jujutsu) で追跡されている状態となるし、一方でリモートにプッシュせずに自分の手元の環境だけで管理しておける。まさに「プライベートな変更」と言える。

変更をリモートに反映したいときは、次のようにして該当する変更に bookmark を作成する。

あとはこれを jj git push --bookmark <bookmark> でプッシュすれば良い。 このように、リモートに反映したい変更はプライベートな変更とは別のブランチ上に作成していくことで、プライベートな変更がリモートにプッシュされないように管理する。 しかし、これだけでは操作を誤ってプライベートな変更をリモートにプッシュしてしまう可能性が依然残る。

jj push エイリアス

Jujutsu の設定ファイルではエイリアス 3 を設定できる。任意のエイリアスに jj のサブコマンドと引数を割り当てられる。 また、 jj util exec サブコマンド 4 を用いると任意の外部コマンドを jj 経由で実行できる。 これらを組み合わせて、 リモートへのプッシュ用に jj push エイリアスを作成する。

jj push の機能は次のとおり。

jj git push の代わりに jj push エイリアスを利用するようにすれば、操作を誤ったとしてもプライベートな変更をリモートにプッシュすることがないようにできる。 誤って Megamerge のマージコミットをリモートにプッシュしようとすると、次のようにエラーで弾かれる。

revset-aliases

リモートにプッシュしたくない変更を識別するため、 revset aliases を利用する。

私は、変更の Description (コミットメッセージ) の接頭辞 ("private:", "wip:", "todo:", "mega" など) により変更の種類を管理するようにしている。 ここでも、変更の Description が "private" からはじまるものをプライベートな変更として扱うことにする。このルールに対応するように、次のとおりに revset alias を作成する。

[revset-aliases]
'private()' = 'description(glob:"private*")'

private() を参照すると、意図したとおり "private" からはじまる Description を持つ変更のみがリストアップされる。

ここでは、プライベートな変更 (private) 以外にも wip (作業中), todo (未完了), mega (Megamerge 用の形式的な変更) も revset aliases として定義しておく。

[revset-aliases]
'wip()' = 'description(glob:"wip*")'
'todo()' = 'description(glob:"todo*")'
'private()' = 'description(glob:"private*")'
'mega()' = 'description(glob:"mega*")'
'local()' = 'wip() | todo() | private() | mega()'

これらにあてはまるものをまとめて扱えるように local() という revset alias も定義しておく。

jj log -r "$target_revset & mutable() & local()" のようにプッシュ対象の revset と組み合わせて jj log コマンドを実行すると、「プッシュ対象」かつ「未プッシュの変更 (mutable())」 (リモートにプッシュ済みの変更はチェック対象外とする) かつ「 local() に該当」する変更のみを抽出できる。 この実行結果が空であれば local() に該当する変更がプッシュ対象に含まれていないと確認できる。

pre-commit

Jujutsu を利用する上で少し困るのが、 pre-commit をはじめとした Git hooks をうまく扱えないことだ。 そこで私は、 jj push エイリアスの中で pre-commit を実行するように設定しているので、これもついでに紹介しておく。

本質的には "Pre commit" ではなくなってしまうが、 "Pre push" なチェックとして pre-commit ツールを実行するようにしている。 pre-commit コマンドの実行結果が失敗ならプッシュを止め、成功ならプッシュを実行するようにしておく。

jj push エイリアス例

以上の内容を踏まえて、私は jj push エイリアスを次のように設定している。

https://github.com/rinx/dotfiles/blob/d1033370a8cb2142a2c506d3131da5cb28633f6d/jjconfig.toml#L45-L81

[aliases]
push = [
  'util',
  'exec',
  '--',
  'bash',
  '-c',
  '''
## pre-commit が利用できるときはチェックを実行
if type pre-commit >/dev/null 2>&1; then
    echo "==> Running pre-commit before push..."
    pre-commit run --all-files

    if [ $? -ne 0 ]; then
        echo "❌ pre-commit failed. Aborting push."
        exit 1
    fi
else
    echo "⚠️  pre-commit not found. Skipping checks."
fi

## コマンドライン引数からプッシュ対象の Revset を抽出。指定されていないときはデフォルトプッシュ対象の `::@` を使用
echo "==> Checking for forbidden markers in pushable commits..."
targets=$(echo "$@" | grep -oP '(?<=(-b|--bookmark|-c|--change|-r|--revision)\s)[^ ]+')
if [ -z "$targets" ]; then
  target_revset="::@"
else
  target_revset="::(${targets//$'\n'/|})"
fi
## プッシュ対象に意図しない変更が含まれないかを確認
if [ -n "$(jj log -r "$target_revset & mutable() & local()" --no-graph --ignore-working-copy -T 'commit_id')" ]; then
    echo "❌ Commits to be pushed contain wip/todo/private/mega markers. Aborting push."
    exit 1
fi

## リモートへのプッシュを実行
echo "==> Pushing changes to remote..."
echo "==> jj git push $@"
jj git push $@
''',
  '',
]

エイリアスとして簡単なガードレールを用意することで、より安心して Jujutsu を利用することができるようになる。 自身の使い方に合わせて最適なワークフローやエイリアスを構築してみてほしい。

追記

この記事を公開した後に気付いたが、 Jujutsu 自体に git.private-commits 5 という設定項目があった。 「プライベートな変更をリモートにプッシュしないようにする」という目的のためには、公式に用意されたこの仕組みを利用する方が筋が良い。 本稿の設定例にあわせて書くと、次のようになる。

[git]
private-commits = 'local()'

これにより、 local() に該当する変更とその子孫にあたる変更のプッシュを防止できる。

jj push エイリアス方式は pre-commit のようにプッシュ操作にフックして何かしらのチェックを実施したい場合には依然有効なので、これはこれで残しておこうと思う。