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