1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import { ChildProcessSpawner } from "effect/process";
8
9import * as VcsProcess from "./VcsProcess.ts";
10import * as VcsProjectConfig from "./VcsProjectConfig.ts";
11import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
12
13const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
14 exitCode: ChildProcessSpawner.ExitCode(0),
15 stdout,
16 stderr: "",
17 stdoutTruncated: false,
18 stderrTruncated: false,
19});
20
21const normalizeGitArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
22 args[0] === "-C" && args.length >= 2 ? args.slice(2) : args;
23
24describe("VcsDriverRegistry", () => {
25 it.effect("routes directly by VCS driver kind for non-repository workflows", () => {
26 const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
27 Layer.provide(NodeServices.layer),
28 Layer.provide(
29 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
30 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
31 }),
32 ),
33 Layer.provide(
34 Layer.mock(VcsProcess.VcsProcess)({
35 run: () => Effect.succeed(processOutput("")),
36 }),
37 ),
38 );
39
40 return Effect.gen(function* () {
41 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
42 const driver = yield* registry.get("git");
43
44 assert.strictEqual(driver.capabilities.kind, "git");
45 }).pipe(Effect.provide(layer));
46 });
47
48 // Answers detection like git: the nearest folder at or above the cwd, up to
49 // `rootDir`, that holds `.git` is the repository.
50 const makeDiskBackedLayer = (rootDir: string, calls: string[]) =>
51 Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe(
52 Layer.provide(NodeServices.layer),
53 Layer.provide(
54 Layer.mock(VcsProjectConfig.VcsProjectConfig)({
55 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
56 }),
57 ),
58 Layer.provide(
59 Layer.mock(VcsProcess.VcsProcess)({
60 run: (input) =>
61 Effect.gen(function* () {
62 const fs = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;
64 const command = normalizeGitArgs(input.args).join(" ");
65 calls.push(command);
66 let repoDir: string | null = input.cwd;
67 while (repoDir !== null && !(yield* fs.exists(path.join(repoDir, ".git")))) {
68 repoDir = repoDir === rootDir ? null : path.dirname(repoDir);
69 }
70 if (repoDir === null) {
71 return {
72 ...processOutput(""),
73 exitCode: ChildProcessSpawner.ExitCode(128),
74 stderr: "fatal: not a git repository",
75 };
76 }
77 if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n");
78 if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`);
79 if (command === "rev-parse --git-common-dir") {
80 return processOutput(`${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`);
81 }
82 return processOutput("");
83 }).pipe(Effect.provide(NodeServices.layer), Effect.orDie),
84 }),
85 ),
86 );
87
88 it.effect("caches repository detection for repeated resolves in the same cwd and kind", () =>
89 Effect.gen(function* () {
90 const fs = yield* FileSystem.FileSystem;
91 const path = yield* Path.Path;
92 const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
93 yield* fs.makeDirectory(path.join(repoDir, ".git"));
94 const calls: string[] = [];
95
96 yield* Effect.gen(function* () {
97 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
98 const first = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" });
99 const second = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" });
100
101 assert.equal(first.repository.rootPath, repoDir);
102 assert.equal(second.repository.rootPath, repoDir);
103 assert.deepStrictEqual(calls, [
104 "rev-parse --is-inside-work-tree",
105 "rev-parse --show-toplevel",
106 "rev-parse --git-common-dir",
107 ]);
108 }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls)));
109 }).pipe(Effect.provide(NodeServices.layer)),
110 );
111
112 it.effect("detects again when a cached repository is removed from disk", () =>
113 Effect.gen(function* () {
114 const fs = yield* FileSystem.FileSystem;
115 const path = yield* Path.Path;
116 const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
117 yield* fs.makeDirectory(path.join(repoDir, ".git"));
118 const calls: string[] = [];
119
120 yield* Effect.gen(function* () {
121 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
122 assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir);
123
124 yield* fs.remove(path.join(repoDir, ".git"), { recursive: true });
125
126 assert.equal(yield* registry.detect({ cwd: repoDir }), null);
127 assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2);
128 }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls)));
129 }).pipe(Effect.provide(NodeServices.layer)),
130 );
131
132 it.effect("detects again when git init creates a repository inside a cached one", () =>
133 Effect.gen(function* () {
134 const fs = yield* FileSystem.FileSystem;
135 const path = yield* Path.Path;
136 const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
137 const projectDir = path.join(parentDir, "project");
138 yield* fs.makeDirectory(path.join(parentDir, ".git"));
139 yield* fs.makeDirectory(projectDir);
140 const calls: string[] = [];
141
142 yield* Effect.gen(function* () {
143 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
144 assert.equal((yield* registry.detect({ cwd: projectDir }))?.repository.rootPath, parentDir);
145
146 yield* fs.makeDirectory(path.join(projectDir, ".git"));
147
148 assert.equal(
149 (yield* registry.detect({ cwd: projectDir }))?.repository.rootPath,
150 projectDir,
151 );
152 }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls)));
153 }).pipe(Effect.provide(NodeServices.layer)),
154 );
155
156 it.effect("detects again when git init creates a repository between the cwd and its root", () =>
157 Effect.gen(function* () {
158 const fs = yield* FileSystem.FileSystem;
159 const path = yield* Path.Path;
160 const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
161 const middleDir = path.join(parentDir, "a");
162 const cwd = path.join(middleDir, "b");
163 yield* fs.makeDirectory(path.join(parentDir, ".git"));
164 yield* fs.makeDirectory(cwd, { recursive: true });
165 const calls: string[] = [];
166
167 yield* Effect.gen(function* () {
168 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
169 assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, parentDir);
170
171 yield* fs.makeDirectory(path.join(middleDir, ".git"));
172
173 assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, middleDir);
174 }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls)));
175 }).pipe(Effect.provide(NodeServices.layer)),
176 );
177
178 it.effect("detects a repository created after a negative lookup", () =>
179 Effect.gen(function* () {
180 const fs = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" });
183 const calls: string[] = [];
184
185 yield* Effect.gen(function* () {
186 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
187 assert.equal(yield* registry.detect({ cwd: repoDir }), null);
188
189 yield* fs.makeDirectory(path.join(repoDir, ".git"));
190
191 assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir);
192 assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2);
193 }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls)));
194 }).pipe(Effect.provide(NodeServices.layer)),
195 );
196});