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:
7
.changeset/fn-8839-worktree-integration-rebase.md
Normal file
7
.changeset/fn-8839-worktree-integration-rebase.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user