| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { |
| 4 | CommandId, |
| 5 | GitCommandError, |
| 6 | ProjectId, |
| 7 | ThreadId, |
| 8 | VcsProcessSpawnError, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Fiber from "effect/Fiber"; |
| 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; |
| 16 | import * as Path from "effect/Path"; |
| 17 | import * as PlatformError from "effect/PlatformError"; |
| 18 | import * as Scope from "effect/Scope"; |
| 19 | |
| 20 | import * as ServerConfig from "../config.ts"; |
| 21 | import * as GitManager from "../git/GitManager.ts"; |
| 22 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 23 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import * as ProcessRunner from "../processRunner.ts"; |
| 26 | import * as ServerSettings from "../serverSettings.ts"; |
| 27 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 28 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 29 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 30 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 31 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; |
| 32 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| 33 | import * as ProjectService from "./ProjectService.ts"; |
| 34 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; |
| 35 | import * 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. |
| 39 | const 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 | |
| 45 | const 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 | |
| 58 | const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer)); |
| 59 | |
| 60 | interface 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 | */ |
| 75 | const 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. */ |
| 97 | const 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 | |
| 116 | const 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 | |
| 122 | const requireRoot = Effect.gen(function* () { |
| 123 | const scratch = yield* ManagedProjectFolders.ManagedProjectFolders; |
| 124 | return Option.getOrThrow(yield* scratch.scratchRoot); |
| 125 | }); |
| 126 | |
| 127 | it.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 | |
| 136 | it.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 | |
| 153 | it.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 | |
| 181 | it.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 | |
| 211 | it.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 | |
| 234 | it.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 | |
| 288 | it.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 | |
| 315 | const 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 | */ |
| 330 | const 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 | |
| 359 | const 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 | |
| 366 | const gitOutput = (cwd: string, args: ReadonlyArray<string>) => |
| 367 | git(cwd, args).pipe(Effect.map((result) => result.stdout.trim())); |
| 368 | |
| 369 | it.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 | |
| 414 | it.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 | |
| 439 | it.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 | |
| 466 | it.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 | |
| 502 | it.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 | |
| 537 | it.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 | |
| 572 | it.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 | ); |