Skip to content

Commit e84f5dc

Browse files
Stream git hook progress during stacked git actions
- Add hook/output progress callbacks to git execution and commit flow - Emit ordered git action progress events (start, phases, hooks, finish/fail) - Publish progress over WebSocket only to the initiating client - Update shared contracts and tests for the new progress event channel
1 parent b500a36 commit e84f5dc

18 files changed

Lines changed: 1177 additions & 128 deletions

apps/server/src/git/Layers/GitCore.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "ef
22

33
import { GitCommandError } from "../Errors.ts";
44
import { GitService } from "../Services/GitService.ts";
5-
import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
5+
import type { ExecuteGitProgress } from "../Services/GitService.ts";
6+
import { GitCore, type GitCommitOptions, type GitCoreShape } from "../Services/GitCore.ts";
67

78
const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
89
const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
@@ -20,6 +21,7 @@ interface ExecuteGitOptions {
2021
timeoutMs?: number | undefined;
2122
allowNonZeroExit?: boolean | undefined;
2223
fallbackErrorMessage?: string | undefined;
24+
progress?: ExecuteGitProgress | undefined;
2325
}
2426

2527
function parseBranchAb(value: string): { ahead: number; behind: number } {
@@ -235,6 +237,7 @@ const makeGitCore = Effect.gen(function* () {
235237
args,
236238
allowNonZeroExit: true,
237239
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
240+
...(options.progress ? { progress: options.progress } : {}),
238241
})
239242
.pipe(
240243
Effect.flatMap((result) => {
@@ -804,14 +807,37 @@ const makeGitCore = Effect.gen(function* () {
804807
};
805808
});
806809

807-
const commit: GitCoreShape["commit"] = (cwd, subject, body) =>
810+
const commit: GitCoreShape["commit"] = (cwd, subject, body, options?: GitCommitOptions) =>
808811
Effect.gen(function* () {
809812
const args = ["commit", "-m", subject];
810813
const trimmedBody = body.trim();
811814
if (trimmedBody.length > 0) {
812815
args.push("-m", trimmedBody);
813816
}
814-
yield* runGit("GitCore.commit.commit", cwd, args);
817+
const progress = options?.progress
818+
? {
819+
...(options.progress.onOutputLine
820+
? {
821+
onStdoutLine: (line: string) =>
822+
options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ??
823+
Effect.void,
824+
onStderrLine: (line: string) =>
825+
options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ??
826+
Effect.void,
827+
}
828+
: {}),
829+
...(options.progress.onHookStarted
830+
? { onHookStarted: options.progress.onHookStarted }
831+
: {}),
832+
...(options.progress.onHookFinished
833+
? { onHookFinished: options.progress.onHookFinished }
834+
: {}),
835+
}
836+
: null;
837+
yield* executeGit("GitCore.commit.commit", cwd, args, {
838+
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
839+
...(progress ? { progress } : {}),
840+
}).pipe(Effect.asVoid);
815841
const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
816842
"rev-parse",
817843
"HEAD",

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

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
66
import { it } from "@effect/vitest";
77
import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
88
import { expect } from "vitest";
9+
import type { GitActionProgressEvent } from "@t3tools/contracts";
910

1011
import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
1112
import { type GitManagerShape } from "../Services/GitManager.ts";
@@ -453,8 +454,9 @@ function runStackedAction(
453454
featureBranch?: boolean;
454455
filePaths?: readonly string[];
455456
},
457+
options?: Parameters<GitManagerShape["runStackedAction"]>[1],
456458
) {
457-
return manager.runStackedAction(input);
459+
return manager.runStackedAction(input, options);
458460
}
459461

460462
function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {
@@ -1936,4 +1938,114 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
19361938
expect(errorMessage).toContain("already checked out in the main repo");
19371939
}),
19381940
);
1941+
1942+
it.effect("emits ordered progress events for commit hooks", () =>
1943+
Effect.gen(function* () {
1944+
const repoDir = yield* makeTempDir("t3code-git-manager-");
1945+
yield* initRepo(repoDir);
1946+
fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n");
1947+
fs.writeFileSync(
1948+
path.join(repoDir, ".git", "hooks", "pre-commit"),
1949+
'#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n',
1950+
{ mode: 0o755 },
1951+
);
1952+
1953+
const { manager } = yield* makeManager();
1954+
const events: GitActionProgressEvent[] = [];
1955+
1956+
const result = yield* runStackedAction(
1957+
manager,
1958+
{
1959+
cwd: repoDir,
1960+
action: "commit",
1961+
},
1962+
{
1963+
actionId: "action-1",
1964+
progressReporter: {
1965+
publish: (event) =>
1966+
Effect.sync(() => {
1967+
events.push(event);
1968+
}),
1969+
},
1970+
},
1971+
);
1972+
1973+
expect(result.commit.status).toBe("created");
1974+
expect(events.map((event) => event.kind)).toContain("action_started");
1975+
expect(events).toEqual(
1976+
expect.arrayContaining([
1977+
expect.objectContaining({
1978+
kind: "phase_started",
1979+
phase: "commit",
1980+
}),
1981+
expect.objectContaining({
1982+
kind: "hook_started",
1983+
hookName: "pre-commit",
1984+
}),
1985+
expect.objectContaining({
1986+
kind: "hook_output",
1987+
hookName: "pre-commit",
1988+
text: "hook: start",
1989+
}),
1990+
expect.objectContaining({
1991+
kind: "hook_finished",
1992+
hookName: "pre-commit",
1993+
}),
1994+
expect.objectContaining({
1995+
kind: "action_finished",
1996+
}),
1997+
]),
1998+
);
1999+
}),
2000+
);
2001+
2002+
it.effect("emits action_failed when a commit hook rejects", () =>
2003+
Effect.gen(function* () {
2004+
const repoDir = yield* makeTempDir("t3code-git-manager-");
2005+
yield* initRepo(repoDir);
2006+
fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n");
2007+
fs.writeFileSync(
2008+
path.join(repoDir, ".git", "hooks", "pre-commit"),
2009+
'#!/bin/sh\necho "hook: fail" >&2\nexit 1\n',
2010+
{ mode: 0o755 },
2011+
);
2012+
2013+
const { manager } = yield* makeManager();
2014+
const events: GitActionProgressEvent[] = [];
2015+
2016+
const errorMessage = yield* runStackedAction(
2017+
manager,
2018+
{
2019+
cwd: repoDir,
2020+
action: "commit",
2021+
},
2022+
{
2023+
actionId: "action-2",
2024+
progressReporter: {
2025+
publish: (event) =>
2026+
Effect.sync(() => {
2027+
events.push(event);
2028+
}),
2029+
},
2030+
},
2031+
).pipe(
2032+
Effect.flip,
2033+
Effect.map((error) => error.message),
2034+
);
2035+
2036+
expect(errorMessage).toContain("hook: fail");
2037+
expect(events).toEqual(
2038+
expect.arrayContaining([
2039+
expect.objectContaining({
2040+
kind: "hook_started",
2041+
hookName: "pre-commit",
2042+
}),
2043+
expect.objectContaining({
2044+
kind: "action_failed",
2045+
phase: "commit",
2046+
}),
2047+
]),
2048+
);
2049+
}),
2050+
);
19392051
});

0 commit comments

Comments
 (0)