a/ analytics note .jp

TECH · field log

Claude Code コミット規律フックの実装 ─ AI が破壊的操作をする前に強制コミットさせる

git reset --hard や git clean を実行する前に未コミット変更を検出してブロックする pre-destructive-git フックと、変更蓄積を監視する commit-reminder フックの設計・実装を解説します。

· 24 min read · #Claude Code / #AI / #Git / #フック / #開発自動化 / #コミット規律 · AI-assisted · reviewed Share on X はてブ Zennにクロスポスト

目次


はじめに ─ 前回記事との位置づけ

前回の記事「Claude Code カスタムフックで AI エージェントの暴走を防ぐ ─ PreToolUse / PostToolUse 設計パターン」では、hooks.json の基本構造と、実装できるガードレールの全体像を解説しました。PreToolUse・PostToolUse の仕組み、exit code によるブロック制御、秘密情報検出や CI 自動確認といった実装例を取り上げた記事です。

今回はその実践編として、Claude Code フック機構を活用したコミット規律フックに特化した詳細実装を解説します。特に pre-destructive-gitcommit-reminder の2つのフックに焦点を当て、設計の背景から実装の細部、運用で見えてきたチューニングポイントまでを掘り下げます。

フック機構の基礎については前回記事を参照してください。本記事では「なぜコミット規律が必要か」から入り、実際のコードを読み解きながら進めていきます。


なぜコミット規律が重要か ─ AIエージェントが起こした事故

発端となった事故

AI コーディングエージェントを使っていると、未コミットの変更が AI の操作で消える事故は誰にでも起こり得ます。たとえば、コンポーネントのリファクタリング中にいくつかのファイルを編集し、テストが通った段階で一息入れたとします。その間に別のタスクを AI に依頼していたところ、エラーを解消しようとした AI がリポジトリを「クリーンな状態に戻す」目的で git reset --hard HEAD を実行する ── これだけで数時間分の実装が消失します。

コミットしていなかった自分にも落ち度はあります。しかし AI が確認なしに復元不可能な操作を実行する点は、Claude Code に限らず AI コーディングツール全般に共通するリスクです。ここから「破壊的操作の前には必ず未コミット変更を確認させる」という要件が生まれました。

AI エージェントの「後でコミットしよう」問題

人間であれば「後でまとめてコミットしよう」という判断はある程度合理的です。しかし AI エージェントの場合、この判断が特に問題になる事情があります。

まず、AI エージェントはセッションをまたいで状態を引き継ぎません。あるセッションで「後でコミットする」という意図があっても、次のセッションではその文脈が消えています。次のセッションで別の指示を受けた AI が、前回の未コミット変更を意識せずに操作する可能性があります。

また AI は「今やっていること」に集中するため、副作用として未コミット変更が消えるケースを考慮しないことがあります。git checkout feature/xxx でブランチを切り替えようとして未コミット変更がある場合、エラーを解消するために git stashgit checkout -- . を実行するかもしれません。意図したアクションの副作用として変更が消えるパターンです。

さらに AI は「コミット規律」という抽象的なルールを状況によって後回しにしがちです。「今は実装を進めることが優先」という判断で、コミットを省略します。しかしルールドキュメントだけでは強制力がなく、AI が守り続けることは保証できません。

コミット規律フックが解決すること

これらの問題に対して、コミット規律フックは技術的な強制力を提供します。ルールを読んでいるかどうかに関係なく、破壊的操作の前には必ず未コミット変更の有無を確認します。変更がある場合は操作をブロックし、コミットを先に促します。

この仕組みによって「ルールは知っているが状況によって省略してしまう」というパターンを防ぎます。


設計思想: ブロックとリマインダーの二段構え

コミット規律フックは2つのスクリプトで構成されています。それぞれの役割を明確に分けています。

