作業フォルダの中に散らばったドキュメントを一通り点検したくて、拡張子で拾って先頭バイトを読むだけの短いループを書きました。80件ほどあるはずのファイルです。
走らせると、こう出ました。
checked=80 readable=0 unreadable=80
80件を検査して、1件も読めていない。最初は同期の問題を疑いました。クラウド同期のフォルダを作業場所にしているので、実体がまだ降りてきていないのだろう、と。
けれど cat で個別に開くと、どれも普通に読めます。中身も欠けていません。
原因はファイルの側ではありませんでした。フォルダ名に空白が一つ入っていた、それだけです。
検査したはずの80件が、160回に増えていた
問題のループはこういう形をしていました。個人開発の点検スクリプトでは、おそらく最も書かれている形です。
for f in $(find "$ROOT/_documents" -type f -name "*.md"); do
if head -c 1 "$f" >/dev/null 2>&1; then ok=$((ok+1)); else ng=$((ng+1)); fi
done
"$ROOT/_documents" はきちんとクォートしてあります。ループ変数の "$f" もクォートしてあります。それでも壊れます。
壊れているのは $( ) の外側です。コマンド置換の結果は、クォートされていない位置に置かれた時点で IFS(既定では空白・タブ・改行)によって単語分割されます。find が返した1行のパスが、空白のところで二つに割れる。
実際に数えてみます。空白を含むディレクトリの下に100バイトのファイルを80件置いて、同じループを回した結果です。
=== A: コマンド置換・非クォート ===
iterations=160 readable=0 unreadable=160 (実ファイル数=80)
イテレーションが160。ファイル数のちょうど2倍です。パスが2つの断片に割れ、その断片一つひとつをファイル名だと思ってループが回っていました。当然どちらも開けません。
find を -print0 で出し、NUL 区切りで読む形に変えると、こうなります。
while IFS= read -r -d '' f; do
head -c 1 "$f" >/dev/null 2>&1 && ok=$((ok+1))
done < <(find "$ROOT/_documents" -type f -name "*.md" -print0)
=== B: find -print0 + while read -d '' ===
iterations=80 readable=80 unreadable=0
80件、全件読めました。ファイルは最初から何も壊れていなかったわけです。
断片が「実在するパス」に化けるとき
ここまでなら「読めなかっただけ」で済みます。実際に背筋が冷えたのは、次を確かめたときでした。
割れた断片のうち、前半は実在するディレクトリになり得ます。
/work/Dolice Labs/out/tmp_1.txt が割れると、前半は /work/Dolice です。もし同じ階層に Dolice という別のフォルダが本当に存在していたら、その断片は「存在しないゴミ」ではなく「実在する別の場所」を指します。
検証用に、消したいファイルを Dolice Labs/out/ に5件、消してはいけないファイルを Dolice/keep/ に3件置き、素朴なループで削除を回しました。
for f in $(find "$T/Dolice Labs/out" -type f -name "tmp_*.txt"); do
rm -rf "$f" 2>/dev/null
done
結果です。
before: keep=3 tmp=5
after : keep=0 tmp=5
| 対象 | 意図 | 実行前 | 実行後 |
Dolice Labs/out/ | 消したい | 5件 | 5件(1件も消えていない) |
Dolice/keep/ | 消してはいけない | 3件 | 0件(全部消えた) |
意図した対象は一つも消えず、触れてはいけない側が全滅しました。しかもループの終了コードは 0 で、標準エラーには1行も出ません。
rm -rf "$f" の "$f" はクォートしてあります。クォートは断片を「1つの引数」として渡すことしか保証しません。その断片がどこを指すかまでは面倒を見てくれない、というのがこの現象の芯です。
set -euo pipefail は、この失敗を止めません
安全なスクリプトの作法として set -euo pipefail を先頭に置く習慣があります。私も置いています。ここでどう振る舞うかを測りました。
set -euo pipefail 下でも完走 exit=0 iterations=160
止まりません。理由を分けて書きます。
| オプション | 本来止めるもの | この失敗で止まらない理由 |
-e | 非ゼロ終了のコマンド | 断片は「存在しないファイル」として静かにスキップされるか、実在する別パスに当たって成功する |
-u | 未定義変数の参照 | 変数は定義済み。値の中身が意図と違うだけ |
-o pipefail | パイプ途中の失敗 | 分割はパイプではなく展開の段階で起きる |
シェルの安全装置は「コマンドが失敗したこと」を見ています。今回はコマンドが失敗していません。成功したまま、違う対象に対して成功している。安全装置の外側にある失敗です。
似た構図は空文字の変数でも起きます。以前、空になった変数のせいで片付けスクリプトが広い範囲に届きかけた夜のことを空の変数と rm -rf の夜に書きました。あのときは事前確認に救われましたが、今回は事前確認に出てくる文字列そのものが正しく見えてしまいます。rm -rf "/work/Dolice" は、それ単体では何もおかしくありません。
書き方ごとに、拾える件数がどれだけ変わるか
同じ80件に対して、よく使われる書き方を並べて測りました。すべて GNU bash 5.1.16 での実測です。
| 書き方 | 拾えた件数 | 備考 |
for f in $(find ...) | 0 / 80 | 160回空回り。エラー出力なし |
grep -l "x" $(find ...) | 0 / 80 | 件数が0なので「該当なし」と読める |
IFS=$'\n' にしてから for | 80 / 80 | 改行を含むファイル名には無力 |
find ... -print0 | xargs -0 | 80 / 80 | 外部コマンドを回すなら第一候補 |
find ... -exec ... {} + | 80 / 80 | シェルを経由しないので分割が起きない |
while IFS= read -r -d '' | 80 / 80 | ループ内で条件分岐するならこれ |
2行目が一番たちが悪いと感じました。grep -l が0件を返したとき、人は「該当するファイルがなかった」と読みます。「1件も開けなかった」とは読みません。同じ0件でも意味が正反対です。監査系のスクリプトで「✅ 違反なし」と出力していたら、そのまま通してしまいます。
IFS=$'\n' は手軽で、実際その場では通ります。ただし改行を含むファイル名に当たると同じ形で崩れます。ダウンロードしたアセットや、外部から受け取った素材を扱う場所では、私は使わないようにしています。
私が置いている三行のガード
回避策を覚えるより、取りこぼしが起きたことに気づける形にしておくほうが確実だと考えるようになりました。書き方は忘れますが、ガードは残ります。
処理の前後で件数を突き合わせるだけです。
# 1. 期待件数を先に数える
expected=$(find "$ROOT" -type f -name "*.md" -print0 | tr -cd '\0' | wc -c)
# 2. 実際に処理した件数を数える
processed=0
while IFS= read -r -d '' f; do
head -c 1 "$f" >/dev/null 2>&1 && processed=$((processed+1))
done < <(find "$ROOT" -type f -name "*.md" -print0)
# 3. 一致しなければ止める
if [ "$expected" -ne "$processed" ]; then
echo "❌ 取りこぼし: expected=$expected processed=$processed" >&2
exit 1
fi
echo "✅ $processed 件を処理しました"
1行目で -print0 の NUL をそのまま数えているのは、期待件数の数え方自体が分割の影響を受けないようにするためです。find ... | wc -l は改行を含むファイル名で水増しされます。
このガードを入れると、先ほどの削除の例はこう変わります。削除を実行する前に expected=5 processed=0 で止まるので、Dolice/keep/ には手が届きません。
削除を伴うバッチでは、もう一段だけ足しています。消してよい場所を、消してよいことが名前で分かるディレクトリに閉じるという決め方です。
# 消してよい場所は1箇所に固定し、そこ以外は触らせない
DISPOSABLE="$ROOT/_scratch"
case "$target" in
"$DISPOSABLE"/*) : ;;
*) echo "❌ 削除対象が _scratch の外です: $target" >&2; exit 1 ;;
esac
パスが割れて $ROOT/Dolice に化けても、case の照合を通りません。ルールの表現力ではなく、照合対象を狭くすることで守るやり方です。私はこちらのほうを信用しています。文章で書く拒否ルールが増えるほど、「書いたつもり」と「効いている」の距離が開いていくので、最後は形が単純なもので受け止めたくなります。
任せる前に、5分で確かめられること
Claude Code や Cowork に一括処理を任せる場面では、この失敗はさらに見つけにくくなります。人が1件ずつ結果を見ないぶん、「0件でした」という報告が素通りするからです。8月14日に auto モードが既定になり、途中の確認が減った運用ではなおさらだと感じています。
任せる前の確認は、次の3つで足ります。
| 確認 | やり方 | 危ないサイン |
| 作業パスに空白があるか | echo "$ROOT" | grep -q " " && echo "空白あり" | 空白があるなら以降を必ず確認する |
| コマンド置換が裸で置かれていないか | grep -n 'in \$(find' script.sh | 1件でも出たら書き換える |
| 件数が一致しているか | 上のガードを先頭のジョブに1つ入れる | processed が 0、または expected の整数倍ずれ |
3つ目の「整数倍のずれ」は、空白の数だけ断片が増えることから来ています。80件が160回になったのは、パスに空白が1つあったからです。2つあれば240回になります。ずれ方が規則的なので、原因の当たりがすぐつきます。
クラウド同期のフォルダを作業場所にしていると、そもそも「読めない」の候補が複数あって切り分けが鈍ります。実体がまだ降りていないケースについてはCowork の bash が『ファイルがありません』と言うのに Finder には見える理由にまとめました。同期の問題と単語分割の問題は、症状がよく似ています。区別する手がかりは、cat で個別に開けるかどうかです。開けるなら、疑うのはファイルではなくループの書き方です。
気づいた後に、何を調べるか
取りこぼしが分かった時点で、破壊的な操作がすでに走っていた可能性があります。私は次の順で調べています。
- イテレーション回数を数える。 ループにカウンタを足して再実行し、実ファイル数の整数倍になっていないか見ます。2倍なら空白1つ、3倍なら2つです。ここが一致していれば、原因は分割ではありません
- 前半の断片が実在するか確かめる。
ls -d "${ROOT%% *}" のように、最初の空白で切った文字列を直接引きます。ここに何かが存在していたら、その配下が巻き添えの候補です
- その配下の更新時刻を見る。
find "${ROOT%% *}" -newermt "-2 hours" -ls で、直近に触られたものを洗い出します。何も出なければ実害はなかったと判断できます
3番目まで通して初めて「読めなかっただけ」と確定できます。私は最初、1番だけ見て安心しかけました。
明日の朝にできる一歩
手元の自動化スクリプトを1本だけ開いて、grep -n 'in \$(find' *.sh を通してみてください。1件でも出たら、その行が今日の対象です。
書き換えは while IFS= read -r -d '' への置き換えで済みます。それより先に、処理件数のアサーションを1つ足すほうを勧めます。書き方は環境が変われば揺れますが、「期待した件数を処理したか」という問いは、どの言語でもどのツールでも同じ形で使えるからです。
私自身、この失敗を踏むまでは、クォートさえしていれば大丈夫だと思っていました。安全だと信じている作法ほど、実際に壊してみないと精度が上がらないのだと改めて感じています。お読みいただきありがとうございました。