Skip to content
Source
Sign inSign up

SourceControlRepositoryService.test.ts

844 lines30.6 KB

1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as NodePath from "@effect/platform-node/NodePath";
3import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { assert, it } from "@effect/vitest";
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 Path from "effect/Path";
11import * as Layer from "effect/Layer";
12import * as PlatformError from "effect/PlatformError";
13import { ChildProcessSpawner } from "effect/process";
14import * as TestClock from "effect/testing/TestClock";
15​
16import {
17 GitCommandError,
18 SourceControlProviderError,
19 type SourceControlCloneRepositoryInput,
20} from "@t3tools/contracts";
21​
22import * as ServerConfig from "../config.ts";
23import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
24import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
25import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
26import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
27​
28const CLONE_URLS = {
29 nameWithOwner: "octocat/t3code",
30 url: "https://github.com/octocat/t3code",
31 sshUrl: "git@github.com:octocat/t3code.git",
32};
33​
34function makeProvider(
35 overrides: Partial<SourceControlProvider.SourceControlProvider["Service"]> = {},
36): SourceControlProvider.SourceControlProvider["Service"] {
37 const unsupported = (operation: string) =>
38 Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
39 never,
40 SourceControlProviderError
41 >;
42​
43 return {
44 kind: SourceControlProviderKind.make("github"),
45 listChangeRequests: () => unsupported("listChangeRequests"),
46 getChangeRequest: () => unsupported("getChangeRequest"),
47 createChangeRequest: () => unsupported("createChangeRequest"),
48 getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS),
49 createRepository: () => Effect.succeed(CLONE_URLS),
50 getDefaultBranch: () => Effect.succeed(null),
51 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
52 ...overrides,
53 };
55​
56function processOutput(): GitVcsDriver.ExecuteGitResult {
57 return {
58 exitCode: ChildProcessSpawner.ExitCode(0),
59 stdout: "",
60 stderr: "",
61 stdoutTruncated: false,
62 stderrTruncated: false,
63 };
65​
66function layer(input: {
67 readonly provider?: SourceControlProvider.SourceControlProvider["Service"];
68 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
69 readonly fileSystem?: FileSystem.FileSystem;
70}) {
71 const layerService = SourceControlRepositoryService.layer.pipe(
72 Layer.provide(
73 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
74 resolveLink: () => undefined,
75 get: () => Effect.succeed(input.provider ?? makeProvider()),
76 }),
77 ),
78 Layer.provide(
79 Layer.mock(GitVcsDriver.GitVcsDriver)({
80 execute: () => Effect.succeed(processOutput()),
81 ensureRemote: () => Effect.succeed("origin"),
82 pushCurrentBranch: () =>
83 Effect.succeed({
84 status: "pushed" as const,
85 branch: "feature/remote-v1",
86 upstreamBranch: "origin/feature/remote-v1",
87 setUpstream: true,
88 }),
89 ...input.git,
90 }),
91 ),
92 Layer.provide(
93 ServerConfig.layerTest(
94 process.cwd(),
95 input.fileSystem ? "/tmp/t3-source-control-repos" : { prefix: "t3-source-control-repos-" },
96 ),
97 ),
98 );
99​
100 return input.fileSystem
101 ? layerService.pipe(
102 Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)),
103 Layer.provideMerge(NodePath.layer),
104 )
105 : layerService.pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
107​
108const lookupFailingWith = (cause: unknown, detail: string) =>
109 Effect.gen(function* () {
110 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
111 return yield* service
112 .lookupRepository({ provider: SourceControlProviderKind.make("bitbucket"), repository: "x" })
113 .pipe(Effect.flip);
114 }).pipe(
115 Effect.provide(
116 layer({
117 provider: makeProvider({
118 getRepositoryCloneUrls: (input) =>
119 Effect.fail(
120 new SourceControlProviderError({
121 provider: SourceControlProviderKind.make("bitbucket"),
122 operation: "getRepositoryCloneUrls",
123 cwd: input.cwd,
124 detail,
125 cause,
126 }),
127 ),
128 }),
129 }),
130 ),
131 );
132​
133it.effect("tells the user a host's fixed explanation of a failed repository lookup", () =>
134 Effect.gen(function* () {
135 const error = yield* lookupFailingWith(
136 { userFacingDetail: "Repositories are named workspace/repository." },
137 "Repositories are named workspace/repository.",
138 );
139 assert.strictEqual(error.detail, "Repositories are named workspace/repository.");
140 }),
141);
142​
143it.effect("keeps a host's raw response text out of a failed repository lookup", () =>
144 Effect.gen(function* () {
145 const error = yield* lookupFailingWith(
146 new Error("upstream"),
147 "<html>internal token=abc</html>",
148 );
149 assert.strictEqual(error.detail, "The source control operation could not be completed.");
150 }),
151);
152​
153it.effect("clones a looked-up repository after creating missing parent directories", () =>
154 Effect.gen(function* () {
155 const fs = yield* FileSystem.FileSystem;
156 const path = yield* Path.Path;
157 const temporaryDirectory = yield* fs.makeTempDirectoryScoped({
158 prefix: "t3-source-control-clone-parent-",
159 });
160 const parent = path.join(temporaryDirectory, "missing", "nested");
161 const destinationPath = path.join(parent, "t3code");
162 const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
163​
164 yield* Effect.gen(function* () {
165 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
166 const result = yield* service.cloneRepository({
167 provider: SourceControlProviderKind.make("github"),
168 repository: "octocat/t3code",
169 destinationPath,
170 protocol: "https",
171 });
172​
173 assert.deepStrictEqual(result, {
174 cwd: destinationPath,
175 remoteUrl: CLONE_URLS.url,
176 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
177 });
178 assert.deepStrictEqual(cloneCalls, [
179 {
180 cwd: parent,
181 args: ["clone", "--progress", "--", CLONE_URLS.url, "t3code"],
182 },
183 ]);
184 }).pipe(
185 Effect.provide(
186 layer({
187 git: {
188 execute: (input) =>
189 Effect.gen(function* () {
190 assert.isTrue(yield* fs.exists(parent).pipe(Effect.orDie));
191 cloneCalls.push({ cwd: input.cwd, args: input.args });
192 return processOutput();
193 }),
194 },
195 }),
196 ),
197 );
198 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
199);
200​
201it.effect.each(["--bare", "source.git"])(
202 "clones a local repository named %s as a working tree",
203 (repositoryName) =>
204 Effect.gen(function* () {
205 const fs = yield* FileSystem.FileSystem;
206 const path = yield* Path.Path;
207 const git = yield* GitVcsDriver.GitVcsDriver;
208 const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-clone-options-" });
209 yield* git.execute({
210 operation: "test.init",
211 cwd: parent,
212 args: ["init", "--bare", path.join(parent, repositoryName)],
213 });
214 const destinationPath = path.join(parent, "checkout");
215 yield* Effect.gen(function* () {
216 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
217 const result = yield* service.cloneRepository({
218 remoteUrl: repositoryName,
219 destinationPath,
220 });
221 assert.strictEqual(result.cwd, destinationPath);
222 assert.isTrue(yield* fs.exists(path.join(destinationPath, ".git")));
223 }).pipe(Effect.provide(layer({ git: { execute: git.execute } })));
224 }).pipe(
225 Effect.provide(
226 GitVcsDriver.layer.pipe(
227 Layer.provide(
228 ServerConfig.layerTest(process.cwd(), {
229 prefix: "t3-clone-options-config-",
230 }),
231 ),
232 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
233 ),
234 ),
235 ),
236);
237​
238it.effect("reports clone progress from git's stderr and keeps its error text on failure", () =>
239 Effect.gen(function* () {
240 const fs = yield* FileSystem.FileSystem;
241 const path = yield* Path.Path;
242 const parent = yield* fs.makeTempDirectoryScoped({
243 prefix: "t3-source-control-clone-progress-",
244 });
245 const destinationPath = path.join(parent, "t3code");
246 const progress: Array<{ stage: string; percent: number | null; detail: string | null }> = [];
247​
248 const stderrLines = [
249 "Cloning into 't3code'...",
250 "remote: Enumerating objects: 10, done.",
251 "Receiving objects: 40% (4/10), 1.00 MiB | 2.00 MiB/s",
252 "Receiving objects: 100% (10/10), 2.50 MiB | 2.00 MiB/s, done.",
253 "fatal: early EOF",
254 "fatal: unable to access 'https://user:s3c@ret@github.com/octocat/t3code.git/': could not resolve host",
255 ];
256 const error = yield* Effect.gen(function* () {
257 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
258 return yield* Effect.flip(
259 service.cloneRepository(
260 { remoteUrl: CLONE_URLS.sshUrl, destinationPath },
261 { onProgress: (line) => Effect.sync(() => void progress.push(line)) },
262 ),
263 );
264 }).pipe(
265 Effect.provide(
266 layer({
267 git: {
268 execute: (input) =>
269 Effect.gen(function* () {
270 for (const line of stderrLines) {
271 yield* input.progress?.onStderrLine?.(line) ?? Effect.void;
272 }
273 return yield* new GitCommandError({
274 operation: input.operation,
275 command: "git",
276 cwd: input.cwd,
277 detail: "Git command exited with a non-zero status.",
278 exitCode: 128,
279 });
280 }),
281 },
282 }),
283 ),
284 );
285​
286 assert.deepStrictEqual(progress, [
287 { stage: "counting", percent: null, detail: null },
288 { stage: "receiving", percent: 40, detail: "1.00 MiB | 2.00 MiB/s" },
289 { stage: "receiving", percent: 100, detail: "2.50 MiB | 2.00 MiB/s" },
290 ]);
291 assert.strictEqual(
292 error.detail,
293 "fatal: early EOF fatal: unable to access 'https://github.com/octocat/t3code.git/': could not resolve host",
294 );
295 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
296);
297​
298it.effect("says a clone that ran out of time timed out, not git's last remote line", () =>
299 Effect.gen(function* () {
300 const fs = yield* FileSystem.FileSystem;
301 const path = yield* Path.Path;
302 const parent = yield* fs.makeTempDirectoryScoped({
303 prefix: "t3-source-control-clone-timeout-",
304 });
305 const started = yield* Deferred.make<void>();
306 const clone = yield* Effect.gen(function* () {
307 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
308 return yield* Effect.flip(
309 service.cloneRepository({
310 remoteUrl: CLONE_URLS.url,
311 destinationPath: path.join(parent, "t3code"),
312 }),
313 );
314 }).pipe(
315 Effect.provide(
316 layer({
317 git: {
318 // Like the real driver: the transfer outlives its deadline and git's last line is
319 // a remote summary.
320 execute: (input) =>
321 Effect.gen(function* () {
322 yield* (
323 input.progress?.onStderrLine?.(
324 "remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0",
325 ) ?? Effect.void
326 );
327 yield* Deferred.succeed(started, undefined);
328 return yield* Effect.never;
329 }).pipe(
330 Effect.timeoutOrElse({
331 duration: input.timeoutMs ?? Infinity,
332 orElse: () =>
333 Effect.fail(
334 new GitCommandError({
335 operation: input.operation,
336 command: "git",
337 cwd: input.cwd,
338 detail: "Git command timed out.",
339 }),
340 ),
341 }),
342 ),
343 },
344 }),
345 ),
346 Effect.forkChild,
347 );
348​
349 yield* Deferred.await(started);
350 yield* TestClock.adjust("2 minutes");
351 const error = yield* Fiber.join(clone);
352​
353 assert.match(error.detail, /timed out/i);
354 }).pipe(Effect.provide(NodeServices.layer)),
355);
356​
357function rejectingSsh(
358 urls: Array<string>,
359 httpsFails: boolean,
360 reason: GitCommandError["reason"] = "authentication_failed",
361) {
362 return {
363 execute: (input: GitVcsDriver.ExecuteGitInput) =>
364 Effect.gen(function* () {
365 const url = input.args.at(-2)!;
366 urls.push(url);
367 if (url.startsWith("https://") && !httpsFails) return processOutput();
368 yield* (
369 input.progress?.onStderrLine?.(
370 url.startsWith("https://")
371 ? "fatal: could not read Username for 'https://github.com': terminal prompts disabled"
372 : "git@github.com: Permission denied (publickey).",
373 ) ?? Effect.void
374 );
375 return yield* new GitCommandError({
376 operation: input.operation,
377 command: "git",
378 cwd: input.cwd,
379 reason,
380 detail: "Git command exited with a non-zero status.",
381 });
382 }),
383 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
385​
386const cloneInTemporaryDirectory = (
387 git: Partial<GitVcsDriver.GitVcsDriver["Service"]>,
388 input: Omit<SourceControlCloneRepositoryInput, "destinationPath">,
389) =>
390 Effect.gen(function* () {
391 const fs = yield* FileSystem.FileSystem;
392 const path = yield* Path.Path;
393 const parent = yield* fs
394 .makeTempDirectoryScoped({ prefix: "t3-clone-fallback-" })
395 .pipe(Effect.orDie);
396 return yield* Effect.gen(function* () {
397 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
398 return yield* service.cloneRepository({
399 ...input,
400 destinationPath: path.join(parent, "t3code"),
401 });
402 }).pipe(Effect.provide(layer({ git }).pipe(Layer.orDie)));
403 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
404​
405it.effect("retries a clone over HTTPS when the SSH login is refused", () =>
406 Effect.gen(function* () {
407 const urls: Array<string> = [];
408 const result = yield* cloneInTemporaryDirectory(rejectingSsh(urls, false), {
409 remoteUrl: CLONE_URLS.sshUrl,
410 });
411​
412 assert.deepStrictEqual(urls, [CLONE_URLS.sshUrl, "https://github.com/octocat/t3code.git"]);
413 assert.strictEqual(result.remoteUrl, "https://github.com/octocat/t3code.git");
414 }),
415);
416​
417it.effect("reports both failures when the HTTPS retry fails too", () =>
418 Effect.gen(function* () {
419 const urls: Array<string> = [];
420 const error = yield* Effect.flip(
421 cloneInTemporaryDirectory(rejectingSsh(urls, true), { remoteUrl: CLONE_URLS.sshUrl }),
422 );
423​
424 assert.strictEqual(urls.length, 2);
425 assert.strictEqual(
426 error.detail,
427 "git@github.com: Permission denied (publickey). Cloning over HTTPS instead also failed: fatal: could not read Username for 'https://github.com': terminal prompts disabled",
428 );
429 }),
430);
431​
432it.effect.each([
433 "git@ssh.dev.azure.com:v3/Fabrikam/Project1/fab_repo",
434 "ssh://user:pa@rt@ssh.dev.azure.com/v3/Fabrikam/Project1/fab_repo",
435 "ssh://us%40er:pa%40ss@ssh.dev.azure.com/v3/Fabrikam/Project1/fab_repo",
436])("retries Azure SSH URL %s using its HTTPS host and repository path", (remoteUrl) =>
437 Effect.gen(function* () {
438 const urls: Array<string> = [];
439 const result = yield* cloneInTemporaryDirectory(rejectingSsh(urls, false), {
440 remoteUrl,
441 });
442​
443 assert.deepStrictEqual(urls, [
444 remoteUrl,
445 "https://dev.azure.com/Fabrikam/Project1/_git/fab_repo",
446 ]);
447 assert.strictEqual(result.remoteUrl, "https://dev.azure.com/Fabrikam/Project1/_git/fab_repo");
448 }),
449);
450​
451it.effect.each([
452 ["the caller asked for SSH", "ssh", "authentication_failed"],
453 ["git failed for another reason", undefined, "remote_unreachable"],
454] as const)("does not retry over HTTPS when %s", ([, protocol, reason]) =>
455 Effect.gen(function* () {
456 const urls: Array<string> = [];
457 yield* Effect.flip(
458 cloneInTemporaryDirectory(rejectingSsh(urls, false, reason), {
459 remoteUrl: CLONE_URLS.sshUrl,
460 ...(protocol === undefined ? {} : { protocol }),
461 }),
462 );
463​
464 assert.deepStrictEqual(urls, [CLONE_URLS.sshUrl]);
465 }),
466);
467​
468it.effect.each([
469 ["a plain token", "s3cret"],
470 ["a token with an apostrophe", "s3'cret"],
471 ["a token with a double quote", 's3"cret'],
472 ["a host-only token with an at sign", "abc@secret"],
473 ["an unquoted token with an apostrophe", "s3'cret", true],
474 ["an unquoted token with a double quote", 's3"cret', true],
475 ["an unquoted URL with an apostrophe in its path", "path-token", true],
476] as const)("keeps %s in the query out of a failed clone's error text", ([, token, unquoted]) =>
477 Effect.gen(function* () {
478 const remote =
479 token === "abc@secret"
480 ? "https://github.com"
481 : token === "path-token"
482 ? "https://github.com/octocat/t3code's.git"
483 : "https://github.com/octocat/t3code.git";
484 const error = yield* Effect.flip(
485 cloneInTemporaryDirectory(
486 {
487 execute: (input) =>
488 Effect.gen(function* () {
489 const cloneUrl = input.args.at(-2)!;
490 // Git treats @ in a pathless URL's query as userinfo, losing the query delimiter.
491 const diagnosticUrl =
492 cloneUrl === "https://github.com?access_token=abc@secret"
493 ? "https://secret"
494 : cloneUrl;
495 yield* (
496 input.progress?.onStderrLine?.(
497 unquoted
498 ? `fatal: ${diagnosticUrl}/info/refs not valid: is this a git repository?`
499 : `fatal: unable to access '${diagnosticUrl}/': Could not resolve host: github.com`,
500 ) ?? Effect.void
501 );
502 return yield* new GitCommandError({
503 operation: input.operation,
504 command: "git",
505 cwd: input.cwd,
506 detail: "Git command exited with a non-zero status.",
507 exitCode: 128,
508 });
509 }),
510 },
511 { remoteUrl: `${remote}?access_token=${token}` },
512 ),
513 );
514​
515 assert.strictEqual(
516 error.detail,
517 unquoted
518 ? `fatal: ${remote} not valid: is this a git repository?`
519 : `fatal: unable to access '${remote}${token === "abc@secret" ? "/" : ""}': Could not resolve host: github.com`,
520 );
521 }),
522);
523​
524it.effect("strips embedded credentials from the remote URL it reports", () =>
525 Effect.gen(function* () {
526 const fs = yield* FileSystem.FileSystem;
527 const path = yield* Path.Path;
528 const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact-" });
529 const destinationPath = path.join(parent, "t3code");
530 const cloneArgs: Array<ReadonlyArray<string>> = [];
531 const result = yield* Effect.gen(function* () {
532 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
533 return yield* service.prepareClone({
534 remoteUrl: "https://user:s3cret@github.com/octocat/t3code.git",
535 destinationPath,
536 });
537 }).pipe(
538 Effect.provide(
539 layer({
540 git: {
541 execute: (input) =>
542 Effect.sync(() => {
543 cloneArgs.push(input.args);
544 return processOutput();
545 }),
546 },
547 }),
548 ),
549 );
550 assert.equal(result.remoteUrl, "https://github.com/octocat/t3code.git");
551 assert.equal(result.cloneUrl, "https://user:s3cret@github.com/octocat/t3code.git");
552 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
553);
554​
555it.effect("discards only a directory git wrote to", () =>
556 Effect.gen(function* () {
557 const fs = yield* FileSystem.FileSystem;
558 const path = yield* Path.Path;
559 const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-discard-" });
560 const partial = path.join(parent, "partial");
561 yield* fs.makeDirectory(path.join(partial, ".git"), { recursive: true });
562 yield* fs.writeFileString(path.join(partial, "README.md"), "half");
563 const foreign = path.join(parent, "foreign");
564 yield* fs.makeDirectory(foreign);
565 yield* fs.writeFileString(path.join(foreign, "notes.txt"), "mine");
566​
567 const replaced = path.join(parent, "replaced");
568 yield* fs.writeFileString(replaced, "not a directory");
569​
570 yield* Effect.gen(function* () {
571 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
572 yield* service.discardClone(partial);
573 const error = yield* Effect.flip(service.discardClone(foreign));
574 assert.include(error.detail, "not from the clone");
575 const replacedError = yield* Effect.flip(service.discardClone(replaced));
576 assert.include(replacedError.detail, "could not be inspected");
577 yield* service.discardClone(path.join(parent, "missing"));
578 }).pipe(Effect.provide(layer({})));
579​
580 assert.deepStrictEqual(yield* fs.readDirectory(partial), []);
581 assert.deepStrictEqual(yield* fs.readDirectory(foreign), ["notes.txt"]);
582 assert.strictEqual(yield* fs.readFileString(replaced), "not a directory");
583 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
584);
585​
586it.effect("redacts query tokens and userinfo containing '@' from reported URLs", () =>
587 Effect.gen(function* () {
588 const fs = yield* FileSystem.FileSystem;
589 const path = yield* Path.Path;
590 const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact2-" });
591 yield* Effect.gen(function* () {
592 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
593 const query = yield* service.prepareClone({
594 remoteUrl: "https://github.com/octocat/t3code.git?access_token=s3cret",
595 destinationPath: path.join(parent, "a"),
596 });
597 assert.equal(query.remoteUrl, "https://github.com/octocat/t3code.git");
598 const nested = yield* service.prepareClone({
599 remoteUrl: "https://user:pa@rt@github.com/octocat/t3code.git",
600 destinationPath: path.join(parent, "b"),
601 });
602 assert.equal(nested.remoteUrl, "https://github.com/octocat/t3code.git");
603 }).pipe(Effect.provide(layer({})));
604 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
605);
606​
607it.effect("preserves destination probe failures instead of treating them as missing paths", () => {
608 const fileSystemCause = PlatformError.systemError({
609 _tag: "PermissionDenied",
610 module: "FileSystem",
611 method: "exists",
612 pathOrDescriptor: "/restricted/t3code",
613 });
614​
615 return Effect.gen(function* () {
616 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
617 const error = yield* Effect.flip(
618 service.cloneRepository({
619 remoteUrl: CLONE_URLS.sshUrl,
620 destinationPath: "/restricted/t3code",
621 }),
622 );
623​
624 assert.strictEqual(error.provider, "unknown");
625 assert.strictEqual(error.operation, "cloneRepository");
626 assert.strictEqual(error.cause, fileSystemCause);
627 }).pipe(
628 Effect.provide(
629 layer({
630 fileSystem: FileSystem.makeNoop({
631 exists: () => Effect.fail(fileSystemCause),
632 makeDirectory: () => Effect.void,
633 }),
634 }),
635 ),
636 );
637});
638​
639it.effect("publishes to the remote name returned by ensureRemote", () => {
640 const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
641​
642 return Effect.gen(function* () {
643 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
644 const result = yield* service.publishRepository({
645 cwd: "/workspace",
646 provider: SourceControlProviderKind.make("github"),
647 repository: "octocat/t3code",
648 visibility: "private",
649 remoteName: "origin",
650 protocol: "ssh",
651 });
652​
653 assert.equal(result.remoteName, "origin-1");
654 assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]);
655 }).pipe(
656 Effect.provide(
657 layer({
658 git: {
659 ensureRemote: () => Effect.succeed("origin-1"),
660 pushCurrentBranch: (cwd, _fallbackBranch, options) =>
661 Effect.sync(() => {
662 pushCalls.push({ cwd, remoteName: options?.remoteName });
663 return {
664 status: "pushed" as const,
665 branch: "feature/remote-v1",
666 upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`,
667 setUpstream: true,
668 };
669 }),
670 },
671 }),
672 ),
673 );
674});
675​
676function failingPush(reason?: GitCommandError["reason"]) {
677 return {
678 pushCurrentBranch: (cwd: string) =>
679 Effect.fail(
680 new GitCommandError({
681 operation: "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote",
682 command: "git",
683 cwd,
684 ...(reason === undefined ? {} : { reason }),
685 detail: "Git command exited with a non-zero status.",
686 }),
687 ),
688 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
690​
691it.effect("explains a rejected SSH push after creating the repository", () =>
692 Effect.gen(function* () {
693 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
694 const error = yield* Effect.flip(
695 service.publishRepository({
696 cwd: "/workspace",
697 provider: SourceControlProviderKind.make("github"),
698 repository: "octocat/t3code",
699 visibility: "private",
700 protocol: "ssh",
701 }),
702 );
703​
704 assert.strictEqual(error.operation, "publishRepository");
705 assert.strictEqual(
706 error.detail,
707 'Created octocat/t3code and added it as remote "origin", but the push failed because SSH authentication was rejected. Add an SSH key to your account, or switch the remote to HTTPS with "git remote set-url origin https://github.com/octocat/t3code", then push again.',
708 );
709 assert.instanceOf(error.cause, GitCommandError);
710 }).pipe(Effect.provide(layer({ git: failingPush("authentication_failed") }))),
711);
712​
713// regression: an auto-selected SSH remote kept failing every later fetch after a refused push
714it.effect("switches an auto-selected remote to HTTPS when the SSH push is refused", () => {
715 const gitArgs: Array<ReadonlyArray<string>> = [];
716 let pushAttempts = 0;
717 return Effect.gen(function* () {
718 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
719 const result = yield* service.publishRepository({
720 cwd: "/workspace",
721 provider: SourceControlProviderKind.make("github"),
722 repository: "octocat/t3code",
723 visibility: "private",
724 });
725​
726 assert.strictEqual(result.status, "pushed");
727 assert.strictEqual(result.remoteUrl, CLONE_URLS.url);
728 assert.strictEqual(pushAttempts, 2);
729 assert.deepStrictEqual(gitArgs, [
730 ["rev-parse", "--verify", "HEAD"],
731 ["remote", "set-url", "origin", CLONE_URLS.url],
732 ]);
733 }).pipe(
734 Effect.provide(
735 layer({
736 git: {
737 execute: (input) =>
738 Effect.sync(() => {
739 gitArgs.push(input.args);
740 return processOutput();
741 }),
742 pushCurrentBranch: (cwd) => {
743 pushAttempts += 1;
744 return pushAttempts === 1
745 ? failingPush("authentication_failed").pushCurrentBranch(cwd)
746 : Effect.succeed({
747 status: "pushed" as const,
748 branch: "main",
749 upstreamBranch: "origin/main",
750 setUpstream: true,
751 });
752 },
753 },
754 }),
755 ),
756 );
757});
758​
759// regression: #19219 publishing did not explain why the first push failed
760it.effect("says the repository exists when an unclassified publish push fails", () =>
761 Effect.gen(function* () {
762 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
763 const error = yield* Effect.flip(
764 service.publishRepository({
765 cwd: "/workspace",
766 provider: SourceControlProviderKind.make("github"),
767 repository: "octocat/t3code",
768 visibility: "private",
769 protocol: "https",
770 }),
771 );
772​
773 assert.strictEqual(
774 error.detail,
775 'Created octocat/t3code and added it as remote "origin", but the push failed. Fix the problem, then push again.',
776 );
777 }).pipe(Effect.provide(layer({ git: failingPush() }))),
778);
779​
780it.effect("publish succeeds with status remote_added when the local repo has no commits", () => {
781 let pushCalls = 0;
782 return Effect.gen(function* () {
783 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
784 const result = yield* service.publishRepository({
785 cwd: "/workspace",
786 provider: SourceControlProviderKind.make("github"),
787 repository: "octocat/t3code",
788 visibility: "private",
789 remoteName: "origin",
790 protocol: "ssh",
791 });
792​
793 assert.deepStrictEqual(result, {
794 repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS },
795 remoteName: "origin",
796 remoteUrl: CLONE_URLS.sshUrl,
797 branch: "main",
798 status: "remote_added",
799 });
800 assert.strictEqual(pushCalls, 0);
801 }).pipe(
802 Effect.provide(
803 layer({
804 git: {
805 execute: (input) =>
806 input.args[0] === "rev-parse"
807 ? Effect.fail(
808 new GitCommandError({
809 operation: input.operation,
810 command: "git rev-parse --verify HEAD",
811 cwd: input.cwd,
812 detail: "fatal: Needed a single revision",
813 }),
814 )
815 : Effect.succeed(processOutput()),
816 statusDetails: () =>
817 Effect.succeed({
818 isRepo: true,
819 hasOriginRemote: true,
820 isDefaultBranch: true,
821 branch: "main",
822 upstreamRef: null,
823 hasWorkingTreeChanges: false,
824 workingTree: { files: [], insertions: 0, deletions: 0 },
825 hasUpstream: false,
826 aheadCount: 0,
827 behindCount: 0,
828 aheadOfDefaultCount: 0,
829 }),
830 pushCurrentBranch: () =>
831 Effect.sync(() => {
832 pushCalls += 1;
833 return {
834 status: "pushed" as const,
835 branch: "main",
836 upstreamBranch: "origin/main",
837 setUpstream: true,
838 };
839 }),
840 },
841 }),
842 ),
843 );
844});