FN-8839: rebase fresh worktrees onto integration branch

Refresh newly created worktrees against the configured integration branch without relying on ambient root HEAD.

- Resolve rebase targets through the canonical integration-branch resolver
- Log skipped refreshes, fetch failures, and successful or conflicted rebases without blocking setup
- Cover configured, remote-default, fallback, and failure rebase behavior

Files changed:
 .changeset/fn-8839-worktree-integration-rebase.md  |   7 +
 .../engine/src/__tests__/executor-worktree.test.ts | 167 +++++++++++++++++++++
 packages/engine/src/executor.ts                    |  91 ++++++-----
 3 files changed, 226 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-8839

Fusion-Task-Lineage: e104a261-39b2-4ab1-aaf5-075764162b4b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-08 18:08:23 -07:00
parent 03f84d406c
commit e2522ebbb0
3 changed files with 226 additions and 39 deletions

View File

@@ -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.

View File

@@ -2920,6 +2920,173 @@ describe("Merger worktree pool integration", () => {
// which tests aiMergeTask with real implementation // 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> = {}): TaskDetail { function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
return { return {
id: "FN-001", id: "FN-001",

View File

@@ -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 * FNXC:WorktreeRebase 2026-08-09-00:48:
// onto the latest <remote>/<defaultBranch>. This makes the worktree * A fresh worktree must refresh against the same integration-branch-first
// start from origin/main + local main both, so divergence only matters * contract that selected its start point. The root checkout may be on a
// if the user actively skips this setting. Best-effort: failures here * sibling task branch, so it must never select this rebase target.
// don't abort task setup. * Refresh remains best-effort, but enabled skips and failures are logged
await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => { * 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( executorLog.warn(
`Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`, `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 * After creating a fresh task worktree, fetch the configured remote and
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a * rebase the task branch onto that remote's resolved integration branch.
* branch that contains origin's tip plus any local main commits, so the * The branch resolver is shared with fresh-worktree acquisition, so an
* eventual merge has fewer surprises and the executor sees the freshest * explicit `integrationBranch` wins over a remote default and root HEAD is
* code its peers/CI may have published. * never consulted.
* *
* No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is * No-op when `worktreeRebaseBeforeMerge` is disabled. Enabled skips,
* configured/resolvable, or the rebase produces conflicts (we abort and * fetch failures, and conflicts are visible in the task log; setup remains
* leave the worktree as-is so the executor can still run). * best-effort and a conflict leaves the local base usable after abort.
*/ */
private async rebaseNewWorktreeOntoRemote( private async rebaseNewWorktreeOntoRemote(
worktreePath: string, worktreePath: string,
branch: string, branch: string,
taskId: string, taskId: string,
settingsOverride?: Settings,
): Promise<void> { ): Promise<void> {
let settings; let settings = settingsOverride;
try { if (!settings) {
settings = await this.store.getSettings(); try {
} catch { settings = await this.store.getSettings();
return; } catch {
return;
}
} }
if (settings.worktreeRebaseBeforeMerge === false) 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. // 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 { try {
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir }); integrationBranch = await resolveIntegrationBranch(this.rootDir, settings, { logger: executorLog });
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), ""); } catch (err) {
} catch { const message = err instanceof Error ? err.message : String(err);
// origin/HEAD not set — fall back to current branch in rootDir. 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 { 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) { } catch (err) {
executorLog.warn( 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; return;
} }
try { try {
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath }); await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
await this.store.logEntry( this.safeLogEntry(
taskId, taskId,
`Rebased new worktree branch ${branch} onto ${remoteRef}`, `Rebased new worktree branch ${branch} onto ${remoteRef}`,
); );
@@ -21066,7 +21079,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
} catch { } catch {
// best-effort // best-effort
} }
await this.store.logEntry( this.safeLogEntry(
taskId, taskId,
`Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`, `Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`,
); );