同じ文字列を、続けて 2 つのコマンドに渡したときのことです。
$ git log -1 --format=%H 1544491
154449150bd5e4d3256ba9511e2cc9def88872a0
$ git show --quiet 1544491
error: short object ID 1544491 is ambiguous
hint: The candidates are:
hint: 154449150 commit 2023-12-12 - change 39719 12e206e9
hint: 1544491a9 blob
fatal: ambiguous argument '1544491'
引数は 1 文字も変えていません。片方は解決し、もう片方は落ちます。
リポジトリが壊れているわけではありませんでした。壊れていたのは、記録として書き残された「7 桁の文字列」という形式のほうです。
Antigravity CLI のコミット履歴ナビゲーションに、短縮ハッシュを前方一致で完全ハッシュへ解決する処理が入りました。手で辿るぶんには快適になった一方で、機械が記録した短縮ハッシュを機械が読み戻す経路は、以前から静かに腐り続けていました。そのことに向き合う機会になりました。
どこから曖昧になるのか。境界を勘で決めたくなかったので、合成リポジトリを 4 サイズ作って測っています。
同じ文字列が、あるコマンドでは通り、別のコマンドでは落ちる
まず現象の切り分けからです。
40,000 コミットの合成リポジトリを用意し、7 桁が衝突しているプレフィックスを 2 種類選びました。片方は commit と blob の衝突、もう片方は commit 同士の衝突です。それぞれに同じ文字列を渡して、終了コードを取っています。
| コマンド | 1544491(commit + blob) | 6ce18aa(commit + commit) |
git rev-parse <p> | exit 128 — ambiguous | exit 128 — ambiguous |
git cat-file -t <p> | exit 128 — ambiguous | exit 128 — ambiguous |
git show --quiet <p> | exit 128 — ambiguous | exit 128 — ambiguous |
git rev-parse <p>^{commit} | exit 0 — 解決する | exit 128 — ambiguous |
git log -1 <p> | exit 0 — 解決する | exit 128 — ambiguous |
commit と blob が衝突しているとき、git log と ^{commit} は生き残ります。文脈上コミットしか受け付けない位置では、git が候補をコミットに絞り込んでくれるためです。
これが厄介でした。パイプラインの一部の工程だけが落ちます。しかも落ちるのは、たまたま blob と衝突した特定のハッシュを踏んだ実行だけ。再現性のない不具合として処理されて、原因が特定されないまま何度も戻ってきます。落とし穴としては、かなり質の悪いほうでした。
commit 同士の衝突になると、逃げ道はありません。^{commit} を付けても候補が 2 つとも commit なので絞り込めません。ここは素直に落ちてくれるぶん、まだ扱いやすい相手です。
プレフィックスの探索範囲は、コミットではなくオブジェクト全体
必要な桁数を「コミット数」から見積もっていたのが、そもそもの誤りでした。
git のオブジェクトデータベースには commit・tree・blob が同じ名前空間で同居しています。プレフィックス解決はそこを横断して探すため、衝突確率を決めるのは総オブジェクト数のほうでした。
計測環境は Linux 6.8.0-124 / 4 vCPU / 3.9 GB RAM、git 2.34.1、Python 3.10.12。git fast-import で合成リポジトリを作り、シードは 20260806 に固定しています。200 ファイルのプールから 1 コミットあたり 3 ファイルを書き換える構成で、コミットあたりちょうど 6 オブジェクト(commit 1 + tree 2 + blob 3)が生まれました。
| コミット数 | 総オブジェクト数 | 生成時間 | 7 桁の衝突ペア(全体) | 7 桁の衝突ペア(コミット同士) |
| 1,000 | 6,000 | 362 ms | 0 | 0 |
| 5,000 | 30,000 | 1,483 ms | 2 | 1 |
| 20,000 | 120,000 | 4,575 ms | 26 | 1 |
| 40,000 | 240,000 | 8,660 ms | 116 | 8 |
40,000 コミットの時点で、コミット同士の衝突は 8 組。ところが blob と tree を含めると 116 組でした。14.5 倍です。
コミット数だけを見て「うちはまだ 4 万コミット、7 桁で十分」と判断していると、実際の衝突面積を一桁近く見誤ることになります。
衝突している集合の内訳も見ておきます。40,000 コミットのリポジトリで 7 桁が衝突しているグループは全体で 116 組。そのうちコミットを 1 つ以上含むのは 35 組で、内訳は commit と非 commit(blob・tree)の混在が 27 組、commit 同士が 8 組でした。
つまりコミットが絡む衝突の 8 割近くが、前節の「一部のコマンドだけ落ちる」型です。落ちてくれれば気づけるほうの衝突は、むしろ少数派でした。
境界を計算する — 自分のリポジトリはどこにいるのか
合成リポジトリの数字をそのまま持ち帰っても意味がありません。オブジェクト数とコミット数の比はリポジトリごとに違います。
そこで誕生日問題の素朴な近似が使えるかを確かめました。総オブジェクト数を M、プレフィックス桁数を k としたときの期待衝突ペア数は M(M-1)/2 ÷ 16^k です。
| 総オブジェクト数 | 桁数 k | 実測ペア数 | 予測ペア数 | 実測 ÷ 予測 |
| 30,000 | 6 | 30 | 26.82 | 1.12 |
| 30,000 | 7 | 2 | 1.68 | 1.19 |
| 120,000 | 6 | 445 | 429.15 | 1.04 |
| 120,000 | 7 | 26 | 26.82 | 0.97 |
| 240,000 | 7 | 116 | 107.29 | 1.08 |
| 240,000 | 8 | 6 | 6.71 | 0.89 |
実測と予測の比は 0.89 〜 1.19 に収まりました。この程度に合うなら、自分のリポジトリの総オブジェクト数を数えるだけで、境界は引けます。
期待衝突ペア数が 1 に達する総オブジェクト数は、桁数ごとにこうなります。
| 桁数 | 期待衝突ペア数が 1 に達する総オブジェクト数 |
| 7 桁 | 約 23,000 |
| 8 桁 | 約 93,000 |
| 9 桁 | 約 371,000 |
| 10 桁 | 約 1,483,000 |
自分のリポジトリの位置は、一行で分かります。
git cat-file --batch-all-objects --batch-check='%(objectname)' | wc -l
240,000 オブジェクトのリポジトリで 0.07 〜 0.08 秒でした。CI に常時仕込んでも気にならない速度です。
23,000 オブジェクトという数字は、感覚より小さいはずです。1 コミットあたり 6 オブジェクトなら 4,000 コミット弱。個人開発で 3 年ほど動かしているリポジトリが、もう境界に入っています。
git は新しい出力だけを守っている
ここで自分の思い込みが崩れました。「git が --short で出してくる桁数を使っていれば安全だろう」と考えていたのです。
各サイズで git rev-parse --short HEAD の桁数を測りました。
| 総オブジェクト数 | 自動選択された桁数 | 全オブジェクトで一意になる最小桁数 |
| 6,000 | 7 | 7 |
| 30,000 | 8 | 8 |
| 120,000 | 9 | 9 |
| 240,000 | 9 | 9 |
git はきちんと追随しています。リポジトリが育てば 7 → 8 → 9 と桁を伸ばし、そのとき出力する値が一意であることを保証してくれます。
保証されているのは その瞬間の出力 だけです。
去年 7 桁で記録されたログ、issue のコメント、生成されたリリースノート、エージェントの実行アーティファクト。これらは書かれた時点では一意でした。リポジトリが育っても、記録された桁が増えることはありません。
つまり記録は書いた瞬間から劣化していきます。それも、参照するまで誰も気づかない形で。
エージェント運用ではこれが効いてきます。人間なら「あれ、通らないな」と気づいて 9 桁で打ち直します。自動実行では、ログに残った 7 桁がそのまま次の実行の入力になります。同じ履歴を読むのに 38 秒と 0.4 秒 — 読み取り専用になった .git をエージェントへどう渡すか で扱った読み取り専用サンドボックスの構成では、履歴参照そのものは軽くなりました。軽くなったぶん参照回数が増え、腐った短縮ハッシュを踏む機会も増えています。
記録側を直す — 完全ハッシュと表示用を分ける
対処の順番としては、まず記録側です。検査ゲートは既に散らばってしまったものへの対処であって、蛇口を締めるほうが先でした。
原則は 1 つだけ。機械が読み戻す値は必ず 40 桁で書く。短縮形は人間が読むためだけに、別フィールドで併記する。
import subprocess
def record_commit_ref(repo: str, rev: str = "HEAD") -> dict:
"""コミット参照を記録用の辞書にする。
full : 機械が読み戻す唯一の値。40桁固定なので曖昧化しない
display: 人間がログを目で追うためだけの値。解決には使わない
subject: 40桁だけでは何のコミットか分からないため添える
"""
def git(*args: str) -> str:
proc = subprocess.run(["git", "-C", repo, *args],
capture_output=True, text=True)
if proc.returncode != 0:
# 解決できない参照を空文字で通さない。ここは必ず落とす
raise RuntimeError(
f"git {' '.join(args)} failed ({proc.returncode}): "
f"{proc.stderr.strip()}"
)
return proc.stdout.strip()
# ^{commit} を付けて、コミット以外へ解決した場合に落とす
full = git("rev-parse", f"{rev}^{{commit}}")
if len(full) != 40:
raise RuntimeError(f"想定外の解決結果です: {full!r}")
return {
"full": full,
"display": full[:12], # 表示専用。再解決には使わない
"subject": git("log", "-1", "--format=%s", full),
}
display を 12 桁にしているのは、期待衝突ペア数が 1 に達するのが約 2,370 万オブジェクトだからです。表示用としては十分な余裕がありますし、それでも「これは解決に使う値ではない」という規律は保ちます。
ログ出力の形はこうなります。
[10:07] rollback candidate: 6ce18aa085b9 (6ce18aa085b9600067e0c456bf99573b2d01406b) "change 5581"
冗長に見えます。ただ、半年後にこの行だけを頼りに戻る場面を想像すると、冗長さは安いほうの代償でした。
既に散らばったログを検査する fail-closed ゲート
蛇口を締めたら、既存分です。
設計方針は 3 つ。曖昧なら落とす。存在しないなら落とす。コミット以外へ解決したら落とす。判断に迷う状態を「たぶん大丈夫」で通さない、という一点に尽きます。
#!/usr/bin/env python3
"""エージェントが記録した短縮ハッシュを、いま解決できるか検証する fail-closed ゲート。
使い方:
python3 hashref_gate.py <repo> <検査対象ファイル...>
終了コード: 0 = 全件が一意なコミットへ解決 / 1 = 1件でも解決不能
設計上の前提:
- git のプレフィックス解決は commit だけでなく blob・tree も探索する
- よって「コミット数」から必要桁数を見積もると足りない
- --batch-check は曖昧な入力に対し "<入力> ambiguous" を stdout へ返すので
1プロセスでまとめて判定できる
"""
import re, subprocess, sys, collections
# 40桁の完全ハッシュは検査対象外(曖昧になり得ないため)。7〜39桁のみ拾う
SHORT_HASH = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{7,39}(?![0-9a-fA-F])")
def collect(paths):
"""ファイル群から短縮ハッシュ候補を (ハッシュ -> 出現箇所) で集める。"""
found = collections.defaultdict(list)
for path in paths:
try:
with open(path, encoding="utf-8", errors="replace") as fh:
for lineno, line in enumerate(fh, 1):
for m in SHORT_HASH.finditer(line):
found[m.group(0)].append(f"{path}:{lineno}")
except OSError as exc:
print(f"::error:: 読み取り失敗 {path}: {exc}", file=sys.stderr)
raise SystemExit(1)
return found
def resolve(repo, hashes):
"""--batch-check で一括解決する。戻り値: 入力 -> (状態, 完全ハッシュ or None)"""
if not hashes:
return {}
proc = subprocess.run(
["git", "-C", repo, "cat-file", "--batch-check=%(objectname) %(objecttype)"],
input="\n".join(hashes) + "\n",
capture_output=True, text=True,
)
# stderr には hint が流れるが判定には使わない。判定は必ず stdout の行で行う
result, lines = {}, proc.stdout.splitlines()
if len(lines) != len(hashes):
# 行数が合わない=前提が崩れている。黙って通さず落とす
print(f"::error:: batch-check の応答行数が不一致 "
f"(入力 {len(hashes)} / 応答 {len(lines)})", file=sys.stderr)
raise SystemExit(1)
for src, line in zip(hashes, lines):
parts = line.split()
if len(parts) == 2 and len(parts[0]) == 40:
state = "commit" if parts[1] == "commit" else f"not-commit:{parts[1]}"
result[src] = (state, parts[0])
else:
# "<入力> ambiguous" / "<入力> missing" の形
result[src] = (parts[-1] if parts else "unknown", None)
return result
def main():
if len(sys.argv) < 3:
print(__doc__)
return 2
repo, paths = sys.argv[1], sys.argv[2:]
found = collect(paths)
resolved = resolve(repo, sorted(found))
bad = 0
for h in sorted(found):
state, full = resolved[h]
if state == "commit":
continue
bad += 1
label = {"ambiguous": "曖昧(候補が複数)",
"missing": "存在しない"}.get(state, state)
print(f"❌ {h} — {label}")
if state == "ambiguous":
cands = subprocess.run(
["git", "-C", repo, "rev-parse", f"--disambiguate={h}"],
capture_output=True, text=True).stdout.split()
for c in cands:
t = subprocess.run(["git", "-C", repo, "cat-file", "-t", c],
capture_output=True, text=True).stdout.strip()
print(f" 候補 {c} ({t})")
for loc in found[h][:3]:
print(f" 参照元 {loc}")
print(f"\n検査 {len(found)} 件 / 解決不能 {bad} 件")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
40,000 コミットのリポジトリに対して、冒頭のログを食わせた実際の出力です。
❌ 1544491 — 曖昧(候補が複数)
候補 154449150bd5e4d3256ba9511e2cc9def88872a0 (commit)
候補 1544491a99037fbbb00ade87e2a49f50a9eb6e8f (blob)
参照元 agent_run.log:1
❌ 6ce18aa — 曖昧(候補が複数)
候補 6ce18aa085b9600067e0c456bf99573b2d01406b (commit)
候補 6ce18aa5c8dede04b47914518f89e34381f4752a (commit)
参照元 agent_run.log:2
❌ deadbee — 存在しない
参照元 agent_run.log:7
検査 6 件 / 解決不能 3 件
候補を両方出しているのが要点です。commit と blob の衝突なら ^{commit} を足せば救えると分かります。commit 同士なら、その参照は人間が特定し直すしかないと分かります。ゲートが「落ちた」で終わらず、次の一手まで示してくれます。
deadbee のような「存在しない」も同じ列で拾えます。削除されたブランチや、rebase で消えたコミットを指したまま残っている参照です。
判定を stdout で受ける — 48 倍の差が出たところ
ゲートを最初に書いたとき、素朴に git rev-parse をループで叩いていました。300 件で 566.8 ms。CI に入れるには許容範囲でしたが、参照が数千件あるリポジトリでは目に見えて待たされます。
ここで git cat-file --batch-check が使えます。stdin に改行区切りで流し込むと、1 プロセスで全件を解決してくれます。
| 方式 | 300 件の所要時間(中央値・3 回) | 1 件あたり |
逐次 git rev-parse <p>^{commit} | 566.8 ms | 1.889 ms |
git cat-file --batch-check 一括 | 11.8 ms | 0.039 ms |
48.0 倍でした。差の正体はプロセス生成コストです。1 件あたり 1.889 ms のうち、ほとんどが git の起動に消えています。
ここで一つ、ドキュメントを読むだけでは分からなかった挙動がありました。曖昧なプレフィックスを --batch-check に渡すと、stderr には候補リストの hint が出ます。同時に stdout へ <入力> ambiguous という 1 行が返るのです。
$ printf '1544491\n6ce18aa\n' | git cat-file --batch-check='%(objectname) %(objecttype)'
1544491 ambiguous
6ce18aa ambiguous
入力 1 行に対して出力 1 行という対応が保たれます。だから入力順で zip して突き合わせられますし、stderr を一切パースせずに済みます。ゲートの実装で len(lines) != len(hashes) を落とす条件にしているのは、この対応関係が前提だからです。前提が崩れたときに黙って通してしまうのが、いちばん避けたい失敗でした。
なお --disambiguate は候補列挙のためだけに使っています。こちらは 1 件ずつしか受け付けませんが、呼ぶのは違反が出たときだけなので実行時間には響きません。
運用に落とす — どこに挟み、何が効かなかったか
私はこの 3 段構えに落ち着きました。本番のパイプラインへ入れるなら、まず 1 番目だけでも効きます。
- エージェント実行の終了フック — その回が生成したログとアーティファクトだけを対象に走らせます。対象が数十件なので 1 ms 未満。違反があればその回を失敗として記録し、次の実行の入力にしない
- プルリクエストの CI — 変更されたドキュメントとリリースノートを対象にします。人間が手で書いた短縮ハッシュもここで拾われます
- 週次の全体スキャン — リポジトリ全体のログとドキュメントを対象に。ここだけは「落とす」のではなく一覧を出すに留めています。過去の記録を遡って直すのは、優先度の判断が要る作業なので
正直に書いておくと、効かなかった対処もあります。
core.abbrev を大きめの固定値にしてみました。9 なり 12 なりを設定すれば、以後の出力は安全になります。ただしこれは 自分のマシンにしか効きません。CI のコンテナ、他の開発者の環境、GitHub の UI が生成する短縮形。それぞれ独立に決まるため、リポジトリ全体の規律にはなりませんでした。設定して安心してしまうぶん、むしろ危うかったと感じています。
もう一つ、正規表現を [0-9a-f]{7,39} にしていることで誤検出も出ます。16 進数に見える ID やハッシュ値の断片を拾ってしまいます。当初は「存在しない」判定を出す誤検出を無視するオプションを付けようとしましたが、やめました。無視できる穴を作った瞬間に、本物の腐った参照もそこを通ってしまいます。代わりに、記録側で参照を書くときのフォーマットを固定し、ゲートはそのフォーマットの行だけを見るモードを併設しています。
agent.md の綴り誤りが権限を広げていた — frontmatter を厳格に検証する lint を書く でも同じ形の判断をしました。fail-open の逃げ道を残すと、ゲートは半年で形骸化します。誤検出は面倒でも、面倒だからこそ記録側の規律が保たれる側面がありました。
今日、最初に走らせる一行
まずは自分のリポジトリの位置を知るところからです。
git cat-file --batch-all-objects --batch-check='%(objectname)' | wc -l
この数が 23,000 を超えていれば、7 桁の記録は既に賭けになっています。93,000 を超えていれば 8 桁も安全ではありません。
そのうえで、直近のエージェント実行ログにゲートを一度だけ通してみてください。0 件なら、記録側の修正だけを静かに入れておくことを推奨します。この場合は急ぐ理由がありません。1 件でも出たなら、それは今まで気づいていなかっただけで、ずっとそこにあったものです。
私自身、この計測をするまでは「桁数の話は理論上の心配ごと」だと思っていました。実際に数えてみると、境界はずいぶん手前にありました。同じように測ってみて、思ったより近かった方がいれば、この記事を書いた甲斐があります。お読みいただきありがとうございました。