authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 11:14:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 11:14:25-07:00
logc03b240265a1d4954536c5c0758ac2b7fa762ba8
tree4f36d712e55c23b3fb933d5a4ed775d008095985
parent80c1f771b39674ada4d3724610f08fba855fdd49
signature Signed by PGP key B5690EEEBB952194

fix(git): explain fetch failures without exposing remote output (#12485)


2 files changed, 231 insertions(+), 12 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+156-8
......@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
99import * as Fiber from "effect/Fiber";
1010import * as Layer from "effect/Layer";
11import * as Logger from "effect/Logger";
1112import * as Metric from "effect/Metric";
1213import * as Path from "effect/Path";
1314import * as PlatformError from "effect/PlatformError";
......@@ -35,6 +36,8 @@ import {
3536} from "./GitVcsDriverCore.ts";
3637import * as GitVcsDriver from "./GitVcsDriver.ts";
3738
39const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError));
40
3841const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
3942 prefix: "t3-git-vcs-driver-test-",
4043});
......@@ -718,7 +721,7 @@ it.effect("refreshes the current branch after an external checkout", () =>
718721 ).pipe(Effect.provide(TestLayer)),
719722);
720723
721it.effect("backs off failed upstream refreshes across linked worktrees", () =>
724it.effect("backs off and logs failed fetch attempts across linked worktrees", () =>
722725 Effect.scoped(
723726 Effect.gen(function* () {
724727 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
......@@ -738,6 +741,14 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () =>
738741 const driver = yield* makeGitVcsDriverCore().pipe(
739742 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner),
740743 );
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])));
741752 const cwd = yield* makeTmpDir();
742753 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
743754 const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-");
......@@ -781,29 +792,146 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () =>
781792 );
782793 yield* Ref.set(fetchAttempts, 0);
783794
784 yield* driver.statusDetailsRemote(cwd);
785 yield* driver.statusDetailsRemote(worktreePath);
795 yield* readRemoteStatus(cwd);
796 yield* readRemoteStatus(worktreePath);
786797 assert.equal(yield* Ref.get(fetchAttempts), 1);
798 assert.lengthOf(warnings, 1);
799 assert.include(warnings[0], "Background Git fetch failed");
787800
788801 yield* TestClock.adjust("29 seconds");
789 yield* driver.statusDetailsRemote(worktreePath);
802 yield* readRemoteStatus(worktreePath);
790803 assert.equal(yield* Ref.get(fetchAttempts), 1);
804 assert.lengthOf(warnings, 1);
791805
792806 yield* TestClock.adjust("1 second");
793 yield* driver.statusDetailsRemote(cwd);
807 yield* readRemoteStatus(cwd);
794808 assert.equal(yield* Ref.get(fetchAttempts), 2);
809 assert.lengthOf(warnings, 2);
795810
796811 yield* TestClock.adjust("59 seconds");
797 yield* driver.statusDetailsRemote(worktreePath);
812 yield* readRemoteStatus(worktreePath);
798813 assert.equal(yield* Ref.get(fetchAttempts), 2);
814 assert.lengthOf(warnings, 2);
799815
800816 yield* TestClock.adjust("1 second");
801 yield* driver.statusDetailsRemote(cwd);
817 yield* readRemoteStatus(cwd);
802818 assert.equal(yield* Ref.get(fetchAttempts), 3);
819 assert.lengthOf(warnings, 3);
803820 }),
804821 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
805822);
806823
824for (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
807935it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
808936 describe("process environment", () => {
809937 it.effect("preserves the caller locale for general Git subprocesses", () =>
......@@ -2398,6 +2526,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
23982526 });
23992527
24002528 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
24012545 it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () =>
24022546 Effect.gen(function* () {
24032547 const cwd = yield* makeTmpDir();
......@@ -2544,7 +2688,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
25442688 if (Result.isFailure(result)) {
25452689 assert.equal(
25462690 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.",
25482696 );
25492697 }
25502698 }),
apps/server/src/vcs/GitVcsDriverCore.ts+75-4
......@@ -469,6 +469,49 @@ function isMissingWorktreeStderr(stderr: string): boolean {
469469 );
470470}
471471
472// Fetch stderr can contain remote credentials. Only fixed diagnoses may enter
473// persisted errors; unrecognized output keeps the generic failure message.
474function fetchFailureDetail(stderr: string): string | undefined {
475 const lines = stderr.split(/\r?\n/).map((line) => line.trim());
476 if (
477 lines.some((line) =>
478 /^(?:fatal: (?:Authentication failed|could not read (?:Username|Password))\b|\S+: Permission denied \(publickey)/i.test(
479 line,
480 ),
481 )
482 ) {
483 return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.";
484 }
485 if (
486 lines.some((line) =>
487 /^(?:(?:fatal: |ssh: )?Could not resolve host(?:name)?\b|fatal: unable to access .+: (?:Could not resolve host|Failed to connect)\b|ssh: connect to host \S+ port \d+: (?:Connection timed out|Connection refused|Network is unreachable)\b)/i.test(
488 line,
489 ),
490 )
491 ) {
492 return "Git could not reach the remote. Check the server's network connection and remote host, then retry.";
493 }
494 if (
495 lines.some((line) =>
496 /^(?:remote: Repository not found\.?$|fatal: repository .+ not found$|fatal: .+ does not appear to be a git repository$)/i.test(
497 line,
498 ),
499 )
500 ) {
501 return "Git could not access the remote repository. Check the remote URL and repository permissions on the server.";
502 }
503 if (
504 lines.some((line) =>
505 /^(?:(?:error|fatal): cannot lock ref\b|fatal: Unable to create ['"].+\.lock['"]:)/i.test(
506 line,
507 ),
508 )
509 ) {
510 return "Git could not update a local reference. Another Git operation or a stale lock may be blocking the fetch; check the repository on the server, then retry.";
511 }
512 return undefined;
513}
514
472515interface Trace2Monitor {
473516 readonly env: NodeJS.ProcessEnv;
474517 readonly flush: Effect.Effect<void, never>;
......@@ -1307,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
13071350 return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(
13081351 Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),
13091352 Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),
1353 Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)),
13101354 Effect.as(true as const),
13111355 );
13121356 });
......@@ -1331,13 +1375,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
13311375 const upstream = yield* resolveCurrentUpstream(cwd);
13321376 if (!upstream) return;
13331377 const gitCommonDir = yield* resolveGitCommonDir(cwd);
1378 // The cache loader logs failed attempts; cache hits keep using the last fetched refs.
13341379 yield* Cache.get(
13351380 statusRemoteRefreshCache,
13361381 new StatusRemoteRefreshCacheKey({
13371382 gitCommonDir,
13381383 remoteName: upstream.remoteName,
13391384 }),
1340 );
1385 ).pipe(Effect.ignore);
13411386 });
13421387
13431388 const resolveDefaultBranchName = (
......@@ -3224,7 +3269,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
32243269 env: STATUS_UPSTREAM_REFRESH_ENV,
32253270 fallbackErrorDetail: `git fetch ${input.remoteName} failed`,
32263271 };
3227 const fetchAll = executeGit("GitVcsDriver.fetchRemote", input.cwd, args, options);
3272 const fetchAll = executeGitWithStableDiagnostics(
3273 "GitVcsDriver.fetchRemote",
3274 input.cwd,
3275 args,
3276 {
3277 ...options,
3278 allowNonZeroExit: true,
3279 },
3280 ).pipe(
3281 Effect.flatMap((result) =>
3282 result.exitCode === 0
3283 ? Effect.void
3284 : Effect.fail(
3285 new GitCommandError({
3286 ...gitCommandContext({
3287 operation: "GitVcsDriver.fetchRemote",
3288 cwd: input.cwd,
3289 args,
3290 }),
3291 detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail,
3292 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
3293 stdoutLength: result.stdout.length,
3294 stderrLength: result.stderr.length,
3295 }),
3296 ),
3297 ),
3298 );
32283299 if (input.refName === undefined) {
32293300 return yield* fetchAll.pipe(Effect.asVoid);
32303301 }
......@@ -3255,8 +3326,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
32553326 cwd: input.cwd,
32563327 args: scopedArgs,
32573328 }),
3258 detail: options.fallbackErrorDetail,
3259 exitCode: result.exitCode,
3329 detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail,
3330 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
32603331 stdoutLength: result.stdout.length,
32613332 stderrLength: result.stderr.length,
32623333 });