Skip to content

Commit 4bf939e

Browse files
authored
Merge pull request #685 from bmad-code-org/pbean/merge-preflight-taxonomy
fix(engine): tell a merge pre-flight refusal from a conflict, and stop escalating over dirt the merge cannot commit (#618, #619, #623)
2 parents fcc381c + 9224e5c commit 4bf939e

10 files changed

Lines changed: 4297 additions & 126 deletions

CHANGELOG.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,33 @@ breaking changes may land in a minor release.
1616

1717
### Fixed
1818

19+
- **Merge-back failures are classified from the measured tree state, and the catch-all stopped
20+
inventing a conflict (#619).** A merge that died part-way through its checkout — all three
21+
strategies, `--ff-only` included — now raises `MergeHalfAppliedError` instead of "refused
22+
before starting": untracked residue is named for you to clear, and a tracked rewrite is
23+
restored automatically by `git checkout HEAD --` over exactly the affected paths.
24+
Attribution is per path — before/after deltas intersected with the branch's incoming set —
25+
so neither your pre-existing dirt nor an edit you make outside that incoming set while the
26+
merge is failing is ever called git's: the repo-wide dirtiness reading this replaces
27+
classified that concurrent-edit scene "failed part-way through checkout" and its repo-wide
28+
`reset --hard HEAD` destroyed the edit. (An edit racing the very paths the merge is
29+
rewriting is indistinguishable from git's write and is restored with them — the stated
30+
ceiling.) A post-merge probe that itself fails — the
31+
residue probes, the unmerged-stages reading, or the MERGE_HEAD reading — no longer bypasses the
32+
merge cleanup or impersonates a verdict: cleanup not gated on the dead reading still runs, the
33+
failure raises `MergeResidueUnreadError` (checkout state unverified, run `git status`), and an
34+
unread MERGE_HEAD skips the abort it gates and says so. The squash **replay** reading gets the
35+
same honesty on the far side of success: unreadable, it used to answer "dirty", and the doomed
36+
`git commit` that followed dressed the probe failure as a commit refusal with a
37+
`reset --hard HEAD` riding on it — now nothing is committed, nothing is reset, and the
38+
escalation names the dead reading. A refused
39+
**squash commit** now raises `MergeCommitRefusedError` like the `--no-ff` leg — the squash leg
40+
seals its result with its own `git commit`, where commit hooks and signing do run — rolled back
41+
by `git reset --hard HEAD` gated on the pre-merge reading having found the tree clean (a dirty
42+
checkout is never reset; the
43+
escalation then names the staged result and clearing it as your first step). A content conflict
44+
is typed too (`MergeConflictError`), so anything unclassified escalates saying just that — run
45+
`git status`, git's text names the cause — instead of "resolve the conflict by hand".
1946
- **psmux: a hand-back that succeeded no longer reports as failed — or undoes itself (#659).**
2047
`switch_client` read its verdict off the session's attached-client count, which a same-session
2148
move cannot change — and same-session is the common shape for the return path, so a correct
@@ -51,6 +78,59 @@ breaking changes may land in a minor release.
5178
its sibling rejected-latch shape, the spec folder raises `stories.StoriesError`, and `--dry-run`
5279
reports it before exiting 1. A spec folder that merely lies outside the project tree still comes
5380
back verbatim — that is a supported layout, and only the canonicalization leg refuses.
81+
- **An unstaged edit in your main checkout no longer escalates the story and pauses an unattended
82+
run (#618).** Under `[scm] isolation = "worktree"` the merge pre-flight refused over any dirty
83+
_tracked_ path outside the unit branch's incoming set, so a worktree-only porcelain `M` — modified in the
84+
working tree, nothing staged — stopped the run over a hazard git itself does not have. The axis
85+
is the index column, not trackedness: such a stray is now tolerated and journaled
86+
`merge-target-tolerated` alongside untracked dirt. A **staged** stray still escalates.
87+
- **Dirt on a path the run commits for itself now blocks the merge whatever its index column
88+
(#618).** The post-merge carries stage the sprint board and the deferred-work ledger by
89+
pathspec, which takes whatever the working tree holds no matter who wrote it, so narrowing the
90+
pre-flight to staged strays alone would have let an operator's private edit land in history
91+
under a `chore(sprint-status): carry ...` message with the tree left clean. Both paths are now
92+
passed to the pre-flight as protected, and a stray among them escalates with its own remedy —
93+
such dirt has to leave the path, not merely be unstaged. Tracked artifacts only.
94+
- **A resumed run no longer commits — or overwrites — board edits you made while it was down
95+
(#618).** When the merge was already journaled `unit-merged`, the replay falls through to the
96+
carry commits with no pre-flight in front of them. The carry cannot simply refuse on dirt: a
97+
crashed pass's own half-written advance is dirt on exactly that path and finishing it is what
98+
the leg exists for. It now asks whether the board holds HEAD's content plus this pass's
99+
advance — git's own question, so a board spelled CRLF by one host and LF by another still
100+
answers yes — and proves the index too, since the carry's `git add` overwrites it as well as
101+
the working tree; an ABSENT index entry counts as a staged untracking (`git rm --cached`) rather
102+
than as nothing to lose. That guards the commit, which is one write too late for the OWN row:
103+
`advance` would already have replaced the edit with the target, leaving precisely the bytes the
104+
proof accepts. So that row is now checked BEFORE the advance, and a status that is neither
105+
HEAD's nor this pass's own refuses it. Either refusal journals `board-advance-carry-foreign-dirt`.
106+
- **A merge git refused before it started no longer sends you to resolve a conflict that does not
107+
exist (#619).** Every `GitError` out of the merge was labelled "content conflict against the
108+
target", but most are git declining at pre-flight — an untracked file the merge would overwrite,
109+
a staged change on an incoming path, a file/directory shape clash, an `ff` target that cannot
110+
fast-forward — where nothing merged, the target checkout is untouched, and there are no markers
111+
to find. Those now raise `verify.MergePreflightError` (a `GitError` subclass, so every existing
112+
handler is unchanged) and escalate describing that state, with git's own text naming the cause.
113+
A third state needed its own type. A `--no-ff` that merges cleanly and is then refused at the
114+
COMMIT — a `pre-merge-commit` or `commit-msg` hook, or a `commit.gpgsign` that cannot sign —
115+
leaves no unmerged stages but does leave `MERGE_HEAD`, so reading the index alone called a
116+
started merge a pre-flight refusal and sent you to clear a clash that does not exist.
117+
`verify.MergeCommitRefusedError` now names it: the merge is aborted and the escalation points at
118+
the policy that declined rather than at a tree with nothing wrong. Where the abort ITSELF fails,
119+
it says so and sends you to recover the mid-merge checkout first — a resume attempted before
120+
that dies on the merge state however well the hook is fixed.
121+
- **A refused `squash` merge no longer destroys the uncommitted work in your main checkout
122+
(#619).** `--squash` has no `--abort`, so the recovery is `git reset --hard HEAD` — gated on a
123+
tree-state probe read _after_ the merge and used to answer "did the squash act". A checkout
124+
already carrying an unstaged edit reads dirty whether or not git touched a byte, so a merge git
125+
refused at pre-flight fired the reset and discarded work the merge never went near. The probe is
126+
now read once _before_ the squash and a tree found dirty is never reset. The same root cause
127+
corrupted the replay gate that recognises "the squash staged nothing", which now asks the index.
128+
- **The journal no longer records a path as tolerated when that path is what stopped the merge
129+
(#623).** `merge-target-tolerated` is written from inside the pre-flight guard, strictly before
130+
the merge runs, so it can only record what the guard decided. A stray outside the incoming set
131+
by _path_ can still clash with it by _shape_ — a file where the merge needs a directory, or the
132+
reverse — and git then refuses over the very path the event called harmless. The refusal path
133+
now appends a corrective `merge-preflight-refused` naming the same paths and carrying git's text.
54134

55135
## [0.11.0] — 2026-08-19
56136

0 commit comments

Comments
 (0)