1import { assert, it, describe } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import { ChildProcessSpawner } from "effect/process";
9import { VcsProcessExitError } from "@t3tools/contracts";
10
11import * as VcsProcess from "./VcsProcess.ts";
12import * as VcsProjectConfig from "./VcsProjectConfig.ts";
13import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
14
15const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
16 exitCode: ChildProcessSpawner.ExitCode(0),
17 stdout,
18 stderr: "",
19 stdoutTruncated: false,
20 stderrTruncated: false,
21});
22
23const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
24 args[0] === "-C" && args.length >= 2 ? args.slice(2) : args;
25
26const DETECT_COMMAND = "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir";
27
28describe("VcsDriverRegistry", () => {
29 const makeDiskBackedLayer = (rootDir: string, calls: string[]) =>
30 Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
31 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
32 Layer.provide(
33 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
34 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
35 }),
36 ),
37 Layer.provide(
38 Layer.mock(VcsProcess.VcsProcess)({
39 run: (input) =>
40 Effect.gen(function* () {
41 const fs = yield* FileSystem.FileSystem;
42 const path = yield* Path.Path;
43 const command = normalizeGitArgs(input.args).join(" ");
44 calls.push(command);
45 let repoDir: string | null = input.cwd;
46 while (repoDir !== null && !(yield* fs.exists(path.join(repoDir, ".git")))) {
47 repoDir = repoDir === rootDir ? null : path.dirname(repoDir);
48 }
49 if (repoDir === null) {
50 return {
51 ...processOutput(""),
52 exitCode: ChildProcessSpawner.ExitCode(128),
53 stderr: "fatal: not a git repository",
54 };
55 }
56 if (command === DETECT_COMMAND) {
57 return processOutput(
58 `true\n${repoDir}\n${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`,
59 );
60 }
61 return processOutput("");
62 }).pipe(
63 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
64 Effect.orDie,
65 ),
66 }),
67 ),
68 );
69
70 it.effect("detects again when a cached repository is removed from disk", () =>
71 Effect.gen(function* () {
72 const fs = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
75 yield* fs.makeDirectory(path.join(repoDir, ".git"));
76 const calls: string[] = [];
77
78 yield* Effect.gen(function* () {
79 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
80 assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir);
81
82 yield* fs.remove(path.join(repoDir, ".git"), { recursive: true });
83
84 assert.equal(yield* registry.detect({ cwd: repoDir }), null);
85 assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2);
86 }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls)));
87 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
88 );
89
90 it.effect("detects again when git init creates a repository inside a cached one", () =>
91 Effect.gen(function* () {
92 const fs = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
94 const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
95 const projectDir = path.join(parentDir, "project");
96 yield* fs.makeDirectory(path.join(parentDir, ".git"));
97 yield* fs.makeDirectory(projectDir);
98 const calls: string[] = [];
99
100 yield* Effect.gen(function* () {
101 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
102 assert.equal((yield* registry.detect({ cwd: projectDir }))?.repository.rootPath, parentDir);
103
104 yield* fs.makeDirectory(path.join(projectDir, ".git"));
105
106 assert.equal(
107 (yield* registry.detect({ cwd: projectDir }))?.repository.rootPath,
108 projectDir,
109 );
110 }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls)));
111 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
112 );
113
114 it.effect("detects a repository created after a negative lookup", () =>
115 Effect.gen(function* () {
116 const fs = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;
118 const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
119 const calls: string[] = [];
120
121 yield* Effect.gen(function* () {
122 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
123 assert.equal(yield* registry.detect({ cwd: repoDir }), null);
124
125 yield* fs.makeDirectory(path.join(repoDir, ".git"));
126
127 assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir);
128 assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2);
129 }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls)));
130 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
131 );
132 const makeFailingLayer = (detectOutput: VcsProcess.VcsProcessOutput) =>
133 Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
134 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
135 Layer.provide(
136 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
137 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
138 }),
139 ),
140 Layer.provide(
141 Layer.mock(VcsProcess.VcsProcess)({
142 run: (input) =>
143 normalizeGitArgs(input.args).join(" ") === DETECT_COMMAND
144 ? Effect.succeed(detectOutput)
145 : Effect.fail(
146 new VcsProcessExitError({
147 operation: input.operation,
148 command: "git",
149 cwd: input.cwd,
150 exitCode: 128,
151 detail: "fatal: bad config line 1 in file .git/config",
152 }),
153 ),
154 }),
155 ),
156 );
157
158 it.effect("reads only a failed work-tree probe as no repository", () =>
159 Effect.gen(function* () {
160 const probeFailure = yield* VcsDriverRegistry.VcsDriverRegistry.pipe(
161 Effect.flatMap((registry) => registry.detect({ cwd: "/repo" })),
162 Effect.provide(
163 makeFailingLayer({
164 ...processOutput(""),
165 exitCode: ChildProcessSpawner.ExitCode(128),
166 stderr: "fatal: bad config line 1 in file .git/config",
167 }),
168 ),
169 );
170 assert.equal(probeFailure, null);
171
172 // A newline in the root path makes detection ask for it again, and main surfaced that failure.
173 const fallbackFailure = yield* VcsDriverRegistry.VcsDriverRegistry.pipe(
174 Effect.flatMap((registry) => registry.detect({ cwd: "/repo" })),
175 Effect.provide(makeFailingLayer(processOutput("true\n/re\npo\n.git\n"))),
176 Effect.flip,
177 );
178 assert.equal(fallbackFailure._tag, "VcsProcessExitError");
179 }),
180 );
181});