Showing the changes to apps/server/src/sourceControl/SourceControlRepositoryService.test.ts (1 of 29 files). Show all
Unified · Split
| @@ -3,15 +3,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 2 unmodified lines | |||
| 3 | import { assert, it } from "@effect/vitest"; | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 8 | import * as PlatformError from "effect/PlatformError"; | 7 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import { ChildProcessSpawner } from "effect/unstable/process"; | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 9 | ||
| 11 | import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; | 10 | import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; |
| 12 | 11 | ||
| 12 | import * as Deferred from "effect/Deferred"; | ||
| 13 | import * as Fiber from "effect/Fiber"; | ||
| 13 | import * as ServerConfig from "../config.ts"; | 14 | import * as ServerConfig from "../config.ts"; |
| 14 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 15 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 15 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; | 17 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 16 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 18 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 17 | import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; | 19 | import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; |
| @@ -54,6 +56,10 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { · 36 unmodified lines | |||
| 54 | }; | 56 | }; |
| 55 | } | 57 | } |
| 56 | 58 | ||
| 59 | function processOutputWithStdout(stdout: string): GitVcsDriver.ExecuteGitResult { | ||
| 60 | return { ...processOutput(), stdout }; | ||
| 61 | } | ||
| 62 | |||
| 57 | function makeLayer(input: { | 63 | function makeLayer(input: { |
| 58 | readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; | 64 | readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; |
| 59 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; | 65 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; |
| @@ -95,6 +101,23 @@ function makeLayer(input: { · 35 unmodified lines | |||
| 95 | : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); | 101 | : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); |
| 96 | } | 102 | } |
| 97 | 103 | ||
| 104 | function makeRealGitLayer() { | ||
| 105 | const gitLayer = GitVcsDriver.layer.pipe( | ||
| 106 | Layer.provide(VcsProcess.layer), | ||
| 107 | Layer.provide(NodeServices.layer), | ||
| 108 | ); | ||
| 109 | return SourceControlRepositoryService.layer.pipe( | ||
| 110 | Layer.provide( | ||
| 111 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ | ||
| 112 | get: () => Effect.succeed(makeProvider()), | ||
| 113 | }), | ||
| 114 | ), | ||
| 115 | Layer.provideMerge(gitLayer), | ||
| 116 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-real-git-" })), | ||
| 117 | Layer.provideMerge(NodeServices.layer), | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | |||
| 98 | it.effect("looks up repositories through the requested provider without search", () => { | 121 | it.effect("looks up repositories through the requested provider without search", () => { |
| 99 | const calls: Array<{ cwd: string; repository: string }> = []; | 122 | const calls: Array<{ cwd: string; repository: string }> = []; |
| 100 | const provider = makeProvider({ | 123 | const provider = makeProvider({ |
| @@ -154,12 +177,20 @@ it.effect("preserves provider failures without deriving the repository message f · 53 unmodified lines | |||
| 154 | it.effect("clones a looked-up repository into the requested destination", () => | 177 | it.effect("clones a looked-up repository into the requested destination", () => |
| 155 | Effect.gen(function* () { | 178 | Effect.gen(function* () { |
| 156 | const fs = yield* FileSystem.FileSystem; | 179 | const fs = yield* FileSystem.FileSystem; |
| 157 | const path = yield* Path.Path; | ||
| 158 | const parent = yield* fs.makeTempDirectoryScoped({ | 180 | const parent = yield* fs.makeTempDirectoryScoped({ |
| 159 | prefix: "t3-source-control-clone-parent-", | 181 | prefix: "t3-source-control-clone-parent-", |
| 160 | }); | 182 | }); |
| 161 | const destinationPath = path.join(parent, "t3code"); | 183 | const destinationPath = `${parent}/missing/nested/t3code`; |
| 162 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; | 184 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; |
| 185 | const lookupCwds: Array<string> = []; | ||
| 186 | const provider = makeProvider({ | ||
| 187 | getRepositoryCloneUrls: (input) => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | assert.strictEqual(yield* fs.exists(input.cwd).pipe(Effect.orDie), true); | ||
| 190 | lookupCwds.push(input.cwd); | ||
| 191 | return CLONE_URLS; | ||
| 192 | }), | ||
| 193 | }); | ||
| 163 | 194 | ||
| 164 | yield* Effect.gen(function* () { | 195 | yield* Effect.gen(function* () { |
| 165 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | 196 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| @@ -177,7 +208,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 11 unmodified lines | |||
| 177 | }); | 208 | }); |
| 178 | assert.deepStrictEqual(cloneCalls, [ | 209 | assert.deepStrictEqual(cloneCalls, [ |
| 179 | { | 210 | { |
| 180 | cwd: parent, | 211 | cwd: `${parent}/missing/nested`, |
| 181 | args: ["clone", CLONE_URLS.url, "t3code"], | 212 | args: ["clone", CLONE_URLS.url, "t3code"], |
| 182 | }, | 213 | }, |
| 183 | ]); | 214 | ]); |
| @@ -191,6 +222,253 @@ it.effect("clones a looked-up repository into the requested destination", () => · 7 unmodified lines | |||
| 191 | return processOutput(); | 222 | return processOutput(); |
| 192 | }), | 223 | }), |
| 193 | }, | 224 | }, |
| 225 | provider, | ||
| 226 | }), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | assert.deepStrictEqual(lookupCwds, [`${parent}/missing/nested`]); | ||
| 230 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 231 | ); | ||
| 232 | |||
| 233 | it.effect("recovers a matching completed clone and rejects unrelated contents", () => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const fs = yield* FileSystem.FileSystem; | ||
| 236 | const parent = yield* fs.makeTempDirectoryScoped({ | ||
| 237 | prefix: "t3-source-control-recover-clone-", | ||
| 238 | }); | ||
| 239 | const matchingDestination = `${parent}/matching`; | ||
| 240 | const unrelatedDestination = `${parent}/unrelated`; | ||
| 241 | const wrongPortDestination = `${parent}/wrong-port`; | ||
| 242 | const wrongCaseDestination = `${parent}/wrong-case`; | ||
| 243 | const wrongUserDestination = `${parent}/wrong-user`; | ||
| 244 | const absoluteSshDestination = `${parent}/absolute-ssh`; | ||
| 245 | yield* fs.makeDirectory(matchingDestination); | ||
| 246 | yield* fs.makeDirectory(unrelatedDestination); | ||
| 247 | yield* fs.makeDirectory(wrongPortDestination); | ||
| 248 | yield* fs.makeDirectory(wrongCaseDestination); | ||
| 249 | yield* fs.makeDirectory(wrongUserDestination); | ||
| 250 | yield* fs.makeDirectory(absoluteSshDestination); | ||
| 251 | yield* fs.writeFileString(`${matchingDestination}/README.md`, "matching clone"); | ||
| 252 | yield* fs.writeFileString(`${unrelatedDestination}/README.md`, "unrelated repository"); | ||
| 253 | yield* fs.writeFileString(`${wrongPortDestination}/README.md`, "different port"); | ||
| 254 | yield* fs.writeFileString(`${wrongCaseDestination}/README.md`, "different case"); | ||
| 255 | yield* fs.writeFileString(`${wrongUserDestination}/README.md`, "different user"); | ||
| 256 | yield* fs.writeFileString(`${absoluteSshDestination}/README.md`, "absolute SSH path"); | ||
| 257 | const cloneCalls: Array<ReadonlyArray<string>> = []; | ||
| 258 | |||
| 259 | yield* Effect.gen(function* () { | ||
| 260 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | ||
| 261 | const recovered = yield* service.cloneRepository({ | ||
| 262 | remoteUrl: CLONE_URLS.url, | ||
| 263 | destinationPath: matchingDestination, | ||
| 264 | }); | ||
| 265 | assert.strictEqual(recovered.cwd, matchingDestination); | ||
| 266 | assert.deepStrictEqual(cloneCalls, []); | ||
| 267 | |||
| 268 | const mismatch = yield* Effect.flip( | ||
| 269 | service.cloneRepository({ | ||
| 270 | remoteUrl: CLONE_URLS.url, | ||
| 271 | destinationPath: unrelatedDestination, | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | assert.strictEqual( | ||
| 275 | mismatch.detail, | ||
| 276 | "Destination path already exists and is not a clone of the requested repository.", | ||
| 277 | ); | ||
| 278 | |||
| 279 | for (const [destinationPath, remoteUrl] of [ | ||
| 280 | [wrongPortDestination, "https://git.example:8444/org/repo.git"], | ||
| 281 | [wrongCaseDestination, "https://git.example/org/repo.git"], | ||
| 282 | [wrongUserDestination, "bob@git.example:repo.git"], | ||
| 283 | [absoluteSshDestination, "git@git.example:repo.git"], | ||
| 284 | ] as const) { | ||
| 285 | const strictMismatch = yield* Effect.flip( | ||
| 286 | service.cloneRepository({ remoteUrl, destinationPath }), | ||
| 287 | ); | ||
| 288 | assert.strictEqual( | ||
| 289 | strictMismatch.detail, | ||
| 290 | "Destination path already exists and is not a clone of the requested repository.", | ||
| 291 | ); | ||
| 292 | } | ||
| 293 | }).pipe( | ||
| 294 | Effect.provide( | ||
| 295 | makeLayer({ | ||
| 296 | git: { | ||
| 297 | execute: (input) => { | ||
| 298 | if (input.args[0] === "rev-parse") { | ||
| 299 | return Effect.succeed( | ||
| 300 | processOutputWithStdout( | ||
| 301 | input.cwd === matchingDestination ? matchingDestination : unrelatedDestination, | ||
| 302 | ), | ||
| 303 | ); | ||
| 304 | } | ||
| 305 | if (input.args[0] === "for-each-ref") { | ||
| 306 | return Effect.succeed(processOutputWithStdout(input.cwd)); | ||
| 307 | } | ||
| 308 | if (input.args[0] === "status") return Effect.succeed(processOutput()); | ||
| 309 | cloneCalls.push(input.args); | ||
| 310 | return Effect.succeed(processOutput()); | ||
| 311 | }, | ||
| 312 | readConfigValue: (cwd) => | ||
| 313 | Effect.succeed( | ||
| 314 | cwd === matchingDestination | ||
| 315 | ? CLONE_URLS.url | ||
| 316 | : cwd === wrongPortDestination | ||
| 317 | ? "https://git.example:8443/org/repo.git" | ||
| 318 | : cwd === wrongCaseDestination | ||
| 319 | ? "https://git.example/org/Repo.git" | ||
| 320 | : cwd === wrongUserDestination | ||
| 321 | ? "alice@git.example:repo.git" | ||
| 322 | : cwd === absoluteSshDestination | ||
| 323 | ? "ssh://git@git.example/repo.git" | ||
| 324 | : "https://github.com/acme/other.git", | ||
| 325 | ), | ||
| 326 | }, | ||
| 327 | }), | ||
| 328 | ), | ||
| 329 | ); | ||
| 330 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 331 | ); | ||
| 332 | |||
| 333 | it.effect("recovers a real completed clone after a lost result and rejects unsafe adoption", () => | ||
| 334 | Effect.gen(function* () { | ||
| 335 | const fs = yield* FileSystem.FileSystem; | ||
| 336 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 337 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | ||
| 338 | const parent = yield* fs.makeTempDirectoryScoped({ | ||
| 339 | prefix: "t3-source-control-real-recovery-", | ||
| 340 | }); | ||
| 341 | const sourcePath = `${parent}/source`; | ||
| 342 | const otherSourcePath = `${parent}/other-source`; | ||
| 343 | const destinationPath = `${parent}/destination`; | ||
| 344 | const incompletePath = `${parent}/incomplete`; | ||
| 345 | const unrelatedCleanPath = `${parent}/unrelated-clean`; | ||
| 346 | |||
| 347 | const initializeRepository = (cwd: string, contents: string) => | ||
| 348 | Effect.gen(function* () { | ||
| 349 | yield* fs.makeDirectory(cwd); | ||
| 350 | yield* git.execute({ operation: "test.init", cwd, args: ["init"] }); | ||
| 351 | yield* git.execute({ | ||
| 352 | operation: "test.config.email", | ||
| 353 | cwd, | ||
| 354 | args: ["config", "user.email", "tests@t3.codes"], | ||
| 355 | }); | ||
| 356 | yield* git.execute({ | ||
| 357 | operation: "test.config.name", | ||
| 358 | cwd, | ||
| 359 | args: ["config", "user.name", "T3 Tests"], | ||
| 360 | }); | ||
| 361 | yield* fs.writeFileString(`${cwd}/README.md`, contents); | ||
| 362 | yield* git.execute({ operation: "test.add", cwd, args: ["add", "README.md"] }); | ||
| 363 | yield* git.execute({ operation: "test.commit", cwd, args: ["commit", "-m", "initial"] }); | ||
| 364 | }); | ||
| 365 | |||
| 366 | yield* initializeRepository(sourcePath, "source"); | ||
| 367 | yield* initializeRepository(otherSourcePath, "other source"); | ||
| 368 | |||
| 369 | const cloned = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); | ||
| 370 | assert.strictEqual(cloned.cwd, destinationPath); | ||
| 371 | |||
| 372 | // Simulate the caller losing the successful clone result before project registration. | ||
| 373 | const recovered = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); | ||
| 374 | assert.strictEqual(recovered.cwd, destinationPath); | ||
| 375 | |||
| 376 | const unrelated = yield* Effect.flip( | ||
| 377 | service.cloneRepository({ remoteUrl: otherSourcePath, destinationPath }), | ||
| 378 | ); | ||
| 379 | assert.strictEqual( | ||
| 380 | unrelated.detail, | ||
| 381 | "Destination path already exists and is not a clone of the requested repository.", | ||
| 382 | ); | ||
| 383 | |||
| 384 | yield* initializeRepository(unrelatedCleanPath, "unrelated history"); | ||
| 385 | yield* git.execute({ | ||
| 386 | operation: "test.unrelated.remote", | ||
| 387 | cwd: unrelatedCleanPath, | ||
| 388 | args: ["remote", "add", "origin", sourcePath], | ||
| 389 | }); | ||
| 390 | const unrelatedHistory = yield* Effect.flip( | ||
| 391 | service.cloneRepository({ remoteUrl: sourcePath, destinationPath: unrelatedCleanPath }), | ||
| 392 | ); | ||
| 393 | assert.strictEqual( | ||
| 394 | unrelatedHistory.detail, | ||
| 395 | "Destination path already exists and is not a clone of the requested repository.", | ||
| 396 | ); | ||
| 397 | |||
| 398 | yield* git.execute({ | ||
| 399 | operation: "test.incomplete.clone", | ||
| 400 | cwd: parent, | ||
| 401 | args: ["clone", "--no-checkout", sourcePath, "incomplete"], | ||
| 402 | }); | ||
| 403 | const incomplete = yield* Effect.flip( | ||
| 404 | service.cloneRepository({ remoteUrl: sourcePath, destinationPath: incompletePath }), | ||
| 405 | ); | ||
| 406 | assert.strictEqual( | ||
| 407 | incomplete.detail, | ||
| 408 | "Destination path already exists and is not a clone of the requested repository.", | ||
| 409 | ); | ||
| 410 | }).pipe(Effect.provide(makeRealGitLayer())), | ||
| 411 | ); | ||
| 412 | |||
| 413 | it.effect("serializes overlapping clones to one destination and reuses the verified result", () => | ||
| 414 | Effect.gen(function* () { | ||
| 415 | const fs = yield* FileSystem.FileSystem; | ||
| 416 | const parent = yield* fs.makeTempDirectoryScoped({ | ||
| 417 | prefix: "t3-source-control-overlap-clone-", | ||
| 418 | }); | ||
| 419 | const realParent = `${parent}/real`; | ||
| 420 | const aliasParent = `${parent}/alias`; | ||
| 421 | yield* fs.makeDirectory(realParent); | ||
| 422 | yield* fs.symlink(realParent, aliasParent); | ||
| 423 | const destinationPath = `${realParent}/t3code`; | ||
| 424 | const aliasDestinationPath = `${aliasParent}/t3code`; | ||
| 425 | const cloneEntered = yield* Deferred.make<void>(); | ||
| 426 | const allowClone = yield* Deferred.make<void>(); | ||
| 427 | let cloneCount = 0; | ||
| 428 | |||
| 429 | yield* Effect.gen(function* () { | ||
| 430 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | ||
| 431 | const first = yield* Effect.forkChild( | ||
| 432 | service.cloneRepository({ remoteUrl: CLONE_URLS.url, destinationPath }), | ||
| 433 | { startImmediately: true }, | ||
| 434 | ); | ||
| 435 | yield* Deferred.await(cloneEntered); | ||
| 436 | const second = yield* Effect.forkChild( | ||
| 437 | service.cloneRepository({ | ||
| 438 | remoteUrl: CLONE_URLS.url, | ||
| 439 | destinationPath: aliasDestinationPath, | ||
| 440 | }), | ||
| 441 | { startImmediately: true }, | ||
| 442 | ); | ||
| 443 | yield* Deferred.succeed(allowClone, undefined); | ||
| 444 | const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); | ||
| 445 | assert.deepStrictEqual( | ||
| 446 | results.map((result) => result.cwd), | ||
| 447 | [destinationPath, aliasDestinationPath], | ||
| 448 | ); | ||
| 449 | assert.strictEqual(cloneCount, 1); | ||
| 450 | }).pipe( | ||
| 451 | Effect.provide( | ||
| 452 | makeLayer({ | ||
| 453 | git: { | ||
| 454 | execute: (input) => { | ||
| 455 | if (input.args[0] === "clone") { | ||
| 456 | cloneCount += 1; | ||
| 457 | return fs | ||
| 458 | .makeDirectory(destinationPath) | ||
| 459 | .pipe( | ||
| 460 | Effect.andThen(fs.writeFileString(`${destinationPath}/README.md`, "cloned")), | ||
| 461 | Effect.andThen(Deferred.succeed(cloneEntered, undefined)), | ||
| 462 | Effect.andThen(Deferred.await(allowClone)), | ||
| 463 | Effect.as(processOutput()), | ||
| 464 | Effect.orDie, | ||
| 465 | ); | ||
| 466 | } | ||
| 467 | if (input.args[0] === "status") return Effect.succeed(processOutput()); | ||
| 468 | return Effect.succeed(processOutputWithStdout(destinationPath)); | ||
| 469 | }, | ||
| 470 | readConfigValue: () => Effect.succeed(CLONE_URLS.url), | ||
| 471 | }, | ||
| 194 | }), | 472 | }), |
| 195 | ), | 473 | ), |
| 196 | ); | 474 | ); |