diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 81b82d7de30..d1cfc7d2a13 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3377,13 +3377,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.map((error) => error.message), ); - expect(errorMessage).toContain("hook: fail"); + expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); + expect(errorMessage).not.toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: fail", + }), expect.objectContaining({ kind: "action_failed", phase: "commit", diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2fd4d447c58..dc58fc2543c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -78,6 +78,114 @@ const initRepoWithCommit = ( }); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("structured errors", () => { + it.effect("preserves structured spawn context and the platform cause", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.missingCwd", + cwd, + args: ["status", "--short"], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.missingCwd", + command: "git", + argumentCount: 2, + cwd, + detail: "Failed to spawn Git process.", + }); + if (!(error.cause instanceof PlatformError.PlatformError)) { + return assert.fail("expected the original platform error cause"); + } + assert.equal(error.cause.reason._tag, "NotFound"); + assert.notInclude(error.detail, error.cause.message); + }), + ); + + it.effect("does not retain git arguments or stderr in command failures", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const secret = "secret-token-value"; + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.redactedFailure", + cwd, + args: ["status", `--unknown-option=${secret}`], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.redactedFailure", + command: "git", + argumentCount: 2, + cwd, + }); + assert.isNumber(error.exitCode); + assert.isAbove(error.stderrLength ?? 0, 0); + assert.notInclude(error.detail, secret); + assert.notInclude(error.message, secret); + assert.notProperty(error, "args"); + assert.notProperty(error, "stderr"); + }), + ); + + it.effect("recovers a structurally identified missing cwd as a non-repository", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const [localStatus, remoteStatus, refs] = yield* Effect.all([ + driver.statusDetails(cwd), + driver.statusDetailsRemote(cwd, { refreshUpstream: false }), + driver.listRefs({ cwd }), + ]); + + assert.equal(localStatus.isRepo, false); + assert.equal(remoteStatus.isRepo, false); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("does not wrap a remove-worktree command failure in a synthetic error", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const missingWorktree = pathService.join(cwd, "missing-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const error = yield* driver + .removeWorktree({ cwd, path: missingWorktree }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.removeWorktree", + command: "git", + argumentCount: 3, + cwd, + }); + assert.notProperty(error, "cause"); + assert.notInclude(error.detail, "Git command failed in"); + }), + ); + }); + describe("review diff previews", () => { it.effect("drops an unterminated path from truncated NUL-separated git output", () => Effect.sync(() => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 23a968a9cfe..a406cbce549 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -36,7 +36,6 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; -const isGitCommandError = Schema.is(GitCommandError); const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -100,7 +99,7 @@ interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; - fallbackErrorMessage?: string | undefined; + fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; @@ -326,8 +325,15 @@ function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { return localBranch.length > 0 ? localBranch : null; } -function commandLabel(args: readonly string[]): string { - return `git ${args.join(" ")}`; +function gitCommandContext( + input: Pick, +) { + return { + operation: input.operation, + command: "git", + cwd: input.cwd, + argumentCount: input.args.length, + } as const; } function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { @@ -340,50 +346,28 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): return refName.length > 0 ? refName : null; } -function createGitCommandError( - operation: string, - cwd: string, - args: readonly string[], - detail: string, - cause?: unknown, -): GitCommandError { - return new GitCommandError({ - operation, - command: commandLabel(args), - cwd, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} +function isMissingGitCwdError(error: GitCommandError): boolean { + if (!(error.cause instanceof PlatformError.PlatformError)) { + return false; + } -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} + const reason = error.cause.reason; + if (reason._tag === "NotFound") { + return reason.pathOrDescriptor === error.cwd; + } -function isMissingGitCwdError(error: GitCommandError): boolean { - const normalized = `${error.detail}\n${error.message}`.toLowerCase(); return ( - normalized.includes("no such file or directory") || - normalized.includes("notfound: filesystem.access") || - normalized.includes("enoent") || - normalized.includes("not a directory") + reason._tag === "BadResource" && + reason.pathOrDescriptor === error.cwd && + typeof reason.cause === "object" && + reason.cause !== null && + "code" in reason.cause && + reason.cause.code === "ENOTDIR" ); } -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - isGitCommandError(cause) - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); +function isNonRepositoryGitStderr(stderr: string): boolean { + return stderr.toLowerCase().includes("not a git repository"); } interface Trace2Monitor { @@ -402,7 +386,11 @@ const addCurrentSpanEvent = (name: string, attributes: Record) yield* Effect.sync(() => { span.event(name, timestamp, compactTraceAttributes(attributes)); }); - }).pipe(Effect.catch(() => Effect.void)); + }).pipe( + Effect.catchTags({ + NoSuchElementError: () => Effect.void, + }), + ); function trace2ChildKey(record: Record): string | null { const childId = record.child_id; @@ -451,7 +439,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( - `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, + `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, traceRecord.failure, ); return; @@ -574,9 +562,9 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); -const collectOutput = Effect.fnUntraced(function* ( +const collectOutput = Effect.fnUntraced(function* ( input: Pick, - stream: Stream.Stream, + stream: Stream.Stream, maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, @@ -614,10 +602,9 @@ const collectOutput = Effect.fnUntraced(function* ( const nextBytes = bytes + chunk.byteLength; if (!appendTruncationMarker && nextBytes > maxOutputBytes) { return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + ...gitCommandContext(input), + detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`, + outputLength: nextBytes, }); } @@ -635,7 +622,14 @@ const collectOutput = Effect.fnUntraced(function* ( }); yield* Stream.runForEach(stream, processChunk).pipe( - Effect.mapError(toGitCommandError(input, "output stream failed.")), + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ + ...gitCommandContext(input), + detail: "Failed to read Git process output.", + cause, + }), + }), ); const remainder = truncated ? "" : decoder.decode(); @@ -669,7 +663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( Effect.provideService(Path.Path, path), Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to create Git trace monitor.", + cause, + }), + ), ); const child = yield* commandSpawner .spawn( @@ -682,7 +683,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }), + ), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -701,12 +711,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.progress?.onStderrLine, ), child.exitCode.pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to read Git process exit code.", + cause, + }), + ), ), input.stdin === undefined ? Effect.void : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to write Git process input.", + cause, + }), + ), ), ], { concurrency: "unbounded" }, @@ -714,15 +738,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* trace2Monitor.flush; if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.text.trim(); return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", + exitCode, + stdoutLength: stdout.text.length, + stderrLength: stderr.text.length, }); } @@ -743,10 +764,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* onNone: () => Effect.fail( new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, + ...gitCommandContext(commandInput), + detail: "Git command timed out.", }), ), onSome: Effect.succeed, @@ -799,22 +818,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (options.allowNonZeroExit || result.exitCode === 0) { return Effect.succeed(result); } - const stderr = result.stderr.trim(); - if (stderr.length > 0) { - return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); - } - if (options.fallbackErrorMessage) { - return Effect.fail( - createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), - ); - } return Effect.fail( - createGitCommandError( - operation, - cwd, - args, - `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, - ), + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), ); }), ); @@ -877,12 +888,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - return yield* createGitCommandError( - "GitVcsDriver.renameBranch", - cwd, - ["branch", "-m", "--", desiredBranch], - `Could not find an available branch name for '${desiredBranch}'.`, - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.renameBranch", + cwd, + args: ["branch", "-m", "--", desiredBranch], + }), + detail: `Could not find an available branch name for '${desiredBranch}'.`, + }); }); const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { @@ -1024,12 +1037,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (firstRemote) { return firstRemote; } - return yield* createGitCommandError( - "GitVcsDriver.resolvePrimaryRemoteName", - cwd, - ["remote"], - "No git remote is configured for this repository.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolvePrimaryRemoteName", + cwd, + args: ["remote"], + }), + detail: "No git remote is configured for this repository.", + }); }); const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( @@ -1174,19 +1189,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, ["rev-parse", "--abbrev-ref", "HEAD"], { allowNonZeroExit: true }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); if (branchResult === null) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } if (branchResult.exitCode !== 0) { - const stderr = branchResult.stderr.trim(); - return yield* createGitCommandError( - "GitVcsDriver.statusDetailsRemote.branch", - cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], - stderr || "git branch lookup failed", - ); + if (isNonRepositoryGitStderr(branchResult.stderr)) { + return NON_REPOSITORY_REMOTE_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); } const branchValue = branchResult.stdout.trim(); @@ -1284,20 +1311,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { allowNonZeroExit: true, }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); if (statusResult === null) { return NON_REPOSITORY_STATUS_DETAILS; } if (statusResult.exitCode !== 0) { - const stderr = statusResult.stderr.trim(); - return yield* createGitCommandError( - "GitVcsDriver.statusDetails.status", - cwd, - ["status", "--porcelain=2", "--branch"], - stderr || "git status failed", - ); + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: ["status", "--porcelain=2", "--branch"], + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); } const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = @@ -1436,7 +1475,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "statusDetails", )(function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), Effect.ignoreCause({ log: true }), ); return yield* readStatusDetailsLocal(cwd); @@ -1446,7 +1488,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.fn("statusDetailsRemote")(function* (cwd, options) { if (options?.refreshUpstream !== false) { yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), Effect.ignoreCause({ log: true }), ); } @@ -1474,7 +1519,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { if (filePaths && filePaths.length > 0) { yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), + Effect.catchTags({ + GitCommandError: () => Effect.void, + }), ); yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ "add", @@ -1550,12 +1597,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const details = yield* statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push from detached HEAD.", + }); } const requestedRemoteName = options?.remoteName?.trim() || null; @@ -1614,12 +1663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (!details.hasUpstream) { const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); if (!publishRemoteName) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push because no git remote is configured for this repository.", + }); } const publishBranch = yield* resolvePublishBranchName(cwd, branch); yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ @@ -1668,20 +1719,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const details = yield* statusDetails(cwd); const refName = details.branch; if (!refName) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Cannot pull from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Current branch has no upstream configured. Push with upstream first.", + }); } const beforeSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.beforeSha", @@ -1691,7 +1746,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => stdout.trim())); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", + fallbackErrorDetail: "git pull failed", }); const afterSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.afterSha", @@ -1874,14 +1929,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( Effect.map(Encoding.encodeHex), Effect.mapError( - toGitCommandError( - { + (cause) => + new GitCommandError({ operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", cwd: input.cwd, - args: [], - }, - "failed to hash review diff.", - ), + detail: "Failed to hash review diff.", + cause, + }), ), ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ @@ -1939,20 +1994,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) + ? Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }) + : Effect.fail(error), + }), ); if (localBranchResult.exitCode !== 0) { const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { + if (isNonRepositoryGitStderr(stderr)) { return { refs: [], isRepo: false, @@ -1961,12 +2019,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* totalCount: 0, }; } - return yield* createGitCommandError( - "GitVcsDriver.listRefs", - input.cwd, - ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.listRefs", + cwd: input.cwd, + args: ["branch", "--no-color", "--no-column"], + }), + detail: "Git branch listing failed.", + exitCode: localBranchResult.exitCode, + stdoutLength: localBranchResult.stdout.length, + stderrLength: localBranchResult.stderr.length, + }); } const remoteBranchResultEffect = executeGit( @@ -1978,19 +2041,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - ), + Effect.catchTags({ + GitCommandError: (error) => + Effect.logWarning( + "Git remote ref lookup failed; falling back to an empty remote ref list.", + { + operation: error.operation, + command: error.command, + cwd: error.cwd, + detail: error.detail, + cause: error, + }, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + }), ); const remoteNamesResultEffect = executeGit( @@ -2002,19 +2073,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - ), + Effect.catchTags({ + GitCommandError: (error) => + Effect.logWarning( + "Git remote name lookup failed; falling back to an empty remote name list.", + { + operation: error.operation, + command: error.command, + cwd: error.cwd, + detail: error.detail, + cause: error, + }, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + }), ); const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = @@ -2175,7 +2254,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : ["worktree", "add", worktreePath, input.refName]; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + fallbackErrorDetail: "git worktree add failed", }); if (input.newRefName && input.baseRefName) { @@ -2214,7 +2293,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, ], { - fallbackErrorMessage: "git fetch pull request branch failed", + fallbackErrorDetail: "git fetch pull request branch failed", }, ); }); @@ -2227,7 +2306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["fetch", "--quiet", input.remoteName], { env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorMessage: `git fetch ${input.remoteName} failed`, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }, ); }, @@ -2302,18 +2381,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), - ), - ); + fallbackErrorDetail: "git worktree remove failed", + }); }); const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( @@ -2330,7 +2399,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["branch", "-m", "--", input.oldBranch, targetBranch], { timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", + fallbackErrorDetail: "git branch rename failed", }, ); @@ -2407,7 +2476,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", + fallbackErrorDetail: "git checkout failed", }); const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ @@ -2423,7 +2492,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* function* (input) { yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", + fallbackErrorDetail: "git branch create failed", }); if (input.switchRef) { yield* switchRef({ cwd: input.cwd, refName: input.refName }); @@ -2436,7 +2505,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", + fallbackErrorDetail: "git init failed", }).pipe(Effect.asVoid); const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 103cc9607c1..0bf95c2ffba 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -36,13 +36,6 @@ export class VcsDriverRegistry extends Context.Service< } >()("t3/vcs/VcsDriverRegistry") {} -const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => - new VcsUnsupportedOperationError({ - operation, - kind, - detail, - }); - function detectionCacheKey(input: { readonly cwd: string; readonly requestedKind: VcsDriverKind | "auto"; @@ -78,7 +71,11 @@ export const make = Effect.gen(function* () { const driver = drivers[kind]; if (!driver) { return Effect.fail( - unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), + new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.get", + kind, + detail: `No ${kind} VCS driver is registered.`, + }), ); } return Effect.succeed(driver); @@ -137,13 +134,14 @@ export const make = Effect.gen(function* () { } const requestedKind = input.requestedKind ?? "auto"; - return yield* unsupported( - "VcsDriverRegistry.resolve", - requestedKind === "auto" ? "unknown" : requestedKind, - requestedKind === "auto" - ? `No supported VCS repository was detected at ${input.cwd}.` - : `No ${requestedKind} repository was detected at ${input.cwd}.`, - ); + return yield* new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.resolve", + kind: requestedKind === "auto" ? "unknown" : requestedKind, + detail: + requestedKind === "auto" + ? `No supported VCS repository was detected at ${input.cwd}.` + : `No ${requestedKind} repository was detected at ${input.cwd}.`, + }); }, ); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 7ee2a571963..38aba54277c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -324,11 +324,16 @@ export class GitCommandError extends Schema.TaggedErrorClass()( operation: Schema.String, command: Schema.String, cwd: Schema.String, + argumentCount: Schema.optional(Schema.Number), + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + outputLength: Schema.optional(Schema.Number), detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { - return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`; } }