Seven Characters Don't Only Point at Commits — Measuring When Your Agent's Recorded Hashes Stop Resolving
Short commit hashes recorded by an agent quietly stop resolving as a repository grows. Measuring where ambiguity begins, fixing the recording side, and building a batch verification gate.
I handed the same string to two commands in a row.
$ 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'
Not a single character of the argument changed. One resolved. The other refused.
Nothing was broken in the repository. What was broken was the format of the thing that had been written down — a bare seven-character string, kept as if it were a durable reference.
Antigravity CLI recently gained prefix-based resolution of abbreviated hashes into full ones in its commit history navigation. Browsing by hand got noticeably more comfortable. Meanwhile the path where a machine writes a short hash and a machine reads it back had been rotting quietly for a long time. This was the nudge to look at it properly.
Where does ambiguity actually begin? I did not want to guess at the boundary, so I built four synthetic repositories and measured.
The same string passes one command and fails the next
Start with isolating the behavior.
I built a 40,000-commit synthetic repository and picked two colliding seven-character prefixes. One collides between a commit and a blob; the other between two commits. Each was handed to the same set of commands, and exit codes recorded.
Command
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 — resolves
exit 128 — ambiguous
git log -1 <p>
exit 0 — resolves
exit 128 — ambiguous
When a commit collides with a blob, git log and the ^{commit} peel operator survive. In positions where only a committish is grammatically valid, git narrows the candidate set for you.
That turned out to be the nasty part. Only some stages of a pipeline fail. And only on runs that happen to touch a hash that happens to share a prefix with a blob. It gets filed as a flaky failure, nobody finds a cause, and it comes back.
Commit-to-commit collisions leave no escape hatch. Adding ^{commit} cannot help when both candidates are commits. That case fails honestly and consistently, which — perversely — makes it the easier one to live with.
Prefix resolution searches every object, not just commits
Sizing the prefix from the commit count was the original mistake.
A git object database keeps commits, trees, and blobs in one shared namespace. Prefix resolution scans across all of them, so what actually governs collision probability is the total object count.
The measurement environment: Linux 6.8.0-124, 4 vCPU, 3.9 GB RAM, git 2.34.1, Python 3.10.12. Repositories were generated with git fast-import using a fixed seed of 20260806. Each commit rewrites 3 files drawn from a pool of 200, which produced exactly 6 objects per commit (1 commit, 2 trees, 3 blobs).
Commits
Total objects
Import time
Colliding 7-char pairs (all objects)
Colliding 7-char pairs (commits only)
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
At 40,000 commits, commit-to-commit collisions number 8. Include blobs and trees and it becomes 116. That is 14.5 times the exposure.
If you reason from commit count alone — "we're only at 40k commits, seven characters is plenty" — you underestimate the real collision surface by nearly an order of magnitude.
The composition is worth a closer look too. Of the 116 colliding seven-character groups in that repository, 35 contain at least one commit: 27 mix a commit with a non-commit object, and 8 are commit-to-commit.
So among collisions that involve a commit at all, close to 80 percent are the "only some commands fail" variety. The kind that fails loudly enough to notice is the minority.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Git prefix resolution searches blobs and trees alongside commits, so a 40,000-commit repository showed 116 colliding 7-character pairs across all objects versus only 8 among commits
✦Why the same short hash succeeds with git log but exits 128 with rev-parse and cat-file, plus a per-command resolution table
✦A fail-closed gate that verifies recorded hashes in bulk — 11.8 ms for 300 references, 48 times faster than looping rev-parse
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Numbers from a synthetic repository are not directly portable. The ratio of objects to commits differs everywhere.
So I checked whether the naive birthday approximation holds well enough to be useful. With M total objects and a prefix of k hex characters, the expected number of colliding pairs is M(M-1)/2 ÷ 16^k.
Total objects
k
Measured pairs
Predicted pairs
Measured ÷ predicted
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
Measured over predicted lands between 0.89 and 1.19. Close enough that counting your objects is all the calibration you need.
Here is where the expected pair count crosses 1 for each prefix length.
That took 0.07 to 0.08 seconds on the 240,000-object repository. Cheap enough to leave permanently in CI.
Twenty-three thousand objects is smaller than it feels. At six objects per commit that is under 4,000 commits. A project an indie developer has kept running for three years is already there.
Git protects new output, not what you wrote down
This is where my own assumption fell apart. I had been thinking that as long as I used whatever length --short produced, I was safe.
I measured the abbreviation length git chose at each size.
Total objects
Length chosen by --short
Minimum unique prefix across all objects
6,000
7
7
30,000
8
8
120,000
9
9
240,000
9
9
Git tracks the growth faithfully. As the repository expands it stretches from 7 to 8 to 9 and guarantees that what it prints right now is unambiguous.
The guarantee covers the output at that moment, and nothing else.
Logs written last year at seven characters. Issue comments. Generated release notes. Agent run artifacts. Every one of them was unique when written. None of them grow a character as the repository grows.
Records degrade from the instant they are written — and in a way nobody notices until something tries to read them back.
That asymmetry is what makes this an agent problem specifically. A human hits the error, shrugs, and retypes nine characters. An unattended run feeds the stale seven characters straight into the next step. In Reading the Same History in 38 Seconds — or 0.4: Handing a Read-Only .git to Your Agent the read-only sandbox arrangement made history access much cheaper. Cheaper access means more lookups, which means more chances to step on a rotted reference.
Fixing the recording side: full hash and display hash are different fields
Order of operations matters here. The verification gate deals with what has already scattered; closing the tap comes first.
One rule covers it. Anything a machine will read back is written at 40 characters. The abbreviated form exists only for human eyes, in a separate field.
import subprocessdef record_commit_ref(repo: str, rev: str = "HEAD") -> dict: """Build a record-safe representation of a commit reference. full : the only value a machine reads back; fixed 40 chars, never ambiguous display : for humans skimming a log; never used for resolution subject : 40 hex characters alone tell you nothing about what the commit was """ def git(*args: str) -> str: proc = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True) if proc.returncode != 0: # Never let an unresolvable reference through as an empty string raise RuntimeError( f"git {' '.join(args)} failed ({proc.returncode}): " f"{proc.stderr.strip()}" ) return proc.stdout.strip() # The ^{commit} peel makes this fail loudly if rev resolves to a non-commit full = git("rev-parse", f"{rev}^{{commit}}") if len(full) != 40: raise RuntimeError(f"unexpected resolution result: {full!r}") return { "full": full, "display": full[:12], # display only; never re-resolved "subject": git("log", "-1", "--format=%s", full), }
Twelve characters for display is not arbitrary — the expected pair count crosses 1 at roughly 23.7 million objects there. Plenty of headroom for a value that is meant to be read, while the discipline of "this is not the resolvable value" stays intact.
Verbose, certainly. But picture yourself six months from now with only that line to work from, and the verbosity is clearly the cheaper cost.
A fail-closed gate for logs that already exist
With the tap closed, the backlog is next.
Three rules shape it. Ambiguous fails. Missing fails. Resolving to anything other than a commit fails. The whole design comes down to refusing to let an uncertain state through on a hunch.
#!/usr/bin/env python3"""Fail-closed gate: can the short hashes an agent recorded still be resolved?Usage: python3 hashref_gate.py <repo> <files to check...>Exit code: 0 = every reference resolves to exactly one commit / 1 = at least one does notAssumptions this relies on: - git prefix resolution searches blobs and trees alongside commits - so sizing the prefix from the commit count is not enough - --batch-check answers an ambiguous input with "<input> ambiguous" on stdout, which lets a single process judge the whole batch"""import re, subprocess, sys, collections# Full 40-char hashes cannot be ambiguous, so they are out of scope. Match 7-39 only.SHORT_HASH = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{7,39}(?![0-9a-fA-F])")def collect(paths): """Gather short-hash candidates as hash -> list of locations.""" 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:: cannot read {path}: {exc}", file=sys.stderr) raise SystemExit(1) return founddef resolve(repo, hashes): """Resolve in one batch. Returns input -> (state, full hash 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, ) # Hints go to stderr, but judgment is made strictly from stdout lines result, lines = {}, proc.stdout.splitlines() if len(lines) != len(hashes): # Line counts disagree, so the assumption is broken. Fail rather than pass. print(f"::error:: batch-check line count mismatch " f"(sent {len(hashes)} / got {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: # Shapes like "<input> ambiguous" or "<input> missing" result[src] = (parts[-1] if parts else "unknown", None) return resultdef 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": "ambiguous (multiple candidates)", "missing": "no such object"}.get(state, state) print(f"FAIL {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" candidate {c} ({t})") for loc in found[h][:3]: print(f" referenced at {loc}") print(f"\nchecked {len(found)} / unresolvable {bad}") return 1 if bad else 0if __name__ == "__main__": sys.exit(main())
Run against the 40,000-commit repository with a log containing the references from the opening, the actual output is this.
FAIL 1544491 — ambiguous (multiple candidates)
candidate 154449150bd5e4d3256ba9511e2cc9def88872a0 (commit)
candidate 1544491a99037fbbb00ade87e2a49f50a9eb6e8f (blob)
referenced at agent_run.log:1
FAIL 6ce18aa — ambiguous (multiple candidates)
candidate 6ce18aa085b9600067e0c456bf99573b2d01406b (commit)
candidate 6ce18aa5c8dede04b47914518f89e34381f4752a (commit)
referenced at agent_run.log:2
FAIL deadbee — no such object
referenced at agent_run.log:7
checked 6 / unresolvable 3
Printing every candidate is the point. A commit-versus-blob collision tells you that adding ^{commit} recovers it. A commit-versus-commit collision tells you a human has to re-identify that reference. The gate does not stop at "failed" — it hands you the next move.
References like deadbee fall out of the same pass. Those are pointers left behind by a deleted branch or a commit dropped in a rebase.
Taking the verdict from stdout — where the 48x showed up
The first version of the gate simply looped over git rev-parse. Three hundred references took 566.8 ms. Tolerable for CI, but visibly slow once a repository carries thousands of references.
git cat-file --batch-check handles this. Feed it newline-separated input on stdin and one process resolves the whole set.
Approach
300 references (median of 3)
Per reference
Looping git rev-parse <p>^{commit}
566.8 ms
1.889 ms
Batched git cat-file --batch-check
11.8 ms
0.039 ms
48.0 times. The gap is process spawn cost — most of that 1.889 ms per reference is git starting up.
One behavior here is not something the documentation prepares you for. Hand an ambiguous prefix to --batch-check and the candidate hints go to stderr, as expected. At the same time stdout receives a single line reading <input> ambiguous.
One input line, one output line, order preserved. That means you can zip against the input and never parse stderr at all. The len(lines) != len(hashes) check in the gate exists precisely because that correspondence is load-bearing — silently passing when the assumption breaks is the failure mode worth the most effort to avoid.
--disambiguate only appears in the candidate-listing path. It accepts one prefix at a time, but it runs only when a violation was already found, so it never shows up in the runtime budget.
Where it sits in practice, and what did not work
I settled on three placements. If you are adding this to a production pipeline, the first one alone already earns its keep.
Agent run exit hook — scoped to the logs and artifacts that run produced. A few dozen references, so under a millisecond. A violation marks the run failed so its output never becomes the next run's input
Pull request CI — scoped to changed docs and release notes. Short hashes typed by hand get caught here too
Weekly repository-wide scan — this one reports rather than fails. Going back to repair historical records is a prioritization decision, not something to block on
It is worth recording what did not work.
I tried pinning core.abbrev to a larger fixed value. Set it to 9 or 12 and everything you print afterward is safe. The catch is that it only applies to your own machine. The CI container, your teammates' environments, the abbreviations GitHub's UI generates — each decides independently, so this never became a repository-wide rule. Worse, configuring it produces a feeling of safety that is not backed by anything.
The [0-9a-f]{7,39} pattern also produces false positives. Fragments of IDs and other hex-looking values get swept in. My first instinct was to add an option that ignores "no such object" results, and I abandoned it. The moment you carve out an ignorable hole, genuinely rotted references start flowing through it. Instead the recording side writes references in a fixed format, and the gate has a companion mode that only inspects lines in that format.
Above roughly 23,000 and seven-character records are already a wager. Above 93,000 and eight characters are no longer safe either.
Then run the gate once over your most recent agent logs. Zero findings means I would recommend landing only the recording-side change and moving on; in that case there is no reason to hurry. Even one finding means it was there the whole time and simply had not been read back yet.
Until I ran these numbers I had filed prefix length under theoretical worries. Counting properly put the boundary a good deal closer than expected. If you measure and find the same, this was worth writing. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.