Showing the changes to packages/shared/src (2 of 121 files). Show all
Unified · Split
| @@ -1,7 +1,54 @@ | |||
| 1 | import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; | 1 | import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { applyGitStatusStreamEvent } from "./git"; | 4 | import { |
| 5 | applyGitStatusStreamEvent, | ||
| 6 | normalizeGitRemoteUrl, | ||
| 7 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl, | ||
| 8 | } from "./git"; | ||
| 9 | |||
| 10 | describe("normalizeGitRemoteUrl", () => { | ||
| 11 | it("canonicalizes equivalent GitHub remotes across protocol variants", () => { | ||
| 12 | expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( | ||
| 13 | "github.com/t3tools/t3code", | ||
| 14 | ); | ||
| 15 | expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( | ||
| 16 | "github.com/t3tools/t3code", | ||
| 17 | ); | ||
| 18 | expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( | ||
| 19 | "github.com/t3tools/t3code", | ||
| 20 | ); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("preserves nested group paths for providers like GitLab", () => { | ||
| 24 | expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( | ||
| 25 | "gitlab.com/t3tools/platform/t3code", | ||
| 26 | ); | ||
| 27 | expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( | ||
| 28 | "gitlab.com/t3tools/platform/t3code", | ||
| 29 | ); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("drops explicit ports from URL-shaped remotes", () => { | ||
| 33 | expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( | ||
| 34 | "gitlab.company.com/team/project", | ||
| 35 | ); | ||
| 36 | expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( | ||
| 37 | "gitlab.company.com/team/project", | ||
| 38 | ); | ||
| 39 | }); | ||
| 40 | }); | ||
| 41 | |||
| 42 | describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { | ||
| 43 | it("extracts the owner and repository from common GitHub remote shapes", () => { | ||
| 44 | expect( | ||
| 45 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), | ||
| 46 | ).toBe("T3Tools/T3Code"); | ||
| 47 | expect( | ||
| 48 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), | ||
| 49 | ).toBe("T3Tools/T3Code"); | ||
| 50 | }); | ||
| 51 | }); | ||
| 5 | 52 | ||
| 6 | describe("applyGitStatusStreamEvent", () => { | 53 | describe("applyGitStatusStreamEvent", () => { |
| 7 | it("treats a remote-only update as a repository when local state is missing", () => { | 54 | it("treats a remote-only update as a repository when local state is missing", () => { |
| @@ -80,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { · 79 unmodified lines | |||
| 80 | return branchName.slice(firstSeparatorIndex + 1); | 80 | return branchName.slice(firstSeparatorIndex + 1); |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | /** | ||
| 84 | * Normalize a git remote URL into a stable comparison key. | ||
| 85 | */ | ||
| 86 | export function normalizeGitRemoteUrl(value: string): string { | ||
| 87 | const normalized = value | ||
| 88 | .trim() | ||
| 89 | .replace(/\/+$/g, "") | ||
| 90 | .replace(/\.git$/i, "") | ||
| 91 | .toLowerCase(); | ||
| 92 | |||
| 93 | if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { | ||
| 94 | try { | ||
| 95 | const url = new URL(normalized); | ||
| 96 | const repositoryPath = url.pathname | ||
| 97 | .split("/") | ||
| 98 | .filter((segment) => segment.length > 0) | ||
| 99 | .join("/"); | ||
| 100 | if (url.hostname && repositoryPath.includes("/")) { | ||
| 101 | return `${url.hostname}/${repositoryPath}`; | ||
| 102 | } | ||
| 103 | } catch { | ||
| 104 | return normalized; | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); | ||
| 109 | if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { | ||
| 110 | return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; | ||
| 111 | } | ||
| 112 | |||
| 113 | return normalized; | ||
| 114 | } | ||
| 115 | |||
| 116 | /** | ||
| 117 | * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. | ||
| 118 | */ | ||
| 119 | export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | ||
| 120 | const trimmed = url?.trim() ?? ""; | ||
| 121 | if (trimmed.length === 0) { | ||
| 122 | return null; | ||
| 123 | } | ||
| 124 | |||
| 125 | const match = | ||
| 126 | /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( | ||
| 127 | trimmed, | ||
| 128 | ); | ||
| 129 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; | ||
| 130 | return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; | ||
| 131 | } | ||
| 132 | |||
| 83 | function deriveLocalBranchNameCandidatesFromRemoteRef( | 133 | function deriveLocalBranchNameCandidatesFromRemoteRef( |
| 84 | branchName: string, | 134 | branchName: string, |
| 85 | remoteName?: string, | 135 | remoteName?: string, |