From 1aa5283dddfdc5f52cc580dad208db7f80b706ac Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:56:09 -0700 Subject: [PATCH] fix(gbrain): canonicalize remotes with trailing slashes --- lib/gstack-memory-helpers.ts | 8 +++++++- test/gstack-memory-helpers.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index 66f2e3397..3a1c563ba 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -106,9 +106,15 @@ export function canonicalizeRemote(url: string | null | undefined): string { // strip user@ prefix on URL-style remotes s = s.replace(/^[^@\/]+@/, ""); } + // strip trailing slash(es) first, so a URL written with a trailing slash + // still matches the `.git$` suffix below (e.g. ".../repo.git/" must + // canonicalize to ".../repo", not ".../repo.git"). + s = s.replace(/\/+$/, ""); // strip trailing .git s = s.replace(/\.git$/i, ""); - // strip trailing slash + // re-strip trailing slash(es): a path remote ending in a `.git` directory + // component ("/repo/.git") exposes a new trailing slash once `.git` is + // stripped, which would split the repo into a second identity. s = s.replace(/\/+$/, ""); // collapse multiple slashes (after path normalization) s = s.replace(/\/{2,}/g, "/"); diff --git a/test/gstack-memory-helpers.test.ts b/test/gstack-memory-helpers.test.ts index 04edd8fe8..244a64976 100644 --- a/test/gstack-memory-helpers.test.ts +++ b/test/gstack-memory-helpers.test.ts @@ -67,6 +67,30 @@ describe("canonicalizeRemote", () => { it("collapses redundant slashes", () => { expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar"); }); + + it("strips .git even when the URL has a trailing slash", () => { + // A remote configured with both a .git suffix and a trailing slash must + // canonicalize to the same key as one without — otherwise the same repo + // gets two dedup/source-id keys across machines. + expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe("github.com/garrytan/gstack"); + expect(canonicalizeRemote("git@github.com:garrytan/gstack.git/")).toBe("github.com/garrytan/gstack"); + expect(canonicalizeRemote("https://github.com/foo/bar.git///")).toBe("github.com/foo/bar"); + }); + + it("produces the same key with or without a trailing slash", () => { + expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe( + canonicalizeRemote("https://github.com/garrytan/gstack.git") + ); + }); + + it("canonicalizes a path remote ending in a .git directory component", () => { + // Stripping the `.git` suffix exposes a new trailing slash + // ("/repo/.git" → "/repo/") which must also be stripped, or the same + // repo splits into two identities. + expect(canonicalizeRemote("file:///Users/x/repo/.git")).toBe( + canonicalizeRemote("file:///Users/x/repo") + ); + }); }); // ── secretScanFile ─────────────────────────────────────────────────────────