1import { SourceControlProviderKind } from "@t3tools/contracts";
2import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
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
19describe("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
92describe("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
158describe("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
172describe("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
236describe("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
300describe("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
327describe("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
353describe("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});