WorktreeCreate/WorktreeRemove hookでworktreeの作成・削除を差し替える
Claude Codeのgit worktree作成・削除を、WorktreeCreate/WorktreeRemove hookで別のVCSに差し替える手順と、パス検証・exit codeの注意点を解説します。
SVNやPerforceのリポジトリでもClaude Codeの並列セッション隔離を使いたいなら、WorktreeCreateとWorktreeRemoveの2つのhookで、worktreeの作成と削除を自前の処理に差し替えられます。claude --worktreeで起動したセッション、isolation: "worktree"を指定したSub-agent、バックグラウンドセッションのいずれでも、隔離コピーを作る場面で発火します。worktree隔離そのものの仕組みと落とし穴は別の記事にあるので、ここでは差し替えの書き方と、書いたあとに起きることを扱います。
WorktreeCreate/WorktreeRemoveで何が置き換わるか
Claude Codeは既定でgit worktreeを使って隔離コピーを作ります。WorktreeCreateを設定すると、この既定のgit操作が完全に置き換わります。追加処理の差し込み口ではなく、代替です。
対になるWorktreeRemoveは、--worktreeセッションの終了時にworktreeを削除するとき、Sub-agentの完了時、バックグラウンドセッションの削除時に発火します。gitで作ったworktreeにはgit worktree removeによる自動クリーンアップが標準で走るので、WorktreeRemoveが要るのは主にWorktreeCreateで差し替えた側です。
置き換わる範囲は、hook設定の見た目より広くなっています。
.worktreeincludeは処理されません。.envのようなgitignore対象のファイルは、hookスクリプトの中でコピーします- この2つのイベントにmatcherは効かず、常に発火します
ifフィールドはツール系のイベント(PreToolUse・PostToolUseなど)でだけ評価され、それ以外のイベントに付けたhookは一度も実行されません。絞り込みたいときは、スクリプトの中でJSON入力を見て分岐します
標準の許可/拒否モデルに乗らず、成否やexit codeだけで結果が決まる点は、追加自体をブロックできないDirectoryAddedhookと同じ型です。ツール名で絞れるPreToolUsehookとは、設計の前提が違います。
導入の流れ
差し替えを動かすまで
- 1
WorktreeCreateを書く
作成したディレクトリのパスを標準出力の最後の非空行に出します。VCSの進捗ログはstderrへ逃がします。
- 2
WorktreeRemoveを対で書く
差し替えたVCSの作業コピーは、gitが知らないディレクトリです。削除処理を自分で書かないとディスクに残ります。
- 3
ブランチ相当の後始末を足す
Claude Codeはhookが作ったworktreeのブランチを削除しません。VCSがブランチ相当を作るなら、削除は
WorktreeRemoveの仕事です。
作成: WorktreeCreateでSVNの作業コピーを用意する
WorktreeCreateへの入力には、共通のsession_id・cwdなどに加えてnameが渡ります。ユーザーが指定するか自動生成されるworktreeのスラッグで、bold-oak-a3f2のような値です。
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"hook_event_name": "WorktreeCreate",
"name": "feature-auth"
}このhookに許可/拒否のdecision controlはなく、成功・失敗そのものが結果です。求められるのは、作成したworktreeディレクトリのパスを返すことだけです。
- command hook: 標準出力の最後の空でない行にパスをprintします。Claude CodeはANSIエスケープを除去してから読むので、
echoの前に出るシェルの起動バナーは無視されます。パス以外の出力はstderrへ回します - HTTP hook: レスポンスボディで
{ "hookSpecificOutput": { "hookEventName": "WorktreeCreate", "worktreePath": "/absolute/path" } }を返します。command hookは標準出力をパスとして読むためJSONを返せず、JSONを返せるのはHTTP hookだけです
設定例です。
{
"hooks": {
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'NAME=$(jq -r .name); DIR=\"$HOME/.claude/worktrees/$NAME\"; svn checkout https://svn.example.com/repo/trunk \"$DIR\" >&2 && echo \"$DIR\"'"
}
]
}
]
}
}標準入力からnameを読み、チェックアウトを実行し、最後にパスをechoしています。進捗を>&2でstderrに送っているのは、パスの後ろに余計な行が出ないようにするためです。
hookが0以外のexit codeで終わるか、パスを出力しなければ、worktreeの作成はエラーで失敗します。systemMessageやcontinueは読み捨てられ、進捗をユーザーに伝える手段にはなりません。
非gitのディレクトリで動くバックグラウンドセッションは、hookが失敗すると隔離を飛ばし、元のディレクトリをそのまま編集します。gitリポジトリの中なら、セッションがworktreeへ移るまで共有チェックアウトへの書き込みがブロックされます。
スクリプト部分だけ手元で動かす
Claude Codeを起動せず、設定例のコマンドをそのままbashで動かすと、標準出力に何が出るかを確かめられます。svnの代わりに「進捗を1行出してディレクトリを作る」だけのダミーをPATHの先頭に置き、HOMEは一時ディレクトリへ差し替えました。
$ echo '{"name":"feature-auth"}' | bash -c 'NAME=$(jq -r .name); DIR="$HOME/.claude/worktrees/$NAME"; svn checkout https://svn.example.com/repo/trunk "$DIR" >&2 && echo "$DIR"' 2>/dev/null
/tmp/demo/home/.claude/worktrees/feature-auth2>/dev/nullでstderrを捨てると、標準出力には絶対パスの1行だけが残ります。>&2を外すと、進捗のA ... (svn checkout progress)の行が標準出力にも出て、パスはその後ろの最終行になります。Claude Codeが読むのは最終行なので、パスを最後に出すかぎり、前の行は無視されます。逆に、パスをechoしたあとに別のコマンドが何か出力すると、その行がパスと見なされます。
削除: WorktreeRemoveで後片付けを書く
WorktreeRemoveの入力には、WorktreeCreateが返したパスがworktree_pathで渡ります。
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"hook_event_name": "WorktreeRemove",
"worktree_path": "/Users/.../my-project/.claude/worktrees/feature-auth"
}結果を決めるのはexit codeだけで、systemMessageやcontinueはWorktreeCreateと同じく読み捨てられます。最小構成は次のとおりです。
{
"hooks": {
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'jq -r .worktree_path | xargs rm -rf'"
}
]
}
]
}
}ログを残して失敗を表に出すなら、スクリプトにします。
#!/bin/bash
# WorktreeRemove: SVNの作業コピーを削除し、失敗時はログに残す
input=$(cat)
path=$(echo "$input" | jq -r '.worktree_path')
if [ ! -d "$path" ]; then
exit 0
fi
rm -rf "$path" 2>>"$HOME/.claude/worktree-remove.log" || {
echo "削除に失敗しました: $path" >&2
exit 1
}削除前に存在確認を入れているのは、二重に発火したときや、手動で消されたパスに余計なエラーを出さないためです。
exit codeごとに何が起きるか
WorktreeRemoveの3つの分岐
hookを書いていない
--worktreeセッションの終了時に削除を選ぶと、Claude CodeはWorktreeCreateが返したパスにgit worktree remove --forceを試みます。gitが認識するworktreeなら消え、非gitのVCSで作ったものはディスクに残ります。exit 0
削除済みとして扱われます。Claude Codeはそれ以上何も読まないので、ディレクトリを実際に消したかどうかはhook側の責任です。
exit 0以外
そのパスのディレクトリがまだ存在すれば、削除は失敗です。worktreeはディスクに残り、gitへのフォールバックもありません。ディレクトリを消してから非ゼロで終わった場合は、削除済みと見なされます。
失敗時、hookのコマンドとstderrはデバッグログに記録されます。
バックグラウンドセッションの削除で起きること
バックグラウンドセッションの削除は、agent viewのCtrl+X2回かclaude rmで行います。hookが作ったworktreeに対しては、次の順で動きます。
- 保存済みのworktreeパスを検証し、シンボリックリンクそのもの、またはリポジトリルート配下のシンボリックリンクを経由するパスは拒否する
- ファイルが残るworktreeでは、agent viewで削除を確認したときだけhookを実行する。
claude rmは、セッションとworktreeをそのまま残す - hookが非ゼロで終わると、worktreeとセッションの両方が残る。拒否メッセージには
exited 1のような終了状態とstderrの冒頭が載る
hookやgitが消せずにセッションが残るときの出口が、claude rm <id> --force-remove-worktree <worktree-id>です。対象は、.claude/worktrees/配下にあるリポジトリのlinked worktreeに限られます。
追跡済みファイルに未コミットの変更がなく、ほかのセッションの記録がそのパスを名指していないことも条件です。ディレクトリだけを強制的に削除し、ブランチは残します。<worktree-id>は、直前のclaude rmの拒否メッセージが表示する値をそのまま渡します。
SVNなどでhookが作ったディレクトリは、この出口の対象外です。WorktreeRemoveが設定されていなければ、削除は拒否されてセッションが残ります。原因のhookを直すか、ディレクトリを手で消してから、もう一度セッションを削除します。
v2.1.287のclaude rm --helpでは、このオプションが次のように出ます。
$ claude rm --help
Usage: claude rm <id> [--discard-unpushed <commit>@<worktree-id>] [--force-remove-worktree <worktree-id>]
Delete a background session and its worktree. Unlike `stop`, works on already-exited sessions.
--force-remove-worktree <worktree-id> delete the worktree directory even though the WorktreeRemove hook or git couldn't remove it (its repository must list it as a worktree, or have dropped it in a removal that couldn't delete it; no uncommitted changes to tracked files, submodules included; the branch is kept) — pass the value a previous 'claude rm <id>' reported--force-remove-worktreeは、agent viewの公式ページがv2.1.268以降と記しています。--discard-unpushedはhookとは別の話で、未pushのコミットを持つgit worktreeを削除するためのものです。
hookで作ったworktreeには、もう1点あります。Claude Codeは自分がgitで作ったworktreeにだけ、マーカーをgitのメタデータへ書きます。サブエージェントとバックグラウンドセッションのworktreeを消す定期掃除は、マーカーのないものを残します。hookが作ったものは掃除の対象外になるので、不要になった分の回収はWorktreeRemoveとセッション削除に頼る形になります。
返したパスは検証される
WorktreeCreateが返したパスは、そのまま使われるわけではありません。相対パスはhookが実行されたディレクトリを基準に解決され、.や..は畳み込まれます。解決後のパスがディレクトリとして入れないものなら、セッションはパスを名指したエラーを表示してexit code 1で終了します。worktreesの公式ページは、この原因の典型として「hookがディレクトリのパス以外のものを出力した」場合と「作成後にディレクトリが削除された」場合を挙げています。
非gitのディレクトリをリポジトリの内側に作ると、gitがそのリポジトリのチェックアウトへ解決します。結果はgit resolves its working tree toのメッセージによる拒否です。hookが作るディレクトリは、どのリポジトリの外にも置きます。
WorktreeRemove側も同じで、バックグラウンドセッションの削除では保存済みパスが検証されます。これもv2.1.216より前は、検証なしでhookが走っていました。
バージョンごとの変更履歴
非gitのVCS向けに段階的に機能が足されてきました。
| バージョン | 変更内容 |
|---|---|
| v2.1.50 | 変更内容WorktreeCreate/WorktreeRemoveイベントを追加。非gitVCSでのセットアップ・後片付けが可能に |
| v2.1.69 | 変更内容プラグイン由来のWorktreeCreate/WorktreeRemoveフックが無視されていた不具合を修正 |
| v2.1.84 | 変更内容WorktreeCreateのHTTP hook対応を追加(hookSpecificOutput.worktreePathでパスを返せるように) |
| v2.1.85 | 変更内容非gitリポジトリで--worktreeがWorktreeCreateフックの実行前にエラー終了していた不具合を修正 |
| v2.1.144 | 変更内容非gitVCSでWorktreeCreateフックを使うバックグラウンドセッションにも、worktree隔離のガードが適用されるよう修正 |
| v2.1.203 | 変更内容非gitディレクトリから起動したバックグラウンドセッションで、WorktreeCreateフック設定時にファイル編集ができなかった不具合を修正 |
| v2.1.216 | 変更内容hookが返した、またはhook作成のworktreeに保存されたパスの検証(シンボリックリンク・./..)を追加(hooksの公式ページに記載) |
| v2.1.281 | 変更内容WorktreeCreatehookがあれば、gitリポジトリの外でも/batchを実行できる(worktreesの公式ページに記載) |
command hookだけだったv2.1.50から、HTTP hookでパスを返せるようになったのがv2.1.84です。既存の設定をHTTP hookへ移すなら、対象の環境がv2.1.84以降であることが前提になります。
まとめ
差し替えが要るのは、worktree隔離をgit以外のVCSで使うときと、作成時に.envのコピーなど独自の初期化を挟みたいときです。gitのままなら、.worktreeincludeで足りる場面ではhookを足さない方が構成は単純です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。