Tracing Why an Agent Wrote That Line Six Months Ago — Commit Granularity and Provenance Trailers
When an agent packs 14 files and 800 lines into a single commit, git blame tells you nothing six months later. Here is how I split commits at intent boundaries, recorded provenance as machine-readable Git trailers, and built a one-command path from a blamed line back to the design decision behind it.
Six months ago I ran git blame on my own repository and stopped cold.
The three lines I needed to change were attributed to me. Dated January. Commit message: "Refactor cache layer and update tests." Fourteen files touched, over 800 lines of diff.
An agent had written those three lines. I had approved the plan, skimmed the diff, squashed everything into one commit, and pushed. What remained for future-me was three lines adrift in an 800-line sea, and a sentence that said nothing at all.
Why was that branch necessary? Which error did we hit before settling on this ordering? Nothing left to answer with.
A Commit Marks an Intent, Not a Work Session
Back when humans wrote every line by hand, commit granularity regulated itself. You spent an hour fixing one thing, then committed it. Elapsed time acted as a natural ceiling.
Hand the work to an agent and that ceiling vanishes. One session advances six plan steps and rewrites fourteen files. Folding that into a single commit feels right only because the session boundary looks like a commit boundary.
To anyone reading history later, a session boundary carries no meaning. An intent boundary does.
How commits are split
What git blame returns six months later
Median diff size
One session, one commit
"Refactor cache layer and update tests"
400–900 lines
One plan step, one commit
"Add TTL guard to cache read path"
30–120 lines
One file, one commit
The filename restated. Intent still lost.
10–60 lines
Look closely at the third row. Smaller is not automatically better. Splitting per file records nothing about why the change was needed.
The test is not line count. It is whether the change can be explained on its own. Antigravity agents carry an explicit plan, and a single plan step is, by construction, a change that can stand alone. That is the boundary I settled on while automating article generation across my four sites. As an indie developer whose only code reviewer is myself, how readable the history stays translates directly into how fast I can fix things later.
Committing at Plan Step Boundaries
The implementation can stay plain. Stop telling the agent to commit once everything is finished; have it commit as each step completes.
# .agent/commit-step.sh# usage: commit-step.sh <step-id> <summary>set -euo pipefailSTEP_ID="$1"; shiftSUMMARY="$*"# Nothing staged means nothing to record. Never create empty commits.if git diff --cached --quiet; then echo "step ${STEP_ID}: no staged changes, skipping commit." exit 0fiFILES=$(git diff --cached --name-only | wc -l | tr -d ' ')LINES=$(git diff --cached --numstat | awk '{ add += $1; del += $2 } END { print add + del }')# Warn the moment a step outgrows a single explainable intent.if [ "${FILES}" -gt 8 ] || [ "${LINES}" -gt 300 ]; then echo "⚠️ step ${STEP_ID}: ${FILES} files / ${LINES} lines. This plan step may be too large." >&2figit commit -m "${SUMMARY}" \ -m "Agent-Run-Id: ${AGENT_RUN_ID}" \ -m "Agent-Step: ${STEP_ID}" \ -m "Agent-Model: ${AGENT_MODEL}" \ -m "Agent-Plan-Sha: ${AGENT_PLAN_SHA}"
Passing several -m flags stacks each as its own paragraph. When the final paragraph consists solely of Key: Value lines, Git treats it as a trailer block — which means git interpret-trailers --parse and git log --format='%(trailers:key=Agent-Run-Id,valueonly)' can extract it mechanically.
That is categorically different from writing "generated by an agent" in prose. Prose is readable. It is not queryable, and it is not countable.
AGENT_PLAN_SHA hashes the plan text you approved. The plan itself lives and dies with the session, but the hash lets you prove later that three particular commits descended from one approved plan.
✦
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
✦A working script that walks from any source line back through Git trailers to the agent run, its intent, and the alternatives it rejected
✦Why one-commit-per-session fails, where to place commit boundaries instead, and how to enforce that mechanically with a commit-msg hook
✦A provenance freezing design that assumes artifacts disappear, keeping only the durable summary in-repo at 1 to 3 KB per step
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.
Start From the Assumption That Artifacts Disappear
Antigravity agents produce artifacts as they work: walkthroughs, verification records, screenshots from the browser sessions they drive to check their own output.
I originally wrote artifact URLs into my commit trailers. Months later, most of those URLs no longer resolved. Clear a local run history, rebuild a workspace, and of course they do not.
A pointer is only worth writing when the target is guaranteed to outlive it. So I gave up on pointers and switched to freezing the summary inside the repository.
# .agent/freeze_provenance.py"""Extract, from a plan step's verification record, only what is worth reading later.Screenshots and full logs are deliberately not kept. What you need six monthsfrom now is what was checked and what it rested on -- not pixels."""from __future__ import annotationsimport hashlibimport jsonimport pathlibimport subprocessimport sysPROVENANCE_DIR = pathlib.Path(".agent-provenance")MAX_NOTE_CHARS = 1200def freeze(run_id: str, step_id: str, record: dict) -> pathlib.Path: """Persist one step's provenance as JSON and return its path.""" PROVENANCE_DIR.mkdir(exist_ok=True) payload = { "run_id": run_id, "step_id": step_id, "model": record["model"], # Why it was built this way (prose, for humans). "intent": record["intent"][:MAX_NOTE_CHARS], # What established correctness (summary only). "verification": [v[:240] for v in record.get("verification", [])][:6], # Options considered and discarded. This is what pays off later. "rejected": [r[:240] for r in record.get("rejected", [])][:4], "plan_sha": record["plan_sha"], } body = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) digest = hashlib.sha256(body.encode("utf-8")).hexdigest()[:12] path = PROVENANCE_DIR / f"{run_id}-{step_id}-{digest}.json" path.write_text(body + "\n", encoding="utf-8") # The frozen record ships inside the same commit it describes. subprocess.run(["git", "add", str(path)], check=True) return pathif __name__ == "__main__": record = json.load(sys.stdin) out = freeze(sys.argv[1], sys.argv[2], record) print(out)
Of all these fields, rejected is the one I value most.
When you return to a piece of code half a year later, the question that surfaces is almost always: why didn't I write this the obvious way? If the agent considered the obvious way and two lines explain why it was discarded, you are spared walking that road a second time.
Each frozen record lands between 1 and 3 KB with those caps in place. Fifteen hundred steps a year comes to a few megabytes. Nothing that strains a repository.
Walking Back From a Line to a Decision
None of this pays off unless the lookup is a single command. The instant it becomes a three-step procedure, nobody performs it.
#!/usr/bin/env python3# .agent/why.py -- usage: why.py <file> <line>"""Trace a line to its commit, then to the intent, verification, and rejects behind it."""from __future__ import annotationsimport jsonimport pathlibimport subprocessimport sysdef _git(*args: str) -> str: return subprocess.run( ["git", *args], check=True, capture_output=True, text=True ).stdout.strip()def blame_commit(path: str, line: int) -> str: # -L <line>,<line> blames exactly one line; the SHA leads the porcelain output. out = _git("blame", "-L", f"{line},{line}", "--porcelain", "--", path) return out.split("\n", 1)[0].split(" ", 1)[0]def trailer(sha: str, key: str) -> str: return _git("log", "-1", f"--format=%(trailers:key={key},valueonly)", sha).strip()def main() -> int: path, line = sys.argv[1], int(sys.argv[2]) sha = blame_commit(path, line) subject = _git("log", "-1", "--format=%s", sha) run_id = trailer(sha, "Agent-Run-Id") step_id = trailer(sha, "Agent-Step") print(f"{sha[:12]} {subject}") if not run_id: print(" -> Written directly by a human (no provenance trailer).") return 0 print(f" model: {trailer(sha, 'Agent-Model')} run: {run_id} step: {step_id}") # Frozen records are named run-step-digest.json matches = sorted(pathlib.Path(".agent-provenance").glob(f"{run_id}-{step_id}-*.json")) if not matches: print(" -> No provenance file found (commit likely predates freezing).") return 1 rec = json.loads(matches[-1].read_text(encoding="utf-8")) print(f"\n intent: {rec['intent']}") if rec["verification"]: print(" verified by:") for v in rec["verification"]: print(f" - {v}") if rec["rejected"]: print(" rejected alternatives:") for r in rec["rejected"]: print(f" - {r}") return 0if __name__ == "__main__": raise SystemExit(main())
Type python .agent/why.py src/cache.py 87 and one screen tells you which model wrote the line, what it checked, and what it threw away.
Had this existed for the three lines that stopped me back in January, I suspect a single sentence would have been waiting there: treating TTL expiry and key absence in one branch triggers the cache rebuild twice.
Without Enforcement, the System Rots Within Months
A provenance trailer loses its value the first time it is forgotten. One missing line and the lookup answers "not found," and from then on nobody trusts it.
A commit-msg hook closes that gap.
#!/usr/bin/env bash# .git/hooks/commit-msgset -euo pipefailMSG_FILE="$1"# Demand nothing outside agent runs. Manual commits stay unencumbered.[ "${AGENT_RUN_ID:-}" = "" ] && exit 0for KEY in Agent-Run-Id Agent-Step Agent-Model Agent-Plan-Sha; do if ! git interpret-trailers --parse < "${MSG_FILE}" | grep -q "^${KEY}:"; then echo "❌ Missing trailer ${KEY}. Commit through commit-step.sh." >&2 exit 1 fidone# The frozen provenance record must ride along.if ! git diff --cached --name-only | grep -q '^\.agent-provenance/'; then echo "❌ No frozen record staged under .agent-provenance/." >&2 exit 1fi
The presence of AGENT_RUN_ID separates human work from agent runs. Demand provenance from a human patching production at midnight and the hook gets bypassed with --no-verify on the first attempt — and never comes back.
Roughly two months of running this across four sites.
Commit count rose about 2.4×, since one session now splits into five to seven commits. History is undeniably longer, and git log --oneline no longer conveys the shape of a week at a glance. I accept that cost.
The time to recover the intent behind a given line, meanwhile, went from several minutes to something under twenty seconds. why.py either answers or tells me a human wrote it. There is no third state. Losing the "I have no idea" state entirely mattered more to me than the speed.
Some things did not change. The intent an agent writes often paraphrases the implementation: "added a TTL check" without saying why one was needed. That gap closes only when I add a sentence myself while approving the plan.
Provenance does not become meaningful because you built a place to store it. That lesson keeps arriving.
Where to Start
You do not need all of this at once. In order of payoff:
Add commit-step.sh alone and split commits at plan steps. Skip trailers, skip freezing. Specific commit messages by themselves transform what git blame gives back.
Add trailers next. Once provenance is queryable, you can see what share of this month's commits an agent authored — and that number redistributes your review attention.
Frozen records and why.py come last. Their value only lands the day you return to that line half a year later.
Six months from now, you are a stranger to yourself. What you leave for that stranger is the whole question — and it grows heavier the more you delegate.
If the next time git blame stops you cold, some part of this saves you a few minutes, I will be glad.
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.