| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as NodePath from "@effect/platform-node/NodePath"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { assert, it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import { ChildProcessSpawner } from "effect/process"; |
| 12 | |
| 13 | import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; |
| 14 | |
| 15 | import * as ServerConfig from "../config.ts"; |
| 16 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 17 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; |
| 18 | import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider"; |
| 19 | import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 20 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 21 | import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; |
| 22 | |
| 23 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); |
| 24 | |
| 25 | const CLONE_URLS = { |
| 26 | nameWithOwner: "octocat/t3code", |
| 27 | url: "https://github.com/octocat/t3code", |
| 28 | sshUrl: "git@github.com:octocat/t3code.git", |
| 29 | }; |
| 30 | |
| 31 | function makeProvider( |
| 32 | overrides: Partial<SourceControlProvider.SourceControlProvider["Service"]> = {}, |
| 33 | ): SourceControlProvider.SourceControlProvider["Service"] { |
| 34 | const unsupported = (operation: string) => |
| 35 | Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< |
| 36 | never, |
| 37 | SourceControlProviderError |
| 38 | >; |
| 39 | |
| 40 | return { |
| 41 | kind: SourceControlProviderKind.make("github"), |
| 42 | listChangeRequests: () => unsupported("listChangeRequests"), |
| 43 | getChangeRequest: () => unsupported("getChangeRequest"), |
| 44 | createChangeRequest: () => unsupported("createChangeRequest"), |
| 45 | getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), |
| 46 | createRepository: () => Effect.succeed(CLONE_URLS), |
| 47 | getDefaultBranch: () => Effect.succeed(null), |
| 48 | checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), |
| 49 | ...overrides, |
| 50 | }; |
| 51 | } |
| 52 | |
| 53 | function processOutput(): GitVcsDriver.ExecuteGitResult { |
| 54 | return { |
| 55 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 56 | stdout: "", |
| 57 | stderr: "", |
| 58 | stdoutTruncated: false, |
| 59 | stderrTruncated: false, |
| 60 | }; |
| 61 | } |
| 62 | |
| 63 | function layer(input: { |
| 64 | readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; |
| 65 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; |
| 66 | readonly fileSystem?: FileSystem.FileSystem; |
| 67 | }) { |
| 68 | const layerService = SourceControlRepositoryService.layer.pipe( |
| 69 | Layer.provide( |
| 70 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ |
| 71 | resolveLink: () => undefined, |
| 72 | get: () => Effect.succeed(input.provider ?? makeProvider()), |
| 73 | }), |
| 74 | ), |
| 75 | Layer.provide( |
| 76 | Layer.mock(GitVcsDriver.GitVcsDriver)({ |
| 77 | execute: () => Effect.succeed(processOutput()), |
| 78 | ensureRemote: () => Effect.succeed("origin"), |
| 79 | pushCurrentBranch: () => |
| 80 | Effect.succeed({ |
| 81 | status: "pushed" as const, |
| 82 | branch: "feature/remote-v1", |
| 83 | upstreamBranch: "origin/feature/remote-v1", |
| 84 | setUpstream: true, |
| 85 | }), |
| 86 | ...input.git, |
| 87 | }), |
| 88 | ), |
| 89 | Layer.provide( |
| 90 | ServerConfig.layerTest( |
| 91 | process.cwd(), |
| 92 | input.fileSystem ? "/tmp/t3-source-control-repos" : { prefix: "t3-source-control-repos-" }, |
| 93 | ), |
| 94 | ), |
| 95 | ); |
| 96 | |
| 97 | return input.fileSystem |
| 98 | ? layerService.pipe( |
| 99 | Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)), |
| 100 | Layer.provideMerge(NodePath.layer), |
| 101 | ) |
| 102 | : layerService.pipe(Layer.provideMerge(NodeServices.layer)); |
| 103 | } |
| 104 | |
| 105 | it.effect("looks up repositories through the requested provider without search", () => { |
| 106 | const calls: Array<{ cwd: string; repository: string }> = []; |
| 107 | const provider = makeProvider({ |
| 108 | getRepositoryCloneUrls: (input) => |
| 109 | Effect.sync(() => { |
| 110 | calls.push({ cwd: input.cwd, repository: input.repository }); |
| 111 | return CLONE_URLS; |
| 112 | }), |
| 113 | }); |
| 114 | |
| 115 | return Effect.gen(function* () { |
| 116 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 117 | const result = yield* service.lookupRepository({ |
| 118 | provider: SourceControlProviderKind.make("github"), |
| 119 | repository: "octocat/t3code", |
| 120 | cwd: "/workspace", |
| 121 | }); |
| 122 | |
| 123 | assert.deepStrictEqual(result, { |
| 124 | provider: SourceControlProviderKind.make("github"), |
| 125 | ...CLONE_URLS, |
| 126 | }); |
| 127 | assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); |
| 128 | }).pipe(Effect.provide(layer({ provider }))); |
| 129 | }); |
| 130 | |
| 131 | it.effect("preserves provider failures without deriving the repository message from them", () => { |
| 132 | const providerCause = new SourceControlProviderError({ |
| 133 | provider: SourceControlProviderKind.make("github"), |
| 134 | operation: "getRepositoryCloneUrls", |
| 135 | cwd: "/workspace", |
| 136 | repository: "octocat/t3code", |
| 137 | detail: "credential token abc123 was rejected", |
| 138 | }); |
| 139 | const provider = makeProvider({ |
| 140 | getRepositoryCloneUrls: () => Effect.fail(providerCause), |
| 141 | }); |
| 142 | |
| 143 | return Effect.gen(function* () { |
| 144 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 145 | const error = yield* Effect.flip( |
| 146 | service.lookupRepository({ |
| 147 | provider: SourceControlProviderKind.make("github"), |
| 148 | repository: "octocat/t3code", |
| 149 | cwd: "/workspace", |
| 150 | }), |
| 151 | ); |
| 152 | |
| 153 | assert.strictEqual(error.provider, "github"); |
| 154 | assert.strictEqual(error.operation, "lookupRepository"); |
| 155 | assert.strictEqual(error.detail, "The source control operation could not be completed."); |
| 156 | assert.strictEqual( |
| 157 | error.message, |
| 158 | "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.", |
| 159 | ); |
| 160 | assert.strictEqual(error.cause, providerCause); |
| 161 | }).pipe(Effect.provide(layer({ provider }))); |
| 162 | }); |
| 163 | |
| 164 | it.effect( |
| 165 | "reports the safe Bitbucket locator detail through repository lookup and publishing", |
| 166 | () => |
| 167 | Effect.gen(function* () { |
| 168 | const locatorCause = new BitbucketApi.BitbucketRepositoryLocatorError({ |
| 169 | repository: "credential-token-abc123", |
| 170 | }); |
| 171 | const provider = yield* BitbucketSourceControlProvider.make.pipe( |
| 172 | Effect.provide( |
| 173 | Layer.mock(BitbucketApi.BitbucketApi)({ |
| 174 | getRepositoryCloneUrls: () => Effect.fail(locatorCause), |
| 175 | createRepository: () => Effect.fail(locatorCause), |
| 176 | }), |
| 177 | ), |
| 178 | ); |
| 179 | |
| 180 | yield* Effect.gen(function* () { |
| 181 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 182 | const input = { |
| 183 | provider: SourceControlProviderKind.make("bitbucket"), |
| 184 | repository: locatorCause.repository, |
| 185 | cwd: "/workspace", |
| 186 | }; |
| 187 | for (const operation of [ |
| 188 | service.lookupRepository(input).pipe(Effect.asVoid), |
| 189 | service.publishRepository({ ...input, visibility: "private" }).pipe(Effect.asVoid), |
| 190 | ]) { |
| 191 | const error = yield* Effect.flip(operation); |
| 192 | |
| 193 | assert.strictEqual(error.provider, "bitbucket"); |
| 194 | assert.strictEqual( |
| 195 | error.detail, |
| 196 | "Bitbucket repositories must be specified as workspace/repository.", |
| 197 | ); |
| 198 | assert.notInclude(error.message, locatorCause.repository); |
| 199 | assert.instanceOf(error.cause, SourceControlProviderError); |
| 200 | assert.strictEqual( |
| 201 | isSourceControlProviderError(error.cause) ? error.cause.cause : null, |
| 202 | locatorCause, |
| 203 | ); |
| 204 | } |
| 205 | }).pipe(Effect.provide(layer({ provider }))); |
| 206 | }), |
| 207 | ); |
| 208 | |
| 209 | it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => { |
| 210 | const provider = makeProvider({ |
| 211 | kind: SourceControlProviderKind.make("bitbucket"), |
| 212 | getRepositoryCloneUrls: () => |
| 213 | Effect.fail( |
| 214 | new SourceControlProviderError({ |
| 215 | provider: SourceControlProviderKind.make("bitbucket"), |
| 216 | operation: "getRepositoryCloneUrls", |
| 217 | cwd: "/workspace", |
| 218 | detail: "credential token abc123 was rejected", |
| 219 | cause: { |
| 220 | _tag: "BitbucketRepositoryLocatorError", |
| 221 | repository: "t3code", |
| 222 | detail: "credential token abc123 was rejected", |
| 223 | }, |
| 224 | }), |
| 225 | ), |
| 226 | }); |
| 227 | |
| 228 | return Effect.gen(function* () { |
| 229 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 230 | const error = yield* Effect.flip( |
| 231 | service.lookupRepository({ |
| 232 | provider: SourceControlProviderKind.make("bitbucket"), |
| 233 | repository: "t3code", |
| 234 | }), |
| 235 | ); |
| 236 | |
| 237 | assert.strictEqual(error.detail, "The source control operation could not be completed."); |
| 238 | assert.notInclude(error.message, "abc123"); |
| 239 | }).pipe(Effect.provide(layer({ provider }))); |
| 240 | }); |
| 241 | |
| 242 | it.effect("keeps arbitrary Bitbucket request failures out of the repository message", () => |
| 243 | Effect.gen(function* () { |
| 244 | const cause = new BitbucketApi.BitbucketRequestError({ |
| 245 | operation: "getRepository", |
| 246 | cause: new Error("credential token abc123 was rejected"), |
| 247 | }); |
| 248 | const provider = yield* BitbucketSourceControlProvider.make.pipe( |
| 249 | Effect.provide( |
| 250 | Layer.mock(BitbucketApi.BitbucketApi)({ |
| 251 | getRepositoryCloneUrls: () => Effect.fail(cause), |
| 252 | }), |
| 253 | ), |
| 254 | ); |
| 255 | |
| 256 | yield* Effect.gen(function* () { |
| 257 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 258 | const error = yield* Effect.flip( |
| 259 | service.lookupRepository({ |
| 260 | provider: SourceControlProviderKind.make("bitbucket"), |
| 261 | repository: "pingdotgg/t3code", |
| 262 | cwd: "/workspace", |
| 263 | }), |
| 264 | ); |
| 265 | |
| 266 | assert.strictEqual(error.detail, "The source control operation could not be completed."); |
| 267 | assert.notInclude(error.message, "abc123"); |
| 268 | assert.notInclude(error.message, cause.detail); |
| 269 | }).pipe(Effect.provide(layer({ provider }))); |
| 270 | }), |
| 271 | ); |
| 272 | |
| 273 | it.effect("clones a looked-up repository into the requested destination", () => |
| 274 | Effect.gen(function* () { |
| 275 | const fs = yield* FileSystem.FileSystem; |
| 276 | const path = yield* Path.Path; |
| 277 | const parent = yield* fs.makeTempDirectoryScoped({ |
| 278 | prefix: "t3-source-control-clone-parent-", |
| 279 | }); |
| 280 | const destinationPath = path.join(parent, "t3code"); |
| 281 | const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; |
| 282 | |
| 283 | yield* Effect.gen(function* () { |
| 284 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 285 | const result = yield* service.cloneRepository({ |
| 286 | provider: SourceControlProviderKind.make("github"), |
| 287 | repository: "octocat/t3code", |
| 288 | destinationPath, |
| 289 | protocol: "https", |
| 290 | }); |
| 291 | |
| 292 | assert.deepStrictEqual(result, { |
| 293 | cwd: destinationPath, |
| 294 | remoteUrl: CLONE_URLS.url, |
| 295 | repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, |
| 296 | }); |
| 297 | assert.deepStrictEqual(cloneCalls, [ |
| 298 | { |
| 299 | cwd: parent, |
| 300 | args: ["clone", "--progress", "--", CLONE_URLS.url, "t3code"], |
| 301 | }, |
| 302 | ]); |
| 303 | }).pipe( |
| 304 | Effect.provide( |
| 305 | layer({ |
| 306 | git: { |
| 307 | execute: (input) => |
| 308 | Effect.sync(() => { |
| 309 | cloneCalls.push({ cwd: input.cwd, args: input.args }); |
| 310 | return processOutput(); |
| 311 | }), |
| 312 | }, |
| 313 | }), |
| 314 | ), |
| 315 | ); |
| 316 | }).pipe(Effect.provide(NodeServices.layer)), |
| 317 | ); |
| 318 | |
| 319 | it.effect.each(["--bare", "source.git"])( |
| 320 | "clones a local repository named %s as a working tree", |
| 321 | (repositoryName) => |
| 322 | Effect.gen(function* () { |
| 323 | const fs = yield* FileSystem.FileSystem; |
| 324 | const path = yield* Path.Path; |
| 325 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 326 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-clone-options-" }); |
| 327 | yield* git.execute({ |
| 328 | operation: "test.init", |
| 329 | cwd: parent, |
| 330 | args: ["init", "--bare", path.join(parent, repositoryName)], |
| 331 | }); |
| 332 | const destinationPath = path.join(parent, "checkout"); |
| 333 | yield* Effect.gen(function* () { |
| 334 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 335 | const result = yield* service.cloneRepository({ |
| 336 | remoteUrl: repositoryName, |
| 337 | destinationPath, |
| 338 | }); |
| 339 | assert.strictEqual(result.cwd, destinationPath); |
| 340 | assert.isTrue(yield* fs.exists(path.join(destinationPath, ".git"))); |
| 341 | }).pipe(Effect.provide(layer({ git: { execute: git.execute } }))); |
| 342 | }).pipe( |
| 343 | Effect.provide( |
| 344 | GitVcsDriver.layer.pipe( |
| 345 | Layer.provide( |
| 346 | ServerConfig.layerTest(process.cwd(), { |
| 347 | prefix: "t3-clone-options-config-", |
| 348 | }), |
| 349 | ), |
| 350 | Layer.provideMerge(NodeServices.layer), |
| 351 | ), |
| 352 | ), |
| 353 | ), |
| 354 | ); |
| 355 | |
| 356 | it.effect("reports clone progress from git's stderr and keeps its error text on failure", () => |
| 357 | Effect.gen(function* () { |
| 358 | const fs = yield* FileSystem.FileSystem; |
| 359 | const path = yield* Path.Path; |
| 360 | const parent = yield* fs.makeTempDirectoryScoped({ |
| 361 | prefix: "t3-source-control-clone-progress-", |
| 362 | }); |
| 363 | const destinationPath = path.join(parent, "t3code"); |
| 364 | const progress: Array<{ stage: string; percent: number | null; detail: string | null }> = []; |
| 365 | |
| 366 | const stderrLines = [ |
| 367 | "Cloning into 't3code'...", |
| 368 | "remote: Enumerating objects: 10, done.", |
| 369 | "Receiving objects: 40% (4/10), 1.00 MiB | 2.00 MiB/s", |
| 370 | "Receiving objects: 100% (10/10), 2.50 MiB | 2.00 MiB/s, done.", |
| 371 | "fatal: early EOF", |
| 372 | "fatal: unable to access 'https://user:s3c@ret@github.com/octocat/t3code.git/': could not resolve host", |
| 373 | ]; |
| 374 | const error = yield* Effect.gen(function* () { |
| 375 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 376 | return yield* Effect.flip( |
| 377 | service.cloneRepository( |
| 378 | { remoteUrl: CLONE_URLS.sshUrl, destinationPath }, |
| 379 | { onProgress: (line) => Effect.sync(() => void progress.push(line)) }, |
| 380 | ), |
| 381 | ); |
| 382 | }).pipe( |
| 383 | Effect.provide( |
| 384 | layer({ |
| 385 | git: { |
| 386 | execute: (input) => |
| 387 | Effect.gen(function* () { |
| 388 | for (const line of stderrLines) { |
| 389 | yield* input.progress?.onStderrLine?.(line) ?? Effect.void; |
| 390 | } |
| 391 | return yield* new GitCommandError({ |
| 392 | operation: input.operation, |
| 393 | command: "git", |
| 394 | cwd: input.cwd, |
| 395 | detail: "Git command exited with a non-zero status.", |
| 396 | exitCode: 128, |
| 397 | }); |
| 398 | }), |
| 399 | }, |
| 400 | }), |
| 401 | ), |
| 402 | ); |
| 403 | |
| 404 | assert.deepStrictEqual(progress, [ |
| 405 | { stage: "counting", percent: null, detail: null }, |
| 406 | { stage: "receiving", percent: 40, detail: "1.00 MiB | 2.00 MiB/s" }, |
| 407 | { stage: "receiving", percent: 100, detail: "2.50 MiB | 2.00 MiB/s" }, |
| 408 | ]); |
| 409 | // Git echoes the remote in some failures; the credentials must not follow. |
| 410 | assert.strictEqual( |
| 411 | error.detail, |
| 412 | "fatal: early EOF fatal: unable to access 'https://github.com/octocat/t3code.git/': could not resolve host", |
| 413 | ); |
| 414 | }).pipe(Effect.provide(NodeServices.layer)), |
| 415 | ); |
| 416 | |
| 417 | it.effect("strips embedded credentials from the remote URL it reports", () => |
| 418 | Effect.gen(function* () { |
| 419 | const fs = yield* FileSystem.FileSystem; |
| 420 | const path = yield* Path.Path; |
| 421 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact-" }); |
| 422 | const destinationPath = path.join(parent, "t3code"); |
| 423 | const cloneArgs: Array<ReadonlyArray<string>> = []; |
| 424 | const result = yield* Effect.gen(function* () { |
| 425 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 426 | return yield* service.prepareClone({ |
| 427 | remoteUrl: "https://user:s3cret@github.com/octocat/t3code.git", |
| 428 | destinationPath, |
| 429 | }); |
| 430 | }).pipe( |
| 431 | Effect.provide( |
| 432 | layer({ |
| 433 | git: { |
| 434 | execute: (input) => |
| 435 | Effect.sync(() => { |
| 436 | cloneArgs.push(input.args); |
| 437 | return processOutput(); |
| 438 | }), |
| 439 | }, |
| 440 | }), |
| 441 | ), |
| 442 | ); |
| 443 | assert.equal(result.remoteUrl, "https://github.com/octocat/t3code.git"); |
| 444 | // Git itself still receives the credentials. |
| 445 | assert.equal(result.cloneUrl, "https://user:s3cret@github.com/octocat/t3code.git"); |
| 446 | }).pipe(Effect.provide(NodeServices.layer)), |
| 447 | ); |
| 448 | |
| 449 | it.effect("discards only a directory git wrote to", () => |
| 450 | Effect.gen(function* () { |
| 451 | const fs = yield* FileSystem.FileSystem; |
| 452 | const path = yield* Path.Path; |
| 453 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-discard-" }); |
| 454 | const partial = path.join(parent, "partial"); |
| 455 | yield* fs.makeDirectory(path.join(partial, ".git"), { recursive: true }); |
| 456 | yield* fs.writeFileString(path.join(partial, "README.md"), "half"); |
| 457 | const foreign = path.join(parent, "foreign"); |
| 458 | yield* fs.makeDirectory(foreign); |
| 459 | yield* fs.writeFileString(path.join(foreign, "notes.txt"), "mine"); |
| 460 | |
| 461 | // A file where the directory should be must not be removed either. |
| 462 | const replaced = path.join(parent, "replaced"); |
| 463 | yield* fs.writeFileString(replaced, "not a directory"); |
| 464 | |
| 465 | yield* Effect.gen(function* () { |
| 466 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 467 | yield* service.discardClone(partial); |
| 468 | const error = yield* Effect.flip(service.discardClone(foreign)); |
| 469 | assert.include(error.detail, "not from the clone"); |
| 470 | const replacedError = yield* Effect.flip(service.discardClone(replaced)); |
| 471 | assert.include(replacedError.detail, "could not be inspected"); |
| 472 | // A destination that never got created is nothing to discard. |
| 473 | yield* service.discardClone(path.join(parent, "missing")); |
| 474 | }).pipe(Effect.provide(layer({}))); |
| 475 | |
| 476 | // The partial clone is emptied but its directory (the workspace root) stays. |
| 477 | assert.deepStrictEqual(yield* fs.readDirectory(partial), []); |
| 478 | assert.deepStrictEqual(yield* fs.readDirectory(foreign), ["notes.txt"]); |
| 479 | assert.strictEqual(yield* fs.readFileString(replaced), "not a directory"); |
| 480 | }).pipe(Effect.provide(NodeServices.layer)), |
| 481 | ); |
| 482 | |
| 483 | it.effect("redacts query tokens and userinfo containing '@' from reported URLs", () => |
| 484 | Effect.gen(function* () { |
| 485 | const fs = yield* FileSystem.FileSystem; |
| 486 | const path = yield* Path.Path; |
| 487 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact2-" }); |
| 488 | yield* Effect.gen(function* () { |
| 489 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 490 | const query = yield* service.prepareClone({ |
| 491 | remoteUrl: "https://github.com/octocat/t3code.git?access_token=s3cret", |
| 492 | destinationPath: path.join(parent, "a"), |
| 493 | }); |
| 494 | assert.equal(query.remoteUrl, "https://github.com/octocat/t3code.git"); |
| 495 | const nested = yield* service.prepareClone({ |
| 496 | remoteUrl: "https://user:pa@rt@github.com/octocat/t3code.git", |
| 497 | destinationPath: path.join(parent, "b"), |
| 498 | }); |
| 499 | assert.equal(nested.remoteUrl, "https://github.com/octocat/t3code.git"); |
| 500 | }).pipe(Effect.provide(layer({}))); |
| 501 | }).pipe(Effect.provide(NodeServices.layer)), |
| 502 | ); |
| 503 | |
| 504 | it.effect("preserves destination probe failures instead of treating them as missing paths", () => { |
| 505 | const fileSystemCause = PlatformError.systemError({ |
| 506 | _tag: "PermissionDenied", |
| 507 | module: "FileSystem", |
| 508 | method: "exists", |
| 509 | pathOrDescriptor: "/restricted/t3code", |
| 510 | }); |
| 511 | |
| 512 | return Effect.gen(function* () { |
| 513 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 514 | const error = yield* Effect.flip( |
| 515 | service.cloneRepository({ |
| 516 | remoteUrl: CLONE_URLS.sshUrl, |
| 517 | destinationPath: "/restricted/t3code", |
| 518 | }), |
| 519 | ); |
| 520 | |
| 521 | assert.strictEqual(error.provider, "unknown"); |
| 522 | assert.strictEqual(error.operation, "cloneRepository"); |
| 523 | assert.strictEqual(error.cause, fileSystemCause); |
| 524 | }).pipe( |
| 525 | Effect.provide( |
| 526 | layer({ |
| 527 | fileSystem: FileSystem.makeNoop({ |
| 528 | exists: () => Effect.fail(fileSystemCause), |
| 529 | makeDirectory: () => Effect.void, |
| 530 | }), |
| 531 | }), |
| 532 | ), |
| 533 | ); |
| 534 | }); |
| 535 | |
| 536 | it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { |
| 537 | const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; |
| 538 | const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; |
| 539 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; |
| 540 | const provider = makeProvider({ |
| 541 | createRepository: (input) => |
| 542 | Effect.sync(() => { |
| 543 | createCalls.push({ |
| 544 | cwd: input.cwd, |
| 545 | repository: input.repository, |
| 546 | visibility: input.visibility, |
| 547 | }); |
| 548 | return CLONE_URLS; |
| 549 | }), |
| 550 | }); |
| 551 | |
| 552 | return Effect.gen(function* () { |
| 553 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 554 | const result = yield* service.publishRepository({ |
| 555 | cwd: "/workspace", |
| 556 | provider: SourceControlProviderKind.make("github"), |
| 557 | repository: "octocat/t3code", |
| 558 | visibility: "private", |
| 559 | remoteName: "origin", |
| 560 | protocol: "ssh", |
| 561 | }); |
| 562 | |
| 563 | assert.deepStrictEqual(result, { |
| 564 | repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, |
| 565 | remoteName: "origin", |
| 566 | remoteUrl: CLONE_URLS.sshUrl, |
| 567 | branch: "feature/remote-v1", |
| 568 | upstreamBranch: "origin/feature/remote-v1", |
| 569 | status: "pushed", |
| 570 | }); |
| 571 | assert.deepStrictEqual(createCalls, [ |
| 572 | { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, |
| 573 | ]); |
| 574 | assert.deepStrictEqual(remoteCalls, [ |
| 575 | { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, |
| 576 | ]); |
| 577 | assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); |
| 578 | }).pipe( |
| 579 | Effect.provide( |
| 580 | layer({ |
| 581 | provider, |
| 582 | git: { |
| 583 | ensureRemote: (input) => |
| 584 | Effect.sync(() => { |
| 585 | remoteCalls.push(input); |
| 586 | return "origin"; |
| 587 | }), |
| 588 | pushCurrentBranch: (cwd, _fallbackBranch, options) => |
| 589 | Effect.sync(() => { |
| 590 | pushCalls.push({ cwd, remoteName: options?.remoteName }); |
| 591 | return { |
| 592 | status: "pushed" as const, |
| 593 | branch: "feature/remote-v1", |
| 594 | upstreamBranch: "origin/feature/remote-v1", |
| 595 | setUpstream: true, |
| 596 | }; |
| 597 | }), |
| 598 | }, |
| 599 | }), |
| 600 | ), |
| 601 | ); |
| 602 | }); |
| 603 | |
| 604 | it.effect("publishes to the remote name returned by ensureRemote", () => { |
| 605 | const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; |
| 606 | |
| 607 | return Effect.gen(function* () { |
| 608 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 609 | const result = yield* service.publishRepository({ |
| 610 | cwd: "/workspace", |
| 611 | provider: SourceControlProviderKind.make("github"), |
| 612 | repository: "octocat/t3code", |
| 613 | visibility: "private", |
| 614 | remoteName: "origin", |
| 615 | protocol: "ssh", |
| 616 | }); |
| 617 | |
| 618 | assert.equal(result.remoteName, "origin-1"); |
| 619 | assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]); |
| 620 | }).pipe( |
| 621 | Effect.provide( |
| 622 | layer({ |
| 623 | git: { |
| 624 | ensureRemote: () => Effect.succeed("origin-1"), |
| 625 | pushCurrentBranch: (cwd, _fallbackBranch, options) => |
| 626 | Effect.sync(() => { |
| 627 | pushCalls.push({ cwd, remoteName: options?.remoteName }); |
| 628 | return { |
| 629 | status: "pushed" as const, |
| 630 | branch: "feature/remote-v1", |
| 631 | upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`, |
| 632 | setUpstream: true, |
| 633 | }; |
| 634 | }), |
| 635 | }, |
| 636 | }), |
| 637 | ), |
| 638 | ); |
| 639 | }); |
| 640 | |
| 641 | it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { |
| 642 | let pushCalls = 0; |
| 643 | return Effect.gen(function* () { |
| 644 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 645 | const result = yield* service.publishRepository({ |
| 646 | cwd: "/workspace", |
| 647 | provider: SourceControlProviderKind.make("github"), |
| 648 | repository: "octocat/t3code", |
| 649 | visibility: "private", |
| 650 | remoteName: "origin", |
| 651 | protocol: "ssh", |
| 652 | }); |
| 653 | |
| 654 | assert.deepStrictEqual(result, { |
| 655 | repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, |
| 656 | remoteName: "origin", |
| 657 | remoteUrl: CLONE_URLS.sshUrl, |
| 658 | branch: "main", |
| 659 | status: "remote_added", |
| 660 | }); |
| 661 | assert.strictEqual(pushCalls, 0); |
| 662 | }).pipe( |
| 663 | Effect.provide( |
| 664 | layer({ |
| 665 | git: { |
| 666 | execute: (input) => |
| 667 | input.args[0] === "rev-parse" |
| 668 | ? Effect.fail( |
| 669 | new GitCommandError({ |
| 670 | operation: input.operation, |
| 671 | command: "git rev-parse --verify HEAD", |
| 672 | cwd: input.cwd, |
| 673 | detail: "fatal: Needed a single revision", |
| 674 | }), |
| 675 | ) |
| 676 | : Effect.succeed(processOutput()), |
| 677 | statusDetails: () => |
| 678 | Effect.succeed({ |
| 679 | isRepo: true, |
| 680 | hasOriginRemote: true, |
| 681 | isDefaultBranch: true, |
| 682 | branch: "main", |
| 683 | upstreamRef: null, |
| 684 | hasWorkingTreeChanges: false, |
| 685 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 686 | hasUpstream: false, |
| 687 | aheadCount: 0, |
| 688 | behindCount: 0, |
| 689 | aheadOfDefaultCount: 0, |
| 690 | }), |
| 691 | pushCurrentBranch: () => |
| 692 | Effect.sync(() => { |
| 693 | pushCalls += 1; |
| 694 | return { |
| 695 | status: "pushed" as const, |
| 696 | branch: "main", |
| 697 | upstreamBranch: "origin/main", |
| 698 | setUpstream: true, |
| 699 | }; |
| 700 | }), |
| 701 | }, |
| 702 | }), |
| 703 | ), |
| 704 | ); |
| 705 | }); |