fix(FN-3305): preserve task branch on worktree conflict retry

This commit is contained in:
gsxdsm
2026-05-03 17:24:19 -07:00
parent 36ad3fa83a
commit 6d4408c179
4 changed files with 63 additions and 8 deletions

View File

@@ -1333,7 +1333,7 @@ describe("TaskExecutor worktree recovery", () => {
mockedGenerateWorktreeName.mockReturnValueOnce("jade-finch");
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
await executor.execute({ ...makeTask(), baseBranch: "fusion/fn-049" });
// Should log that we're trying a new path
expect(store.logEntry).toHaveBeenCalledWith(
@@ -1343,6 +1343,24 @@ describe("TaskExecutor worktree recovery", () => {
);
// Should generate a new name
expect(mockedGenerateWorktreeName).toHaveBeenCalledTimes(2);
const worktreeAddCalls = mockedExecSync.mock.calls
.map((call) => String(call[0]))
.filter((command) => command.includes("git worktree add -b"));
expect(
worktreeAddCalls.some(
(command) =>
command.includes('git worktree add -b "fusion/fn-050"') &&
command.endsWith('"fusion/fn-049"'),
),
).toBe(true);
expect(
worktreeAddCalls.some(
(command) =>
command.includes('git worktree add -b "fusion/fn-050-2"') &&
command.endsWith('"fusion/fn-050"'),
),
).toBe(true);
});
it("removes stale branch and retries when branch exists without worktree", async () => {

View File

@@ -286,7 +286,32 @@ describe("WorktreePool", () => {
const checkoutCalls = mockedExecSync.mock.calls
.map((c) => c[0])
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-2" main');
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-2" fusion/fn-042');
});
it("seeds suffixed retry branches from the original branch instead of the generic base", async () => {
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git checkout -B "fusion/fn-042" fusion/fn-041') {
const err: any = new Error("branch conflict");
err.stderr = Buffer.from(
"fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'"
);
throw err;
}
return Buffer.from("");
});
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041");
expect(result).toBe("fusion/fn-042-2");
const checkoutCalls = mockedExecSync.mock.calls
.map((c) => c[0])
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-2" fusion/fn-042');
expect(checkoutCalls).not.toContain('git checkout -B "fusion/fn-042-2" fusion/fn-041');
});
it("increments suffix when lower suffixes are also in use", async () => {
@@ -295,8 +320,8 @@ describe("WorktreePool", () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
// Original and -2 are both in use
if (cmdStr === 'git checkout -B "fusion/fn-042" main' ||
cmdStr === 'git checkout -B "fusion/fn-042-2" main') {
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') ||
cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
const err: any = new Error("branch conflict");
err.stderr = Buffer.from(
`fatal: 'x' is already used by worktree at '/other/wt'`
@@ -308,6 +333,11 @@ describe("WorktreePool", () => {
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
expect(result).toBe("fusion/fn-042-3");
const checkoutCalls = mockedExecSync.mock.calls
.map((c) => c[0])
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-3" fusion/fn-042');
});
it("falls back to git worktree prune when conflicting worktree no longer exists on disk", async () => {

View File

@@ -5564,7 +5564,11 @@ and show an appropriate message to the user.\`
if (shouldGenerateNewName) {
// Conflicting worktree belongs to an active task — generate new path AND
// use a suffixed branch name so git doesn't conflict with the branch
// already checked out in the existing worktree.
// already checked out in the existing worktree. Branch conflicts here
// mean the original task branch already exists and is checked out
// elsewhere, so suffix retries must branch from that task branch tip
// rather than the stale base ref to preserve the task's commits.
const conflictStartPoint = branch;
const newPath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedBranch = `${branch}-${suffix}`;
@@ -5574,7 +5578,7 @@ and show an appropriate message to the user.\`
`Conflicting worktree in use by active task, trying new path with branch ${suffixedBranch}`,
newPath,
);
return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, startPoint, attemptNumber);
return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber);
} catch (suffixErr: unknown) {
const info = this.extractWorktreeConflictInfo(suffixErr);
if (info.type === "already-used") {

View File

@@ -234,10 +234,13 @@ export class WorktreePool {
}
// Conflicting worktree exists and is active — use a suffixed branch name
// to avoid disrupting the other worktree
// to avoid disrupting the other worktree. Seed the suffix from the
// original task branch tip rather than the generic base ref so retries
// preserve the task's commits instead of resetting to main/baseBranch.
const conflictBase = branchName;
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedName = `${branchName}-${suffix}`;
const suffixedCmd = `git checkout -B "${suffixedName}" ${base}`;
const suffixedCmd = `git checkout -B "${suffixedName}" ${conflictBase}`;
try {
await execAsync(suffixedCmd, { cwd: worktreePath });
return suffixedName;