受託のサイト修正と、手元のアプリ側の小さな移行を、同じ機械で並行して走らせていた週でした。金曜の夜に空き容量を眺めましたら、思っていたより二桁少ない数字が出ておりました。
原因を探して git worktree list を叩きましたら、見覚えのない行が六つ並んでおりました。どれも自分では作っていない名前でした。サブエージェントに隔離を任せたときに作られ、そのまま残っていたものでした。
手が止まりました。隔離は「変更が無ければ自動で片づく」と説明されておりましたので、私は後片づけを自分の作業の外に置いていたのです。
畳む責任は、作る側ではなく残るものの側に置きます。 その夜から、隔離を任せるかどうかを決める前に、残ったときに誰がどう数えるかを先に決めるようにしております。
自動で畳まれるはずのものが、畳まれないことがあります
サブエージェントに作業を隔離させる仕組みでは、一時的な作業ツリーが作られ、変更が無ければ実行後に片づけられる、という説明になっております。実際、短い調べもののような用途では期待どおりに消えてくれます。
ところが、同じ前提で走らせ続けていると残る場合があります。同じ症状の報告が anthropics/claude-code の Issue #95644 に出ておりまして、1 セッションで六つ蓄積し、すべて変更が無い状態にもかかわらず git worktree remove --force が必要になった、という切り分けまで書かれております。
日本語で読める解説は、いまのところ「未変更なら自動で消えるので後片づけは不要」という前提で書かれたものが多く、この実測と食い違ったままです。私の環境で起きたことも、そちらに近い形でした。
自動の片づけが効く前提でいると、気づく契機がディスクの残量しかありません。ですから、まず「どういうときに畳めないのか」を手元で測るところから始めました。
contains modified or untracked files, use --force to delete it が出る境目
隔離作業ツリーの片づけは、最終的に git worktree remove が通るかどうかで決まります。そこで小さなリポジトリを作り、状態を変えながら同じコマンドを流しました。git は 2.34.1 です。
# 検証用リポジトリを作る(使い捨て)
LAB="$HOME/wtlab"; mkdir -p "$LAB/main"; cd "$LAB/main"
git init -q -b main .
printf 'node_modules/\n.next/\ndist/\n' > .gitignore
echo "hello" > README.md
git add -A && git commit -qm init
# ケース1: 何も置いていない作業ツリー
git worktree add -q "$LAB/wt-clean" -b feat-clean
git worktree remove "$LAB/wt-clean" # → 成功(終了コード 0)
# ケース2: .gitignore 済みの成果物だけを置いた作業ツリー
git worktree add -q "$LAB/wt-ignored" -b feat-ignored
mkdir -p "$LAB/wt-ignored/node_modules/pkg"
head -c 200000 /dev/urandom > "$LAB/wt-ignored/node_modules/pkg/blob.bin"
git worktree remove "$LAB/wt-ignored" # → 成功(終了コード 0)
# ケース3: 未追跡のメモを1本だけ置いた作業ツリー
git worktree add -q "$LAB/wt-untracked" -b feat-untracked
echo "scratch" > "$LAB/wt-untracked/notes.txt"
git worktree remove "$LAB/wt-untracked"
# fatal: '.../wt-untracked' contains modified or untracked files, use --force to delete it
# 終了コード 128
実測をまとめますと、次のようになりました。
| 作業ツリーの状態 | git worktree remove | 終了コード | 片づけに必要なもの |
| 何も置いていない | 成功 | 0 | そのまま畳めます |
.gitignore 済みの成果物のみ(node_modules/ 等) | 成功 | 0 | そのまま畳めます |
| 未追跡ファイルが1件 | fatal: contains modified or untracked files | 128 | --force |
| 追跡ファイルを変更 | 同上 | 128 | --force |
git worktree lock 済み | fatal: cannot remove a locked working tree | 128 | unlock か remove -f -f |
| ディレクトリを手で削除 | 対象が無い | — | prune(メタデータのみ) |
同じ判定を自分の環境でも取りたい場合は、git --version を先に確認していただけましたら安心です。この境目は git 側の仕様に依存しますので、手元の版で一度流しておく価値があります。
ignored ではなく、未追跡が境目でした
意外だったのは、重い成果物のほうが片づけを止めないという点でした。node_modules/ が丸ごと残っていても remove は通り、テキスト 1 行のメモが残っているだけで止まります。
理由は判定の材料にあります。remove が見ているのは git status --porcelain に現れるもの、つまり追跡ファイルの変更と未追跡ファイルです。.gitignore に載っているものは既定では出てきません。
cd "$LAB/wt-dirty"
git status --porcelain # → 変更・未追跡だけが出る
git status --porcelain --ignored=matching # → !! node_modules/ が追加で出る
ここが運用上いちばん効いてきます。自動の片づけが「変更なし」と見なした作業ツリーでも、エージェントが置いた一時ファイルが .gitignore に載っていなければ remove は失敗します。そして失敗の多くは記録の奥に埋もれ、残量の数字として後から現れるのです。
成果物は消せて、メモは消せません。 逆に読めてしまうこの一文を、私は片づけの設計を見直すときの目印にしております。
ビルド成果物を .gitignore に入れておくのは容量のためだと思っておりましたが、後片づけが通るかどうかの分かれ目にもなっていたのだと、いま思えば測って初めて腑に落ちました。
消したあとにも、二つのものが残ります
畳んだあとの状態も測りました。残るものは二層に分かれます。
一つはブランチです。git worktree add -b で作ったブランチは、作業ツリーを remove しても残ります。実測では、四つ畳んだ時点で四本の孤立ブランチが残っておりました。
もう一つはメタデータです。ディレクトリを手で削除した場合、.git/worktrees/<名前>/ が残り、一覧では prunable と表示されます。
$ git worktree list
/home/me/wtlab/main b349ed1 [main]
/home/me/wtlab/wt-dirty b349ed1 [feat-dirty]
/home/me/wtlab/wt-manual b349ed1 [feat-manual] prunable
$ git worktree list --porcelain | tail -5
worktree /home/me/wtlab/wt-manual
HEAD b349ed1be18907b9e94d2071e59f9d1ab3ff400f
branch refs/heads/feat-manual
prunable gitdir file points to non-existent location
$ git worktree prune -v
Removing worktrees/wt-manual: gitdir file points to non-existent location
git worktree prune が回収するのは、この実体の無いメタデータだけです。ロック済みのものは prune でも残りますし、孤立ブランチにも触りません。ですから「prune を流したので綺麗になった」という感覚は、私の場合は当たっておりませんでした。
残骸を数える、読み取り専用の棚卸し
消す判断の前に、まず数える道具を置きました。何も削除せず、状態と実体のサイズと孤立ブランチだけを出します。
#!/usr/bin/env bash
# 隔離作業ツリーの棚卸し(読み取り専用・何も消しません)
# 使い方: worktree-audit.sh [リポジトリのパス]
set -u
cd "${1:-.}" || exit 1
git rev-parse --git-common-dir >/dev/null 2>&1 || { echo "git リポジトリではありません: $(pwd)"; exit 1; }
report() { # $1=パス $2=ブランチ $3=状態フラグ(prunable/locked/空)
local p="$1" br="${2:--}" flag="$3" state note payload="-"
if [ "$flag" = "prunable" ]; then
state="prunable"; note="実体が無い。prune でメタデータのみ回収"
elif [ "$flag" = "locked" ]; then
state="locked"; note="unlock か remove -f -f が必要"
payload=$(du -sh --exclude=.git "$p" 2>/dev/null | cut -f1)
else
payload=$(du -sh --exclude=.git "$p" 2>/dev/null | cut -f1)
local dirty; dirty=$(git -C "$p" status --porcelain 2>/dev/null | wc -l)
if [ "$dirty" -gt 0 ]; then
state="needs --force"; note="変更または未追跡が ${dirty} 件"
else
state="removable"; note="remove で畳めます"
fi
fi
printf '%-18s %-14s %-8s %-14s %s\n' "$(basename "$p")" "$state" "$payload" "$br" "$note"
}
MAIN="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
printf '%-18s %-14s %-8s %-14s %s\n' TREE STATE SIZE BRANCH NOTE
P=""; BR=""; FLAG=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
worktree\ *) P="${line#worktree }"; BR=""; FLAG="" ;;
branch\ *) BR="${line##*/}" ;;
prunable*) FLAG="prunable" ;;
locked*) FLAG="locked" ;;
"") [ -n "$P" ] && { [ "$P" = "$MAIN" ] || report "$P" "$BR" "$FLAG"; }; P="" ;;
esac
done < <(git worktree list --porcelain; echo)
echo
echo "[実体が消えたあとに残ったブランチ]"
git for-each-ref --format='%(refname:short)' refs/heads | while read -r b; do
git worktree list --porcelain | grep -q "^branch refs/heads/${b}$" || echo " 孤立: ${b}"
done
検証用リポジトリに流した出力が、次のものです。状態を四通り用意しておきました。
TREE STATE SIZE BRANCH NOTE
wt-dirty needs --force 800K feat-dirty 変更または未追跡が 1 件
wt-gone prunable - feat-gone 実体が無い。prune でメタデータのみ回収
wt-locked locked 12K feat-locked unlock か remove -f -f が必要
wt-review removable 12K feat-review remove で畳めます
[実体が消えたあとに残ったブランチ]
孤立: feat-clean
孤立: feat-ignored
孤立: feat-manual
孤立: feat-untracked
判定を git status --porcelain の件数で行っているところが要点です。remove が見るものと同じ材料で見ておきますと、「この作業ツリーは --force なしでは畳めない」という予告が事前に取れます。
なぜ消さない道具にしたのかと言いますと、残っている作業ツリーには「残してあるもの」が混ざるからです。私は一度、途中の検証を入れたままの作業ツリーを一括で畳んでしまい、翌朝に同じ検証をやり直しました。数えるところまでを自動にして、消すところは目で見てから、という順番にしております。
回収は、この順番で流しております
git worktree prune -v で実体の無いメタデータを回収します。ここは安全ですので迷わず流せます。
- 棚卸しで
removable と出たものを git worktree remove <path> で畳みます。
needs --force は、git -C <path> status --porcelain の中身を読んでから判断します。生成物だけなら --force、書きかけが混ざっていれば退避します。
locked は、ロックを置いた理由を思い出せるものだけ git worktree unlock します。理由が分からないものは残します。
- 孤立ブランチは
git branch --no-merged main を先に見て、空であることを確かめてから git branch -d で落とします。
# 3 の退避の一例(消す前に取り出す)
P="$HOME/.claude/worktrees/wt-xxxxxx"
git -C "$P" status --porcelain
tar czf "$HOME/rescue-$(basename "$P").tgz" -C "$P" \
$(git -C "$P" status --porcelain | awk '{print $2}')
git worktree remove --force "$P"
5 の --no-merged は、私にとっていちばん効いた一手でした。孤立ブランチが四本並んでいても、すべて main に含まれていれば捨てて構いません。一本でも未マージが出たら、その日は消さずに名前だけ書き残します。
worktree・別クローン・同一クローン、どれに任せるか
ここまでの実測を踏まえて、隔離の置き方を三つに整理しました。ディスクの差も測っております。手元の Lab のリポジトリ(浅いクローン)で、本体は合計 47 MB、内訳は .git が 13 MB と作業ファイルが 35 MB でした。
git worktree add を1つ追加すると、実体 35 MB とメタデータ 308 KB の増加でした。
- 同じものをローカルに再クローンすると、合計 47 MB の増加でした。
つまり作業ツリー1つあたり 12 MB、割合にして約 25% の差です。並列数が一桁のうちは決定的な差にはなりません。ですから私は、容量よりも後片づけの責任で選ぶようにしております。
| 置き方 | 向いている場面 | 後片づけの責任 | 気をつける点 |
worktree による隔離 | 同じ履歴を共有したまま、短い作業を複数走らせる場面 | 作った側(自動の片づけに頼れない前提で) | 未追跡ファイルで remove が止まります。ブランチとメタデータが二層で残ります |
| 別クローンによる隔離 | 依存関係の入れ替えやビルドを伴い、履歴を汚したくない場面 | ディレクトリを消せば完結します | 1つあたり .git の 13 MB が二重になります。取り直しの鮮度管理が要ります |
| 同一クローン+直列化 | 書き込みが短く、順番に流せる場面 | 不要(増えません) | index.lock の競合が出ます。並列度は上げられません |
作業ディレクトリが変わると、プロンプトキャッシュの当たり方も変わってきます。この点はキャッシュのTTLを1時間に延ばすかは、離席の長さではなく戻る場所で決めていますで書き残しておりますので、並列数を増やす前に併せて見ていただけましたら判断が早くなります。
作業ツリーごとに走らせる組み方そのものはClaude Code を worktree ごとに走らせる並列開発の実装メモに、タスクの分け方はClaude Code のマルチエージェント並列実行 — Task ツールと SubAgent の実装パターンと運用の勘所にまとめております。
私がいま引いている線
個人開発で壁紙アプリを含む複数のアプリと、Lab のサイト群、WordPress のブログ、受託のサイトを同じ機械で見ております。並行して走る処理が多い日は、隔離を増やすより先に空き容量を測るようにしました。
いまの線引きは三つです。まず、書き込みを伴う無人の処理には永続クローンを1つだけ持ち、ビルド成果物は毎回落とします。次に、隔離を任せる場合は棚卸しを日課の側に置き、残骸の本数を記録します。最後に、--force を自動で付けません。付けた瞬間に、残してあるものと残ってしまったものの区別が消えるからです。
自動で畳める前提は、畳めなかったものを数えてから信じます。 容量が減っていく速さを不安の種にしないために、この順番だけは崩さないようにしております。
今日試していただけるとしましたら、git worktree list --porcelain を一度流して、prunable の行数と孤立ブランチの本数を数えるところだけで十分です。二つとも 0 であれば、隔離は任せたままで構いません。数字が出たなら、その日のうちに棚卸しを置く価値があります。
最後までお読みいただきありがとうございました。同じ場所でディスクを眺めている方の役に立てば嬉しく思います。