fix(engine): block dirty integration merge landings

Refuse AI merge landing on a checked-out dirty integration worktree by default. This prevents Fusion from stashing/restoring unrelated project-root edits into main and then marking tasks done against contaminated state.

Fusion-Task-Id: FN-5780
This commit is contained in:
Phil Larson
2026-05-31 12:11:54 -07:00
parent 93e8a5f78a
commit 3dee395d67
8 changed files with 97 additions and 15 deletions

View File

@@ -409,18 +409,34 @@ describe("landSquash (advance + local-checkout sync)", () => {
expect(git(dir, "rev-parse --abbrev-ref HEAD")).toBe("somewhere-else");
});
it("stashes dirty edits, fast-forwards, and restores them", async () => {
it("refuses to land onto a dirty checked-out integration branch by default", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { tipSha, squashSha } = makeDescendantSquash(dir, () => writeFileSync(join(dir, "landed.txt"), "landed\n"));
writeFileSync(join(dir, "mydraft.txt"), "local draft\n");
const audit = auditStub();
await expect(landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit })).rejects.toThrow(/dirty integration checkout/i);
expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({
type: "merge:ai-local-sync",
metadata: expect.objectContaining({ outcome: "blocked-dirty-checkout", reason: "dirty-integration-checkout" }),
}));
expect(git(dir, "rev-parse main")).toBe(tipSha);
expect(existsSync(join(dir, "landed.txt"))).toBe(false);
expect(readFileSync(join(dir, "mydraft.txt"), "utf-8")).toContain("local draft");
});
it("stashes dirty edits, fast-forwards, and restores them when explicitly allowed", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { tipSha, squashSha } = makeDescendantSquash(dir, () => writeFileSync(join(dir, "landed.txt"), "landed\n"));
writeFileSync(join(dir, "mydraft.txt"), "local draft\n"); // dirty, non-conflicting
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub() });
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub(), allowDirtyLocalCheckoutSync: true });
expect(res.localSync).toBe("stash-ff-restore");
expect(existsSync(join(dir, "landed.txt"))).toBe(true);
expect(readFileSync(join(dir, "mydraft.txt"), "utf-8")).toContain("local draft");
});
it("invokes the AI resolver when restoring the stash conflicts, then lands resolved", async () => {
it("invokes the AI resolver when restoring the stash conflicts, then lands resolved when explicitly allowed", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { tipSha, squashSha } = makeDescendantSquash(dir, () => writeFileSync(join(dir, "base.txt"), "base\nlanded-upstream\n"));
writeFileSync(join(dir, "base.txt"), "base\nmy-local-edit\n"); // dirty edit on the same line → restore conflict
@@ -430,7 +446,7 @@ describe("landSquash (advance + local-checkout sync)", () => {
execSync("git add -A", { cwd, stdio: "pipe" });
});
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub(), resolveConflicts: resolver });
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub(), resolveConflicts: resolver, allowDirtyLocalCheckoutSync: true });
expect(resolver).toHaveBeenCalled();
expect(res.localSync).toBe("stash-ff-airesolved");
expect(git(dir, "rev-parse main")).toBe(squashSha);

View File