フックタイミング動作exit code
pre-destructive-gitPreToolUse(破壊的操作の直前)ブロック2(未コミット変更あり時)
commit-reminderPostToolUse(Write・Edit・Bash 後)警告のみ0

この二段構えには明確な意図があります。

ブロックは「復元不可能な状況」だけに使う。すべての操作をブロックで縛ると、AI の作業効率が著しく落ちます。また「コミットしろ」と言われ続けるフローは開発の邪魔になります。破壊的操作の前、つまり「変更が消える寸前」という限られた場面でのみブロックします。

リマインダーは「気づかせる」ことに特化する。変更が5件以上蓄積したらコミットを促すメッセージを出しますが、作業をブロックはしません。気づきを与えるだけで判断は AI(や人間)に委ねます。強制ではなく習慣化を促す仕組みです。

この使い分けは、前回記事で紹介した「ブロックと警告を使い分ける」設計原則に基づいています。

{
  "PreToolUse": [
    {
      "id": "pre-destructive-git",
      "description": "破壊的git操作の前に未コミット変更を検出してブロック",
      "matcher": "Bash",
      "command": "node .claude/scripts/pre-destructive-git.js"
    }
  ],
  "PostToolUse": [
    {
      "id": "commit-reminder",
      "description": "未コミット変更が5件を超えたらリマインドする",
      "matcher": "Write|Edit|Bash",
      "command": "node .claude/scripts/commit-reminder.js"
    }
  ]
}

pre-destructive-git の matcher は Bash のみです。破壊的な git 操作は必ず Bash ツール経由で実行されるため、Bash ツールの実行前だけを監視すれば十分です。

commit-reminder の matcher は Write|Edit|Bash と幅広く設定しています。ファイルの書き込みや編集、コマンド実行のたびに変更蓄積を確認します。ただし後述するクールダウン機構により、頻繁な出力は避けています。


破壊的操作の検出パターン設計

pre-destructive-git の核心は、コマンド文字列から「破壊的な git 操作かどうか」を判定する正規表現パターン群です。

const DESTRUCTIVE_PATTERNS = [
  /\bgit\s+checkout\s+--\s/,          // git checkout -- <file>
  /\bgit\s+checkout\s+--$/,           // git checkout -- (末尾スペースなし)
  /\bgit\s+checkout\s+\.\b/,          // git checkout .
  /\bgit\s+clean\s+.*-[fd]/,          // git clean -fd, -f, -d
  /\bgit\s+reset\s+--hard\b/,         // git reset --hard
  /\bgit\s+restore\s+\./,             // git restore .
  /\bgit\s+restore\s+--staged\s+\./,  // git restore --staged .
];

各パターンの設計意図を解説します。

git checkout — の検出

/\bgit\s+checkout\s+--\s//\bgit\s+checkout\s+--$/ の2パターンで git checkout -- <path> を検出します。\b(単語境界)を先頭に入れているのは、my-git-checkout-helper のような別コマンドに誤反応しないためです。

-- の後にスペースなしで終わるケース(git checkout --)も別パターンで拾います。シェルのオートコンプリートや AI の生成コードでスペースが省略されるケースに備えています。

git checkout .\bgit\s+checkout\s+\.\b で検出します。. の後に \b を入れているのは、git checkout ./some/file のようなパスが続くケースも含めるためです。

git clean の検出

/\bgit\s+clean\s+.*-[fd]/git clean -fdgit clean -fgit clean -dgit clean -n -fd など -f または -d フラグを含む実行形式を検出します。-n(ドライラン)は実際には削除しないため、-n だけのケースはブロックしません。-f-d が含まれる場合にのみブロックします。

git reset —hard の検出

/\bgit\s+reset\s+--hard\b/ でシンプルに検出します。git reset --hard の後に続く文字列(HEAD、コミットハッシュ、ブランチ名など)は問いません。--hard フラグ自体が「ワーキングツリーを強制的に変更する」意味を持つためです。

git restore の検出

