8月14日という日付をカレンダーに書き込んでから、自分の手元で何が変わるのかを確かめる作業に半日を使いました。
Claude Code の auto モードが、Pro・Max・Team の既定として有効になります。auto モードは各ステップごとに承認を求めず、「取り消せない・破壊的・自分の環境の外へ向かう」と判定された操作でだけ止まります。
言葉としては、迷いようがないほど明快です。
けれど自分の環境に当てはめようとした瞬間に、手が止まりました。個人開発でアプリのビルド成果物やスクリーンショットの整理を夜間に走らせていると、削除も上書きも日常的に混ざります。そのうちのどれが「取り消せない」のか、私は即答できませんでした。
答えを出すために、リポジトリを一本まるごと使って測ってみました。結果として得られたのは、「危険なコマンドの一覧」を作ろうとしていた自分の前提そのものが間違っていた、という気づきでした。
「取り消せない」を操作の名前で決めようとして詰まった
最初にやったのは、多くの人がやるのと同じことです。手元のシェル履歴を眺めて、危なそうなコマンドを書き出しました。
rm -rf、git push --force、git clean -fdx、npm publish、外部 API への POST。
書き出したところで、リストが役に立たないことに気づきます。同じ rm -rf でも、git が追跡しているディレクトリを消したときと、.gitignore に入っているディレクトリを消したときでは、被害の性質がまったく違います。前者は数百ミリ秒で完全に戻ります。後者は永久に戻りません。
つまり「取り消せるかどうか」は、コマンドの属性ではありませんでした。そのコマンドが、その瞬間に、どんな状態のファイルへ向けられているかという、状態の属性です。
コマンド名の列挙で表現できないものを、コマンド名の列挙で表現しようとしていた。これが半日の最初の1時間で分かったことです。
私はこの気づきを、deny ルールを書き始める前に得られてよかったと感じています。列挙型のルールは、増やすほど自分の首を絞めます。ルールセットが膨らんだ後の棚卸しについては 許可ルールを積み上げたセッションが毎ターン重くなる — ルールセットの棚卸しと、安全性を保ったまま数を減らす設計 に別途まとめています。
可逆性を測るプローブを書く
状態の属性であるなら、実行前に状態を測ればよいことになります。
測る対象は、対象パス配下のファイルのうち git から戻せないもののバイト数 です。追跡されていてクリーンなファイルは戻ります。追跡されていない、あるいは無視されているファイルは戻りません。
以下が実際に使ったプローブです。標準ライブラリだけで動きます。
#!/usr/bin/env python3
"""指定パス配下のファイルを「git から戻せるか」で分類し、復旧不能なバイト数を出す。"""
import os, subprocess, sys, collections
def git(*args):
r = subprocess.run(["git", *args], capture_output=True, text=True)
return [p for p in r.stdout.split("\0") if p]
def classify(root="."):
tracked = set(git("ls-files", "-z"))
ignored = set(git("ls-files", "-z", "--others", "--ignored", "--exclude-standard"))
untracked = set(git("ls-files", "-z", "--others", "--exclude-standard"))
dirty = set(git("diff", "-z", "--name-only"))
staged = set(git("diff", "-z", "--name-only", "--cached"))
buckets = collections.defaultdict(lambda: [0, 0]) # [ファイル数, バイト数]
for dirpath, dirnames, filenames in os.walk(root):
if ".git" in dirpath.split(os.sep):
continue
for name in filenames:
path = os.path.relpath(os.path.join(dirpath, name), root)
if path in ignored: kind = "ignored" # 復旧不能
elif path in untracked: kind = "untracked" # 復旧不能
elif path in staged: kind = "tracked-staged" # index からのみ
elif path in dirty: kind = "tracked-modified" # 変更分は失う
elif path in tracked: kind = "tracked-clean" # 完全復旧
else: kind = "unknown"
try:
size = os.path.getsize(os.path.join(root, path))
except OSError:
continue
buckets[kind][0] += 1
buckets[kind][1] += size
return buckets
RECOVERABLE = {"tracked-clean"}
if __name__ == "__main__":
b = classify(sys.argv[1] if len(sys.argv) > 1 else ".")
lost_f = lost_b = tot_f = tot_b = 0
for kind, (n, size) in sorted(b.items()):
tot_f += n; tot_b += size
if kind not in RECOVERABLE:
lost_f += n; lost_b += size
print(f"{kind:18} files={n:6d} bytes={size:12,d}")
print("-" * 46)
print(f"復旧不能 files={lost_f}/{tot_f} ({lost_f/tot_f*100:.1f}%) "
f"bytes={lost_b:,}/{tot_b:,} ({lost_b/tot_b*100:.1f}%)")
分類を6つに分けたのには理由があります。tracked-clean だけが無条件に戻ります。tracked-modified は「ファイルは戻るが、まだコミットしていない編集内容は失う」という中間状態で、これを「戻せる」に数えると判断を誤ります。私は RECOVERABLE を意図的に1種類だけにしました。
計測はサンドボックス(Linux 6.8.0 / Python 3.10.12 / git 2.34.1 / 4 vCPU / メモリ 3.9GB)で実際に走らせています。以下の数値はすべてその実行結果です。
実測1 — 同じ rm -rf が、10箇所で可逆・5箇所で不可逆
素材にしたのは、記事 1,678 本を含む実際の Next.js リポジトリ(追跡ファイル 1,790 本・28.9MB)です。ここに、実プロジェクトなら普通に存在する「git に入っていないもの」を足しました。node_modules/ に 400 ファイル、.next/cache/ に 120 ファイル、build/artifacts/ に 40 ファイル、作業メモの scratch/ に 25 ファイル、そして .env。
プローブの出力です。
| 分類 | ファイル数 | バイト数 | 戻せるか |
| tracked-clean | 1,789 | 28,980,257 | 戻る |
| tracked-modified | 1 | 620 | ファイルのみ |
| ignored | 561 | 9,830,453 | 戻らない |
| untracked | 25 | 166 | 戻らない |
合計 2,376 ファイル・38,811,496 バイトのうち、復旧不能は 587 ファイル(24.7%)・9,831,239 バイト(25.3%) でした。
同じプローブを、クローン直後の何も足していない状態で走らせると復旧不能は 0.0% です。ファイルの中身は同じで、違いは「作業を始めてしばらく経ったかどうか」だけです。
さらに、対象ディレクトリを変えながら同じ判定を15箇所へ当てました。
| 対象 | 判定 | 復旧不能ファイル | 復旧不能バイト |
| content/ | 可逆 | 0 | 0 |
| content/articles/ | 可逆 | 0 | 0 |
| src/ | 可逆 | 0 | 0 |
| src/components/ | 可逆 | 0 | 0 |
| public/ | 可逆 | 0 | 0 |
| scripts/ | 可逆 | 0 | 0 |
| .next/ | 不可逆 | 120 | 3,932,160 |
| node_modules/ | 不可逆 | 400 | 3,276,800 |
| scratch/ | 不可逆 | 25 | 166 |
15対象の内訳は 可逆 10 / 不可逆 5 でした。コマンドはすべて rm -rf <対象> の一種類だけです。
面白いのは scratch/ です。復旧不能バイトはわずか 166 バイトで、node_modules/ の 2万分の1にも満たない量です。それでも私にとっての痛みは scratch/ のほうが大きい。node_modules/ は再インストールで戻りますが、書きかけのメモは戻りません。
バイト数は「戻らない量」であって「失う価値」ではありません。 プローブは前者しか測れないので、後者は自分で重みを付ける必要があります。私は .env と作業メモ用のディレクトリだけ、バイト数に関係なく無条件で止める扱いにしました。
実測2 — 破壊は 27ms、復旧は 174ms
可逆だと分かっている対象でも、戻すのはタダではありません。削除と復旧の所要時間を測りました。
まずリポジトリ全体に近い規模での単発計測です。
| 操作 | 所要時間 | ファイル数の変化 |
| rm -rf(追跡下+無視分をまとめて) | 45ms | 2,376 → 111 |
| git restore(HEAD から) | 183ms | 111 → 1,790 |
復旧後、content/ の MDX は 1,678 本すべて戻りました。一方で .env・node_modules/・scratch/ は戻りません。ファイル数が 2,376 ではなく 1,790 に着地しているのが、その差です。
content/ だけを対象に5回繰り返した結果は次のとおりです。
| 回 | 削除 | 復旧 |
| 1 | 28ms | 174ms |
| 2 | 27ms | 172ms |
| 3 | 27ms | 174ms |
| 4 | 31ms | 235ms |
| 5 | 27ms | 187ms |
中央値で削除 27ms・復旧 174ms、およそ 6.4 倍の開きです。単発計測では 45ms 対 183ms で 4.07 倍でした。
この非対称は、感覚として持っておく価値があると私は考えています。エージェントに任せた処理が暴走したとき、破壊のスループットは復旧のスループットより常に速い。人間が気づいて手を伸ばすまでの数秒間に、その速度差の分だけ差が開きます。
実測3 — force push は「リモートに残るか」では決まらない
「自分の環境の外へ向かう」の代表格が git push --force です。ここは削除と違って、可逆性の判定がもっと直感に反しました。
bare リポジトリをリモートに見立てて、次の順序で再現しました。
- 同僚のコミットに相当する
hotfix を push する
- 別マシンを模した2つ目のクローンを取る
- 元のクローンで
git reset --hard HEAD~1 して履歴を書き換え、--force で push する
force push の直後にリモートを調べた結果です。
| 調べたこと | 結果 |
| リモートの core.logAllRefUpdates | 未設定(bare の既定は無効) |
| リモートの reflog エントリ数 | 0 件 |
| 上書きされたコミットのオブジェクト | 存在する |
| ローカルの reflog エントリ数 | 4 件 |
| 先にクローンしていた別マシン | オブジェクトを保持 |
上書きされたコミットは、リモートから消えていませんでした。ただし、それを指す名前がありません。bare リポジトリは既定で reflog を記録しないため、リモート側だけを見ても「さっきまで main が指していた SHA」を知る方法がないのです。
オブジェクトは残っているのに、たどり着けない。この状態を「復旧できる」と呼ぶのは無理があります。
続けてリモートで git reflog expire --expire=now --all と git gc --prune=now を走らせました。所要 32ms で、オブジェクトは実際に消えました。
一方、先にクローンしていた別マシンからは復元できました。
# 別マシン側で、失われたコミットを新しいブランチ名として押し戻す
git push origin <失われたSHA>:refs/heads/rescue
# → 20ms で完了。リモートに再びオブジェクトが現れる
つまり force push の可逆性は、リモートの状態ではなく その時点で誰がその SHA を保持しているか で決まります。自分のローカル reflog、または force push より前にフェッチしていた誰かのクローン。どちらも存在しなければ、gc を待たずとも実質的に復旧不能です。
削除の話とまったく同じ構造でした。可逆性は操作の名前ではなく、実行時点の分散した状態の関数です。
台帳から deny ルールを導く
ここまでで、判定に必要な材料が揃いました。あとは、これを実行前の判断に変換します。
軽量版のプローブです。git status を pathspec 付きで1回だけ呼び、復旧不能なものがあれば終了コード 1 を返します。
#!/usr/bin/env python3
"""対象パスだけを見る軽量版。復旧不能なものがあれば exit 1。"""
import os, subprocess, sys
def scoped_unrecoverable(path):
r = subprocess.run(
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all",
"--ignored=matching", "--", path],
capture_output=True, text=True)
lost_files = lost_bytes = 0
for entry in r.stdout.split("\0"):
if len(entry) < 4:
continue
code, name = entry[:2], entry[3:]
# ?? = untracked, !! = ignored — どちらも git からは戻らない
if code not in ("??", "!!"):
continue
if os.path.isdir(name):
for dp, _, fs in os.walk(name):
for f in fs:
lost_files += 1
try: lost_bytes += os.path.getsize(os.path.join(dp, f))
except OSError: pass
else:
lost_files += 1
try: lost_bytes += os.path.getsize(name)
except OSError: pass
return lost_files, lost_bytes
if __name__ == "__main__":
target = sys.argv[1]
f, b = scoped_unrecoverable(target)
print(f"{target}: 復旧不能 {f} files / {b:,} bytes")
sys.exit(1 if f else 0)
実行結果は次のとおりです。
| 対象 | 復旧不能 | 平均所要時間 |
| content/ | 0 ファイル / 0 バイト | 61ms |
| node_modules/ | 400 ファイル / 3,276,800 バイト | 29ms |
| scratch/ | 25 ファイル / 166 バイト | 27ms |
| リポジトリ全体 | 546 ファイル / 7,209,147 バイト | 32ms |
この結果を deny ルールへ落とすときに、私は「復旧不能バイトが 0 でない場所」をそのまま列挙するのではなく、分類ごとに扱いを分ける方針を採りました。
| 状態 | 扱い | 理由 |
| ignored かつ再生成できる(node_modules・.next) | 許可 | 戻らないが、作り直せる |
| ignored かつ再生成できない(.env・鍵) | hard deny | 失うと業務が止まる |
| untracked(scratch・作業メモ) | 確認 | 量は小さいが、代替が存在しない |
| tracked-modified | 確認 | ファイルは戻るが編集内容は消える |
| tracked-clean のみ | 許可 | 174ms で完全に戻る |
「再生成できるか」という軸は、プローブでは測れません。ここだけは人間が一度決める必要があります。判断に迷う場合は、いったん確認扱いへ寄せることを推奨します。許可へ倒して失うより、確認が一度増えるほうが安いためです。私は個人開発の4つのリポジトリで、この表を1枚作るのに30分ほどかけました。一度作れば、あとは実行時にプローブが当てはめてくれます。
事前判定をフックに載せるときのコスト
判定を PreToolUse フックに載せる場合、実行のたびに数十ミリ秒が乗ります。この内訳を測ったところ、想定と違う場所にコストがありました。
| 計測対象 | 中央値 |
| git status(リポジトリ全体) | 6.2ms |
| git status(content/ に限定) | 5.3ms |
| git status(node_modules/ に限定) | 3.2ms |
| python3 -c pass(起動のみ) | 17.3ms |
| python3 -S -c pass(site 読み込み省略) | 8.1ms |
| scoped_probe.py src(全体) | 26.6ms |
| bash + git status のみ | 5.5ms |
判定ロジックそのものは 3〜6ms しかかかっていません。支配的なのは Python インタプリタの起動(17.3ms) でした。-S を付けて site の読み込みを飛ばすだけで 8.1ms まで落ちます。
つまり、判定アルゴリズムを賢くしても体感は変わりません。フックは常駐プロセスではないので、削るべきは起動コストのほうです。私は最終的に、判定を Python から git status 一発のシェルスクリプトへ書き換えました。5.5ms です。
リポジトリの規模による変化も測っておきました。
| 追跡ファイル数 | 限定版 | 全走査版 |
| 1,000 | 34ms | 67ms |
| 5,000 | 54ms | 182ms |
| 20,000 | 88ms | 582ms |
2万ファイルになると全走査版は 582ms かかり、対話的な作業には重すぎます。限定版は 88ms に収まりました。pathspec で対象を絞る設計は、規模が大きいほど効いてきます。
フック自体の実装パターンと、権限モードを変えても素通りしてしまう経路については 権限モードを変えても止まらない書き込み — サブエージェント用 PreToolUse ゲートを150通りで測る で別途扱っています。
つまずきやすい点
半日のあいだに実際に踏んだ落とし穴を、回避方法と一緒に残しておきます。本番のリポジトリで同じ間違いをすると、プローブが安全だと誤って報告する側へ倒れます。
--ignored を付け忘れると復旧不能分が見えない
git status --porcelain は既定で無視されたファイルを出力しません。--ignored=matching を付けないと .env も node_modules/ も一切現れず、プローブは「復旧不能 0 件」と報告します。安全だと誤報告するプローブは、無いより危険です。
--untracked-files=all を省くとディレクトリ単位でしか出ない
既定の normal では、未追跡のディレクトリは scratch/ という1エントリに畳まれます。ファイル数もバイト数も数えられません。all を指定して初めて 25 ファイル・166 バイトという数字が出ました。
tracked-modified を「戻せる」に数えない
ファイルパスとしては復元されるので、つい可逆側へ入れたくなります。しかし戻るのは HEAD の内容で、その日の編集は消えます。私はここで一度、判定を甘くしたまま計測を進めてしまい、数値が合わずに戻りました。
リモートの reflog を当てにしない
bare リポジトリの core.logAllRefUpdates は既定で無効です。「リモートに残っているはず」という前提で復旧手順を書くと、いざというときに名前がなくてたどり着けません。復旧経路として当てにできるのは、自分の reflog か、force push より前にフェッチしていた別クローンです。
チェックポイントを意図的に置いて巻き戻せる状態を保つ設計は Claude Code の大規模リファクタを1コミット単位で巻き戻せる状態に保つ — チェックポイント設計とロールバック検知の実装メモ にまとめてあります。
明日の朝までにできる一歩
一番効くのは、いま作業しているリポジトリでプローブを1回走らせて、復旧不能なバイト数を実際の数字として見ることです。私の環境では 25.3% でした。0% だと思い込んでいた数字です。
数字が想定より大きかったこの場合は、まず失うと困るものの置き場所を決め直すところから手を付けると解決が早くなります。その数字を見てから deny ルールを書くと、書く順番が変わります。危なそうなコマンドを並べるのではなく、失うと困るものが置かれている場所を先に押さえる形になります。
8月14日は近いですが、判断の材料を作るのに必要なのは数十ミリ秒の実行と、再生成できるかどうかを1度決める30分です。auto モードを止めるか使うかという二択の前に、自分の環境の可逆性を数字で持っておけると、選び方そのものが落ち着きます。
私自身、App Store と Google Play へ出しているアプリのリポジトリを4本抱えていて、そのうち2本はこの計測をするまで .env の扱いが曖昧なままでした。同じ場所でつまずく方が減れば嬉しく思います。お読みいただきありがとうございました。