fix(FN-3305): preserve task branch on worktree conflict retry
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user