Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
2import * as NodeFS from "node:fs";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { vi } from "vite-plus/test";
9
10import * as ProcessRunner from "../processRunner.ts";
11import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
12
13const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
14const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
15
16const git = (cwd: string, args: ReadonlyArray<string>) =>
17 Effect.gen(function* () {
18 const processRunner = yield* ProcessRunner.ProcessRunner;
19 return yield* processRunner.run({
20 command: "git",
21 args: ["-C", cwd, ...args],
22 });
23 }).pipe(Effect.provide(ProcessRunner.layer));
24
25it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
26 it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
27 Effect.gen(function* () {
28 const fileSystem = yield* FileSystem.FileSystem;
29 const path = yield* Path.Path;
30 const cwd = yield* fileSystem.makeTempDirectoryScoped({
31 prefix: "t3-repository-identity-test-",
32 });
33
34 yield* git(cwd, ["init"]);
35 yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
36
37 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
38 const identity = yield* resolver.resolve(cwd);
39 // Native realpath matches git's long paths when Windows temp dirs use 8.3 names.
40 const resolvedIdentityRoot =
41 identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath);
42 const resolvedCwd = NodeFS.realpathSync.native(cwd);
43
44 expect(identity).not.toBeNull();
45 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
46 expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd));
47 expect(identity?.displayName).toBe("T3Tools/t3code");
48 expect(identity?.provider).toBe("github");
49 expect(identity?.owner).toBe("T3Tools");
50 expect(identity?.name).toBe("t3code");
51 expect(yield* resolver.resolve(path.join(cwd, ".git"))).toBeNull();
52 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
53 );
54
55 it.effect("identifies the host of an SSH remote that leaves its user to ssh config", () =>
56 Effect.gen(function* () {
57 const fileSystem = yield* FileSystem.FileSystem;
58 const cwd = yield* fileSystem.makeTempDirectoryScoped({
59 prefix: "t3-repository-identity-userless-remote-test-",
60 });
61
62 yield* git(cwd, ["init"]);
63 yield* git(cwd, ["remote", "add", "origin", "github.com:T3Tools/t3code.git"]);
64
65 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
66 const identity = yield* resolver.resolve(cwd);
67
68 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
69 expect(identity?.provider).toBe("github");
70 expect(identity?.displayName).toBe("T3Tools/t3code");
71 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
72 );
73
74 it.effect("resolves the fetch remote of a partial clone", () =>
75 Effect.gen(function* () {
76 const fileSystem = yield* FileSystem.FileSystem;
77 const cwd = yield* fileSystem.makeTempDirectoryScoped({
78 prefix: "t3-repository-identity-partial-clone-test-",
79 });
80
81 yield* git(cwd, ["init"]);
82 yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
83 yield* git(cwd, ["remote", "set-url", "--push", "origin", "git@github.com:T3Tools/push.git"]);
84 yield* git(cwd, ["config", "remote.origin.promisor", "true"]);
85 yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]);
86
87 const remotes = yield* git(cwd, ["remote", "-v"]);
88 expect(remotes.stdout).toContain("(fetch) [blob:none]");
89
90 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
91 const identity = yield* resolver.resolve(cwd);
92 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
93 expect(identity?.locator.remoteUrl).toBe("git@github.com:T3Tools/t3code.git");
94 expect(identity?.locator.remoteName).toBe("origin");
95 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
96 );
97
98 it.effect("resolves a remote whose URL contains a space", () =>
99 Effect.gen(function* () {
100 const fileSystem = yield* FileSystem.FileSystem;
101 const cwd = yield* fileSystem.makeTempDirectoryScoped({
102 prefix: "t3-repository-identity-spaced-url-test-",
103 });
104
105 yield* git(cwd, ["init"]);
106 yield* git(cwd, ["remote", "add", "origin", "ssh://git@git.example.test/team repos/app.git"]);
107
108 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
109 const identity = yield* resolver.resolve(cwd);
110 expect(identity?.locator.remoteUrl).toBe("ssh://git@git.example.test/team repos/app.git");
111 expect(identity?.locator.remoteName).toBe("origin");
112 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
113 );
114
115 it.effect.each(["repo", "repo "] as const)(
116 "returns the git top-level root path when resolving from a nested workspace in %j",
117 (rootName) =>
118 Effect.gen(function* () {
119 const fileSystem = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const tempDir = yield* fileSystem.makeTempDirectoryScoped({
122 prefix: "t3-repository-identity-nested-root-test-",
123 });
124 const repoRoot = path.join(tempDir, rootName);
125 const nestedWorkspace = path.join(repoRoot, "packages", "web");
126
127 yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true });
128 yield* git(repoRoot, ["init"]);
129 yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
130
131 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
132 const identity = yield* resolver.resolve(nestedWorkspace);
133 const resolvedIdentityRoot =
134 identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath);
135 const resolvedRepoRoot = NodeFS.realpathSync.native(repoRoot);
136
137 expect(identity).not.toBeNull();
138 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
139 expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(
140 normalizeResolvedPath(resolvedRepoRoot),
141 );
142 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
143 );
144
145 it.effect.each(["bare clone", "dot-bare"] as const)(
146 "resolves a %s to its Git directory and the same identity as a checkout",
147 (layout) =>
148 Effect.gen(function* () {
149 const fileSystem = yield* FileSystem.FileSystem;
150 const path = yield* Path.Path;
151 const checkout = yield* fileSystem.makeTempDirectoryScoped({
152 prefix: "t3-repository-identity-checkout-",
153 });
154 const root = yield* fileSystem.makeTempDirectoryScoped({
155 prefix: "t3-repository-identity-bare-",
156 });
157 const bareGitDir = layout === "dot-bare" ? path.join(root, ".bare") : root;
158
159 yield* git(checkout, ["init"]);
160 yield* git(checkout, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
161 yield* git(root, ["init", "--bare", bareGitDir]);
162 if (layout === "dot-bare") {
163 yield* fileSystem.writeFileString(path.join(root, ".git"), "gitdir: ./.bare\n");
164 }
165 yield* git(root, [
166 "--git-dir",
167 bareGitDir,
168 "remote",
169 "add",
170 "origin",
171 "https://github.com/T3Tools/t3code.git",
172 ]);
173
174 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
175 const checkoutIdentity = yield* resolver.resolve(checkout);
176 expect(checkoutIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
177 const resolvedBareGitDir = NodeFS.realpathSync.native(bareGitDir);
178 const projectRoots = layout === "dot-bare" ? [root, bareGitDir] : [root];
179
180 for (const projectRoot of projectRoots) {
181 const identity = yield* resolver.resolve(projectRoot);
182 const resolvedIdentityRoot =
183 identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath);
184
185 expect(identity).not.toBeNull();
186 expect(identity?.canonicalKey).toBe(checkoutIdentity?.canonicalKey);
187 expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(
188 normalizeResolvedPath(resolvedBareGitDir),
189 );
190 }
191
192 yield* git(root, ["--git-dir", bareGitDir, "remote", "remove", "origin"]);
193 for (const projectRoot of projectRoots) {
194 expect(yield* resolver.resolve(projectRoot, { refresh: true })).toBeNull();
195 }
196 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
197 );
198
199 it.effect(
200 "returns null for non-git, missing and no-remote folders, probing only what can exist",
201 () =>
202 Effect.gen(function* () {
203 const fileSystem = yield* FileSystem.FileSystem;
204 const path = yield* Path.Path;
205 const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({
206 prefix: "t3-repository-identity-non-git-",
207 });
208 const gitDir = yield* fileSystem.makeTempDirectoryScoped({
209 prefix: "t3-repository-identity-no-remote-",
210 });
211
212 yield* git(gitDir, ["init"]);
213
214 const runner = yield* ProcessRunner.ProcessRunner;
215 const runs: Array<ReadonlyArray<string>> = [];
216 const resolver = yield* RepositoryIdentityResolver.make().pipe(
217 Effect.provideService(ProcessRunner.ProcessRunner, {
218 run: (input) =>
219 Effect.suspend(() => {
220 runs.push(input.args);
221 return runner.run(input);
222 }),
223 }),
224 );
225 const bareProbes = () =>
226 runs.filter((args) => args.some((arg) => arg.startsWith("--git-dir=")));
227
228 expect(yield* resolver.resolve(path.join(nonGitDir, "deleted"))).toBeNull();
229 expect(runs).toEqual([]);
230
231 expect(yield* resolver.resolve(nonGitDir)).toBeNull();
232 expect(bareProbes()).toEqual([]);
233
234 expect(yield* resolver.resolve(gitDir)).toBeNull();
235 }).pipe(Effect.provide(ProcessRunner.layer)),
236 );
237
238 it.effect("ignores local-path remotes such as GitButler's gb-local", () =>
239 Effect.gen(function* () {
240 const fileSystem = yield* FileSystem.FileSystem;
241 const localOnlyDir = yield* fileSystem.makeTempDirectoryScoped({
242 prefix: "t3-repository-identity-local-remote-",
243 });
244 const mixedDir = yield* fileSystem.makeTempDirectoryScoped({
245 prefix: "t3-repository-identity-mixed-remote-",
246 });
247
248 yield* git(localOnlyDir, ["init"]);
249 yield* git(localOnlyDir, ["remote", "add", "gb-local", "."]);
250 yield* git(localOnlyDir, ["remote", "add", "backup", "/srv/git/repo.git"]);
251 yield* git(localOnlyDir, ["remote", "add", "archive", "file:///srv/git/repo.git"]);
252 yield* git(mixedDir, ["init"]);
253 yield* git(mixedDir, ["remote", "add", "gb-local", "."]);
254 yield* git(mixedDir, ["remote", "add", "mirror", "git@github.com:T3Tools/t3code.git"]);
255
256 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
257 expect(yield* resolver.resolve(localOnlyDir)).toBeNull();
258
259 const mixedIdentity = yield* resolver.resolve(mixedDir);
260 expect(mixedIdentity?.locator.remoteName).toBe("mirror");
261 expect(mixedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
262 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
263 );
264
265 it.effect.each(["add", "replace"] as const)(
266 "refreshes the primary upstream after %s before cache expiry",
267 (change) =>
268 Effect.gen(function* () {
269 const fileSystem = yield* FileSystem.FileSystem;
270 const cwd = yield* fileSystem.makeTempDirectoryScoped({
271 prefix: "t3-repository-identity-upstream-test-",
272 });
273
274 yield* git(cwd, ["init"]);
275 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
276 if (change === "replace") {
277 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]);
278 }
279
280 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
281 const initialIdentity = yield* resolver.resolve(cwd);
282 expect(initialIdentity?.canonicalKey).toBe(
283 change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous",
284 );
285
286 yield* git(cwd, [
287 "remote",
288 change === "add" ? "add" : "set-url",
289 "upstream",
290 "git@github.com:T3Tools/t3code.git",
291 ]);
292 expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity);
293 const identity = yield* resolver.resolve(cwd, { refresh: true });
294
295 expect(identity).not.toBeNull();
296 expect(identity?.locator.remoteName).toBe("upstream");
297 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
298 expect(identity?.displayName).toBe("T3Tools/t3code");
299 expect(yield* resolver.resolve(cwd)).toEqual(identity);
300 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
301 );
302
303 it.effect("reports a fork's own remote as origin next to the upstream identity", () =>
304 Effect.gen(function* () {
305 const fileSystem = yield* FileSystem.FileSystem;
306 const cwd = yield* fileSystem.makeTempDirectoryScoped({
307 prefix: "t3-repository-identity-fork-test-",
308 });
309
310 yield* git(cwd, ["init"]);
311 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
312 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
313
314 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
315 const identity = yield* resolver.resolve(cwd);
316
317 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
318 expect(identity?.displayName).toBe("T3Tools/t3code");
319 expect(identity?.origin).toEqual({
320 canonicalKey: "github.com/julius/t3code-fork",
321 remoteUrl: "git@github.com:julius/t3code-fork.git",
322 displayName: "julius/t3code-fork",
323 });
324 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
325 );
326
327 it.effect("uses the fork as the identity when gh repo set-default points at it", () =>
328 Effect.gen(function* () {
329 const fileSystem = yield* FileSystem.FileSystem;
330 const cwd = yield* fileSystem.makeTempDirectoryScoped({
331 prefix: "t3-repository-identity-gh-default-test-",
332 });
333
334 yield* git(cwd, ["init"]);
335 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
336 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
337 yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]);
338
339 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
340 const identity = yield* resolver.resolve(cwd);
341
342 expect(identity?.canonicalKey).toBe("github.com/julius/t3code-fork");
343 expect(identity?.locator.remoteName).toBe("origin");
344 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
345 );
346
347 it.effect("reads the project's gh default when the server inherited GIT_DIR", () =>
348 Effect.gen(function* () {
349 const fileSystem = yield* FileSystem.FileSystem;
350 const path = yield* Path.Path;
351 const cwd = yield* fileSystem.makeTempDirectoryScoped({
352 prefix: "t3-repository-identity-gh-default-git-dir-",
353 });
354 const other = yield* fileSystem.makeTempDirectoryScoped({
355 prefix: "t3-repository-identity-inherited-git-dir-",
356 });
357
358 yield* git(cwd, ["init"]);
359 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
360 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
361 yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]);
362 yield* git(other, ["init"]);
363 yield* git(other, ["config", "remote.upstream.gh-resolved", "base"]);
364
365 vi.stubEnv("GIT_DIR", path.join(other, ".git"));
366 yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllEnvs()));
367
368 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
369 const identity = yield* resolver.resolve(cwd);
370
371 expect(identity?.canonicalKey).toBe("github.com/julius/t3code-fork");
372 expect(identity?.locator.remoteName).toBe("origin");
373 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
374 );
375
376 it.effect("uses the last remote path segment as the repository name for nested groups", () =>
377 Effect.gen(function* () {
378 const fileSystem = yield* FileSystem.FileSystem;
379 const cwd = yield* fileSystem.makeTempDirectoryScoped({
380 prefix: "t3-repository-identity-nested-group-test-",
381 });
382
383 yield* git(cwd, ["init"]);
384 yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]);
385
386 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
387 const identity = yield* resolver.resolve(cwd);
388
389 expect(identity).not.toBeNull();
390 expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code");
391 expect(identity?.displayName).toBe("T3Tools/platform/t3code");
392 expect(identity?.owner).toBe("T3Tools");
393 expect(identity?.name).toBe("t3code");
394 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
395 );
396});