1import { SourceControlProviderKind } from "@t3tools/contracts";
2// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
3import * as NodeFS from "node:fs";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { SourceControlProviderError } from "@t3tools/contracts";
6import { expect, it } from "@effect/vitest";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13import { TestClock } from "effect/testing";
14
15import * as ProcessRunner from "../processRunner.ts";
16import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
17
18const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
19const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
20
21const git = (cwd: string, args: ReadonlyArray<string>) =>
22 Effect.gen(function* () {
23 const processRunner = yield* ProcessRunner.ProcessRunner;
24 return yield* processRunner.run({
25 command: "git",
26 args: ["-C", cwd, ...args],
27 });
28 }).pipe(Effect.provide(ProcessRunner.layer));
29
30const layerRepositoryIdentityResolverTest = (options: {
31 readonly positiveCacheTtl?: Duration.Input;
32 readonly negativeCacheTtl?: Duration.Input;
33}) =>
34 Layer.effect(
35 RepositoryIdentityResolver.RepositoryIdentityResolver,
36 RepositoryIdentityResolver.make({
37 cacheCapacity: 16,
38 ...options,
39 }),
40 ).pipe(Layer.provide(ProcessRunner.layer));
41
42it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
43 it.effect("refreshes the Git root only when requested", () => {
44 const calls: Array<ReadonlyArray<string>> = [];
45 let rootPath = "/repo";
46 let remoteUrl = "git@github.com:T3Tools/t3code.git";
47 let refinements = 0;
48 let refinementFails = false;
49 const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
50 run: (input) =>
51 Effect.sync(() => {
52 calls.push(input.args);
53 return {
54 stdout: input.args.includes("rev-parse")
55 ? `${rootPath}\n`
56 : `origin\t${remoteUrl} (fetch)\n`,
57 stderr: "",
58 code: ChildProcessSpawner.ExitCode(0),
59 timedOut: false,
60 stdoutTruncated: false,
61 stderrTruncated: false,
62 stdoutInvalidUtf8: false,
63 stderrInvalidUtf8: false,
64 };
65 }),
66 });
67 const layerResolver = Layer.effect(
68 RepositoryIdentityResolver.RepositoryIdentityResolver,
69 RepositoryIdentityResolver.make({
70 refine: (identity) => {
71 refinements++;
72 if (refinementFails)
73 return Effect.fail(
74 new SourceControlProviderError({
75 provider: SourceControlProviderKind.make("forgejo"),
76 operation: "detectProvider",
77 cwd: rootPath,
78 detail: "account unavailable",
79 }),
80 );
81 return Effect.succeed(
82 identity.canonicalKey.startsWith("ssh.forge.test/")
83 ? {
84 ...identity,
85 provider: "forgejo",
86 webUrl: "http://forge.test:3000/git/team/repo",
87 }
88 : identity,
89 );
90 },
91 }),
92 ).pipe(Layer.provide(layerProcessRunner));
93
94 return Effect.gen(function* () {
95 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
96 const first = yield* resolver.resolve("/repo/packages/web");
97 rootPath = "/repo/packages/web";
98 // Longer than the one-minute cadence of the background sweeps.
99 yield* TestClock.adjust(Duration.minutes(10));
100 const second = yield* resolver.resolve("/repo/packages/web");
101
102 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");
103 expect(second).toEqual(first);
104 expect(refinements).toBe(1);
105 expect(calls).toEqual([
106 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
107 ["-C", "/repo", "remote", "-v"],
108 ]);
109
110 const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true });
111 expect(refreshed?.rootPath).toBe("/repo/packages/web");
112 expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed);
113 expect(calls.slice(2)).toEqual([
114 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
115 ["-C", "/repo/packages/web", "remote", "-v"],
116 ]);
117 remoteUrl = "git@ssh.forge.test:team/repo.git";
118 const forgejo = yield* resolver.resolve(rootPath, { refresh: true });
119 expect(forgejo?.webUrl).toBe("http://forge.test:3000/git/team/repo");
120 expect(forgejo?.provider).toBe("forgejo");
121 expect(forgejo?.canonicalKey).toBe("ssh.forge.test/team/repo");
122 expect(forgejo?.locator.remoteUrl).toBe(remoteUrl);
123 expect(yield* resolver.resolve(rootPath)).toEqual(forgejo);
124 expect(refinements).toBe(3);
125 refinementFails = true;
126 const unavailable = yield* resolver.resolve(rootPath, { refresh: true });
127 expect(unavailable?.webUrl).toBeUndefined();
128 expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo");
129 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver)));
130 });
131
132 it.effect("retries Git root discovery after the negative TTL", () => {
133 const calls: Array<ReadonlyArray<string>> = [];
134 let rootAttempts = 0;
135 const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
136 run: (input) =>
137 Effect.sync(() => {
138 calls.push(input.args);
139 const rootLookup = input.args.includes("rev-parse");
140 const failed = rootLookup && rootAttempts++ === 0;
141 return {
142 stdout: rootLookup
143 ? failed
144 ? ""
145 : "/repo\n"
146 : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
147 stderr: failed ? "temporary Git failure" : "",
148 code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
149 timedOut: false,
150 stdoutTruncated: false,
151 stderrTruncated: false,
152 stdoutInvalidUtf8: false,
153 stderrInvalidUtf8: false,
154 };
155 }),
156 });
157 const layerResolver = Layer.effect(
158 RepositoryIdentityResolver.RepositoryIdentityResolver,
159 RepositoryIdentityResolver.make(),
160 ).pipe(Layer.provide(layerProcessRunner));
161
162 return Effect.gen(function* () {
163 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
164 expect(yield* resolver.resolve("/repo/packages/web")).toBeNull();
165 expect(yield* resolver.resolve("/repo/packages/web")).toBeNull();
166
167 yield* TestClock.adjust(Duration.minutes(1));
168 const recovered = yield* resolver.resolve("/repo/packages/web");
169 expect(recovered?.rootPath).toBe("/repo");
170 expect(calls).toEqual([
171 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
172 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
173 ["-C", "/repo", "remote", "-v"],
174 ]);
175 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver)));
176 });
177
178 it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
179 Effect.gen(function* () {
180 const fileSystem = yield* FileSystem.FileSystem;
181 const cwd = yield* fileSystem.makeTempDirectoryScoped({
182 prefix: "t3-repository-identity-test-",
183 });
184
185 yield* git(cwd, ["init"]);
186 yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
187
188 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
189 const identity = yield* resolver.resolve(cwd);
190 // Native realpath, since git reports the long form of a directory the
191 // temp dir may name by its 8.3 short form on Windows.
192 const resolvedIdentityRoot =
193 identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath);
194 const resolvedCwd = NodeFS.realpathSync.native(cwd);
195
196 expect(identity).not.toBeNull();
197 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
198 expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd));
199 expect(identity?.displayName).toBe("t3tools/t3code");
200 expect(identity?.provider).toBe("github");
201 expect(identity?.owner).toBe("t3tools");
202 expect(identity?.name).toBe("t3code");
203 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
204 );
205
206 it.effect("returns the git top-level root path when resolving from a nested workspace", () =>
207 Effect.gen(function* () {
208 const fileSystem = yield* FileSystem.FileSystem;
209 const path = yield* Path.Path;
210 const repoRoot = yield* fileSystem.makeTempDirectoryScoped({
211 prefix: "t3-repository-identity-nested-root-test-",
212 });
213 const nestedWorkspace = path.join(repoRoot, "packages", "web");
214
215 yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true });
216 yield* git(repoRoot, ["init"]);
217 yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
218
219 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
220 const identity = yield* resolver.resolve(nestedWorkspace);
221 const resolvedIdentityRoot =
222 identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath);
223 const resolvedRepoRoot = NodeFS.realpathSync.native(repoRoot);
224
225 expect(identity).not.toBeNull();
226 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
227 expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(
228 normalizeResolvedPath(resolvedRepoRoot),
229 );
230 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
231 );
232
233 it.effect("returns null for non-git folders and repos without remotes", () =>
234 Effect.gen(function* () {
235 const fileSystem = yield* FileSystem.FileSystem;
236 const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({
237 prefix: "t3-repository-identity-non-git-",
238 });
239 const gitDir = yield* fileSystem.makeTempDirectoryScoped({
240 prefix: "t3-repository-identity-no-remote-",
241 });
242
243 yield* git(gitDir, ["init"]);
244
245 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
246 const nonGitIdentity = yield* resolver.resolve(nonGitDir);
247 const noRemoteIdentity = yield* resolver.resolve(gitDir);
248
249 expect(nonGitIdentity).toBeNull();
250 expect(noRemoteIdentity).toBeNull();
251 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
252 );
253
254 it.effect.each(["add", "replace"] as const)(
255 "refreshes the primary upstream after %s before cache expiry",
256 (change) =>
257 Effect.gen(function* () {
258 const fileSystem = yield* FileSystem.FileSystem;
259 const cwd = yield* fileSystem.makeTempDirectoryScoped({
260 prefix: "t3-repository-identity-upstream-test-",
261 });
262
263 yield* git(cwd, ["init"]);
264 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
265 if (change === "replace") {
266 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]);
267 }
268
269 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
270 const initialIdentity = yield* resolver.resolve(cwd);
271 expect(initialIdentity?.canonicalKey).toBe(
272 change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous",
273 );
274
275 yield* git(cwd, [
276 "remote",
277 change === "add" ? "add" : "set-url",
278 "upstream",
279 "git@github.com:T3Tools/t3code.git",
280 ]);
281 expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity);
282 const identity = yield* resolver.resolve(cwd, { refresh: true });
283
284 expect(identity).not.toBeNull();
285 expect(identity?.locator.remoteName).toBe("upstream");
286 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
287 expect(identity?.displayName).toBe("t3tools/t3code");
288 expect(yield* resolver.resolve(cwd)).toEqual(identity);
289 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
290 );
291
292 it.effect("reports a fork's own remote as origin next to the upstream identity", () =>
293 Effect.gen(function* () {
294 const fileSystem = yield* FileSystem.FileSystem;
295 const cwd = yield* fileSystem.makeTempDirectoryScoped({
296 prefix: "t3-repository-identity-fork-test-",
297 });
298
299 yield* git(cwd, ["init"]);
300 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
301 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
302
303 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
304 const identity = yield* resolver.resolve(cwd);
305
306 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
307 expect(identity?.displayName).toBe("t3tools/t3code");
308 expect(identity?.origin).toEqual({
309 canonicalKey: "github.com/julius/t3code-fork",
310 displayName: "julius/t3code-fork",
311 });
312 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
313 );
314
315 it.effect("uses the last remote path segment as the repository name for nested groups", () =>
316 Effect.gen(function* () {
317 const fileSystem = yield* FileSystem.FileSystem;
318 const cwd = yield* fileSystem.makeTempDirectoryScoped({
319 prefix: "t3-repository-identity-nested-group-test-",
320 });
321
322 yield* git(cwd, ["init"]);
323 yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]);
324
325 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
326 const identity = yield* resolver.resolve(cwd);
327
328 expect(identity).not.toBeNull();
329 expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code");
330 expect(identity?.displayName).toBe("t3tools/platform/t3code");
331 expect(identity?.owner).toBe("t3tools");
332 expect(identity?.name).toBe("t3code");
333 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
334 );
335
336 it.effect(
337 "keeps null identities cached across repeated resolves until the negative TTL expires",
338 () =>
339 Effect.gen(function* () {
340 const fileSystem = yield* FileSystem.FileSystem;
341 const cwd = yield* fileSystem.makeTempDirectoryScoped({
342 prefix: "t3-repository-identity-late-remote-test-",
343 });
344
345 yield* git(cwd, ["init"]);
346
347 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
348 const initialIdentity = yield* resolver.resolve(cwd);
349 expect(initialIdentity).toBeNull();
350
351 yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
352
353 for (const _attempt of [1, 2, 3]) {
354 const cachedIdentity = yield* resolver.resolve(cwd);
355 expect(cachedIdentity).toBeNull();
356 }
357
358 yield* TestClock.adjust(Duration.millis(120));
359
360 const refreshedIdentity = yield* resolver.resolve(cwd);
361 expect(refreshedIdentity).not.toBeNull();
362 expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
363 expect(refreshedIdentity?.name).toBe("t3code");
364 }).pipe(
365 Effect.provide(
366 Layer.merge(
367 TestClock.layer(),
368 layerRepositoryIdentityResolverTest({
369 negativeCacheTtl: Duration.millis(50),
370 positiveCacheTtl: Duration.seconds(1),
371 }),
372 ),
373 ),
374 ),
375 );
376
377 it.effect("refreshes cached identities after the positive TTL when a remote changes", () =>
378 Effect.gen(function* () {
379 const fileSystem = yield* FileSystem.FileSystem;
380 const cwd = yield* fileSystem.makeTempDirectoryScoped({
381 prefix: "t3-repository-identity-remote-change-test-",
382 });
383
384 yield* git(cwd, ["init"]);
385 yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
386
387 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
388 const initialIdentity = yield* resolver.resolve(cwd);
389 expect(initialIdentity).not.toBeNull();
390 expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
391
392 yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]);
393
394 const cachedIdentity = yield* resolver.resolve(cwd);
395 expect(cachedIdentity).not.toBeNull();
396 expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
397
398 yield* TestClock.adjust(Duration.millis(180));
399
400 const refreshedIdentity = yield* resolver.resolve(cwd);
401 expect(refreshedIdentity).not.toBeNull();
402 expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next");
403 expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next");
404 expect(refreshedIdentity?.name).toBe("t3code-next");
405 }).pipe(
406 Effect.provide(
407 Layer.merge(
408 TestClock.layer(),
409 layerRepositoryIdentityResolverTest({
410 negativeCacheTtl: Duration.millis(50),
411 positiveCacheTtl: Duration.millis(100),
412 }),
413 ),
414 ),
415 ),
416 );
417});