git restoregit checkout -- の現代的な代替コマンドです。git restore .git restore --staged . をパターンで検出します。git restore <specific-file> についても、本来は . を対象にする操作と同等のリスクがありますが、特定ファイルの復元は意図的なケースが多いため、現在は .(カレントディレクトリ全体)に絞っています。

網羅できていない操作

意図的にパターンに含めていない操作もあります。

操作含めない理由
git checkout <branch>ブランチ切り替えは通常の操作。未コミット変更があれば git 自体がエラーを出す
git stash変更を保存してから退避する操作のためリスクが低い
git reset --soft / --mixedワーキングツリーには影響しない
git restore <specific-file>特定ファイルの意図的な復元は許容

pre-destructive-git の実装詳解

完全な実装を読み解いていきます。

#!/usr/bin/env node
// .claude/scripts/pre-destructive-git.js
// 破壊的 git 操作の前に未コミット変更を検出してブロックする

const { execSync } = require("child_process");
const { shouldRun } = require("./hook-gate");

if (!shouldRun("pre-destructive-git")) process.exit(0);

// ブロック対象のパターン
const DESTRUCTIVE_PATTERNS = [
  /\bgit\s+checkout\s+--\s/,
  /\bgit\s+checkout\s+--$/,
  /\bgit\s+checkout\s+\.\b/,
  /\bgit\s+clean\s+.*-[fd]/,
  /\bgit\s+reset\s+--hard\b/,
  /\bgit\s+restore\s+\./,
  /\bgit\s+restore\s+--staged\s+\./,
];

// stdin から入力を読む(Claude Code は STDIN に JSON を流す)
let input = "";
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
  let command = "";
  try {
    const parsed = JSON.parse(input);
    command = parsed.command || parsed.input?.command || "";
  } catch {
    command = input;
  }

  const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
  if (!isDestructive) process.exit(0);

  // 未コミット変更を確認
  let status = "";
  try {
    status = execSync("git status --porcelain", {
      encoding: "utf-8",
      stdio: ["pipe", "pipe", "pipe"]
    }).trim();
  } catch {
    process.exit(0); // git 操作自体が失敗する環境では通す
  }

  if (!status) process.exit(0); // クリーンなら許可

  const changedFiles = status.split("\n").filter(Boolean);
  const count = changedFiles.length;

  process.stderr.write(
    `\n❌ ブロック: 未コミット変更が ${count} 件あります\n\n` +
    `変更ファイル:\n` +
    changedFiles.slice(0, 10).map(f => `  ${f}`).join("\n") +
    (count > 10 ? `\n  ...他 ${count - 10} 件` : "") +
    `\n\n` +
    `先に以下を実行してください:\n` +
    `  git add -A && git commit -m "chore: WIP save"\n\n` +
    `または、変更を意図的に捨てる場合は以下を直接実行:\n` +
    `  ${command}\n`
  );
  process.exit(2); // exit 2 でブロック
});

STDIN からの JSON 読み取り

Claude Code はフックスクリプトに対して、実行しようとしたツールの入力を STDIN に JSON として流します。前回記事の実装例では process.argv[2] でコマンド引数から受け取っていましたが、STDIN 経由のほうがより確実です。

let input = "";
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
  let command = "";
  try {
    const parsed = JSON.parse(input);
    command = parsed.command || parsed.input?.command || "";
  } catch {
    command = input;
  }
  // ...
});

parsed.commandparsed.input?.command の両方を試しているのは、Claude Code のバージョンによって JSON 構造が微妙に異なるケースに対応するためです。どちらでも取れなければ input をそのまま文字列として使います。

非同期の stdin.on("end") コールバック内で処理するため、スクリプト全体の制御フローはこのコールバックの中に収まっています。

git status —porcelain の活用

未コミット変更の確認には git status --porcelain を使います。--porcelain オプションはマシン可読な形式で出力します。変更がなければ空文字列、変更があれば1行1ファイルの形式で出力されます。

