Always create a fresh worktree (#3224)

* fix(server): always create requested worktrees

A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees.

* fix(server): suffix occupied checkout branches

Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request.

* test(server): align occupied branch coverage

* test(server): remove stale worktree reuse expectations
This commit is contained in:
Mohamed Boudra
2026-08-11 17:54:59 +02:00
committed by GitHub
parent 308e9af845
commit b2ff359b60
7 changed files with 175 additions and 95 deletions
@@ -2,7 +2,6 @@
/* eslint-disable max-nested-callbacks */
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
BranchAlreadyCheckedOutError,
createWorktree as createWorktreePrimitive,
deriveWorktreeProjectHash,
deletePaseoWorktree,
@@ -299,22 +298,17 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(currentBranch).toBe("release/1.1.15");
});
it("throws a typed error when checking out a branch already checked out in the main repo", async () => {
let caughtError: unknown;
try {
await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "main" },
runSetup: true,
paseoHome,
});
} catch (error) {
caughtError = error;
}
it("creates a suffixed branch when checking out a branch already checked out in the main repo", async () => {
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "main" },
runSetup: true,
paseoHome,
});
expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError);
expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main");
expect(result.branchName).toBe("main-1");
expect(existsSync(result.worktreePath)).toBe(true);
});
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
+6 -40
View File
@@ -207,13 +207,6 @@ export interface CreateWorktreeOptions {
worktreesRoot?: string;
}
interface ResolveExistingWorktreeForSlugOptions {
slug: string;
repoRoot: string;
paseoHome?: string;
worktreesRoot?: string;
}
export class BranchAlreadyCheckedOutError extends Error {
readonly branchName: string;
@@ -1063,38 +1056,6 @@ export async function listPaseoWorktrees({
);
}
export async function resolveExistingWorktreeForSlug({
slug,
repoRoot,
paseoHome,
worktreesRoot,
}: ResolveExistingWorktreeForSlugOptions): Promise<WorktreeConfig | null> {
const worktrees = await listPaseoWorktrees({
cwd: repoRoot,
paseoHome,
worktreesRoot,
});
const slugSuffix = `${sep}${slug}`;
const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix));
if (!existingWorktree) {
return null;
}
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: existingWorktree.path,
envOverlay: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (!branchName) {
throw new Error(`Unable to resolve branch for existing worktree: ${existingWorktree.path}`);
}
return {
branchName,
worktreePath: existingWorktree.path,
};
}
export interface DeletePaseoWorktreeOptions {
cwd: string | null;
worktreePath?: string;
@@ -1375,7 +1336,12 @@ async function resolveWorktreeSourcePlan({
}
}
if (await isBranchCheckedOut(cwd, source.branchName)) {
throw new BranchAlreadyCheckedOutError(source.branchName);
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
return {
branchName,
metadataBaseRefName: source.branchName,
addArguments: ["-b", branchName, "--no-track", source.branchName],
};
}
return {