1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
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 * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import { ChildProcessSpawner } from "effect/process";
10
11import { referenceRepos } from "./lib/reference-repos.ts";
12import {
13 planReferenceRepoSync,
14 resolveReferenceRepoRef,
15 syncReferenceRepos,
16} from "./sync-reference-repos.ts";
17
18const encoder = new TextEncoder();
19const effectSmol = referenceRepos[0]!;
20const alchemyEffect = referenceRepos[1]!;
21
22function mockHandle(
23 options: {
24 readonly exitCode?: number;
25 readonly stdout?: string;
26 readonly stderr?: string;
27 } = {},
28) {
29 return ChildProcessSpawner.makeHandle({
30 pid: ChildProcessSpawner.ProcessId(1),
31 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode ?? 0)),
32 isRunning: Effect.succeed(false),
33 kill: () => Effect.void,
34 unref: Effect.succeed(Effect.void),
35 stdin: Sink.drain,
36 stdout: Stream.make(encoder.encode(options.stdout ?? "done\n")),
37 stderr: Stream.make(encoder.encode(options.stderr ?? "")),
38 all: Stream.empty,
39 getInputFd: () => Sink.drain,
40 getOutputFd: () => Stream.empty,
41 });
42}
43
44function mockSpawnerLayer(
45 commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>,
46 handle = mockHandle(),
47) {
48 return Layer.succeed(
49 ChildProcessSpawner.ChildProcessSpawner,
50 ChildProcessSpawner.make((command) => {
51 const childProcess = command as unknown as {
52 readonly command: string;
53 readonly args: ReadonlyArray<string>;
54 };
55 commands.push({
56 command: childProcess.command,
57 args: childProcess.args,
58 });
59 return Effect.succeed(handle);
60 }),
61 );
62}
63
64it.layer(NodeServices.layer)("sync-reference-repos", (it) => {
65 it.effect("resolves the effect-smol tag from the root catalog", () =>
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const rootDir = yield* fs.makeTempDirectoryScoped({
70 prefix: "sync-reference-repos-version-",
71 });
72 yield* fs.writeFileString(
73 path.join(rootDir, "pnpm-workspace.yaml"),
74 "catalog:\n effect: 4.0.0-beta.73\n",
75 );
76
77 assert.equal(
78 yield* resolveReferenceRepoRef(effectSmol, rootDir, false),
79 "effect@4.0.0-beta.73",
80 );
81 }),
82 );
83
84 it.effect("uses the latest branch without reading package versions", () =>
85 Effect.gen(function* () {
86 const fs = yield* FileSystem.FileSystem;
87 const rootDir = yield* fs.makeTempDirectoryScoped({
88 prefix: "sync-reference-repos-latest-",
89 });
90
91 assert.equal(yield* resolveReferenceRepoRef(effectSmol, rootDir, true), "main");
92 }),
93 );
94
95 it.effect("preserves version source read context and the filesystem cause", () =>
96 Effect.gen(function* () {
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const rootDir = yield* fs.makeTempDirectoryScoped({
100 prefix: "sync-reference-repos-read-error-",
101 });
102 const sourcePath = path.join(rootDir, effectSmol.versionSourcePath);
103
104 const error = yield* resolveReferenceRepoRef(effectSmol, rootDir, false).pipe(Effect.flip);
105
106 if (error._tag !== "ReferenceRepoVersionSourceError") {
107 assert.fail(`Unexpected error: ${error._tag}`);
108 }
109 assert.equal(error.operation, "read");
110 assert.equal(error.repoId, effectSmol.id);
111 assert.equal(error.sourcePath, sourcePath);
112 assert.ok(error.cause !== undefined);
113 assert.ok(!error.message.includes(String((error.cause as Error).message)));
114 }),
115 );
116
117 it.effect("preserves version source parse context and the schema cause", () =>
118 Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const rootDir = yield* fs.makeTempDirectoryScoped({
122 prefix: "sync-reference-repos-parse-error-",
123 });
124 const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath);
125 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
126 yield* fs.writeFileString(sourcePath, "{");
127
128 const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip);
129
130 if (error._tag !== "ReferenceRepoVersionSourceError") {
131 assert.fail(`Unexpected error: ${error._tag}`);
132 }
133 assert.equal(error.operation, "parse");
134 assert.equal(error.repoId, alchemyEffect.id);
135 assert.equal(error.sourcePath, sourcePath);
136 assert.ok(error.cause !== undefined);
137 assert.ok(!error.message.includes(String((error.cause as Error).message)));
138 }),
139 );
140
141 it.effect("reports the unresolved package path without inventing a cause", () =>
142 Effect.gen(function* () {
143 const fs = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const rootDir = yield* fs.makeTempDirectoryScoped({
146 prefix: "sync-reference-repos-resolution-error-",
147 });
148 const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath);
149 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
150 yield* fs.writeFileString(sourcePath, '{"dependencies":{}}');
151
152 const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip);
153
154 if (error._tag !== "ReferenceRepoVersionResolutionError") {
155 assert.fail(`Unexpected error: ${error._tag}`);
156 }
157 assert.equal(error.repoId, alchemyEffect.id);
158 assert.equal(error.sourcePath, sourcePath);
159 assert.deepStrictEqual(error.packageVersionPath, ["dependencies", "alchemy"]);
160 assert.ok(!("cause" in error));
161 }),
162 );
163
164 it.effect("resolves the alchemy-effect tag from the relay package", () =>
165 Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const rootDir = yield* fs.makeTempDirectoryScoped({
169 prefix: "sync-reference-repos-alchemy-version-",
170 });
171 yield* fs.makeDirectory(path.join(rootDir, "infra", "relay"), { recursive: true });
172 yield* fs.writeFileString(
173 path.join(rootDir, "infra", "relay", "package.json"),
174 '{"dependencies":{"alchemy":"2.0.0-beta.49"}}',
175 );
176
177 assert.equal(yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false), "v2.0.0-beta.49");
178 }),
179 );
180
181 it.effect("plans an add for a missing subtree and a pull for an existing subtree", () =>
182 Effect.gen(function* () {
183 const fs = yield* FileSystem.FileSystem;
184 const path = yield* Path.Path;
185 const rootDir = yield* fs.makeTempDirectoryScoped({
186 prefix: "sync-reference-repos-plan-",
187 });
188 yield* fs.writeFileString(
189 path.join(rootDir, "pnpm-workspace.yaml"),
190 "catalog:\n effect: 4.0.0-beta.73\n",
191 );
192
193 const addPlan = yield* planReferenceRepoSync(effectSmol, rootDir, false);
194 assert.equal(addPlan.action, "add");
195 assert.deepStrictEqual(addPlan.args, [
196 "subtree",
197 "add",
198 "--prefix=.repos/effect-smol",
199 "https://github.com/Effect-TS/effect.git",
200 "effect@4.0.0-beta.73",
201 "--squash",
202 ]);
203
204 yield* fs.makeDirectory(path.join(rootDir, effectSmol.prefix), { recursive: true });
205 assert.equal((yield* planReferenceRepoSync(effectSmol, rootDir, false)).action, "pull");
206 }),
207 );
208
209 it.effect("runs the planned git subtree command through the process service", () => {
210 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
211
212 return Effect.gen(function* () {
213 const fs = yield* FileSystem.FileSystem;
214 const path = yield* Path.Path;
215 const rootDir = yield* fs.makeTempDirectoryScoped({
216 prefix: "sync-reference-repos-run-",
217 });
218 yield* fs.writeFileString(
219 path.join(rootDir, "pnpm-workspace.yaml"),
220 "catalog:\n effect: 4.0.0-beta.73\n",
221 );
222
223 yield* syncReferenceRepos({ rootDir, repoId: "effect-smol" }).pipe(
224 Effect.provide(mockSpawnerLayer(commands)),
225 );
226
227 assert.deepStrictEqual(commands, [
228 {
229 command: "git",
230 args: [
231 "subtree",
232 "add",
233 "--prefix=.repos/effect-smol",
234 "https://github.com/Effect-TS/effect.git",
235 "effect@4.0.0-beta.73",
236 "--squash",
237 ],
238 },
239 ]);
240 });
241 });
242
243 it.effect("rejects unknown repo selectors", () =>
244 Effect.gen(function* () {
245 const error = yield* syncReferenceRepos({
246 repoId: "missing",
247 dryRun: true,
248 }).pipe(Effect.flip);
249
250 if (error._tag !== "ReferenceRepoSelectionError") {
251 assert.fail(`Unexpected error: ${error._tag}`);
252 }
253 assert.equal(error.repoId, "missing");
254 assert.deepStrictEqual(error.expectedRepoIds, ["effect-smol", "alchemy-effect"]);
255 assert.ok(!("cause" in error));
256 }),
257 );
258
259 it.effect("reports non-zero git exits without retaining process output", () => {
260 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
261
262 return Effect.gen(function* () {
263 const fs = yield* FileSystem.FileSystem;
264 const path = yield* Path.Path;
265 const rootDir = yield* fs.makeTempDirectoryScoped({
266 prefix: "sync-reference-repos-exit-error-",
267 });
268 yield* fs.writeFileString(
269 path.join(rootDir, "pnpm-workspace.yaml"),
270 "catalog:\n effect: 4.0.0-beta.73\n",
271 );
272
273 const error = yield* syncReferenceRepos({ rootDir, repoId: "effect-smol" }).pipe(
274 Effect.provide(
275 mockSpawnerLayer(
276 commands,
277 mockHandle({ exitCode: 23, stderr: "subtree failed secret-token-value\n" }),
278 ),
279 ),
280 Effect.flip,
281 );
282
283 if (error._tag !== "ReferenceRepoGitSubtreeError") {
284 assert.fail(`Unexpected error: ${error._tag}`);
285 }
286 assert.equal(error.operation, "exit");
287 assert.equal(error.repoId, effectSmol.id);
288 assert.equal(error.action, "add");
289 assert.equal(error.repository, effectSmol.repository);
290 assert.equal(error.ref, "effect@4.0.0-beta.73");
291 assert.equal(error.rootDir, rootDir);
292 assert.equal(error.argumentCount, commands[0]?.args.length);
293 assert.equal(error.exitCode, 23);
294 assert.equal(error.stdoutLength, 5);
295 assert.equal(error.stderrLength, 34);
296 assert.notProperty(error, "args");
297 assert.notProperty(error, "stderr");
298 assert.notInclude(error.message, "secret-token-value");
299 assert.ok(!("cause" in error));
300 });
301 });
302});