M  apps/site/src/content/blog/article.mdx
?? apps/site/src/components/NewComponent.astro
D  apps/site/src/utils/old-helper.ts

先頭2文字がステータスコードです。M(Modified)、??(Untracked)、D(Deleted)など、インデックスとワーキングツリーそれぞれの状態が表示されます。フックではファイル数のカウントと一覧表示にのみ使うため、ステータスコードの解析はしていません。

stdio: ["pipe", "pipe", "pipe"] を指定しているのは、git コマンドの stderr が呼び出し元のターミナルに直接流れないようにするためです。git リポジトリ外で実行された場合のエラー出力を抑制します。

エラー時の安全な通過

try {
  status = execSync("git status --porcelain", { ... }).trim();
} catch {
  process.exit(0); // git 操作自体が失敗する環境では通す
}

git リポジトリ外のディレクトリで実行された場合や、git コマンドが存在しない環境では execSync が例外をスローします。このような場合は安全側として process.exit(0) でブロックせずに通過させます。フックの目的は「守りたい変更を誤って消さない」ことであり、フックが壊れているせいで正常な操作がブロックされる状況は避けたいためです。

exit code 2 のブロック仕様

Claude Code のフック仕様では、exit code の意味は次のとおりです。

exit code動作
0ツール実行を許可
1警告(ツール実行は継続)
2ブロック(ツール実行をキャンセル)

exit code 2 を返すと、Claude Code は Bash ツールの実行をキャンセルし、フックが stderr に出力したメッセージを AI の会話に表示します。AI はこのメッセージを見て、次のアクションを判断します。

エラーメッセージの設計

process.stderr.write(
  `\n❌ ブロック: 未コミット変更が ${count} 件あります\n\n` +
  `変更ファイル:\n` +
  changedFiles.slice(0, 10).map(f => `  ${f}`).join("\n") +
  (count > 10 ? `\n  ...他 ${count - 10} 件` : "") +
  `\n\n` +
  `先に以下を実行してください:\n` +
  `  git add -A && git commit -m "chore: WIP save"\n\n` +
  `または、変更を意図的に捨てる場合は以下を直接実行:\n` +
  `  ${command}\n`
);

エラーメッセージには3つの要素を含めています。

1. 状況の説明: 何件の未コミット変更があるかと、最大10件のファイル一覧。AI が「どの変更が影響を受けるか」を把握できます。10件を超える場合は省略表示にして、メッセージが長くなりすぎないようにしています。

2. 推奨アクション: git add -A && git commit -m "chore: WIP save" という具体的なコマンドを示します。AI はこのコマンドをそのまま実行できます。

3. エスケープハッチ: 「変更を意図的に捨てる場合は直接実行してください」という案内と、元の操作コマンドを再掲します。フックによって「どうしても実行できない」という事態を避けます。ユーザーや AI が意図的に変更を破棄したい場合は、フックを通さずに直接実行するという選択肢を明示します。


commit-reminder の実装詳解

#!/usr/bin/env node
// .claude/scripts/commit-reminder.js

const { execSync } = require("child_process");
const { shouldRun } = require("./hook-gate");

if (!shouldRun("commit-reminder")) process.exit(0);

const THRESHOLD = parseInt(process.env.COMMIT_REMINDER_THRESHOLD || "5", 10);

let status = "";
try {
  status = execSync("git status --porcelain", {
    encoding: "utf-8",
    stdio: ["pipe", "pipe", "pipe"]
  }).trim();
} catch {
  process.exit(0);
}

if (!status) process.exit(0);

const changedFiles = status.split("\n").filter(Boolean);
if (changedFiles.length < THRESHOLD) process.exit(0);

