From 8100337eaab75321a940c9fc05da908874789f24 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 10 Jul 2026 18:34:15 -0300 Subject: [PATCH 1/3] fix: normalize branch names in CLI and UI --- GIT Command Generator.html | 10 ++++++++- app/HomeClient.tsx | 7 ++---- lib/branch-name.ts | 15 +++++++++++++ scripts/cli.ts | 32 ++++++++++++++++++++------ test/branch-name.test.ts | 46 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 lib/branch-name.ts create mode 100644 test/branch-name.test.ts diff --git a/GIT Command Generator.html b/GIT Command Generator.html index d3a20c9..d256f6c 100644 --- a/GIT Command Generator.html +++ b/GIT Command Generator.html @@ -811,7 +811,15 @@

GIT CMD
GENERATOR

const $ = id => document.getElementById(id); function normalizeBranch(v) { - return (v || "").replace(/[_\s]+/g, "-").replace(/-+/g, "-"); + return (v || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[_\s]+/g, "-") + .replace(/[^a-zA-Z0-9\-/.]+/g, "") + .replace(/-+/g, "-") + .replace(/\/+/g, "/") + .replace(/^-+|-+$/g, "") + .replace(/^\/+|\/+$/g, ""); } async function copyText(text, badgeId) { diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx index 6321fe7..ca151e8 100644 --- a/app/HomeClient.tsx +++ b/app/HomeClient.tsx @@ -1,15 +1,12 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { sanitizeBranchName } from "../lib/branch-name"; /* ── helpers ── */ const DEFAULT_OPENROUTER_MODEL = "google/gemini-2.0-flash-001"; const DEFAULT_OPENAI_MODEL = "gpt-5.4-mini"; -function normalizeBranch(v: string): string { - return (v || "").replace(/[_\s]+/g, "-").replace(/-+/g, "-"); -} - async function copyToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); @@ -327,7 +324,7 @@ export default function HomeClient({ env }: { env: EnvDefaults }) { /* shared branch name (sincroniza criar / merge / mudar de branch) */ const [branch, setBranch] = useState(""); - const onBranchChange = (v: string) => setBranch(normalizeBranch(v)); + const onBranchChange = (v: string) => setBranch(sanitizeBranchName(v)); /* commit messages */ const [pushMsg, setPushMsg] = useState(""); diff --git a/lib/branch-name.ts b/lib/branch-name.ts new file mode 100644 index 0000000..8347d65 --- /dev/null +++ b/lib/branch-name.ts @@ -0,0 +1,15 @@ +/** + * Sanitize a git branch name: normalize accents (ç→c, á→a), spaces/underscores → + * hyphens, remove invalid characters. Safe on Windows and macOS (Unicode NFD). + */ +export function sanitizeBranchName(input: string): string { + return (input || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[_\s]+/g, "-") + .replace(/[^a-zA-Z0-9\-/.]+/g, "") + .replace(/-+/g, "-") + .replace(/\/+/g, "/") + .replace(/^-+|-+$/g, "") + .replace(/^\/+|\/+$/g, ""); +} \ No newline at end of file diff --git a/scripts/cli.ts b/scripts/cli.ts index dc93ae7..662a106 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -71,6 +71,7 @@ import { npmGlobalInstallCommand, parseNpmLatestVersion, } from "../lib/update-check"; +import { sanitizeBranchName } from "../lib/branch-name"; import { APP_NAME, CLI_NAME, getPackageName, getVersion } from "../lib/version"; import { c, header, row, sym, visibleLength } from "../lib/ui"; @@ -115,6 +116,17 @@ function die(msg: string): never { function warn(msg: string) { log(` ${sym.warn} ${c.yellow(msg)}`); } +/** Apply branch-name rules; log when the CLI auto-corrected user input. */ +function resolveBranchName(raw: string, label = "branch name"): string { + const trimmed = (raw || "").trim(); + if (!trimmed) die(`${label} required`); + const name = sanitizeBranchName(trimmed); + if (!name) die(`${label} invalid after sanitization — use letters, numbers, hyphens, or slashes`); + if (name !== trimmed) { + log(` ${sym.info} ${c.dim("corrected")} ${c.dim(trimmed)} ${c.dim("→")} ${c.bold(name)}`); + } + return name; +} /** Command banner: bold action title + the folder it runs in. */ function banner(action: string) { log(`\n ${c.bold(c.cyan("gitgen"))} ${c.dim("·")} ${c.bold(action)}`); @@ -1186,8 +1198,10 @@ async function main() { } case "branch": { - const name = arg1; - if (!name) die('branch name required — e.g. gg b feature/login (or gitgen branch feature/login)'); + const name = resolveBranchName( + arg1 || "", + 'branch name required — e.g. gg b feature/login (or gitgen branch feature/login)' + ); banner(`create branch ${name}`); const dirty = await hasChanges(); await runSteps(async () => { @@ -1205,9 +1219,11 @@ async function main() { } case "merge": { - const source = arg1; - if (!source) die('branch name required — e.g. gg m feature/login [target]'); - const target = arg2 || "main"; + const source = resolveBranchName( + arg1 || "", + 'branch name required — e.g. gg m feature/login [target]' + ); + const target = arg2 ? sanitizeBranchName(arg2) || "main" : "main"; banner(`merge ${source} → ${target}`); const message = await resolveMessage(messageFlag, `merge: integrate ${source} into ${target}`); await runSteps(async () => { @@ -1238,8 +1254,10 @@ async function main() { } case "switch": { - const target = arg1; - if (!target) die('branch name required — e.g. gg ck main (or gitgen checkout main)'); + const target = resolveBranchName( + arg1 || "", + 'branch name required — e.g. gg ck main (or gitgen checkout main)' + ); banner(`checkout ${target}`); await runSteps(async () => { await git(["checkout", target]); diff --git a/test/branch-name.test.ts b/test/branch-name.test.ts new file mode 100644 index 0000000..dcea0ed --- /dev/null +++ b/test/branch-name.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeBranchName } from "../lib/branch-name"; + +describe("sanitizeBranchName", () => { + it("converts spaces and underscores to hyphens", () => { + assert.equal(sanitizeBranchName("feature login"), "feature-login"); + assert.equal(sanitizeBranchName("feature_login"), "feature-login"); + assert.equal(sanitizeBranchName("a b c"), "a-b-c"); + }); + + it("normalizes accented characters", () => { + assert.equal(sanitizeBranchName("ação rápida"), "acao-rapida"); + assert.equal(sanitizeBranchName("configuração"), "configuracao"); + assert.equal(sanitizeBranchName("résumé"), "resume"); + assert.equal(sanitizeBranchName("niño"), "nino"); + }); + + it("removes special characters", () => { + assert.equal(sanitizeBranchName("feature@login!"), "featurelogin"); + assert.equal(sanitizeBranchName("fix#123"), "fix123"); + assert.equal(sanitizeBranchName("test (wip)"), "test-wip"); + }); + + it("keeps slashes and dots for hierarchical or versioned branches", () => { + assert.equal(sanitizeBranchName("feature/login"), "feature/login"); + assert.equal(sanitizeBranchName("release/1.0.0"), "release/1.0.0"); + }); + + it("collapses repeated hyphens and slashes", () => { + assert.equal(sanitizeBranchName("feature--login"), "feature-login"); + assert.equal(sanitizeBranchName("feature//login"), "feature/login"); + }); + + it("trims leading and trailing separators", () => { + assert.equal(sanitizeBranchName("-feature-"), "feature"); + assert.equal(sanitizeBranchName("/feature/"), "feature"); + assert.equal(sanitizeBranchName(" feature "), "feature"); + }); + + it("returns empty for invalid-only input", () => { + assert.equal(sanitizeBranchName(""), ""); + assert.equal(sanitizeBranchName(" "), ""); + assert.equal(sanitizeBranchName("@#$"), ""); + }); +}); \ No newline at end of file From 54c07cba53003d3adbe377b9b7cc7e21a37ba924 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 10 Jul 2026 18:57:56 -0300 Subject: [PATCH 2/3] fix: sanitize branch name and update cli tests --- lib/branch-name.ts | 1 + scripts/cli.ts | 17 ++++------------- test/branch-name.test.ts | 4 +++- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/branch-name.ts b/lib/branch-name.ts index 8347d65..144e18a 100644 --- a/lib/branch-name.ts +++ b/lib/branch-name.ts @@ -10,6 +10,7 @@ export function sanitizeBranchName(input: string): string { .replace(/[^a-zA-Z0-9\-/.]+/g, "") .replace(/-+/g, "-") .replace(/\/+/g, "/") + .replace(/\.\.+/g, ".") .replace(/^-+|-+$/g, "") .replace(/^\/+|\/+$/g, ""); } \ No newline at end of file diff --git a/scripts/cli.ts b/scripts/cli.ts index 662a106..2082cdb 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -1198,10 +1198,7 @@ async function main() { } case "branch": { - const name = resolveBranchName( - arg1 || "", - 'branch name required — e.g. gg b feature/login (or gitgen branch feature/login)' - ); + const name = resolveBranchName(arg1 || "", "branch name"); banner(`create branch ${name}`); const dirty = await hasChanges(); await runSteps(async () => { @@ -1219,11 +1216,8 @@ async function main() { } case "merge": { - const source = resolveBranchName( - arg1 || "", - 'branch name required — e.g. gg m feature/login [target]' - ); - const target = arg2 ? sanitizeBranchName(arg2) || "main" : "main"; + const source = resolveBranchName(arg1 || "", "source branch"); + const target = arg2 ? resolveBranchName(arg2, "target branch") : "main"; banner(`merge ${source} → ${target}`); const message = await resolveMessage(messageFlag, `merge: integrate ${source} into ${target}`); await runSteps(async () => { @@ -1254,10 +1248,7 @@ async function main() { } case "switch": { - const target = resolveBranchName( - arg1 || "", - 'branch name required — e.g. gg ck main (or gitgen checkout main)' - ); + const target = resolveBranchName(arg1 || "", "branch name"); banner(`checkout ${target}`); await runSteps(async () => { await git(["checkout", target]); diff --git a/test/branch-name.test.ts b/test/branch-name.test.ts index dcea0ed..0f9a826 100644 --- a/test/branch-name.test.ts +++ b/test/branch-name.test.ts @@ -27,9 +27,11 @@ describe("sanitizeBranchName", () => { assert.equal(sanitizeBranchName("release/1.0.0"), "release/1.0.0"); }); - it("collapses repeated hyphens and slashes", () => { + it("collapses repeated hyphens, slashes, and dots", () => { assert.equal(sanitizeBranchName("feature--login"), "feature-login"); assert.equal(sanitizeBranchName("feature//login"), "feature/login"); + assert.equal(sanitizeBranchName("feature..main"), "feature.main"); + assert.equal(sanitizeBranchName("a...b"), "a.b"); }); it("trims leading and trailing separators", () => { From 5281093e2c072a381f00d885cd9c1ab85db3f68b Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 10 Jul 2026 19:02:27 -0300 Subject: [PATCH 3/3] fix: handle multiple dots in path normalization --- GIT Command Generator.html | 1 + 1 file changed, 1 insertion(+) diff --git a/GIT Command Generator.html b/GIT Command Generator.html index d256f6c..acce9fa 100644 --- a/GIT Command Generator.html +++ b/GIT Command Generator.html @@ -818,6 +818,7 @@

GIT CMD
GENERATOR

.replace(/[^a-zA-Z0-9\-/.]+/g, "") .replace(/-+/g, "-") .replace(/\/+/g, "/") + .replace(/\.\.+/g, ".") .replace(/^-+|-+$/g, "") .replace(/^\/+|\/+$/g, ""); }