diff --git a/packages/engine/src/__tests__/branch-conflicts-recovery.test.ts b/packages/engine/src/__tests__/branch-conflicts-recovery.test.ts index 72618c2ac..4b16ad092 100644 --- a/packages/engine/src/__tests__/branch-conflicts-recovery.test.ts +++ b/packages/engine/src/__tests__/branch-conflicts-recovery.test.ts @@ -6,6 +6,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { autoRecoverCrossContamination, + reanchorBranchToBase, classifyBootstrapMisbinding, classifyForeignCommits, type BranchCrossContaminationCommit, @@ -183,6 +184,27 @@ describe("branch contamination recovery classification", () => { }); }); + it("reanchors branch to base and clears bootstrap foreign history", async () => { + const { repoDir, baseSha } = await setupRepo(); + const foreign = await makeCommit(repoDir, "foreign-reanchor", "feat(FN-4367): dependency change", "FN-4367"); + const before = await run("git rev-parse feature", repoDir); + + const result = await reanchorBranchToBase({ + repoDir, + worktreePath: repoDir, + branchName: "feature", + baseSha, + taskId: "FN-4488", + }); + + const after = await run("git rev-parse feature", repoDir); + const range = await run(`git rev-list --count ${baseSha}..feature`, repoDir); + expect(result.previousTipSha).toBe(before); + expect(result.newTipSha).toBe(after); + expect(result.previousTipSha).toBe(foreign.sha); + expect(range).toBe("0"); + }); + it("auto-recovers by dropping already-upstream foreign commits and preserving remaining branch work", async () => { const { repoDir, baseSha } = await setupRepo(); await writeFile(path.join(repoDir, "foreign.txt"), "", "utf-8"); @@ -212,3 +234,4 @@ describe("branch contamination recovery classification", () => { expect(result.newTipSha).not.toEqual(originalTip); }); }); + diff --git a/packages/engine/src/branch-conflicts.ts b/packages/engine/src/branch-conflicts.ts index 96dd51a64..233b13a41 100644 --- a/packages/engine/src/branch-conflicts.ts +++ b/packages/engine/src/branch-conflicts.ts @@ -507,6 +507,52 @@ export async function classifyForeignCommits( } } +export interface ReanchorBranchToBaseInput { + repoDir: string; + worktreePath: string; + branchName: string; + baseSha: string; + taskId: string; +} + +export interface ReanchorBranchToBaseResult { + previousTipSha: string; + newTipSha: string; +} + +export async function reanchorBranchToBase( + input: ReanchorBranchToBaseInput, +): Promise { + const { repoDir, worktreePath, branchName, baseSha, taskId } = input; + const previousTipSha = await revParse(repoDir, branchName); + + try { + await execAsync("git checkout -- .", { + cwd: worktreePath, + encoding: "utf-8", + timeout: GIT_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + }); + } catch { + // best-effort: worktree may already be clean + } + + await execAsync("git clean -fd", { + cwd: worktreePath, + encoding: "utf-8", + timeout: GIT_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + }); + await runGit(worktreePath, `git checkout --detach ${quoteShellArg(baseSha)}`); + await runGit(worktreePath, `git checkout -B ${quoteShellArg(branchName)} ${quoteShellArg(baseSha)}`); + await assertCleanBranchAtBase(repoDir, branchName, baseSha, taskId); + + return { + previousTipSha, + newTipSha: await revParse(repoDir, branchName), + }; +} + export interface AutoRecoverCrossContaminationInput { repoDir: string; branchName: string;