@@ -21,9 +21,11 @@
* common path.
* 4. CAS fast-forward of `refs/heads/<integration>` to the squash (retry on a
* concurrent advance by rebuilding on the new tip).
* 5. Sync the user's local checkout to the new tip — fast-forward if clean,
* stash → ff → restore if dirty (best-effort, never destroys uncommitted
* work) — then finalize (delete branch, task → done, remove temp worktree).
* 5. Sync the user's local checkout to the new tip only when it is clean by
* default. Dirty checked-out integration worktrees fail closed before the
* branch ref advances, preventing unrelated local changes from poisoning
* subsequent merge runs. An explicit escape hatch can opt into the legacy
* stash → ff → restore path.
*
* Pure helpers (prompt builders, verdict parser) are exported for unit testing;
* the orchestrator accepts injectable agent functions for the same reason.
@@ -527,6 +529,7 @@ export type LocalSyncOutcome =
| "stash-ff-restore"
| "stash-ff-airesolved"
| "stash-ff-conflict"
| "blocked-dirty-checkout"
| "skipped-dirty-unstashable"
| "skipped-other-branch";
@@ -551,10 +554,10 @@ async function hasUnresolvedConflicts(cwd: string): Promise<boolean> {
* checkout — `git merge --ff-only <squash>` (it moves both the branch ref
* and the working tree). The user's real dirty state is read accurately
* BEFORE the fast-forward (while HEAD === tipSha, so `git status` isn't
* polluted by the ref move). Dirty edits are stashed, fast-forwarded, then
* restored — and if the restore conflicts, the AI merger reconciles them.
* If the checkout HEAD has already moved off tipSha, that's a concurrent
* advance → rebuild.
* polluted by the ref move). By default a dirty checked-out integration
* worktree is a hard blocker; callers must explicitly opt into stash/pop
* reconciliation. If the checkout HEAD has already moved off tipSha,
* that's a concurrent advance → rebuild.
*
* B. The checkout is on a different branch (or the target isn't checked out
* here). We advance the ref atomically via `update-ref` (CAS) and leave the
@@ -572,8 +575,15 @@ export async function landSquash(input: {
taskId: string;
audit: RunAuditor;
resolveConflicts?: (cwd: string, prompt: string) => Promise<void>;
/**
* Explicit escape hatch for callers that truly want Fusion to stash/pop real
* local edits in the checked-out integration worktree. The default is false:
* automation must not land a task while also manufacturing uncommitted local
* state in the project root, because that poisons subsequent merge runs.
*/
allowDirtyLocalCheckoutSync?: boolean;
}): Promise<LandResult> {
const { projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, resolveConflicts } = input;
const { projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, resolveConflicts, allowDirtyLocalCheckoutSync = false } = input;
const emit = (outcome: LocalSyncOutcome, extra: Record<string, unknown> = {}) =>
audit.git({ type: "merge:ai-local-sync", target: integrationBranch, metadata: { taskId, outcome, squashSha, ...extra } }).catch(() => undefined);
@@ -603,6 +613,13 @@ export async function landSquash(input: {
return { outcome: "concurrent", localSync: "skipped-other-branch" };
}
const dirty = (await git(["status", "--porcelain"], projectRootDir)).length > 0;
if (dirty && !allowDirtyLocalCheckoutSync) {
await emit("blocked-dirty-checkout", { reason: "dirty-integration-checkout" });
throw new Error(
`AI merge for ${taskId}: dirty integration checkout on ${integrationBranch}; refusing to land onto a dirty project root. `
+ `Commit, stash, or clean local changes before retrying.`,
);
}
const stashed = dirty
? await gitOk(["stash", "push", "--include-untracked", "-m", `fusion-ai-merge-sync-${taskId}`], projectRootDir)
: false;
@@ -804,6 +821,7 @@ export async function runAiMerge(
const landed = await landSquash({
projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit,
resolveConflicts: stashResolveAgent,
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
});
if (landed.outcome === "concurrent") {
if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) {

View File

@@ -5925,6 +5925,12 @@ export interface MergerOptions {
agentStore?: import("@fusion/core").AgentStore;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/**
* Escape hatch for trusted callers that want the AI merge path to stash/pop
* dirty edits in the checked-out integration worktree instead of failing
* closed. Defaults false because project-root dirt contaminates later merges.
*/
allowDirtyLocalCheckoutSync?: boolean;
}
function quoteArg(value: string): string {

View File

@@ -1848,9 +1848,14 @@ export class ProjectEngine {
// FN-5633: "ai" mode (default) uses the standalone AI merge path
// (clean-room worktree + AI merge + AI reviewer); "deterministic"
// keeps the legacy aiMergeTask pipeline.
const mergerMode = normalizeMergerMode((await store.getSettings().catch(() => ({}) as Settings)).merger?.mode);
const settings = await store.getSettings().catch(() => ({}) as Settings);
const mergerMode = normalizeMergerMode(settings.merger?.mode);
const mergeOptionsWithSettings = {
...mergerOptions,
allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true,
};
return mergerMode === "ai"
? runAiMerge(store, cwd, taskId, mergerOptions)
? runAiMerge(store, cwd, taskId, mergeOptionsWithSettings)
: aiMergeTask(store, cwd, taskId, mergerOptions);
};