fix(iterate-pr): say when the rebase upstream was a guess - #317
Conversation
#301 described a silent loss: a rewritten parent fetched into a fresh checkout, tier 3 falling back to the parent, and the balloon guard blind to it because expectation and outcome share the upstream. Reproduced against real repositories, that is not what happens, and the issue (which I wrote) was wrong on both halves. `--fork-point` reads the parent branch's reflog, and A WORKTREE SHARES REFS AND REFLOGS WITH ITS CLONE. The pattern CLAUDE.md recommends for background agents, several agents in worktrees of one repository, keeps tier 2 working: one worktree amends the parent, another reads the pre-amend tip from the shared reflog and replays the child from exactly the right place. Measured end to end; the child kept only its own commit and the parent's fix survived. Reaching tier 3 with a rewritten parent needs a separate CLONE that never saw the old tip. Measured there, both outcomes are safe: - patch-compatible stale copies (a pure rebase, or an amend that only adds) are dropped by rebase, and the result is correct; - genuinely divergent ones CONFLICT, and the script aborts the rebase, leaves the child untouched, pushes nothing, and exits 2. So the tool does not lose work. What it did do was explain itself badly. The conflict landed in a file the child never touched, reported as "from parent", with nothing marking the upstream as inferred. The danger is the next step: a person or agent reconciling by hand, seeing an unfamiliar conflict, resolving toward the child's copy, and putting the parent's superseded work back. `forkUpstream` now reports WHERE the upstream came from (`recorded`, `fork-point`, `guessed`). A guess is announced in the plan, before the rebase, so a clean replay is still attributable, and again at a conflict, saying which side is the superseded one. It is announced only when the parent is not already an ancestor of the child, because when it is the fallback is right by construction and warning every time would train the reader to skip the line that matters. Failing closed was considered and rejected: refusing whenever tier 3 fires and the parent is not an ancestor would also refuse the case tier 3 exists for, a parent that was only appended to, where the fallback is correct. Both directions are mutation-checked: never warning fails the two announcement tests, always warning fails the ancestor-exemption test. Fixes #301
|
@claude /review |
|
Claude finished @thecodedrift's task in 3m 26s —— View job Review mode: incremental — read 0 prior review thread(s) before reviewing.
AssessmentThe change does what the PR body says: [New] Reuse — const guessIsRisky = (git, parent, child, source) =>
source === "guessed" &&
git("merge-base", "--is-ancestor", parent, child).code !== 0;
const isAncestor = (git, ancestor, descendant) =>
git("merge-base", "--is-ancestor", ancestor, descendant).code === 0;Both files already share [New] Documentation gap — the PR body promises a rationale the code doesn't contain. [New] Test-coverage gap — the "not risky" combination is only tested pre-rebase, not at conflict time. No blocking correctness issues found. The two non-blocking items above are cleanup/completeness suggestions, not bugs. |
`guessIsRisky` wrote out the `merge-base --is-ancestor` call that `stack_status.cjs` already had as `isAncestor`, in a pair of files that deliberately share `shared.cjs` for exactly this reason — `orderedDescendants` carries a comment saying so, having been consolidated for the same reason one review earlier. Both callers ask the same question: propagate_stack, whether a guessed upstream is worth warning about; stack_status, whether a branch is cleanly stacked on its parent. One implementation now, with a test pinning the argument order, which is the half of this that is easy to get backwards and silent when you do.
Fixed in
Two tests came with it. One pins the exit-code mapping, and one pins the argument order — Thanks also for checking that — AI Coding Agent |
#301 described a silent loss: a rewritten parent fetched into a fresh checkout, tier 3 falling back to the parent, and the balloon guard blind to it because the expectation and the outcome share the upstream. I wrote that issue. Reproduced against real repositories, it is wrong on both halves, and this PR fixes what actually reproduces.
What the reproduction showed
The recommended pattern already works.
--fork-pointreads the parent branch's reflog, and a worktree shares refs and reflogs with its clone. So several agents in worktrees of one repository — exactly what CLAUDE.md recommends — keep tier 2 functioning. One worktree amends the parent; another reads the pre-amend tip out of the shared reflog and replays the child from the right place:Reaching tier 3 with a rewritten parent needs a separate CLONE that never saw the old tip: a fresh CI checkout, a second machine, someone else's copy. Measured there, both outcomes are safe:
git rebase; result correct, parent's fix intactCONFLICT, rebase aborted, child untouched, nothing pushed, exit 2No silent loss, and no guard reading as satisfied.
What is actually wrong
The tool explained itself badly. The conflict arrived as:
parent.txtis a file the child never touched, and nothing marks the upstream as inferred.(from parent)is the only tell and reads as information rather than a warning.The danger is the next step, not the abort. Someone reconciling by hand sees an inexplicable conflict and resolves toward the child's copy — which is the superseded side, and taking it puts the parent's old work back. The tool stops safely; the person then undoes the fix.
The change
forkUpstreamnow reports where the upstream came from (recorded,fork-point,guessed). A guess is announced twice: in the plan, before the rebase, so a clean replay is still attributable, and again at a conflict, naming which side is superseded.It warns only when the parent is not already an ancestor of the child. When it is, the child contains the current parent,
parent..childis exactly the child's own commits, and the fallback is right by construction — warning every time would train the reader to skip the line that matters. The worktree case above stays silent, because it knows its fork point.Rejected: failing closed
Refusing whenever tier 3 fires and the parent is not an ancestor would also refuse the case tier 3 exists for — a parent that was only appended to, where
parent..childis the child's own commits and the replay is correct. That would break working behaviour to guard against a case that already stops safely.Rejected: recording pre-rewrite tips in a pushed ref
The obvious "make it actually work" fix, and it is worse than it looks. The record has to be written by whatever rewrites the branch, which is ordinary
git commit --amendandgit push --force, not this script — so it would need a hook installed per clone, and a fresh clone is precisely the one least likely to have hooks. The mechanism would be absent where it is needed.Worse, a stale record beats
--fork-pointin the tier order, so it is trusted. One surviving a later legitimate rebase makes the tool confidently replay the wrong range — manufacturing the silent, confident failure this issue wrongly attributed to the current code.Tests
Six new cases, mutation-checked in both directions:
The docblock is rewritten to describe what reproduces rather than what was assumed, including why failing closed and ref-recording were rejected, so neither gets re-proposed.
Fixes #301