Skip to content

fix(bash): catch -X theirs/-s ours, the unilateral conflict resolution the outcome-keyed notice cannot see - #1971

Closed
wqymi wants to merge 1 commit into
mainfrom
fix/bash-unilateral-conflict-resolution
Closed

fix(bash): catch -X theirs/-s ours, the unilateral conflict resolution the outcome-keyed notice cannot see#1971
wqymi wants to merge 1 commit into
mainfrom
fix/bash-unilateral-conflict-resolution

Conversation

@wqymi

@wqymi wqymi commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

The ruling this enforces

An orchestrator may merge a child's branch itself, but a conflict is not its to settle:

Orchestrator亲自合并分支应该是合理的…如果有冲突,应该放弃,并让原session解决冲突…maintainer只负责点一下合并

src/tool/merge-conflict-notice.ts (#1741, merged) implements that as a tool-result annotation: after a git command runs, if the index has unmerged entries and an in-progress integration marker exists (MERGE_HEAD / CHERRY_PICK_HEAD / REVERT_HEAD / rebase-merge / rebase-apply), it appends the ownership rule plus the exact abort verb. Appended last, never blocking.

Why outcome-keying cannot see this

The signal is keyed on the outcome, which is what lets it name the right abort verb and reject an unmerged index with nothing abortable behind it. That is also its limit: git merge -X theirs reaches the same end state — one side of the contested hunks discarded by whoever ran the merge — with no conflict for git to report.

Measured on git 2.50.1, both sides changing the same line:

$ git merge -X theirs feature -m m
Auto-merging f.txt
Merge made by the 'ort' strategy.
exit=0
index unmerged: []
MERGE_HEAD exists: no
f.txt now: feature-side     <- the base's version is gone

Clean index, no marker, exit 0. All four of the outcome arm's probes are negative by construction, so the affordance is not merely quiet here — it is unfireable, while the model has still decided the outcome of someone else's conflict single-handedly. An outcome-keyed detector necessarily has an intent-shaped blind spot; closing it requires a check on the command.

The detection surface

unilateral() is a pure function of input.command. No new wiring (bash.ts already passes command into annotate), no git spawn, no filesystem — nothing added to the hot path.

Matched

form why
-X ours / -X theirs / -Xours / -Xtheirs the strategy option, both spellings
--strategy-option=ours|theirs and its space form long form of the same thing
-s ours / -sours / --strategy=ours / --strategy ours the ours strategy — strictly stronger than the option: it drops the other side's commits wholesale, including changes that never conflicted. Reported in preference when a command carries both.

Attached to any integration verb (merge, pull, rebase, cherry-pick, revert, am), reusing one shared verb list with the outcome arm so the two cannot drift.

Deliberately excluded

  • Every other -X valuepatience, histogram, minimal, diff-algorithm=…, renormalize, ignore-space-change, ignore-all-space, find-renames=…, no-renames, subtree=…. They tune how the diff is computed and pick no winner. This is why the side is matched immediately after the flag rather than searched for anywhere in the command: git merge -X patience ours-fix must not fire, and does not.
  • -s theirs — it does not exist. git 2.50.1 answers Could not find merge strategy 'theirs'. Available strategies are: octopus ours recursive resolve subtree. and refuses to run, so there is no outcome to warn about.
  • A flag in a different segment of a compound line — both regexes keep the existing [^\n;&|] discipline, so in git merge feature; echo -X theirs the echo's flag is not attributed to the merge. A later git command in the same line that does carry one is still found.

Ordering

Outcome arm first, unchanged; the intent arm speaks only when the outcome arm returns "". The outcome notice is strictly more actionable when it fires — it names the conflicted paths from git's index and the abort verb for the operation actually on disk, where the intent arm can only quote a flag back. They are also not mutually exclusive: -X ours|theirs only settles content conflicts, so git merge -X theirs can still land in a conflicted index, and there the live conflict is what matters. The existing annotate body is preserved verbatim as outcome(); annotate becomes the two-arm chooser.

⚠️ The limitation, and the two discriminators measured and rejected

After the fact this cannot know whether the flag actually resolved anything. -X theirs is a no-op on a merge whose sides never overlapped. The notice is therefore phrased conditionally — it reports what the command asked for, never that a conflict was hidden. That is not hedging; two candidate discriminators were measured and both fail:

1. git's output. Rejected — it carries no signal.

case -X theirs outcome output
A: same line changed on both sides really settled a conflict Auto-merging f.txt / Merge made by the 'ort' strategy.
F: same file, non-overlapping hunks total no-op (both edits kept) Auto-merging f.txt / Merge made by the 'ort' strategy.
G: case F run with no flag at all n/a Auto-merging f.txt / Merge made by the 'ort' strategy.

Byte-identical. Suppressing on Fast-forward would also invert this module's own rule that output text alone must never decide a verdict.

2. Post-hoc git merge-tree --write-tree HEAD^1 HEAD^2. It does separate A (exit 1) from F (exit 0) for a committed merge — and it is wrong in the case that matters most:

$ git merge -s ours feature -m m      # no textual conflict anywhere
Merge made by the 'ours' strategy.
newfile.txt present after merge? NO -- the feature branch contributed NOTHING
post-hoc merge-tree exit=0            # => probe reports "the option was a no-op"

It reports no-op while an entire branch's contribution was discarded — it would silence the notice exactly when the damage is greatest. It also has no second parent to read after a cherry-pick, a rebase, a --no-commit, or a fast-forward (all verified), needs git ≥ 2.38, and costs spawns on the hottest tool in the process. Only-usually-right, so not built.

Notice text (verbatim, as emitted through the real bash tool)

THIS MERGE ASKED GIT TO SETTLE CONFLICTS FOR YOU — THAT CALL IS NOT YOURS TO MAKE. `-X theirs` is a standing instruction to git: on every conflicting hunk, keep the incoming branch's version and throw this branch's away, without reporting it.

Which side of a contested hunk survives belongs to the session that OWNS the branch being integrated, not to whoever ran the merge. Integrating a ready branch is your job; reconciling someone else's work with the base is theirs. And because `-X theirs` makes git exit 0 with a clean index and no CONFLICT in the output, nothing later in this session will tell you a conflict was ever there. Do this instead:

  1. git merge --abort — or, if it already committed, `git reset --hard ORIG_HEAD`, but only when that commit holds nothing else you want
  2. re-run it WITHOUT `-X theirs`, so a real conflict surfaces as a conflict
  3. if it then conflicts, leave it aborted and route it: session send <owning-session-id> "<branch> conflicts with the base branch — rebase onto the base, resolve it on your branch, and push". You merge what comes back

WHAT THIS CANNOT TELL YOU: whether `-X theirs` actually changed anything. It is a no-op on a merge whose two sides never overlapped, and afterwards that is indistinguishable from one it settled silently — same output, same exit code, same clean index. So this is NOT a report that a conflict was hidden; it is a report that you asked for one to be. Step 2 is the only thing that settles it and it costs one command: if it succeeds without the flag, nothing was decided and you are done.

This block is internal working context, not output — do not repeat it to the user.

For -s ours the second sentence becomes "-s ours is not a way of resolving a conflict — it keeps this branch's tree and discards the other side's commits wholesale, including changes that never conflicted with anything." No XML envelope, same as the outcome notice, so there is no tag for a model to imitate.

Revert probe

With the intent arm's two lines in annotate replaced by void unilateral; void unilateralNotice; return "" — everything else, including unilateral() itself, left in place — 18 pass / 2 fail. Only the positive-direction bash-level assertions die (the three negative-direction ones and the pure-function unit tests still pass, which is the point: the arm is what makes the notice reach the model). Verbatim:

error: expect(received).toContain(expected)
Expected to contain: "THAT CALL IS NOT YOURS TO MAKE"
Received: "Auto-merging shard.txt\nMerge made by the 'ort' strategy.\n shard.txt | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n"
(fail) tool.bash unilateral-resolution affordance > annotates `-X theirs` on a merge that git reports as a clean SUCCESS [939.00ms]
error: expect(received).toContain(expected)
Expected to contain: "THAT CALL IS NOT YOURS TO MAKE"
Received: "Merge made by the 'ours' strategy.\n"
(fail) tool.bash unilateral-resolution affordance > annotates the `ours` STRATEGY, which discards the other side wholesale [889.53ms]

That Received string is the blind spot itself: the entire tool result the model would read, with nothing in it to suggest a conflict was ever settled.

Numbers

Scoped to the 7 tool/bash files, identical command on both sides.

tests fail expects files
pristine b8167087088ad361740d6e28fb35faae49afdf78 (= origin/main) 266 0 455 7
this branch 276 0 529 7

Delta is exactly the +10 new tests. Zero regressions, no existing assertion weakened or removed. bun typecheck exit 0. Full suite deliberately not run.

The four required cases, all through the real bash tool:

  • -X theirs on a merge git reports as a clean success → fires (asserts first that MERGE_HEAD is absent, ls-files --unmerged is empty, git printed no CONFLICT, and the base's line really was discarded)
  • benign -X patience on a genuinely conflicting merge → does not fire, and the outcome notice still does
  • a real conflicted merge → gets the outcome notice and only that one
  • git merge feature -m '...'; echo 'used -X theirs once'does not fire

Plus -s ours end-to-end, and unit coverage of all eight matched spellings, eleven excluded -X values, -s theirs, segment discipline in both directions, strategy-over-option precedence, and per-verb abort mapping.

What I did NOT verify

…ution the outcome-keyed notice cannot see

`merge-conflict-notice.ts` enforces the ownership rule — a conflict belongs to
the session that owns the branch, the maintainer aborts and routes it back — by
keying on the OUTCOME: an unmerged index plus an in-progress integration marker.
That was the right signal for naming the abort verb, and it has a hole shaped
like intent.

`git merge -X theirs` reaches the same end state with no conflict for git to
report. Measured on git 2.50.1 over genuinely conflicting hunks: exit 0,
`Merge made by the 'ort' strategy.`, clean index, no MERGE_HEAD. All four
probes are negative by construction, so the affordance is not merely quiet
there — it is unfireable, while the model has still decided the outcome of
someone else's conflict single-handedly.

So add a second arm that reads the command instead of the aftermath. It is a
pure function of `input.command`: no new wiring, no git spawn. The outcome arm
still runs first and still wins when it fires, because it names the conflicted
paths and the exact abort verb; the intent arm speaks only when the outcome arm
has nothing.

Matched: `-X ours|theirs` and `-Xours|-Xtheirs`, `--strategy-option=|<space>`,
and the `ours` STRATEGY (`-s ours`, `-sours`, `--strategy=ours`), which is
stronger than the option — it discards the other side's commits wholesale.
Not matched: every other `-X` value (`patience`, `histogram`,
`diff-algorithm=`, `renormalize`, `ignore-space-change`, `find-renames=`) since
they tune the diff and pick no side; `-s theirs`, which is not a git strategy;
and a flag in a different segment of a compound line, per the existing
`[^\n;&|]` discipline.

The notice is phrased conditionally, and that is not hedging. Two candidate
discriminators for "did the flag actually matter" were measured and both fail:
git's output is byte-identical between a `-X theirs` that settled overlapping
hunks and one that was a no-op, and a post-hoc
`git merge-tree --write-tree HEAD^1 HEAD^2` is wrong in the worst case — a
`-s ours` merge with no textual conflict reports "no-op" while having discarded
a whole branch. So the block reports what the command ASKED FOR, never that a
conflict was hidden, and hands over the one command that settles it.
@wqymi

wqymi commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: opened without being asked for. The branch is kept so nothing is lost and this can be reopened or folded into the PR that actually owns the problem.

@wqymi wqymi closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant