From 35e4aa53e9c80293ff74e1e4dabe9907e59950cf Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Sat, 11 Apr 2026 19:48:18 +0000 Subject: [PATCH] fix: normalize ssh URL remotes in global discovery Handle URL-style SSH git remotes such as ssh://git@github.com/owner/repo.git in the same canonicalization path as scp-style SSH remotes. This prevents global discovery consumers from reporting duplicate repos when different tools or clones use equivalent SSH remote formats. Add regression coverage for GitHub and GitLab URL-style SSH remotes and for deduping SSH URL, scp-style SSH, and HTTPS forms together. Fixes #975 --- bin/gstack-global-discover.ts | 14 ++++++++++++-- test/global-discover.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index 4e1445b37..ff2953a2c 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -124,8 +124,18 @@ function windowToDate(window: string): Date { export function normalizeRemoteUrl(url: string): string { let normalized = url.trim(); - // SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo - const sshMatch = normalized.match(/^(?:ssh:\/\/)?git@([^:]+):(.+)$/); + // SSH URL → HTTPS: ssh://git@github.com/user/repo → https://github.com/user/repo + try { + const parsed = new URL(normalized); + if (parsed.protocol === "ssh:" && parsed.username === "git" && parsed.pathname.length > 1) { + normalized = `https://${parsed.host}${parsed.pathname}`; + } + } catch { + // Not a URL-style remote; try scp-style below. + } + + // SCP-style SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo + const sshMatch = normalized.match(/^git@([^:]+):(.+)$/); if (sshMatch) { normalized = `https://${sshMatch[1]}/${sshMatch[2]}`; } diff --git a/test/global-discover.test.ts b/test/global-discover.test.ts index e541644c2..94645995f 100644 --- a/test/global-discover.test.ts +++ b/test/global-discover.test.ts @@ -30,6 +30,12 @@ describe("gstack-global-discover", () => { ); }); + test("converts URL-style SSH to HTTPS", () => { + expect(normalizeRemoteUrl("ssh://git@github.com/user/repo.git")).toBe( + "https://github.com/user/repo" + ); + }); + test("converts SSH without .git to HTTPS", () => { expect(normalizeRemoteUrl("git@github.com:user/repo")).toBe( "https://github.com/user/repo" @@ -44,9 +50,11 @@ describe("gstack-global-discover", () => { test("SSH and HTTPS for same repo normalize to same URL", () => { const ssh = normalizeRemoteUrl("git@github.com:garrytan/gstack.git"); + const sshUrl = normalizeRemoteUrl("ssh://git@github.com/garrytan/gstack.git"); const https = normalizeRemoteUrl("https://github.com/garrytan/gstack.git"); const httpsNoDotGit = normalizeRemoteUrl("https://github.com/garrytan/gstack"); expect(ssh).toBe(https); + expect(sshUrl).toBe(https); expect(https).toBe(httpsNoDotGit); }); @@ -61,6 +69,12 @@ describe("gstack-global-discover", () => { "https://gitlab.com/org/project" ); }); + + test("handles GitLab URL-style SSH URLs", () => { + expect(normalizeRemoteUrl("ssh://git@gitlab.com/org/project.git")).toBe( + "https://gitlab.com/org/project" + ); + }); }); describe("CLI", () => {