fix(engine/merger): autostash unrelated rootDir changes around aiMergeTask

The merger issues several `git reset --hard` / `git reset --merge` and
forced-checkout calls against `rootDir` during merge attempts. When
`rootDir` is the developer's primary checkout (common for solo / single-host
setups), those resets silently discard any unrelated unstaged or untracked
changes — we burned dev work this way during FN-3329 (dashboard-tui edits
wiped mid-flight by an unrelated merge run).

Snapshot dirty paths at entry to `aiMergeTask`, stash them under a
recognizable label including the taskId (`-u` to capture untracked), and
pop them in a finally block on every exit path. On pop conflict we leave
the stash intact and log a recovery hint rather than dropping it. Best-
effort: a stash failure logs and proceeds with the old behavior so the
merge itself is never blocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-04 17:21:58 -07:00
parent 1187ea45d0
commit 2b809fc99e
2 changed files with 123 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
---
"@runfusion/fusion": patch
---
Stop the merger from wiping concurrent dev edits in `rootDir`.
`aiMergeTask` issues several `git reset --hard` / `git reset --merge` / forced-checkout calls against `rootDir` during merge attempts. When `rootDir` is the developer's primary checkout (the common case for solo / single-host setups), those resets silently discard any unrelated unstaged or untracked changes in the working tree. We've burned developer work this way (FN-3329 retro: dashboard-tui edits were wiped mid-flight by an unrelated merge run).
`aiMergeTask` now snapshots dirty paths at entry and, if any are present, stashes them under a labeled autostash (`fusion-merger-autostash:<taskId>:<ts>`, includes untracked files via `git stash push -u`). A try/finally around the merge body restores the stash on every exit path — success, error, or abort. If the pop conflicts (e.g. the merge committed an overlapping change), the stash is left intact and the operator gets a recovery hint in the merger log; we never silently `git stash drop`.
Best-effort throughout: a stash failure logs and proceeds with the old behavior rather than blocking the merge — strictly worse regressions are off the table.

View File

@@ -839,6 +839,103 @@ function resetMergeWithWarn(rootDir: string, taskId: string, label: string): voi
} }
} }
/**
* Stash any unrelated dirty changes in `rootDir` before a merge runs.
*
* The merger frequently issues `git reset --hard` / `git reset --merge` /
* forced checkouts against `rootDir`. When `rootDir` happens to be the
* developer's primary checkout (the common case for solo / single-host
* setups), those resets discard any uncommitted dev edits in the working
* tree — silently and without recourse. We've burned developer work this
* way (FN-3329 retro): dashboard-tui edits were wiped mid-flight by an
* unrelated FN-3329 merge.
*
* The fix: snapshot dirty state up-front, stash it (including untracked
* files) under a recognizable label, and pop it back after the merge
* finishes — success OR failure — via a try/finally in `aiMergeTask`.
*
* Returns the stash ref (e.g. `stash@{0}`) when a stash was created, or
* `null` when the working tree was already clean. Best-effort: any failure
* to stash logs and returns null — the merge still proceeds, but with the
* old behavior. We do NOT want a stash failure to block the merge entirely
* (that would be a strictly worse regression than the current state).
*/
async function stashUnrelatedRootDirChanges(
rootDir: string,
taskId: string,
): Promise<string | null> {
try {
// Cheap dirty-check first so we don't litter empty stashes during the
// common all-clean case.
const dirty = await snapshotDirtyFiles(rootDir);
if (dirty.size === 0) return null;
const label = `fusion-merger-autostash:${taskId}:${Date.now()}`;
// -u → include untracked. -m → label so we can locate it later even if
// another stash arrives (unlikely but possible under concurrent tooling).
await execAsync(
`git stash push -u -m "${label}"`,
{ cwd: rootDir },
);
// Resolve the actual ref. `git stash push` doesn't print one in a
// machine-friendly way, so we look up the most recent stash that
// matches our label. This guards against another tool sneaking in a
// stash between push and resolve.
const { stdout } = await execAsync(
`git stash list --format="%gd %s"`,
{ cwd: rootDir, encoding: "utf-8" },
);
const lines = String(stdout).split("\n");
const match = lines.find((line) => line.includes(label));
if (!match) {
mergerLog.warn(
`${taskId}: created autostash but could not locate it in stash list — leaving in place to avoid data loss`,
);
return null;
}
const ref = match.split(/\s+/)[0] ?? null;
mergerLog.log(
`${taskId}: stashed ${dirty.size} unrelated dirty path(s) in rootDir as ${ref} (${label})`,
);
return ref;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(
`${taskId}: pre-merge autostash failed (${msg}) — proceeding without stash; concurrent dev edits in rootDir may be wiped`,
);
return null;
}
}
/**
* Restore the autostash created by `stashUnrelatedRootDirChanges` after a
* merge completes. Best-effort: any failure logs a warning but does not
* throw — by the time we reach the finally block the merge result has
* already been recorded, and a stash-pop failure should never mask or
* undo a successful merge.
*
* On pop conflict (e.g. the merge committed a change that overlaps the
* stashed dev edit) we leave the stash in place and instruct the operator
* to recover manually. That's vastly preferable to silently dropping the
* stash via `git stash drop`.
*/
async function restoreUnrelatedRootDirChanges(
rootDir: string,
taskId: string,
stashRef: string,
): Promise<void> {
try {
await execAsync(`git stash pop "${stashRef}"`, { cwd: rootDir });
mergerLog.log(`${taskId}: restored autostash ${stashRef}`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(
`${taskId}: failed to pop autostash ${stashRef} (${msg}) — stash left intact; recover with: cd ${rootDir} && git stash list && git stash pop ${stashRef}`,
);
}
}
async function generateAiMergeSummary( async function generateAiMergeSummary(
commitLog: string, commitLog: string,
diffStat: string, diffStat: string,
@@ -2485,6 +2582,15 @@ export async function aiMergeTask(
): Promise<MergeResult> { ): Promise<MergeResult> {
throwIfAborted(options.signal, taskId); throwIfAborted(options.signal, taskId);
// Pre-merge guard against the common single-checkout setup where rootDir
// is the developer's working tree. The merge flow below issues several
// `git reset --hard/--merge` calls and forced checkouts that would
// otherwise wipe any unrelated unstaged/untracked dev edits. Stash them
// here, restore in the finally below — see stashUnrelatedRootDirChanges
// for the full rationale.
const autostashRef = await stashUnrelatedRootDirChanges(rootDir, taskId);
try {
// 1. Validate task state // 1. Validate task state
const task = await store.getTask(taskId); const task = await store.getTask(taskId);
const mergeBlocker = getTaskMergeBlocker(task); const mergeBlocker = getTaskMergeBlocker(task);
@@ -3867,6 +3973,12 @@ export async function aiMergeTask(
}); });
await completeTask(store, taskId, result); await completeTask(store, taskId, result);
return result; return result;
} finally {
if (autostashRef) {
await restoreUnrelatedRootDirChanges(rootDir, taskId, autostashRef);
}
}
} }
/** Best-effort `git fetch origin <currentBranch>` + fast-forward of local /** Best-effort `git fetch origin <currentBranch>` + fast-forward of local