Skip to content

Commit 779bf4e

Browse files
committed
Preserve commit hook output attribution
1 parent 5cefaf0 commit 779bf4e

2 files changed

Lines changed: 85 additions & 18 deletions

File tree

apps/server/src/git/GitManager.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3639,6 +3639,63 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
36393639
}),
36403640
);
36413641

3642+
it.effect("does not attribute ambiguous output to the wrong commit hook", () =>
3643+
Effect.gen(function* () {
3644+
const repoDir = yield* makeTempDir("t3code-git-manager-");
3645+
yield* initRepo(repoDir);
3646+
NodeFS.writeFileSync(NodePath.join(repoDir, "multi-hook.txt"), "multi hook\n");
3647+
NodeFS.writeFileSync(
3648+
NodePath.join(repoDir, ".git", "hooks", "pre-commit"),
3649+
'#!/bin/sh\necho "output from pre-commit" >&2\n',
3650+
{ mode: 0o755 },
3651+
);
3652+
NodeFS.writeFileSync(
3653+
NodePath.join(repoDir, ".git", "hooks", "commit-msg"),
3654+
'#!/bin/sh\necho "output from commit-msg" >&2\n',
3655+
{ mode: 0o755 },
3656+
);
3657+
3658+
const { manager } = yield* makeManager();
3659+
const events: GitActionProgressEvent[] = [];
3660+
3661+
const result = yield* runStackedAction(
3662+
manager,
3663+
{
3664+
cwd: repoDir,
3665+
action: "commit",
3666+
commitMessage: "test: preserve hook output attribution",
3667+
},
3668+
{
3669+
actionId: "action-multi-hook",
3670+
progressReporter: {
3671+
publish: (event) =>
3672+
Effect.sync(() => {
3673+
events.push(event);
3674+
}),
3675+
},
3676+
},
3677+
);
3678+
3679+
expect(result.commit.status).toBe("created");
3680+
const hookOutput = events.filter((event) => event.kind === "hook_output");
3681+
const preCommitOutput = hookOutput.find((event) =>
3682+
event.text.includes("output from pre-commit"),
3683+
);
3684+
const commitMsgOutput = hookOutput.find((event) =>
3685+
event.text.includes("output from commit-msg"),
3686+
);
3687+
const gitOutput = hookOutput.find((event) =>
3688+
event.text.includes("test: preserve hook output attribution"),
3689+
);
3690+
3691+
expect(preCommitOutput).toBeDefined();
3692+
expect([null, "pre-commit"]).toContain(preCommitOutput?.hookName);
3693+
expect(commitMsgOutput).toBeDefined();
3694+
expect([null, "commit-msg"]).toContain(commitMsgOutput?.hookName);
3695+
expect(gitOutput).toMatchObject({ hookName: null });
3696+
}),
3697+
);
3698+
36423699
it.effect("emits action_failed when a commit hook rejects", () =>
36433700
Effect.gen(function* () {
36443701
const repoDir = yield* makeTempDir("t3code-git-manager-");

apps/server/src/git/GitManager.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,9 +1382,10 @@ export const make = Effect.gen(function* () {
13821382
});
13831383

13841384
let currentHookName: string | null = null;
1385-
let pendingHookOutput: Array<{ stream: "stdout" | "stderr"; text: string }> = [];
1385+
let sawCommitHook = false;
1386+
let pendingUnattributedOutput: Array<{ stream: "stdout" | "stderr"; text: string }> = [];
13861387
const emitHookOutput = (
1387-
hookName: string,
1388+
hookName: string | null,
13881389
{ stream, text }: { stream: "stdout" | "stderr"; text: string },
13891390
) => {
13901391
const sanitized = sanitizeProgressText(text);
@@ -1398,32 +1399,33 @@ export const make = Effect.gen(function* () {
13981399
text: sanitized,
13991400
});
14001401
};
1402+
const finalizeUnattributedOutput = (shouldEmit: boolean) =>
1403+
Effect.suspend(() => {
1404+
const pending = pendingUnattributedOutput;
1405+
pendingUnattributedOutput = [];
1406+
return shouldEmit
1407+
? Effect.forEach(pending, (output) => emitHookOutput(null, output), { discard: true })
1408+
: Effect.void;
1409+
});
14011410
const commitProgress =
14021411
progressReporter && actionId
14031412
? {
14041413
onOutputLine: (output: { stream: "stdout" | "stderr"; text: string }) =>
14051414
Effect.suspend(() => {
14061415
if (currentHookName === null) {
1407-
pendingHookOutput.push(output);
1416+
pendingUnattributedOutput.push(output);
14081417
return Effect.void;
14091418
}
14101419
return emitHookOutput(currentHookName, output);
14111420
}),
14121421
onHookStarted: (hookName: string) =>
14131422
Effect.suspend(() => {
1423+
sawCommitHook = true;
14141424
currentHookName = hookName;
1415-
const bufferedOutput = pendingHookOutput;
1416-
pendingHookOutput = [];
14171425
return emit({
14181426
kind: "hook_started",
14191427
hookName,
1420-
}).pipe(
1421-
Effect.flatMap(() =>
1422-
Effect.forEach(bufferedOutput, (output) => emitHookOutput(hookName, output), {
1423-
discard: true,
1424-
}),
1425-
),
1426-
);
1428+
});
14271429
}),
14281430
onHookFinished: ({
14291431
hookName,
@@ -1446,11 +1448,19 @@ export const make = Effect.gen(function* () {
14461448
},
14471449
}
14481450
: null;
1449-
const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
1450-
timeoutMs: COMMIT_TIMEOUT_MS,
1451-
...(disableSigning ? { disableSigning: true } : {}),
1452-
...(commitProgress ? { progress: commitProgress } : {}),
1453-
});
1451+
const { commitSha } = yield* gitCore
1452+
.commit(cwd, suggestion.subject, suggestion.body, {
1453+
timeoutMs: COMMIT_TIMEOUT_MS,
1454+
...(disableSigning ? { disableSigning: true } : {}),
1455+
...(commitProgress ? { progress: commitProgress } : {}),
1456+
})
1457+
.pipe(
1458+
Effect.tapError((error) =>
1459+
finalizeUnattributedOutput(
1460+
sawCommitHook && error.failureKind !== "commit_signing_failed",
1461+
),
1462+
),
1463+
);
14541464
if (currentHookName !== null) {
14551465
yield* emit({
14561466
kind: "hook_finished",
@@ -1460,7 +1470,7 @@ export const make = Effect.gen(function* () {
14601470
});
14611471
currentHookName = null;
14621472
}
1463-
pendingHookOutput = [];
1473+
yield* finalizeUnattributedOutput(sawCommitHook);
14641474
return {
14651475
status: "created" as const,
14661476
commitSha,

0 commit comments

Comments
 (0)