// 同じリマインドを連続で出さないようにクールダウン(60秒)
const REMINDER_STATE_FILE = "/tmp/.claude-commit-reminder-last";
const fs = require("fs");
try {
  const last = parseInt(fs.readFileSync(REMINDER_STATE_FILE, "utf-8").trim(), 10);
  if (Date.now() - last < 60_000) process.exit(0); // 60秒以内は出さない
} catch { /* ファイルなし = 初回 */ }
fs.writeFileSync(REMINDER_STATE_FILE, String(Date.now()));

process.stdout.write(
  `\n💾 コミット推奨: 未コミット変更が ${changedFiles.length} 件あります\n` +
  `論理的な区切りでコミットしてください。\n` +
  `  git add -A && git commit -m "feat: <説明>"\n\n`
);
process.exit(0); // 警告のみ、ブロックしない

閾値設計: なぜ5件か

COMMIT_REMINDER_THRESHOLD のデフォルト値は5件です。この数値は「論理的なコミット単位として多すぎる」ラインを意識しています。

1〜2件の変更は細かいコミットになりすぎて実用的でないケースがあります。3〜4件は1つの機能や修正のまとまりとして自然な範囲です。5件以上になると、複数の独立した変更が混在している可能性が高くなります。

ただし適切な閾値はプロジェクトや作業内容によって異なります。環境変数 COMMIT_REMINDER_THRESHOLD で調整できるようにしています。

# CI 環境では閾値を上げる
COMMIT_REMINDER_THRESHOLD=10 node .claude/scripts/commit-reminder.js

# 厳格な運用の場合
COMMIT_REMINDER_THRESHOLD=3 node .claude/scripts/commit-reminder.js

クールダウン機構: 60秒以内は再出力しない

commit-reminderPostToolUseWrite|Edit|Bash にマッチするため、ファイル編集やコマンド実行のたびに呼ばれます。閾値を超えた状態で連続して作業をしていると、毎回リマインドが出続けて邪魔になります。

この問題をクールダウン機構で解決しています。

const REMINDER_STATE_FILE = "/tmp/.claude-commit-reminder-last";
const fs = require("fs");
try {
  const last = parseInt(fs.readFileSync(REMINDER_STATE_FILE, "utf-8").trim(), 10);
  if (Date.now() - last < 60_000) process.exit(0); // 60秒以内は出さない
} catch { /* ファイルなし = 初回 */ }
fs.writeFileSync(REMINDER_STATE_FILE, String(Date.now()));

最後にリマインドを出した時刻を /tmp/.claude-commit-reminder-last に保存します。60秒以内に再度呼ばれた場合は出力せずに終了します。60秒経過していれば時刻を更新してリマインドを出します。

/tmp を使っているのは、セッションをまたいでも同一マシン上では状態が持続するためです。別マシンや Docker コンテナではリセットされますが、それで構いません。クールダウンはあくまで「同一セッション内での連続出力」を避けるための仕掛けです。

ファイルが存在しない場合(初回実行)は try-catch でエラーを握りつぶし、初回出力を許可します。

stdout vs stderr の使い分け

pre-destructive-git では process.stderr.write を使いましたが、commit-reminder では process.stdout.write を使っています。

この違いは意図的です。pre-destructive-git は「エラー: 操作がブロックされた」という性質のメッセージなので stderr が適切です。一方 commit-reminder は「情報: コミットを推奨します」という性質のメッセージなので stdout に出します。

Claude Code がフックの出力をどのように AI に伝えるかは、stdout と stderr で扱いが異なる可能性があります。エラーメッセージとして強調されてほしいものは stderr に、参考情報として流してほしいものは stdout に出すという方針です。


hook-gate によるフック制御

フックは便利ですが、デバッグ時や特定の環境で一時的に無効化したい場面があります。hook-gate.js はこのための軽量な制御機構です。

// .claude/scripts/hook-gate.js
function shouldRun(hookId) {
  const disabled = (process.env.CLAUDE_DISABLE_HOOKS || "").split(",").map(s => s.trim());
  return !disabled.includes(hookId) && !disabled.includes("all");
}
module.exports = { shouldRun };

