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
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:218 → checkpointing/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:
git pull origin main→ hundreds of already-reviewed upstream files listed as agent changes.git checkout other-branch→ the entire branch delta appears.git cherry-pick/git rebase→ content authored elsewhere (or re-applied copies of the agent's own earlier work) shows up as new.
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.
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)
| Scenario | What happens | Result |
|---|---|---|
Agent edits foo.ts with Edit tool | HEAD didn't move → fast path | SHOW |
npm install updates lockfile | HEAD didn't move → fast path | SHOW |
git pull origin main, 214 files | All 214 blob transitions match H; commits pre-date turn | DEMOTE |
git checkout other-branch | Same — full branch delta matches H | DEMOTE |
git cherry-pick abc123 | New commit OID, but author date is old → not in turnCommits | DEMOTE |
Agent edits file, then git commits it | Commit authored during turn → file ∈ S_agent | SHOW |
| Pull and hand-edit the same file in one turn | Blob transition in T ≠ transition in H | SHOW (diff includes upstream hunks; refinement in Phase 3) |
| Untracked build artifact appears | Untracked files never appear in H | SHOW |
| Merge-conflict resolution by agent | Resolved content matches neither parent's transition | SHOW |
git restore undoing an earlier agent edit | Net tree delta disappears (snapshot property, unchanged) | absent — correct |
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
- apps/server/src/vcs/GitVcsDriver.ts — in
captureCheckpoint(line 651), also readgit rev-parse HEADandgit symbolic-ref --short -q HEAD; return them alongside the checkpoint ref. Handle unborn HEAD (no commits yet) by returning null — the filter fast-path then applies. - apps/server/src/orchestration/Layers/CheckpointReactor.ts — thread
headOid/branchthroughcaptureAndDispatchCheckpoint(line 218) and both baseline capture paths (lines 478, 548) into the dispatched events. - apps/server/src/orchestration/projector.ts — persist the new optional fields on
OrchestrationCheckpointSummary(case at line 539). Also record the turn-start timestamp on the baseline entry if not already retrievable fromcompletedAtof checkpoint N−1 (whose capture time is the turn start — verify during implementation; if so, no new field needed). - Schema: extend the checkpoint summary type (server + apps/web/src/types.ts) with optional
headOid?,branch?. Old persisted checkpoints simply lack them → filter disabled for those turns automatically.
Phase 1 The attribution filter — the core
- New module apps/server/src/checkpointing/Attribution.ts — pure logic from §3. Input: the two snapshot refs, the two head OIDs, turn-start time, plus a thin git-plumbing interface (raw diff, rev-list, diff-tree) added to the VCS driver. Output:
{ shownPaths: Set<string>, gitActivity?: { fromHead, toHead, fromBranch?, toBranch?, fileCount, additions, deletions } }. Pure and separately unit-testable. - apps/server/src/vcs/GitVcsDriver.ts — add the three plumbing helpers (
diffRaw(refA, refB),revListWithAuthorDates(range),diffTreePaths(commit)), same execution pattern as the existingdiffCheckpoints(line 762). - CheckpointReactor.ts
captureAndDispatchCheckpoint— after computing the unified diff (line 259), run the filter; drop demoted files fromfiles[]; attachgitActivityto thethread.turn.diff.completeevent payload. If any plumbing call fails, log and fall back to unfiltered — never block the checkpoint pipeline on attribution. - apps/server/src/checkpointing/CheckpointDiffQuery.ts — in
getTurnDiff(line 79) andfullThreadDiff, run the same filter and drop demoted files from the parsed patch before returning (filter the parsed file list — no pathspec gymnastics or arg-length limits). Accept anincludeGitChanges?: booleaninput that skips filtering, for the UI escape hatch.fullThreadDiffusesheadOid(0)vsheadOid(N)with turn-start = thread start. - Projector + summary schema: add optional
gitActivity?toOrchestrationCheckpointSummary.
Phase 2 UI — small
- apps/web/src/components/chat/ChangedFilesTree.tsx + MessagesTimeline.tsx (
AssistantChangedFilesSection, line 1234) — whengitActivityis present, render one non-expandable row beneath the file tree: "Updated via git — 214 files" with branch names when available ("main → feature-x"), styled distinctly (muted, git icon). If the turn has only git activity and zero agent files, the card renders just this row instead of disappearing — the user should still learn the agent moved the tree. - apps/web/src/components/DiffPanel.tsx — when the selected turn has
gitActivity, show a dismissible banner: "Changes applied via git are hidden. Show them" → togglesincludeGitChangeson the query. This is the escape hatch that keeps raw truth one click away.
Phase 3 Optional refinements — only if the demand shows up
- Mixed-file rebasing: for a file both pulled and edited in one turn, display
diff(HEAD(N):f, snap(N):f)when the file was clean at turn start — isolating agent hunks from upstream hunks. Punted because it complicates the patch pipeline and the file is at least correctly shown today. - Amend detection: patch-id comparison to catch
commit --amendof pre-existing commits (see §6). - Classify the operation: label the git-activity row "pulled" vs "switched branch" vs "cherry-picked" by inspecting reflog or commit topology. Nice copy, zero mechanism value.
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
| Case | Behavior | Verdict |
|---|---|---|
| Teammate's commit pushed seconds before the turn, then pulled — author date could be ≥ turn start | Misclassified as turn-authored → shown | Fails open (extra noise, nothing hidden). Acceptable. |
| Clock skew between machines | Same direction: at worst, extra files shown | Acceptable. |
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 content | No HEAD movement → fast path → shown | Correct per the "agent-authored" rule. |
| Rebase of the agent's own earlier commits | Old author dates preserved → demoted | Correct — that work was already shown in the turns that authored it. |
| Old threads / checkpoints captured before Phase 0 | No headOid → fast path → exactly today's behavior | Backward compatible by construction. |
| Revert flow | Untouched — restoreCheckpoint and ref pruning don't read the file list | No 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-diff | Log + fall back to unfiltered diff | Never worse than today. |
7. Explicit non-goals
- No changes to snapshot capture, storage, refs, or revert. The checkpoint mechanism stays byte-for-byte identical; this is a read-side layer.
- No tool-call interception or per-command sub-snapshots. The runtime executes tools on its own schedule; snapshotting "around" Bash calls is inherently racy and touches the ingestion layer. The post-hoc history subtraction gets ~95% of the value with none of that risk.
- No changes to the Source-Control panel (VcsStatusBroadcaster) or the working-tree / branch-range diff scopes in DiffPanel — those intentionally answer git-level questions and are correct as-is.
- Not parsing agent command lines to classify git operations. Commit metadata is more reliable than guessing what a shell command did.
8. Testing & rollout
- Unit tests for Attribution.ts against scripted fixture repos, one per row of the §3 table: pull, checkout, cherry-pick, rebase, own-commit, mixed pull+edit, untracked file, merge conflict, unborn HEAD, missing headOid.
- Reactor/query integration tests: extend existing checkpoint tests to assert
files[]excludes demoted paths andgitActivityis populated; assertincludeGitChanges: truereturns the full patch. - Manual verification via the
test-t3-appflow: seed a repo with an upstream remote, have the agent pull + edit in one turn, eyeball the card and both diff panel modes. - Rollout: Phase 0 ships silently (metadata only). Phase 1+2 ship together behind the existing pattern of read-side derivation — no migration, no flag needed since old data degrades to current behavior. Log a structured event whenever demotion occurs (turn id, demoted count) so we can see real-world hit rates and spot misclassification reports.
9. Effort estimate
| Phase | Scope | Size |
|---|---|---|
| 0 — capture metadata | 2 files + schema, ~60 lines | Small (half a day incl. tests) |
| 1 — attribution filter | New module + 3 wiring sites + plumbing helpers, ~300 lines | Medium (1–2 days incl. fixture tests) |
| 2 — UI | Card row + panel banner/toggle, ~120 lines | Small (half a day) |
| 3 — refinements | Deferred | — |
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.