diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d27cdd792..1dc9a6389 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -6,6 +6,7 @@ import { createWorktree as createWorktreePrimitive, deriveWorktreeProjectHash, deletePaseoWorktree, + InvalidGitBranchNameError, getScriptConfigs, getWorktreeSetupCommands, getWorktreeTerminalSpecs, @@ -270,6 +271,26 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" }); }); + it("checks out an existing local branch whose name contains uppercase letters and dots", async () => { + execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "release-worktree", + source: { kind: "checkout-branch", branchName: "release/1.1.15" }, + runSetup: true, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + 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 { @@ -340,6 +361,51 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ baseRefName: "main" }); }); + it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => { + const remoteDir = join(tempDir, "remote.git"); + const remoteCloneDir = join(tempDir, "remote-clone"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + + execFileSync("git", ["clone", remoteDir, remoteCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); + execFileSync("git", ["checkout", "-b", "Feature.X"], { cwd: remoteCloneDir }); + writeFileSync(join(remoteCloneDir, "file.txt"), "from-uppercase-pr\n"); + execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "uppercase pr branch"], { + cwd: remoteCloneDir, + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", "Feature.X"], { cwd: remoteCloneDir }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/43/head", prHead]); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "pr-43", + source: { + kind: "checkout-github-pr", + githubPrNumber: 43, + headRef: "Feature.X", + baseRefName: "main", + }, + runSetup: true, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe( + "from-uppercase-pr\n", + ); + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("Feature.X"); + }); + it("prefers origin/{branch} over local {branch} when both exist", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); @@ -425,6 +491,42 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).rejects.toThrow("Invalid branch name"); }); + it("throws a typed error when checking out an invalid existing branch name", async () => { + let caughtError: unknown; + try { + await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "invalid-existing-branch", + source: { kind: "checkout-branch", branchName: "bad..name" }, + runSetup: true, + paseoHome, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError); + expect((caughtError as InvalidGitBranchNameError).branchName).toBe("bad..name"); + }); + + it("throws a typed error when checking out a ref that is valid but not a branch name", async () => { + let caughtError: unknown; + try { + await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "invalid-option-like-branch", + source: { kind: "checkout-branch", branchName: "-bad" }, + runSetup: true, + paseoHome, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError); + expect((caughtError as InvalidGitBranchNameError).branchName).toBe("-bad"); + }); + it("handles branch name collision by adding suffix", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); // Create a branch named "hello" first diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 1e23676f2..8c3a62c2c 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -206,6 +206,16 @@ export class UnknownBranchError extends Error { } } +export class InvalidGitBranchNameError extends Error { + readonly branchName: string; + + constructor(branchName: string) { + super(`Invalid branch name: Git rejected ref name '${branchName}'`); + this.name = "InvalidGitBranchNameError"; + this.branchName = branchName; + } +} + export type ReadPaseoConfigResult = | { ok: true; config: PaseoConfig | null } | { ok: false; configPath: string; error: unknown }; @@ -1261,7 +1271,7 @@ async function resolveWorktreeSourcePlan({ }; } case "checkout-branch": { - validateWorktreeBranchName(source.branchName); + await validateExistingWorktreeBranchName(cwd, source.branchName); if (!(await localBranchExists(cwd, source.branchName))) { try { await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { @@ -1284,7 +1294,7 @@ async function resolveWorktreeSourcePlan({ } case "checkout-github-pr": { const localBranchCandidate = source.localBranchName ?? source.headRef; - validateWorktreeBranchName(localBranchCandidate); + await validateExistingWorktreeBranchName(cwd, localBranchCandidate); const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); await runGitCommand( @@ -1350,6 +1360,17 @@ function validateWorktreeBranchName(branchName: string): void { } } +async function validateExistingWorktreeBranchName(cwd: string, branchName: string): Promise { + const result = await runGitCommand(["check-ref-format", "--branch", branchName], { + cwd, + timeout: 30_000, + acceptExitCodes: [0, 1, 128], + }); + if (result.exitCode !== 0) { + throw new InvalidGitBranchNameError(branchName); + } +} + function normalizeRequiredBaseBranch(baseBranch: string): string { const normalizedBaseBranch = normalizeBaseRefName(baseBranch); if (!normalizedBaseBranch) {