各フックスクリプトの冒頭で呼び出します。

const { shouldRun } = require("./hook-gate");
if (!shouldRun("pre-destructive-git")) process.exit(0);

環境変数 CLAUDE_DISABLE_HOOKS にフック ID をカンマ区切りで指定すると、そのフックが無効化されます。all を指定するとすべてのフックが無効になります。

# 特定のフックだけ無効化
CLAUDE_DISABLE_HOOKS=commit-reminder claude

# すべてのフックを無効化(デバッグ時)
CLAUDE_DISABLE_HOOKS=all claude

# 複数のフックを無効化
CLAUDE_DISABLE_HOOKS=pre-destructive-git,commit-reminder claude

フック無効化が必要になるケース

実際の運用で無効化が必要になった場面をまとめます。

CI/CD パイプライン内での実行: 自動化スクリプトが Claude Code を呼び出す場合、フックが人間向けのメッセージを出力したり、操作をブロックしたりするのは困ります。CLAUDE_DISABLE_HOOKS=all を設定して実行します。

大規模なリセット作業: プルリクエストのレビュー後に変更を意図的に全て破棄したいケース。pre-destructive-git を無効化した上で git reset --hard HEAD を実行します。

フック自体のデバッグ: フックスクリプトを修正中、フック自体が発火して作業を妨げる場合。修正中のフック ID を無効化して作業します。

hook-gate の拡張

現在の実装は環境変数による無効化のみですが、拡張の余地があります。

// 拡張版 hook-gate.js(例)
function shouldRun(hookId) {
  // 環境変数による無効化
  const disabled = (process.env.CLAUDE_DISABLE_HOOKS || "").split(",").map(s => s.trim());
  if (disabled.includes(hookId) || disabled.includes("all")) return false;

  // 特定ブランチでは無効化
  const branch = execSync("git branch --show-current", { encoding: "utf-8" }).trim();
  if (branch === "main" || branch === "develop") return true; // 保護ブランチでは常に有効

  return true;
}

ブランチに応じてフックの有効/無効を切り替えるなど、より細かい制御も実装できます。


ルール(commit-discipline.md)との組み合わせ

技術的なフックだけでは不十分です。フックは「ガードレール」であり、AI の行動の基本方針はルールドキュメントで定めます。

commit-discipline.md は AI エージェントが参照するルールファイルです。フックと合わせて、二段階の防御を形成します。

# コミット規律ルール

## 基本方針

AIエージェントは論理的な作業単位ごとに必ずコミットする。

コミット規律を守ることで、以下の操作が安全に自動承認される:

- git checkout -- *(ワーキングツリーのリセット)
- git clean *(未追跡ファイルの削除)
- git reset --hard HEAD(HEAD へのリセット)

## コミットタイミング

以下のタイミングで必ずコミットする:

| タイミング | 例 |
|---|---|
| 機能の論理的な区切り | コンポーネント1つ実装完了 |
| テストを追加・修正した後 | テストがグリーンになった時点 |
| 設定ファイルを変更した後 | 設定変更単体でコミット |
| バグ修正を完了した後 | 修正内容が確認できる最小単位 |
| 大きな作業の前 | リファクタ・削除・移動の前に現状を保存 |

ルールとフックの役割分担

手段役割
ルール層commit-discipline.mdAI が自発的にコミットするよう誘導する
フック層pre-destructive-git破壊的操作の直前に技術的にブロックする
フック層commit-reminder変更蓄積を検知して気づかせる

ルール層は「AI が良い判断をする確率を上げる」ための仕組みです。AI はルールを読んでいれば、適切なタイミングでコミットします。しかしルールを読んでいても状況によって省略することがあります。

