| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | |
| 5 | import { |
| 6 | applyGitStatusStreamEvent, |
| 7 | formatGeneratedBranchName, |
| 8 | buildTemporaryWorktreeBranchName, |
| 9 | flattenTemporaryWorktreeBranchName, |
| 10 | isTemporaryWorktreeBranch, |
| 11 | normalizeGitRemoteUrl, |
| 12 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl, |
| 13 | parseOriginUrlFromGitConfig, |
| 14 | resolveAutoFeatureBranchName, |
| 15 | sanitizeFeatureBranchName, |
| 16 | WORKTREE_BRANCH_PREFIX, |
| 17 | } from "./git.ts"; |
| 18 | |
| 19 | describe("normalizeGitRemoteUrl", () => { |
| 20 | it("canonicalizes equivalent GitHub remotes across protocol variants", () => { |
| 21 | expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( |
| 22 | "github.com/t3tools/t3code", |
| 23 | ); |
| 24 | expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( |
| 25 | "github.com/t3tools/t3code", |
| 26 | ); |
| 27 | expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( |
| 28 | "github.com/t3tools/t3code", |
| 29 | ); |
| 30 | }); |
| 31 | |
| 32 | it("preserves nested group paths for providers like GitLab", () => { |
| 33 | expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( |
| 34 | "gitlab.com/t3tools/platform/t3code", |
| 35 | ); |
| 36 | expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( |
| 37 | "gitlab.com/t3tools/platform/t3code", |
| 38 | ); |
| 39 | }); |
| 40 | |
| 41 | it("drops explicit ports from URL-shaped remotes", () => { |
| 42 | expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( |
| 43 | "gitlab.company.com/team/project", |
| 44 | ); |
| 45 | expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( |
| 46 | "gitlab.company.com/team/project", |
| 47 | ); |
| 48 | }); |
| 49 | |
| 50 | it("normalizes SCP-like remotes with non-git SSH users", () => { |
| 51 | expect(normalizeGitRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe( |
| 52 | "gitlab.example.com/group/project", |
| 53 | ); |
| 54 | expect(normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe( |
| 55 | "bitbucket.org/workspace/repo", |
| 56 | ); |
| 57 | }); |
| 58 | |
| 59 | it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => { |
| 60 | expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code")).toBe( |
| 61 | "dev.azure.com/t3tools/platform/_git/t3code", |
| 62 | ); |
| 63 | expect(normalizeGitRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code")).toBe( |
| 64 | "dev.azure.com/t3tools/platform/_git/t3code", |
| 65 | ); |
| 66 | expect( |
| 67 | normalizeGitRemoteUrl("https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code"), |
| 68 | ).toBe("dev.azure.com/t3tools/platform/_git/t3code"); |
| 69 | }); |
| 70 | |
| 71 | it("puts the organization back in the host on the name dev.azure.com replaced", () => { |
| 72 | expect( |
| 73 | normalizeGitRemoteUrl("T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code"), |
| 74 | ).toBe("t3tools.visualstudio.com/platform/_git/t3code"); |
| 75 | expect(normalizeGitRemoteUrl("https://T3Tools.visualstudio.com/Platform/_git/T3Code")).toBe( |
| 76 | "t3tools.visualstudio.com/platform/_git/t3code", |
| 77 | ); |
| 78 | }); |
| 79 | |
| 80 | it("leaves an Azure SSH host it cannot read as the path it was given", () => { |
| 81 | // Not `v3`, and not four segments: rewriting either would invent a repository that the web |
| 82 | // spelling has no name for, so the remote stands as it arrived. |
| 83 | expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v4/T3Tools/Platform/T3Code")).toBe( |
| 84 | "ssh.dev.azure.com/v4/t3tools/platform/t3code", |
| 85 | ); |
| 86 | expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/T3Code")).toBe( |
| 87 | "ssh.dev.azure.com/v3/t3tools/t3code", |
| 88 | ); |
| 89 | }); |
| 90 | }); |
| 91 | |
| 92 | describe("parseOriginUrlFromGitConfig", () => { |
| 93 | it("reads the origin url and ignores other remotes", () => { |
| 94 | const config = [ |
| 95 | "[core]", |
| 96 | "\trepositoryformatversion = 0", |
| 97 | '[remote "upstream"]', |
| 98 | "\turl = https://github.com/other/repo.git", |
| 99 | '[remote "origin"]', |
| 100 | "\turl = git@github.com:pingdotgg/t3code.git", |
| 101 | "\tfetch = +refs/heads/*:refs/remotes/origin/*", |
| 102 | '[branch "main"]', |
| 103 | "\tremote = origin", |
| 104 | ].join("\n"); |
| 105 | expect(parseOriginUrlFromGitConfig(config)).toBe("git@github.com:pingdotgg/t3code.git"); |
| 106 | }); |
| 107 | |
| 108 | it("strips inline comments and quotes from the url value", () => { |
| 109 | expect( |
| 110 | parseOriginUrlFromGitConfig( |
| 111 | '[remote "origin"]\n\turl = https://github.com/acme/repo.git # mirror\n', |
| 112 | ), |
| 113 | ).toBe("https://github.com/acme/repo.git"); |
| 114 | expect( |
| 115 | parseOriginUrlFromGitConfig('[remote "origin"]\n\turl = "git@github.com:acme/repo.git"\n'), |
| 116 | ).toBe("git@github.com:acme/repo.git"); |
| 117 | }); |
| 118 | |
| 119 | it("accepts legacy dotted headers, header comments, and line continuations", () => { |
| 120 | expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe( |
| 121 | "git@github.com:a/b.git", |
| 122 | ); |
| 123 | // Git folds the dotted form to lowercase but keeps quoted names as written. |
| 124 | expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe( |
| 125 | "git@github.com:a/b.git", |
| 126 | ); |
| 127 | expect( |
| 128 | parseOriginUrlFromGitConfig( |
| 129 | '[remote "Origin"]\n\turl = git@github.com:x/y.git\n[remote "origin"]\n\turl = git@github.com:a/b.git\n', |
| 130 | ), |
| 131 | ).toBe("git@github.com:a/b.git"); |
| 132 | expect( |
| 133 | parseOriginUrlFromGitConfig('[remote "origin"] # primary\n\turl = git@github.com:a/b.git\n'), |
| 134 | ).toBe("git@github.com:a/b.git"); |
| 135 | expect( |
| 136 | parseOriginUrlFromGitConfig( |
| 137 | '[remote "origin"]\n\turl = https://github.com/acme/\\\n\t\trepo.git\n', |
| 138 | ), |
| 139 | ).toBe("https://github.com/acme/repo.git"); |
| 140 | }); |
| 141 | |
| 142 | it("falls back to the first remote when there is no origin", () => { |
| 143 | const config = [ |
| 144 | '[remote "upstream"]', |
| 145 | "\turl = https://github.com/acme/repo.git", |
| 146 | '[remote "fork"]', |
| 147 | "\turl = https://github.com/me/repo.git", |
| 148 | ].join("\n"); |
| 149 | expect(parseOriginUrlFromGitConfig(config)).toBe("https://github.com/acme/repo.git"); |
| 150 | }); |
| 151 | |
| 152 | it("returns null when there is no remote section", () => { |
| 153 | expect(parseOriginUrlFromGitConfig("[core]\n\tbare = false\n")).toBeNull(); |
| 154 | expect(parseOriginUrlFromGitConfig("")).toBeNull(); |
| 155 | }); |
| 156 | }); |
| 157 | |
| 158 | describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { |
| 159 | it("extracts the owner and repository from common GitHub remote shapes", () => { |
| 160 | expect( |
| 161 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), |
| 162 | ).toBe("T3Tools/T3Code"); |
| 163 | expect( |
| 164 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), |
| 165 | ).toBe("T3Tools/T3Code"); |
| 166 | expect( |
| 167 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://github.com/T3Tools/T3Code.git"), |
| 168 | ).toBe("T3Tools/T3Code"); |
| 169 | }); |
| 170 | }); |
| 171 | |
| 172 | describe("isTemporaryWorktreeBranch", () => { |
| 173 | it("matches the generated temporary worktree refName format", () => { |
| 174 | expect( |
| 175 | isTemporaryWorktreeBranch( |
| 176 | buildTemporaryWorktreeBranchName((byteLength) => { |
| 177 | expect(byteLength).toBe(4); |
| 178 | return "DEADBEEF"; |
| 179 | }), |
| 180 | ), |
| 181 | ).toBe(true); |
| 182 | }); |
| 183 | |
| 184 | it("matches generated temporary worktree refs", () => { |
| 185 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); |
| 186 | expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); |
| 187 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); |
| 188 | }); |
| 189 | |
| 190 | it("normalizes a UUID-shaped random callback to the canonical 8-hex form", () => { |
| 191 | expect(buildTemporaryWorktreeBranchName(() => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe( |
| 192 | `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e`, |
| 193 | ); |
| 194 | }); |
| 195 | |
| 196 | it("matches legacy t3code temporary worktree refs", () => { |
| 197 | expect(isTemporaryWorktreeBranch("t3code/deadbeef")).toBe(true); |
| 198 | expect(isTemporaryWorktreeBranch("t3code-deadbeef")).toBe(true); |
| 199 | expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe(true); |
| 200 | expect( |
| 201 | isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`), |
| 202 | ).toBe(false); |
| 203 | }); |
| 204 | |
| 205 | it("rejects UUID-shaped refs that are not RFC 4122 v4", () => { |
| 206 | // version nibble is not 4 |
| 207 | expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12")).toBe(false); |
| 208 | // variant nibble is not [89ab] |
| 209 | expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12")).toBe(false); |
| 210 | }); |
| 211 | |
| 212 | it("matches the flat fallback used when a plain t3 branch exists", () => { |
| 213 | const flat = flattenTemporaryWorktreeBranchName(`${WORKTREE_BRANCH_PREFIX}/deadbeef`); |
| 214 | expect(flat).toBe(`${WORKTREE_BRANCH_PREFIX}-deadbeef`); |
| 215 | expect(isTemporaryWorktreeBranch(flat)).toBe(true); |
| 216 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-deadbeef-extra`)).toBe(false); |
| 217 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-feature`)).toBe(false); |
| 218 | expect(flattenTemporaryWorktreeBranchName("t3code/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe( |
| 219 | `${WORKTREE_BRANCH_PREFIX}-f4ae4e0e`, |
| 220 | ); |
| 221 | expect(flattenTemporaryWorktreeBranchName("t3code-deadbeef")).toBe( |
| 222 | `${WORKTREE_BRANCH_PREFIX}-deadbeef`, |
| 223 | ); |
| 224 | expect(flattenTemporaryWorktreeBranchName("t3code/deadbeef")).toBe( |
| 225 | `${WORKTREE_BRANCH_PREFIX}-deadbeef`, |
| 226 | ); |
| 227 | }); |
| 228 | |
| 229 | it("rejects non-temporary refName names", () => { |
| 230 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); |
| 231 | expect(isTemporaryWorktreeBranch("main")).toBe(false); |
| 232 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); |
| 233 | }); |
| 234 | }); |
| 235 | |
| 236 | describe("applyGitStatusStreamEvent", () => { |
| 237 | it("treats a remote-only update as a repository when local state is missing", () => { |
| 238 | const remote: VcsStatusRemoteResult = { |
| 239 | hasUpstream: true, |
| 240 | aheadCount: 2, |
| 241 | behindCount: 1, |
| 242 | pr: null, |
| 243 | }; |
| 244 | |
| 245 | expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ |
| 246 | isRepo: true, |
| 247 | hasPrimaryRemote: false, |
| 248 | isDefaultRef: false, |
| 249 | refName: null, |
| 250 | hasWorkingTreeChanges: false, |
| 251 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 252 | hasUpstream: true, |
| 253 | aheadCount: 2, |
| 254 | behindCount: 1, |
| 255 | pr: null, |
| 256 | }); |
| 257 | }); |
| 258 | |
| 259 | it("preserves local-only fields when applying a remote update", () => { |
| 260 | const current: VcsStatusResult = { |
| 261 | isRepo: true, |
| 262 | isJjWorkspace: true, |
| 263 | sourceControlProvider: { |
| 264 | kind: SourceControlProviderKind.make("github"), |
| 265 | name: "GitHub", |
| 266 | baseUrl: "https://github.com", |
| 267 | }, |
| 268 | hasPrimaryRemote: true, |
| 269 | isDefaultRef: false, |
| 270 | refName: "feature/demo", |
| 271 | hasWorkingTreeChanges: true, |
| 272 | workingTree: { |
| 273 | files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], |
| 274 | insertions: 1, |
| 275 | deletions: 0, |
| 276 | }, |
| 277 | hasUpstream: false, |
| 278 | aheadCount: 0, |
| 279 | behindCount: 0, |
| 280 | pr: null, |
| 281 | }; |
| 282 | |
| 283 | const remote: VcsStatusRemoteResult = { |
| 284 | hasUpstream: true, |
| 285 | aheadCount: 2, |
| 286 | behindCount: 1, |
| 287 | pr: null, |
| 288 | }; |
| 289 | |
| 290 | expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({ |
| 291 | ...current, |
| 292 | hasUpstream: true, |
| 293 | aheadCount: 2, |
| 294 | behindCount: 1, |
| 295 | pr: null, |
| 296 | }); |
| 297 | }); |
| 298 | }); |
| 299 | |
| 300 | describe("sanitizeFeatureBranchName", () => { |
| 301 | it.each(["feature", "fix", "team/jules"])("preserves the %s namespace", (namespace) => { |
| 302 | expect(sanitizeFeatureBranchName(`${namespace}/refine-toolbar`)).toBe( |
| 303 | `${namespace}/refine-toolbar`, |
| 304 | ); |
| 305 | }); |
| 306 | |
| 307 | it("sanitizes an explicit namespace before preserving it", () => { |
| 308 | expect(sanitizeFeatureBranchName(' "FIX//Calendar recruitment filter" ')).toBe( |
| 309 | "fix/calendar-recruitment-filter", |
| 310 | ); |
| 311 | }); |
| 312 | |
| 313 | it.each([ |
| 314 | ["refine toolbar", "feature/refine-toolbar"], |
| 315 | ["", "feature/update"], |
| 316 | [" /?. / ", "feature/update"], |
| 317 | ["fix///", "feature/fix"], |
| 318 | ])("keeps the fallback for unprefixed input %s", (input, expected) => { |
| 319 | expect(sanitizeFeatureBranchName(input)).toBe(expected); |
| 320 | }); |
| 321 | |
| 322 | it("keeps the existing length limit for namespaced branches", () => { |
| 323 | expect(sanitizeFeatureBranchName(`fix/${"x".repeat(80)}`)).toBe(`fix/${"x".repeat(60)}`); |
| 324 | }); |
| 325 | }); |
| 326 | |
| 327 | describe("resolveAutoFeatureBranchName", () => { |
| 328 | it("resolves case-insensitive collisions within the generated namespace", () => { |
| 329 | expect( |
| 330 | resolveAutoFeatureBranchName( |
| 331 | ["FIX/refine-toolbar", "fix/refine-toolbar-2"], |
| 332 | "fix/refine-toolbar", |
| 333 | ), |
| 334 | ).toBe("fix/refine-toolbar-3"); |
| 335 | }); |
| 336 | |
| 337 | it("keeps the fallback when no preferred branch is supplied", () => { |
| 338 | expect(resolveAutoFeatureBranchName(["feature/update"])).toBe("feature/update-2"); |
| 339 | }); |
| 340 | |
| 341 | it.each([ |
| 342 | ["fix/name", ["fix"], "fix-2/name"], |
| 343 | ["team/jules/fix/name", ["team/jules"], "team/jules-2/fix/name"], |
| 344 | ["fix/name", ["fix/name/child"], "fix/name-2"], |
| 345 | ["FIX/name", ["FIX", "FIX-2"], "fix-3/name"], |
| 346 | ["fix/name", ["fixes", "fix/name-extra/child"], "fix/name"], |
| 347 | [undefined, ["feature", "feature-2"], "feature-3/update"], |
| 348 | ])("resolves %s against namespace blockers %j", (preferredBranch, existingNames, expected) => { |
| 349 | expect(resolveAutoFeatureBranchName(existingNames, preferredBranch)).toBe(expected); |
| 350 | }); |
| 351 | }); |
| 352 | |
| 353 | describe("formatGeneratedBranchName", () => { |
| 354 | it.each(["t3code", "t3code/"])("joins static prefix %s with one slash", (prefix) => { |
| 355 | expect( |
| 356 | formatGeneratedBranchName("Add Search", { mode: "static", prefix, instructions: "" }), |
| 357 | ).toBe("t3code/add-search"); |
| 358 | }); |
| 359 | it("supports an empty prefix and preserves user prefix casing", () => { |
| 360 | expect( |
| 361 | formatGeneratedBranchName("Add Search", { mode: "static", prefix: "", instructions: "" }), |
| 362 | ).toBe("add-search"); |
| 363 | expect( |
| 364 | formatGeneratedBranchName("Add Search", { |
| 365 | mode: "static", |
| 366 | prefix: "Team/Julius/", |
| 367 | instructions: "", |
| 368 | }), |
| 369 | ).toBe("Team/Julius/add-search"); |
| 370 | }); |
| 371 | it.each([ |
| 372 | ["release..candidate", "release-candidate/add-search"], |
| 373 | [" Team / Jules.lock/", "Team/Jules-lock/add-search"], |
| 374 | ["-team//feature@{new}", "team/feature-new/add-search"], |
| 375 | [" /?. / ", "add-search"], |
| 376 | ])("normalizes invalid static prefix %s", (prefix, expected) => { |
| 377 | expect( |
| 378 | formatGeneratedBranchName("Add Search", { mode: "static", prefix, instructions: "" }), |
| 379 | ).toBe(expected); |
| 380 | }); |
| 381 | it("uses the model's semantic prefix without the stored static prefix", () => { |
| 382 | expect( |
| 383 | formatGeneratedBranchName("feat/Add Search", { |
| 384 | mode: "semantic", |
| 385 | prefix: "t3code", |
| 386 | instructions: "", |
| 387 | }), |
| 388 | ).toBe("feat/add-search"); |
| 389 | }); |
| 390 | it("preserves the full custom name, including case, dots and length", () => { |
| 391 | const branch = `Julius/ABC-123/release.v2-${"x".repeat(70)}`; |
| 392 | expect( |
| 393 | formatGeneratedBranchName(` ${branch} `, { |
| 394 | mode: "custom", |
| 395 | prefix: "ignored", |
| 396 | instructions: "", |
| 397 | }), |
| 398 | ).toBe(branch); |
| 399 | }); |
| 400 | }); |