Skip to content

ADR 0064: update reports what changed beneath the branch

Vocabulary amendment: Current pointers use finishdone, integrateupdate, scopesimpact where it names the verb; the decision and reasoning are unchanged.

Status: accepted. Extends the update verb with a structured result payload, built on the result envelope + advisory hints of ADR 0028 / ADR 0030, and reusing the scope-classification SSOT of the impact verb.

Context

§

ADR 0055 gave the worktree lifecycle its middle verb: update merges the latest main into the branch and re-materializes the agent files in one step. Its result reported that it ran — a merge step, a refresh step, the convergent ensure steps — and a single generic hint ("run discern done"). It said nothing about the content of the merge.

That omission is the whole point of the operation. An agent working in a worktree has a mental model frozen at its fork point. When main advances underneath it and the agent updates, a textually-clean merge is a false sense of safety: git auto-merges hunk by hunk, so two edits to the same file — or logically-coupled edits across files — merge without conflict while breaking the combined semantics. The agent is given no signal that any of its assumptions were just invalidated. The failure mode isn't "I can't see a changelog"; it's "I don't know which of my own files just shifted under me." A weaker agent, told only "merged main, run done," sails straight into the semantic break.

The raw material to fix this is already in hand at merge time. updateMain computes how far behind the branch was and whether the merge was a fast-forward, then discards everything richer — the commits, the files, and crucially the overlap between what the agent changed and what main changed.

Decision

§

update returns an UpdateData payload summarizing what the merge brought in beneath the branch — overlap-first — and surfaces it identically on the CLI, in --json, and through the MCP tool. The result spine of ADR 0027 / ADR 0028 means one computed object renders to every surface; the human narration and the structured data can never disagree on what happened.

The payload, from highest-value signal down:

  • overlap — the files the branch and main both changed (the branch's own diff since the fork ∩ the files the merge brings in). This is the hot zone: the places a clean merge most likely hides a semantic conflict. It is the headline of the agent-facing hint ("⚠ … N file(s) you've changed are also changed by main: … — re-read them for semantic conflicts a clean merge can't catch") and the one list capped loosely, because it is already narrow and is what the agent must act on.
  • scopes_incoming — the fire-scopes the incoming files fall in, classified through the same matcher the gate uses (scopesForPaths, factored out of impact so the two can't drift). A broader "be wary of the engine scope" signal that survives even when the exact files differ.
  • commits / files — what landed, each capped (10 / 20) with the pre-cap *_total and a *_truncated flag, so the agent always knows the true size without the full list flooding its context.
  • range — the four SHA anchors that bound the integration: base (fork point), before (the branch tip / the agent's own work), main (the tip merged), after (the merged HEAD). These make a capped list a single deliberate git call instead of a dead end: when a list truncates, a hint hands back the exact command with the anchors pre-substituted (git diff --stat <before>..<after>, git log --oneline <before>..<main>), so even a weak agent pulls the full set in one round-trip rather than inferring the range.

It works the same in --dry-run, as a prediction. A preview can't merge, so there is no after and the file delta is the three-dot before...main prediction (changes on main since the fork) rather than the apply's two-dot before..after (the real post-merge tree change, reflecting conflict resolution). The range/commits/overlap/scopes are otherwise identical, so update --dry-run answers "what would land, and does any of it touch my work?" before committing to the merge.

Default-on, fail-open, and capped. No flag: the user's instinct was right — there is no case where an integrating agent doesn't want this, and it costs ~4 read-only git calls on a merge that already happened. Every git read fails open to an empty result and the whole summary is wrapped so a hiccup degrades to the old plain hint — on an apply the merge has already landed ( ADR 0055's "a refresh can fail without losing the merge" extended to the summary). Renames are decomposed to delete + add (--no-renames) so every path is one matchable entry regardless of the user's diff.renames, and the overlap matches on literal paths.

Consequences

§
  • A clean merge stops being a silent risk. The single most valuable line an integrating agent reads is now "these N files you touched also moved" — a directed action, not a discovery left to the next failing test.
  • Truncation is never a dead end. Caps protect the agent's context; the range anchors + escape-hatch hints turn an overflow into one precise git call. This matters most in a busy repo, where the atomic-commit discipline discern itself encourages produces high merge churn.
  • The scope matcher gained one caller and lost its duplication. scopesForPaths is now the single classifier behind both impact and update; a path-glob change moves both together.
  • update's tool advertises a data-bearing outputSchema. It joined the data verbs (done/status/start/…) — UpdateOutputSchema narrows the envelope's data to UpdateData; the no-op (already-updated) path simply omits data, which the optional field allows.
  • One more payload to keep faithful. The wire schema, the engine type, and the human narration are three renderings of one object; the schema is derived, and the engine tests pin the overlap math, the capping/escape-hatch, the scope classification, and dry-run/apply parity.

Alternatives considered

§
  • Dump the full commit + file lists. Rejected — it floods the agent's context on exactly the busy-repo merges where the summary matters most. Caps + the range escape hatch give the full picture on demand without the flood.
  • Report counts only ("+7 commits, 41 files"). Rejected — the count is noise; the agent needs which files, and specifically the overlap. A bare count doesn't change what the agent does next.
  • Compute overlap as a git command the agent runs. Rejected — git has no single command for the intersection of two diffs, and offloading the set logic is exactly what a weaker agent gets wrong. discern computes it and hands back the answer; the range anchors remain for any deeper inspection.
  • Predict conflicts in --dry-run via git merge-tree. Deferred, not rejected — a natural follow-up (git ≥2.38 can merge in the object store and report conflicts without touching the tree), kept out of this change so the irreducible core — surfacing what landed and the overlap — lands first.
choose openEsc close