Skip to content
Source
Sign inSign up

git.test.ts

380 lines13.3 KB

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 isBareGitConfig,
11 isTemporaryWorktreeBranch,
12 normalizeGitRemoteUrl,
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 expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git?view=compact")).toBe(
31 "github.com/t3tools/t3code",
32 );
33 expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git#section")).toBe(
34 "github.com/t3tools/t3code",
35 );
36 expect(normalizeGitRemoteUrl("ssh://git@code.example.test/owner/repo.git.git")).toBe(
37 normalizeGitRemoteUrl("git@code.example.test:owner/repo.git.git"),
38 );
39 });
40​
41 it("preserves nested group paths for providers like GitLab", () => {
42 expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe(
43 "gitlab.com/t3tools/platform/t3code",
44 );
45 expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe(
46 "gitlab.com/t3tools/platform/t3code",
47 );
48 });
49​
50 it("drops explicit ports from URL-shaped remotes", () => {
51 expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe(
52 "gitlab.company.com/team/project",
53 );
54 expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe(
55 "gitlab.company.com/team/project",
56 );
57 });
58​
59 it("normalizes SCP-like remotes with non-git SSH users", () => {
60 expect(normalizeGitRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe(
61 "gitlab.example.com/group/project",
62 );
63 expect(normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe(
64 "bitbucket.org/workspace/repo",
65 );
66 expect(normalizeGitRemoteUrl("code.example.test:team/repo.git")).toBe(
67 "code.example.test/team/repo",
68 );
69 expect(normalizeGitRemoteUrl("git@code.example.test:/team/repo.git")).toBe(
70 "code.example.test/team/repo",
71 );
72 expect(normalizeGitRemoteUrl("C:/repos/project")).toBe("c:/repos/project");
73 expect(normalizeGitRemoteUrl("git@x:team/project.git")).toBe("x/team/project");
74 });
75});
76​
77describe("isBareGitConfig", () => {
78 it("reads Git's numeric booleans without accepting invalid or overflowing values", () => {
79 for (const value of [
80 "true",
81 "yes",
82 "on",
83 "1",
84 "2",
85 "-1",
86 "+1",
87 "01",
88 "0x10",
89 "1k",
90 "1m",
91 "1g",
92 "2147483647",
93 "-2147483648",
94 ]) {
95 expect(isBareGitConfig(`[core]\nbare = ${value}\n`), value).toBe(true);
96 }
97 for (const value of [
98 "false",
99 "no",
100 "off",
101 "0",
102 "-0",
103 "00",
104 "0x0",
105 "0k",
106 "08",
107 "1.5",
108 "2no",
109 "2g",
110 "2147483648",
111 "-2147483649",
112 ]) {
113 expect(isBareGitConfig(`[core]\nbare = ${value}\n`), value).toBe(false);
114 }
115 expect(isBareGitConfig("[core]\nbare = 2\nbare = 0\n")).toBe(false);
116 expect(isBareGitConfig("[core]\nbare = false\nbare = -2\n")).toBe(true);
117 });
118​
119 it("reads continued bare values after comments and at end of input", () => {
120 const config = ["[core] bare = false # ignored backslash \\", 'bare = "tr\\', 'ue"'].join("\n");
121​
122 expect(isBareGitConfig(config)).toBe(true);
123 expect(isBareGitConfig("[core]\nbare = tr\\\nue\\")).toBe(true);
124 expect(isBareGitConfig('[core]\nbare = "true\\')).toBe(false);
125 expect(isBareGitConfig('[core]\nbare = "true\\\n')).toBe(false);
126 });
127});
128​
129describe("parseOriginUrlFromGitConfig", () => {
130 it("reads the origin url and ignores other remotes", () => {
131 const config = [
132 "[core]",
133 "\trepositoryformatversion = 0",
134 '[remote "upstream"]',
135 "\turl = https://github.com/other/repo.git",
136 '[remote "origin"]',
137 "\turl = git@github.com:pingdotgg/t3code.git",
138 "\tfetch = +refs/heads/*:refs/remotes/origin/*",
139 '[branch "main"]',
140 "\tremote = origin",
141 ].join("\n");
142 expect(parseOriginUrlFromGitConfig(config)).toBe("git@github.com:pingdotgg/t3code.git");
143 });
144​
145 it("strips inline comments and quotes from the url value", () => {
146 expect(
147 parseOriginUrlFromGitConfig(
148 '[remote "origin"]\n\turl = https://github.com/acme/repo.git # mirror\n',
149 ),
150 ).toBe("https://github.com/acme/repo.git");
151 expect(
152 parseOriginUrlFromGitConfig('[remote "origin"]\n\turl = "git@github.com:acme/repo.git"\n'),
153 ).toBe("git@github.com:acme/repo.git");
154 });
155​
156 it("accepts legacy dotted headers, header comments, and line continuations", () => {
157 expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe(
158 "git@github.com:a/b.git",
159 );
160 expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe(
161 "git@github.com:a/b.git",
162 );
163 expect(
164 parseOriginUrlFromGitConfig(
165 '[remote "Origin"]\n\turl = git@github.com:x/y.git\n[remote "origin"]\n\turl = git@github.com:a/b.git\n',
166 ),
167 ).toBe("git@github.com:a/b.git");
168 expect(
169 parseOriginUrlFromGitConfig('[remote "origin"] # primary\n\turl = git@github.com:a/b.git\n'),
170 ).toBe("git@github.com:a/b.git");
171 expect(
172 parseOriginUrlFromGitConfig(
173 '[remote "origin"]\n\turl = https://github.com/acme/\\\n\t\trepo.git\n',
174 ),
175 ).toBe("https://github.com/acme/repo.git");
176 });
177​
178 it("reads settings on the remote header line", () => {
179 const upstream = '\n[remote "upstream"]\n\turl = https://github.com/other/source.git\n';
180 expect(
181 parseOriginUrlFromGitConfig(`[remote "origin"] url = git@github.com:a/b.git${upstream}`),
182 ).toBe("git@github.com:a/b.git");
183 expect(parseOriginUrlFromGitConfig("[remote.Origin]url=git@github.com:a/b.git\n")).toBe(
184 "git@github.com:a/b.git",
185 );
186 expect(
187 parseOriginUrlFromGitConfig(
188 `[remote "origin"] fetch = +refs/heads/*:refs/remotes/origin/*\n\turl = git@github.com:a/b.git${upstream}`,
189 ),
190 ).toBe("git@github.com:a/b.git");
191 });
192​
193 it("falls back to the first remote when there is no origin", () => {
194 const config = [
195 '[remote "upstream"]',
196 "\turl = https://github.com/acme/repo.git",
197 '[remote "fork"]',
198 "\turl = https://github.com/me/repo.git",
199 ].join("\n");
200 expect(parseOriginUrlFromGitConfig(config)).toBe("https://github.com/acme/repo.git");
201 });
202});
203​
204describe("isTemporaryWorktreeBranch", () => {
205 it("matches generated temporary worktree refs", () => {
206 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true);
207 expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true);
208 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true);
209 });
210​
211 it("normalizes a UUID-shaped random callback to the canonical 8-hex form", () => {
212 expect(buildTemporaryWorktreeBranchName(() => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe(
213 `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e`,
214 );
215 });
216​
217 it("matches legacy t3code temporary worktree refs", () => {
218 expect(isTemporaryWorktreeBranch("t3code/deadbeef")).toBe(true);
219 expect(isTemporaryWorktreeBranch("t3code-deadbeef")).toBe(true);
220 expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe(true);
221 expect(
222 isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`),
223 ).toBe(false);
224 });
225​
226 it("rejects UUID-shaped refs that are not RFC 4122 v4", () => {
227 expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12")).toBe(false);
228 expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12")).toBe(false);
229 });
230​
231 it("matches the flat fallback used when a plain t3 branch exists", () => {
232 const flat = flattenTemporaryWorktreeBranchName(`${WORKTREE_BRANCH_PREFIX}/deadbeef`);
233 expect(flat).toBe(`${WORKTREE_BRANCH_PREFIX}-deadbeef`);
234 expect(isTemporaryWorktreeBranch(flat)).toBe(true);
235 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-deadbeef-extra`)).toBe(false);
236 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-feature`)).toBe(false);
237 expect(flattenTemporaryWorktreeBranchName("t3code/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe(
238 `${WORKTREE_BRANCH_PREFIX}-f4ae4e0e`,
239 );
240 expect(flattenTemporaryWorktreeBranchName("t3code-deadbeef")).toBe(
241 `${WORKTREE_BRANCH_PREFIX}-deadbeef`,
242 );
243 expect(flattenTemporaryWorktreeBranchName("t3code/deadbeef")).toBe(
244 `${WORKTREE_BRANCH_PREFIX}-deadbeef`,
245 );
246 });
247​
248 it("rejects non-temporary refName names", () => {
249 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false);
250 expect(isTemporaryWorktreeBranch("main")).toBe(false);
251 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false);
252 });
253});
254​
255describe("applyGitStatusStreamEvent", () => {
256 it("treats a remote-only update as a repository when local state is missing", () => {
257 const remote: VcsStatusRemoteResult = {
258 hasUpstream: true,
259 aheadCount: 2,
260 behindCount: 1,
261 pr: null,
262 };
263​
264 expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({
265 isRepo: true,
266 hasPrimaryRemote: false,
267 isDefaultRef: false,
268 refName: null,
269 hasWorkingTreeChanges: false,
270 workingTree: { files: [], insertions: 0, deletions: 0 },
271 hasUpstream: true,
272 aheadCount: 2,
273 behindCount: 1,
274 pr: null,
275 });
276 });
277​
278 it("preserves local-only fields when applying a remote update", () => {
279 const current: VcsStatusResult = {
280 isRepo: true,
281 sourceControlProvider: {
282 kind: SourceControlProviderKind.make("github"),
283 name: "GitHub",
284 baseUrl: "https://github.com",
285 },
286 hasPrimaryRemote: true,
287 isDefaultRef: false,
288 refName: "feature/demo",
289 hasWorkingTreeChanges: true,
290 workingTree: {
291 files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }],
292 insertions: 1,
293 deletions: 0,
294 },
295 hasUpstream: false,
296 aheadCount: 0,
297 behindCount: 0,
298 pr: null,
299 };
300​
301 const remote: VcsStatusRemoteResult = {
302 hasUpstream: true,
303 aheadCount: 2,
304 behindCount: 1,
305 pr: null,
306 };
307​
308 expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({
309 ...current,
310 hasUpstream: true,
311 aheadCount: 2,
312 behindCount: 1,
313 pr: null,
314 });
315 });
316});
317​
318describe("sanitizeFeatureBranchName", () => {
319 it("sanitizes an explicit namespace before preserving it", () => {
320 expect(sanitizeFeatureBranchName(' "FIX//Calendar recruitment filter" ')).toBe(
321 "fix/calendar-recruitment-filter",
322 );
323 });
324​
325 // regression: #15615 generated branch namespaces are preserved
326 it.each([
327 ["refine toolbar", "feature/refine-toolbar"],
328 [" /?. / ", "feature/update"],
329 ["fix///", "feature/fix"],
330 ])("keeps the fallback for unprefixed input %s", (input, expected) => {
331 expect(sanitizeFeatureBranchName(input)).toBe(expected);
332 });
333​
334 it("keeps accented letters as their plain letters", () => {
335 expect(sanitizeFeatureBranchName("fix/Añadir validación del café")).toBe(
336 "fix/anadir-validacion-del-cafe",
337 );
338 });
339});
340​
341describe("resolveAutoFeatureBranchName", () => {
342 it("resolves case-insensitive collisions within the generated namespace", () => {
343 expect(
344 resolveAutoFeatureBranchName(
345 ["FIX/refine-toolbar", "fix/refine-toolbar-2"],
346 "fix/refine-toolbar",
347 ),
348 ).toBe("fix/refine-toolbar-3");
349 });
350​
351 // regression: #15615 generated branch namespaces are preserved
352 it.each([
353 ["fix/name", ["fix"], "fix-2/name"],
354 ["fix/name", ["fix/name/child"], "fix/name-2"],
355 ["fix/name", ["fixes", "fix/name-extra/child"], "fix/name"],
356 ])("resolves %s against namespace blockers %j", (preferredBranch, existingNames, expected) => {
357 expect(resolveAutoFeatureBranchName(existingNames, preferredBranch)).toBe(expected);
358 });
359});
360​
361describe("formatGeneratedBranchName", () => {
362 it.each([
363 [" Team / Jules.lock/", "Team/Jules-lock/add-search"],
364 ["-team//feature@{new}", "team/feature-new/add-search"],
365 [" /?. / ", "add-search"],
366 ])("normalizes invalid static prefix %s", (prefix, expected) => {
367 expect(
368 formatGeneratedBranchName("Add Search", { mode: "static", prefix, instructions: "" }),
369 ).toBe(expected);
370 });
371 it("uses the model's semantic prefix without the stored static prefix", () => {
372 expect(
373 formatGeneratedBranchName("feat/Add Search", {
374 mode: "semantic",
375 prefix: "t3code",
376 instructions: "",
377 }),
378 ).toBe("feat/add-search");
379 });
380});