| ... | ... | @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | 9 | import * as Fiber from "effect/Fiber"; |
| 10 | 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Logger from "effect/Logger"; |
| 11 | 12 | import * as Metric from "effect/Metric"; |
| 12 | 13 | import * as Path from "effect/Path"; |
| 13 | 14 | import * as PlatformError from "effect/PlatformError"; |
| ... | ... | @@ -35,6 +36,8 @@ import { |
| 35 | 36 | } from "./GitVcsDriverCore.ts"; |
| 36 | 37 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 37 | 38 | |
| 39 | const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); |
| 40 | |
| 38 | 41 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| 39 | 42 | prefix: "t3-git-vcs-driver-test-", |
| 40 | 43 | }); |
| ... | ... | @@ -718,7 +721,7 @@ it.effect("refreshes the current branch after an external checkout", () => |
| 718 | 721 | ).pipe(Effect.provide(TestLayer)), |
| 719 | 722 | ); |
| 720 | 723 | |
| 721 | | it.effect("backs off failed upstream refreshes across linked worktrees", () => |
| 724 | it.effect("backs off and logs failed fetch attempts across linked worktrees", () => |
| 722 | 725 | Effect.scoped( |
| 723 | 726 | Effect.gen(function* () { |
| 724 | 727 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; |
| ... | ... | @@ -738,6 +741,14 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => |
| 738 | 741 | const driver = yield* makeGitVcsDriverCore().pipe( |
| 739 | 742 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), |
| 740 | 743 | ); |
| 744 | const warnings: string[] = []; |
| 745 | const logger = Logger.make<unknown, void>(({ message }) => { |
| 746 | warnings.push(String(message)); |
| 747 | }); |
| 748 | const readRemoteStatus = (workingDirectory: string) => |
| 749 | driver |
| 750 | .statusDetailsRemote(workingDirectory) |
| 751 | .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); |
| 741 | 752 | const cwd = yield* makeTmpDir(); |
| 742 | 753 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); |
| 743 | 754 | const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); |
| ... | ... | @@ -781,29 +792,146 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => |
| 781 | 792 | ); |
| 782 | 793 | yield* Ref.set(fetchAttempts, 0); |
| 783 | 794 | |
| 784 | | yield* driver.statusDetailsRemote(cwd); |
| 785 | | yield* driver.statusDetailsRemote(worktreePath); |
| 795 | yield* readRemoteStatus(cwd); |
| 796 | yield* readRemoteStatus(worktreePath); |
| 786 | 797 | assert.equal(yield* Ref.get(fetchAttempts), 1); |
| 798 | assert.lengthOf(warnings, 1); |
| 799 | assert.include(warnings[0], "Background Git fetch failed"); |
| 787 | 800 | |
| 788 | 801 | yield* TestClock.adjust("29 seconds"); |
| 789 | | yield* driver.statusDetailsRemote(worktreePath); |
| 802 | yield* readRemoteStatus(worktreePath); |
| 790 | 803 | assert.equal(yield* Ref.get(fetchAttempts), 1); |
| 804 | assert.lengthOf(warnings, 1); |
| 791 | 805 | |
| 792 | 806 | yield* TestClock.adjust("1 second"); |
| 793 | | yield* driver.statusDetailsRemote(cwd); |
| 807 | yield* readRemoteStatus(cwd); |
| 794 | 808 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| 809 | assert.lengthOf(warnings, 2); |
| 795 | 810 | |
| 796 | 811 | yield* TestClock.adjust("59 seconds"); |
| 797 | | yield* driver.statusDetailsRemote(worktreePath); |
| 812 | yield* readRemoteStatus(worktreePath); |
| 798 | 813 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| 814 | assert.lengthOf(warnings, 2); |
| 799 | 815 | |
| 800 | 816 | yield* TestClock.adjust("1 second"); |
| 801 | | yield* driver.statusDetailsRemote(cwd); |
| 817 | yield* readRemoteStatus(cwd); |
| 802 | 818 | assert.equal(yield* Ref.get(fetchAttempts), 3); |
| 819 | assert.lengthOf(warnings, 3); |
| 803 | 820 | }), |
| 804 | 821 | ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), |
| 805 | 822 | ); |
| 806 | 823 | |
| 824 | for (const scenario of [ |
| 825 | { |
| 826 | name: "HTTPS credentials", |
| 827 | stderr: "fatal: Authentication failed for", |
| 828 | expected: "could not authenticate", |
| 829 | }, |
| 830 | { |
| 831 | name: "SSH credentials", |
| 832 | stderr: "git@example.com: Permission denied (publickey).", |
| 833 | expected: "could not authenticate", |
| 834 | }, |
| 835 | { |
| 836 | name: "disabled prompts", |
| 837 | stderr: "fatal: could not read Username: terminal prompts disabled", |
| 838 | expected: "could not authenticate", |
| 839 | }, |
| 840 | { |
| 841 | name: "DNS failure", |
| 842 | stderr: "fatal: Could not resolve host: example.com", |
| 843 | expected: "could not reach the remote", |
| 844 | }, |
| 845 | { |
| 846 | name: "connection failure", |
| 847 | stderr: "ssh: connect to host example.com port 22: Connection refused", |
| 848 | expected: "could not reach the remote", |
| 849 | }, |
| 850 | { |
| 851 | name: "missing remote", |
| 852 | stderr: "remote: Repository not found.", |
| 853 | expected: "could not access the remote repository", |
| 854 | }, |
| 855 | { |
| 856 | name: "invalid remote", |
| 857 | stderr: "fatal: remote does not appear to be a git repository", |
| 858 | expected: "could not access the remote repository", |
| 859 | }, |
| 860 | { |
| 861 | name: "reference lock", |
| 862 | stderr: "error: cannot lock ref 'refs/remotes/origin/main': is at abc but expected def", |
| 863 | expected: "could not update a local reference", |
| 864 | }, |
| 865 | { |
| 866 | name: "lock file", |
| 867 | stderr: "fatal: Unable to create '/repo/.git/FETCH_HEAD.lock': File exists.", |
| 868 | expected: "could not update a local reference", |
| 869 | }, |
| 870 | { |
| 871 | name: "unrelated remote chatter", |
| 872 | stderr: |
| 873 | "remote: Help: authentication failed, connection refused, cannot lock ref\nremote: unrelated service error", |
| 874 | expected: "git fetch origin failed", |
| 875 | }, |
| 876 | { |
| 877 | name: "HTTPS DNS failure", |
| 878 | stderr: |
| 879 | "fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com", |
| 880 | expected: "could not reach the remote", |
| 881 | }, |
| 882 | { |
| 883 | name: "unknown failure", |
| 884 | stderr: "fatal: unexpected remote failure", |
| 885 | expected: "git fetch origin failed", |
| 886 | }, |
| 887 | ] as const) { |
| 888 | it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () => |
| 889 | Effect.gen(function* () { |
| 890 | const secret = "secret-fetch-token"; |
| 891 | const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; |
| 892 | const attempts = yield* Ref.make(0); |
| 893 | const spawner = ChildProcessSpawner.make((command) => |
| 894 | Effect.gen(function* () { |
| 895 | if (!ChildProcess.isStandardCommand(command)) |
| 896 | return yield* Effect.die("expected Git command"); |
| 897 | if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); |
| 898 | assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); |
| 899 | assert.equal(command.options.env?.LC_ALL, "C"); |
| 900 | assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); |
| 901 | yield* Ref.update(attempts, (count) => count + 1); |
| 902 | return ChildProcessSpawner.makeHandle({ |
| 903 | pid: ChildProcessSpawner.ProcessId(1), |
| 904 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), |
| 905 | isRunning: Effect.succeed(false), |
| 906 | kill: () => Effect.void, |
| 907 | unref: Effect.succeed(Effect.void), |
| 908 | stdin: Sink.drain, |
| 909 | stdout: Stream.encodeText(Stream.make(secret)), |
| 910 | stderr: Stream.encodeText(Stream.make(stderr)), |
| 911 | all: Stream.empty, |
| 912 | getInputFd: () => Sink.drain, |
| 913 | getOutputFd: () => Stream.empty, |
| 914 | }); |
| 915 | }), |
| 916 | ); |
| 917 | const driver = yield* makeGitVcsDriverCore().pipe( |
| 918 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 919 | ); |
| 920 | const cwd = yield* makeTmpDir(); |
| 921 | const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); |
| 922 | assert.include(error.detail, scenario.expected); |
| 923 | assert.equal(error.exitCode, 128); |
| 924 | assert.equal(error.stderrLength, stderr.length); |
| 925 | assert.equal(error.stdoutLength, secret.length); |
| 926 | assert.notInclude(error.message, secret); |
| 927 | assert.notInclude(yield* encodeGitCommandError(error), secret); |
| 928 | assert.notProperty(error, "stderr"); |
| 929 | assert.notProperty(error, "args"); |
| 930 | assert.equal(yield* Ref.get(attempts), 1); |
| 931 | }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), |
| 932 | ); |
| 933 | } |
| 934 | |
| 807 | 935 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 808 | 936 | describe("process environment", () => { |
| 809 | 937 | it.effect("preserves the caller locale for general Git subprocesses", () => |
| ... | ... | @@ -2398,6 +2526,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2398 | 2526 | }); |
| 2399 | 2527 | |
| 2400 | 2528 | describe("remote operations", () => { |
| 2529 | it.effect("explains a real fetch failure for a missing local remote", () => |
| 2530 | Effect.gen(function* () { |
| 2531 | const cwd = yield* makeTmpDir(); |
| 2532 | yield* initRepoWithCommit(cwd); |
| 2533 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 2534 | const missingRemote = `${cwd}/private-missing-remote`; |
| 2535 | yield* git(cwd, ["remote", "add", "origin", missingRemote]); |
| 2536 | const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); |
| 2537 | assert.include(error.detail, "could not access the remote repository"); |
| 2538 | assert.equal(error.exitCode, 128); |
| 2539 | assert.isAbove(error.stderrLength ?? 0, 0); |
| 2540 | assert.notInclude(error.detail, missingRemote); |
| 2541 | assert.notProperty(error, "stderr"); |
| 2542 | }), |
| 2543 | ); |
| 2544 | |
| 2401 | 2545 | it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => |
| 2402 | 2546 | Effect.gen(function* () { |
| 2403 | 2547 | const cwd = yield* makeTmpDir(); |
| ... | ... | @@ -2544,7 +2688,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2544 | 2688 | if (Result.isFailure(result)) { |
| 2545 | 2689 | assert.equal( |
| 2546 | 2690 | result.failure.detail, |
| 2547 | | failure === "timeout" ? "Git command timed out." : "git fetch origin failed", |
| 2691 | failure === "timeout" |
| 2692 | ? "Git command timed out." |
| 2693 | : failure === "offline" |
| 2694 | ? "Git could not reach the remote. Check the server's network connection and remote host, then retry." |
| 2695 | : "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.", |
| 2548 | 2696 | ); |
| 2549 | 2697 | } |
| 2550 | 2698 | }), |