ディスクの残りが少なくなったとき、古い作業コピーを片付ける小さな処理を書きました。今このリポジトリで作業しているのだから、それだけは残して、兄弟のディレクトリを消せばいい。そう思って書いた行が、これです。
for OTHER in /tmp/repos/*; do
[ "$OTHER" = "$WORK" ] && continue
rm -rf "$OTHER"
done
個人開発で iOS と Android のアプリ、それに複数のサイトのリポジトリを一台で回していると、作業コピーは常に4つも5つも同居します。この行は、そのうち1つだけを守るつもりで書いたものでした。
守れていませんでした。$WORK の末尾にスラッシュが1つ付いているだけで、この比較は一度も成立しません。
取り違えが入り込む3つの入口
作業ルートの取り違えは、派手な事故として起きません。文字列としては違うのに実体としては同じ、あるいはその逆という、ごく地味なずれから入ってきます。私が実際に踏んだ入口は3つでした。
表記ゆれ
/tmp/repos/siteA と /tmp/repos/siteA/ は、シェルにとって別の文字列です。パスを組み立てる箇所が2つ以上あると、片方だけに末尾スラッシュが残ることは珍しくありません。
シンボリックリンクの別名
作業しやすいように別名を張っておくと、その別名で渡されたパスは元のディレクトリと文字列一致しません。
引き継がれた深さ
エージェントにリポジトリのルートを渡したつもりでも、直前のタスクで src/ に降りていれば、その位置がそのまま作業ルートとして引き継がれることがあります。
小さな砂場で、何が起きるかを見る
推測で対策を書くと、だいたい別のところがずれます。実際の挙動を見てから決めたいので、使い捨てのディレクトリで再現しました。siteA・siteB と、siteA を指すシンボリックリンクを置いた状態です。
T=$(mktemp -d); cd "$T"
mkdir -p repos/siteA repos/siteB
ln -s "$T/repos/siteA" repos/siteA-link
WORK="$T/repos/siteA/" # 末尾スラッシュ付き
for OTHER in "$T"/repos/*; do
if [ "$OTHER" = "$WORK" ]; then echo " skip(self): $OTHER"
else echo " DELETE候補: $OTHER"; fi
done
出力はこうなりました。
DELETE候補: /tmp/tmp.vFHcRXrU5D/repos/siteA
DELETE候補: /tmp/tmp.vFHcRXrU5D/repos/siteA-link
DELETE候補: /tmp/tmp.vFHcRXrU5D/repos/siteB
守るつもりだった siteA が、削除候補の先頭に並んでいます。skip(self) は一度も出ていません。
同じループを realpath で正規化してから比較すると、結果が変わります。
WORK_R=$(realpath "$WORK")
for OTHER in "$T"/repos/*; do
OR=$(realpath "$OTHER")
if [ "$OR" = "$WORK_R" ]; then echo " skip(self): $OTHER -> $OR"
else echo " DELETE候補: $OTHER -> $OR"; fi
done
skip(self): .../repos/siteA -> .../repos/siteA
skip(self): .../repos/siteA-link -> .../repos/siteA
DELETE候補: .../repos/siteB -> .../repos/siteB
末尾スラッシュが落ち、シンボリックリンクが解決され、siteA を指す2つの入口が両方とも skip 側へ回りました。比較の前に一度だけ実体へ落とす。対策の芯はここだけです。
番人をひとつのファイルにまとめる
正規化を思い出したときだけ書く、という運用は続きません。エージェントに作業させる前に必ず通す1本のスクリプトにして、そこで止まるようにしました。
| 検査 | 何を防ぐか |
| 許可ルート配下か | ../ やシンボリックリンクで許可範囲の外へ出ること |
| 許可ルート自体でないか | 兄弟のリポジトリごと巻き込む操作 |
| git の頂点と一致するか | サブディレクトリをルートと誤認したまま進むこと |
#!/usr/bin/env bash
# workroot-guard.sh — エージェントに触らせてよい作業ルートを確定する
set -euo pipefail
ALLOWED_ROOT="${ALLOWED_ROOT:?ALLOWED_ROOT が未設定です}"
DECLARED_WORK="${1:?作業ルートを引数で渡してください}"
[ -d "$DECLARED_WORK" ] || { echo "guard: 作業ルートが存在しません: $DECLARED_WORK" >&2; exit 2; }
ROOT_REAL=$(realpath -e "$ALLOWED_ROOT")
WORK_REAL=$(realpath -e "$DECLARED_WORK")
# (1) 許可ルートの外は拒否(.. や symlink を潰した後の実体で判定する)
case "$WORK_REAL/" in
"$ROOT_REAL"/*) : ;;
*) echo "guard: 許可ルートの外です: $WORK_REAL (allowed: $ROOT_REAL)" >&2; exit 3 ;;
esac
# (2) 許可ルートそのものを作業ルートにさせない(兄弟ごと巻き込む事故の元)
[ "$WORK_REAL" != "$ROOT_REAL" ] || { echo "guard: 許可ルート自体は作業ルートにできません" >&2; exit 4; }
# (3) 宣言したパスが git リポジトリの頂点かを実体に問い直す
TOP=$(git -C "$WORK_REAL" rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$TOP" ] || { echo "guard: git リポジトリではありません: $WORK_REAL" >&2; exit 5; }
[ "$(realpath -e "$TOP")" = "$WORK_REAL" ] || {
echo "guard: 頂点ではありません。実際の頂点: $TOP" >&2; exit 6; }
echo "$WORK_REAL"
成功したときだけ、標準出力に実体パスを1行返します。呼び出し側は WORK=$(workroot-guard.sh "$1") と受け取り、以降そこにしか触れません。失敗時は終了コードで理由が分かれるので、どの検査で落ちたかがログに残ります。
realpath -e の -e は「存在しないパスならエラーにする」指定です。-e を付けないと、綴りを間違えた存在しないパスがそのまま正規化されて通ってしまいます。ここは省略しないことを強く推奨します。
7つの入力で、終了コードを確かめる
書いたら通します。想定していた入力を並べて、期待どおりの場所で止まるかを一度に見ました。
export ALLOWED_ROOT="$T/repos"
run(){ printf '%-40s -> ' "$1"; out=$(./workroot-guard.sh "$2" 2>&1) \
&& echo "OK $out" || echo "exit=$? $out"; }
run "正常(リポジトリの頂点)" "$T/repos/siteA"
run "末尾スラッシュ付き" "$T/repos/siteA/"
run "symlink 経由の別名" "$T/repos/siteA-link"
run "リポジトリ内のサブディレクトリ" "$T/repos/siteA/src"
run "許可ルート自体" "$T/repos"
run "許可ルートの外(../ で脱出)" "$T/repos/../repos/../"
run "git 管理下でない場所" "$T/bin"
実行結果です。
| 入力 | 結果 | 返した内容 |
| 正常(リポジトリの頂点) | OK | .../repos/siteA |
| 末尾スラッシュ付き | OK | .../repos/siteA(スラッシュが落ちる) |
| symlink 経由の別名 | OK | .../repos/siteA(実体に解決される) |
| リポジトリ内のサブディレクトリ | exit 6 | 頂点ではありません。実際の頂点: .../siteA |
| 許可ルート自体 | exit 4 | 許可ルート自体は作業ルートにできません |
許可ルートの外(../ で脱出) | exit 3 | 許可ルートの外です |
| git 管理下でない場所 | exit 3 | 許可ルートの外です |
前半3つが同じ .../repos/siteA に収束しているところが、このガードの働きそのものです。文字列としては3通りの入力が、以降の処理では1つの値になります。
「git リポジトリか」を確かめるだけでは足りません
表の4行目が、私にとって一番意外でした。
git -C .../siteA/src rev-parse --show-toplevel は成功します。エラーにはなりません。.../siteA という正しい頂点を返してくるからです。つまり「渡されたパスは git リポジトリですか」という問いに対しては、サブディレクトリも堂々と「はい」と答えます。
私は当初、この呼び出しを「リポジトリのルートかどうかの判定」として使うつもりでいました。実際には「リポジトリの内側かどうかの判定」でしかありません。頂点であることを確かめたいなら、返ってきた頂点と、宣言されたパスをもう一度自分で照合する必要があります。スクリプトの検査(3)が2行に分かれているのは、そのためです。
この差が効くのは、エージェントが直前のタスクの作業位置を引き継いだときです。src/ に立ったまま「このリポジトリで作業して」と指示すると、リポジトリの内側ではあるので素通りします。そして相対パスで組んだ出力先が、ひとつ深い場所に生えます。
掃除する側にも、同じ実体パスを渡す
ガードが返した値は、作業だけでなく片付けにも使います。冒頭の掃除ループを、実体パスで書き直しました。
ROOT_REAL=$(realpath "$ALLOWED_ROOT")
WORK_REAL=$(./workroot-guard.sh "$DECLARED_WORK") # ここで止まれば掃除も走らない
find "$ROOT_REAL" -mindepth 1 -maxdepth 1 -print0 | while IFS= read -r -d '' P; do
PR=$(realpath -e "$P") || continue
if [ "$PR" = "$WORK_REAL" ]; then echo " keep : $P"; continue; fi
echo " sweep: $P"
rm -rf -- "$PR"
done
siteA・siteB・siteC とシンボリックリンクを置き、シンボリックリンク経由で作業ルートを宣言した状態で走らせた結果です。
keep : .../repos/siteA
sweep: .../repos/siteB
sweep: .../repos/siteC
keep : .../repos/siteA-link
siteA の中に置いた目印のファイルは残り、repos ディレクトリ自体も残りました。別名で宣言しても守るべきものが守られています。
rm -rf -- "$PR" の -- は、ハイフンで始まる名前のディレクトリがオプションとして解釈されるのを止めるためのものです。人が手で作らない名前でも、ビルド生成物には紛れ込みます。
-mindepth 1 を書き忘れると、親ごと消えます
掃除ループで find を使うなら、-mindepth 1 は装飾ではありません。find は探索の起点そのものを結果に含めるので、これを書かないと親ディレクトリが対象に入ります。
# 起点も対象に入る書き方
find "$T/cache" -maxdepth 1 -type d -exec rm -rf {} +
# → cache ディレクトリは残っているか: NO
# -mindepth 1 を付けた書き方
find "$T2/cache" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +
# → cache ディレクトリは残っているか: yes
# → keep.txt は残っているか: yes
前者は cache ごと消えました。中の a/・b/ を消したかっただけなのに、置いてあった keep.txt も一緒に無くなります。後者では cache と keep.txt が残り、ディレクトリだけが消えました。
-delete を使っているうちは中身のあるディレクトリで失敗して気づけますが、-exec rm -rf {} + に変えた瞬間に静かに通ります。書き方を変えたときこそ、-mindepth 1 の有無を見てください。
set -u が守るもの、守らないもの
スクリプト冒頭の set -euo pipefail のうち、ここで効いているのは -u です。未定義の変数を展開しようとした時点で止まります。
( set -u; rm -rf "${UNSET_VAR}/build" )
# → bash: UNSET_VAR: unbound variable
echo "${UNSET_VAR2}/build"
# → /build
set -u がなければ、"${UNSET_VAR2}/build" は /build という文字列になります。変数名の綴りを間違えただけで、削除対象がルート直下を向きます。
ただし -u は「未定義」を捕まえるだけで、「空文字列」は素通りします。WORK="" は未定義ではないので警告なく /build になります。空も弾きたい場合は ${WORK:?...} の形を使ってください。スクリプトの冒頭で ALLOWED_ROOT="${ALLOWED_ROOT:?...}" と書いているのは、この差を埋めるためです。
エージェントへの指示にどう組み込むか
ガードができたら、あとは「エージェントが自分でパスを決めない」形に寄せます。私の場合は、次の3点だけを守るようにしました。個人開発では手順を増やすほど守られなくなるので、覚えていられる数に絞っています。
作業ルートはタスクの最初に一度だけ確定し、それ以降は変えません。エージェントに渡す指示文の中で cd させるのではなく、ガードが返した絶対パスを git -C "$WORK" ... の形で毎回明示します。カレントディレクトリに依存する書き方を減らすほど、途中で位置がずれても結果が変わらなくなります。
削除を伴う処理は、ガードの成功を前提にしか書かないようにします。ガードが exit 3 で止まったなら、その後ろの掃除ループは一行も走りません。set -e があるので、明示的な if を書かなくてもここは守られます。
そして、ガードの出力をログに残すようにしました。guard: 頂点ではありません。実際の頂点: ... の1行があるだけで、後から見返したときに「どこで作業していたつもりだったのか」が分かります。エージェントの実行ログは長くなりがちですが、この1行は探しやすい位置に置く価値があります。
作業範囲そのものをどう決めるかについては、読み取りが自動承認になったレビューモードで、ワークスペースの範囲を決め直すでも扱っています。また、ガードの終了コードが呼び出し側に届かない構造については、Antigravity CLI の失敗を握りつぶしていたのは呼び出し側のパイプでしたが近い話です。
次の一手
まずは、いま使っている掃除処理やクリーンアップの行に realpath が入っているかだけ、確かめてみてください。入っていなければ、比較の前に一度実体へ落とす2行を足すところから始められます。
私自身、この番人を置くまでは「自分は作業ディレクトリを間違えない」と思っていました。間違えないのは人の側であって、パスの表記ゆれは間違いとして現れないというだけのことでした。お読みいただきありがとうございました。