diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710..9d9c7e2eb2b 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -3,6 +3,8 @@ import { useCallback, useEffect, useMemo } from "react"; import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { + buildUnsignedCommitRetryInput, + isCommitSigningFailure, type GitActionRequestInput, type VcsActionOperation, type VcsRef, @@ -14,6 +16,7 @@ import { } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; +import { Alert } from "react-native"; import { useBranches } from "../state/queries"; import { threadEnvironment } from "../state/threads"; @@ -131,7 +134,10 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { + readonly managedExternally?: boolean; + readonly onFailure?: (error: unknown) => boolean; + }, ): Promise => { if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; @@ -154,6 +160,9 @@ export function useSelectedThreadGitActions() { : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); + if (options?.onFailure?.(error) === true) { + return null; + } const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); @@ -319,49 +328,95 @@ export function useSelectedThreadGitActions() { const onRunSelectedThreadGitAction = useCallback( async (input: GitActionRequestInput): Promise => { - const actionId = uuidv4(); - return await runSelectedThreadGitMutation( - "run_change_request", - "Running source control action", - async ({ thread, cwd }) => { - const result = await runStackedAction({ - actionId, - action: input.action, - ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), - ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), - ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), - }); - if (AsyncResult.isFailure(result)) { - return result; - } - - showGitActionResult({ - type: "success", - title: result.value.toast.title, - description: result.value.toast.description, - prUrl: - result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, - }); + async function runAction( + actionInput: GitActionRequestInput, + options?: { readonly syncCurrentBranchOnSuccess?: boolean }, + ): Promise { + const actionId = uuidv4(); + return await runSelectedThreadGitMutation( + "run_change_request", + "Running source control action", + async ({ thread, cwd }) => { + const result = await runStackedAction({ + actionId, + action: actionInput.action, + ...(actionInput.commitMessage ? { commitMessage: actionInput.commitMessage } : {}), + ...(actionInput.featureBranch ? { featureBranch: actionInput.featureBranch } : {}), + ...(actionInput.disableCommitSigning ? { disableCommitSigning: true } : {}), + ...(actionInput.filePaths?.length ? { filePaths: [...actionInput.filePaths] } : {}), + }); + if (AsyncResult.isFailure(result)) { + return result; + } - if (result.value.branch.status === "created" && result.value.branch.name) { - const syncResult = await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result.value.branch.name, - worktreePath: selectedThreadWorktreePath, - }, + showGitActionResult({ + type: "success", + title: result.value.toast.title, + description: result.value.toast.description, + prUrl: + result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, }); - if (AsyncResult.isFailure(syncResult)) { - return AsyncResult.failure(syncResult.cause); + + if (result.value.branch.status === "created" && result.value.branch.name) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.branch.name, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } else if (options?.syncCurrentBranchOnSuccess) { + const status = await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + if (status?.refName) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: status.refName, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } + } else { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); } - } else { - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - } - return result; - }, - { managedExternally: true }, - ); + return result; + }, + { + managedExternally: true, + onFailure: (error) => { + if (actionInput.disableCommitSigning || !isCommitSigningFailure(error)) { + return false; + } + Alert.alert( + "Commit signing failed", + "Git couldn’t sign the commit. Retry this commit without signing?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Retry without signing", + onPress: () => { + void runAction(buildUnsignedCommitRetryInput(actionInput), { + syncCurrentBranchOnSuccess: actionInput.featureBranch === true, + }); + }, + }, + ], + ); + return true; + }, + }, + ); + } + + return await runAction(input); }, [ runStackedAction, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 000a9d99c6b..a9610939d4b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -246,6 +246,7 @@ function initRepo( yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* runGit(cwd, ["config", "commit.gpgSign", "false"]); yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); @@ -609,6 +610,7 @@ function runStackedAction( actionId?: string; commitMessage?: string; featureBranch?: boolean; + disableCommitSigning?: boolean; filePaths?: readonly string[]; }, options?: Parameters[1], @@ -622,6 +624,19 @@ function runStackedAction( ); } +const configureFailingCommitSigner = Effect.fn("configureFailingCommitSigner")(function* ( + repoDir: string, +) { + const signerPath = NodePath.join(repoDir, "failing-signer.sh"); + NodeFS.writeFileSync( + signerPath, + "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", + { mode: 0o755 }, + ); + yield* runGit(repoDir, ["config", "commit.gpgSign", "true"]); + yield* runGit(repoDir, ["config", "gpg.program", signerPath]); +}); + function resolvePullRequest( manager: GitManager.GitManager["Service"], input: { cwd: string; reference: string }, @@ -3624,6 +3639,63 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("does not attribute ambiguous output to the wrong commit hook", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "multi-hook.txt"), "multi hook\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "output from pre-commit" >&2\n', + { mode: 0o755 }, + ); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "commit-msg"), + '#!/bin/sh\necho "output from commit-msg" >&2\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "test: preserve hook output attribution", + }, + { + actionId: "action-multi-hook", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("created"); + const hookOutput = events.filter((event) => event.kind === "hook_output"); + const preCommitOutput = hookOutput.find((event) => + event.text.includes("output from pre-commit"), + ); + const commitMsgOutput = hookOutput.find((event) => + event.text.includes("output from commit-msg"), + ); + const gitOutput = hookOutput.find((event) => + event.text.includes("test: preserve hook output attribution"), + ); + + expect(preCommitOutput).toBeDefined(); + expect([null, "pre-commit"]).toContain(preCommitOutput?.hookName); + expect(commitMsgOutput).toBeDefined(); + expect([null, "commit-msg"]).toContain(commitMsgOutput?.hookName); + expect(gitOutput).toMatchObject({ hookName: null }); + }), + ); + it.effect("emits action_failed when a commit hook rejects", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3673,12 +3745,155 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect.objectContaining({ kind: "action_failed", phase: "commit", + failureKind: "unknown", }), ]), ); }), ); + it.effect( + "classifies signing failures and retries a created feature branch without branching again", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "signing.txt"), "signing retry\n"); + yield* configureFailingCommitSigner(repoDir); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "feat: signing retry", + featureBranch: true, + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "action_failed", + phase: "commit", + failureKind: "commit_signing_failed", + }), + ]), + ); + expect( + events.some( + (event) => event.kind === "hook_output" && event.text.includes("No secret key"), + ), + ).toBe(false); + const branchAfterFailure = yield* runGit(repoDir, ["branch", "--show-current"]).pipe( + Effect.map((result) => result.stdout.trim()), + ); + const branchCountAfterFailure = yield* runGit(repoDir, [ + "branch", + "--format=%(refname)", + ]).pipe(Effect.map((result) => result.stdout.trim().split("\n").length)); + + const retried = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessage: "feat: signing retry", + disableCommitSigning: true, + }); + const branchCountAfterRetry = yield* runGit(repoDir, [ + "branch", + "--format=%(refname)", + ]).pipe(Effect.map((result) => result.stdout.trim().split("\n").length)); + + expect(retried.commit.status).toBe("created"); + expect(retried.branch.status).toBe("skipped_not_requested"); + expect(branchAfterFailure).toBe("feature/feat-signing-retry"); + expect(branchCountAfterRetry).toBe(branchCountAfterFailure); + }), + ); + + for (const action of ["commit", "commit_push", "commit_push_pr"] as const) { + it.effect(`forwards the unsigned override for ${action}`, () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + if (action !== "commit") { + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", `feature/unsigned-${action}`]); + } + NodeFS.writeFileSync(NodePath.join(repoDir, `${action}.txt`), `${action}\n`); + yield* configureFailingCommitSigner(repoDir); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action, + commitMessage: `test: ${action} unsigned`, + disableCommitSigning: true, + }); + + expect(result.commit.status).toBe("created"); + if (action !== "commit") { + expect(result.push.status).toBe("pushed"); + } + if (action === "commit_push_pr") { + expect(result.pr.status).toBe("created"); + } + }), + ); + } + + it.effect("does not advertise another retry when an unsigned attempt fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "unsigned-hook.txt"), "hook failure\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "test: unsigned hook failure", + disableCommitSigning: true, + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + + const failure = events.find((event) => event.kind === "action_failed"); + expect(failure).toMatchObject({ + kind: "action_failed", + phase: "commit", + failureKind: "unknown", + }); + }), + ); + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e59613f7cc8..290c8d95385 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1339,6 +1339,7 @@ export const make = Effect.gen(function* () { commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], + disableSigning?: boolean, progressReporter?: GitActionProgressReporter, actionId?: string, ) { @@ -1381,28 +1382,51 @@ export const make = Effect.gen(function* () { }); let currentHookName: string | null = null; + let sawCommitHook = false; + let pendingUnattributedOutput: Array<{ stream: "stdout" | "stderr"; text: string }> = []; + const emitHookOutput = ( + hookName: string | null, + { stream, text }: { stream: "stdout" | "stderr"; text: string }, + ) => { + const sanitized = sanitizeProgressText(text); + if (!sanitized) { + return Effect.void; + } + return emit({ + kind: "hook_output", + hookName, + stream, + text: sanitized, + }); + }; + const finalizeUnattributedOutput = (shouldEmit: boolean) => + Effect.suspend(() => { + const pending = pendingUnattributedOutput; + pendingUnattributedOutput = []; + return shouldEmit + ? Effect.forEach(pending, (output) => emitHookOutput(null, output), { discard: true }) + : Effect.void; + }); const commitProgress = progressReporter && actionId ? { - onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { - const sanitized = sanitizeProgressText(text); - if (!sanitized) { - return Effect.void; - } - return emit({ - kind: "hook_output", - hookName: currentHookName, - stream, - text: sanitized, - }); - }, - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return emit({ - kind: "hook_started", - hookName, - }); - }, + onOutputLine: (output: { stream: "stdout" | "stderr"; text: string }) => + Effect.suspend(() => { + if (currentHookName === null) { + pendingUnattributedOutput.push(output); + return Effect.void; + } + return emitHookOutput(currentHookName, output); + }), + onHookStarted: (hookName: string) => + Effect.suspend(() => { + sawCommitHook = true; + currentHookName = hookName; + return emit({ + kind: "hook_started", + hookName, + }); + }), onHookFinished: ({ hookName, exitCode, @@ -1424,10 +1448,19 @@ export const make = Effect.gen(function* () { }, } : null; - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, - ...(commitProgress ? { progress: commitProgress } : {}), - }); + const { commitSha } = yield* gitCore + .commit(cwd, suggestion.subject, suggestion.body, { + timeoutMs: COMMIT_TIMEOUT_MS, + ...(disableSigning ? { disableSigning: true } : {}), + ...(commitProgress ? { progress: commitProgress } : {}), + }) + .pipe( + Effect.tapError((error) => + finalizeUnattributedOutput( + sawCommitHook && error.failureKind !== "commit_signing_failed", + ), + ), + ); if (currentHookName !== null) { yield* emit({ kind: "hook_finished", @@ -1437,6 +1470,7 @@ export const make = Effect.gen(function* () { }); currentHookName = null; } + yield* finalizeUnattributedOutput(sawCommitHook); return { status: "created" as const, commitSha, @@ -1937,6 +1971,7 @@ export const make = Effect.gen(function* () { commitMessageForStep, preResolvedCommitSuggestion, input.filePaths, + input.disableCommitSigning, options?.progressReporter, progress.actionId, ), @@ -2003,6 +2038,10 @@ export const make = Effect.gen(function* () { kind: "action_failed", phase: Option.getOrNull(phase), message: error.message, + failureKind: + !input.disableCommitSigning && error._tag === "GitCommandError" + ? error.failureKind + : "unknown", }), ), ), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..ca67f421908 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -170,6 +170,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: "Failed to resolve the VCS driver for this Git command.", cause, }), @@ -180,6 +181,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } @@ -222,6 +224,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: "Failed to detect a VCS repository for this Git command.", cause, }), @@ -235,6 +238,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c8c4ff377e8..83657f3dcfd 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5114,6 +5114,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { operation: "pull", command: "git pull --ff-only", cwd: "/tmp/repo", + failureKind: "unknown", detail: "upstream missing", }); let invalidationCalls = 0; @@ -5194,6 +5195,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { operation: "commit", command: "git commit", cwd: "/tmp/repo", + failureKind: "unknown", detail: "nothing to commit", }); let invalidationCalls = 0; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b..141d1cef4c9 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -656,6 +656,7 @@ it.effect("preserves Git checkout failures without deriving the domain message f operation: "fetchRemoteBranch", command: "git fetch origin feature/source-control", cwd: "/repo", + failureKind: "unknown", detail: "remote rejected the request", }); const { layer } = makeLayer({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..a5de4117b7c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -364,6 +364,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no operation: input.operation, command: "git rev-parse --verify HEAD", cwd: input.cwd, + failureKind: "unknown", detail: "fatal: Needed a single revision", }), ) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..57cab42c3f8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -111,6 +111,7 @@ export interface GitCommitProgress { export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; + readonly disableSigning?: boolean; } export interface GitPushResult { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ecd2d0ad2b0..5d2d7c6b8e8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -12,7 +12,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + isCommitSigningFailureStderr, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -88,6 +91,7 @@ const initRepoWithCommit = ( yield* driver.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); + yield* git(cwd, ["config", "commit.gpgSign", "false"]); yield* writeTextFile(cwd, "README.md", "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); @@ -772,6 +776,75 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.include(status, "?? selected1.txt"); }), ); + + it("recognizes representative GPG, pinentry, and SSH signing diagnostics", () => { + assert.isTrue(isCommitSigningFailureStderr("error: gpg failed to sign the data")); + assert.isTrue( + isCommitSigningFailureStderr( + "gpg: signing failed: Inappropriate ioctl for device\nfatal: failed to write commit object", + ), + ); + assert.isTrue(isCommitSigningFailureStderr("error: ssh-keygen failed to sign the data")); + assert.isTrue( + isCommitSigningFailureStderr( + "error: Couldn't load public key /tmp/missing.pub: No such file or directory", + ), + ); + assert.isFalse(isCommitSigningFailureStderr("fatal: failed to write commit object")); + }); + + it.effect("classifies signing failures and can commit unsigned for one attempt", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const signerPath = pathService.join(cwd, "failing-signer.sh"); + yield* fileSystem.writeFileString( + signerPath, + "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", + ); + yield* fileSystem.chmod(signerPath, 0o755); + yield* git(cwd, ["config", "commit.gpgSign", "true"]); + yield* git(cwd, ["config", "gpg.program", signerPath]); + yield* writeTextFile(cwd, "signed.txt", "sign me\n"); + yield* git(cwd, ["add", "signed.txt"]); + + const error = yield* driver.commit(cwd, "Signed commit", "").pipe(Effect.flip); + assert.equal(error.failureKind, "commit_signing_failed"); + assert.notProperty(error, "stderr"); + + const commit = yield* driver.commit(cwd, "Unsigned commit", "", { + disableSigning: true, + }); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Unsigned commit"); + assert.notInclude(yield* git(cwd, ["cat-file", "commit", "HEAD"]), "gpgsig "); + assert.equal(yield* git(cwd, ["config", "--bool", "commit.gpgSign"]), "true"); + }), + ); + + it.effect("does not classify a failed commit hook as a signing failure", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const hookPath = pathService.join(cwd, ".git", "hooks", "pre-commit"); + yield* fileSystem.writeFileString( + hookPath, + "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", + ); + yield* fileSystem.chmod(hookPath, 0o755); + yield* writeTextFile(cwd, "hooked.txt", "fail first\n"); + yield* git(cwd, ["add", "hooked.txt"]); + + const error = yield* driver.commit(cwd, "Hook failure", "").pipe(Effect.flip); + assert.equal(error.failureKind, "unknown"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..8dd9f050f65 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -61,6 +61,29 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; + +const COMMIT_SIGNING_FAILURE_PATTERNS = [ + /gpg(?:2)?(?:\.exe)?: .*failed to sign/i, + /gpg failed to sign the data/i, + /signing failed:/i, + /failed to sign the data/i, + /pinentry.*(?:failed|error|not found|no such file|cancell?ed)/i, + /(?:failed|error|no such file|cancell?ed).*pinentry/i, + /inappropriate ioctl for device/i, + /cannot open \/dev\/tty/i, + /no secret key/i, + /secret key not available/i, + /ssh-keygen(?:\.exe)?:?.*(?:failed|error|couldn['’]t).*sign/i, + /couldn['’]t sign (?:message|data)/i, + /couldn['’]t load public key/i, + /no private key found for public key/i, + /load key .*: (?:invalid format|no such file or directory|permission denied)/i, + /agent refused operation/i, +] as const; + +export function isCommitSigningFailureStderr(stderr: string): boolean { + return COMMIT_SIGNING_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr)); +} const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -325,6 +348,7 @@ function gitCommandContext( command: "git", cwd: input.cwd, argumentCount: input.args.length, + failureKind: "unknown" as const, } as const; } @@ -437,16 +461,15 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - if (traceRecord.success.child_class !== "hook") { - return; - } - const event = traceRecord.success.event; const childKey = trace2ChildKey(traceRecord.success); if (childKey === null) { return; } const started = hookStartByChildKey.get(childKey); + if (traceRecord.success.child_class !== "hook" && started === undefined) { + return; + } const hookNameFromEvent = typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); @@ -468,7 +491,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.exitCode; + const code = traceRecord.success.code ?? traceRecord.success.exitCode; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; const now = yield* DateTime.now; const durationMs = started @@ -1571,25 +1594,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - const args = ["commit", "-m", subject]; + const args = ["commit"]; + if (options?.disableSigning) { + args.push("--no-gpg-sign"); + } + args.push("-m", subject); const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } - const progress = - options?.progress?.onOutputLine === undefined - ? options?.progress - : { - ...options.progress, + let hookFailed = false; + const progress: GitVcsDriver.ExecuteGitProgress = { + ...(options?.progress?.onOutputLine + ? { onStdoutLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, - }; - yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + } + : {}), + ...(options?.progress?.onHookStarted + ? { onHookStarted: options.progress.onHookStarted } + : {}), + onHookFinished: (input) => { + if (input.exitCode !== null && input.exitCode !== 0) { + hookFailed = true; + } + return options?.progress?.onHookFinished?.(input) ?? Effect.void; + }, + }; + const result = yield* executeGitWithStableDiagnostics("GitVcsDriver.commit.commit", cwd, args, { + allowNonZeroExit: true, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); + progress, + }); + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }), + detail: "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + ...(!options?.disableSigning && !hookFailed && isCommitSigningFailureStderr(result.stderr) + ? { failureKind: "commit_signing_failed" as const } + : {}), + }); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", @@ -1941,6 +1991,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* operation: "GitVcsDriver.getReviewDiffPreview.hash", command: "crypto.digest SHA-256", cwd: input.cwd, + failureKind: "unknown", detail: "Failed to hash review diff.", cause, }), diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f71e855a18f..d7f2cdbe983 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,6 +4,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + buildUnsignedCommitRetryInput, + isCommitSigningFailure, +} from "@t3tools/client-runtime/state/vcs"; import type { GitActionProgressEvent, GitRunStackedActionResult, @@ -103,7 +107,7 @@ interface PendingDefaultBranchAction { includesCommit: boolean; commitMessage?: string; onConfirmed?: () => void; - filePaths?: string[]; + filePaths?: ReadonlyArray; } type PublishProviderKind = Extract< @@ -132,8 +136,9 @@ interface RunGitActionWithToastInput { skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; + disableCommitSigning?: boolean; progressToastId?: GitActionToastId; - filePaths?: string[]; + filePaths?: ReadonlyArray; } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; @@ -1019,6 +1024,7 @@ export default function GitActionsControl({ title: progress.title, description: resolveProgressDescription(progress), timeout: 0, + actionProps: undefined, data: progress.toastData, }); }, []); @@ -1250,6 +1256,7 @@ export default function GitActionsControl({ skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, + disableCommitSigning = false, progressToastId, filePaths, }: RunGitActionWithToastInput) => { @@ -1326,6 +1333,7 @@ export default function GitActionsControl({ title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, + actionProps: undefined, data: scopedToastData, }); } @@ -1391,6 +1399,7 @@ export default function GitActionsControl({ action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(disableCommitSigning ? { disableCommitSigning: true } : {}), ...(filePaths ? { filePaths } : {}), onProgress: applyProgressEvent, }); @@ -1403,6 +1412,42 @@ export default function GitActionsControl({ } const error = squashAtomCommandFailure(result); + if (!disableCommitSigning && isCommitSigningFailure(error)) { + const retryInput = buildUnsignedCommitRetryInput({ + action, + ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch ? { featureBranch: true } : {}), + ...(filePaths ? { filePaths } : {}), + }); + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "error", + title: "Commit signing failed", + description: "Git couldn’t sign the commit. Retry this commit without signing?", + timeout: 0, + actionProps: { + children: "Retry without signing", + onClick: () => { + void runGitActionWithToast({ + action: retryInput.action, + ...(retryInput.commitMessage !== undefined + ? { commitMessage: retryInput.commitMessage } + : {}), + ...(retryInput.filePaths !== undefined + ? { filePaths: retryInput.filePaths } + : {}), + disableCommitSigning: true, + skipDefaultBranchPrompt: true, + progressToastId: resolvedProgressToastId, + }); + }, + }, + ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), + }), + ); + return; + } toastManager.update( resolvedProgressToastId, stackedThreadToast({ diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..6b4cd9e3c03 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -217,7 +217,8 @@ export function useGitStackedAction(scope: SourceControlActionScope) { action: GitStackedAction; commitMessage?: string; featureBranch?: boolean; - filePaths?: string[]; + disableCommitSigning?: boolean; + filePaths?: ReadonlyArray; onProgress?: (event: GitActionProgressEvent) => void; }) => { if (resolveScope(scope) === null) { @@ -236,6 +237,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.disableCommitSigning ? { disableCommitSigning: true } : {}), ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), ...(input.onProgress ? { onProgress: input.onProgress } : {}), }); diff --git a/packages/client-runtime/src/state/gitActions.test.ts b/packages/client-runtime/src/state/gitActions.test.ts new file mode 100644 index 00000000000..bcaad3f55ca --- /dev/null +++ b/packages/client-runtime/src/state/gitActions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildUnsignedCommitRetryInput } from "./gitActions.ts"; + +describe("buildUnsignedCommitRetryInput", () => { + it("preserves the requested action, custom message, and files while removing featureBranch", () => { + const filePaths = ["src/a.ts", "src/b.ts"]; + + expect( + buildUnsignedCommitRetryInput({ + action: "commit_push_pr", + commitMessage: "feat: preserve this message", + featureBranch: true, + filePaths, + }), + ).toEqual({ + action: "commit_push_pr", + commitMessage: "feat: preserve this message", + filePaths, + disableCommitSigning: true, + }); + }); + + it("leaves generated commit messages absent so they may be regenerated", () => { + expect( + buildUnsignedCommitRetryInput({ + action: "commit", + featureBranch: true, + }), + ).toEqual({ + action: "commit", + disableCommitSigning: true, + }); + }); +}); diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index 436db17110f..ac60764b9e8 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -41,9 +41,18 @@ export type DefaultBranchConfirmableAction = export type GitActionRequestInput = Pick< GitRunStackedActionInput, - "action" | "commitMessage" | "featureBranch" | "filePaths" + "action" | "commitMessage" | "featureBranch" | "disableCommitSigning" | "filePaths" >; +export function buildUnsignedCommitRetryInput(input: GitActionRequestInput): GitActionRequestInput { + return { + action: input.action, + ...(input.commitMessage !== undefined ? { commitMessage: input.commitMessage } : {}), + ...(input.filePaths !== undefined ? { filePaths: input.filePaths } : {}), + disableCommitSigning: true, + }; +} + export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index dac852c166c..f038bea2988 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -21,6 +21,7 @@ import { createVcsActionTransportId, EMPTY_VCS_ACTION_STATE, getVcsActionTargetKey, + isCommitSigningFailure, normalizeVcsActionProgressEvent, parseVcsActionTargetKey, VcsActionMissingTerminalEventError, @@ -210,6 +211,7 @@ describe("vcsActionState", () => { kind: "action_failed", phase: null, message: "Push failed.", + failureKind: "unknown", }), ); @@ -350,6 +352,7 @@ describe("vcsActionState", () => { kind: "action_failed", phase: "push", message: remoteMessage, + failureKind: "unknown", }, ]), { @@ -369,6 +372,7 @@ describe("vcsActionState", () => { environmentId, cwd, phase: "push", + failureKind: "unknown", remoteMessageLength: remoteMessage.length, }); expect(error).not.toHaveProperty("detail"); @@ -377,6 +381,55 @@ describe("vcsActionState", () => { }), ); + it.effect("prefers a classified terminal failure over a following stream error", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const transportError = new Error("rpc stream closed"); + const stream = Stream.fromIterable([ + { + actionId: transportActionId, + action, + cwd, + kind: "action_failed", + phase: "commit", + message: "Remote diagnostic that must stay sanitized.", + failureKind: "commit_signing_failed", + }, + ]).pipe(Stream.concat(Stream.fail(transportError))); + + const error = yield* consumeVcsActionProgress(stream, { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsActionRemoteFailureError); + expect(error).toMatchObject({ failureKind: "commit_signing_failed" }); + expect(isCommitSigningFailure(error)).toBe(true); + }), + ); + + it.effect("preserves a stream error when no terminal failure was received", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const transportError = new Error("rpc stream closed"); + + const error = yield* consumeVcsActionProgress(Stream.fail(transportError), { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }).pipe(Effect.flip); + + expect(error).toBe(transportError); + }), + ); + it.effect("reports a missing terminal event as a protocol failure", () => Effect.gen(function* () { const target = { environmentId, cwd }; diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f2cba39cf9d..b49ec254ac2 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type EnvironmentId as EnvironmentIdType, GitActionProgressPhase, + GitActionFailureKind, type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, @@ -73,6 +74,7 @@ export interface RunVcsStackedActionInput { readonly action: GitStackedAction; readonly commitMessage?: string; readonly featureBranch?: boolean; + readonly disableCommitSigning?: boolean; readonly filePaths?: ReadonlyArray; readonly onProgress?: (event: GitActionProgressEvent) => void; } @@ -99,6 +101,7 @@ export class VcsActionRemoteFailureError extends Schema.TaggedErrorClass({ isRunning: false, operation: null, @@ -263,6 +274,19 @@ export function consumeVcsActionProgress( ): Effect.Effect { return Effect.suspend(() => { let terminalEvent: GitActionProgressEvent | null = null; + const remoteFailure = ( + event: Extract, + ): VcsActionRemoteFailureError => + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: event.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, + phase: event.phase, + failureKind: event.failureKind, + remoteMessageLength: event.message.length, + }); return stream.pipe( Stream.runForEach((event) => { const normalized = normalizeVcsActionProgressEvent( @@ -279,22 +303,18 @@ export function consumeVcsActionProgress( } return input.onProgress(normalized); }), + Effect.catch((error) => { + const terminal = terminalEvent; + const failure: E | VcsActionRemoteFailureError = + terminal?.kind === "action_failed" ? remoteFailure(terminal) : error; + return Effect.fail(failure); + }), Effect.flatMap(() => { if (terminalEvent?.kind === "action_finished") { return Effect.succeed(terminalEvent.result); } if (terminalEvent?.kind === "action_failed") { - return Effect.fail( - new VcsActionRemoteFailureError({ - actionId: input.actionId, - transportActionId: input.transportActionId, - action: terminalEvent.action, - environmentId: input.target.environmentId, - cwd: input.target.cwd, - phase: terminalEvent.phase, - remoteMessageLength: terminalEvent.message.length, - }), - ); + return Effect.fail(remoteFailure(terminalEvent)); } return Effect.fail( new VcsActionMissingTerminalEventError({ @@ -460,6 +480,7 @@ export function createVcsActionManager( action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.disableCommitSigning ? { disableCommitSigning: true } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), }; return consumeVcsActionProgress( diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 4ea86670ff8..0ac5c5fee2d 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -6,6 +6,8 @@ import { GitPreparePullRequestThreadInput, GitRunStackedActionResult, GitRunStackedActionInput, + GitActionProgressEvent, + GitCommandError, GitResolvePullRequestResult, } from "./git.ts"; @@ -15,6 +17,8 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( ); const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); +const decodeActionProgressEvent = Schema.decodeUnknownSync(GitActionProgressEvent); +const decodeGitCommandError = Schema.decodeUnknownSync(GitCommandError); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); describe("VcsCreateWorktreeInput", () => { @@ -83,6 +87,102 @@ describe("GitRunStackedActionInput", () => { expect(parsed.actionId).toBe("action-1"); expect(parsed.action).toBe("create_pr"); + expect(parsed.disableCommitSigning).toBeUndefined(); + }); + + it("accepts a per-attempt commit-signing override", () => { + const parsed = decodeRunStackedActionInput({ + actionId: "action-2", + cwd: "/repo", + action: "commit", + disableCommitSigning: true, + }); + + expect(parsed.disableCommitSigning).toBe(true); + }); +}); + +describe("GitActionProgressEvent", () => { + it("defaults omitted failure classifications for older action-failure payloads", () => { + const parsed = decodeActionProgressEvent({ + actionId: "action-1", + cwd: "/repo", + action: "commit", + kind: "action_failed", + phase: "commit", + message: "Commit failed.", + }); + + expect(parsed).toMatchObject({ + kind: "action_failed", + failureKind: "unknown", + }); + }); + + it("accepts action failures with an unknown classification", () => { + const parsed = decodeActionProgressEvent({ + actionId: "action-1", + cwd: "/repo", + action: "commit", + kind: "action_failed", + phase: "commit", + message: "Commit failed.", + failureKind: "unknown", + }); + + expect(parsed.kind).toBe("action_failed"); + if (parsed.kind === "action_failed") { + expect(parsed.failureKind).toBe("unknown"); + } + }); + + it("accepts classified commit-signing failures", () => { + const parsed = decodeActionProgressEvent({ + actionId: "action-2", + cwd: "/repo", + action: "commit_push", + kind: "action_failed", + phase: "commit", + message: "Commit failed.", + failureKind: "commit_signing_failed", + }); + + expect(parsed).toMatchObject({ + kind: "action_failed", + failureKind: "commit_signing_failed", + }); + }); +}); + +describe("GitCommandError", () => { + const baseError = { + _tag: "GitCommandError", + operation: "GitVcsDriver.commit.commit", + command: "git", + cwd: "/repo", + detail: "Git command exited with a non-zero status.", + } as const; + + it("defaults omitted failure classifications for older command-error payloads", () => { + expect(decodeGitCommandError(baseError).failureKind).toBe("unknown"); + }); + + it("accepts errors with an unknown classification", () => { + expect( + decodeGitCommandError({ + ...baseError, + failureKind: "unknown", + }).failureKind, + ).toBe("unknown"); + }); + + it("accepts classified commit-signing errors", () => { + expect( + decodeGitCommandError({ + ...baseError, + failureKind: "commit_signing_failed", + }).failureKind, + ).toBe("commit_signing_failed"); }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..ecc334b765d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; @@ -28,6 +29,11 @@ export const GitActionProgressKind = Schema.Literals([ "action_failed", ]); export type GitActionProgressKind = typeof GitActionProgressKind.Type; +export const GitActionFailureKind = Schema.Literals(["unknown", "commit_signing_failed"]); +export type GitActionFailureKind = typeof GitActionFailureKind.Type; +const GitActionFailureKindWithDefault = GitActionFailureKind.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("unknown" as const)), +); export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]); export type GitActionProgressStream = typeof GitActionProgressStream.Type; const GitCommitStepStatus = Schema.Literals([ @@ -115,6 +121,7 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), + disableCommitSigning: Schema.optional(Schema.Boolean), filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), @@ -329,6 +336,7 @@ export class GitCommandError extends Schema.TaggedErrorClass()( stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.optional(Schema.Number), outputLength: Schema.optional(Schema.Number), + failureKind: GitActionFailureKindWithDefault, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { @@ -432,6 +440,7 @@ const GitActionFailedEvent = Schema.Struct({ kind: Schema.Literal("action_failed"), phase: Schema.NullOr(GitActionProgressPhase), message: TrimmedNonEmptyStringSchema, + failureKind: GitActionFailureKindWithDefault, }); export const GitActionProgressEvent = Schema.Union([