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:
11
.changeset/merger-autostash-rootdir.md
Normal file
11
.changeset/merger-autostash-rootdir.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user