diff --git a/GIT Command Generator.html b/GIT Command Generator.html index d3a20c9..acce9fa 100644 --- a/GIT Command Generator.html +++ b/GIT Command Generator.html @@ -811,7 +811,16 @@

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, "") + .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..144e18a --- /dev/null +++ b/lib/branch-name.ts @@ -0,0 +1,16 @@ +/** + * 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, "") + .replace(/^\/+|\/+$/g, ""); +} \ No newline at end of file diff --git a/scripts/cli.ts b/scripts/cli.ts index dc93ae7..2082cdb 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,7 @@ 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"); banner(`create branch ${name}`); const dirty = await hasChanges(); await runSteps(async () => { @@ -1205,9 +1216,8 @@ 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 || "", "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 () => { @@ -1238,8 +1248,7 @@ 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"); 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..0f9a826 --- /dev/null +++ b/test/branch-name.test.ts @@ -0,0 +1,48 @@ +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, 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", () => { + 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