Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion GIT Command Generator.html
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,16 @@ <h1>GIT <span>CMD</span><br>GENERATOR</h1>
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, "");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

async function copyText(text, badgeId) {
Expand Down
7 changes: 2 additions & 5 deletions app/HomeClient.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
try {
await navigator.clipboard.writeText(text);
Expand Down Expand Up @@ -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("");
Expand Down
16 changes: 16 additions & 0 deletions lib/branch-name.ts
Original file line number Diff line number Diff line change
@@ -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, "")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.replace(/^\/+|\/+$/g, "");
}
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Leading and trailing dots survive sanitization, and git's check-ref-format unconditionally rejects any ref whose name starts or ends with .. For example, the input .feature passes every existing replace step unchanged and produces an invalid branch name. The same applies to feature. (e.g., from a user typing feature. or from the removal of a trailing non-alphanum that happened to follow a dot). Adding a dot-trim step after the existing - and / trims closes the gap.

Suggested change
.replace(/^-+|-+$/g, "")
.replace(/^\/+|\/+$/g, "");
}
.replace(/^-+|-+$/g, "")
.replace(/^\/+|\/+$/g, "")
.replace(/^\.+|\.+$/g, "");
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/branch-name.ts
Line: 14-16

Comment:
Leading and trailing dots survive sanitization, and git's `check-ref-format` unconditionally rejects any ref whose name starts or ends with `.`. For example, the input `.feature` passes every existing replace step unchanged and produces an invalid branch name. The same applies to `feature.` (e.g., from a user typing `feature.` or from the removal of a trailing non-alphanum that happened to follow a dot). Adding a dot-trim step after the existing `-` and `/` trims closes the gap.

```suggestion
    .replace(/^-+|-+$/g, "")
    .replace(/^\/+|\/+$/g, "")
    .replace(/^\.+|\.+$/g, "");
}
```

How can I resolve this? If you propose a fix, please make it concise.

23 changes: 16 additions & 7 deletions scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
/** 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)}`);
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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]);
Expand Down
48 changes: 48 additions & 0 deletions test/branch-name.test.ts
Original file line number Diff line number Diff line change
@@ -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("@#$"), "");
});
});
Loading