diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 4624c198f..1c7d89ae8 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -448,7 +448,7 @@ function getCommitInstructions( Bash(git commit -m "\\n\\n${coAuthorLine}")` : "" } - - Push to the remote: Bash(git push origin ${branchName})`; + - Push to the remote: Bash(git push origin "${branchName}")`; } } } diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 86197da96..489242a12 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -23,18 +23,19 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined { } /** - * Validates a git branch name against a strict whitelist pattern. + * Validates a git branch name against a whitelist pattern. * This prevents command injection by ensuring only safe characters are used. * * Valid branch names: - * - Start with alphanumeric character (not dash, to prevent option injection) - * - Contain only alphanumeric, forward slash, hyphen, underscore, or period + * - Start with letter or number (not dash, to prevent option injection) + * - Contain only letters, numbers, forward slash, hyphen, underscore, period, @, or # + * - Support Unicode characters (e.g., Japanese branch names) * - Do not start or end with a period * - Do not end with a slash * - Do not contain '..' (path traversal) * - Do not contain '//' (consecutive slashes) * - Do not end with '.lock' - * - Do not contain '@{' + * - Do not contain '@{' (git reflog syntax) * - Do not contain control characters or special git characters (~^:?*[\]) */ export function validateBranchName(branchName: string): void { @@ -58,12 +59,15 @@ export function validateBranchName(branchName: string): void { ); } - // Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period - const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/; + // Whitelist pattern: Unicode letter/number start, then Unicode letter/number/slash/hyphen/underscore/period/@/# + // Allows @ and # for Azure DevOps integration (AB#123) and common naming patterns + // Allows Unicode for internationalization (e.g., Japanese branch names) + // Note: quotes are still blocked for safe shell interpolation + const validPattern = /^[\p{L}\p{N}][\p{L}\p{N}/_@#.-]*$/u; if (!validPattern.test(branchName)) { throw new Error( - `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`, + `Invalid branch name: "${branchName}". Branch names must start with a letter or number and contain only letters, numbers, forward slashes, hyphens, underscores, periods, @, or #.`, ); } diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 62ab6c1ca..8b572294c 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -243,5 +243,49 @@ describe("branch template utilities", () => { expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/); expect(result.length).toBeLessThanOrEqual(50); }); + + it.each([ + { + template: "{{prefix}}TICKET@{{entityNumber}}", + prefix: "feat/", + entityNumber: 123, + expected: "feat/TICKET@123", + description: "@ character", + }, + { + template: "{{prefix}}AB#{{entityNumber}}-fix", + prefix: "feature/", + entityNumber: 1992, + expected: "feature/AB#1992-fix", + description: "# character", + }, + { + template: "{{prefix}}機能-{{entityNumber}}", + prefix: "feat/", + entityNumber: 456, + expected: "feat/機能-456", + description: "Japanese characters", + }, + { + template: "{{prefix}}особенность-{{entityNumber}}", + prefix: "fix/", + entityNumber: 789, + expected: "fix/особенность-789", + description: "Russian characters", + }, + { + template: "{{prefix}}AB#{{entityNumber}}@新機能", + prefix: "特徴/", + entityNumber: 999, + expected: "特徴/AB#999@新機能", + description: "@, #, and Unicode combined", + }, + ])("should generate and validate branch name with $description", ({ template, prefix, entityNumber, expected }) => { + const result = generateBranchName(template, prefix, "issue", entityNumber); + expect(result).toBe(expected); + + const { validateBranchName } = require("../src/github/operations/branch"); + expect(() => validateBranchName(result)).not.toThrow(); + }); }); }); diff --git a/test/validate-branch-name.test.ts b/test/validate-branch-name.test.ts index 539932dd0..13d367605 100644 --- a/test/validate-branch-name.test.ts +++ b/test/validate-branch-name.test.ts @@ -36,6 +36,20 @@ describe("validateBranchName", () => { expect(() => validateBranchName("refs/heads/main")).not.toThrow(); expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow(); }); + + it.each([ + "TICKET-123@add-feature", + "user@branch", + "feat@test", + "feature/AB#1992-sentry-enhancements", + "AB#123-fix", + "issue#456", + "feat/add-機能追加", + "フィーチャー/新機能", + "особенность/новая", + ])("should accept special characters and Unicode: %s", (branchName) => { + expect(() => validateBranchName(branchName)).not.toThrow(); + }); }); describe("command injection attempts", () => {