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