Neeraj Sharma 以 Python 審計 Battle Drones 334 次提交,估算 Claude 編寫 98.1% 變更行數
98% of Battle Drones Was Written by Claude: I Audited 334 Commits With 140 Lines of Python
Neeraj Sharma 用僅依賴 Python 標準函式庫的 140 行腳本審計 Battle Drones 的 334 次提交,估算 98.1% 的變更行數由 Claude 編寫。
On 20 October Simon Willison published Battle Drones, a browser RTS in the Command & Conquer mould. He built it over 12 major versions and 321 commits, mostly with Claude Code, and he is candid about it: "I've reviewed almost none of the code." The repo is public, which makes it the best open dataset I have seen for a question every team adopting coding agents should ask: how much of our codebase did the agent write, and what happened to that code afterwards?
So I wrote a small, stdlib-only Python script that answers it from git log, cloned the repo and ran it. Below are the script, the real output and what I think it means for teams.
Why measure this at all
My team is small and agents now write a real share of our commits. "Is the agent productive?" is the wrong question. The useful ones are operational:
- Review load. If most changed lines come from an agent, reviewers are the bottleneck. The cost of a merge is reading, not writing.
- Churn. Code added and deleted within two weeks is work you paid for twice, and agents can produce a lot of it.
- Fix-ups. If fix commits keep landing on files an agent just touched, that is where you need tests, a spec or a narrower prompt.
- Hotspots. The files agents touch most are where a guardrail (a lint rule, a CODEOWNERS entry, a contract test) pays back fastest.
None of this needs a vendor dashboard. Agents already leave fingerprints in git.
How it detects agent commits
Claude Code adds a Co-Authored-By: Claude ... <noreply@anthropic.com> trailer, and Claude Code on the web also adds a Claude-Session: line with a session URL. Copilot's coding agent commits as copilot-swe-agent[bot] or a +Copilot@users.noreply.github.com address. Cursor, Codex, Devin and Aider have their own patterns. The script matches one regex per agent against author, committer and the full message. Everything else counts as human. Merge commits and CI bots are skipped.
Then, in one pass over the full patch history (git log -p with zero context lines), oldest commit first, it computes:
-
Share: commits and changed lines (added plus deleted) per class, ignoring generated paths such as
dist/, lockfiles and minified bundles. - Churn: an added line counts as churned if a later commit deletes an identical line in the same file within N days (default 14). This is content matching, not true blame, so treat it as an estimate.
- Fix-ups: commits whose subject says fix, bug, revert, regression and so on, split by whether they touch a file that agent or human work changed inside the window.
- Hotspots and timeline: top files by agent-changed lines, plus a per-day table.
The script
#!/usr/bin/env python3
"""agentaudit.py: how much of a git repo did coding agents write?
Usage: python3 agentaudit.py [REPO] [--days 14] [--top 8] (stdlib only)
"""
import argparse, collections, datetime as dt, re, subprocess
# One regex per agent, matched against "author / committer / full message".
AGENTS = {
"Claude": r"noreply@anthropic\.com|co-authored-by:[^\n]*claude|claude-session:"
r"|claude\.ai/code/session|generated with \[claude code\]",
"Codex": r"co-authored-by:[^\n]*codex|chatgpt-codex-connector|codex@openai\.com",
"Copilot": r"copilot-swe-agent|\+copilot@users\.noreply|co-authored-by:[^\n]*copilot",
"Cursor": r"cursoragent@cursor\.com|co-authored-by:[^\n]*cursor",
"Devin": r"devin-ai-integration",
"Aider": r"\(aider\)|co-authored-by:[^\n]*aider",
}
AGENTS = {k: re.compile(v, re.I | re.M) for k, v in AGENTS.items()}
BOT = re.compile(r"\[bot\]|actions@users\.noreply|github-actions", re.I) # CI, not agents
FIX = re.compile(r"^(revert|fixup!|hotfix)|\b(fix(es|ed)?|bug|regression|broke|broken|oops)\b", re.I)
TRAILER = re.compile(r"^co-authored-by:\s*([^<\n]+?)\s*<", re.I | re.M)
# Generated files inflate line counts; skip them (override with --skip).
SKIP = r"(^|/)(dist|build|vendor|node_modules|docs/assets)/|\.lock$|-lock\.json$|\.min\.|\.map$"
FMT = "%x00%H%x1f%an <%ae>%x1f%cn <%ce>%x1f%at%x1f%P%x1f%B%x1e"
def who(author, committer, msg):
text = f"{author}\n{committer}\n{msg}"
return next((name for name, rx in AGENTS.items() if rx.search(text)), None)
def audit(repo, days, skip_rx):
skip_rx = re.compile(skip_rx)
out = subprocess.run(["git", "-C", repo, "log", "--reverse", "-p", "-M", "--unified=0",
"--no-color", "--format=" + FMT], capture_output=True,
text=True, errors="replace", check=True).stdout
window = days * 86400
s = collections.defaultdict(collections.Counter) # stat name -> {"agent"/"human": n}
per_agent, models = collections.Counter(), collections.Counter()
files, day = collections.defaultdict(collections.Counter), collections.defaultdict(collections.Counter)
added = collections.defaultdict(lambda: collections.defaultdict(collections.deque))
last_touch = {} # path -> (ts, cls)
for chunk in out.split("\x00")[1:]:
head, patch = chunk.split("\x1e", 1)
sha, author, committer, ts, parents, msg = head.split("\x1f")
ts = int(ts)
if len(parents.split()) > 1:
s["merges"]["all"] += 1
continue
agent = who(author, committer, msg)
if not agent and BOT.search(author):
s["merges"]["bots"] += 1
continue
cls = "agent" if agent else "human"
if agent:
per_agent[agent] += 1
models.update(t for t in TRAILER.findall(msg) if AGENTS[agent].search(f"co-authored-by: {t}"))
s["commits"][cls] += 1
if not agent and re.search(r"\(#\d+\)$", msg.split("\n", 1)[0].strip()):
s["squash"]["human"] += 1 # squash-merged PR: author unknown
d = dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime("%Y-%m-%d")
day[d][cls] += 1
path, in_hunk, skip, touched = None, False, False, set()
for line in patch.split("\n"):
if line.startswith("diff --git "):
m = re.match(r"diff --git a/(.*) b/(.*)", line)
old, path = (m.group(1), m.group(2)) if m else (None, line)
if old and old != path and old in added: # follow renames
added[path] = added.pop(old)
skip = bool(skip_rx.search(path))
if not skip:
touched.add(path)
in_hunk = False
elif line.startswith("@@"):
in_hunk = not skip
elif in_hunk and line[:1] in ("+", "-"):
content = line[1:].strip()
s["lines"][cls] += 1
files[path][cls] += 1
day[d][cls + "_lines"] += 1
if line[0] == "+":
s["added"][cls] += 1
if len(content) > 2: # skip "}", "", ");"
added[path][content].append((ts, cls))
elif added[path][content]:
t0, c0 = added[path][content].popleft() # oldest matching add
if ts - t0 <= window:
s["churn"][c0] += 1
if FIX.search(msg.split("\n", 1)[0]):
s["fixes"][cls] += 1
hit = {last_touch[p][1] for p in touched
if p in last_touch and ts - last_touch[p][0] <= window}
s["fix_after"].update(hit)
for p in touched:
last_touch[p] = (ts, cls)
return s, per_agent, models, files, day
def pct(a, b):
return f"{100 * a / b:5.1f}%" if b else " n/a"
def report(repo, days, top, skip_rx):
s, per_agent, models, files, day = audit(repo, days, skip_rx)
c, l = s["commits"], s["lines"]
nc, nl = sum(c.values()), sum(l.values())
print(f"repo: {repo} window: {days} days merges skipped: {s['merges']['all']} bot commits skipped: {s['merges']['bots']}")
print(f"commits agent {c['agent']:>6} {pct(c['agent'], nc)} human {c['human']:>6} {pct(c['human'], nc)}")
print(f" human commits that look like squash-merged PRs (true author unknown): "
f"{s['squash']['human']}")
print(f"lines agent {l['agent']:>6} {pct(l['agent'], nl)} human {l['human']:>6} {pct(l['human'], nl)}")
print("agents " + ", ".join(f"{k} {v}" for k, v in per_agent.most_common()))
print("models " + ", ".join(f"{k} {v}" for k, v in models.most_common(5)))
for cls in ("agent", "human"):
print(f"churn {cls}: {s['churn'][cls]} of {s['added'][cls]} added lines deleted within "
f"{days}d ({pct(s['churn'][cls], s['added'][cls]).strip()})")
print(f"fixes fix/revert commits: {sum(s['fixes'].values())} (by agent {s['fixes']['agent']}, "
f"by human {s['fixes']['human']})")
for cls in ("agent", "human"):
print(f" touching files last changed by {cls} work <= {days}d earlier: "
f"{s['fix_after'][cls]} ({s['fix_after'][cls] / max(c[cls], 1) * 100:.1f} per 100 {cls} commits)")
print(f"\ntop {top} files by agent-changed lines:")
for p, n in sorted(files.items(), key=lambda kv: -kv[1]["agent"])[:top]:
print(f" {n['agent']:>6} {pct(n['agent'], n['agent'] + n['human'])} agent {p}")
print("\nday commits A/H lines A/H agent share of lines")
for d in sorted(day):
n = day[d]
share = n["agent_lines"] / max(n["agent_lines"] + n["human_lines"], 1)
print(f"{d} {n['agent']:>4}/{n['human']:<4} {n['agent_lines']:>7}/{n['human_lines']:<6} "
f"{'#' * round(share * 20):<20}")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("repo", nargs="?", default=".")
ap.add_argument("--days", type=int, default=14)
ap.add_argument("--top", type=int, default=8)
ap.add_argument("--skip", default=SKIP, help="regex of paths to ignore")
a = ap.parse_args()
report(a.repo, a.days, a.top, a.skip)
Usage is just this, and it took about 4 seconds on Battle Drones:
python3 agentaudit.py path/to/repo --days 14 --top 8
Battle Drones: the real output
I cloned at commit 39d6f58 (20 Oct). The clone has 334 commits, 48 of them merges, so slightly more than the 321 counted in Simon's post. Dates are UTC.
repo: . window: 14 days merges skipped: 48 bot commits skipped: 0
commits agent 267 93.4% human 19 6.6%
human commits that look like squash-merged PRs (true author unknown): 0
lines agent 170299 98.1% human 3258 1.9%
agents Claude 267
models Claude Opus 5.5 229, Claude Fable 5 28, Claude 1
churn agent: 12824 of 153755 added lines deleted within 14d (8.3%)
churn human: 362 of 2875 added lines deleted within 14d (12.6%)
fixes fix/revert commits: 39 (by agent 39, by human 0)
touching files last changed by agent work <= 14d earlier: 38 (14.2 per 100 agent commits)
touching files last changed by human work <= 14d earlier: 0 (0.0 per 100 human commits)
top 8 files by agent-changed lines:
6362 100.0% agent src/ui/style.css
4640 100.0% agent DESIGN.md
3874 100.0% agent src/game/GameScene.ts
2747 100.0% agent src/ui/hud.ts
2680 99.8% agent SOUND-DUEL.md
2444 100.0% agent src/game/three/terrain.ts
2365 100.0% agent src/sim/ai/commander.ts
2339 100.0% agent public/assets/sprites/game.json
day commits A/H lines A/H agent share of lines
2026-07-17 28/1 6504/2 ####################
2026-09-28 3/0 13953/0 ####################
2026-10-07 23/0 33545/0 ####################
2026-10-08 5/1 9589/581 ###################
2026-10-09 21/2 10002/473 ###################
2026-10-10 40/5 11759/683 ###################
2026-10-11 18/0 10034/0 ####################
2026-10-12 65/0 16785/0 ####################
2026-10-13 10/0 3075/0 ####################
2026-10-14 6/7 3934/1210 ###############
2026-10-15 9/0 2832/0 ####################
2026-10-16 3/0 2677/0 ####################
2026-10-19 15/0 33969/0 ####################
2026-10-20 20/3 11597/309 ###################
2026-10-21 1/0 44/0 ####################
What I read from it:
98.1% of changed lines are agent-written. The history backs up "mostly by Claude Code": 267 of 286 non-merge commits carry a Claude signal. 229 name Claude Opus 5.5 in the trailer and 28 name Claude Fable 5, which matches his account of starting v1 on an earlier model in July.
Generated files distort everything. My first run had no skip list. Agent share of lines came out at 95.4%, and five of the top eight "agent files" were build output under docs/assets/ plus package-lock.json. If you are measuring review load, exclude what nobody reviews.
Squash merges nearly hid commits. Simon is the author on 28 non-merge commits. Nine of them are squash-merged agent PRs, with subjects like "Shift+click adds or removes a unit from the selection (#6)". Eight of those nine have no Co-Authored-By trailer at all and are caught only because the message keeps a Claude Code session link. Remove that one signal and the human commit count goes from 19 to 27.
Churn is moderate. 8.3% of agent-added lines were deleted within 14 days. The human figure is higher at 12.6%, but on only 2,875 lines, so I would not read anything into the comparison. For a game rebalanced across 12 versions with overnight playtest runs, I expected more.
Every fix commit came from the agent. There are 39 fix-style commits, all agent-authored, and 38 of them touch files that agent work changed in the previous two weeks. Most subjects read like "Fixes from the final review" or "Sound duel: fix the final review's three major findings". The fix loop here is agents reviewing and repairing agent code. That works for a game. It is the first number I would want visible before letting the same workflow near a billing service.
Hotspots are where you would expect. src/ui/style.css, GameScene.ts, hud.ts and the AI's commander.ts lead. Two of the top five are design docs (DESIGN.md, SOUND-DUEL.md): the agent writes and maintains its own specs, which is a habit worth copying.
A second repo: where attribution breaks
For contrast I ran it on simonw/tools, his collection of single-file HTML tools, at commit befbcec. Top of the output:
repo: . window: 14 days merges skipped: 6 bot commits skipped: 483
commits agent 226 24.5% human 698 75.5%
human commits that look like squash-merged PRs (true author unknown): 48
lines agent 72709 9.1% human 727389 90.9%
agents Claude 214, Copilot 12
models Claude 155, copilot-swe-agent[bot] 9, Claude Fable 5.1 6, Claude Opus 5.5 4, claude[bot] 3
churn agent: 1420 of 68797 added lines deleted within 14d (2.1%)
churn human: 4639 of 716876 added lines deleted within 14d (0.6%)
fixes fix/revert commits: 74 (by agent 40, by human 34)
touching files last changed by agent work <= 14d earlier: 18 (8.0 per 100 agent commits)
touching files last changed by human work <= 14d earlier: 42 (6.0 per 100 human commits)
top 6 files by agent-changed lines:
5244 100.0% agent grok-mermaid/src/mermaid.rs
2929 100.0% agent chirp-pong.html
2343 100.0% agent video-compressor.html
2080 100.0% agent bluesky-bot-check.html
1614 100.0% agent latex.html
1613 100.0% agent sqlite-query-explainer.html
Here only 24.5% of commits and 9.1% of lines are attributed to agents (Claude 214, Copilot 12). Agent code churns more than human code (2.1% vs 0.6%) and its files attract slightly more follow-up fix commits (8.0 vs 6.0 per 100 commits). But the "human" bucket is not what it looks like. Of the 698 commits the script calls human, 112 link to a shared Claude conversation and 48 to a ChatGPT one, and 48 look like squash-merged PRs. Code written in a chat window and pasted in by hand looks exactly like human code to git. Git records who committed, not who wrote.
Caveats to take seriously
- Trailers are voluntary. They can be disabled in settings, stripped by hand or lost in a rebase. Treat the agent share as a floor.
- Squash merges erase authorship unless the squash message keeps the trailer or a session link. If you want this metric, make that a merge rule.
- Chat-and-paste is invisible. The script only sees agents that commit.
- Content-matched churn misses lines that were edited slightly and counts moved lines as churn.
- "Fix" in a subject line is a crude signal. It is fine for trends inside one repo and poor for comparing teams.
Most of these are solved by policy, not code: require the trailer, keep it through squash merges, and run something like this weekly in CI so the trend is visible before it becomes a surprise.
Where this fits
At Axionry we build LLM apps, agents, RAG pipelines and MCP tooling, and every build ships with observability and cost-per-request dashboards. A git audit like this is the development-side twin of those dashboards: production metrics tell you what an agent costs to run, this tells you what agent-written code costs to review and keep. If you are scoping an AI feature and want a rough number first, try our AI product cost estimator.
The script is above, 140 lines, no dependencies. Point it at your own repo and see what your real agent share is.
Sources: Simon Willison, "Battle Drones", simonw/battledrones on GitHub, simonw/tools on GitHub, AI Weekly coverage.
來源:DEV Community · dev.to