diff --git a/.changeset/fn-8839-worktree-integration-rebase.md b/.changeset/fn-8839-worktree-integration-rebase.md new file mode 100644 index 0000000000..d5cdde064e --- /dev/null +++ b/.changeset/fn-8839-worktree-integration-rebase.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Rebase fresh worktrees onto the configured integration branch and log skipped refreshes. +category: fix +dev: Reuses the canonical integration-branch resolver and removes ambient root HEAD selection. diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index f9ebd21a17..08cba39faf 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -2920,6 +2920,173 @@ describe("Merger worktree pool integration", () => { // which tests aiMergeTask with real implementation }); +describe("fresh worktree integration rebase", () => { + beforeEach(() => { + resetExecutorMocks(); + }); + + function mockGitCommands(respond: (command: string) => Error | null) { + mockedExec.mockImplementation(((command: string, _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(respond(command), "", ""); + return {} as any; + }) as any); + } + + it("rebases onto configured integrationBranch instead of remote or ambient HEAD", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + worktreeRebaseBeforeMerge: true, + worktreeRebaseRemote: "origin", + integrationBranch: "develop", + }); + mockGitCommands(() => null); + const executor = createWorktreeExecutor(store, "/repo"); + + await (executor as any).rebaseNewWorktreeOntoRemote("/repo/.worktrees/fn-8839", "fusion/fn-8839", "FN-8839"); + + const commands = mockedExec.mock.calls.map(([command]) => String(command)); + expect(commands).toContain("git fetch 'origin' 'develop'"); + expect(commands).toContain("git rebase 'origin/develop'"); + expect(commands).not.toContain(expect.stringContaining("rev-parse --abbrev-ref HEAD")); + expect(commands).not.toContain(expect.stringContaining("origin/HEAD")); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-8839", + "Rebased new worktree branch fusion/fn-8839 onto origin/develop", + undefined, + undefined, + ); + }); + + it("uses the canonical resolver's origin HEAD and fixed fallback without ambient HEAD", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + worktreeRebaseBeforeMerge: true, + worktreeRebaseRemote: "origin", + }); + mockedExec.mockImplementation(((command: string, _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(null, command.includes("refs/remotes/origin/HEAD") ? "origin/release\n" : "", ""); + return {} as any; + }) as any); + const executor = createWorktreeExecutor(store, "/repo"); + + await (executor as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839"); + + expect(mockedExec.mock.calls.map(([command]) => String(command))).toEqual(expect.arrayContaining([ + "git symbolic-ref --short refs/remotes/origin/HEAD", + "git fetch 'origin' 'release'", + "git rebase 'origin/release'", + ])); + + resetExecutorMocks(); + const fallbackStore = createMockStore(); + fallbackStore.getSettings.mockResolvedValue({ + worktreeRebaseBeforeMerge: true, + worktreeRebaseRemote: "origin", + }); + mockedExec.mockImplementation(((command: string, _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(command.includes("refs/remotes/origin/HEAD") ? new Error("origin HEAD unset") : null, "", ""); + return {} as any; + }) as any); + + await (createWorktreeExecutor(fallbackStore, "/repo") as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839"); + + const fallbackCommands = mockedExec.mock.calls.map(([command]) => String(command)); + expect(fallbackCommands).toContain("git fetch 'origin' 'main'"); + expect(fallbackCommands).toContain("git rebase 'origin/main'"); + expect(fallbackCommands).not.toContain(expect.stringContaining("rev-parse --abbrev-ref HEAD")); + }); + + it("logs an enabled refresh skip when no remote is resolvable", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ worktreeRebaseBeforeMerge: true }); + mockGitCommands((command) => command === "git remote" ? null : new Error(`unexpected ${command}`)); + const executor = createWorktreeExecutor(store, "/repo"); + + await (executor as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839"); + + expect(mockedExec).toHaveBeenCalledWith("git remote", { cwd: "/repo" }, expect.any(Function)); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-8839", + "Skipped new worktree rebase refresh — no remote was resolvable", + undefined, + undefined, + ); + }); + + it("logs fetch failures without rebasing or failing worktree setup", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + worktreeRebaseBeforeMerge: true, + worktreeRebaseRemote: "origin", + integrationBranch: "develop", + }); + mockGitCommands((command) => command.includes("git fetch") ? new Error("network unavailable") : null); + const executor = createWorktreeExecutor(store, "/repo"); + + await expect((executor as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839")).resolves.toBeUndefined(); + + const commands = mockedExec.mock.calls.map(([command]) => String(command)); + expect(commands).toContain("git fetch 'origin' 'develop'"); + expect(commands).not.toContain("git rebase 'origin/develop'"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-8839", + "Could not refresh new worktree rebase target origin/develop — fetch failed; kept local base.", + undefined, + undefined, + ); + }); + + it("keeps a successful rebase successful when its task-log write fails", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + worktreeRebaseBeforeMerge: true, + worktreeRebaseRemote: "origin", + integrationBranch: "develop", + }); + store.logEntry.mockRejectedValue(new Error("task log unavailable")); + mockGitCommands(() => null); + const executor = createWorktreeExecutor(store, "/repo"); + + await expect((executor as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839")).resolves.toBeUndefined(); + await Promise.resolve(); + + const commands = mockedExec.mock.calls.map(([command]) => String(command)); + expect(commands).toContain("git rebase 'origin/develop'"); + expect(commands).not.toContain("git rebase --abort"); + }); + + it("aborts a conflicting rebase, retains the local-base log, and keeps disabled mode silent", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + worktreeRebaseBeforeMerge: true, + worktreeRebaseRemote: "origin", + integrationBranch: "develop", + }); + mockGitCommands((command) => command === "git rebase 'origin/develop'" || command === "git rebase --abort" + ? new Error("conflict") + : null); + const executor = createWorktreeExecutor(store, "/repo"); + + await (executor as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839"); + + expect(mockedExec.mock.calls.map(([command]) => String(command))).toContain("git rebase --abort"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-8839", + "Could not rebase new worktree onto origin/develop — kept local base. The merge-time rebase will retry with conflict resolution.", + undefined, + undefined, + ); + + resetExecutorMocks(); + const disabledStore = createMockStore(); + disabledStore.getSettings.mockResolvedValue({ worktreeRebaseBeforeMerge: false }); + const disabledExecutor = createWorktreeExecutor(disabledStore, "/repo"); + await (disabledExecutor as any).rebaseNewWorktreeOntoRemote("/worktree", "fusion/fn-8839", "FN-8839"); + expect(mockedExec).not.toHaveBeenCalled(); + expect(disabledStore.logEntry).not.toHaveBeenCalled(); + }); +}); + function createMockTaskDetail(overrides: Partial = {}): TaskDetail { return { id: "FN-001", diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 120b327416..6fd0b1f1c2 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -20754,13 +20754,17 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB ); }); } - // Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge - // is enabled, fetch the remote and rebase the just-created task branch - // onto the latest /. This makes the worktree - // start from origin/main + local main both, so divergence only matters - // if the user actively skips this setting. Best-effort: failures here - // don't abort task setup. - await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => { + /* + * FNXC:WorktreeRebase 2026-08-09-00:48: + * A fresh worktree must refresh against the same integration-branch-first + * contract that selected its start point. The root checkout may be on a + * sibling task branch, so it must never select this rebase target. + * Refresh remains best-effort, but enabled skips and failures are logged + * durably for operators rather than looking like the setting was disabled. + */ + // Fetch and rebase the just-created task branch only when the setting + // is enabled. Failures here never abort task setup. + await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId, settings).catch((err: unknown) => { executorLog.warn( `Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`, ); @@ -20987,25 +20991,28 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB /** * After creating a fresh task worktree, fetch the configured remote and - * rebase the task branch onto `/`. The result is a - * branch that contains origin's tip plus any local main commits, so the - * eventual merge has fewer surprises and the executor sees the freshest - * code its peers/CI may have published. + * rebase the task branch onto that remote's resolved integration branch. + * The branch resolver is shared with fresh-worktree acquisition, so an + * explicit `integrationBranch` wins over a remote default and root HEAD is + * never consulted. * - * No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is - * configured/resolvable, or the rebase produces conflicts (we abort and - * leave the worktree as-is so the executor can still run). + * No-op when `worktreeRebaseBeforeMerge` is disabled. Enabled skips, + * fetch failures, and conflicts are visible in the task log; setup remains + * best-effort and a conflict leaves the local base usable after abort. */ private async rebaseNewWorktreeOntoRemote( worktreePath: string, branch: string, taskId: string, + settingsOverride?: Settings, ): Promise { - let settings; - try { - settings = await this.store.getSettings(); - } catch { - return; + let settings = settingsOverride; + if (!settings) { + try { + settings = await this.store.getSettings(); + } catch { + return; + } } if (settings.worktreeRebaseBeforeMerge === false) return; @@ -21020,39 +21027,45 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB // No remote resolvable — nothing to rebase against. } } - if (!remote) return; + if (!remote) { + this.safeLogEntry( + taskId, + "Skipped new worktree rebase refresh — no remote was resolvable", + ); + return; + } - let defaultBranch = ""; + let integrationBranch: string; try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir }); - defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), ""); - } catch { - // origin/HEAD not set — fall back to current branch in rootDir. + integrationBranch = await resolveIntegrationBranch(this.rootDir, settings, { logger: executorLog }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + executorLog.warn(`Worktree rebase: could not resolve integration branch for ${taskId}: ${message}`); + this.safeLogEntry( + taskId, + `Skipped new worktree rebase refresh — integration branch could not be resolved for ${remote}`, + ); + return; } - if (!defaultBranch) { - try { - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir }); - defaultBranch = stdout.trim(); - } catch { - return; - } - } - if (!defaultBranch || defaultBranch === "HEAD") return; - const remoteRef = `${remote}/${defaultBranch}`; + const remoteRef = `${remote}/${integrationBranch}`; try { - await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir }); + await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(integrationBranch)}`, { cwd: this.rootDir }); } catch (err) { executorLog.warn( - `Worktree rebase: fetch ${remote} ${defaultBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + `Worktree rebase: fetch ${remote} ${integrationBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + this.safeLogEntry( + taskId, + `Could not refresh new worktree rebase target ${remoteRef} — fetch failed; kept local base.`, ); return; } try { await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath }); - await this.store.logEntry( + this.safeLogEntry( taskId, `Rebased new worktree branch ${branch} onto ${remoteRef}`, ); @@ -21066,7 +21079,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB } catch { // best-effort } - await this.store.logEntry( + this.safeLogEntry( taskId, `Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`, );