diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx index 7e2e60aa5a3..4415e0254f4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx @@ -20,6 +20,7 @@ import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { NotFound } from "renderer/routes/not-found"; +import { sanitizeSegment } from "shared/utils/branch"; export const Route = createFileRoute( "/_authenticated/_dashboard/project/$projectId/", @@ -53,17 +54,7 @@ function generateBranchFromTitle({ title: string; authorPrefix?: string; }): string { - if (!title.trim()) return ""; - - const slug = title - .toLowerCase() - .trim() - .replace(/[^a-z0-9\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 50); - + const slug = sanitizeSegment(title); if (!slug) return ""; if (authorPrefix) { diff --git a/apps/desktop/src/shared/utils/branch.test.ts b/apps/desktop/src/shared/utils/branch.test.ts index 568dab99f29..89e2128620d 100644 --- a/apps/desktop/src/shared/utils/branch.test.ts +++ b/apps/desktop/src/shared/utils/branch.test.ts @@ -18,12 +18,41 @@ describe("sanitizeSegment", () => { expect(sanitizeSegment("Hello's World!")).toBe("hellos-world"); }); + test("preserves underscores", () => { + expect(sanitizeSegment("hello_world")).toBe("hello_world"); + }); + + test("preserves dots", () => { + expect(sanitizeSegment("v1.0.0")).toBe("v1.0.0"); + }); + + test("preserves plus signs", () => { + expect(sanitizeSegment("c++fix")).toBe("c++fix"); + }); + + test("preserves at signs", () => { + expect(sanitizeSegment("user@feature")).toBe("user@feature"); + }); + + test("strips @{ sequence", () => { + expect(sanitizeSegment("test@{0}")).toBe("test@0"); + }); + + test("collapses consecutive dots", () => { + expect(sanitizeSegment("hello..world")).toBe("hello.world"); + }); + + test("removes .lock suffix", () => { + expect(sanitizeSegment("hello.lock")).toBe("hello"); + }); + test("collapses multiple hyphens", () => { expect(sanitizeSegment("hello--world")).toBe("hello-world"); }); - test("removes leading/trailing hyphens", () => { + test("removes leading/trailing hyphens and dots", () => { expect(sanitizeSegment("-hello-")).toBe("hello"); + expect(sanitizeSegment(".hello.")).toBe("hello"); }); test("respects maxLength", () => { @@ -44,8 +73,9 @@ describe("sanitizeAuthorPrefix", () => { expect(sanitizeAuthorPrefix("John Doe")).toBe("john-doe"); }); - test("removes special characters", () => { + test("removes special characters but keeps underscores and dots", () => { expect(sanitizeAuthorPrefix("John's Name!")).toBe("johns-name"); + expect(sanitizeAuthorPrefix("user_name")).toBe("user_name"); }); test("collapses multiple hyphens", () => { @@ -78,6 +108,26 @@ describe("sanitizeBranchName", () => { expect(sanitizeBranchName("John's/Feature!")).toBe("johns/feature"); }); + test("preserves underscores and dots in branch names", () => { + expect(sanitizeBranchName("user/fix_bug")).toBe("user/fix_bug"); + expect(sanitizeBranchName("release/v1.0.0")).toBe("release/v1.0.0"); + }); + + test("preserves plus and at signs", () => { + expect(sanitizeBranchName("user/c++fix")).toBe("user/c++fix"); + expect(sanitizeBranchName("user@org/feature")).toBe("user@org/feature"); + }); + + test("strips git-forbidden characters", () => { + expect(sanitizeBranchName("feat/test~1")).toBe("feat/test1"); + expect(sanitizeBranchName("feat/test^2")).toBe("feat/test2"); + expect(sanitizeBranchName("feat/test:foo")).toBe("feat/testfoo"); + expect(sanitizeBranchName("feat/test?foo")).toBe("feat/testfoo"); + expect(sanitizeBranchName("feat/test*foo")).toBe("feat/testfoo"); + expect(sanitizeBranchName("feat/test[foo")).toBe("feat/testfoo"); + expect(sanitizeBranchName("feat/test\\foo")).toBe("feat/testfoo"); + }); + test("handles empty string", () => { expect(sanitizeBranchName("")).toBe(""); }); diff --git a/apps/desktop/src/shared/utils/branch.ts b/apps/desktop/src/shared/utils/branch.ts index d8b77456324..795787720f8 100644 --- a/apps/desktop/src/shared/utils/branch.ts +++ b/apps/desktop/src/shared/utils/branch.ts @@ -2,10 +2,13 @@ export function sanitizeSegment(text: string, maxLength = 50): string { return text .toLowerCase() .trim() - .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") + .replace(/[^a-z0-9._+@-]/g, "") + .replace(/\.{2,}/g, ".") + .replace(/@\{/g, "@") .replace(/-+/g, "-") - .replace(/^-|-$/g, "") + .replace(/^[-.]|[-.]+$/g, "") + .replace(/\.lock$/g, "") .slice(0, maxLength); }