authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:53:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:53:53-07:00
log4238a0e769c9f149d4083968823298ace1e2bd0b
tree6f8c5e292a314581aa03be18f177595a7e464f0e
parentff0f702c7cb5006acf72b63edf5c0df7686f60c4
signature Signed by PGP key B5690EEEBB952194

[codex] Enrich Git VCS driver errors (#3253)

Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 407 insertions(+), 222 deletions(-)

apps/server/src/git/GitManager.test.ts+6-1
......@@ -3377,13 +3377,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
33773377 Effect.map((error) => error.message),
33783378 );
33793379
3380 expect(errorMessage).toContain("hook: fail");
3380 expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit");
3381 expect(errorMessage).not.toContain("hook: fail");
33813382 expect(events).toEqual(
33823383 expect.arrayContaining([
33833384 expect.objectContaining({
33843385 kind: "hook_started",
33853386 hookName: "pre-commit",
33863387 }),
3388 expect.objectContaining({
3389 kind: "hook_output",
3390 text: "hook: fail",
3391 }),
33873392 expect.objectContaining({
33883393 kind: "action_failed",
33893394 phase: "commit",
apps/server/src/vcs/GitVcsDriverCore.test.ts+108
......@@ -78,6 +78,114 @@ const initRepoWithCommit = (
7878 });
7979
8080it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
81 describe("structured errors", () => {
82 it.effect("preserves structured spawn context and the platform cause", () =>
83 Effect.gen(function* () {
84 const parent = yield* makeTmpDir();
85 const pathService = yield* Path.Path;
86 const cwd = pathService.join(parent, "missing");
87 const driver = yield* GitVcsDriver.GitVcsDriver;
88
89 const error = yield* driver
90 .execute({
91 operation: "GitVcsDriver.test.missingCwd",
92 cwd,
93 args: ["status", "--short"],
94 })
95 .pipe(Effect.flip);
96
97 assert.deepInclude(error, {
98 _tag: "GitCommandError",
99 operation: "GitVcsDriver.test.missingCwd",
100 command: "git",
101 argumentCount: 2,
102 cwd,
103 detail: "Failed to spawn Git process.",
104 });
105 if (!(error.cause instanceof PlatformError.PlatformError)) {
106 return assert.fail("expected the original platform error cause");
107 }
108 assert.equal(error.cause.reason._tag, "NotFound");
109 assert.notInclude(error.detail, error.cause.message);
110 }),
111 );
112
113 it.effect("does not retain git arguments or stderr in command failures", () =>
114 Effect.gen(function* () {
115 const cwd = yield* makeTmpDir();
116 const driver = yield* GitVcsDriver.GitVcsDriver;
117 yield* driver.initRepo({ cwd });
118
119 const secret = "secret-token-value";
120 const error = yield* driver
121 .execute({
122 operation: "GitVcsDriver.test.redactedFailure",
123 cwd,
124 args: ["status", `--unknown-option=${secret}`],
125 })
126 .pipe(Effect.flip);
127
128 assert.deepInclude(error, {
129 _tag: "GitCommandError",
130 operation: "GitVcsDriver.test.redactedFailure",
131 command: "git",
132 argumentCount: 2,
133 cwd,
134 });
135 assert.isNumber(error.exitCode);
136 assert.isAbove(error.stderrLength ?? 0, 0);
137 assert.notInclude(error.detail, secret);
138 assert.notInclude(error.message, secret);
139 assert.notProperty(error, "args");
140 assert.notProperty(error, "stderr");
141 }),
142 );
143
144 it.effect("recovers a structurally identified missing cwd as a non-repository", () =>
145 Effect.gen(function* () {
146 const parent = yield* makeTmpDir();
147 const pathService = yield* Path.Path;
148 const cwd = pathService.join(parent, "missing");
149 const driver = yield* GitVcsDriver.GitVcsDriver;
150
151 const [localStatus, remoteStatus, refs] = yield* Effect.all([
152 driver.statusDetails(cwd),
153 driver.statusDetailsRemote(cwd, { refreshUpstream: false }),
154 driver.listRefs({ cwd }),
155 ]);
156
157 assert.equal(localStatus.isRepo, false);
158 assert.equal(remoteStatus.isRepo, false);
159 assert.equal(refs.isRepo, false);
160 assert.deepStrictEqual(refs.refs, []);
161 }),
162 );
163
164 it.effect("does not wrap a remove-worktree command failure in a synthetic error", () =>
165 Effect.gen(function* () {
166 const cwd = yield* makeTmpDir();
167 const pathService = yield* Path.Path;
168 const missingWorktree = pathService.join(cwd, "missing-worktree");
169 const driver = yield* GitVcsDriver.GitVcsDriver;
170 yield* driver.initRepo({ cwd });
171
172 const error = yield* driver
173 .removeWorktree({ cwd, path: missingWorktree })
174 .pipe(Effect.flip);
175
176 assert.deepInclude(error, {
177 _tag: "GitCommandError",
178 operation: "GitVcsDriver.removeWorktree",
179 command: "git",
180 argumentCount: 3,
181 cwd,
182 });
183 assert.notProperty(error, "cause");
184 assert.notInclude(error.detail, "Git command failed in");
185 }),
186 );
187 });
188
81189 describe("review diff previews", () => {
82190 it.effect("drops an unterminated path from truncated NUL-separated git output", () =>
83191 Effect.sync(() => {
apps/server/src/vcs/GitVcsDriverCore.ts+274-205
......@@ -36,7 +36,6 @@ import {
3636 parseRemoteRefWithRemoteNames,
3737} from "../git/remoteRefs.ts";
3838import { ServerConfig } from "../config.ts";
39const isGitCommandError = Schema.is(GitCommandError);
4039
4140const DEFAULT_TIMEOUT_MS = 30_000;
4241const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
......@@ -100,7 +99,7 @@ interface ExecuteGitOptions {
10099 stdin?: string | undefined;
101100 timeoutMs?: number | undefined;
102101 allowNonZeroExit?: boolean | undefined;
103 fallbackErrorMessage?: string | undefined;
102 fallbackErrorDetail?: string | undefined;
104103 env?: NodeJS.ProcessEnv | undefined;
105104 maxOutputBytes?: number | undefined;
106105 appendTruncationMarker?: boolean | undefined;
......@@ -326,8 +325,15 @@ function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null {
326325 return localBranch.length > 0 ? localBranch : null;
327326}
328327
329function commandLabel(args: readonly string[]): string {
330 return `git ${args.join(" ")}`;
328function gitCommandContext(
329 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
330) {
331 return {
332 operation: input.operation,
333 command: "git",
334 cwd: input.cwd,
335 argumentCount: input.args.length,
336 } as const;
331337}
332338
333339function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
......@@ -340,50 +346,28 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string):
340346 return refName.length > 0 ? refName : null;
341347}
342348
343function createGitCommandError(
344 operation: string,
345 cwd: string,
346 args: readonly string[],
347 detail: string,
348 cause?: unknown,
349): GitCommandError {
350 return new GitCommandError({
351 operation,
352 command: commandLabel(args),
353 cwd,
354 detail,
355 ...(cause !== undefined ? { cause } : {}),
356 });
357}
349function isMissingGitCwdError(error: GitCommandError): boolean {
350 if (!(error.cause instanceof PlatformError.PlatformError)) {
351 return false;
352 }
358353
359function quoteGitCommand(args: ReadonlyArray<string>): string {
360 return `git ${args.join(" ")}`;
361}
354 const reason = error.cause.reason;
355 if (reason._tag === "NotFound") {
356 return reason.pathOrDescriptor === error.cwd;
357 }
362358
363function isMissingGitCwdError(error: GitCommandError): boolean {
364 const normalized = `${error.detail}\n${error.message}`.toLowerCase();
365359 return (
366 normalized.includes("no such file or directory") ||
367 normalized.includes("notfound: filesystem.access") ||
368 normalized.includes("enoent") ||
369 normalized.includes("not a directory")
360 reason._tag === "BadResource" &&
361 reason.pathOrDescriptor === error.cwd &&
362 typeof reason.cause === "object" &&
363 reason.cause !== null &&
364 "code" in reason.cause &&
365 reason.cause.code === "ENOTDIR"
370366 );
371367}
372368
373function toGitCommandError(
374 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
375 detail: string,
376) {
377 return (cause: unknown) =>
378 isGitCommandError(cause)
379 ? cause
380 : new GitCommandError({
381 operation: input.operation,
382 command: quoteGitCommand(input.args),
383 cwd: input.cwd,
384 detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,
385 ...(cause !== undefined ? { cause } : {}),
386 });
369function isNonRepositoryGitStderr(stderr: string): boolean {
370 return stderr.toLowerCase().includes("not a git repository");
387371}
388372
389373interface Trace2Monitor {
......@@ -402,7 +386,11 @@ const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>)
402386 yield* Effect.sync(() => {
403387 span.event(name, timestamp, compactTraceAttributes(attributes));
404388 });
405 }).pipe(Effect.catch(() => Effect.void));
389 }).pipe(
390 Effect.catchTags({
391 NoSuchElementError: () => Effect.void,
392 }),
393 );
406394
407395function trace2ChildKey(record: Record<string, unknown>): string | null {
408396 const childId = record.child_id;
......@@ -451,7 +439,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
451439 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);
452440 if (Result.isFailure(traceRecord)) {
453441 yield* Effect.logDebug(
454 `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,
442 `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`,
455443 traceRecord.failure,
456444 );
457445 return;
......@@ -574,9 +562,9 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
574562 };
575563});
576564
577const collectOutput = Effect.fnUntraced(function* <E>(
565const collectOutput = Effect.fnUntraced(function* (
578566 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
579 stream: Stream.Stream<Uint8Array, E>,
567 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
580568 maxOutputBytes: number,
581569 appendTruncationMarker: boolean,
582570 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
......@@ -614,10 +602,9 @@ const collectOutput = Effect.fnUntraced(function* <E>(
614602 const nextBytes = bytes + chunk.byteLength;
615603 if (!appendTruncationMarker && nextBytes > maxOutputBytes) {
616604 return yield* new GitCommandError({
617 operation: input.operation,
618 command: quoteGitCommand(input.args),
619 cwd: input.cwd,
620 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
605 ...gitCommandContext(input),
606 detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`,
607 outputLength: nextBytes,
621608 });
622609 }
623610
......@@ -635,7 +622,14 @@ const collectOutput = Effect.fnUntraced(function* <E>(
635622 });
636623
637624 yield* Stream.runForEach(stream, processChunk).pipe(
638 Effect.mapError(toGitCommandError(input, "output stream failed.")),
625 Effect.catchTags({
626 PlatformError: (cause) =>
627 new GitCommandError({
628 ...gitCommandContext(input),
629 detail: "Failed to read Git process output.",
630 cause,
631 }),
632 }),
639633 );
640634
641635 const remainder = truncated ? "" : decoder.decode();
......@@ -669,7 +663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
669663 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
670664 Effect.provideService(Path.Path, path),
671665 Effect.provideService(FileSystem.FileSystem, fileSystem),
672 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),
666 Effect.mapError(
667 (cause) =>
668 new GitCommandError({
669 ...gitCommandContext(commandInput),
670 detail: "Failed to create Git trace monitor.",
671 cause,
672 }),
673 ),
673674 );
674675 const child = yield* commandSpawner
675676 .spawn(
......@@ -682,7 +683,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
682683 },
683684 }),
684685 )
685 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
686 .pipe(
687 Effect.mapError(
688 (cause) =>
689 new GitCommandError({
690 ...gitCommandContext(commandInput),
691 detail: "Failed to spawn Git process.",
692 cause,
693 }),
694 ),
695 );
686696
687697 const [stdout, stderr, exitCode] = yield* Effect.all(
688698 [
......@@ -701,12 +711,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
701711 input.progress?.onStderrLine,
702712 ),
703713 child.exitCode.pipe(
704 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
714 Effect.mapError(
715 (cause) =>
716 new GitCommandError({
717 ...gitCommandContext(commandInput),
718 detail: "Failed to read Git process exit code.",
719 cause,
720 }),
721 ),
705722 ),
706723 input.stdin === undefined
707724 ? Effect.void
708725 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
709 Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")),
726 Effect.mapError(
727 (cause) =>
728 new GitCommandError({
729 ...gitCommandContext(commandInput),
730 detail: "Failed to write Git process input.",
731 cause,
732 }),
733 ),
710734 ),
711735 ],
712736 { concurrency: "unbounded" },
......@@ -714,15 +738,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
714738 yield* trace2Monitor.flush;
715739
716740 if (!input.allowNonZeroExit && exitCode !== 0) {
717 const trimmedStderr = stderr.text.trim();
718741 return yield* new GitCommandError({
719 operation: commandInput.operation,
720 command: quoteGitCommand(commandInput.args),
721 cwd: commandInput.cwd,
722 detail:
723 trimmedStderr.length > 0
724 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
725 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
742 ...gitCommandContext(commandInput),
743 detail: "Git command exited with a non-zero status.",
744 exitCode,
745 stdoutLength: stdout.text.length,
746 stderrLength: stderr.text.length,
726747 });
727748 }
728749
......@@ -743,10 +764,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
743764 onNone: () =>
744765 Effect.fail(
745766 new GitCommandError({
746 operation: commandInput.operation,
747 command: quoteGitCommand(commandInput.args),
748 cwd: commandInput.cwd,
749 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
767 ...gitCommandContext(commandInput),
768 detail: "Git command timed out.",
750769 }),
751770 ),
752771 onSome: Effect.succeed,
......@@ -799,22 +818,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
799818 if (options.allowNonZeroExit || result.exitCode === 0) {
800819 return Effect.succeed(result);
801820 }
802 const stderr = result.stderr.trim();
803 if (stderr.length > 0) {
804 return Effect.fail(createGitCommandError(operation, cwd, args, stderr));
805 }
806 if (options.fallbackErrorMessage) {
807 return Effect.fail(
808 createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),
809 );
810 }
811821 return Effect.fail(
812 createGitCommandError(
813 operation,
814 cwd,
815 args,
816 `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`,
817 ),
822 new GitCommandError({
823 ...gitCommandContext({ operation, cwd, args }),
824 detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.",
825 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
826 stdoutLength: result.stdout.length,
827 stderrLength: result.stderr.length,
828 }),
818829 );
819830 }),
820831 );
......@@ -877,12 +888,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
877888 }
878889 }
879890
880 return yield* createGitCommandError(
881 "GitVcsDriver.renameBranch",
882 cwd,
883 ["branch", "-m", "--", desiredBranch],
884 `Could not find an available branch name for '${desiredBranch}'.`,
885 );
891 return yield* new GitCommandError({
892 ...gitCommandContext({
893 operation: "GitVcsDriver.renameBranch",
894 cwd,
895 args: ["branch", "-m", "--", desiredBranch],
896 }),
897 detail: `Could not find an available branch name for '${desiredBranch}'.`,
898 });
886899 });
887900
888901 const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) {
......@@ -1024,12 +1037,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
10241037 if (firstRemote) {
10251038 return firstRemote;
10261039 }
1027 return yield* createGitCommandError(
1028 "GitVcsDriver.resolvePrimaryRemoteName",
1029 cwd,
1030 ["remote"],
1031 "No git remote is configured for this repository.",
1032 );
1040 return yield* new GitCommandError({
1041 ...gitCommandContext({
1042 operation: "GitVcsDriver.resolvePrimaryRemoteName",
1043 cwd,
1044 args: ["remote"],
1045 }),
1046 detail: "No git remote is configured for this repository.",
1047 });
10331048 });
10341049
10351050 const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* (
......@@ -1174,19 +1189,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
11741189 cwd,
11751190 ["rev-parse", "--abbrev-ref", "HEAD"],
11761191 { allowNonZeroExit: true },
1177 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1192 ).pipe(
1193 Effect.catchTags({
1194 GitCommandError: (error) =>
1195 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
1196 }),
1197 );
11781198
11791199 if (branchResult === null) {
11801200 return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
11811201 }
11821202 if (branchResult.exitCode !== 0) {
1183 const stderr = branchResult.stderr.trim();
1184 return yield* createGitCommandError(
1185 "GitVcsDriver.statusDetailsRemote.branch",
1186 cwd,
1187 ["rev-parse", "--abbrev-ref", "HEAD"],
1188 stderr || "git branch lookup failed",
1189 );
1203 if (isNonRepositoryGitStderr(branchResult.stderr)) {
1204 return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
1205 }
1206 return yield* new GitCommandError({
1207 ...gitCommandContext({
1208 operation: "GitVcsDriver.statusDetailsRemote.branch",
1209 cwd,
1210 args: ["rev-parse", "--abbrev-ref", "HEAD"],
1211 }),
1212 detail: "Git branch lookup failed.",
1213 exitCode: branchResult.exitCode,
1214 stdoutLength: branchResult.stdout.length,
1215 stderrLength: branchResult.stderr.length,
1216 });
11901217 }
11911218
11921219 const branchValue = branchResult.stdout.trim();
......@@ -1284,20 +1311,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
12841311 {
12851312 allowNonZeroExit: true,
12861313 },
1287 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1314 ).pipe(
1315 Effect.catchTags({
1316 GitCommandError: (error) =>
1317 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
1318 }),
1319 );
12881320
12891321 if (statusResult === null) {
12901322 return NON_REPOSITORY_STATUS_DETAILS;
12911323 }
12921324
12931325 if (statusResult.exitCode !== 0) {
1294 const stderr = statusResult.stderr.trim();
1295 return yield* createGitCommandError(
1296 "GitVcsDriver.statusDetails.status",
1297 cwd,
1298 ["status", "--porcelain=2", "--branch"],
1299 stderr || "git status failed",
1300 );
1326 if (isNonRepositoryGitStderr(statusResult.stderr)) {
1327 return NON_REPOSITORY_STATUS_DETAILS;
1328 }
1329 return yield* new GitCommandError({
1330 ...gitCommandContext({
1331 operation: "GitVcsDriver.statusDetails.status",
1332 cwd,
1333 args: ["status", "--porcelain=2", "--branch"],
1334 }),
1335 detail: "Git status failed.",
1336 exitCode: statusResult.exitCode,
1337 stdoutLength: statusResult.stdout.length,
1338 stderrLength: statusResult.stderr.length,
1339 });
13011340 }
13021341
13031342 const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] =
......@@ -1436,7 +1475,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14361475 "statusDetails",
14371476 )(function* (cwd) {
14381477 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1439 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1478 Effect.catchTags({
1479 GitCommandError: (error) =>
1480 isMissingGitCwdError(error) ? Effect.void : Effect.fail(error),
1481 }),
14401482 Effect.ignoreCause({ log: true }),
14411483 );
14421484 return yield* readStatusDetailsLocal(cwd);
......@@ -1446,7 +1488,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14461488 Effect.fn("statusDetailsRemote")(function* (cwd, options) {
14471489 if (options?.refreshUpstream !== false) {
14481490 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1449 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1491 Effect.catchTags({
1492 GitCommandError: (error) =>
1493 isMissingGitCwdError(error) ? Effect.void : Effect.fail(error),
1494 }),
14501495 Effect.ignoreCause({ log: true }),
14511496 );
14521497 }
......@@ -1474,7 +1519,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14741519 Effect.fn("prepareCommitContext")(function* (cwd, filePaths) {
14751520 if (filePaths && filePaths.length > 0) {
14761521 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1477 Effect.catch(() => Effect.void),
1522 Effect.catchTags({
1523 GitCommandError: () => Effect.void,
1524 }),
14781525 );
14791526 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [
14801527 "add",
......@@ -1550,12 +1597,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
15501597 const details = yield* statusDetails(cwd);
15511598 const branch = details.branch ?? fallbackBranch;
15521599 if (!branch) {
1553 return yield* createGitCommandError(
1554 "GitVcsDriver.pushCurrentBranch",
1555 cwd,
1556 ["push"],
1557 "Cannot push from detached HEAD.",
1558 );
1600 return yield* new GitCommandError({
1601 ...gitCommandContext({
1602 operation: "GitVcsDriver.pushCurrentBranch",
1603 cwd,
1604 args: ["push"],
1605 }),
1606 detail: "Cannot push from detached HEAD.",
1607 });
15591608 }
15601609
15611610 const requestedRemoteName = options?.remoteName?.trim() || null;
......@@ -1614,12 +1663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
16141663 if (!details.hasUpstream) {
16151664 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
16161665 if (!publishRemoteName) {
1617 return yield* createGitCommandError(
1618 "GitVcsDriver.pushCurrentBranch",
1619 cwd,
1620 ["push"],
1621 "Cannot push because no git remote is configured for this repository.",
1622 );
1666 return yield* new GitCommandError({
1667 ...gitCommandContext({
1668 operation: "GitVcsDriver.pushCurrentBranch",
1669 cwd,
1670 args: ["push"],
1671 }),
1672 detail: "Cannot push because no git remote is configured for this repository.",
1673 });
16231674 }
16241675 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
16251676 yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [
......@@ -1668,20 +1719,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
16681719 const details = yield* statusDetails(cwd);
16691720 const refName = details.branch;
16701721 if (!refName) {
1671 return yield* createGitCommandError(
1672 "GitVcsDriver.pullCurrentBranch",
1673 cwd,
1674 ["pull", "--ff-only"],
1675 "Cannot pull from detached HEAD.",
1676 );
1722 return yield* new GitCommandError({
1723 ...gitCommandContext({
1724 operation: "GitVcsDriver.pullCurrentBranch",
1725 cwd,
1726 args: ["pull", "--ff-only"],
1727 }),
1728 detail: "Cannot pull from detached HEAD.",
1729 });
16771730 }
16781731 if (!details.hasUpstream) {
1679 return yield* createGitCommandError(
1680 "GitVcsDriver.pullCurrentBranch",
1681 cwd,
1682 ["pull", "--ff-only"],
1683 "Current branch has no upstream configured. Push with upstream first.",
1684 );
1732 return yield* new GitCommandError({
1733 ...gitCommandContext({
1734 operation: "GitVcsDriver.pullCurrentBranch",
1735 cwd,
1736 args: ["pull", "--ff-only"],
1737 }),
1738 detail: "Current branch has no upstream configured. Push with upstream first.",
1739 });
16851740 }
16861741 const beforeSha = yield* runGitStdout(
16871742 "GitVcsDriver.pullCurrentBranch.beforeSha",
......@@ -1691,7 +1746,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
16911746 ).pipe(Effect.map((stdout) => stdout.trim()));
16921747 yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
16931748 timeoutMs: 30_000,
1694 fallbackErrorMessage: "git pull failed",
1749 fallbackErrorDetail: "git pull failed",
16951750 });
16961751 const afterSha = yield* runGitStdout(
16971752 "GitVcsDriver.pullCurrentBranch.afterSha",
......@@ -1874,14 +1929,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
18741929 crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe(
18751930 Effect.map(Encoding.encodeHex),
18761931 Effect.mapError(
1877 toGitCommandError(
1878 {
1932 (cause) =>
1933 new GitCommandError({
18791934 operation: "GitVcsDriver.getReviewDiffPreview.hash",
1935 command: "crypto.digest SHA-256",
18801936 cwd: input.cwd,
1881 args: [],
1882 },
1883 "failed to hash review diff.",
1884 ),
1937 detail: "Failed to hash review diff.",
1938 cause,
1939 }),
18851940 ),
18861941 );
18871942 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([
......@@ -1939,20 +1994,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
19391994 allowNonZeroExit: true,
19401995 },
19411996 ).pipe(
1942 Effect.catchIf(isMissingGitCwdError, () =>
1943 Effect.succeed({
1944 exitCode: ChildProcessSpawner.ExitCode(128),
1945 stdout: "",
1946 stderr: "fatal: not a git repository",
1947 stdoutTruncated: false,
1948 stderrTruncated: false,
1949 }),
1950 ),
1997 Effect.catchTags({
1998 GitCommandError: (error) =>
1999 isMissingGitCwdError(error)
2000 ? Effect.succeed({
2001 exitCode: ChildProcessSpawner.ExitCode(128),
2002 stdout: "",
2003 stderr: "fatal: not a git repository",
2004 stdoutTruncated: false,
2005 stderrTruncated: false,
2006 })
2007 : Effect.fail(error),
2008 }),
19512009 );
19522010
19532011 if (localBranchResult.exitCode !== 0) {
19542012 const stderr = localBranchResult.stderr.trim();
1955 if (stderr.toLowerCase().includes("not a git repository")) {
2013 if (isNonRepositoryGitStderr(stderr)) {
19562014 return {
19572015 refs: [],
19582016 isRepo: false,
......@@ -1961,12 +2019,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
19612019 totalCount: 0,
19622020 };
19632021 }
1964 return yield* createGitCommandError(
1965 "GitVcsDriver.listRefs",
1966 input.cwd,
1967 ["branch", "--no-color", "--no-column"],
1968 stderr || "git branch failed",
1969 );
2022 return yield* new GitCommandError({
2023 ...gitCommandContext({
2024 operation: "GitVcsDriver.listRefs",
2025 cwd: input.cwd,
2026 args: ["branch", "--no-color", "--no-column"],
2027 }),
2028 detail: "Git branch listing failed.",
2029 exitCode: localBranchResult.exitCode,
2030 stdoutLength: localBranchResult.stdout.length,
2031 stderrLength: localBranchResult.stderr.length,
2032 });
19702033 }
19712034
19722035 const remoteBranchResultEffect = executeGit(
......@@ -1978,19 +2041,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
19782041 allowNonZeroExit: true,
19792042 },
19802043 ).pipe(
1981 Effect.catch((error) =>
1982 Effect.logWarning(
1983 `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`,
1984 ).pipe(
1985 Effect.as({
1986 exitCode: ChildProcessSpawner.ExitCode(1),
1987 stdout: "",
1988 stderr: "",
1989 stdoutTruncated: false,
1990 stderrTruncated: false,
1991 } satisfies GitVcsDriver.ExecuteGitResult),
1992 ),
1993 ),
2044 Effect.catchTags({
2045 GitCommandError: (error) =>
2046 Effect.logWarning(
2047 "Git remote ref lookup failed; falling back to an empty remote ref list.",
2048 {
2049 operation: error.operation,
2050 command: error.command,
2051 cwd: error.cwd,
2052 detail: error.detail,
2053 cause: error,
2054 },
2055 ).pipe(
2056 Effect.as({
2057 exitCode: ChildProcessSpawner.ExitCode(1),
2058 stdout: "",
2059 stderr: "",
2060 stdoutTruncated: false,
2061 stderrTruncated: false,
2062 } satisfies GitVcsDriver.ExecuteGitResult),
2063 ),
2064 }),
19942065 );
19952066
19962067 const remoteNamesResultEffect = executeGit(
......@@ -2002,19 +2073,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
20022073 allowNonZeroExit: true,
20032074 },
20042075 ).pipe(
2005 Effect.catch((error) =>
2006 Effect.logWarning(
2007 `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
2008 ).pipe(
2009 Effect.as({
2010 exitCode: ChildProcessSpawner.ExitCode(1),
2011 stdout: "",
2012 stderr: "",
2013 stdoutTruncated: false,
2014 stderrTruncated: false,
2015 } satisfies GitVcsDriver.ExecuteGitResult),
2016 ),
2017 ),
2076 Effect.catchTags({
2077 GitCommandError: (error) =>
2078 Effect.logWarning(
2079 "Git remote name lookup failed; falling back to an empty remote name list.",
2080 {
2081 operation: error.operation,
2082 command: error.command,
2083 cwd: error.cwd,
2084 detail: error.detail,
2085 cause: error,
2086 },
2087 ).pipe(
2088 Effect.as({
2089 exitCode: ChildProcessSpawner.ExitCode(1),
2090 stdout: "",
2091 stderr: "",
2092 stdoutTruncated: false,
2093 stderrTruncated: false,
2094 } satisfies GitVcsDriver.ExecuteGitResult),
2095 ),
2096 }),
20182097 );
20192098
20202099 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
......@@ -2175,7 +2254,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21752254 : ["worktree", "add", worktreePath, input.refName];
21762255
21772256 yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, {
2178 fallbackErrorMessage: "git worktree add failed",
2257 fallbackErrorDetail: "git worktree add failed",
21792258 });
21802259
21812260 if (input.newRefName && input.baseRefName) {
......@@ -2214,7 +2293,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22142293 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
22152294 ],
22162295 {
2217 fallbackErrorMessage: "git fetch pull request branch failed",
2296 fallbackErrorDetail: "git fetch pull request branch failed",
22182297 },
22192298 );
22202299 });
......@@ -2227,7 +2306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22272306 ["fetch", "--quiet", input.remoteName],
22282307 {
22292308 env: STATUS_UPSTREAM_REFRESH_ENV,
2230 fallbackErrorMessage: `git fetch ${input.remoteName} failed`,
2309 fallbackErrorDetail: `git fetch ${input.remoteName} failed`,
22312310 },
22322311 );
22332312 },
......@@ -2302,18 +2381,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23022381 args.push(input.path);
23032382 yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, {
23042383 timeoutMs: 15_000,
2305 fallbackErrorMessage: "git worktree remove failed",
2306 }).pipe(
2307 Effect.mapError((error) =>
2308 createGitCommandError(
2309 "GitVcsDriver.removeWorktree",
2310 input.cwd,
2311 args,
2312 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`,
2313 error,
2314 ),
2315 ),
2316 );
2384 fallbackErrorDetail: "git worktree remove failed",
2385 });
23172386 });
23182387
23192388 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(
......@@ -2330,7 +2399,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23302399 ["branch", "-m", "--", input.oldBranch, targetBranch],
23312400 {
23322401 timeoutMs: 10_000,
2333 fallbackErrorMessage: "git branch rename failed",
2402 fallbackErrorDetail: "git branch rename failed",
23342403 },
23352404 );
23362405
......@@ -2407,7 +2476,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24072476
24082477 yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, {
24092478 timeoutMs: 10_000,
2410 fallbackErrorMessage: "git checkout failed",
2479 fallbackErrorDetail: "git checkout failed",
24112480 });
24122481
24132482 const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [
......@@ -2423,7 +2492,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24232492 function* (input) {
24242493 yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], {
24252494 timeoutMs: 10_000,
2426 fallbackErrorMessage: "git branch create failed",
2495 fallbackErrorDetail: "git branch create failed",
24272496 });
24282497 if (input.switchRef) {
24292498 yield* switchRef({ cwd: input.cwd, refName: input.refName });
......@@ -2436,7 +2505,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24362505 const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) =>
24372506 executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], {
24382507 timeoutMs: 10_000,
2439 fallbackErrorMessage: "git init failed",
2508 fallbackErrorDetail: "git init failed",
24402509 }).pipe(Effect.asVoid);
24412510
24422511 const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = (
apps/server/src/vcs/VcsDriverRegistry.ts+13-15
......@@ -36,13 +36,6 @@ export class VcsDriverRegistry extends Context.Service<
3636 }
3737>()("t3/vcs/VcsDriverRegistry") {}
3838
39const unsupported = (operation: string, kind: VcsDriverKind, detail: string) =>
40 new VcsUnsupportedOperationError({
41 operation,
42 kind,
43 detail,
44 });
45
4639function detectionCacheKey(input: {
4740 readonly cwd: string;
4841 readonly requestedKind: VcsDriverKind | "auto";
......@@ -78,7 +71,11 @@ export const make = Effect.gen(function* () {
7871 const driver = drivers[kind];
7972 if (!driver) {
8073 return Effect.fail(
81 unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`),
74 new VcsUnsupportedOperationError({
75 operation: "VcsDriverRegistry.get",
76 kind,
77 detail: `No ${kind} VCS driver is registered.`,
78 }),
8279 );
8380 }
8481 return Effect.succeed(driver);
......@@ -137,13 +134,14 @@ export const make = Effect.gen(function* () {
137134 }
138135
139136 const requestedKind = input.requestedKind ?? "auto";
140 return yield* unsupported(
141 "VcsDriverRegistry.resolve",
142 requestedKind === "auto" ? "unknown" : requestedKind,
143 requestedKind === "auto"
144 ? `No supported VCS repository was detected at ${input.cwd}.`
145 : `No ${requestedKind} repository was detected at ${input.cwd}.`,
146 );
137 return yield* new VcsUnsupportedOperationError({
138 operation: "VcsDriverRegistry.resolve",
139 kind: requestedKind === "auto" ? "unknown" : requestedKind,
140 detail:
141 requestedKind === "auto"
142 ? `No supported VCS repository was detected at ${input.cwd}.`
143 : `No ${requestedKind} repository was detected at ${input.cwd}.`,
144 });
147145 },
148146 );
149147
packages/contracts/src/git.ts+6-1
......@@ -324,11 +324,16 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()(
324324 operation: Schema.String,
325325 command: Schema.String,
326326 cwd: Schema.String,
327 argumentCount: Schema.optional(Schema.Number),
328 exitCode: Schema.optional(Schema.Number),
329 stdoutLength: Schema.optional(Schema.Number),
330 stderrLength: Schema.optional(Schema.Number),
331 outputLength: Schema.optional(Schema.Number),
327332 detail: Schema.String,
328333 cause: Schema.optional(Schema.Defect()),
329334}) {
330335 override get message(): string {
331 return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
336 return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`;
332337 }
333338}
334339