authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 16:34:22-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 16:34:22-08:00
log39390c0b9a8751a9a7213daafd9f1797cbca04ae
tree46c46fb9d56b2bde7502831596efe973e9145bcd
parent169d06f05b543433278a123b3512fddad05f9f2a

Add stacked git actions flow


14 files changed, 1857 insertions(+), 0 deletions(-)

apps/server/src/codexTextGenerator.ts created+232
...@@ -0,0 +1,232 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5
6import type {
7 CommitMessageGenerationInput,
8 CommitMessageGenerationResult,
9 PrContentGenerationInput,
10 PrContentGenerationResult,
11 TextGenerationService,
12} from "./coreServices";
13import { runProcess } from "./processRunner";
14
15const COMMIT_OUTPUT_SCHEMA_JSON = {
16 type: "object",
17 properties: {
18 subject: { type: "string" },
19 body: { type: "string" },
20 },
21 required: ["subject", "body"],
22 additionalProperties: false,
23} as const;
24
25const PR_OUTPUT_SCHEMA_JSON = {
26 type: "object",
27 properties: {
28 title: { type: "string" },
29 body: { type: "string" },
30 },
31 required: ["title", "body"],
32 additionalProperties: false,
33} as const;
34
35function parseCommitOutput(raw: unknown): { subject: string; body: string } {
36 if (!raw || typeof raw !== "object") {
37 throw new Error("Codex returned a non-object commit message payload.");
38 }
39 const record = raw as Record<string, unknown>;
40 const subject = typeof record.subject === "string" ? record.subject.trim() : "";
41 const body = typeof record.body === "string" ? record.body : "";
42 if (subject.length === 0) {
43 throw new Error("Codex returned an empty commit subject.");
44 }
45 return { subject, body };
46}
47
48function parsePrOutput(raw: unknown): { title: string; body: string } {
49 if (!raw || typeof raw !== "object") {
50 throw new Error("Codex returned a non-object PR payload.");
51 }
52 const record = raw as Record<string, unknown>;
53 const title = typeof record.title === "string" ? record.title.trim() : "";
54 const body = typeof record.body === "string" ? record.body.trim() : "";
55 if (title.length === 0 || body.length === 0) {
56 throw new Error("Codex returned an invalid PR title/body payload.");
57 }
58 return { title, body };
59}
60
61function limitSection(value: string, maxChars: number): string {
62 if (value.length <= maxChars) return value;
63 const truncated = value.slice(0, maxChars);
64 return `${truncated}\n\n[truncated]`;
65}
66
67async function writeTempFile(prefix: string, content: string): Promise<string> {
68 const filePath = path.join(
69 os.tmpdir(),
70 `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
71 );
72 await fs.writeFile(filePath, content, "utf8");
73 return filePath;
74}
75
76async function safeUnlink(filePath: string): Promise<void> {
77 try {
78 await fs.unlink(filePath);
79 } catch {
80 // Best-effort cleanup.
81 }
82}
83
84function sanitizeCommitSubject(raw: string): string {
85 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
86 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
87 if (withoutTrailingPeriod.length === 0) {
88 return "Update project files";
89 }
90
91 if (withoutTrailingPeriod.length <= 72) {
92 return withoutTrailingPeriod;
93 }
94 return withoutTrailingPeriod.slice(0, 72).trimEnd();
95}
96
97function sanitizePrTitle(raw: string): string {
98 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
99 if (singleLine.length > 0) {
100 return singleLine;
101 }
102 return "Update project changes";
103}
104
105async function runCodexJson<T>({
106 cwd,
107 prompt,
108 outputSchemaJson,
109 parse,
110}: {
111 cwd: string;
112 prompt: string;
113 outputSchemaJson: object;
114 parse: (raw: unknown) => T;
115}): Promise<T> {
116 const schemaPath = await writeTempFile(
117 "codex-schema",
118 JSON.stringify(outputSchemaJson),
119 );
120 const outputPath = await writeTempFile("codex-output", "");
121
122 try {
123 await runProcess(
124 "codex",
125 [
126 "exec",
127 "--ephemeral",
128 "-s",
129 "read-only",
130 "--output-schema",
131 schemaPath,
132 "--output-last-message",
133 outputPath,
134 "-",
135 ],
136 {
137 cwd,
138 timeoutMs: 180_000,
139 stdin: prompt,
140 },
141 );
142
143 const rawOutput = (await fs.readFile(outputPath, "utf8")).trim();
144 if (rawOutput.length === 0) {
145 throw new Error("Codex returned an empty response.");
146 }
147
148 let parsedJson: unknown;
149 try {
150 parsedJson = JSON.parse(rawOutput);
151 } catch {
152 throw new Error("Codex returned invalid JSON output.");
153 }
154
155 return parse(parsedJson);
156 } finally {
157 await Promise.all([safeUnlink(schemaPath), safeUnlink(outputPath)]);
158 }
159}
160
161export class CodexTextGenerator implements TextGenerationService {
162 async generateCommitMessage(
163 input: CommitMessageGenerationInput,
164 ): Promise<CommitMessageGenerationResult> {
165 const prompt = [
166 "You write concise git commit messages.",
167 "Return a JSON object with keys: subject, body.",
168 "Rules:",
169 "- subject must be imperative, <= 72 chars, and no trailing period",
170 "- body can be empty string or short bullet points",
171 "- capture the primary user-visible or developer-visible change",
172 "",
173 `Branch: ${input.branch ?? "(detached)"}`,
174 "",
175 "Staged files:",
176 limitSection(input.stagedSummary, 6_000),
177 "",
178 "Staged patch:",
179 limitSection(input.stagedPatch, 40_000),
180 ].join("\n");
181
182 const generated = await runCodexJson({
183 cwd: input.cwd,
184 prompt,
185 outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON,
186 parse: (raw) => parseCommitOutput(raw),
187 });
188
189 return {
190 subject: sanitizeCommitSubject(generated.subject),
191 body: generated.body.trim(),
192 };
193 }
194
195 async generatePrContent(
196 input: PrContentGenerationInput,
197 ): Promise<PrContentGenerationResult> {
198 const prompt = [
199 "You write GitHub pull request content.",
200 "Return a JSON object with keys: title, body.",
201 "Rules:",
202 "- title should be concise and specific",
203 "- body must be markdown and include headings '## Summary' and '## Testing'",
204 "- under Summary, provide short bullet points",
205 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
206 "",
207 `Base branch: ${input.baseBranch}`,
208 `Head branch: ${input.headBranch}`,
209 "",
210 "Commits:",
211 limitSection(input.commitSummary, 12_000),
212 "",
213 "Diff stat:",
214 limitSection(input.diffSummary, 12_000),
215 "",
216 "Diff patch:",
217 limitSection(input.diffPatch, 40_000),
218 ].join("\n");
219
220 const generated = await runCodexJson({
221 cwd: input.cwd,
222 prompt,
223 outputSchemaJson: PR_OUTPUT_SCHEMA_JSON,
224 parse: (raw) => parsePrOutput(raw),
225 });
226
227 return {
228 title: sanitizePrTitle(generated.title),
229 body: generated.body.trim(),
230 };
231 }
232}
apps/server/src/coreServices.ts created+35
...@@ -0,0 +1,35 @@
1export interface CommitMessageGenerationInput {
2 cwd: string;
3 branch: string | null;
4 stagedSummary: string;
5 stagedPatch: string;
6}
7
8export interface CommitMessageGenerationResult {
9 subject: string;
10 body: string;
11}
12
13export interface PrContentGenerationInput {
14 cwd: string;
15 baseBranch: string;
16 headBranch: string;
17 commitSummary: string;
18 diffSummary: string;
19 diffPatch: string;
20}
21
22export interface PrContentGenerationResult {
23 title: string;
24 body: string;
25}
26
27export interface TextGenerationService {
28 generateCommitMessage(
29 input: CommitMessageGenerationInput,
30 ): Promise<CommitMessageGenerationResult>;
31 generatePrContent(
32 input: PrContentGenerationInput,
33 ): Promise<PrContentGenerationResult>;
34}
35
apps/server/src/gitManager.test.ts created+397
...@@ -0,0 +1,397 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { GitManager } from "./gitManager";
8import {
9 type ProcessRunOptions,
10 type ProcessRunResult,
11 runProcess,
12} from "./processRunner";
13
14interface FakeGhScenario {
15 prListSequence?: string[];
16 createdPrUrl?: string;
17 defaultBranch?: string;
18 failWith?: Error;
19}
20
21interface FakeGitTextGenerator {
22 generateCommitMessage: (input: {
23 cwd: string;
24 branch: string | null;
25 stagedSummary: string;
26 stagedPatch: string;
27 }) => Promise<{ subject: string; body: string }>;
28 generatePrContent: (input: {
29 cwd: string;
30 baseBranch: string;
31 headBranch: string;
32 commitSummary: string;
33 diffSummary: string;
34 diffPatch: string;
35 }) => Promise<{ title: string; body: string }>;
36}
37
38const tempDirs: string[] = [];
39
40function makeTempDir(prefix: string): string {
41 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
42 tempDirs.push(dir);
43 return dir;
44}
45
46async function runGit(
47 cwd: string,
48 args: readonly string[],
49 allowNonZeroExit = false,
50): Promise<ProcessRunResult> {
51 return runProcess("git", args, { cwd, allowNonZeroExit });
52}
53
54async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> {
55 const result = await runGit(cwd, args);
56 return result.stdout.trim();
57}
58
59async function initRepo(cwd: string): Promise<void> {
60 await runGit(cwd, ["init", "--initial-branch=main"]);
61 await runGit(cwd, ["config", "user.email", "test@example.com"]);
62 await runGit(cwd, ["config", "user.name", "Test User"]);
63 fs.writeFileSync(path.join(cwd, "README.md"), "hello\n");
64 await runGit(cwd, ["add", "README.md"]);
65 await runGit(cwd, ["commit", "-m", "Initial commit"]);
66}
67
68async function createBareRemote(): Promise<string> {
69 const remoteDir = makeTempDir("t3code-git-remote-");
70 await runProcess("git", ["init", "--bare"], { cwd: remoteDir });
71 return remoteDir;
72}
73
74function createTextGenerator(
75 overrides: Partial<FakeGitTextGenerator> = {},
76): FakeGitTextGenerator {
77 return {
78 generateCommitMessage: async () => ({
79 subject: "Implement stacked git actions",
80 body: "",
81 }),
82 generatePrContent: async () => ({
83 title: "Add stacked git actions",
84 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
85 }),
86 ...overrides,
87 };
88}
89
90function createRunnerWithFakeGh(scenario: FakeGhScenario = {}): {
91 runner: (
92 command: string,
93 args: readonly string[],
94 options?: ProcessRunOptions,
95 ) => Promise<ProcessRunResult>;
96 ghCalls: string[];
97} {
98 const prListQueue = [...(scenario.prListSequence ?? [])];
99 const ghCalls: string[] = [];
100
101 const runner = async (
102 command: string,
103 args: readonly string[],
104 options?: ProcessRunOptions,
105 ): Promise<ProcessRunResult> => {
106 if (command !== "gh") {
107 return runProcess(command, args, options);
108 }
109
110 ghCalls.push(args.join(" "));
111 if (scenario.failWith) {
112 throw scenario.failWith;
113 }
114
115 if (args[0] === "pr" && args[1] === "list") {
116 const stdout = (prListQueue.shift() ?? "[]") + "\n";
117 return {
118 stdout,
119 stderr: "",
120 code: 0,
121 signal: null,
122 timedOut: false,
123 };
124 }
125
126 if (args[0] === "pr" && args[1] === "create") {
127 return {
128 stdout:
129 (scenario.createdPrUrl ??
130 "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n",
131 stderr: "",
132 code: 0,
133 signal: null,
134 timedOut: false,
135 };
136 }
137
138 if (args[0] === "pr" && args[1] === "view") {
139 return {
140 stdout: "",
141 stderr: "",
142 code: 0,
143 signal: null,
144 timedOut: false,
145 };
146 }
147
148 if (args[0] === "repo" && args[1] === "view") {
149 return {
150 stdout: `${scenario.defaultBranch ?? "main"}\n`,
151 stderr: "",
152 code: 0,
153 signal: null,
154 timedOut: false,
155 };
156 }
157
158 throw new Error(`Unexpected gh command: ${args.join(" ")}`);
159 };
160
161 return { runner, ghCalls };
162}
163
164afterEach(() => {
165 for (const dir of tempDirs.splice(0, tempDirs.length)) {
166 fs.rmSync(dir, { recursive: true, force: true });
167 }
168});
169
170describe("GitManager", () => {
171 it("creates a commit when working tree is dirty", async () => {
172 const repoDir = makeTempDir("t3code-git-manager-");
173 await initRepo(repoDir);
174 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n");
175
176 const manager = new GitManager({
177 textGenerator: createTextGenerator(),
178 });
179 const result = await manager.runStackedAction({
180 cwd: repoDir,
181 action: "commit",
182 });
183
184 expect(result.commit.status).toBe("created");
185 expect(result.push.status).toBe("skipped_not_requested");
186 expect(result.pr.status).toBe("skipped_not_requested");
187 expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe(
188 "Implement stacked git actions",
189 );
190 });
191
192 it("skips commit when there are no uncommitted changes", async () => {
193 const repoDir = makeTempDir("t3code-git-manager-");
194 await initRepo(repoDir);
195
196 const manager = new GitManager({
197 textGenerator: createTextGenerator(),
198 });
199 const result = await manager.runStackedAction({
200 cwd: repoDir,
201 action: "commit",
202 });
203
204 expect(result.commit.status).toBe("skipped_no_changes");
205 expect(result.push.status).toBe("skipped_not_requested");
206 expect(result.pr.status).toBe("skipped_not_requested");
207 });
208
209 it("commits and pushes with upstream auto-setup when needed", async () => {
210 const repoDir = makeTempDir("t3code-git-manager-");
211 await initRepo(repoDir);
212 await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]);
213 const remoteDir = await createBareRemote();
214 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
215 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
216
217 const manager = new GitManager({
218 textGenerator: createTextGenerator(),
219 });
220 const result = await manager.runStackedAction({
221 cwd: repoDir,
222 action: "commit_push",
223 });
224
225 expect(result.commit.status).toBe("created");
226 expect(result.push.status).toBe("pushed");
227 expect(result.push.setUpstream).toBe(true);
228 expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
229 "origin/feature/stacked-flow",
230 );
231 });
232
233 it("skips push when branch is already up to date", async () => {
234 const repoDir = makeTempDir("t3code-git-manager-");
235 await initRepo(repoDir);
236 await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]);
237 const remoteDir = await createBareRemote();
238 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
239 await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]);
240
241 const manager = new GitManager({
242 textGenerator: createTextGenerator(),
243 });
244 const result = await manager.runStackedAction({
245 cwd: repoDir,
246 action: "commit_push",
247 });
248
249 expect(result.commit.status).toBe("skipped_no_changes");
250 expect(result.push.status).toBe("skipped_up_to_date");
251 });
252
253 it("opens existing PR for commit/push/pr action", async () => {
254 const repoDir = makeTempDir("t3code-git-manager-");
255 await initRepo(repoDir);
256 await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);
257 const remoteDir = await createBareRemote();
258 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
259 await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]);
260
261 const { runner, ghCalls } = createRunnerWithFakeGh({
262 prListSequence: [
263 JSON.stringify([
264 {
265 number: 42,
266 title: "Existing PR",
267 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
268 baseRefName: "main",
269 headRefName: "feature/existing-pr",
270 },
271 ]),
272 ],
273 });
274
275 const manager = new GitManager({
276 runProcess: runner,
277 textGenerator: createTextGenerator(),
278 });
279 const result = await manager.runStackedAction({
280 cwd: repoDir,
281 action: "commit_push_pr",
282 });
283
284 expect(result.pr.status).toBe("opened_existing");
285 expect(result.pr.number).toBe(42);
286 expect(
287 ghCalls.some((call) => call.startsWith("pr view feature/existing-pr --web")),
288 ).toBe(true);
289 });
290
291 it("creates PR when one does not already exist", async () => {
292 const repoDir = makeTempDir("t3code-git-manager-");
293 await initRepo(repoDir);
294 await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
295 const remoteDir = await createBareRemote();
296 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
297 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
298 await runGit(repoDir, ["add", "changes.txt"]);
299 await runGit(repoDir, ["commit", "-m", "Feature commit"]);
300 await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
301 await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
302
303 const { runner, ghCalls } = createRunnerWithFakeGh({
304 prListSequence: [
305 "[]",
306 JSON.stringify([
307 {
308 number: 88,
309 title: "Add stacked git actions",
310 url: "https://github.com/pingdotgg/codething-mvp/pull/88",
311 baseRefName: "main",
312 headRefName: "feature-create-pr",
313 },
314 ]),
315 ],
316 });
317
318 const manager = new GitManager({
319 runProcess: runner,
320 textGenerator: createTextGenerator(),
321 });
322 const result = await manager.runStackedAction({
323 cwd: repoDir,
324 action: "commit_push_pr",
325 });
326
327 expect(result.pr.status).toBe("created");
328 expect(result.pr.number).toBe(88);
329 expect(
330 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
331 ).toBe(true);
332 });
333
334 it("rejects push/pr actions from detached HEAD", async () => {
335 const repoDir = makeTempDir("t3code-git-manager-");
336 await initRepo(repoDir);
337 await runGit(repoDir, ["checkout", "--detach", "HEAD"]);
338
339 const manager = new GitManager({
340 textGenerator: createTextGenerator(),
341 });
342 await expect(
343 manager.runStackedAction({
344 cwd: repoDir,
345 action: "commit_push",
346 }),
347 ).rejects.toThrow("detached HEAD");
348 });
349
350 it("surfaces missing gh binary errors", async () => {
351 const repoDir = makeTempDir("t3code-git-manager-");
352 await initRepo(repoDir);
353 await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]);
354 const remoteDir = await createBareRemote();
355 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
356 await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]);
357
358 const { runner } = createRunnerWithFakeGh({
359 failWith: new Error("Command not found: gh"),
360 });
361 const manager = new GitManager({
362 runProcess: runner,
363 textGenerator: createTextGenerator(),
364 });
365
366 await expect(
367 manager.runStackedAction({
368 cwd: repoDir,
369 action: "commit_push_pr",
370 }),
371 ).rejects.toThrow("GitHub CLI (`gh`) is required");
372 });
373
374 it("surfaces gh auth errors with guidance", async () => {
375 const repoDir = makeTempDir("t3code-git-manager-");
376 await initRepo(repoDir);
377 await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]);
378 const remoteDir = await createBareRemote();
379 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
380 await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]);
381
382 const { runner } = createRunnerWithFakeGh({
383 failWith: new Error("authentication failed"),
384 });
385 const manager = new GitManager({
386 runProcess: runner,
387 textGenerator: createTextGenerator(),
388 });
389
390 await expect(
391 manager.runStackedAction({
392 cwd: repoDir,
393 action: "commit_push_pr",
394 }),
395 ).rejects.toThrow("gh auth login");
396 });
397});
apps/server/src/gitManager.ts created+623
...@@ -0,0 +1,623 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5
6import {
7 gitRunStackedActionInputSchema,
8 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,
10 gitStatusResultSchema,
11 type GitRunStackedActionInput,
12 type GitRunStackedActionResult,
13 type GitStatusInput,
14 type GitStatusResult,
15} from "@t3tools/contracts";
16
17import type {
18 CommitMessageGenerationResult,
19 PrContentGenerationResult,
20 TextGenerationService,
21} from "./coreServices";
22import { CodexTextGenerator } from "./codexTextGenerator";
23import { type ProcessRunOptions, runProcess } from "./processRunner";
24
25interface GitStatusDetails extends GitStatusResult {
26 upstreamRef: string | null;
27}
28
29interface GitManagerDeps {
30 runProcess?: (
31 command: string,
32 args: readonly string[],
33 options?: ProcessRunOptions,
34 ) => Promise<{
35 stdout: string;
36 stderr: string;
37 code: number | null;
38 signal: NodeJS.Signals | null;
39 timedOut: boolean;
40 }>;
41 textGenerator?: TextGenerationService;
42}
43
44interface OpenPrInfo {
45 number: number;
46 title: string;
47 url: string;
48 baseRefName: string;
49 headRefName: string;
50}
51
52function parseOpenPrList(raw: unknown): OpenPrInfo[] {
53 if (!Array.isArray(raw)) return [];
54
55 const parsed: OpenPrInfo[] = [];
56 for (const entry of raw) {
57 if (!entry || typeof entry !== "object") continue;
58 const record = entry as Record<string, unknown>;
59 const number = record.number;
60 const title = record.title;
61 const url = record.url;
62 const baseRefName = record.baseRefName;
63 const headRefName = record.headRefName;
64 if (
65 typeof number !== "number" ||
66 !Number.isInteger(number) ||
67 number <= 0
68 ) {
69 continue;
70 }
71 if (
72 typeof title !== "string" ||
73 typeof url !== "string" ||
74 typeof baseRefName !== "string" ||
75 typeof headRefName !== "string"
76 ) {
77 continue;
78 }
79 parsed.push({
80 number,
81 title,
82 url,
83 baseRefName,
84 headRefName,
85 });
86 }
87 return parsed;
88}
89
90function trimStdout(value: string): string {
91 return value.trim();
92}
93
94function limitContext(value: string, maxChars: number): string {
95 if (value.length <= maxChars) return value;
96 return `${value.slice(0, maxChars)}\n\n[truncated]`;
97}
98
99function sanitizeCommitMessage(
100 generated: CommitMessageGenerationResult,
101): CommitMessageGenerationResult {
102 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
103 const subject = rawSubject.replace(/[.]+$/g, "").trim();
104 const safeSubject =
105 subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files";
106 return {
107 subject: safeSubject,
108 body: generated.body.trim(),
109 };
110}
111
112function extractBranchFromRef(ref: string): string {
113 const normalized = ref.trim();
114 const afterSlash = normalized.includes("/")
115 ? normalized.slice(normalized.lastIndexOf("/") + 1)
116 : normalized;
117 return afterSlash.trim();
118}
119
120function parseBranchAb(value: string): { ahead: number; behind: number } {
121 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
122 if (!match) return { ahead: 0, behind: 0 };
123 return {
124 ahead: Number(match[1] ?? "0"),
125 behind: Number(match[2] ?? "0"),
126 };
127}
128
129function asCommandNotFound(
130 command: string,
131 error: unknown,
132): Error | undefined {
133 if (!(error instanceof Error)) return undefined;
134 if (!error.message.includes(`Command not found: ${command}`)) return undefined;
135 if (command === "gh") {
136 return new Error("GitHub CLI (`gh`) is required but not available on PATH.");
137 }
138 if (command === "codex") {
139 return new Error("Codex CLI (`codex`) is required but not available on PATH.");
140 }
141 if (command === "git") {
142 return new Error("Git is required but not available on PATH.");
143 }
144 return new Error(`${command} is required but not available on PATH.`);
145}
146
147function normalizeGitHubAuthError(error: unknown): Error | undefined {
148 if (!(error instanceof Error)) return undefined;
149 const lower = error.message.toLowerCase();
150 if (
151 lower.includes("authentication failed") ||
152 lower.includes("not logged in") ||
153 lower.includes("gh auth login") ||
154 lower.includes("no oauth token")
155 ) {
156 return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry.");
157 }
158 return undefined;
159}
160
161function normalizeNotGitRepoError(error: unknown): Error | undefined {
162 if (!(error instanceof Error)) return undefined;
163 if (error.message.toLowerCase().includes("not a git repository")) {
164 return new Error("Current folder is not a git repository.");
165 }
166 return undefined;
167}
168
169export class GitManager {
170 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;
171 private readonly textGenerator: TextGenerationService;
172
173 constructor(deps: GitManagerDeps = {}) {
174 this.run = deps.runProcess ?? runProcess;
175 this.textGenerator = deps.textGenerator ?? new CodexTextGenerator();
176 }
177
178 async status(raw: GitStatusInput): Promise<GitStatusResult> {
179 const input = gitStatusInputSchema.parse(raw);
180 const details = await this.readStatusDetails(input.cwd);
181 return gitStatusResultSchema.parse({
182 branch: details.branch,
183 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
184 hasUpstream: details.hasUpstream,
185 aheadCount: details.aheadCount,
186 behindCount: details.behindCount,
187 });
188 }
189
190 async runStackedAction(
191 raw: GitRunStackedActionInput,
192 ): Promise<GitRunStackedActionResult> {
193 const input = gitRunStackedActionInputSchema.parse(raw);
194 const wantsPush = input.action !== "commit";
195 const wantsPr = input.action === "commit_push_pr";
196
197 const initialStatus = await this.readStatusDetails(input.cwd);
198 if (wantsPush && !initialStatus.branch) {
199 throw new Error("Cannot push from detached HEAD.");
200 }
201 if (wantsPr && !initialStatus.branch) {
202 throw new Error("Cannot create a pull request from detached HEAD.");
203 }
204
205 const commit = await this.runCommitStep(input.cwd, initialStatus.branch);
206
207 const push = wantsPush
208 ? await this.runPushStep(input.cwd, initialStatus.branch)
209 : { status: "skipped_not_requested" as const };
210
211 const pr = wantsPr
212 ? await this.runPrStep(input.cwd, initialStatus.branch)
213 : { status: "skipped_not_requested" as const };
214
215 return gitRunStackedActionResultSchema.parse({
216 action: input.action,
217 commit,
218 push,
219 pr,
220 });
221 }
222
223 private async readStatusDetails(cwd: string): Promise<GitStatusDetails> {
224 let stdout = "";
225 try {
226 stdout = await this.runGitStdout(cwd, ["status", "--porcelain=2", "--branch"]);
227 } catch (error) {
228 throw (
229 asCommandNotFound("git", error) ??
230 normalizeNotGitRepoError(error) ??
231 (error instanceof Error ? error : new Error("Failed to read git status."))
232 );
233 }
234
235 let branch: string | null = null;
236 let upstreamRef: string | null = null;
237 let aheadCount = 0;
238 let behindCount = 0;
239 let hasWorkingTreeChanges = false;
240
241 for (const line of stdout.split(/\r?\n/g)) {
242 if (line.startsWith("# branch.head ")) {
243 const value = line.slice("# branch.head ".length).trim();
244 branch = value.startsWith("(") ? null : value;
245 continue;
246 }
247 if (line.startsWith("# branch.upstream ")) {
248 const value = line.slice("# branch.upstream ".length).trim();
249 upstreamRef = value.length > 0 ? value : null;
250 continue;
251 }
252 if (line.startsWith("# branch.ab ")) {
253 const value = line.slice("# branch.ab ".length).trim();
254 const parsed = parseBranchAb(value);
255 aheadCount = parsed.ahead;
256 behindCount = parsed.behind;
257 continue;
258 }
259 if (line.trim().length > 0 && !line.startsWith("#")) {
260 hasWorkingTreeChanges = true;
261 }
262 }
263
264 return {
265 branch,
266 upstreamRef,
267 hasWorkingTreeChanges,
268 hasUpstream: upstreamRef !== null,
269 aheadCount,
270 behindCount,
271 };
272 }
273
274 private async runCommitStep(
275 cwd: string,
276 branch: string | null,
277 ): Promise<{
278 status: "created" | "skipped_no_changes";
279 commitSha?: string | undefined;
280 subject?: string | undefined;
281 }> {
282 await this.runGit(cwd, ["add", "-A"]);
283 const stagedSummary = await this.runGitStdout(cwd, [
284 "diff",
285 "--cached",
286 "--name-status",
287 ]);
288 if (trimStdout(stagedSummary).length === 0) {
289 return { status: "skipped_no_changes" };
290 }
291
292 const stagedPatch = await this.runGitStdout(cwd, [
293 "diff",
294 "--cached",
295 "--patch",
296 "--minimal",
297 ]);
298
299 let generated: CommitMessageGenerationResult;
300 try {
301 generated = sanitizeCommitMessage(
302 await this.textGenerator.generateCommitMessage({
303 cwd,
304 branch,
305 stagedSummary: limitContext(stagedSummary, 8_000),
306 stagedPatch: limitContext(stagedPatch, 50_000),
307 }),
308 );
309 } catch (error) {
310 throw asCommandNotFound("codex", error) ?? error;
311 }
312
313 const commitArgs = ["commit", "-m", generated.subject];
314 if (generated.body.length > 0) {
315 commitArgs.push("-m", generated.body);
316 }
317 await this.runGit(cwd, commitArgs);
318
319 const commitSha = trimStdout(await this.runGitStdout(cwd, ["rev-parse", "HEAD"]));
320 return {
321 status: "created",
322 commitSha,
323 subject: generated.subject,
324 };
325 }
326
327 private async runPushStep(
328 cwd: string,
329 fallbackBranch: string | null,
330 ): Promise<{
331 status: "pushed" | "skipped_not_requested" | "skipped_up_to_date";
332 branch?: string | undefined;
333 upstreamBranch?: string | undefined;
334 setUpstream?: boolean | undefined;
335 }> {
336 const details = await this.readStatusDetails(cwd);
337 const branch = details.branch ?? fallbackBranch;
338 if (!branch) {
339 throw new Error("Cannot push from detached HEAD.");
340 }
341
342 if (details.hasUpstream && details.aheadCount === 0) {
343 return {
344 status: "skipped_up_to_date",
345 branch,
346 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
347 };
348 }
349
350 if (!details.hasUpstream) {
351 await this.runGit(cwd, ["push", "-u", "origin", branch]);
352 return {
353 status: "pushed",
354 branch,
355 upstreamBranch: `origin/${branch}`,
356 setUpstream: true,
357 };
358 }
359
360 await this.runGit(cwd, ["push"]);
361 return {
362 status: "pushed",
363 branch,
364 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
365 setUpstream: false,
366 };
367 }
368
369 private async runPrStep(
370 cwd: string,
371 fallbackBranch: string | null,
372 ): Promise<{
373 status: "created" | "opened_existing" | "skipped_not_requested";
374 url?: string | undefined;
375 number?: number | undefined;
376 baseBranch?: string | undefined;
377 headBranch?: string | undefined;
378 title?: string | undefined;
379 }> {
380 const details = await this.readStatusDetails(cwd);
381 const branch = details.branch ?? fallbackBranch;
382 if (!branch) {
383 throw new Error("Cannot create a pull request from detached HEAD.");
384 }
385 if (!details.hasUpstream) {
386 throw new Error("Current branch has not been pushed. Push before creating a PR.");
387 }
388
389 const existing = await this.findOpenPr(cwd, branch);
390 if (existing) {
391 await this.openPrInBrowser(cwd, branch);
392 return {
393 status: "opened_existing",
394 url: existing.url,
395 number: existing.number,
396 baseBranch: existing.baseRefName,
397 headBranch: existing.headRefName,
398 title: existing.title,
399 };
400 }
401
402 const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef);
403 const commitSummary = await this.runGitStdout(cwd, [
404 "log",
405 "--oneline",
406 `${baseBranch}..HEAD`,
407 ]);
408 const diffSummary = await this.runGitStdout(cwd, [
409 "diff",
410 "--stat",
411 `${baseBranch}..HEAD`,
412 ]);
413 const diffPatch = await this.runGitStdout(cwd, [
414 "diff",
415 "--patch",
416 "--minimal",
417 `${baseBranch}..HEAD`,
418 ]);
419
420 let generated: PrContentGenerationResult;
421 try {
422 generated = await this.textGenerator.generatePrContent({
423 cwd,
424 baseBranch,
425 headBranch: branch,
426 commitSummary: limitContext(commitSummary, 20_000),
427 diffSummary: limitContext(diffSummary, 20_000),
428 diffPatch: limitContext(diffPatch, 60_000),
429 });
430 } catch (error) {
431 throw asCommandNotFound("codex", error) ?? error;
432 }
433
434 const bodyFile = path.join(
435 os.tmpdir(),
436 `t3code-pr-body-${process.pid}-${randomUUID()}.md`,
437 );
438 await fs.writeFile(bodyFile, generated.body, "utf8");
439
440 try {
441 await this.runGh(cwd, [
442 "pr",
443 "create",
444 "--base",
445 baseBranch,
446 "--head",
447 branch,
448 "--title",
449 generated.title,
450 "--body-file",
451 bodyFile,
452 ]);
453 } finally {
454 try {
455 await fs.unlink(bodyFile);
456 } catch {
457 // Best-effort cleanup.
458 }
459 }
460
461 const created = await this.findOpenPr(cwd, branch);
462 await this.openPrInBrowser(cwd, branch);
463
464 if (!created) {
465 return {
466 status: "created",
467 baseBranch,
468 headBranch: branch,
469 title: generated.title,
470 };
471 }
472
473 return {
474 status: "created",
475 url: created.url,
476 number: created.number,
477 baseBranch: created.baseRefName,
478 headBranch: created.headRefName,
479 title: created.title,
480 };
481 }
482
483 private async openPrInBrowser(cwd: string, branch: string): Promise<void> {
484 try {
485 await this.runGh(cwd, ["pr", "view", branch, "--web"]);
486 } catch {
487 // Opening the browser is best-effort.
488 }
489 }
490
491 private async findOpenPr(
492 cwd: string,
493 branch: string,
494 ): Promise<OpenPrInfo | null> {
495 const stdout = await this.runGhStdout(cwd, [
496 "pr",
497 "list",
498 "--head",
499 branch,
500 "--state",
501 "open",
502 "--limit",
503 "1",
504 "--json",
505 "number,title,url,baseRefName,headRefName",
506 ]);
507
508 const raw = trimStdout(stdout);
509 if (raw.length === 0) return null;
510
511 let parsedJson: unknown;
512 try {
513 parsedJson = JSON.parse(raw);
514 } catch {
515 throw new Error("GitHub CLI returned invalid PR list JSON.");
516 }
517
518 const parsed = parseOpenPrList(parsedJson);
519 return parsed[0] ?? null;
520 }
521
522 private async resolveBaseBranch(
523 cwd: string,
524 branch: string,
525 upstreamRef: string | null,
526 ): Promise<string> {
527 const mergeBaseConfig = await this.runGitStdout(
528 cwd,
529 ["config", "--get", `branch.${branch}.gh-merge-base`],
530 true,
531 );
532 const configured = trimStdout(mergeBaseConfig);
533 if (configured.length > 0) return configured;
534
535 if (upstreamRef) {
536 const upstreamBranch = extractBranchFromRef(upstreamRef);
537 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
538 return upstreamBranch;
539 }
540 }
541
542 try {
543 const ghDefault = await this.runGhStdout(cwd, [
544 "repo",
545 "view",
546 "--json",
547 "defaultBranchRef",
548 "--jq",
549 ".defaultBranchRef.name",
550 ]);
551 const defaultBranch = trimStdout(ghDefault);
552 if (defaultBranch.length > 0) return defaultBranch;
553 } catch {
554 // Fall through to deterministic fallback.
555 }
556
557 return "main";
558 }
559
560 private async runGit(
561 cwd: string,
562 args: readonly string[],
563 allowNonZeroExit = false,
564 ): Promise<void> {
565 try {
566 await this.run("git", args, {
567 cwd,
568 allowNonZeroExit,
569 });
570 } catch (error) {
571 throw (
572 asCommandNotFound("git", error) ??
573 normalizeNotGitRepoError(error) ??
574 (error instanceof Error ? error : new Error("Git command failed."))
575 );
576 }
577 }
578
579 private async runGitStdout(
580 cwd: string,
581 args: readonly string[],
582 allowNonZeroExit = false,
583 ): Promise<string> {
584 try {
585 const result = await this.run("git", args, {
586 cwd,
587 allowNonZeroExit,
588 });
589 return result.stdout;
590 } catch (error) {
591 throw (
592 asCommandNotFound("git", error) ??
593 normalizeNotGitRepoError(error) ??
594 (error instanceof Error ? error : new Error("Git command failed."))
595 );
596 }
597 }
598
599 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
600 try {
601 await this.run("gh", args, { cwd });
602 } catch (error) {
603 throw (
604 asCommandNotFound("gh", error) ??
605 normalizeGitHubAuthError(error) ??
606 (error instanceof Error ? error : new Error("GitHub CLI command failed."))
607 );
608 }
609 }
610
611 private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {
612 try {
613 const result = await this.run("gh", args, { cwd });
614 return result.stdout;
615 } catch (error) {
616 throw (
617 asCommandNotFound("gh", error) ??
618 normalizeGitHubAuthError(error) ??
619 (error instanceof Error ? error : new Error("GitHub CLI command failed."))
620 );
621 }
622 }
623}
apps/server/src/processRunner.ts created+134
...@@ -0,0 +1,134 @@
1import { spawn } from "node:child_process";
2
3export interface ProcessRunOptions {
4 cwd?: string | undefined;
5 timeoutMs?: number | undefined;
6 env?: NodeJS.ProcessEnv | undefined;
7 stdin?: string | undefined;
8 allowNonZeroExit?: boolean | undefined;
9}
10
11export interface ProcessRunResult {
12 stdout: string;
13 stderr: string;
14 code: number | null;
15 signal: NodeJS.Signals | null;
16 timedOut: boolean;
17}
18
19function commandLabel(command: string, args: readonly string[]): string {
20 return [command, ...args].join(" ");
21}
22
23function normalizeSpawnError(
24 command: string,
25 args: readonly string[],
26 error: unknown,
27): Error {
28 if (!(error instanceof Error)) {
29 return new Error(`Failed to run ${commandLabel(command, args)}.`);
30 }
31
32 const maybeCode = (error as NodeJS.ErrnoException).code;
33 if (maybeCode === "ENOENT") {
34 return new Error(`Command not found: ${command}`);
35 }
36
37 return new Error(
38 `Failed to run ${commandLabel(command, args)}: ${error.message}`,
39 );
40}
41
42function normalizeExitError(
43 command: string,
44 args: readonly string[],
45 result: ProcessRunResult,
46): Error {
47 const reason = result.timedOut
48 ? "timed out"
49 : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`;
50 const stderr = result.stderr.trim();
51 const detail = stderr.length > 0 ? ` ${stderr}` : "";
52 return new Error(`${commandLabel(command, args)} ${reason}.${detail}`);
53}
54
55export async function runProcess(
56 command: string,
57 args: readonly string[],
58 options: ProcessRunOptions = {},
59): Promise<ProcessRunResult> {
60 const timeoutMs = options.timeoutMs ?? 60_000;
61
62 return new Promise<ProcessRunResult>((resolve, reject) => {
63 const child = spawn(command, args, {
64 cwd: options.cwd,
65 env: options.env,
66 stdio: "pipe",
67 });
68
69 let stdout = "";
70 let stderr = "";
71 let timedOut = false;
72 let settled = false;
73 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
74
75 const timeoutTimer = setTimeout(() => {
76 timedOut = true;
77 child.kill("SIGTERM");
78 forceKillTimer = setTimeout(() => {
79 child.kill("SIGKILL");
80 }, 1_000);
81 }, timeoutMs);
82
83 const finalize = (callback: () => void): void => {
84 if (settled) return;
85 settled = true;
86 clearTimeout(timeoutTimer);
87 if (forceKillTimer) {
88 clearTimeout(forceKillTimer);
89 }
90 callback();
91 };
92
93 child.stdout.on("data", (chunk: Buffer | string) => {
94 stdout += chunk.toString();
95 });
96
97 child.stderr.on("data", (chunk: Buffer | string) => {
98 stderr += chunk.toString();
99 });
100
101 child.once("error", (error) => {
102 finalize(() => {
103 reject(normalizeSpawnError(command, args, error));
104 });
105 });
106
107 child.once("close", (code, signal) => {
108 const result: ProcessRunResult = {
109 stdout,
110 stderr,
111 code,
112 signal,
113 timedOut,
114 };
115
116 finalize(() => {
117 if (
118 !options.allowNonZeroExit &&
119 (timedOut || (code !== null && code !== 0))
120 ) {
121 reject(normalizeExitError(command, args, result));
122 return;
123 }
124 resolve(result);
125 });
126 });
127
128 if (options.stdin !== undefined) {
129 child.stdin.write(options.stdin);
130 }
131 child.stdin.end();
132 });
133}
134
apps/server/src/wsServer.test.ts+69
...@@ -85,6 +85,10 @@ describe("WebSocket Server", () => {...@@ -85,6 +85,10 @@ describe("WebSocket Server", () => {
85 devUrl?: string;85 devUrl?: string;
86 authToken?: string;86 authToken?: string;
87 stateDir?: string;87 stateDir?: string;
88 gitManager?: {
89 status: (input: { cwd: string }) => Promise<unknown>;
90 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
91 };
88 } = {},92 } = {},
89 ): ReturnType<typeof createServer> {93 ): ReturnType<typeof createServer> {
90 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");94 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
...@@ -94,6 +98,7 @@ describe("WebSocket Server", () => {...@@ -94,6 +98,7 @@ describe("WebSocket Server", () => {
94 ...(options.devUrl ? { devUrl: options.devUrl } : {}),98 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
95 ...(options.authToken ? { authToken: options.authToken } : {}),99 ...(options.authToken ? { authToken: options.authToken } : {}),
96 projectRegistry: new ProjectRegistry(stateDir),100 projectRegistry: new ProjectRegistry(stateDir),
101 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
97 });102 });
98 }103 }
99104
...@@ -281,6 +286,70 @@ describe("WebSocket Server", () => {...@@ -281,6 +286,70 @@ describe("WebSocket Server", () => {
281 expect(afterRemove.result).toEqual([]);286 expect(afterRemove.result).toEqual([]);
282 });287 });
283288
289 it("responds to git.status via the git manager", async () => {
290 const gitManager = {
291 status: vi.fn().mockResolvedValue({
292 branch: "feature/test",
293 hasWorkingTreeChanges: true,
294 hasUpstream: false,
295 aheadCount: 0,
296 behindCount: 0,
297 }),
298 runStackedAction: vi.fn(),
299 };
300
301 server = createTestServer({ cwd: "/test", gitManager });
302 await server.start();
303 const addr = server.httpServer.address();
304 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
305
306 const ws = await connectWs(port);
307 connections.push(ws);
308 await waitForMessage(ws);
309
310 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
311 cwd: "/test",
312 });
313 expect(response.error).toBeUndefined();
314 expect(response.result).toEqual({
315 branch: "feature/test",
316 hasWorkingTreeChanges: true,
317 hasUpstream: false,
318 aheadCount: 0,
319 behindCount: 0,
320 });
321 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
322 });
323
324 it("returns errors from git.runStackedAction", async () => {
325 const gitManager = {
326 status: vi.fn(),
327 runStackedAction: vi
328 .fn()
329 .mockRejectedValue(new Error("Cannot push from detached HEAD.")),
330 };
331
332 server = createTestServer({ cwd: "/test", gitManager });
333 await server.start();
334 const addr = server.httpServer.address();
335 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
336
337 const ws = await connectWs(port);
338 connections.push(ws);
339 await waitForMessage(ws);
340
341 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
342 cwd: "/test",
343 action: "commit_push",
344 });
345 expect(response.result).toBeUndefined();
346 expect(response.error?.message).toContain("detached HEAD");
347 expect(gitManager.runStackedAction).toHaveBeenCalledWith({
348 cwd: "/test",
349 action: "commit_push",
350 });
351 });
352
284 it("prunes missing projects on startup", async () => {353 it("prunes missing projects on startup", async () => {
285 const stateDir = makeTempDir("t3code-ws-prune-state-");354 const stateDir = makeTempDir("t3code-ws-prune-state-");
286 const existing = makeTempDir("t3code-ws-existing-project-");355 const existing = makeTempDir("t3code-ws-existing-project-");
apps/server/src/wsServer.ts+10
...@@ -19,6 +19,7 @@ import { WebSocketServer, type WebSocket } from "ws";...@@ -19,6 +19,7 @@ import { WebSocketServer, type WebSocket } from "ws";
19import { createLogger } from "./logger";19import { createLogger } from "./logger";
20import { ProjectRegistry } from "./projectRegistry";20import { ProjectRegistry } from "./projectRegistry";
21import { ProviderManager } from "./providerManager";21import { ProviderManager } from "./providerManager";
22import { GitManager } from "./gitManager";
2223
23const MIME_TYPES: Record<string, string> = {24const MIME_TYPES: Record<string, string> = {
24 ".html": "text/html; charset=utf-8",25 ".html": "text/html; charset=utf-8",
...@@ -43,6 +44,7 @@ export interface ServerOptions {...@@ -43,6 +44,7 @@ export interface ServerOptions {
43 devUrl?: string | undefined;44 devUrl?: string | undefined;
44 logWebSocketEvents?: boolean | undefined;45 logWebSocketEvents?: boolean | undefined;
45 projectRegistry?: ProjectRegistry | undefined;46 projectRegistry?: ProjectRegistry | undefined;
47 gitManager?: GitManager | undefined;
46 authToken?: string | undefined;48 authToken?: string | undefined;
47}49}
4850
...@@ -63,11 +65,13 @@ export function createServer(options: ServerOptions) {...@@ -63,11 +65,13 @@ export function createServer(options: ServerOptions) {
63 devUrl,65 devUrl,
64 logWebSocketEvents: explicitLogWsEvents,66 logWebSocketEvents: explicitLogWsEvents,
65 projectRegistry: providedRegistry,67 projectRegistry: providedRegistry,
68 gitManager: providedGitManager,
66 authToken,69 authToken,
67 } = options;70 } = options;
68 const providerManager = new ProviderManager();71 const providerManager = new ProviderManager();
69 const projectRegistry =72 const projectRegistry =
70 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));73 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
74 const gitManager = providedGitManager ?? new GitManager();
71 const clients = new Set<WebSocket>();75 const clients = new Set<WebSocket>();
72 const logger = createLogger("ws");76 const logger = createLogger("ws");
73 const logWebSocketEvents =77 const logWebSocketEvents =
...@@ -304,6 +308,12 @@ export function createServer(options: ServerOptions) {...@@ -304,6 +308,12 @@ export function createServer(options: ServerOptions) {
304 return undefined;308 return undefined;
305 }309 }
306310
311 case WS_METHODS.gitStatus:
312 return gitManager.status(request.params as never);
313
314 case WS_METHODS.gitRunStackedAction:
315 return gitManager.runStackedAction(request.params as never);
316
307 case WS_METHODS.serverGetConfig:317 case WS_METHODS.serverGetConfig:
308 return { cwd };318 return { cwd };
309319
apps/web/src/components/ChatView.tsx+197
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import {1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 type GitRunStackedActionResult,
4 type GitStackedAction,
5 type GitStatusResult,
3 type ProviderApprovalDecision,6 type ProviderApprovalDecision,
4 type ProviderEvent,7 type ProviderEvent,
5} from "@t3tools/contracts";8} from "@t3tools/contracts";
...@@ -7,6 +10,7 @@ import {...@@ -7,6 +10,7 @@ import {
7 type FormEvent,10 type FormEvent,
8 Fragment,11 Fragment,
9 type KeyboardEvent,12 type KeyboardEvent,
13 useCallback,
10 useEffect,14 useEffect,
11 useMemo,15 useMemo,
12 useRef,16 useRef,
...@@ -52,6 +56,14 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {...@@ -52,6 +56,14 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {
5256
53const LAST_EDITOR_KEY = "t3code:last-editor";57const LAST_EDITOR_KEY = "t3code:last-editor";
54const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;58const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
59const GIT_ACTION_ITEMS: Array<{
60 id: GitStackedAction;
61 label: string;
62}> = [
63 { id: "commit", label: "Commit" },
64 { id: "commit_push", label: "Commit & Push" },
65 { id: "commit_push_pr", label: "Commit, Push & Create PR" },
66];
5567
56function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {68function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
57 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";69 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
...@@ -132,6 +144,35 @@ function derivePendingApprovals(...@@ -132,6 +144,35 @@ function derivePendingApprovals(
132 return Array.from(pending.values());144 return Array.from(pending.values());
133}145}
134146
147function summarizeGitActionResult(result: GitRunStackedActionResult): string {
148 const parts: string[] = [];
149
150 if (result.commit.status === "created") {
151 parts.push(`Committed: ${result.commit.subject ?? "changes saved"}.`);
152 } else {
153 parts.push("No local changes to commit.");
154 }
155
156 if (result.push.status === "pushed") {
157 const upstream = result.push.upstreamBranch;
158 parts.push(
159 upstream ? `Pushed to ${upstream}.` : "Pushed to remote branch.",
160 );
161 } else if (result.push.status === "skipped_up_to_date") {
162 parts.push("Push skipped: branch already up to date.");
163 }
164
165 if (result.pr.status === "created") {
166 parts.push(result.pr.url ? `PR created: ${result.pr.url}` : "PR created.");
167 } else if (result.pr.status === "opened_existing") {
168 parts.push(
169 result.pr.url ? `Opened existing PR: ${result.pr.url}` : "Opened existing PR.",
170 );
171 }
172
173 return parts.join(" ");
174}
175
135export default function ChatView() {176export default function ChatView() {
136 const { state, dispatch } = useStore();177 const { state, dispatch } = useStore();
137 const api = useMemo(() => readNativeApi(), []);178 const api = useMemo(() => readNativeApi(), []);
...@@ -140,6 +181,11 @@ export default function ChatView() {...@@ -140,6 +181,11 @@ export default function ChatView() {
140 const [isConnecting, setIsConnecting] = useState(false);181 const [isConnecting, setIsConnecting] = useState(false);
141 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);182 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
142 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);183 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
184 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
185 const [isGitActionRunning, setIsGitActionRunning] = useState(false);
186 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
187 const [gitActionNotice, setGitActionNotice] = useState<string | null>(null);
188 const [gitActionError, setGitActionError] = useState<string | null>(null);
143 const [lastEditor, setLastEditor] = useState<EditorId>(() => {189 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
144 const stored = localStorage.getItem(LAST_EDITOR_KEY);190 const stored = localStorage.getItem(LAST_EDITOR_KEY);
145 return EDITORS.some((e) => e.id === stored)191 return EDITORS.some((e) => e.id === stored)
...@@ -160,6 +206,7 @@ export default function ChatView() {...@@ -160,6 +206,7 @@ export default function ChatView() {
160 const textareaRef = useRef<HTMLTextAreaElement>(null);206 const textareaRef = useRef<HTMLTextAreaElement>(null);
161 const modelMenuRef = useRef<HTMLDivElement>(null);207 const modelMenuRef = useRef<HTMLDivElement>(null);
162 const editorMenuRef = useRef<HTMLDivElement>(null);208 const editorMenuRef = useRef<HTMLDivElement>(null);
209 const gitMenuRef = useRef<HTMLDivElement>(null);
163210
164 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);211 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
165 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);212 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
...@@ -265,6 +312,49 @@ export default function ChatView() {...@@ -265,6 +312,49 @@ export default function ChatView() {
265 approvalPolicy: "on-request",312 approvalPolicy: "on-request",
266 sandboxMode: "workspace-write",313 sandboxMode: "workspace-write",
267 } as const);314 } as const);
315 const gitActionsDisabled =
316 !api || !activeProject || !gitStatus || isGitActionRunning;
317 const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null;
318
319 const refreshGitStatus = useCallback(async () => {
320 if (!api || !activeProject) {
321 setGitStatus(null);
322 return;
323 }
324
325 const nextStatus = await api.git.status({ cwd: activeProject.cwd });
326 setGitStatus(nextStatus);
327 }, [api, activeProject]);
328
329 const runGitAction = useCallback(
330 async (action: GitStackedAction) => {
331 if (!api || !activeProject) return;
332
333 setIsGitMenuOpen(false);
334 setIsGitActionRunning(true);
335 setGitActionError(null);
336 setGitActionNotice(null);
337 try {
338 const result = await api.git.runStackedAction({
339 cwd: activeProject.cwd,
340 action,
341 });
342 setGitActionNotice(summarizeGitActionResult(result));
343 } catch (error) {
344 setGitActionError(
345 error instanceof Error ? error.message : "Git action failed.",
346 );
347 } finally {
348 setIsGitActionRunning(false);
349 try {
350 await refreshGitStatus();
351 } catch {
352 setGitStatus(null);
353 }
354 }
355 },
356 [api, activeProject, refreshGitStatus],
357 );
268358
269 const handleRuntimeModeChange = async (359 const handleRuntimeModeChange = async (
270 mode: "approval-required" | "full-access",360 mode: "approval-required" | "full-access",
...@@ -309,6 +399,42 @@ export default function ChatView() {...@@ -309,6 +399,42 @@ export default function ChatView() {
309 setExpandedWorkGroups({});399 setExpandedWorkGroups({});
310 }, [activeThread?.id]);400 }, [activeThread?.id]);
311401
402 useEffect(() => {
403 setGitActionNotice(null);
404 setGitActionError(null);
405 }, [activeProject?.id]);
406
407 useEffect(() => {
408 let cancelled = false;
409 if (!api || !activeProject) {
410 setGitStatus(null);
411 return;
412 }
413
414 const load = async () => {
415 try {
416 const nextStatus = await api.git.status({ cwd: activeProject.cwd });
417 if (!cancelled) {
418 setGitStatus(nextStatus);
419 }
420 } catch (error) {
421 if (!cancelled) {
422 setGitStatus(null);
423 setGitActionError(
424 error instanceof Error
425 ? error.message
426 : "Failed to read git status.",
427 );
428 }
429 }
430 };
431
432 void load();
433 return () => {
434 cancelled = true;
435 };
436 }, [api, activeProject]);
437
312 // Auto-resize textarea438 // Auto-resize textarea
313 useEffect(() => {439 useEffect(() => {
314 const ta = textareaRef.current;440 const ta = textareaRef.current;
...@@ -362,6 +488,22 @@ export default function ChatView() {...@@ -362,6 +488,22 @@ export default function ChatView() {
362 };488 };
363 }, [isEditorMenuOpen]);489 }, [isEditorMenuOpen]);
364490
491 useEffect(() => {
492 if (!isGitMenuOpen) return;
493
494 const handleClickOutside = (event: MouseEvent) => {
495 if (!gitMenuRef.current) return;
496 if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) {
497 setIsGitMenuOpen(false);
498 }
499 };
500
501 window.addEventListener("mousedown", handleClickOutside);
502 return () => {
503 window.removeEventListener("mousedown", handleClickOutside);
504 };
505 }, [isGitMenuOpen]);
506
365 // Cmd+O / Ctrl+O to open in last-used editor507 // Cmd+O / Ctrl+O to open in last-used editor
366 useEffect(() => {508 useEffect(() => {
367 const handler = (e: globalThis.KeyboardEvent) => {509 const handler = (e: globalThis.KeyboardEvent) => {
...@@ -617,6 +759,49 @@ export default function ChatView() {...@@ -617,6 +759,49 @@ export default function ChatView() {
617 )}759 )}
618 </div>760 </div>
619 )}761 )}
762 {/* Git actions */}
763 {activeProject && (
764 <div className="relative" ref={gitMenuRef}>
765 <button
766 type="button"
767 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"
768 onClick={() => setIsGitMenuOpen((v) => !v)}
769 disabled={!gitStatus || isGitActionRunning}
770 >
771 {isGitActionRunning ? "Running..." : "Git actions"}
772 <span aria-hidden="true">▾</span>
773 </button>
774 {isGitMenuOpen && (
775 <div className="absolute right-0 top-full z-50 mt-1 w-[240px] rounded-xl border border-border bg-popover p-2 shadow-xl">
776 <p className="px-2 pb-1 text-[10px] uppercase tracking-[0.1em] text-muted-foreground/60">
777 Git actions
778 </p>
779 {GIT_ACTION_ITEMS.map((item) => {
780 const disabled =
781 item.id === "commit" ? gitActionsDisabled : pushActionDisabled;
782 return (
783 <button
784 key={item.id}
785 type="button"
786 className="mb-1 flex w-full items-center rounded-lg px-2 py-2 text-left text-[11px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
787 disabled={disabled}
788 onClick={() => {
789 void runGitAction(item.id);
790 }}
791 >
792 {item.label}
793 </button>
794 );
795 })}
796 {gitStatus?.branch === null && (
797 <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
798 Detached HEAD: push and PR actions are unavailable.
799 </p>
800 )}
801 </div>
802 )}
803 </div>
804 )}
620 {/* Diff toggle */}805 {/* Diff toggle */}
621 <button806 <button
622 type="button"807 type="button"
...@@ -639,6 +824,18 @@ export default function ChatView() {...@@ -639,6 +824,18 @@ export default function ChatView() {
639 </div>824 </div>
640 )}825 )}
641826
827 {gitActionError && (
828 <div className="mx-4 mt-3 rounded-lg border border-rose-400/20 bg-rose-900/20 px-3 py-2 text-xs text-rose-200">
829 {gitActionError}
830 </div>
831 )}
832
833 {gitActionNotice && (
834 <div className="mx-4 mt-3 rounded-lg border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-2 text-xs text-emerald-100">
835 {gitActionNotice}
836 </div>
837 )}
838
642 {pendingApprovals.length > 0 && (839 {pendingApprovals.length > 0 && (
643 <div className="mx-4 mt-3 space-y-2">840 <div className="mx-4 mt-3 space-y-2">
644 {pendingApprovals.map((approval) => {841 {pendingApprovals.map((approval) => {
apps/web/src/wsNativeApi.ts+4
...@@ -96,6 +96,10 @@ export function createWsNativeApi(): NativeApi {...@@ -96,6 +96,10 @@ export function createWsNativeApi(): NativeApi {
96 openInEditor: (cwd, editor) =>96 openInEditor: (cwd, editor) =>
97 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),97 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
98 },98 },
99 git: {
100 status: (input) => transport.request(WS_METHODS.gitStatus, input),
101 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
102 },
99 contextMenu: {103 contextMenu: {
100 show: async <T extends string>(104 show: async <T extends string>(
101 items: readonly { id: T; label: string }[],105 items: readonly { id: T; label: string }[],
packages/contracts/src/git.test.ts created+73
...@@ -0,0 +1,73 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 gitRunStackedActionInputSchema,
5 gitRunStackedActionResultSchema,
6 gitStackedActionSchema,
7 gitStatusInputSchema,
8 gitStatusResultSchema,
9} from "./git";
10
11describe("git contracts", () => {
12 it("parses git status input and trims cwd", () => {
13 const parsed = gitStatusInputSchema.parse({ cwd: " /tmp/repo " });
14 expect(parsed.cwd).toBe("/tmp/repo");
15 });
16
17 it("parses git status result", () => {
18 const parsed = gitStatusResultSchema.parse({
19 branch: "feature/git-actions",
20 hasWorkingTreeChanges: true,
21 hasUpstream: false,
22 aheadCount: 0,
23 behindCount: 0,
24 });
25 expect(parsed.branch).toBe("feature/git-actions");
26 expect(parsed.hasWorkingTreeChanges).toBe(true);
27 });
28
29 it("accepts supported stacked actions", () => {
30 expect(gitStackedActionSchema.parse("commit")).toBe("commit");
31 expect(gitStackedActionSchema.parse("commit_push")).toBe("commit_push");
32 expect(gitStackedActionSchema.parse("commit_push_pr")).toBe(
33 "commit_push_pr",
34 );
35 });
36
37 it("parses stacked action input", () => {
38 const parsed = gitRunStackedActionInputSchema.parse({
39 cwd: "/tmp/repo",
40 action: "commit_push",
41 });
42 expect(parsed.action).toBe("commit_push");
43 });
44
45 it("parses stacked action result with metadata", () => {
46 const parsed = gitRunStackedActionResultSchema.parse({
47 action: "commit_push_pr",
48 commit: {
49 status: "created",
50 commitSha: "abc123",
51 subject: "Implement stacked git actions",
52 },
53 push: {
54 status: "pushed",
55 branch: "feature/git-actions",
56 upstreamBranch: "origin/feature/git-actions",
57 setUpstream: true,
58 },
59 pr: {
60 status: "created",
61 url: "https://github.com/pingdotgg/codething-mvp/pull/123",
62 number: 123,
63 baseBranch: "main",
64 headBranch: "feature/git-actions",
65 title: "Add stacked git actions workflow",
66 },
67 });
68
69 expect(parsed.commit.status).toBe("created");
70 expect(parsed.pr.number).toBe(123);
71 });
72});
73
packages/contracts/src/git.ts created+66
...@@ -0,0 +1,66 @@
1import { z } from "zod";
2
3export const gitStatusInputSchema = z.object({
4 cwd: z.string().trim().min(1),
5});
6
7export const gitStatusResultSchema = z.object({
8 branch: z.string().min(1).nullable(),
9 hasWorkingTreeChanges: z.boolean(),
10 hasUpstream: z.boolean(),
11 aheadCount: z.number().int().nonnegative(),
12 behindCount: z.number().int().nonnegative(),
13});
14
15export const gitStackedActionSchema = z.enum([
16 "commit",
17 "commit_push",
18 "commit_push_pr",
19]);
20
21const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]);
22const gitPushStepStatusSchema = z.enum([
23 "pushed",
24 "skipped_not_requested",
25 "skipped_up_to_date",
26]);
27const gitPrStepStatusSchema = z.enum([
28 "created",
29 "opened_existing",
30 "skipped_not_requested",
31]);
32
33export const gitRunStackedActionInputSchema = z.object({
34 cwd: z.string().trim().min(1),
35 action: gitStackedActionSchema,
36});
37
38export const gitRunStackedActionResultSchema = z.object({
39 action: gitStackedActionSchema,
40 commit: z.object({
41 status: gitCommitStepStatusSchema,
42 commitSha: z.string().min(1).optional(),
43 subject: z.string().min(1).optional(),
44 }),
45 push: z.object({
46 status: gitPushStepStatusSchema,
47 branch: z.string().min(1).optional(),
48 upstreamBranch: z.string().min(1).optional(),
49 setUpstream: z.boolean().optional(),
50 }),
51 pr: z.object({
52 status: gitPrStepStatusSchema,
53 url: z.string().url().optional(),
54 number: z.number().int().positive().optional(),
55 baseBranch: z.string().min(1).optional(),
56 headBranch: z.string().min(1).optional(),
57 title: z.string().min(1).optional(),
58 }),
59});
60
61export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
62export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
63export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
64export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
65export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
66
packages/contracts/src/index.ts+1
...@@ -6,3 +6,4 @@ export * from "./provider";...@@ -6,3 +6,4 @@ export * from "./provider";
6export * from "./model";6export * from "./model";
7export * from "./ws";7export * from "./ws";
8export * from "./project";8export * from "./project";
9export * from "./git";
packages/contracts/src/ipc.ts+12
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
2import type {
3 GitRunStackedActionInput,
4 GitRunStackedActionResult,
5 GitStatusInput,
6 GitStatusResult,
7} from "./git";
2import type {8import type {
3 ProviderEvent,9 ProviderEvent,
4 ProviderInterruptTurnInput,10 ProviderInterruptTurnInput,
...@@ -62,6 +68,12 @@ export interface NativeApi {...@@ -62,6 +68,12 @@ export interface NativeApi {
62 shell: {68 shell: {
63 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;69 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
64 };70 };
71 git: {
72 status: (input: GitStatusInput) => Promise<GitStatusResult>;
73 runStackedAction: (
74 input: GitRunStackedActionInput,
75 ) => Promise<GitRunStackedActionResult>;
76 };
65 contextMenu: {77 contextMenu: {
66 show: <T extends string>(78 show: <T extends string>(
67 items: readonly { id: T; label: string }[],79 items: readonly { id: T; label: string }[],
packages/contracts/src/ws.ts+4
...@@ -19,6 +19,10 @@ export const WS_METHODS = {...@@ -19,6 +19,10 @@ export const WS_METHODS = {
19 // Shell methods19 // Shell methods
20 shellOpenInEditor: "shell.openInEditor",20 shellOpenInEditor: "shell.openInEditor",
2121
22 // Git methods
23 gitStatus: "git.status",
24 gitRunStackedAction: "git.runStackedAction",
25
22 // Server meta26 // Server meta
23 serverGetConfig: "server.getConfig",27 serverGetConfig: "server.getConfig",
24} as const;28} as const;