フック層はルール層が機能しなかった場合のセーフティネットです。ルールの遵守に関係なく、技術的に危険な状況(未コミット変更がある状態での破壊的操作)を防ぎます。

この二段構えは「AI を信頼しつつも、最悪のケースを技術で防ぐ」という設計思想です。

CLAUDE.md への記述

ルールドキュメントだけでなく、CLAUDE.md(プロジェクトの AI への指示ファイル)にもコミット規律を記載します。

## コミット規律

- 論理的な作業単位ごとにコミットする
- 大きな作業(リファクタ・削除)の前に必ず現状をコミット
- WIP コミットは `git add -A && git commit -m "chore: WIP save"` を使う
- `pre-destructive-git` フックが未コミット変更のある状態でのブロックを自動検出する

CLAUDE.md はセッション開始時に AI が参照するため、フックの存在と目的を AI 自身が把握できます。「フックにブロックされた」という状況でも、AI は理由を理解して適切に対処できます。


WIPコミットのすすめ ─ 気軽に保存する文化

コミット規律フックを導入すると、「コミットしないと先に進めない」場面が発生します。この状況に対応するために、WIP(Work In Progress)コミットを気軽に使う文化を定着させることが重要です。

# 作業状態を即座に保存する
git add -A && git commit -m "chore: WIP save"

WIP コミットは「完成していなくてもいい、とにかく保存する」という考え方です。コミットメッセージが雑でも構いません。後から整理できます。

WIP コミットのメリット

復元ポイントとして機能する: どんな中途半端な状態でもコミットされていれば git refloggit checkout で戻れます。git reset --hard されても、コミットされていればロスト確定ではありません。

AI との協働がスムーズになる: AI が破壊的操作を実行する前にコミットを促されても、WIP コミットという選択肢があれば迷わず進められます。「まだ完成していないからコミットできない」という心理的ハードルが下がります。

差分の可視化: WIP コミットを積み重ねると、作業の流れが git log で見えます。「どのタイミングで何を変えたか」のトレースが容易になります。

WIP コミット後の整理

WIP コミットが多くなってきたら git rebase -i で整理します。

# 直近3コミットをインタラクティブに整理
git rebase -i HEAD~3

エディタが開き、picksquash(前のコミットにまとめる)、reword(メッセージを変更)などの操作ができます。WIP コミット数個を1つの意味のあるコミットにまとめます。

あるいは PR のマージ時に「Squash and merge」を使えば、WIP コミットをまとめた上でメインブランチに取り込めます。ブランチ内のコミット履歴の雑さをクリーンに保てます。

AI へのWIPコミット指示

ルールドキュメントに WIP コミットを明示することで、AI も積極的に使うようになります。

## WIP コミットの活用

作業途中でも気軽に WIP コミットを作ってよい。
後から git rebase -i で整理するか、PR でスカッシュマージする。

# 作業状態を即座に保存する
git add -A && git commit -m "chore: WIP save"

「完璧なコミットでなければコミットしてはいけない」というルールは AI にとっても心理的ハードルになります。WIP コミットを明示的に許可することで、AI がコミットをためらわなくなります。


運用してみて分かったこと

フックを導入して数週間運用した中で見えてきたこと、チューニングが必要だったポイントをまとめます。

誤検知が発生したケース

サブモジュールを含むリポジトリ: git status --porcelain がサブモジュールの変更を検出することがあります。サブモジュールのコミット状態は別リポジトリで管理するため、pre-destructive-git がブロックしても意味がない場面がありました。対策として、サブモジュールの変更のみの場合は通過させるフィルタを追加することを検討中です。

自動生成ファイルの変更: ビルドツールが自動生成するファイル(.astro/ のキャッシュなど)が変更として検出されることがあります。これらは .gitignore に入れるべきで、入っていれば問題ありませんが、.gitignore の設定漏れが見つかるきっかけになりました。

