From 751e0038320253a2f1cb5bb09b03f1d5530847ed Mon Sep 17 00:00:00 2001 From: NickNojiri <48599208+NickNojiri@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:59:52 -0700 Subject: [PATCH] fix(branch): collapse empty path segments in branch_name_template (#1539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A branch_name_template that places {{description}} (or another variable) next to a slash crashes the run when the variable resolves to an empty string. An issue/PR title with no ASCII-alphanumeric content — emoji-only, CJK-only, or punctuation-only — makes extractDescription() return "", so a template like "{{prefix}}{{description}}/{{entityNumber}}" produces "claude//123". validateBranchName rejects consecutive (and leading/trailing) slashes, and the thrown error propagates uncaught out of setupBranch, aborting the entire run. Normalize the templated result before the empty-result check: collapse runs of slashes and drop any leading/trailing slash. Single-slash and dash separators are untouched, so existing template behavior is unchanged; a template that collapses to empty still falls back to the default format. This is distinct from the {{label}} sanitization tracked in #1491 (and its open PRs), which deliberately leave {{description}} alone — so this path remained broken. Fixes the whole empty-segment class regardless of variable. Adds regression tests for emoji-only and CJK-only titles, a trailing empty segment, and a direct validateBranchName assertion proving the run no longer aborts. --- src/utils/branch-template.ts | 19 ++++++++++- test/branch-template.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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(); + }); }); });