fix(agent): detected patch-mode idempotence via reverse-check

git apply --3way --check exits 0 even when the real apply would write conflict markers and unmerged index stages, so the previous fix left the worktree dirty on conflicting patches while only flipping changesApplied to false.

Dropped --3way for patch-mode merge and used a --reverse --check probe instead: it succeeds only when the target state is already present (true no-op) and reads without touching the worktree. Conflicts fall through to the normal --check + apply path, which rejects them before writing anything.

Added regression coverage for the conflict scenario asserting the worktree stays clean, and for the fresh apply path.
This commit is contained in:
roboomp
2026-07-01 12:06:06 +00:00
parent 4c9f18972c
commit f474fa0e11
3 changed files with 62 additions and 11 deletions
@@ -291,14 +291,25 @@ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise
hadAnyChanges = false;
} else {
const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
changesApplied = await git.patch.canApplyText(repoRoot, normalized, { threeWay: true });
hadAnyChanges = false;
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, normalized, { threeWay: true });
hadAnyChanges = true;
} catch {
changesApplied = false;
// Idempotence: if the reverse patch applies cleanly the target state is
// already present. Reverse-check reads without touching the worktree, so
// a conflicting patch cannot be misdetected as a no-op — unlike
// `--3way --check`, which exits 0 even when the real apply would write
// conflict markers and unmerged index entries.
const alreadyApplied = await git.patch.canApplyText(repoRoot, normalized, { reverse: true });
if (alreadyApplied) {
changesApplied = true;
hadAnyChanges = false;
} else {
changesApplied = await git.patch.canApplyText(repoRoot, normalized);
hadAnyChanges = false;
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, normalized);
hadAnyChanges = true;
} catch {
changesApplied = false;
}
}
}
}