ディスクの残りが少なくなったとき、古い作業コピーを片付ける小さな処理を書きました。今このリポジトリで作業しているのだから、それだけは残して、兄弟のディレクトリを消せばよいはずだと考えて書いた行が、これです。
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本のスクリプトにして、そこで止まるようにしました。
#!/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 は「存在しないパスならエラーにする」指定です。作業ルート側は先頭の -d で既に弾いているので、実際に効くのは ALLOWED_ROOT の綴り間違いのほうでした。
-e を外して rpos と打ち間違えた状態で走らせると、guard: 許可ルートの外です: .../siteA (allowed: .../rpos) という、原因とは違う場所を指すメッセージで exit 3 になります。-e があれば realpath: .../rpos: No such file or directory と、間違えた名前そのものが表示されます(終了コードは 1 です)。止まる点は同じでも、ログを読み返したときに辿り着ける場所が違うのです。
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 /repos/plain"
実行結果です。
前半3つが同じ .../repos/siteA に収束しているところが、このガードの働きそのものです。文字列としては3通りの入力が、以降の処理では1つの値になります。
最初に書いた版では、この最終行を $T/bin(許可ルートの外)にしていました。結果は exit 3 で、期待どおりに止まったように見えます。ところが、検査ごとの終了コードを見比べ直すと、検査(3)の exit 5 が一度も通っていませんでした。同じ exit 3 でも、止めていたのは検査(1)だったのです。repos の中に git 管理外の plain を置いて差し替えると、ようやく exit 5 が出ました。
表の終了コードは、検査の数だけ別々の値が出て、はじめて検査が働いた証拠になります。
もう一つ、表にない入力も試しました。許可ルート repos と名前の頭が同じ兄弟 repos-evil を指定した場合で、結果は exit 3 です。(1) の case が "$WORK_REAL/" と末尾にスラッシュを足して比べているのは、この取り違えを避けるためでした。パターンの / を落として "$ROOT_REAL"* と書くと、repos-evil まで許可側に入ります。case だけを取り出して確かめたところ、スラッシュなしは誤って一致し、スラッシュありは拒否しました。
「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 -> $PR "
rm -rf -- " $PR "
done
siteA・siteB・siteC とシンボリックリンク siteA-link を置き、そのリンク経由で作業ルートを宣言して走らせました。siteA の中には目印のファイルを入れてあります。siteA と siteA-link は残り、siteB と siteC が消えました。ここまでは想定どおりで、目印のファイルも、repos ディレクトリ自体も残っています。
ここで、もう一つ試しました。許可ルートの外のディレクトリを指す古いリンクです。外側の outside/data に keep.txt を置き、repos/old-link をそこへ向けて、同じ手順で走らせ直しました。
keep : .../repos/siteA
keep : .../repos/siteA-link
sweep: .../repos/old-link -> .../outside/data
sweep: .../repos/siteB -> .../repos/siteB
sweep: .../repos/siteC -> .../repos/siteC
走らせたあとで outside/data/keep.txt を探すと、ありませんでした 。
rm -rf -- "$PR" が消すのは、リンクではなくリンクの宛先です。許可ルートの中を掃除したつもりで、ルートの外にある実データまで消していました。比べるために実体へ落とした値を、そのまま削除にも使ったのが原因です。表記ゆれを潰すために足した realpath が、今度は削除の範囲を広げる側に回っていた——いま思えば、比較と削除で同じ変数を使い回した時点で、気づけた場所でした。
比べるときは実体、消すときは入口。 比較には realpath の結果を使い、削除には find が返した入口の名前をそのまま渡します。入口がリンクなら rm -- でリンクだけを外し、宛先には触れません。
find " $ROOT_REAL " -mindepth 1 -maxdepth 1 -print0 | while IFS = read -r -d '' P ; do
PR = $( realpath " $P " 2> /dev/null || true ) # 実体が引けなくても止まらない
if [ -n " $PR " ] && [ " $PR " = " $WORK_REAL " ]; then echo " keep : $P " ; continue ; fi
if [ -L " $P " ]; then echo " unlink: $P " ; rm -- " $P " ; continue ; fi # リンクの宛先には触れない
echo " sweep : $P " ; rm -rf -- " $P "
done
宛先のないリンク dangling と、git 管理外の plain も足して、同じ条件で走らせた結果です(見やすいよう並べ替えています)。
keep : .../repos/siteA
keep : .../repos/siteA-link
sweep : .../repos/plain
sweep : .../repos/siteB
sweep : .../repos/siteC
unlink: .../repos/dangling
unlink: .../repos/old-link
outside/data/keep.txt は残り、repos に残ったのは siteA と siteA-link の2つでした。宛先のないリンクも、比較には当たらないまま -L の枝でリンクだけが外されます。
rm -rf -- "$P" の -- は、ハイフンで始まる名前のディレクトリがオプションとして解釈されるのを止めるためのものです。人が手で作らない名前でも、ビルド生成物には紛れ込みます。
-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:?...}" と書いているのは、この差を埋めるためです。
手元が macOS のときは、realpath -e を疑ってください
ここまでのスクリプトは、Linux(bash 5.1・GNU coreutils 8.32)で動かして確かめたものです。iMac でも同じように使いたいのですが、realpath -e の -e は GNU の拡張で、macOS 標準の realpath では通らないことがあります。私は macOS の実機ではまだ走らせておらず、ここは未検証です。
そこで、-e に頼らず cd -P と pwd -P で実体を求める版を用意し、Linux 上で元の版と突き合わせました。変えたのは、実体を求める関数と、それを呼ぶ3か所だけです。
# macOS 標準の realpath には GNU の -e が無い場合があるため、cd -P / pwd -P で実体を求める
real () { ( cd -P -- " $1 " && pwd -P ); }
ROOT_REAL = $( real " $ALLOWED_ROOT " )
WORK_REAL = $( real " $DECLARED_WORK " )
# ... (1)(2) は変更なし ...
[ "$( real " $TOP ")" = " $WORK_REAL " ] || {
echo "guard: 頂点ではありません。実際の頂点: $TOP " >&2 ; exit 6 ; }
入力は10通りです。表の7つに、repos と名前の頭が同じ兄弟、存在しないパス、ファイルを加えました。終了コードとメッセージは、元の版とすべて一致しました。差が出たのは ALLOWED_ROOT を打ち間違えたときだけで、realpath: ... の代わりに cd: ...: No such file or directory が出ます。どちらも間違えた名前が見える点は同じです。
この関数が扱えるのはディレクトリだけです。ファイルを渡すと失敗しますが、作業ルートは必ずディレクトリなので、先頭の -d で先に止まります。
エージェントへの指示にどう組み込むか
ガードができたら、あとは「エージェントが自分でパスを決めない」形に寄せます。私の場合は、次の3点だけを守るようにしました。個人開発では手順を増やすほど守られなくなるので、覚えていられる数に絞っています。
作業ルートはタスクの最初に一度だけ確定 し、それ以降は変えません。エージェントに渡す指示文の中で cd させるのではなく、ガードが返した絶対パスを git -C "$WORK" ... の形で毎回明示します。カレントディレクトリに依存する書き方を減らすほど、途中で位置がずれても結果が変わらなくなります。
削除を伴う処理は、ガードの成功を前提にしか書かない ようにします。ガードが exit 3 で止まったなら、その後ろの掃除ループは一行も走りません。set -e があるので、明示的な if を書かなくてもここは守られます。
そして、ガードの出力をログに残す ようにしました。guard: 頂点ではありません。実際の頂点: ... の1行があるだけで、後から見返したときに「どこで作業していたつもりだったのか」が分かります。エージェントの実行ログは長くなりがちですが、この1行は探しやすい位置に置く価値があります。
作業範囲そのものをどう決めるかについては、読み取りが自動承認になったレビューモードで、ワークスペースの範囲を決め直す でも扱っています。また、ガードの終了コードが呼び出し側に届かない構造については、Antigravity CLI の失敗を握りつぶしていたのは呼び出し側のパイプでした が近い話です。
次の一手
まずは、いま使っている掃除処理やクリーンアップの行に realpath が入っているかだけ、確かめてみてください。入っていなければ、比較の前に一度実体へ落とす2行を足すところから始められます。
私自身、この番人を置くまでは「自分は作業ディレクトリを間違えない」と思っていました。間違えないのは人の側であって、パスの表記ゆれは間違いとして現れないというだけのことでした。お読みいただきありがとうございました。