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 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | −import * as Path from "effect/Path"; | |
| 7 | 6 | import * as Layer from "effect/Layer"; |
| 8 | 7 | import * as PlatformError from "effect/PlatformError"; |
| 9 | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 9 | |
| 11 | 10 | import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; |
| 12 | 11 | |
| 12 | +import * as Deferred from "effect/Deferred"; | |
| 13 | +import * as Fiber from "effect/Fiber"; | |
| 13 | 14 | import * as ServerConfig from "../config.ts"; |
| 14 | 15 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 16 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 15 | 17 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 16 | 18 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 17 | 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 | 63 | function makeLayer(input: { |
| 58 | 64 | readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; |
| 59 | 65 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; |
| @@ -95,6 +101,23 @@ function makeLayer(input: { · 35 unmodified lines | ||
| 95 | 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 | 121 | it.effect("looks up repositories through the requested provider without search", () => { |
| 99 | 122 | const calls: Array<{ cwd: string; repository: string }> = []; |
| 100 | 123 | const provider = makeProvider({ |
| @@ -154,12 +177,20 @@ it.effect("preserves provider failures without deriving the repository message f · 53 unmodified lines | ||
| 154 | 177 | it.effect("clones a looked-up repository into the requested destination", () => |
| 155 | 178 | Effect.gen(function* () { |
| 156 | 179 | const fs = yield* FileSystem.FileSystem; |
| 157 | − const path = yield* Path.Path; | |
| 158 | 180 | const parent = yield* fs.makeTempDirectoryScoped({ |
| 159 | 181 | prefix: "t3-source-control-clone-parent-", |
| 160 | 182 | }); |
| 161 | − const destinationPath = path.join(parent, "t3code"); | |
| 183 | + const destinationPath = `${parent}/missing/nested/t3code`; | |
| 162 | 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 | 195 | yield* Effect.gen(function* () { |
| 165 | 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 | 209 | assert.deepStrictEqual(cloneCalls, [ |
| 179 | 210 | { |
| 180 | − cwd: parent, | |
| 211 | + cwd: `${parent}/missing/nested`, | |
| 181 | 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 | 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 | ); |