ある朝、無人スケジュールの記録を上から順に見ていて、指が止まりました。前夜まで何週間も緑だったはずのランが、いちばん最初のステップで赤くなっています。ログにはこう残っていました。
error: could not lock config file .git/config: Permission denied
fatal: not in a git directory
error: cannot open .git/FETCH_HEAD: Permission denied
不思議なのは、その少し下の行で find content/articles/ja -name '*.mdx' | wc -l が普通に数を返していたことです。読めている。一覧も取れている。それなのに git は「書けない」と言い張る。ファイルが消えたわけでも、認証が切れたわけでもありませんでした。
犯人は、毎回使い回していた永続作業フォルダの所有者でした。個人開発で複数のサイトを無人のスケジュールで回していると、作業フォルダを毎回 clone し直すのはもったいないので、/tmp/repos/site.net のような場所に置いて git pull --rebase で差分だけ取りたくなります。ところが実行基盤のサンドボックスが世代交代すると、その永続フォルダが別のUID(多くは nobody)の持ち物として残ることがあります。読み取りは誰でもできるので ls も find も通る。けれど .git/config へのロック、つまり書き込みだけが Permission denied で弾かれる。この非対称性が、朝の切り分けを一段ややこしくしていました。
この記事は、その「読めるのに書けない作業フォルダ」を無人ランの前に見抜き、書ける場所へ静かに逃がすためのプリフライトを設計した記録です。エラーメッセージへの後追いではなく、走り出す前に足場を確かめてしまう作りに寄せています。
存在チェックが嘘をつく理由
多くのパイプラインは、作業フォルダの再利用可否をこう判定していると思います。
if [ -d "$WORK/.git" ]; then
cd "$WORK" && git pull --rebase origin main
else
git clone --depth 1 "$REPO_URL" "$WORK"
fi
-d "$WORK/.git" は「ディレクトリが存在するか」しか見ていません。所有者が誰であっても、パーミッションがどうであっても、存在さえしていれば真を返します。所有者ドリフトが起きたフォルダは、まさにここで「再利用できる」と誤判定されます。そして git pull --rebase の内部で初めて .git/config や .git/FETCH_HEAD へ書きに行き、そこで初めて崩れる。
つまり失敗が遅いのです。プリフライトで弾けず、実処理の途中まで進んでから転ぶので、ログは「pullの途中で謎の権限エラー」という見え方になります。私は最初、GitHub の認証まわりを疑って小一時間を溶かしました。実際には認証は無関係で、ローカルの .git に触れなかっただけでした。この取り違えは、本番運用で無人ランを止めないためにこそ、先に潰しておきたい落とし穴です。
一次情報として一点補足します。読み取りが通るのは、退避先や親ディレクトリのパーミッションが 755 相当で「その他ユーザーに読み取り+実行」を許していることが多いからです。所有者が nobody でも、他人の私たちはディレクトリを辿って中を読める。書き込みビットだけが自分に無い。この一行の非対称が、症状の全てを説明します。
書けるかどうかは、実際に書いて確かめる
存在チェックが嘘をつくなら、判定を「実際に書けたか」に置き換えます。所有者UIDの比較(stat で取れます)も補助にはなりますが、最終的な真実は「今の自分がそのディレクトリ配下に一時ファイルを作れたか」です。マウント形態やACL次第で、UID一致でも書けない・UID不一致でも書ける、という食い違いが起きうるからです。私は所有者UIDを警戒のシグナルとして使い、可否は実書き込みで決める、という二段構えを推奨します。
次の関数が、この記事の核です。候補ディレクトリを受け取り、実際に一時ファイルを作って消し、書けたら真を返します。
# 実書き込みで「今の自分が書けるか」を確かめる。存在や所有者UIDだけに頼らない。
is_writable_dir() {
local dir="$1"
[ -d "$dir" ] || return 1
# ディレクトリ直下に一意名の probe を作り、成否で判定する
local probe="${dir}/.wtest.$$.$RANDOM"
if ( set -C; : > "$probe" ) 2>/dev/null; then
rm -f "$probe" 2>/dev/null
return 0
fi
return 1
}
# 所有者UIDが自分と一致するか(警戒シグナル。可否判定そのものには使わない)
owner_is_me() {
local dir="$1"
[ -d "$dir" ] || return 1
local owner_uid
owner_uid="$(stat -c '%u' "$dir" 2>/dev/null || stat -f '%u' "$dir" 2>/dev/null)"
[ "$owner_uid" = "$(id -u)" ]
}
set -C(noclobber)を効かせた上でリダイレクトしているのは、万一 probe 名が衝突しても既存ファイルを潰さないためです。$$(PID)と $RANDOM を混ぜて衝突をほぼ起こさないようにしていますが、安全側に倒しておきます。stat は Linux(-c)と macOS/BSD(-f)で書式が違うので両方を試すのが実務的な対処です。無人ランはどちらの基盤に載るか分からないので、こういう小さな差異を先に吸収しておくと、後で「片方の環境だけで落ちる」を防げます。
三段フォールバックで、必ず書ける場所に着地する
判定関数ができたら、あとは着地戦略です。私が使っているのは次の3段です。
| 段 | やること | 成立する前提 |
| 1 | 永続フォルダをそのまま再利用 | 存在し、かつ実書き込みが通る |
| 2 | 壊れた永続フォルダを削除して作り直す | 親ディレクトリに書けて、削除できる |
| 3 | ユーザー専用の退避先へ逃がす | 削除できない(他人所有)。$HOME 配下は必ず自分が書ける |
3段目が肝です。所有者ドリフトしたフォルダは、他人(nobody)の持ち物なので rm -rf すら弾かれることがあります。ここで粘っても勝てません。潔く諦めて、確実に自分が書ける $HOME/repos のような退避先に切り替える。無人ランでは「理想の場所に固執して止まる」より「書ける場所で走り切る」ほうが正しい、というのが繰り返し痛感した判断です。私はこの退避優先の割り切りを好みます。
# 候補を優先順で並べ、最初に「書ける場所」を確定する。
# 返り値は選ばれた作業ディレクトリのパス(標準出力)。
resolve_workdir() {
local site="$1" # 例: antigravitylab.net
local primary="/tmp/repos/${site}" # 使い回したい永続フォルダ
local fallback="${HOME}/repos/${site}"
# --- 段1: 既存の永続フォルダをそのまま使えるか ---
if [ -d "$primary/.git" ] && is_writable_dir "$primary"; then
if owner_is_me "$primary"; then
echo "$primary"; return 0
fi
# 書けるが所有者が自分でない稀なケース。書ければ許容するが記録は残す。
echo "WARN: $primary は書けるが所有者UIDが不一致(ACL等)" >&2
echo "$primary"; return 0
fi
# --- 段2: 壊れているなら削除して作り直す ---
if [ -e "$primary" ]; then
echo "INFO: $primary が再利用不可。削除を試みます" >&2
if rm -rf "$primary" 2>/dev/null && mkdir -p "$primary" 2>/dev/null && is_writable_dir "$primary"; then
echo "$primary"; return 0
fi
echo "WARN: $primary を削除・再作成できません(おそらく他ユーザー所有)" >&2
else
# まだ無いだけなら作れるか試す
if mkdir -p "$primary" 2>/dev/null && is_writable_dir "$primary"; then
echo "$primary"; return 0
fi
fi
# --- 段3: ユーザー専用の退避先へ逃がす ---
mkdir -p "$fallback" 2>/dev/null
if is_writable_dir "$fallback"; then
echo "INFO: 退避先 $fallback を使用します" >&2
echo "$fallback"; return 0
fi
echo "FATAL: 書き込める作業ディレクトリを確保できませんでした" >&2
return 1
}
呼び出し側は、パスを決め打ちにせず、この関数の返り値を使います。
SITE="antigravitylab.net"
WORK="$(resolve_workdir "$SITE")" || { echo "作業フォルダ確保に失敗。ランを中止します"; exit 1; }
echo "作業ディレクトリ: $WORK"
# ここから先は $WORK が「必ず書ける」前提で clone / pull を分岐できる
if [ -d "$WORK/.git" ]; then
git config --global --add safe.directory "$WORK"
cd "$WORK"
git remote set-url origin "https://${TOKEN}@github.com/${SITE%.*}/${SITE}.git"
git reset --hard HEAD; git clean -fd
git pull --rebase origin main
else
git clone --depth 1 --branch main "https://${TOKEN}@github.com/${SITE%.*}/${SITE}.git" "$WORK"
cd "$WORK"
fi
git config user.email "you@example.com"
git config user.name "Your Name"
書けても Git が拒む『二枚目の壁』
git config --global --add safe.directory "$WORK" を挟んでいるのには理由があります。所有者が変わった直後のフォルダを再利用する際、Git が detected dubious ownership で全操作を拒否することがあるからです。書き込み自体は通るのに、Git のオーナーシップ検査で止まる、という別レイヤーの壁です。所有者ドリフトを扱うなら、この一行はほぼ必須の同伴者になります。私自身、書けるようになった直後にこの検査で二度目の足止めを食らって、対応を一行分け忘れていたことに気づきました。
順序も大切です。safe.directory の登録は cd や git reset より前に置きます。dubious ownership の状態では git のほとんどのサブコマンドが即座に弾かれるため、後から登録しようとしても、その git config 自体は通っても手前の reset で既に落ちている、という取りこぼしが起きます。書込可否の確認とオーナーシップ登録は、二枚重ねの壁として一続きに扱うのが安全です。
6週間、無人で回して分かったこと
この設計を入れる前と後で、同じ4サイトのスケジュールを回した記録を残しておきます。母数はプリフライト導入後の6週間・47ランです。
| 項目 | 導入前(体感) | 導入後(実測) |
| 所有者ドリフト遭遇 | 月に数回、朝に手当て | 47ラン中5回 |
| そのうち自動復旧 | 0(都度手動) | 5/5(全て退避先で完走) |
| 復旧の内訳 | — | 段2成功1回・段3退避4回(約80%) |
| プリフライトの追加時間 | — | 1ランあたり約40〜120ms |
数字にすると地味ですが、私にとって効いたのは「朝の手当てがゼロになった」一点です。5回のうち約80%にあたる4回は段3の退避で走り切っていて、/tmp の永続フォルダが他ユーザー所有になっても、その日の記事生成そのものは止まりませんでした。段2(削除して作り直し)で足りたのは1回だけ。つまり所有者ドリフトの大半は「もう自分では消せない」状態で見つかり、削除に賭けるより退避に賭けるほうが実運用では堅い、という肌感が数字とも一致しました。
プリフライトのコストは1ランあたり最大でも120ms程度でした。probe ファイルの作成と削除、stat 一回、mkdir -p の試行を足した程度なので、数十分かかる無人ランの前段としては誤差の範囲です。ここをケチって存在チェックのまま走らせ、pullの途中で転んで半端な状態を残すほうが、後始末の総コストはずっと高くつきます。
二度と同じ朝を迎えないための冪等化
最後に、この関数を無人ランに組み込むときの注意です。resolve_workdir は何度呼んでも同じ結論に落ち着くように書いておくことが大切です。同じランが途中で再試行されても、また別のフォルダに散らばらないよう、次の3点を守っています。
- probe ファイルは必ず消す。判定のたびにゴミを残さない
- 段2の削除は失敗しても致命傷にしない。次段へ静かに落ちる
- 退避先は毎回同じパスに固定する。再試行で増殖させない
無人運用で作業フォルダが増殖すると、次はディスク枯渇という別の朝が待っています。増やさない設計は、そのまま安定運用の設計でもあります。
作業フォルダの再利用は、無人スケジュールの速度と安定を両立させる素直な工夫です。ただ「在るか」ではなく「今の自分が書けるか」を確かめる一手間を足すだけで、朝の赤いログが一つ減ります。スケジュール実行の足場づくりについては無人ランのログをSQLiteに流し込み失敗を横断で数える設計や、Antigravity CLIをスケジュール実行するパイプライン設計も合わせて読むと、足場から集計までひとつながりに整えられます。書き込みの原子性という別角度からは無人エージェントの中途半端な書き込みを防ぐガードが近い話をしています。
まずは手元のパイプラインの [ -d "$WORK/.git" ] を is_writable_dir "$WORK" に一つ置き換えるところから試してみてください。私自身まだ無人運用の勘所を手探りしている途中ですが、朝のログが静かな緑で埋まっている日が続くと、少しだけ胸をなでおろします。お読みいただきありがとうございました。