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
});
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 {
return {
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
// onto the latest <remote>/<defaultBranch>. 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 `<remote>/<defaultBranch>`. 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<void> {
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.`,
);