1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 CommandId,
5 GitCommandError,
6 ProjectId,
7 ThreadId,
8 VcsProcessSpawnError,
9} from "@t3tools/contracts";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
18import * as Scope from "effect/Scope";
19
20import * as ServerConfig from "../config.ts";
21import * as GitManager from "../git/GitManager.ts";
22import * as GitWorkflow from "../git/GitWorkflowService.ts";
23import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as ProcessRunner from "../processRunner.ts";
26import * as ServerSettings from "../serverSettings.ts";
27import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
28import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
29import * as VcsProcess from "../vcs/VcsProcess.ts";
30import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
31import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
32import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
33import * as ProjectService from "./ProjectService.ts";
34import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
35import * as ManagedProjectFolders from "./ManagedProjectFolders.ts";
36
37// The real Git workflow over real repository detection, with a mock GitManager
38// since the service only asks whether the data dir is inside a checkout.
39const layerGitWorkflow = (vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>) =>
40 GitWorkflow.layer.pipe(
41 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(vcsProcess ?? VcsProcess.layer))),
42 Layer.provide(Layer.mock(GitManager.GitManager)({})),
43 );
44
45const layerEnrichment = ProjectEnrichmentService.layer.pipe(
46 Layer.provide(
47 Layer.mergeAll(
48 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
49 resolve: () => Effect.succeed(null),
50 }),
51 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
52 resolvePath: () => Effect.succeed(null),
53 }),
54 ),
55 ),
56);
57
58const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer));
59
60interface HarnessOptions {
61 /** The processes repository detection runs, such as a machine with no Git. */
62 readonly vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>;
63 /** A git driver for failures real git cannot produce on demand. */
64 readonly git?: Layer.Layer<GitVcsDriver.GitVcsDriver>;
65 /** Wraps the real ProjectService, for failures it cannot produce on demand. */
66 readonly projects?: (
67 real: ProjectService.ProjectService["Service"],
68 ) => ProjectService.ProjectService["Service"];
69}
70
71/**
72 * The service over a real ProjectService and real git, with its data dir at
73 * `baseDir`.
74 */
75const layer = (baseDir: string, options?: HarnessOptions) =>
76 ManagedProjectFolders.layer.pipe(
77 Layer.provide(
78 options?.projects === undefined
79 ? Layer.empty
80 : Layer.effect(
81 ProjectService.ProjectService,
82 ProjectService.ProjectService.pipe(Effect.map(options.projects)),
83 ),
84 ),
85 Layer.provideMerge(RuntimeLayer.layerProjectService),
86 Layer.provideMerge(layerEnrichment),
87 Layer.provideMerge(WorkspacePaths.layer),
88 Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),
89 Layer.provideMerge(options?.git ?? layerRealGit),
90 Layer.provideMerge(SqlitePersistence.layerMemory),
91 Layer.provideMerge(ServerSettings.layerTest()),
92 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),
93 Layer.provideMerge(NodeServices.layer),
94 );
95
96/** Runs `body` with a data dir in a fresh temp folder, outside any checkout. */
97const withScratch = <A, E>(
98 body: (input: {
99 readonly baseDir: string;
100 }) => Effect.Effect<
101 A,
102 E,
103 | ManagedProjectFolders.ManagedProjectFolders
104 | ProjectService.ProjectService
105 | NodeServices.NodeServices
106 | Scope.Scope
107 >,
108 options?: HarnessOptions,
109) =>
110 Effect.gen(function* () {
111 const fileSystem = yield* FileSystem.FileSystem;
112 const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-managed-folders-" });
113 return yield* body({ baseDir }).pipe(Effect.provide(layer(baseDir, options)));
114 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
115
116const git = (cwd: string, args: ReadonlyArray<string>) =>
117 ProcessRunner.ProcessRunner.pipe(
118 Effect.flatMap((runner) => runner.run({ command: "git", args: ["-C", cwd, ...args] })),
119 Effect.provide(ProcessRunner.layer),
120 );
121
122const requireRoot = Effect.gen(function* () {
123 const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
124 return Option.getOrThrow(yield* scratch.scratchRoot);
125});
126
127it.effect("offers a Scratch folder under the data dir when it is outside a checkout", () =>
128 withScratch(({ baseDir }) =>
129 Effect.gen(function* () {
130 const path = yield* Path.Path;
131 assert.equal(yield* requireRoot, path.resolve(baseDir, "scratch"));
132 }),
133 ),
134);
135
136it.effect("offers nothing when the data dir sits inside a Git checkout", () =>
137 Effect.gen(function* () {
138 const fileSystem = yield* FileSystem.FileSystem;
139 const path = yield* Path.Path;
140 const checkout = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-scratch-repo-" });
141 yield* git(checkout, ["init", "--quiet"]);
142 const baseDir = path.join(checkout, ".t3");
143 yield* fileSystem.makeDirectory(baseDir);
144 yield* Effect.gen(function* () {
145 const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
146 assert.isTrue(Option.isNone(yield* scratch.scratchRoot));
147 const failure = yield* Effect.flip(scratch.ensureScratchProject);
148 assert.equal(failure._tag, "ScratchUnavailableError");
149 }).pipe(Effect.provide(layer(baseDir)));
150 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
151);
152
153it.effect("offers a Scratch folder when Git is not installed", () =>
154 withScratch(
155 ({ baseDir }) =>
156 Effect.gen(function* () {
157 const path = yield* Path.Path;
158 assert.equal(yield* requireRoot, path.resolve(baseDir, "scratch"));
159 }),
160 {
161 vcsProcess: Layer.succeed(VcsProcess.VcsProcess, {
162 run: (input) =>
163 Effect.fail(
164 new VcsProcessSpawnError({
165 operation: input.operation,
166 command: input.command,
167 cwd: input.cwd,
168 cause: PlatformError.systemError({
169 _tag: "NotFound",
170 module: "ChildProcess",
171 method: "spawn",
172 syscall: `spawn ${input.command}`,
173 }),
174 }),
175 ),
176 }),
177 },
178 ),
179);
180
181it.effect("creates one Scratch project, even for concurrent first requests", () =>
182 withScratch(() =>
183 Effect.gen(function* () {
184 const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
185 const projects = yield* ProjectService.ProjectService;
186 // Without handling the losing creates' conflicts, eight racers fail.
187 const racers = yield* Effect.all(
188 Array.from({ length: 8 }, () => scratch.ensureScratchProject),
189 { concurrency: "unbounded" },
190 );
191 const again = yield* scratch.ensureScratchProject;
192 assert.deepEqual(
193 new Set(racers.map((result) => result.projectId)),
194 new Set([again.projectId]),
195 );
196
197 const snapshot = yield* projects.snapshot;
198 const root = yield* requireRoot;
199 const scratchProjects = snapshot.projects.filter((project) => project.workspaceRoot === root);
200 assert.lengthOf(scratchProjects, 1);
201 assert.equal(scratchProjects[0]?.title, "No project");
202 assert.deepEqual(scratchProjects[0]?.projectIcon, {
203 kind: "lucide",
204 name: "message-square-dashed",
205 color: "gray",
206 });
207 }),
208 ),
209);
210
211it.effect("recreates the Scratch folder after it is deleted", () =>
212 withScratch(() =>
213 Effect.gen(function* () {
214 const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
215 const fileSystem = yield* FileSystem.FileSystem;
216 const { projectId } = yield* scratch.ensureScratchProject;
217 const root = yield* requireRoot;
218 yield* fileSystem.remove(root, { recursive: true });
219
220 assert.equal((yield* scratch.ensureScratchProject).projectId, projectId);
221 assert.isTrue(yield* fileSystem.exists(root));
222
223 yield* fileSystem.remove(root, { recursive: true });
224 const folder = yield* scratch.folderForThread({
225 projectId,
226 threadId: ThreadId.make("thread-after-delete"),
227 text: "Still works",
228 });
229 assert.isTrue(yield* fileSystem.exists(Option.getOrThrow(folder)));
230 }),
231 ),
232);
233
234it.effect("gives each Scratch thread its own folder, named from its message", () =>
235 withScratch(() =>
236 Effect.gen(function* () {
237 const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
238 const fileSystem = yield* FileSystem.FileSystem;
239 const path = yield* Path.Path;
240 const { projectId } = yield* scratch.ensureScratchProject;
241 const root = yield* requireRoot;
242 const text = "Convert these PNGs to WebP, please!";
243 // Two ids with the same tail would collide on the short name.
244 const first = Option.getOrThrow(
245 yield* scratch.folderForThread({
246 projectId,
247 threadId: ThreadId.make("thread:a:0123456789abcdef"),
248 text,
249 }),
250 );
251 const second = Option.getOrThrow(
252 yield* scratch.folderForThread({
253 projectId,
254 threadId: ThreadId.make("thread:b:0123456789abcdef"),
255 text,
256 }),
257 );
258
259 // Ids that normalize to the same characters take both of its names.
260 const third = Option.getOrThrow(
261 yield* scratch.folderForThread({
262 projectId,
263 threadId: ThreadId.make("thread:b0123456789abcdef"),
264 text,
265 }),
266 );
267
268 assert.equal(new Set([first, second, third]).size, 3);
269 for (const folder of [first, second, third]) {
270 assert.equal(path.dirname(folder), root);
271 assert.match(path.basename(folder), /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-/);
272 assert.isTrue(yield* fileSystem.exists(folder));
273 }
274
275 const pasted = Option.getOrThrow(
276 yield* scratch.folderForThread({
277 projectId,
278 threadId: ThreadId.make("thread-pasted"),
279 text: `${"word ".repeat(10_000)}../../etc`,
280 }),
281 );
282 assert.equal(path.dirname(pasted), root);
283 assert.isAtMost(path.basename(pasted).length, 80);
284 }),
285 ),
286);
287
288it.effect("leaves threads in other projects alone", () =>
289 withScratch(({ baseDir }) =>
290 Effect.gen(function* () {
291 const scratch = yield* ManagedProjectFolders.ManagedProjectFolders;
292 const projects = yield* ProjectService.ProjectService;
293 const fileSystem = yield* FileSystem.FileSystem;
294 const path = yield* Path.Path;
295 yield* scratch.ensureScratchProject;
296 const other = yield* projects.create({
297 commandId: CommandId.make("command:other"),
298 projectId: ProjectId.make("project:other"),
299 title: "Other",
300 workspaceRoot: path.join(baseDir, "other"),
301 createWorkspaceRootIfMissing: true,
302 });
303
304 const folder = yield* scratch.folderForThread({
305 projectId: other.id,
306 threadId: ThreadId.make("thread-other"),
307 text: "Hello",
308 });
309 assert.isTrue(Option.isNone(folder));
310 assert.deepEqual(yield* fileSystem.readDirectory(yield* requireRoot), []);
311 }),
312 ),
313);
314
315const GIT_IDENTITY_KEYS = [
316 "GIT_AUTHOR_NAME",
317 "GIT_AUTHOR_EMAIL",
318 "GIT_COMMITTER_NAME",
319 "GIT_COMMITTER_EMAIL",
320 "EMAIL",
321 "GIT_CONFIG_GLOBAL",
322 "GIT_CONFIG_NOSYSTEM",
323] as const;
324
325/**
326 * Runs `effect` with git reading only an empty global config plus `env`, so
327 * the developer's own identity and signing settings stay out of the test. The
328 * suite's pinned settings (gitConfig.setup.ts) still apply.
329 */
330const withGitEnv = <A, E, R>(
331 env: Partial<Record<(typeof GIT_IDENTITY_KEYS)[number], string>>,
332 effect: Effect.Effect<A, E, R>,
333) =>
334 Effect.gen(function* () {
335 const fileSystem = yield* FileSystem.FileSystem;
336 const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" });
337 return yield* Effect.acquireUseRelease(
338 Effect.sync(() => {
339 const saved = GIT_IDENTITY_KEYS.map((key) => [key, process.env[key]] as const);
340 for (const key of GIT_IDENTITY_KEYS) delete process.env[key];
341 Object.assign(process.env, {
342 GIT_CONFIG_GLOBAL: emptyConfig,
343 GIT_CONFIG_NOSYSTEM: "1",
344 ...env,
345 });
346 return saved;
347 }),
348 () => effect,
349 (saved) =>
350 Effect.sync(() => {
351 for (const [key, value] of saved) {
352 if (value === undefined) delete process.env[key];
353 else process.env[key] = value;
354 }
355 }),
356 );
357 });
358
359const TEST_IDENTITY = {
360 GIT_AUTHOR_NAME: "Test",
361 GIT_AUTHOR_EMAIL: "test@test.com",
362 GIT_COMMITTER_NAME: "Test",
363 GIT_COMMITTER_EMAIL: "test@test.com",
364};
365
366const gitOutput = (cwd: string, args: ReadonlyArray<string>) =>
367 git(cwd, args).pipe(Effect.map((result) => result.stdout.trim()));
368
369it.effect("starts a named project as a committed repository, and suffixes a taken name", () =>
370 withScratch(({ baseDir }) =>
371 withGitEnv(
372 TEST_IDENTITY,
373 Effect.gen(function* () {
374 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
375 const projects = yield* ProjectService.ProjectService;
376 const fileSystem = yield* FileSystem.FileSystem;
377 const path = yield* Path.Path;
378 const root = path.resolve(baseDir, "projects");
379 assert.equal(folders.namedProjectsRoot, root);
380
381 const first = yield* folders.createNamedProject({ name: "Pinball Stats" });
382 const second = yield* folders.createNamedProject({ name: "pinball stats" });
383
384 assert.equal(first.workspaceRoot, path.join(root, "pinball-stats"));
385 assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2"));
386 assert.isUndefined(first.commitError);
387 const project = Option.getOrThrow(yield* projects.getById(first.projectId));
388 assert.equal(project.title, "Pinball Stats");
389 assert.equal(project.workspaceRoot, first.workspaceRoot);
390
391 const readme = yield* fileSystem.readFileString(
392 path.join(first.workspaceRoot, "README.md"),
393 );
394 assert.include(readme, "# Pinball Stats");
395 assert.include(readme, `src="assets/icon.svg"`);
396 const icon = yield* fileSystem.readFileString(
397 path.join(first.workspaceRoot, "assets", "icon.svg"),
398 );
399 assert.include(icon, ">PS</text>");
400 assert.equal(
401 yield* gitOutput(first.workspaceRoot, ["log", "--format=%s"]),
402 "Initial commit",
403 );
404 assert.equal(
405 yield* gitOutput(first.workspaceRoot, ["rev-parse", "--abbrev-ref", "HEAD"]),
406 "main",
407 );
408 assert.equal(yield* gitOutput(first.workspaceRoot, ["status", "--porcelain"]), "");
409 }),
410 ),
411 ),
412);
413
414it.effect("gives concurrent named projects with the same name distinct folders", () =>
415 withScratch(() =>
416 withGitEnv(
417 TEST_IDENTITY,
418 Effect.gen(function* () {
419 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
420 const projects = yield* ProjectService.ProjectService;
421 const created = yield* Effect.all(
422 Array.from({ length: 4 }, () => folders.createNamedProject({ name: "Race" })),
423 { concurrency: "unbounded" },
424 );
425
426 assert.equal(new Set(created.map((result) => result.workspaceRoot)).size, 4);
427 assert.equal(new Set(created.map((result) => result.projectId)).size, 4);
428 const snapshot = yield* projects.snapshot;
429 for (const result of created) {
430 assert.isTrue(
431 snapshot.projects.some((project) => project.workspaceRoot === result.workspaceRoot),
432 );
433 }
434 }),
435 ),
436 ),
437);
438
439it.effect("keeps a named project and reports why when Git cannot commit", () =>
440 withScratch(({ baseDir }) =>
441 // No identity anywhere, and Git may not guess one from the host name.
442 withGitEnv(
443 {},
444 Effect.gen(function* () {
445 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
446 const projects = yield* ProjectService.ProjectService;
447 const fileSystem = yield* FileSystem.FileSystem;
448 const path = yield* Path.Path;
449 yield* fileSystem.writeFileString(
450 path.join(baseDir, "no-identity.gitconfig"),
451 "[user]\n\tuseConfigOnly = true\n",
452 );
453 process.env.GIT_CONFIG_GLOBAL = path.join(baseDir, "no-identity.gitconfig");
454
455 const result = yield* folders.createNamedProject({ name: "No Identity" });
456
457 assert.include(result.commitError ?? "", "no name or email");
458 assert.isTrue(Option.isSome(yield* projects.getById(result.projectId)));
459 assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, "README.md")));
460 assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, ".git")));
461 }),
462 ),
463 ),
464);
465
466it.effect("keeps a folder that another project owns when the create conflicts", () =>
467 withScratch(({ baseDir }) =>
468 withGitEnv(
469 TEST_IDENTITY,
470 Effect.gen(function* () {
471 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
472 const projects = yield* ProjectService.ProjectService;
473 const fileSystem = yield* FileSystem.FileSystem;
474 const path = yield* Path.Path;
475 const taken = path.join(folders.namedProjectsRoot, "taken");
476 // A project registered at this path some other way (by hand, or a
477 // client that predates the claim) owns the folder the create claims.
478 yield* projects.create({
479 commandId: CommandId.make("command:owner"),
480 projectId: ProjectId.make("project:owner"),
481 title: "Owner",
482 workspaceRoot: taken,
483 createWorkspaceRootIfMissing: true,
484 });
485 yield* fileSystem.remove(taken, { recursive: true });
486
487 const failure = yield* Effect.flip(folders.createNamedProject({ name: "Taken" }));
488
489 assert.equal(failure._tag, "NamedProjectCreateError");
490 assert.equal(failure.message, "Failed to create the project.");
491 assert.isTrue(yield* fileSystem.exists(path.join(taken, "README.md")));
492 const owner = yield* projects.getByWorkspaceRoot(taken);
493 assert.equal(Option.getOrThrow(owner).id, ProjectId.make("project:owner"));
494 assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), [
495 "taken",
496 ]);
497 }),
498 ),
499 ),
500);
501
502it.effect("removes the folder when the project create is rejected for another reason", () =>
503 withScratch(
504 ({ baseDir }) =>
505 withGitEnv(
506 TEST_IDENTITY,
507 Effect.gen(function* () {
508 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
509 const fileSystem = yield* FileSystem.FileSystem;
510 const path = yield* Path.Path;
511
512 const failure = yield* Effect.flip(folders.createNamedProject({ name: "Rejected" }));
513
514 assert.equal(failure._tag, "NamedProjectCreateError");
515 assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), []);
516 }),
517 ),
518 {
519 // The project store failing the create is not something a real
520 // ProjectService can be made to do on demand.
521 projects: (real) =>
522 ProjectService.ProjectService.of({
523 ...real,
524 create: (input) =>
525 Effect.fail(
526 new ProjectService.ProjectOperationError({
527 operation: "dispatch-project-command",
528 projectId: input.projectId,
529 cause: "store unavailable",
530 }),
531 ),
532 }),
533 },
534 ),
535);
536
537it.effect("removes the folder when the create is cancelled before the project exists", () =>
538 Effect.gen(function* () {
539 const scaffolding = yield* Deferred.make<void>();
540 yield* withScratch(
541 ({ baseDir }) =>
542 Effect.gen(function* () {
543 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
544 const projects = yield* ProjectService.ProjectService;
545 const fileSystem = yield* FileSystem.FileSystem;
546 const path = yield* Path.Path;
547
548 const fiber = yield* folders
549 .createNamedProject({ name: "Cancelled" })
550 .pipe(Effect.forkChild);
551 yield* Deferred.await(scaffolding);
552 assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), [
553 "cancelled",
554 ]);
555 yield* Fiber.interrupt(fiber);
556
557 assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), []);
558 assert.deepEqual((yield* projects.snapshot).projects, []);
559 }),
560 {
561 // Holds `git init` open so the create can be interrupted mid-scaffold.
562 git: Layer.mock(GitVcsDriver.GitVcsDriver)({
563 readConfigValue: () => Effect.succeed(null),
564 execute: () =>
565 Deferred.succeed(scaffolding, undefined).pipe(Effect.andThen(Effect.never)),
566 }),
567 },
568 );
569 }),
570);
571
572it.effect("removes the folder when the repository cannot be made", () =>
573 withScratch(
574 ({ baseDir }) =>
575 Effect.gen(function* () {
576 const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
577 const projects = yield* ProjectService.ProjectService;
578 const fileSystem = yield* FileSystem.FileSystem;
579 const path = yield* Path.Path;
580
581 const failure = yield* Effect.flip(folders.createNamedProject({ name: "Broken" }));
582
583 assert.equal(failure._tag, "NamedProjectFolderError");
584 assert.equal(failure.message, "Failed to create the project folder.");
585 assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), []);
586 assert.deepEqual((yield* projects.snapshot).projects, []);
587 }),
588 {
589 // git init itself failing (a missing or broken git) is not something a
590 // real git can be made to do on demand.
591 git: Layer.mock(GitVcsDriver.GitVcsDriver)({
592 readConfigValue: () => Effect.succeed(null),
593 execute: (input) =>
594 Effect.fail(
595 new GitCommandError({
596 operation: input.operation,
597 command: "git",
598 cwd: input.cwd,
599 detail: "git is broken",
600 }),
601 ),
602 }),
603 },
604 ),
605);