第1幕: 現場 ── 照合は、通った
AI に別のブランチを切らせて、夜のあいだ作業を任せた。そのあいだ、俺は何も触らなかった。それなのに、ゆうべ書きかけていたセレモニーの演出が、コミットする前に消えていた。照明が点くまで、あと2時間半だった。
「それで、今朝も一本マージしたんです。計画どおりのファイルだけで」
俺は少し早口になっていた。午後3時半、ライト側の照明塔の根元。外野の芝は昼の日を吸って、まだ少し温かい匂いがした。
独立リーグの小さな球団のフロントで、コードを書けるのは俺しかいない。ほかの日は、問い合わせの電話番と、物販の在庫の数え係もやっている。十日ほど前、売店の画面の番号がずれるのを AI に直させたら、ついでに決済の端数の処理まで手が入っていた。それからは、先に AI に計画を書かせて、人が読んで承認してから書かせる。最後に出てきた差分が計画のファイルに収まっているかを、小さなスクリプトで突き合わせる。経理の槙村さんは、計画に載ったファイルだけを読んでマージするようになった。42ファイルを前に頭を抱えていた人が、今は昼前に読み終える。
その礼を言いに来た。あのとき球場で、放す前の手順を見せてくれた人に。
鷹宮さんは、照明塔の根元からずいぶん離れた芝の端にいた。地面に挿す型の止まり木に鷹がとまっていて、その脇に蓋付きの箱がある。前に会ったときは左腕に分厚い手袋をはめていたけれど、今日はそれを外して、箱の蓋の上に置いていた。
「よかった」
鷹宮さんはそれだけ言った。礼を受け取るのでも、謙遜するのでもない。照明塔の高さでも読み上げるような、平らな声だった。
「あの、前のときの……」
俺は、用意していた話の続きを言いかけて、やめた。鷹宮さんの目は、俺ではなく、照明塔の根元のほうを見ていた。点検の作業員さんたちが脚立を立てて、工具箱を開け、地面にケーブルを這わせている。止まり木と箱は、その脚立から芝の上を二十歩ほど離れたところにあった。照明塔の根元にも、空いている場所はいくらでもあるのに。
「三塁側の、階段」
鷹宮さんが、ふいに言った。この前、鷹宮さんが鷹を拳に乗せて上っていた階段のことだと分かるまで、少しかかった。覚えていてくれたらしい。
そこへ、外野の通路を走ってくる足音が聞こえた。
小此木だった。場内演出の担当で、外野の大型ビジョンの卓を扱っている。アルバイトから契約社員になって一年目。今夜の進行表を、丸めて握りしめていた。
「出ないです」
息を切らしたまま、小此木は言った。
「セレモニーの演出。リハーサルで呼び出したら、引退の映像が出ない。いつもの試合前の流れが出るんです。三回やり直して、三回とも同じでした。先輩のパソコンから呼び出しました」
俺は黙った。
今夜はシーズン最終戦で、引退する選手のセレモニーがある。外野のビジョンに流す演出の台本を、俺はゆうべ10時まで書いていた。書いて、保存した。コミットは、まだだった。朝に見直してからコミットするつもりだった。
鷹宮さんの口は動かなかった。止まり木の鷹のほうへ一歩寄って、箱の脇に立った。
俺と小此木は、バックスクリーン裏のビジョンの操作室へ走った。操作室は三階で、窓から外野の芝と照明塔が見える。卓の横に、俺のノートパソコンが開いたまま置いてあった。
ターミナルを見た。プロンプトに、今いるブランチの名前が出ている。
ai/refund
ゆうべ、俺は ceremony というブランチにいたはずだった。git switch ceremony で戻った。
エディタで演出のフォルダを開いた。台本を書いた新しいファイル src/vision/ceremony.ts が無い。演出を流す順番を並べた src/vision/playlist.ts を開くと、ゆうべ足した引退の映像の一行が無い。
順番に並べてみた。ゆうべ帰る前、雨で中止になった先週の試合の払い戻しの集計を作り直すよう、AI に頼んだ。前回の件があったから、先に計画を書かせて承認した。それに加えて、俺の作業を巻き込まないように、AI 用のブランチ ai/refund を切ってからやって、と頼み添えた。夜のあいだ、俺は何も触らなかった。今朝、AI の作業は終わっていて、PR が上がっていた。差分は計画に載せたファイルだけで、突き合わせのスクリプトは通した。槙村さんが読んで、昼前にマージした。午前中は電話と、最終戦の物販の搬入で、エディタを一度も開かなかった。
照合は、通った。AI の差分には、俺の演出のファイルは一つも入っていなかった。入っていなかったのに、消えた。
「点灯が6時です。セレモニーが6時20分から」
小此木が、進行表を読み上げた。壁の時計は、3時40分を過ぎていた。
窓の外、芝の端で、鷹宮さんが止まり木の鷹を拳に呼んでいるのが、小さく見えた。
第2幕: 再現 ── 同じフォルダにあった
ゆうべの AI との会話は、ターミナルにそのまま残っていた。俺は画面をさかのぼって、最後のほうを読んだ。AI は作業の終わりに、こう報告していた。
テストが失敗していました。原因は今回の作業と関係のない変更(src/vision/playlist.ts と src/vision/ceremony.ts)だったので、元に戻してから作業しました。
その上に、AI が打ったコマンドが並んでいた。
先に、言葉を二つ書いておく。git には、ファイルの状態を一行ずつ短く出す git status --porcelain というコマンドがある。行の頭が M(空白と M)なら、コミット済みのファイルを書き換えたまま、まだコミットしていない変更。?? なら、未追跡のファイル。git にまだ一度も登録していない、新しいファイルのことだ。ゆうべ帰るときの俺のフォルダは、こうなっていたはずだ。
| |
書き換えた playlist.ts と、新しく作った ceremony.ts。どちらもコミットしていない。これが俺の書きかけだった。
AI が打った操作を、順に書き出した。
| 順 | 操作 | 何をしたか |
|---|---|---|
| 1 | git switch -c ai/refund | 新しいブランチ ai/refund を作って、そこへ移る。頼んだとおり |
| 2 | テストを走らせる | 失敗した |
| 3 | git checkout -- . | コミットしていない書き換えを捨てて、最後のコミットの中身に戻す |
| 4 | git clean -fd | 未追跡のファイルとフォルダを消す |
| 5 | src/refund/summary.ts を書く | 払い戻しの集計。計画どおり |
| 6 | git add src/refund/summary.ts | 自分の書いたファイルだけを登録する |
| 7 | git commit | 計画どおりのコミット |
4番目の操作の出力も、画面に残っていた。
| |
AI の側から見れば、筋は通っている。頼まれたのは払い戻しの集計だった。テストが落ちていて、落としているのは頼まれていないファイルだった。だから戻して、自分の仕事をした。そして、自分の書いたファイルだけをコミットした。それで PR の差分はきれいで、照合も通った。
戻せないか、と思った。git には reflog という記録がある。ブランチや、今開いているところ(HEAD)が、過去にどのコミットを指していたかを全部覚えている記録だ。間違えて消したコミットでも、ここからたどれば戻せる、と前に読んだことがあった。
| |
下から読む。main から ceremony へ移った。ceremony から ai/refund へ移った(ゆうべの AI)。AI がコミットした。ai/refund から ceremony へ戻った(さっき、操作室で俺が戻した)。下から二つ目の reset の行は、先週、俺が別の作業を git stash で一時的によけたときに残った記録だ(stash のことは、あとで書く)。
HEAD の動きは全部ある。でも、俺の書きかけはどこにも無かった。
git には、次のコミットに入れるものを置いておく控えがある。index(ステージ)と呼ばれていて、git add するとそこに入る。俺はゆうべ、書きかけを git add していなかった。コミットもしていない。一度も git の記録にならなかったものは、git からは戻せない。git checkout -- . が捨てるのは、その控えに入っていない書き換えだ。書き換えた playlist.ts は、ちょうどそこにあった。新しく作った ceremony.ts のほうは、そもそも git が知らないファイルで、git clean -fd がそのまま消した。
ノートパソコンの蓋を半分閉じかけて、手が止まった。閉じたところで何も戻らない。開き直した。
書き直すしかない。点灯まで2時間と少し。台本の中身は、だいたい頭に残っている。ただ、何が悪かったのかを分からないまま書き直せば、今夜もう一度 AI に集計の残りを頼むとき、同じことがまた起きる。
「ブランチって、分けるためのものじゃないんですか」
小此木が聞いた。卓の前の椅子に浅く座って、進行表を膝の上で伸ばしていた。
俺も、そう思っていた。だから AI 用のブランチを切らせた。
確かめるために、ゆうべの任せ方を小さなコードに書き起こすことにした。AI が作業した場所はどこだったのか。それを、本物の git で一つずつ確かめたかった。
まず、AI がすることと、俺が見たいものの形を types.ts に決めた。
| |
Op は AI の操作一つ分で、| は「どちらかの形」という意味の TypeScript の書き方だ。git のコマンドを打つか、ファイルを書くか。本物の AI はここでは呼ばない。ゆうべの会話から拾った操作を、固定の列(fixture)にして順に流す。Snapshot は、俺のフォルダ(このあと「本体」と呼ぶ)の様子を写したもの。git status --porcelain の出力と、見張りたいファイルの中身を持つ。string | null は、中身の文字列か、ファイルが無いことを表す null のどちらか。
それから、ゆうべの俺の任せ方を before.ts に。
| |
gitOut は、git を走らせて出力を受け取る小さな手伝いの関数で、git.ts に置いた。このテスト用の git は、俺のパソコンの git の設定を読まないようにしてある。人の設定で結果が変わらないように。
書いてみて、手が止まった。prepareWorkdir が返しているのは、repo。本体のパスそのものだった。
AI 用のブランチは、俺のフォルダの中で切られていた。git switch -c は、今開いているところから新しいブランチを作って、そこへ移るコマンドだ。このとき、ファイルには一切手を付けない。コミットしていない書きかけは、どのブランチにも属していない。フォルダ(作業ツリー)に置いてあるだけだ。だから、ブランチの名前が ai/refund に変わっても、書きかけはそのままフォルダに残って、まるごと AI の足元にあった。AI が作業した場所は、俺が書きかけを置いていた場所と同じだった。
もしゆうべの AI が、自分のファイルだけでなく全部を git add していたら、書きかけは消えなかった代わりに、AI のコミットに混ざっていただろう。
AI の操作を流す関数と、本体の様子を写す関数を ops.ts に書いた。
| |
runOps は、dir の中で、操作の列を上から順に流す。'write' in op は、その操作が write という名前の項目を持っているか、つまりファイルを書く操作かを見ている。そうでなければ git の操作で、git(...) で走らせる。git は gitOut と同じ git.ts にある手伝いで、出力だけでなく、終了コード(status)とエラーの出力(stderr)もそのまま返す。git が失敗したら、その場で例外を投げて止まる。log?.push(...) の ?. は、log が渡されていなければ何もしない、という書き方。snapshot は git status --porcelain の出力と、ファイルの中身(無ければ null)を集める。
ゆうべの AI の操作は、fixtures.ts に固定の列として置いた。1番目の switch -c は prepareWorkdir の側でやるので、列は3番目からになる。
| |
summaryNight は集計のファイルの中身の文字列。commitSummary は、そのファイルだけを git add してコミットする、二つの操作を返す小さな関数だ。
テストでは、一時的なフォルダに小さなリポジトリを作る。main に最初のコミットを置き、ceremony を切って、playlist.ts を書き換え、ceremony.ts を新しく作る。git add はしない。ゆうべの俺と同じ状態だ。それを作る makeRepo を repo.ts に置いた。見張るファイルの一覧 WIP_FILES も同じところにある。
そして、確かめたいことを一本のテストにした。
| |
最初に本体の様子を写して、書きかけが本当に二つともあることを assert.match で確かめる。この確かめが無いと、最初から書きかけが無い状態で比べて、何も起きていないのに緑になりかねない。それから、prepareWorkdir が用意した場所に夜の操作を流す。最後に、本体の様子をもう一度写して、最初と同じかを確かめる。assert.deepEqual は、中身まで突き合わせて、違えばその場で赤にする関数だ。
走らせた。
| |
赤だった。ゆうべと同じことが、テストの中で起きた。
「分けたのは、名前だけだったんだ」
小此木に向かって言ったけれど、半分は自分に言っていた。
「ブランチは、フォルダに今どの中身を広げているかの名前みたいなもので、フォルダ自体は一つしかない。切り替えれば、コミットした中身はそのブランチのものに入れ替わる。でも、コミットしてない書きかけは、フォルダに残ったまま、次のブランチでもそこにある。AI がブランチを切っても、作業してたのは俺のフォルダの中だった」
小此木は、進行表を見たまま小さくうなずいた。分かったのかどうかは、顔からは読めなかった。
点灯まで、あと2時間。書き直す前に、どうしても聞きたいことがあった。何を聞きたいのか、自分でもはっきりしないまま、小此木に卓の準備を頼んで、俺は照明塔の根元へ戻った。
鷹宮さんは、鷹を拳に据えて、外野の芝の上に立っていた。腕を振ると、鷹は芝すれすれを低く一回りした。外野席の手すりに並んでいたハトの群れが、一斉に立って、バックスクリーンの向こうへ流れていく。鷹は弧を描いて、拳に戻った。
「どうして、止まり木をわざわざあそこまで運んだんですか」
俺は芝の端の止まり木と箱を指した。
「根元にも、置く場所はいくらでもあるのに。作業員さんたちだって、5時には点検を終えて帰るんでしょう」
夜は触らない、の俺の理屈と同じだった。言ってから、そう気づいた。
鷹宮さんは、拳の上の鷹の脚の紐を指で確かめながら言った。
「置いたものは、いない間もそこにある」
俺が何か言い返す前に、鷹宮さんは照明塔の根元を、順に目で示した。脚立の脚。地面を這うケーブル。開いたままの工具箱。
「いつ、置く場所を決めたんですか」
「来たとき。脚立の向きと、箱の場所を見てから」
止まり木を置く前に、場所を決めていた。
鷹宮さんがその場を離れても、止まり木はそこにある。作業員さんたちが脚立を動かすとき、ケーブルを巻き取るとき、そこに置いてあるものは、どかされる。どかす人に悪気はない。自分の仕事の邪魔だから、どかすだけだ。俺が触っていない夜のあいだも、書きかけは、俺のフォルダに置いてあった。時間を分けても、置いた場所は分かれていなかった。
ポケットで電話が鳴った。小此木だった。卓の準備ができた、もう4時を過ぎた、と言う。
せめて中身だけでも、と言いかけた。それを口に出す前に、俺は操作室へ走った。
第3幕: 手順を整える ── フォルダごと
操作室に戻ると、もう一本電話が入った。槙村さんだった。
「払い戻しの集計、外野席の分がまだなの。窓口は5時に開く。外野席のお客さんが来はじめる6時までには要る」
ゆうべの AI は、内野席の分までで止まっていた。残りをもう一度 AI に頼まなければならない。そして俺は、その間にセレモニーを書き直さなければならない。二つを、同時に。
「任せる前に、書きかけをコミットしておけばいいんだ」
小此木に向かって、自分を納得させるように言った。場所を分けるやり方は、まだ知らない。だからせめて、中身だけでも。
「コミットしてあれば、AI が何を戻しても git から戻せる」
これは本当だ。コミットしたものは git の記録になるから、さっきの reflog にも残る。間違えて戻されても、記録をたどれば取り戻せる。ゆうべ消えたのは、コミットしていなかったからだった。小此木は、分からないなりに、うなずいた。
書きかけの台本を少し打ち込んで、コミットしようとして、手が止まった。
コミットして、AI に ai/refund を切らせて任せたら、このフォルダは ai/refund になる。さっきターミナルに出ていた、あのブランチの名前だ。AI が走っている間、俺はどこで ceremony を書けばいいのか。俺が ceremony へ切り替えれば、AI の足元のフォルダの中身ごと入れ替わる。AI が書きかけた集計のファイルが、作業の途中で目の前から消える。それに、俺の書きかけのコミットの上から AI のブランチを切れば、そのコミットが AI のブランチに乗って、前回の照合を通らない。
どこで書けばいいのか。答えが出ないまま、手が止まった。
窓の外を見た。照明塔の根元で、作業員さんが鷹宮さんに何か言っている。俺は小此木に、書き直しの段取りの確認を頼んで、もう一度走った。4時15分だった。
照明塔の根元では、作業員さんたちが脚立を畳み始めていた。年配の作業員さんが、工具箱の蓋を閉めながら、鷹宮さんに言った。
「点検、終わったよ。カバー掛けとけば、こっちに置いてもよかったのに。邪魔にはしないから」
鷹宮さんは軽く頭を下げた。止まり木と箱は、芝の端のまま動かなかった。
「中身が守れるなら、カバーで足りるんじゃないですか」
俺は聞いた。
「掛けても、同じ場所」
鷹宮さんの目は、畳まれていく脚立と、芝の端の止まり木を行き来していた。
作業員さんたちが脚立を畳んで、ケーブルを巻き取っている間も、芝の端では鷹宮さんの仕事が止まらずに進んでいた。鷹は止まり木で待ち、箱は閉じたまま、誰の手も通らない。同じ場所に置いていたら、作業員さんたちが片付け終わるまで、鷹宮さんは待っていただろう。カバーは中身を守る。でも、待たずに済ませてはくれない。場所が違うから、二つの仕事が同時に進んでいる。
さっきの、どこで書けばいいのか、という問いに、手が届きかけた気がした。
鷹宮さんは、照明塔の上のハトを追う準備を始めた。点灯までに、塔のほうの仕事を終える。それが球団との取り決めだと、運営部の人から聞いていた。準備の前に、鷹宮さんは箱の蓋の裏からカードを一枚出して、ペンで短く何か書き、俺に差し出した。
「あとで」
俺は読まずに、カードをジャンパーのポケットに入れた。読む時間が惜しかった。鷹宮さんは止まり木と箱を抱えて、照明塔の根元へ歩いていった。俺は操作室へ戻った。
場所を分ける。それを git でやる方法を調べた。
先に、言葉を一つ。作業ツリーは、ブランチの中身を展開して、ファイルとして触れるようにしたフォルダのことだ。俺がエディタで開いているのは、作業ツリーのファイルだ。普通、一つのリポジトリには、作業ツリーが一つしかない。だから、ブランチを切り替えると、その一つの作業ツリーの中身が入れ替わる。
git worktree は、一つのリポジトリに、作業ツリーを二つ以上持たせる仕組みだ。ブランチごとに、別のフォルダを持てる。ファイルを展開する場所が、フォルダごとに別になる。git の公式のマニュアルの冒頭には、こう書いてあった。
A git repository can support multiple working trees, allowing you to check out more than one branch at a time.
(git-worktree のマニュアル、2026年10月29日参照)
一つのリポジトリで、作業ツリーを複数持てる。一度に、二つ以上のブランチを開ける。
Claude Code の公式の Best practices にも、並行して作業するときの節に同じ考えが書いてあった。
When you have separate pieces of work going at once, give each its own checkout so the edits can’t collide.
(Claude Code docs「Best practices」、2026年10月29日参照)
同時に進む別々の作業には、それぞれのチェックアウト(作業ツリー)を持たせて、編集がぶつからないようにする。
なぜそれで守れるのか。一段ずつ並べた。git は、コマンドを打ったフォルダを起点に、どの作業ツリーを相手にするかを決める。worktree は、フォルダごとに、今どのブランチを開いているか(HEAD)と、次のコミットの控え(index)を別々に持つ。だから git checkout -- . も git clean -fd も、触るのは、そのコマンドを打ったフォルダの作業ツリーだけだ。AI が別のフォルダで打てば、戻るのはそのフォルダのファイルだけになる。俺のフォルダの書きかけは、そのコマンドからは届かない場所にある。ブランチを切り替える git switch も、打ったフォルダの HEAD とファイルだけを動かす。AI が向こうで何を切り替えても、俺のフォルダの HEAD は ceremony のままだ。
「つまり、AI に別の部屋を用意するってこと」
小此木に言うと、小此木は顔を上げた。
「同じ家の、別の部屋。散らかすのは自分の部屋だけ」
自分で言いながら、少し雑なたとえだと思った。でも小此木は、そこで初めて、分かったという顔でうなずいた。
after.ts に、AI の作業場所を用意する関数を書いた。
| |
git worktree add -b ai/refund <場所> main は、新しいブランチ ai/refund を作り、それを <場所> のフォルダに広げる。最後の main は、どこから分かれるかの指定だ。書かなければ、今開いているところ(HEAD)から分かれる。俺が自分の作業ブランチで書きかけをコミットしていたら、AI のブランチはその上に乗ってしまう。だから main と書いておく。
場所は、リポジトリの外にした。dirname(repo) はリポジトリのフォルダの一つ上、basename(repo) はリポジトリのフォルダの名前だ。front-tools というリポジトリなら、その隣に front-tools-ai-refund というフォルダができる。replaceAll('/', '-') で、ブランチ名の / を - に置き換えている。リポジトリの中に作ると、そのフォルダが本体の git status に未追跡のフォルダとして出てしまう。せっかく分けたのに、本体の様子が変わったように見える。
ゆうべの任せ方を写した prepareWorkdir と、引数も返り値も同じ形にした。違うのは、AI にどこで作業させるか、その一点だけにしたかった。AI に流す操作の列は、一字も変えない。
さっきの赤いテストを、prepareWorkdir を prepareWorktree に替えて書いた。テストの中で、もう一つ確かめることを足した。AI のフォルダが本体の外にあること。
| |
realpathSync は、パスの別名をたどって、実体のパスにそろえる関数だ。俺の使っている macOS では、一時的なフォルダが /var/... と /private/var/... の二つの名前を持っていて、そろえないと同じ場所なのに違うと判定される。startsWith は、文字列が、渡した文字列で始まっているかを見る。本体のパスに / を足したもので始まっていなければ、本体の中ではない。
夜の操作の列は、さっきと一字も変えていない。checkout -- . も clean -fd も入っている。それを、AI のフォルダで流す。
緑だった。
赤と緑を並べてみた。AI に流したものは一字も変わらない。変えたのは、それが流れる場所だけだった。

