| ... | @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Fiber from "effect/Fiber"; | 9 | import * as Fiber from "effect/Fiber"; |
| 10 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| | 11 | import * as Logger from "effect/Logger"; |
| 11 | import * as Metric from "effect/Metric"; | 12 | import * as Metric from "effect/Metric"; |
| 12 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 13 | import * as PlatformError from "effect/PlatformError"; | 14 | import * as PlatformError from "effect/PlatformError"; |
| ... | @@ -35,6 +36,8 @@ import { | ... | @@ -35,6 +36,8 @@ import { |
| 35 | } from "./GitVcsDriverCore.ts"; | 36 | } from "./GitVcsDriverCore.ts"; |
| 36 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | 37 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 37 | | 38 | |
| | 39 | const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); |
| | 40 | |
| 38 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | 41 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| 39 | prefix: "t3-git-vcs-driver-test-", | 42 | prefix: "t3-git-vcs-driver-test-", |
| 40 | }); | 43 | }); |
| ... | @@ -718,7 +721,7 @@ it.effect("refreshes the current branch after an external checkout", () => | ... | @@ -718,7 +721,7 @@ it.effect("refreshes the current branch after an external checkout", () => |
| 718 | ).pipe(Effect.provide(TestLayer)), | 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 | Effect.scoped( | 725 | Effect.scoped( |
| 723 | Effect.gen(function* () { | 726 | Effect.gen(function* () { |
| 724 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; | 727 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; |
| ... | @@ -738,6 +741,14 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => | ... | @@ -738,6 +741,14 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => |
| 738 | const driver = yield* makeGitVcsDriverCore().pipe( | 741 | const driver = yield* makeGitVcsDriverCore().pipe( |
| 739 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), | 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 | const cwd = yield* makeTmpDir(); | 752 | const cwd = yield* makeTmpDir(); |
| 742 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); | 753 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); |
| 743 | const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); | 754 | const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); |
| ... | @@ -781,29 +792,146 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => | ... | @@ -781,29 +792,146 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => |
| 781 | ); | 792 | ); |
| 782 | yield* Ref.set(fetchAttempts, 0); | 793 | yield* Ref.set(fetchAttempts, 0); |
| 783 | | 794 | |
| 784 | yield* driver.statusDetailsRemote(cwd); | 795 | yield* readRemoteStatus(cwd); |
| 785 | yield* driver.statusDetailsRemote(worktreePath); | 796 | yield* readRemoteStatus(worktreePath); |
| 786 | assert.equal(yield* Ref.get(fetchAttempts), 1); | 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 | yield* TestClock.adjust("29 seconds"); | 801 | yield* TestClock.adjust("29 seconds"); |
| 789 | yield* driver.statusDetailsRemote(worktreePath); | 802 | yield* readRemoteStatus(worktreePath); |
| 790 | assert.equal(yield* Ref.get(fetchAttempts), 1); | 803 | assert.equal(yield* Ref.get(fetchAttempts), 1); |
| | 804 | assert.lengthOf(warnings, 1); |
| 791 | | 805 | |
| 792 | yield* TestClock.adjust("1 second"); | 806 | yield* TestClock.adjust("1 second"); |
| 793 | yield* driver.statusDetailsRemote(cwd); | 807 | yield* readRemoteStatus(cwd); |
| 794 | assert.equal(yield* Ref.get(fetchAttempts), 2); | 808 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| | 809 | assert.lengthOf(warnings, 2); |
| 795 | | 810 | |
| 796 | yield* TestClock.adjust("59 seconds"); | 811 | yield* TestClock.adjust("59 seconds"); |
| 797 | yield* driver.statusDetailsRemote(worktreePath); | 812 | yield* readRemoteStatus(worktreePath); |
| 798 | assert.equal(yield* Ref.get(fetchAttempts), 2); | 813 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| | 814 | assert.lengthOf(warnings, 2); |
| 799 | | 815 | |
| 800 | yield* TestClock.adjust("1 second"); | 816 | yield* TestClock.adjust("1 second"); |
| 801 | yield* driver.statusDetailsRemote(cwd); | 817 | yield* readRemoteStatus(cwd); |
| 802 | assert.equal(yield* Ref.get(fetchAttempts), 3); | 818 | assert.equal(yield* Ref.get(fetchAttempts), 3); |
| | 819 | assert.lengthOf(warnings, 3); |
| 803 | }), | 820 | }), |
| 804 | ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), | 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 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | 935 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 808 | describe("process environment", () => { | 936 | describe("process environment", () => { |
| 809 | it.effect("preserves the caller locale for general Git subprocesses", () => | 937 | it.effect("preserves the caller locale for general Git subprocesses", () => |
| ... | @@ -2398,6 +2526,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -2398,6 +2526,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2398 | }); | 2526 | }); |
| 2399 | | 2527 | |
| 2400 | describe("remote operations", () => { | 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 | it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => | 2545 | it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => |
| 2402 | Effect.gen(function* () { | 2546 | Effect.gen(function* () { |
| 2403 | const cwd = yield* makeTmpDir(); | 2547 | const cwd = yield* makeTmpDir(); |
| ... | @@ -2544,7 +2688,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -2544,7 +2688,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 2544 | if (Result.isFailure(result)) { | 2688 | if (Result.isFailure(result)) { |
| 2545 | assert.equal( | 2689 | assert.equal( |
| 2546 | result.failure.detail, | 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 | }), |