fix(server): accept Git-valid worktree branch names (#3591)

Branch-off creation reused the filesystem slug validator even when callers supplied a separate branch name and worktree slug. Validate branch refs with Git at the worktree boundary so exact valid refs are preserved while path slugs remain restricted.
This commit is contained in:
Mohamed Boudra
2026-08-20 09:44:29 +02:00
committed by GitHub
parent 2e18620a28
commit 4dbd26c88c
4 changed files with 42 additions and 18 deletions
@@ -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();
+1 -3
View File
@@ -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") {
@@ -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 () => {
+4 -12
View File
@@ -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<void> {
async function validateGitBranchName(cwd: string, branchName: string): Promise<void> {
const result = await runGitCommand(["check-ref-format", "--branch", branchName], {
cwd,
timeout: 30_000,