もう一本、書いておきたいテストがあった。AI がゆうべ、戻す操作をせずに、正しく仕事だけをしていたらどうなるか。
| |
correctOps は、ゆうべの列から戻す二つの操作を抜いたもので、集計を書いて、自分のファイルだけを登録して、コミットする。それを、ゆうべの任せ方(b)と、別のフォルダの任せ方(a)の両方で流す。
git rev-parse ai/refund^{tree} は、ai/refund の最新のコミットが持っているファイル一式の中身を、一つの識別子にして出す。二つが同じなら、AI のブランチに積まれたものは同じ中身だ。正しい仕事なら、どこで作業させても、AI の成果物は変わらない。別のフォルダにしたことで、正しい仕事を邪魔してはいない。
違うのは、本体のほうだけだ。git branch --show-current は、今開いているブランチの名前を出す。ゆうべの任せ方では、正しい仕事をしただけでも、本体のフォルダが ai/refund に移っている。別のフォルダなら、本体は ceremony のまま。assert.notDeepEqual は、二つが違うことを確かめる関数だ。この違いは、わざと残した。これがそのまま、さっきの「どこで書けばいいのか」への答えになっていた。
AI の作業場所を作るのを、毎回手で打たずに済むように、delegate.ts にまとめた。
| |
process.argv[2] は、コマンドに渡した最初の引数。process.cwd() は、コマンドを打ったフォルダ。本体のフォルダで node delegate.ts ai/refund と打つと、本体の隣に worktree ができて、そのパスが1行出る。
これを走らせて、出てきたパスに移ってから、そこで Claude Code を起動する。AI に「ブランチを切ってからやって」と頼むのは、やめた。ブランチと場所は、人が先に用意する。どこで起動するかを決めるのは、俺の手順だ。
新しいフォルダに移って、テストを走らせようとしたら、依存のパッケージが無いと言われた。新しい worktree には、コミットされているファイルしか来ない。node_modules も、.env のような設定のファイルも、コミットしていないから来ない。Best practices にも、そのとおり書いてあった。
A worktree is a fresh checkout, which means gitignored files like .env aren’t there and dependencies aren’t installed.
npm ci で依存を入れ直し、.env を本体から写した。それから、AI を起動して、外野席の分の集計を頼んだ。計画を書かせて、承認して。前回からの手順は同じだ。4時半だった。
一つ思いついたことがあった。AI が書き込める範囲を、作業しているフォルダに絞る仕組みもある。でも、絞った先のフォルダに俺の書きかけが置いてあったら、ゆうべと同じことになる。囲いをどれだけ狭くしても、その囲いの中を二人で使っていたら、同じだ。
俺は本体のフォルダに戻って、セレモニーの台本を書き直し始めた。卓の前で、小此木が進行表を読み上げた。開門5時、点灯6時。
5時になった。開門の音楽が鳴って、外野席に人が入り始める足音が、操作室の窓越しに聞こえた。窓の外、照明塔の根元に鷹宮さんが小さく見えた。鷹が塔の照明の枠まで飛び上がっては、鷹宮さんの拳に戻ってくる。そのたびに、塔の上のハトが散っていく。
台本の書き直しは、7割ほど進んでいた。セレモニーの映像が客席からどう見えるかを確かめたくて、小此木に卓からテスト表示を出してもらい、俺は外野席へ出た。通路の途中で、照明塔のほうから戻ってくる鷹宮さんと行き合った。
書き直しの目処が立って、俺は少し気が緩んでいた。
「これで、AI が向こうの部屋で何を打っても、こっちのコマンドには何も出てこないんです」
言い切った。
鷹宮さんは、うなずきも、首を振りもしなかった。照明塔の上の空を見上げた。塔を離れたハトの群れが、外野席の上を大きく回っていた。客席に入ってきた人たちの頭の上を、鷹が飛んだばかりの、同じ空を。
何を見ているのか、俺には分からなかった。分からないまま、時間が無くて、操作室へ戻った。
台本の最後に、試合の速報の文言を流す。その文言を、先週一度直しかけて、git stash でよけておいたのを思い出した。stash は、コミットしていない変更を一時的によけて、作業ツリーを最後のコミットの状態に戻すコマンドだ。よけたものは、git stash list で一覧が見えて、git stash pop で一覧の先頭を戻せる。
pop を打とうとして、念のため、先に一覧を出した。十日ほど前の件から、打つ前に一覧を確かめる癖がついていた。
| |
手が止まった。
二行目が俺のだ。先週、main でよけた速報の文言。でも一行目は、俺がよけたものじゃない。On ai/refund。向こうのフォルダで、AI がテストの前に、途中の変更をよけて、そのままにしたものだ。
pop は先頭を戻す。そのまま打っていたら、AI の作業の途中の変更が、書き直したばかりの俺の台本の上にかぶさっていた。
向こうで何を打っても、こっちには出てこない。さっき、そう言った。
マニュアルを開き直した。さっきは冒頭の一文しか読んでいなかった。add の節に、こう書いてあった。
The new worktree is linked to the current repository, sharing everything except per-worktree files such as
HEAD,index, etc.
新しい worktree は今のリポジトリにつながっていて、HEAD や index のような worktree ごとのファイル以外は、全部を共有する。
続けて、こうも書いてあった。
In general, all pseudo refs are per-worktree and all refs starting with
refs/are shared.
ref は、コミットに付けた名札のようなものだ。git はそれを .git の中にファイルとして置いている。ブランチの名札は refs/heads/ の下にある。HEAD のように refs/ の外に置かれた名札だけが、フォルダごとに分かれる。refs/ で始まる名札は、共有される。stash という言葉は、マニュアルのその段落には出てこない。でも、stash の一覧も、git の中では名札の一つとして持たれていて、それがどこに置かれているかは、git に聞けば分かる。本体のフォルダで git rev-parse --git-path refs/stash と打つと、git がその記録を置いている場所を出す。
| |
本体の .git の下の refs/stash。refs/ の下だ。向こうのフォルダにも .git はあるが、それはフォルダではなく、本体の .git の中の置き場所を書いた1行のファイルだ。だから向こうのフォルダで同じコマンドを打っても、たどり着くのは同じ refs/stash になる。
分けたのは、ファイルを広げる場所だけだった。記録の棚は、一つのままだった。
鷹宮さんが見上げた空は、塔の上も、芝の端も、客席の上も、一つだった。
並べ直した。worktree で分かれるのは、作業ツリーと、そこで今開いているブランチ(HEAD)と、次のコミットの控え(index)。分かれないものは、こうだった。
- stash の一覧。どのフォルダでよけても、一つの一覧に積まれる
- ブランチ。片方のフォルダで消したブランチは、もう片方からも消える
- リポジトリの設定(
git config)と、決まった時点で走るスクリプト(hooks) - リモートのブランチへの push。二つのフォルダから同じリモートのブランチに push すれば、そこで取り合いになる
- フォルダの外にあるもの。データベース、ポート、外部の API。二つのフォルダで、それぞれテスト用のサーバーを同じポートで立てようとすれば、ぶつかる。フォルダを分けても、フォルダの外のものは分かれない
この事実をテストに一本残した。
| |
afternoonOps は、午後の AI の操作の列だ。worktree の中で途中の変更を git stash push -m "テストの前に退避" でよけて、戻さないまま集計を書き直してコミットする。-m は、よけたものに付けるメッセージ。それを流したあと、本体の stash の一覧の一行目が AI のもので、二行目が俺のもの。endsWith は、その文字列で終わるかを見る。
俺は自分の stash を、番号を指定して戻した。git stash pop stash@{1}。一覧の二番目、俺の速報の文言だけを戻す。AI がよけたものは、AI の作業が終わってから、向こうのフォルダで片付けることにした。
5時40分、書き直しが終わった。小此木が卓から呼び出すと、外野のビジョンに、背番号7の映像が出た。小此木は何も言わずに、進行表の「セレモニー」の欄に、ペンで小さく丸をつけた。
第4幕: 検める ── 本体を指せば届く
5時50分、向こうのフォルダの AI から、外野席の分の集計が上がってきた。前回の照合を通す。計画どおりのファイルだけだった。槙村さんに送って電話すると、外野席のお客さんが来はじめる前に間に合った、手で数えた分とも合っていた、とだけ返ってきた。
もっとも、今夜うまく収まったのは、AI がたまたま行儀よく作業したからでもある。次も同じとは限らない。
テストを全部流した。
| |
6本、全部緑。さっきの3本に、もう3本を足してある。
一本は、同じブランチを二つのフォルダで開こうとしたらどうなるか。
| |
ai/refund を開いた worktree があるところに、もう一つ別のフォルダで ai/refund を開こうとする。git は断って、終了コード128で止まる。終了コードは、コマンドが最後に返す番号で、0 なら成功。128 は、git が「その操作はできない」と言って止まったときの番号だ。エラーの文はこうだった。
| |
(git 2.56.0 で確かめた出力。パスは置き換えた)
マニュアルにも、そう書いてある。
If
<branch>does exist, it will be checked out in the new worktree, if it’s not checked out anywhere else, otherwise the command will refuse to create the worktree (unless--forceis used).
そのブランチがほかのどこかで開かれていれば、git は worktree を作るのを断る。二つの AI に同じブランチを渡そうとしても、git が先に止めてくれる。
もう一本は、main から分かれることの確かめだ。本体の ceremony に書きかけのコミットがあっても、AI のブランチの一つ前のコミットは main になっている。最後の一本は、delegate.ts を本当に子プロセスとして走らせて、本体の隣に worktree ができ、そこで ai/refund が開かれていて、本体の様子が変わっていないことを確かめる。
緑が並ぶと、かえって落ち着かない。わざと壊してみた。prepareWorktree が、worktree をリポジトリの中のフォルダに作るように変えると、「本体の書きかけは残る」が赤くなった。本体の git status に、未追跡のフォルダが一行増えるからだ。ほかにも、worktree を作らずに本体で切り替えるようにする、main を書かずに今のところから分かれるようにする、snapshot が未追跡のファイルを見ないようにする、と壊すたびに、どれかのテストが赤くなった。壊したところに、ちゃんとテストが噛みついた。
5時55分、小此木が卓の前で、ビジョンの明るさを確かめながら聞いた。
「さっきの、別の部屋の話ですけど」
画面から目を離さないまま、小此木は続けた。
「その部屋から、AI が自分で出てきちゃったら、どうなるんですか」
「守れない」
俺は正直に答えた。
worktree が分けるのは、AI がその中にいる間だけだ。AI が本体のフォルダへ移って git を打てば、本体の書きかけを戻せてしまう。しかも、出てこなくても届く。向こうのフォルダにいたまま、git -C <本体のパス> のように本体を指して git を打てば、本体で打ったのと同じになる。cp でファイルを写したり、> のようなリダイレクトで本体のパスに書き込んだりしても、本体に届く。部屋から出なければ安全、ではない。本体を指せば、届く。
実際に、そういう報告がある。Claude Code の GitHub の issue で、題は Agent destroyed 2 days of uncommitted work via destructive git operation in main worktree。報告者は、こう書いていた。
That agent attempted a worktree approach but backed out into the main working directory
worktree で作業しようとした AI が、途中で本体の作業ディレクトリに戻り、そこで git reset --hard origin/main を打って、二日分のコミットしていない作業を消した。これは報告者本人の記述で、再現が確かめられたわけではない。報告は、しばらく動きがなかったために、自動で閉じられている。直ったから閉じられたわけでもない。
道具の側にも、手当てはある。ここから先は道具ごとの決まりで、いつ変わるか分からない。今日の日付と一緒に書いておく。
2026年10月29日時点の Claude Code の設定(公式ドキュメントの worktrees・best-practices で確認)。
claude --worktree <名前>(または-w)で起動すると、Claude Code が.claude/worktrees/<名前>/に worktree を作り、worktree-<名前>という名前のブランチを作り、その中で動く。分岐元は、何も指定しなければリモートの既定のブランチ(worktree.baseRefの既定値"fresh")で、本体でまだ push していないコミットも持ち込まない。この記事の手順(素の git でai/refundをmainから作る)とは、ブランチの名前も分岐元も違う。.envのように git に無視させているファイルは、プロジェクトの一番上に.worktreeincludeを置くと、そこに書いたものに一致するファイルだけを写してくれる(書き方は.gitignoreと同じ)。Claude Code が作った worktree のセッションでは、本体のファイルの編集、本体を作業ディレクトリにするコマンド、git -Cなどで本体を指す git を、Claude Code が止める。ただし、公式は防がないものもはっきり書いている。None of them tracks which files a shell command writes, so a command that writes into the main checkout without running git there, such as cp or a shell redirect, isn't refused by them.(シェルのコマンドがどのファイルに書くかは追っていないので、cpやリダイレクトで本体に書き込むものは拒否しない)。手でgit worktree addしたフォルダで普通に起動したときに、同じ止め方が効くかどうかは、確かめたページには書かれていなかった。対話なし(-p)で--worktreeを使うと、終わっても worktree を片付けない。
この手順が守れないものを、小此木の問いの続きのつもりで、指を折って数えた。
AI が worktree の外の本体を指して打つ操作は、守れない。いま言ったとおりだ。
共有されるものは、一つのままだ。stash の一覧も、ブランチも、設定も、リモートへの push も。さっき並べたものは、worktree を分けても分かれない。
本体の書きかけそのものも、守れない。俺が本体で git checkout -- . を打てば、俺の書きかけは消える。worktree は、AI と俺の作業場所を分けるだけで、俺の書きかけを俺の手から守るわけじゃない。だから、本体の書きかけのコミットは、やめなくていい。
最後に、テストの緑が言えることの幅。俺が用意した操作の列を、手元の git 2.56.0 に流したらこうなった、までだ。git や Claude Code の仕様が変われば、この手順は何も言わずに効かなくなるかもしれない。そのときもテストは緑のまま並んでいて、本物の道具の上では別のことが起きている、ということがありうる。
後片付けのことも書いておいた。AI の作業がマージされたら、git worktree remove <パス> で向こうのフォルダを消す。中にコミットしていない変更や未追跡のファイルが残っていると、git は断る。
| |
--force を付ければ消せるが、中身はフォルダごと消えて戻らない。フォルダを消しても、ai/refund のブランチは残る。ブランチを消すのは git branch -d で、別の手順だ。フォルダを git worktree remove を使わずに手で消してしまったら、git の側に記録だけが残るので、git worktree prune で掃除する。
6時。照明が点いた。
外野の芝が、急に白く浮かび上がった。操作室の窓から照明塔の根元を見た。芝の端に、止まり木も、蓋付きの箱も、もう無かった。塔の上にも、誰もいなかった。いつ帰ったのか、俺は見ていなかった。
俺は、二つのフォルダと、その下の一つの記録を、図にしておいた。小此木に見せるためでもあった。

