diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 43eaa08de53a..9421e7af27fb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -6041,7 +6041,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ); expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); - expect(errorMessage).not.toContain("hook: fail"); + expect(errorMessage).toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 82d070f87cd0..99c3209b8169 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3415,6 +3415,44 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("surfaces the output of a hook that rejected the commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const hookOutput = "commit message is missing Signed-off-by"; + yield* writeTextFile( + cwd, + ".git/hooks/pre-commit", + `#!/bin/sh\necho "${hookOutput}" >&2\nexit 1\n`, + ); + yield* fileSystem.chmod(pathService.join(cwd, ".git/hooks/pre-commit"), 0o755); + yield* writeTextFile(cwd, "a.txt", "a\n"); + + const error = yield* driver + .commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } }) + .pipe(Effect.flip); + + assert.equal(error._tag, "GitCommandError"); + assert.include(error.message, hookOutput); + }), + ); + + it.effect("says when there is nothing to commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver.commit(cwd, "Empty", "").pipe(Effect.flip); + + assert.include(error.message, "nothing to commit"); + }), + ); + it.effect.each([false, true])( "preserves partial staging and split-index files (split: %s)", (split) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 92f7478fa34b..4a40f913e482 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -491,6 +491,18 @@ function classifyGitFailure(stderr: string): GitCommandFailureReason | null { return null; } +const COMMIT_FAILURE_OUTPUT_MAX_CHARS = 1_000; + +// `git commit` is local, so its output carries no remote URLs; it is surfaced +// because a rejecting hook's own words are the only explanation of why. +function commitFailureOutput({ + stdout, + stderr, +}: Pick): string { + const output = (stderr.trim() || stdout.trim()).slice(-COMMIT_FAILURE_OUTPUT_MAX_CHARS); + return output.length > 0 ? output : "Git command exited with a non-zero status."; +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -2250,10 +2262,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; - yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + const commitResult = yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); + allowNonZeroExit: true, + }); + if (commitResult.exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }), + detail: commitFailureOutput(commitResult), + ...(commitResult.exitCode === null ? {} : { exitCode: commitResult.exitCode }), + stdoutLength: commitResult.stdout.length, + stderrLength: commitResult.stderr.length, + }); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD",