Attribution-aware turn diffs

Minimal changes to stop git-only actions (pull, checkout, cherry-pick, rebase) from polluting the changed-files card and inline diff view in T3 Code. — 2026-07-21

TL;DR — Keep the full-tree checkpoint snapshot mechanism exactly as it is (it powers revert and is ground truth). Add two optional fields to each checkpoint (headOid, branch) and a pure attribution filter that runs at diff time: any file whose blob transition is fully explained by commits that pre-existed the turn is demoted to a one-line "git activity" summary; everything else is shown as agent work. No new snapshots, no tool-call interception, no changes to capture timing, fully backward compatible.

1. Problem recap

The changed-files card and per-turn inline diff are derived from full-tree git snapshots at turn boundaries: at turn start and turn end, CheckpointReactor captures a parentless commit of the working tree (apps/server/src/vcs/GitVcsDriver.ts:651), and the visible diff is simply git diff snap(N-1) snap(N). The file list on the card is the parsed output of that patch (CheckpointReactor.ts:218checkpointing/Diffs.ts:9 → projector → apps/web/src/hooks/useTurnDiffSummaries.ts).

This answers "what bytes changed on disk between turn boundaries?" — but the card is a review surface, and the user's question is "what did the agent author?". The two diverge whenever the agent runs a tree-rewriting git command:

The rule we're implementing: content that moved through git history should not appear as agent work; content the agent wrote (via Edit/Write, npm install, codegen, formatters, etc.) should.

2. Design principle

The insight that keeps this change minimal: we don't need to observe what the agent did (no tool-call interception, no per-command sub-snapshots, no races with the provider runtime). Git history itself already tells us which changes "moved through git" — after the fact. If HEAD moved during the turn, the delta between the two HEADs describes exactly the content that arrived via history. Subtract it, per file, from the tree delta:

agent-authored delta  =  tree delta (snapshots)  −  foreign-history delta (HEAD movement)

One subtlety: not all HEAD movement is foreign. If the agent edits a file and then commits it, HEAD moves and includes agent-authored content that must not be subtracted. We distinguish the two with commit timestamps: a commit whose author date predates the turn start carried pre-existing content (pull, checkout, reset, and — because cherry-pick/rebase preserve author dates — cherry-picked and rebased commits too). A commit authored during the turn is agent work.

snapshot turn N−1 + headOid, branch ✦ snapshot turn N + headOid, branch ✦ turn runs tree delta T = diff(snaps) history delta H = diff(HEAD N−1, HEAD N) Attribution filter ✦ per-file: T minus foreign H SHOWN: agent-authored files changed-files card + inline diff DEMOTED: git activity row "pulled origin/main — 214 files" ✦ = new in this plan. Everything else already exists and is unchanged.
The attribution filter is a pure function inserted at diff time. Capture, storage, and revert are untouched.

3. The attribution algorithm

Runs in two places with identical logic: the reactor (to produce the card's file list) and CheckpointDiffQuery (to produce the inline patch). Fast path first:

if headOid(N−1) is missing or headOid(N−1) == headOid(N):
    → current behavior, no filtering. (This is the vast majority of turns:
      the agent didn't move HEAD, so every tree change is agent work.
      Note: this correctly SHOWS `git apply` / `stash pop` output, and a
      plain `git commit` never appears — committing doesn't change the tree.)

When HEAD moved, classify every file in the tree delta:

# All plumbing on OIDs — no file content is read. --no-renames for stable matching.
T = git diff --raw --no-renames snap(N−1) snap(N)          # tree delta w/ blob OIDs
H = git diff --raw --no-renames HEAD(N−1) HEAD(N)          # history delta w/ blob OIDs

commits  = git rev-list --format='%H %at' HEAD(N−1)..HEAD(N)
turnCommits = commits whose author date ≥ turn-start timestamp     # agent-authored this turn
S_agent  = union of `git diff-tree --name-only -r c` for c in turnCommits

for each file f in T:
    if f has an entry in H with identical (srcOid, dstOid, srcMode, dstMode)
       and f ∉ S_agent:
        → DEMOTE  (change fully explained by pre-existing history)
    else:
        → SHOW    (agent touched it, or history alone doesn't explain it)
ScenarioWhat happensResult
Agent edits foo.ts with Edit toolHEAD didn't move → fast pathSHOW
npm install updates lockfileHEAD didn't move → fast pathSHOW
git pull origin main, 214 filesAll 214 blob transitions match H; commits pre-date turnDEMOTE
git checkout other-branchSame — full branch delta matches HDEMOTE
git cherry-pick abc123New commit OID, but author date is old → not in turnCommitsDEMOTE
Agent edits file, then git commits itCommit authored during turn → file ∈ S_agentSHOW
Pull and hand-edit the same file in one turnBlob transition in T ≠ transition in HSHOW (diff includes upstream hunks; refinement in Phase 3)
Untracked build artifact appearsUntracked files never appear in HSHOW
Merge-conflict resolution by agentResolved content matches neither parent's transitionSHOW
git restore undoing an earlier agent editNet tree delta disappears (snapshot property, unchanged)absent — correct
Failure direction is chosen deliberately. Every ambiguity resolves toward SHOW. Misclassification can only ever add noise (a file shown that was really just pulled), never hide agent work — with one narrow exception (commit --amend of a pre-existing commit, see §6). "Too much shown" is today's status quo; "agent work hidden" would be a new and worse failure mode.

Cost

The fast path adds one git rev-parse HEAD + one git symbolic-ref per checkpoint capture (~2ms). The slow path runs only on turns where HEAD moved and is all plumbing on OIDs: two diff --raw, one rev-list, and a diff-tree per turn-authored commit (usually 0–2). Even a 1,000-commit pull is a single cheap rev-list.

4. Change set, by phase

Phase 0 Capture metadata — tiny, ship first, zero behavior change

Phase 1 The attribution filter — the core

Phase 2 UI — small

Phase 3 Optional refinements — only if the demand shows up

5. UI behavior

┌─ 3 changed files                                +142 −38 ─┐
│  ▸ apps/web/src/components/                               │
│      ChatView.tsx                                +90  −20 │
│      DiffPanel.tsx                               +40  −12 │
│  ▸ apps/server/src/                                       │
│      routes.ts                                   +12   −6 │
│  ⎇  Updated via git (main → main) — 214 files    (hidden) │
└───────────────────────────────────────────────────────────┘

Three files of agent work stay reviewable at a glance; the pull that used to bury them becomes one muted row. Clicking "View diff" opens the panel showing only the three files, with the banner offering the full tree delta.

6. Edge cases & known limitations

CaseBehaviorVerdict
Teammate's commit pushed seconds before the turn, then pulled — author date could be ≥ turn startMisclassified as turn-authored → shownFails open (extra noise, nothing hidden). Acceptable.
Clock skew between machinesSame direction: at worst, extra files shownAcceptable.
git commit --amend of a pre-existing commit (old author date preserved)The amended content is misclassified as foreign → demoted. The only known case where agent work can be hidden.Rare in agent workflows; recoverable via the "show git changes" toggle; fixable in Phase 3 via patch-id. Documented, accepted.
git apply / git stash pop of agent-generated contentNo HEAD movement → fast path → shownCorrect per the "agent-authored" rule.
Rebase of the agent's own earlier commitsOld author dates preserved → demotedCorrect — that work was already shown in the turns that authored it.
Old threads / checkpoints captured before Phase 0No headOid → fast path → exactly today's behaviorBackward compatible by construction.
Revert flowUntouched — restoreCheckpoint and ref pruning don't read the file listNo interaction.
Renames--no-renames on both raw diffs → matching stays exact (delete+add pairs match or don't, symmetrically)Deliberate; rename display in the patch is unaffected.
Attribution plumbing fails mid-diffLog + fall back to unfiltered diffNever worse than today.

7. Explicit non-goals

8. Testing & rollout

9. Effort estimate

PhaseScopeSize
0 — capture metadata2 files + schema, ~60 linesSmall (half a day incl. tests)
1 — attribution filterNew module + 3 wiring sites + plumbing helpers, ~300 linesMedium (1–2 days incl. fixture tests)
2 — UICard row + panel banner/toggle, ~120 linesSmall (half a day)
3 — refinementsDeferred

Sources: mechanism mapping from CheckpointReactor.ts, GitVcsDriver.ts, CheckpointDiffQuery.ts, projector.ts, ChangedFilesTree.tsx, MessagesTimeline.tsx, DiffPanel.tsx — verified 2026-07-21 on branch t3code/6e22f0d6.