個人開発で、対話中のClaude Code、cronで回る定期ジョブ、常駐ワーカーが同じ1本のブランチに書き込む構成にしています。ある日、別のセッションが途中まで編集していたファイルが、自分が頼んだわけでもないcommitに混ざって入っていました。
原因は「各自が生の git commit を打っている」ことでした。この記事では、commitの入口をラッパー1本に集約して、巻き込みと競合を減らした設計を書きます。大規模なPR基盤の話ではなく、1人で複数のAI・ジョブを同時に走らせている規模の話です。
前提と環境
- 1人運用。リポジトリは1つ、作業ブランチも1本(PRを挟まず直接push)
- 書き込む主体は3種類:対話セッション(Claude Code)/cronなどの定期ジョブ/SessionEnd時の自動commit
- シェルは Bash。OSは macOS
- 本記事のコードは最小の再現例で、私の実運用スクリプトの縮約版です。社内固有の処理は省いています
何が起きたか(導入前の巻き込み事故)
実際に起きたことを、判明している範囲で書きます。
- セッションAがファイルXを編集中(まだcommitしない)
- セッションBが終了し、SessionEnd時の自動commitが走る
- 自動commitは「作業ツリーのdirtyをまとめて拾う」実装で、開始前から汚れていたXも一緒にcommitした
主因は、自動commit側が「自分が触ったファイル」を区別していなかったことです。副因として、本来gitignoreする想定の状態ファイルが追跡済みだったことも見つかりました。別の時期には、複数の書き手が同時にindexを触ってindex競合・上書き消失・rebase途中停止が起きたこともあります。
教訓は単純で、「誰がどのファイルを触ったか」を各書き手が自己申告しない限り、gitは区別してくれないということでした。
設計の3原則
ラッパー(以降 safe-git)には3つだけ入れました。
| 原則 | 守るもの | 実装の考え方 |
|---|---|---|
| 明示パスのみ | 他セッションのWIPの巻き込み |
commit にパスが無ければ拒否。-a 相当は存在させない(ただし . やディレクトリ指定は通るので、個別ファイル指定は運用で守る) |
| 排他ロック | 同時書き込みによるindex競合 | ロックを取れた1プロセスだけが書く |
| 失敗を無言にしない | 無人実行での取りこぼし | ロック失敗・pathspec不一致は非0で終了 |
原則1:パスを明示させる
Gitは、パスを指定した git commit の場合、indexにstage済みの他の変更を無視して、指定パスの作業ツリーの内容だけをcommitする仕様です(--only が、パス指定時のデフォルト動作)。git-commit のドキュメントの --only の項に、"disregarding any contents that have been staged for other paths. This is the default mode of operation of git commit if any paths are given on the command line" とあります。
つまり、他のセッションがstage済みにしたファイルがあっても、パス指定なら巻き込まれにくい。逆に、パス無しの git commit は他セッションがstageした変更を含めてしまいます。ラッパーは「パスが無ければ使えない」ようにして、この差を人の注意力ではなく仕組みで強制します。
ただし、同じファイルを2つのセッションが編集していた場合は防げません。パス指定は「ファイル単位」の分離であり、行単位の分離ではないからです(既知の限界)。
原則2:ロックで直列化する
次は、ロックを取るだけの最小例です。ロックには mkdir を使っています。「ディレクトリが既に存在すれば作成に失敗する」ことを排他に流用する方式です。
-p 無しの mkdir は、既存のパスに対して非0で終了するのが一般的な挙動で、この「作成の成否が原子的に決まる」性質をロック取得に使っています。本記事では実行して確かめてはいないので、採用する場合は自分の環境で検証してください。flock などの代替でも構いません。
#!/usr/bin/env bash
# safe-git.sh commit "<msg>" <path>...
set -euo pipefail
[ "${1:-}" = commit ] && [ $# -ge 3 ] || { echo "usage: $0 commit <msg> <path>..." >&2; exit 1; }
msg=$2; shift 2
lockdir="$(git rev-parse --git-dir)/safe-git.lock"
n=0
until mkdir "$lockdir" 2>/dev/null; do
n=$((n+1))
[ "$n" -ge 5 ] && { echo "lock busy" >&2; exit 2; }
sleep 1
done
trap 'rmdir "$lockdir"' EXIT
git commit -m "$msg" -- "$@"
説明とコードの対応は次のとおりです。
- 引数のパスが1つも無い場合:
$# -ge 3を満たさず、使い方を出して終了コード1 - ロックが取れない場合:最大5回試行(初回+再試行4回、各再試行の前に1秒待機)して、終了コード2
- 通常終了時:
trap ... EXITでロックを外す。SIGKILLや電源断では trap が動かず、ロックディレクトリが残ると以後ずっとlock busyになります。この最小例にはstaleロックの回収処理がなく、残った場合は手動でrmdirします(無人運用では要対策) - 対象パス:このスクリプトは「1個以上のpathspec」を要求するだけで、
.やディレクトリ、globも通ります。その範囲の他セッションの変更も巻き込むので、個別ファイルを渡す運用が前提です - 制約:
git addはしないため、追跡済みファイルの変更・削除だけが対象で、新規の未追跡ファイルはcommitできません - コミット:パスを
--の後ろに渡す=上で引用した、パス指定commitの動作になる
このスクリプトの検証状況は syntax-checked(bash -n による構文確認のみ)です。 実際にgitリポジトリで走らせた出力はここには載せていません。分岐の動作(ロック競合時のexit 2など)は未実行で、「この出力になる」とは断言しません。
原則3:失敗を無言にしない
人が見ていれば、エラーはその場で目に入ります。問題は無人実行です。定期ジョブが黙って失敗すると、気づくのは数日後になります。
これは別の経験からも痛感していました。毎朝手動で回していた日次集計が、担当者の不調で3日間止まって月次レポートに影響したことがあります。cron化して失敗時にエラーログ付きで通知するようにしてからは、3か月間、遅延も漏れもありませんでした。「失敗が誰にも見えない」状態が最も高くつきます。
そこでラッパーは、書けなかった場合に必ず非0で終了し、無人実行のときだけ通知を送るようにしています(通知部分は本記事では省略)。人が端末を見ている最中は通知を鳴らさない、という分け方です。ただしAI経由のシェル実行は非ttyになるため、通知が余分に鳴る側に倒れるのは許容しています(既知の限界)。
他セッションのWIPを巻き込まないための追加ルール
実運用では、最小例のほかに次を足しました。
- 自動commitは「開始時点のdirty一覧」を覚えておき、それ以外だけを対象にする(事故の主因への対策)
- gitignore済みのパスが混ざったら、そのパスだけ除外して残りを続行する(同梱の正当な変更まで未commitにならないように)
-
pullは fast-forward のみ。git-pull のドキュメントによれば
--ff-onlyは「ローカルが分岐していれば失敗する」ので、無人で勝手にmergeやrebaseが走りません - rebaseを常用しない。複数の書き手が同じブランチにいる状況でrebaseが途中停止すると、他の書き手がその状態を踏みます
これらは私の運用上の判断で、一般的な正解とは限りません。
導入後の運用と、止められないもの
ラッパーに一本化するだけでは不十分で、ラッパーを迂回する生の git commit を撃たれたら止められません。私の環境ではClaude Codeのフックで生のgit書き込みを検知して拒否していますが、これで全経路が塞がっているとは言えません(検知できるのは、私が想定したコマンドの形に限られます)。
そのため、指示書(CLAUDE.md)にも「git書き込みは必ずラッパー経由」と明記しています。コミットメッセージの書き方を指示書に書いたときも、メッセージの質が目に見えて上がりました。AIにGit操作を任せるなら、ルールは指示書に書き、可能なものは仕組みで強制する二重構えがいちばん効きます。
シェルスクリプトとして整える
ラッパーのような小さなスクリプトは、書き方を揃えておくと保守が楽です。以前、別の人が書いたバラバラのスタイルの運用スクリプト10本以上を、Claude Codeに読ませて共通のテンプレートに揃えたことがあります。set -euo pipefail や trap の追加といった定型の安全策は、AIが確実に当ててくれました。障害時の対応時間は平均で30分短縮されています(この数字は私の環境での記録で、一般的な効果ではありません)。
今回の safe-git も同じテンプレート(set -euo pipefail、終了コードの固定、trap での後始末)で書いています。
CIとの関係
CIを導入した経験からも、同じ構造が見えます。以前、手動のテスト実行とデプロイで、テストの実行忘れとデプロイ手順ミスが四半期に1回起きていました。GitHub Actionsで「pushで自動テスト、mainマージでステージング、タグで本番」にしてから、そのミスはゼロになり、デプロイ頻度は月2回から週2回になりました。
人が毎回気をつけることを、通る場所を1本にして仕組みに任せる。CIも safe-git も同じ発想です。違いは、CIはサーバー側で最終判定できるのに対し、ローカルのラッパーは迂回できてしまう点です。この差は埋まっていません。
導入チェックリスト
- 書き手(対話・cron・SessionEnd・ワーカー)を全部列挙したか
-
どの書き手も、生の
git commit/git add -Aを使っていないか -
commitにパス指定を必須にしたか(
.・ディレクトリ・globを許していないかも確認) - 同時書き込みの排他を入れたか(方式は環境で検証)
- 失敗時、無人実行でも気づける経路があるか
- 迂回経路(生git)への対策と、その限界を把握しているか
まとめ
- 複数のAI・ジョブが同じブランチに書くなら、commitの入口を1本にする
- 明示パス・ロック・失敗の可視化の3つで、私の運用では巻き込みと競合が減りました(件数などは計測していない体験談です)
- ただし同一ファイルの同時編集、
.やディレクトリ指定、ラッパーの迂回、staleロックは防げない
結局のところ、今回いちばん効いたのは凝った機構ではなく、「パス無しのcommitを存在させない」という小さな制約でした。事故の原因は、技術の難しさではなく「誰が何を触ったかを区別する手段がなかったこと」で、そこを埋める入口になったのだと思います。
この記事の要点だけ持ち帰るなら、「commitには個別ファイルのパスを必ず付けさせる」の一点です。
自分の環境で試すときのメモ代わりに ストック しておくと、必要になったときにすぐ引けます。