以前、fzfでworktreeとブランチを一括整理する4本のエイリアスを作った話を書いた。マージ済みworktreeをgh pr viewの状態で判定してwrm一発で消す、という仕組みだ。作った当初は「これで片付けは解決した」と思っていた。
ところが数週間運用していると、wrmを打っても一部のworktreeだけ毎回「Skip (locked)」と表示されて残り続ける現象が出てきた。マージ済みで、PRもクローズ済み。消していい条件は揃っている。なのに一律スキップされる。
原因をgit worktree list --porcelainで覗いて分かった。ロックを取得したはずのプロセスが、もうこの世にいない。 本人はいなくなったのに鍵だけ握って離さない、ゾンビのようなロックだった(消せない側の身にもなってほしい)。一括整理の仕組みを作った後にも、「ロックだから消せない」という別種の詰まりが残っていた、という話をする。
この記事で学べること
git worktree list --porcelainのlockedフラグとロック理由からpidを抜き出す方法kill -0でpidの生死を確認し、死んでいれば安全にunlockする判定ロジック- 手動ロック(pid情報のないロック)を誤って解除しないための線引き
- stale lock対応を入れた後のテストケース設計
前提条件
- git worktreeを使った並列開発をしている(複数のworktreeを同時に開いて作業する運用)
- worktreeに
locked状態がつくケースがある(後述) - awk・bashの基本操作、
git worktreeコマンドの一般的な使い方
なぜロックが残るのか
git worktree lockはworktreeを「削除禁止」にする仕組みで、ロック理由の文字列を添えられる。人間が手動でgit worktree lock <path> --reason "作業中"と打つ以外に、worktreeを扱う仕組みがセッション単位で自動的にロックをかけて、終了時に自分で外す、という使い方もできる。
問題はこの「終了時に自分で外す」の部分だ。セッションが正常終了せずに落ちると、ロックを外す処理そのものが実行されない。ロック理由の文字列に(pid 12345 ...)のようにプロセスIDを埋め込んでおけば、後から「このロックを握ったプロセスは今どうなっているか」を追跡できるが、埋め込んでおかなければ、ロックだけが未来永劫残る。実際、複数のworktreeを並行してAIエージェントに振る運用をしていると、エージェントのセッションが異常終了してロックだけ残るケースが複数件確認された。マージ済みworktreeが消えずに溜まっていく、という一括整理の仕組みを作ったはずの状況に、また戻ってしまう。
実装方法
ロック判定を分岐させる
これまでのwrmは「lockedならSkip」の一択だった。ここに「pidが死んでいるかどうか」の分岐を追加する。
3つの分岐がポイントだ。ロックにpid情報がない場合(人間が手動でロックした場合)は無条件でスキップする。pidがあっても生きていればスキップする。pidがあって、かつそのpidがもう存在しない場合だけunlockする。
porcelain出力からpidを抜き出す
git worktree list --porcelainは、ロックされたworktreeについてlocked行を出す。ロック理由を指定していればlocked <reason>という形式になり、seed実装では(pid <N> ...)という形式の理由文字列を仕込んでいる前提でパースする。
git worktree list --porcelain | awk '
BEGIN{RS=""}
{
p=""; b=""; l=0; pid="-"
for(i=1;i<=NF;i++){
if($i=="worktree") p=$(i+1)
else if($i=="branch"){ b=$(i+1); sub("refs/heads/","",b) }
else if($i=="locked") l=1
else if($i=="(pid") pid=$(i+1)
}
if(b) print p, b, l, pid
}'
porcelainのブロックは空行区切りなのでRS=""で1worktree分をまとめて処理し、フィールドを順に見ていく。lockedという単語が出てきたらフラグを立て、(pidという単語の次のフィールドを見ればプロセスIDが取れる——ロック理由の文字列の中に(pid 12345 start ...)という形式で埋め込まれている前提のパースだ。
kill -0でpidの生死を確認する
pidが取れたら、そのプロセスがまだ生きているかを確認する。ここで使うのがkill -0。
if [ "$pid" != "-" ] && ! kill -0 "$pid" 2>/dev/null; then
git worktree unlock "$p" && echo "Unlocked (stale pid $pid): $b"
else
echo "Skip (locked): $b"
fi
kill -0はシグナル番号0を送る指定で、実際には何のシグナルも送らない。bashのbuiltinとして実装されていて、「そのpidのプロセスが存在し、シグナルを送る権限があるか」だけを確認する。プロセスが存在しなければ非ゼロで終了するので、! kill -0 "$pid"が真になったときだけ「死んでいる」と判定してunlockする。
pidを再利用して別プロセスが同じ番号で立っている可能性はゼロではないが、kill -0は「自分の権限で送信できるプロセスか」も見ているので、元のセッションと無関係な他ユーザーのプロセスが同じpidを引き継いでいた場合でも、権限エラーで失敗=unlock対象、という判定になる。手元の開発マシン1台で動かしている前提なら、この程度の粒度で十分実用に足りる。
手動ロックは尊重する
pidが読み取れないロック——つまり人間が--reasonなしで、あるいはpid情報を含まない理由文字列でロックした場合——は、無条件でSkipにする。ここを自動unlockの対象に含めると、「意図的に残している調査用worktree」まで消えてしまう事故につながる(自動化が便利になった分だけ、事故ったときの被害範囲も広くなる)。stale lock対応は「死んだプロセスの後始末」に限定し、人間の意思表示はそのまま尊重する設計にした。
動作確認
改善後、以下のケースをテストリポジトリで確認した。
| ケース | 結果 |
|---|---|
| stale pidロック(プロセス死亡) | Unlocked → Removed |
| 生存pidロック | Skip (locked) |
| pidなし手動ロック | Skip (locked) |
| ロックなし・PRなし | Removed |
| current / mainのworktree | Skip |
「生存pidロック」と「pidなし手動ロック」がどちらもSkipになることを確認しておくのが重要だ。stale lock対応を入れたことで、それまで安全に保護されていたロックまで解除されるようになっていないか——ここを見落とすと、機能追加のつもりが安全装置の後退になる。
注意点・Tips
- ロック理由の文字列形式に依存する:
(pid N ...)という形式で埋め込まれていることを前提にパースしている。ロックをかける側の仕組みが理由文字列のフォーマットを変えると、pid抽出が効かなくなり、全部「pidなし」として保護的にSkipされる(危険な方向ではなく安全側に転ぶのは救い)。 kill -0は信号を送らない: 名前から誤解しやすいが、実際にプロセスを終了させる操作ではない。生死確認と権限確認だけの副作用なしのチェックだ。- 1台のマシンで完結する運用が前提: 複数マシン・複数ユーザーでworktreeを共有するような環境では、pidの意味が変わってしまうので、この判定ロジックはそのままでは持ち込めない。
まとめ
一括整理の仕組みを作っても、「ロックされていて消せない」という別種の詰まりは別問題として残っていた。原因は、ロックを握ったプロセスが死んでいるのにロックだけが残るstaleロック。git worktree list --porcelainからpidを抜き出し、kill -0で生死を確認して、死んでいれば自動でunlockしてから削除判定に進む——このひと手間を足すだけで、wrmが本来やりたかった「片付けを自動化する」という目的に一段近づいた。
pidが読み取れない手動ロックは今回もそのまま保護している。自動化を足すたびに「どこまで自動でやっていいか」の線を引き直すのは地味な作業だが、この線引きを省くと痛い目を見るのは大体自分自身だ。



