diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 0ae15e526..a63d2e4a8 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -96,6 +96,40 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", } }, 180000); +test("workspace.create accepts a Git-valid branch-off name outside Paseo slug syntax", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + branchName: "ABC-123_Fix-Broken-Model-Relationships_Author_Name", + worktreeSlug: "git-valid-branch-name", + baseBranch: "main", + }, + }); + + expect(result.error).toBeNull(); + expect(result.workspace?.gitRuntime?.currentBranch).toBe( + "ABC-123_Fix-Broken-Model-Relationships_Author_Name", + ); + expect(path.basename(result.workspace?.workspaceDirectory ?? "")).toBe("git-valid-branch-name"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); + test("workspace.create always creates a new worktree when the slug is already occupied", async () => { const daemon = await createTestPaseoDaemon(); const { repoDir, tempRoot } = createGitRepoWithBranch(); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 7189d75c2..f4b51e055 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -62,9 +62,7 @@ async function createWorktreeCoreWithPriority( const requestedWorktreeSlug = input.worktreeSlug ? normalizeWorktreeSlug(input.worktreeSlug) : undefined; - const requestedBranchName = input.branchName - ? validateWorktreeSlug(input.branchName.trim()) - : undefined; + const requestedBranchName = input.branchName?.trim(); let intentInput: ResolveWorktreeCreationIntentInput; if (input.action === "checkout") { diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 6efae9b50..5673c1412 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -655,15 +655,15 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).rejects.toThrow("Base branch not found: does-not-exist"); }); - it("fails with invalid branch name", async () => { + it("fails with a Git-invalid branch name", async () => { await expect( createLegacyWorktreeForTest({ - branchName: "INVALID_UPPERCASE", + branchName: "bad..name", cwd: repoDir, baseBranch: "main", worktreeSlug: "test", }), - ).rejects.toThrow("Invalid branch name"); + ).rejects.toThrow("Git rejected ref name 'bad..name'"); }); it("throws a typed error when checking out an invalid existing branch name", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 594ea0068..c1dd34821 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -42,7 +42,6 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; -import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; import { terminateWithTreeKill } from "./tree-kill.js"; @@ -1310,7 +1309,7 @@ async function resolveWorktreeSourcePlan({ switch (source.kind) { case "branch-off": { const branchName = source.branchName; - validateWorktreeBranchName(branchName); + await validateGitBranchName(cwd, branchName); const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch); const branchExists = await localBranchExists(cwd, branchName); @@ -1330,7 +1329,7 @@ async function resolveWorktreeSourcePlan({ }; } case "checkout-branch": { - await validateExistingWorktreeBranchName(cwd, source.branchName); + await validateGitBranchName(cwd, source.branchName); if (!(await localBranchExists(cwd, source.branchName))) { try { await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { @@ -1367,7 +1366,7 @@ async function resolveWorktreeSourcePlan({ case "checkout-change-request": case "checkout-github-pr": { const localBranchCandidate = source.localBranchName ?? source.headRef; - await validateExistingWorktreeBranchName(cwd, localBranchCandidate); + await validateGitBranchName(cwd, localBranchCandidate); const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); const changeRequestNumber = @@ -1589,14 +1588,7 @@ async function configureWorktreeTrackingRemote(options: { ); } -function validateWorktreeBranchName(branchName: string): void { - const validation = validateBranchSlug(branchName); - if (!validation.valid) { - throw new Error(`Invalid branch name: ${validation.error}`); - } -} - -async function validateExistingWorktreeBranchName(cwd: string, branchName: string): Promise { +async function validateGitBranchName(cwd: string, branchName: string): Promise { const result = await runGitCommand(["check-ref-format", "--branch", branchName], { cwd, timeout: 30_000,