向こうの部屋から本体を指す線だけは、別の色で一本引いた。小此木の「出てきちゃったら」は、この線のことだった。
6時20分。外野のビジョンに、セレモニーの映像が流れていた。背番号7の選手が、グラウンドの真ん中で帽子を取っている。客席から拍手が起きた。小此木は卓の前で、映像を切り替えるつまみに指を置いたまま、ビジョンから目を離さなかった。
ジャンパーのポケットに手を入れて、紙の角に指が当たった。照明塔の根元で受け取ったまま、忘れていたカードだった。
初めて読んだ。いちばん上に名前。短い字で四行。五行目は、見出しの言葉だけで止まっていて、その先が空いていた。
俺は、小此木の進行表の裏の固い台紙を借りて、その上にカードを置き、五つ目の欄を埋めた。
放鷹カード
- 合図: git worktree。AI にブランチを切らせるのではなく、人が先に別のフォルダ(worktree)を main から作って、そこで任せる
- 検め方: 任せる前と後で、本体の
git statusと、書きかけのファイルの中身(git diff)を見比べる - 止めないもの: AI が自分のフォルダの中で、自分のファイルだけを書いてコミットする作業。そのコミットの中身は、同じフォルダで任せたときと変わらない
- 鷹のせいじゃなかった: 書きかけを置いた場所で、AI に作業させていた
- 自分の言葉で: 中身はコミットで守る。場所はフォルダで分ける。記録の棚は、どこからでも一つ
