diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index fecfacd2..f9fac933 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -72,6 +72,21 @@ export function applyBranchTemplate( return result; } +/** + * Collapses empty path segments produced when a template variable resolves to + * an empty string. For example, an issue title with no alphanumeric characters + * (emoji-only, CJK-only, or punctuation-only) makes `{{description}}` empty, so + * a template like `{{prefix}}{{description}}/{{entityNumber}}` yields + * `claude//123`. Consecutive slashes — and a leading or trailing slash — are + * rejected by `validateBranchName`, which aborts the whole run, so normalize + * them into a valid branch name instead of crashing. + */ +function collapseEmptyPathSegments(branchName: string): string { + return branchName + .replace(/\/{2,}/g, "/") // collapse runs of slashes left by empty segments + .replace(/^\/+|\/+$/g, ""); // drop leading/trailing slashes +} + /** * Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result. */ @@ -97,7 +112,9 @@ export function generateBranchName( }; if (template?.trim()) { - const branchName = applyBranchTemplate(template, variables); + const branchName = collapseEmptyPathSegments( + applyBranchTemplate(template, variables), + ); // Some templates could produce empty results- validate if (branchName.trim().length > 0) return branchName; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 418eaaea..dbb7d52a 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -291,5 +291,68 @@ describe("branch template utilities", () => { expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/); expect(result.length).toBeLessThanOrEqual(50); }); + + // Regression: a title with no ASCII-alphanumeric content makes + // {{description}} sanitize to an empty string. Around a slash separator this + // previously produced "claude//123", which validateBranchName rejects + // ("cannot contain consecutive slashes"), aborting the entire run. + it("should collapse the double slash from an empty description (emoji-only title)", () => { + const template = "{{prefix}}{{description}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + undefined, + "🎉🎉🎉", + ); + + expect(result).toBe("claude/123"); + }); + + it("should collapse the double slash for a CJK-only title", () => { + const template = "{{prefix}}{{description}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + undefined, + "日本語のタイトル", + ); + + expect(result).toBe("claude/123"); + }); + + it("should drop a trailing slash left by an empty trailing segment", () => { + const template = "{{prefix}}{{entityNumber}}/{{description}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + undefined, + "!!! ???", + ); + + expect(result).toBe("claude/123"); + }); + + it("should produce a name that passes validateBranchName when a segment is empty", () => { + const result = generateBranchName( + "{{prefix}}{{description}}/{{entityNumber}}", + "claude/", + "issue", + 123, + undefined, + undefined, + "🎉", + ); + + expect(() => validateBranchName(result)).not.toThrow(); + }); }); });