diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 0056dd66..fecfacd2 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -28,6 +28,20 @@ function extractDescription( .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens } +/** + * Sanitizes a label into a git-safe branch segment. Labels are free-form and + * often scoped (e.g. "area:permissions"), so characters that are invalid in a + * branch name (":", "/", spaces, ...) are replaced with a hyphen rather than + * dropped, keeping the label readable. Returns "" if nothing usable remains. + */ +function sanitizeLabel(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen + .replace(/-+/g, "-") // Collapse multiple hyphens + .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens +} + export interface BranchTemplateVariables { prefix: string; entityType: string; @@ -78,7 +92,7 @@ export function generateBranchName( entityNumber, timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`, sha: sha?.substring(0, 8), // First 8 characters of SHA - label: label || entityType, // Fall back to entityType if no label + label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label description: title ? extractDescription(title) : undefined, }; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 62ab6c1c..418eaaea 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -5,6 +5,7 @@ import { applyBranchTemplate, generateBranchName, } from "../src/utils/branch-template"; +import { validateBranchName } from "../src/github/operations/branch"; describe("branch template utilities", () => { describe("applyBranchTemplate", () => { @@ -144,6 +145,53 @@ describe("branch template utilities", () => { expect(result).toBe("dev/enhancement-issue_789"); }); + it("should sanitize scoped labels that contain invalid git characters", () => { + const template = "{{prefix}}{{label}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + "area:permissions", + ); + + expect(result).toBe("claude/area-permissions/123"); + // Regression: an unsanitized ":" here previously failed validateBranchName + // and crashed the run via process.exit(1). + expect(() => validateBranchName(result)).not.toThrow(); + }); + + it("should replace spaces in labels with hyphens", () => { + const template = "{{prefix}}{{label}}-{{entityNumber}}"; + const result = generateBranchName( + template, + "fix/", + "issue", + 456, + undefined, + "needs review", + ); + + expect(result).toBe("fix/needs-review-456"); + expect(() => validateBranchName(result)).not.toThrow(); + }); + + it("should fall back to entityType when a label sanitizes to empty", () => { + const template = "{{prefix}}{{label}}-{{entityNumber}}"; + const result = generateBranchName( + template, + "fix/", + "pr", + 789, + undefined, + "🎉", + ); + + expect(result).toBe("fix/pr-789"); + expect(() => validateBranchName(result)).not.toThrow(); + }); + it("should use description in template when provided", () => { const template = "{{prefix}}{{description}}/{{entityNumber}}"; const result = generateBranchName(