CI 環境でのフック発火: CI で Claude Code を使う設定にしていたプロジェクトで、commit-reminder が CI のログに混入しました。CI 環境では CLAUDE_DISABLE_HOOKS=all を設定することで解決しました。

チューニングしたポイント

クールダウン時間の調整: 最初は30秒でしたが、連続してファイルを編集するケースでは30秒でもリマインドが頻繁に出ました。60秒に延ばすとちょうど良いバランスになりました。プロジェクトの作業リズムによって30〜120秒程度で調整するとよいでしょう。

ブロックメッセージのコマンド表示: 最初のバージョンでは、ブロック時に「元のコマンドを直接実行する方法」を表示していませんでした。これにより「コミットしたくないが進めたい」という場面で AI が迷ってしまうケースがありました。エスケープハッチとして元コマンドを再掲するようにしてから、詰まるケースが減りました。

変更ファイル数の上限表示: 当初は全ファイルを表示していましたが、変更が多い場合にメッセージが長くなりすぎました。10件に上限を設けて ...他N件 表示にしたことで読みやすくなりました。

フックが根本解決しない問題

フックは「すでに未コミット変更がある状態で破壊的操作をしようとした時」には機能します。しかし次のような問題には対応していません。

コミット内容の品質: WIP コミットを大量に作るだけでコミットメッセージが雑になる問題。これはコードレビューや commitlint で対処します。

コミットすべきでないファイルのステージング: 秘密情報や大きなバイナリファイルをコミットしてしまう問題。pre-commit フックや git-secrets で対処します。

ブランチ管理: どのブランチで作業するかの問題。pre-destructive-git はブランチ切り替えの際には介入しません。ブランチ保護は別のフック(前回記事の実装例3参照)で対処します。

コミット規律フックは「変更の消失を防ぐ」という限定的な目的に特化した道具です。それ以外の問題には別の手段を組み合わせます。


まとめ

本記事では、AI エージェントのコミット規律を技術的に強制するための2つのフック、pre-destructive-gitcommit-reminder の設計と実装を詳しく解説しました。

要点を整理します。

設計の核心は二段構え。破壊的操作の直前にブロックする pre-destructive-git と、変更蓄積を監視して気づかせる commit-reminder が互いを補完します。ブロックは「復元不可能な状況」だけに限定し、リマインダーは警告にとどめます。

STDIN からの JSON 読み取りが Claude Code との正しいインターフェースです。コマンド引数ではなく STDIN に流れる JSON を読むことで、より確実に操作内容を取得できます。

エスケープハッチの設計が重要。フックがすべての状況で正解とは限りません。「意図的に変更を捨てたい場合は直接実行してください」というエスケープハッチを明示することで、フックが開発の邪魔にならないようにします。

ルールとフックの二段階防御がベストプラクティスです。commit-discipline.md で AI の行動方針を定め、フックで技術的に保証します。どちらか一方だけでは不十分です。

WIP コミット文化の醸成がフックを活かすカギです。「完成していなくてもコミットしてよい」という文化がないと、フックがブロックするたびに詰まります。WIP コミットを明示的に許可し、後から整理するフローを確立します。

この実装は Claude Code 特有のものではなく、他の AI コーディングエージェントが hooks や pre-tool callbacks に対応した場合にも同様のアプローチが使えます。AI エージェントの「うっかり」を技術で防ぐという考え方は、AI が開発チームの一員として定着していく中でますます重要になります。

前回記事(Claude Code カスタムフックで AI エージェントの暴走を防ぐ)で解説した他のフック実装(main/develop への直接 push ブロック、秘密情報検出、CI 自動確認)と組み合わせることで、AI エージェントの行動に対する包括的なガードレールを構築できます。

関連記事

F/ この記事の設計を反映しているプロダクト: FlowAgent

see →
an

analytics note — editor

AI とデータ分析の実装ログを毎週編集。設計判断と運用のつまずきを、再現できる形で残すことを大切にしています。