authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:53:53-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-12 14:53:53-08:00
log44f3b3cb7c82c6a2cfd33dad92bb76260f9e32ff
tree669d65674e0bbfb6fdee4ab5a20faaf76b995725
parent36cdceb4bff336dbb53ad638806e4e1a88043b73
parentdd920846b9195275e6d8605a0f61cf8786cfa469
signature Signed by PGP key B5690EEEBB952194

Merge pull request #13 from pingdotgg/codex/add-github-commit-push-ui


30 files changed, 3667 insertions(+), 289 deletions(-)

apps/desktop/src/main.ts+27-1
...@@ -5,7 +5,7 @@ import net from "node:net";...@@ -5,7 +5,7 @@ import net from "node:net";
5import os from "node:os";5import os from "node:os";
6import path from "node:path";6import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron";8import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
99
10import { fixPath } from "./fixPath";10import { fixPath } from "./fixPath";
1111
...@@ -13,6 +13,7 @@ fixPath();...@@ -13,6 +13,7 @@ fixPath();
1313
14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
15const CONTEXT_MENU_CHANNEL = "desktop:context-menu";15const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
16const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
16const ROOT_DIR = path.resolve(__dirname, "../../..");17const ROOT_DIR = path.resolve(__dirname, "../../..");
17const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");18const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
18const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");19const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
...@@ -159,6 +160,31 @@ function registerIpcHandlers(): void {...@@ -159,6 +160,31 @@ function registerIpcHandlers(): void {
159 });160 });
160 });161 });
161 });162 });
163
164 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
165 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
166 if (typeof rawUrl !== "string" || rawUrl.length === 0) {
167 return false;
168 }
169
170 let parsedUrl: URL;
171 try {
172 parsedUrl = new URL(rawUrl);
173 } catch {
174 return false;
175 }
176
177 if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
178 return false;
179 }
180
181 try {
182 await shell.openExternal(parsedUrl.toString());
183 return true;
184 } catch {
185 return false;
186 }
187 });
162}188}
163189
164function createWindow(): BrowserWindow {190function createWindow(): BrowserWindow {
apps/desktop/src/preload.ts+2
...@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";...@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
22
3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
5const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;6const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
67
7contextBridge.exposeInMainWorld("desktopBridge", {8contextBridge.exposeInMainWorld("desktopBridge", {
...@@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", {
9 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,10 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,
10 showContextMenu: (items: { id: string; label: string }[]) =>11 showContextMenu: (items: { id: string; label: string }[]) =>
11 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,12 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
13 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
12});14});
apps/server/src/codexTextGenerator.test.ts created+123
...@@ -0,0 +1,123 @@
1import fs from "node:fs/promises";
2
3import { describe, expect, it } from "vitest";
4
5import { CodexTextGenerator } from "./codexTextGenerator";
6import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
7
8type ProcessRunner = (
9 command: string,
10 args: readonly string[],
11 options?: ProcessRunOptions,
12) => Promise<ProcessRunResult>;
13
14function getArgValue(args: readonly string[], flag: string): string {
15 const index = args.indexOf(flag);
16 if (index < 0 || index + 1 >= args.length) {
17 throw new Error(`Missing argument value for ${flag}`);
18 }
19 const value = args[index + 1];
20 if (!value) {
21 throw new Error(`Missing argument value for ${flag}`);
22 }
23 return value;
24}
25
26function okResult(): ProcessRunResult {
27 return {
28 stdout: "",
29 stderr: "",
30 code: 0,
31 signal: null,
32 timedOut: false,
33 };
34}
35
36function commitInput() {
37 return {
38 cwd: process.cwd(),
39 branch: "feat/example",
40 stagedSummary: "M apps/server/src/gitManager.ts",
41 stagedPatch: "diff --git a/file b/file",
42 };
43}
44
45describe("CodexTextGenerator", () => {
46 it("uses gpt-5.3-codex-spark when available with medium reasoning effort", async () => {
47 const models: string[] = [];
48 const configs: string[] = [];
49
50 const runner: ProcessRunner = async (command, args) => {
51 expect(command).toBe("codex");
52 models.push(getArgValue(args, "--model"));
53 configs.push(getArgValue(args, "--config"));
54
55 const outputPath = getArgValue(args, "--output-last-message");
56 await fs.writeFile(
57 outputPath,
58 JSON.stringify({
59 subject: "Add stacked git actions menu behavior",
60 body: "- Keep menu actions visible\n- Improve disabled states",
61 }),
62 "utf8",
63 );
64 return okResult();
65 };
66
67 const generator = new CodexTextGenerator({ runProcess: runner });
68 const result = await generator.generateCommitMessage(commitInput());
69
70 expect(result.subject).toBe("Add stacked git actions menu behavior");
71 expect(models).toEqual(["gpt-5.3-codex-spark"]);
72 expect(configs).toEqual(['model_reasoning_effort="medium"']);
73 });
74
75 it("uses gpt-5.3-codex-spark for PR content generation", async () => {
76 const models: string[] = [];
77
78 const runner: ProcessRunner = async (command, args) => {
79 expect(command).toBe("codex");
80 models.push(getArgValue(args, "--model"));
81
82 const outputPath = getArgValue(args, "--output-last-message");
83 await fs.writeFile(
84 outputPath,
85 JSON.stringify({
86 title: "Improve Git action modal behavior",
87 body: "## Summary\n- Update PR generation model\n\n## Testing\n- Not run",
88 }),
89 "utf8",
90 );
91 return okResult();
92 };
93
94 const generator = new CodexTextGenerator({ runProcess: runner });
95 const result = await generator.generatePrContent({
96 cwd: process.cwd(),
97 baseBranch: "main",
98 headBranch: "feat/example",
99 commitSummary: "abc123 Update model",
100 diffSummary: "1 file changed",
101 diffPatch: "diff --git a/file b/file",
102 });
103
104 expect(result.title).toBe("Improve Git action modal behavior");
105 expect(models).toEqual(["gpt-5.3-codex-spark"]);
106 });
107
108 it("propagates generation failures without retrying a second model", async () => {
109 const models: string[] = [];
110
111 const runner: ProcessRunner = async (_command, args) => {
112 models.push(getArgValue(args, "--model"));
113 throw new Error("Request timed out while contacting Codex.");
114 };
115
116 const generator = new CodexTextGenerator({ runProcess: runner });
117
118 await expect(generator.generateCommitMessage(commitInput())).rejects.toThrow(
119 "Request timed out while contacting Codex.",
120 );
121 expect(models).toEqual(["gpt-5.3-codex-spark"]);
122 });
123});
apps/server/src/codexTextGenerator.ts created+267
...@@ -0,0 +1,267 @@
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 {
14 type ProcessRunOptions,
15 type ProcessRunResult,
16 runProcess,
17} from "./processRunner";
18
19type ProcessRunner = (
20 command: string,
21 args: readonly string[],
22 options?: ProcessRunOptions,
23) => Promise<ProcessRunResult>;
24
25const CODEX_MODEL = "gpt-5.3-codex-spark";
26const CODEX_REASONING_EFFORT = "medium";
27
28const COMMIT_OUTPUT_SCHEMA_JSON = {
29 type: "object",
30 properties: {
31 subject: { type: "string" },
32 body: { type: "string" },
33 },
34 required: ["subject", "body"],
35 additionalProperties: false,
36} as const;
37
38const PR_OUTPUT_SCHEMA_JSON = {
39 type: "object",
40 properties: {
41 title: { type: "string" },
42 body: { type: "string" },
43 },
44 required: ["title", "body"],
45 additionalProperties: false,
46} as const;
47
48function parseCommitOutput(raw: unknown): { subject: string; body: string } {
49 if (!raw || typeof raw !== "object") {
50 throw new Error("Codex returned a non-object commit message payload.");
51 }
52 const record = raw as Record<string, unknown>;
53 const subject = typeof record.subject === "string" ? record.subject.trim() : "";
54 const body = typeof record.body === "string" ? record.body : "";
55 if (subject.length === 0) {
56 throw new Error("Codex returned an empty commit subject.");
57 }
58 return { subject, body };
59}
60
61function parsePrOutput(raw: unknown): { title: string; body: string } {
62 if (!raw || typeof raw !== "object") {
63 throw new Error("Codex returned a non-object PR payload.");
64 }
65 const record = raw as Record<string, unknown>;
66 const title = typeof record.title === "string" ? record.title.trim() : "";
67 const body = typeof record.body === "string" ? record.body.trim() : "";
68 if (title.length === 0 || body.length === 0) {
69 throw new Error("Codex returned an invalid PR title/body payload.");
70 }
71 return { title, body };
72}
73
74function limitSection(value: string, maxChars: number): string {
75 if (value.length <= maxChars) return value;
76 const truncated = value.slice(0, maxChars);
77 return `${truncated}\n\n[truncated]`;
78}
79
80async function writeTempFile(prefix: string, content: string): Promise<string> {
81 const filePath = path.join(
82 os.tmpdir(),
83 `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`,
84 );
85 await fs.writeFile(filePath, content, "utf8");
86 return filePath;
87}
88
89async function safeUnlink(filePath: string): Promise<void> {
90 try {
91 await fs.unlink(filePath);
92 } catch {
93 // Best-effort cleanup.
94 }
95}
96
97function sanitizeCommitSubject(raw: string): string {
98 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
99 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
100 if (withoutTrailingPeriod.length === 0) {
101 return "Update project files";
102 }
103
104 if (withoutTrailingPeriod.length <= 72) {
105 return withoutTrailingPeriod;
106 }
107 return withoutTrailingPeriod.slice(0, 72).trimEnd();
108}
109
110function sanitizePrTitle(raw: string): string {
111 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
112 if (singleLine.length > 0) {
113 return singleLine;
114 }
115 return "Update project changes";
116}
117
118async function runCodexJson<T>({
119 cwd,
120 prompt,
121 outputSchemaJson,
122 parse,
123 run,
124}: {
125 cwd: string;
126 prompt: string;
127 outputSchemaJson: object;
128 parse: (raw: unknown) => T;
129 run: ProcessRunner;
130}): Promise<T> {
131 const schemaPath = await writeTempFile(
132 "codex-schema",
133 JSON.stringify(outputSchemaJson),
134 );
135 let outputPath: string | null = null;
136
137 try {
138 outputPath = await writeTempFile("codex-output", "");
139 await run(
140 "codex",
141 [
142 "exec",
143 "--ephemeral",
144 "-s",
145 "read-only",
146 "--model",
147 CODEX_MODEL,
148 "--config",
149 `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
150 "--output-schema",
151 schemaPath,
152 "--output-last-message",
153 outputPath,
154 "-",
155 ],
156 {
157 cwd,
158 timeoutMs: 180_000,
159 stdin: prompt,
160 },
161 );
162
163 const rawOutput = (await fs.readFile(outputPath, "utf8")).trim();
164 if (rawOutput.length === 0) {
165 throw new Error("Codex returned an empty response.");
166 }
167
168 let parsedJson: unknown;
169 try {
170 parsedJson = JSON.parse(rawOutput);
171 } catch {
172 throw new Error("Codex returned invalid JSON output.");
173 }
174
175 return parse(parsedJson);
176 } finally {
177 await Promise.all([
178 safeUnlink(schemaPath),
179 ...(outputPath ? [safeUnlink(outputPath)] : []),
180 ]);
181 }
182}
183
184interface CodexTextGeneratorDeps {
185 runProcess?: ProcessRunner;
186}
187
188export class CodexTextGenerator implements TextGenerationService {
189 private readonly run: ProcessRunner;
190
191 constructor(deps: CodexTextGeneratorDeps = {}) {
192 this.run = deps.runProcess ?? runProcess;
193 }
194
195 async generateCommitMessage(
196 input: CommitMessageGenerationInput,
197 ): Promise<CommitMessageGenerationResult> {
198 const prompt = [
199 "You write concise git commit messages.",
200 "Return a JSON object with keys: subject, body.",
201 "Rules:",
202 "- subject must be imperative, <= 72 chars, and no trailing period",
203 "- body can be empty string or short bullet points",
204 "- capture the primary user-visible or developer-visible change",
205 "",
206 `Branch: ${input.branch ?? "(detached)"}`,
207 "",
208 "Staged files:",
209 limitSection(input.stagedSummary, 6_000),
210 "",
211 "Staged patch:",
212 limitSection(input.stagedPatch, 40_000),
213 ].join("\n");
214
215 const generated = await runCodexJson({
216 cwd: input.cwd,
217 prompt,
218 outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON,
219 parse: (raw) => parseCommitOutput(raw),
220 run: this.run,
221 });
222
223 return {
224 subject: sanitizeCommitSubject(generated.subject),
225 body: generated.body.trim(),
226 };
227 }
228
229 async generatePrContent(
230 input: PrContentGenerationInput,
231 ): Promise<PrContentGenerationResult> {
232 const prompt = [
233 "You write GitHub pull request content.",
234 "Return a JSON object with keys: title, body.",
235 "Rules:",
236 "- title should be concise and specific",
237 "- body must be markdown and include headings '## Summary' and '## Testing'",
238 "- under Summary, provide short bullet points",
239 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
240 "",
241 `Base branch: ${input.baseBranch}`,
242 `Head branch: ${input.headBranch}`,
243 "",
244 "Commits:",
245 limitSection(input.commitSummary, 12_000),
246 "",
247 "Diff stat:",
248 limitSection(input.diffSummary, 12_000),
249 "",
250 "Diff patch:",
251 limitSection(input.diffPatch, 40_000),
252 ].join("\n");
253
254 const generated = await runCodexJson({
255 cwd: input.cwd,
256 prompt,
257 outputSchemaJson: PR_OUTPUT_SCHEMA_JSON,
258 parse: (raw) => parsePrOutput(raw),
259 run: this.run,
260 });
261
262 return {
263 title: sanitizePrTitle(generated.title),
264 body: generated.body.trim(),
265 };
266 }
267}
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/git.test.ts+82
...@@ -5,6 +5,7 @@ import path from "node:path";...@@ -5,6 +5,7 @@ import path from "node:path";
5import { describe, expect, it } from "vitest";5import { describe, expect, it } from "vitest";
66
7import {7import {
8 GitCoreService,
8 checkoutGitBranch,9 checkoutGitBranch,
9 createGitBranch,10 createGitBranch,
10 createGitWorktree,11 createGitWorktree,
...@@ -435,4 +436,85 @@ describe("git integration", () => {...@@ -435,4 +436,85 @@ describe("git integration", () => {
435 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);436 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
436 });437 });
437 });438 });
439
440 describe("GitCoreService", () => {
441 it("supports branch lifecycle operations through the service API", async () => {
442 await using tmp = await makeTmpDir();
443 const core = new GitCoreService();
444
445 await core.initRepo({ cwd: tmp.path });
446 await git(tmp.path, "config user.email 'test@test.com'");
447 await git(tmp.path, "config user.name 'Test'");
448 await writeFile(path.join(tmp.path, "README.md"), "# test\n");
449 await git(tmp.path, "add .");
450 await git(tmp.path, "commit -m 'initial commit'");
451
452 await core.createBranch({ cwd: tmp.path, branch: "feature/service-api" });
453 await core.checkoutBranch({ cwd: tmp.path, branch: "feature/service-api" });
454 const branches = await core.listBranches({ cwd: tmp.path });
455
456 expect(branches.isRepo).toBe(true);
457 expect(branches.branches.find((branch) => branch.current)?.name).toBe("feature/service-api");
458 });
459
460 it("reports status details and dirty state", async () => {
461 await using tmp = await makeTmpDir();
462 await initRepoWithCommit(tmp.path);
463 const core = new GitCoreService();
464
465 const clean = await core.status({ cwd: tmp.path });
466 expect(clean.hasWorkingTreeChanges).toBe(false);
467 expect(clean.branch).toBeTruthy();
468
469 await writeFile(path.join(tmp.path, "README.md"), "updated\n");
470 const dirty = await core.statusDetails(tmp.path);
471 expect(dirty.hasWorkingTreeChanges).toBe(true);
472 });
473
474 it("prepares commit context by auto-staging and creates commit", async () => {
475 await using tmp = await makeTmpDir();
476 await initRepoWithCommit(tmp.path);
477 const core = new GitCoreService();
478
479 await writeFile(path.join(tmp.path, "README.md"), "new content\n");
480 const context = await core.prepareCommitContext(tmp.path);
481 expect(context).not.toBeNull();
482 expect(context!.stagedSummary.length).toBeGreaterThan(0);
483 expect(context!.stagedPatch.length).toBeGreaterThan(0);
484
485 const created = await core.commit(
486 tmp.path,
487 "Add README update",
488 "- include updated content",
489 );
490 expect(created.commitSha.length).toBeGreaterThan(0);
491 expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update");
492 });
493
494 it("pushes with upstream setup and then skips when up to date", async () => {
495 await using tmp = await makeTmpDir();
496 await using remote = await makeTmpDir();
497 await initRepoWithCommit(tmp.path);
498 await git(remote.path, "init --bare");
499 await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`);
500 await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" });
501 await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" });
502
503 await writeFile(path.join(tmp.path, "feature.txt"), "push me\n");
504 const core = new GitCoreService();
505 const context = await core.prepareCommitContext(tmp.path);
506 expect(context).not.toBeNull();
507 await core.commit(tmp.path, "Add feature file", "");
508
509 const pushed = await core.pushCurrentBranch(tmp.path, null);
510 expect(pushed.status).toBe("pushed");
511 expect(pushed.setUpstream).toBe(true);
512 expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe(
513 "origin/feature/core-push",
514 );
515
516 const skipped = await core.pushCurrentBranch(tmp.path, null);
517 expect(skipped.status).toBe("skipped_up_to_date");
518 });
519 });
438});520});
apps/server/src/git.ts+430-233
...@@ -1,18 +1,22 @@...@@ -1,18 +1,22 @@
1import { spawn } from "node:child_process";
2import fs from "node:fs";1import fs from "node:fs";
3import os from "node:os";2import os from "node:os";
4import path from "node:path";3import path from "node:path";
54
6import type {5import {
7 GitCheckoutInput,6 gitStatusInputSchema,
8 GitCreateBranchInput,7 gitStatusResultSchema,
9 GitCreateWorktreeInput,8 type GitCheckoutInput,
10 GitCreateWorktreeResult,9 type GitCreateBranchInput,
11 GitInitInput,10 type GitCreateWorktreeInput,
12 GitListBranchesInput,11 type GitCreateWorktreeResult,
13 GitListBranchesResult,12 type GitInitInput,
14 GitRemoveWorktreeInput,13 type GitListBranchesInput,
14 type GitListBranchesResult,
15 type GitRemoveWorktreeInput,
16 type GitStatusInput,
17 type GitStatusResult,
15} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { type ProcessRunResult, runProcess } from "./processRunner";
1620
17export interface TerminalCommandInput {21export interface TerminalCommandInput {
18 command: string;22 command: string;
...@@ -21,95 +25,419 @@ export interface TerminalCommandInput {...@@ -21,95 +25,419 @@ export interface TerminalCommandInput {
21 maxOutputBytes?: number;25 maxOutputBytes?: number;
22}26}
2327
24export interface TerminalCommandResult {28export type TerminalCommandResult = ProcessRunResult;
25 stdout: string;29
26 stderr: string;30export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
27 code: number | null;31 upstreamRef: string | null;
28 signal: NodeJS.Signals | null;32}
29 timedOut: boolean;33
34export interface GitPreparedCommitContext {
35 stagedSummary: string;
36 stagedPatch: string;
37}
38
39export interface GitPushResult {
40 status: "pushed" | "skipped_up_to_date";
41 branch: string;
42 upstreamBranch?: string | undefined;
43 setUpstream?: boolean | undefined;
44}
45
46export interface GitRangeContext {
47 commitSummary: string;
48 diffSummary: string;
49 diffPatch: string;
50}
51
52interface RunGitOptions {
53 timeoutMs?: number | undefined;
54 allowNonZeroExit?: boolean | undefined;
55}
56
57interface ExecuteGitOptions {
58 timeoutMs?: number | undefined;
59 allowNonZeroExit?: boolean | undefined;
60 fallbackErrorMessage?: string | undefined;
30}61}
3162
32const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;63const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
3364
34function appendChunkWithinLimit(65/** Spawn git directly with an argv array — no shell, no quoting needed. */
35 target: string,66function runGit(
36 currentBytes: number,67 args: readonly string[],
37 chunk: Buffer,68 cwd: string,
38 maxBytes: number,69 timeoutMs = 30_000,
39): {70): Promise<ProcessRunResult> {
40 next: string;71 return runProcess("git", args, {
41 nextBytes: number;72 cwd,
42 truncated: boolean;73 timeoutMs,
43} {74 allowNonZeroExit: true,
44 const remaining = maxBytes - currentBytes;75 maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES,
45 if (remaining <= 0) {76 outputMode: "truncate",
46 return { next: target, nextBytes: currentBytes, truncated: true };77 });
78}
79
80function trimStdout(value: string): string {
81 return value.trim();
82}
83
84function parseBranchAb(value: string): { ahead: number; behind: number } {
85 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
86 if (!match) return { ahead: 0, behind: 0 };
87 return {
88 ahead: Number(match[1] ?? "0"),
89 behind: Number(match[2] ?? "0"),
90 };
91}
92
93function commandLabel(args: readonly string[]): string {
94 return `git ${args.join(" ")}`;
95}
96
97function normalizeGitSpawnError(cwd: string, error: unknown): Error {
98 if (!(error instanceof Error)) {
99 return new Error("Failed to run git command.");
100 }
101 if (!fs.existsSync(cwd)) {
102 return new Error(`Working directory does not exist: ${cwd}`);
103 }
104 if (error.message.includes("Command not found: git")) {
105 return new Error("Git is required but not available on PATH.");
106 }
107 return new Error(`Failed to run git command: ${error.message}`);
108}
109
110function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error {
111 const stderr = result.stderr.trim();
112 if (stderr.toLowerCase().includes("not a git repository")) {
113 return new Error("Current folder is not a git repository.");
114 }
115 if (result.timedOut) {
116 return new Error(`${commandLabel(args)} timed out.`);
117 }
118 const detail =
119 stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`;
120 return new Error(`${commandLabel(args)} failed: ${detail}`);
121}
122
123async function runGitOrThrow(
124 cwd: string,
125 args: readonly string[],
126 options: RunGitOptions = {},
127): Promise<TerminalCommandResult> {
128 let result: TerminalCommandResult;
129 try {
130 result = await runGit(args, cwd, options.timeoutMs);
131 } catch (error) {
132 throw normalizeGitSpawnError(cwd, error);
133 }
134
135 if (result.timedOut) {
136 throw normalizeGitExecutionError(args, result);
137 }
138 if (!options.allowNonZeroExit && result.code !== 0) {
139 throw normalizeGitExecutionError(args, result);
140 }
141 return result;
142}
143
144async function executeGit(
145 cwd: string,
146 args: readonly string[],
147 options: ExecuteGitOptions = {},
148): Promise<TerminalCommandResult> {
149 const result = await runGitOrThrow(cwd, args, {
150 timeoutMs: options.timeoutMs,
151 allowNonZeroExit: true,
152 });
153
154 if (options.allowNonZeroExit || result.code === 0) {
155 return result;
156 }
157
158 const stderr = result.stderr.trim();
159 if (stderr.length > 0) {
160 throw new Error(stderr);
47 }161 }
48 if (chunk.length <= remaining) {162 if (options.fallbackErrorMessage) {
163 throw new Error(options.fallbackErrorMessage);
164 }
165 throw normalizeGitExecutionError(args, result);
166}
167
168export class GitCoreService {
169 async status(raw: GitStatusInput): Promise<GitStatusResult> {
170 const input = gitStatusInputSchema.parse(raw);
171 const details = await this.statusDetails(input.cwd);
172 return gitStatusResultSchema.parse({
173 branch: details.branch,
174 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
175 hasUpstream: details.hasUpstream,
176 aheadCount: details.aheadCount,
177 behindCount: details.behindCount,
178 openPr: null,
179 });
180 }
181
182 async statusDetails(cwd: string): Promise<GitStatusDetails> {
183 const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]);
184
185 let branch: string | null = null;
186 let upstreamRef: string | null = null;
187 let aheadCount = 0;
188 let behindCount = 0;
189 let hasWorkingTreeChanges = false;
190
191 for (const line of stdout.split(/\r?\n/g)) {
192 if (line.startsWith("# branch.head ")) {
193 const value = line.slice("# branch.head ".length).trim();
194 branch = value.startsWith("(") ? null : value;
195 continue;
196 }
197 if (line.startsWith("# branch.upstream ")) {
198 const value = line.slice("# branch.upstream ".length).trim();
199 upstreamRef = value.length > 0 ? value : null;
200 continue;
201 }
202 if (line.startsWith("# branch.ab ")) {
203 const value = line.slice("# branch.ab ".length).trim();
204 const parsed = parseBranchAb(value);
205 aheadCount = parsed.ahead;
206 behindCount = parsed.behind;
207 continue;
208 }
209 if (line.trim().length > 0 && !line.startsWith("#")) {
210 hasWorkingTreeChanges = true;
211 }
212 }
213
49 return {214 return {
50 next: `${target}${chunk.toString()}`,215 branch,
51 nextBytes: currentBytes + chunk.length,216 upstreamRef,
52 truncated: false,217 hasWorkingTreeChanges,
218 hasUpstream: upstreamRef !== null,
219 aheadCount,
220 behindCount,
53 };221 };
54 }222 }
55 return {
56 next: `${target}${chunk.subarray(0, remaining).toString()}`,
57 nextBytes: currentBytes + remaining,
58 truncated: true,
59 };
60}
61223
62/** Spawn git directly with an argv array — no shell, no quoting needed. */224 async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> {
63function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {225 await this.git(cwd, ["add", "-A"]);
64 return new Promise((resolve, reject) => {226
65 const child = spawn("git", args, {227 const stagedSummary = await this.gitStdout(cwd, [
66 cwd,228 "diff",
67 env: process.env,229 "--cached",
68 stdio: ["ignore", "pipe", "pipe"],230 "--name-status",
231 ]);
232 if (trimStdout(stagedSummary).length === 0) {
233 return null;
234 }
235
236 const stagedPatch = await this.gitStdout(cwd, [
237 "diff",
238 "--cached",
239 "--patch",
240 "--minimal",
241 ]);
242
243 return {
244 stagedSummary,
245 stagedPatch,
246 };
247 }
248
249 async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> {
250 const args = ["commit", "-m", subject];
251 const trimmedBody = body.trim();
252 if (trimmedBody.length > 0) {
253 args.push("-m", trimmedBody);
254 }
255 await this.git(cwd, args);
256 const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"]));
257 return { commitSha };
258 }
259
260 async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> {
261 const details = await this.statusDetails(cwd);
262 const branch = details.branch ?? fallbackBranch;
263 if (!branch) {
264 throw new Error("Cannot push from detached HEAD.");
265 }
266
267 if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) {
268 return {
269 status: "skipped_up_to_date",
270 branch,
271 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
272 };
273 }
274
275 if (!details.hasUpstream) {
276 await this.git(cwd, ["push", "-u", "origin", branch]);
277 return {
278 status: "pushed",
279 branch,
280 upstreamBranch: `origin/${branch}`,
281 setUpstream: true,
282 };
283 }
284
285 await this.git(cwd, ["push"]);
286 return {
287 status: "pushed",
288 branch,
289 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
290 setUpstream: false,
291 };
292 }
293
294 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {
295 const range = `${baseBranch}..HEAD`;
296 const [commitSummary, diffSummary, diffPatch] = await Promise.all([
297 this.gitStdout(cwd, ["log", "--oneline", range]),
298 this.gitStdout(cwd, ["diff", "--stat", range]),
299 this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]),
300 ]);
301
302 return {
303 commitSummary,
304 diffSummary,
305 diffPatch,
306 };
307 }
308
309 async readConfigValue(cwd: string, key: string): Promise<string | null> {
310 const stdout = await this.gitStdout(cwd, ["config", "--get", key], true);
311 const value = trimStdout(stdout);
312 return value.length > 0 ? value : null;
313 }
314
315 async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
316 const result = await executeGit(input.cwd, ["branch", "--no-color"], {
317 timeoutMs: 10_000,
318 allowNonZeroExit: true,
69 });319 });
70320
71 const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;321 if (result.code !== 0) {
72 let stdout = "";322 const stderr = result.stderr.trim();
73 let stderr = "";323 if (stderr.toLowerCase().includes("not a git repository")) {
74 let stdoutBytes = 0;324 return { branches: [], isRepo: false };
75 let stderrBytes = 0;325 }
76 let timedOut = false;326 throw new Error(stderr || "git branch failed");
77 let outputTruncated = false;327 }
78328
79 const timeout = setTimeout(() => {329 const [defaultRef, worktreeList] = await Promise.all([
80 timedOut = true;330 executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], {
81 child.kill("SIGTERM");331 timeoutMs: 5_000,
82 setTimeout(() => {332 allowNonZeroExit: true,
83 if (!child.killed) child.kill("SIGKILL");333 }),
84 }, 1_000).unref();334 executeGit(input.cwd, ["worktree", "list", "--porcelain"], {
85 }, timeoutMs);335 timeoutMs: 5_000,
86336 allowNonZeroExit: true,
87 child.stdout?.on("data", (chunk: Buffer) => {337 }),
88 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);338 ]);
89 stdout = appended.next;339 const defaultBranch =
90 stdoutBytes = appended.nextBytes;340 defaultRef.code === 0
91 outputTruncated = outputTruncated || appended.truncated;341 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
342 : null;
343
344 const worktreeMap = new Map<string, string>();
345 if (worktreeList.code === 0) {
346 let currentPath: string | null = null;
347 for (const line of worktreeList.stdout.split("\n")) {
348 if (line.startsWith("worktree ")) {
349 const candidatePath = line.slice("worktree ".length);
350 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
351 } else if (line.startsWith("branch refs/heads/") && currentPath) {
352 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
353 } else if (line === "") {
354 currentPath = null;
355 }
356 }
357 }
358
359 const branches = result.stdout
360 .split("\n")
361 .map((line) => line.trim())
362 .filter((line) => line.length > 0)
363 .map((line) => {
364 const name = line.replace(/^[*+]\s+/, "");
365 return {
366 name,
367 current: line.startsWith("* "),
368 isDefault: name === defaultBranch,
369 worktreePath: worktreeMap.get(name) ?? null,
370 };
371 })
372 .toSorted((a, b) => {
373 if (a.current !== b.current) return a.current ? -1 : 1;
374 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
375 return a.name.localeCompare(b.name);
376 });
377
378 return { branches, isRepo: true };
379 }
380
381 async createWorktree(input: GitCreateWorktreeInput): Promise<GitCreateWorktreeResult> {
382 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
383 const repoName = path.basename(input.cwd);
384 const worktreePath =
385 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
386
387 await executeGit(
388 input.cwd,
389 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
390 { fallbackErrorMessage: "git worktree add failed" },
391 );
392
393 return {
394 worktree: {
395 path: worktreePath,
396 branch: input.newBranch,
397 },
398 };
399 }
400
401 async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> {
402 await executeGit(input.cwd, ["worktree", "remove", input.path], {
403 timeoutMs: 15_000,
404 fallbackErrorMessage: "git worktree remove failed",
92 });405 });
93 child.stderr?.on("data", (chunk: Buffer) => {406 }
94 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);407
95 stderr = appended.next;408 async createBranch(input: GitCreateBranchInput): Promise<void> {
96 stderrBytes = appended.nextBytes;409 await executeGit(input.cwd, ["branch", input.branch], {
97 outputTruncated = outputTruncated || appended.truncated;410 timeoutMs: 10_000,
411 fallbackErrorMessage: "git branch create failed",
98 });412 });
99 child.on("error", (error) => {413 }
100 clearTimeout(timeout);414
101 reject(error);415 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
416 await executeGit(input.cwd, ["checkout", input.branch], {
417 timeoutMs: 10_000,
418 fallbackErrorMessage: "git checkout failed",
102 });419 });
103 child.on("close", (code, signal) => {420 }
104 clearTimeout(timeout);421
105 if (outputTruncated) {422 async initRepo(input: GitInitInput): Promise<void> {
106 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;423 await executeGit(input.cwd, ["init"], {
107 }424 timeoutMs: 10_000,
108 resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });425 fallbackErrorMessage: "git init failed",
109 });426 });
110 });427 }
428
429 async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> {
430 await executeGit(cwd, args, { allowNonZeroExit });
431 }
432
433 async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> {
434 const result = await executeGit(cwd, args, { allowNonZeroExit });
435 return result.stdout;
436 }
111}437}
112438
439const defaultGitCoreService = new GitCoreService();
440
113export async function runTerminalCommand(441export async function runTerminalCommand(
114 input: TerminalCommandInput,442 input: TerminalCommandInput,
115): Promise<TerminalCommandResult> {443): Promise<TerminalCommandResult> {
...@@ -122,177 +450,46 @@ export async function runTerminalCommand(...@@ -122,177 +450,46 @@ export async function runTerminalCommand(
122 const args =450 const args =
123 process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];451 process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];
124452
125 return new Promise((resolve, reject) => {453 const result = await runProcess(shellPath, args, {
126 const child = spawn(shellPath, args, {454 cwd: input.cwd,
127 cwd: input.cwd,455 timeoutMs: input.timeoutMs ?? 30_000,
128 env: process.env,456 allowNonZeroExit: true,
129 stdio: ["ignore", "pipe", "pipe"],457 maxBufferBytes: maxOutputBytes,
130 });458 outputMode: "truncate",
131
132 let stdout = "";
133 let stderr = "";
134 let stdoutBytes = 0;
135 let stderrBytes = 0;
136 let timedOut = false;
137 let outputTruncated = false;
138
139 const timeout = setTimeout(() => {
140 timedOut = true;
141 child.kill("SIGTERM");
142 setTimeout(() => {
143 if (!child.killed) {
144 child.kill("SIGKILL");
145 }
146 }, 1_000).unref();
147 }, input.timeoutMs ?? 30_000);
148
149 child.stdout?.on("data", (chunk: Buffer) => {
150 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
151 stdout = appended.next;
152 stdoutBytes = appended.nextBytes;
153 outputTruncated = outputTruncated || appended.truncated;
154 });
155
156 child.stderr?.on("data", (chunk: Buffer) => {
157 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
158 stderr = appended.next;
159 stderrBytes = appended.nextBytes;
160 outputTruncated = outputTruncated || appended.truncated;
161 });
162
163 child.on("error", (error) => {
164 clearTimeout(timeout);
165 reject(error);
166 });
167
168 child.on("close", (code, signal) => {
169 clearTimeout(timeout);
170 if (outputTruncated) {
171 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
172 }
173 resolve({
174 stdout,
175 stderr,
176 code: code ?? null,
177 signal: signal ?? null,
178 timedOut,
179 });
180 });
181 });459 });
182}
183
184export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
185 const result = await runGit(["branch", "--no-color"], input.cwd, 10_000);
186460
187 if (result.code !== 0) {461 if (!result.stdoutTruncated && !result.stderrTruncated) {
188 const stderr = result.stderr.trim();462 return result;
189 if (stderr.includes("not a git repository")) {
190 return { branches: [], isRepo: false };
191 }
192 throw new Error(stderr || "git branch failed");
193 }463 }
194464
195 // Resolve the real default branch from the remote465 return {
196 const [defaultRef, worktreeList] = await Promise.all([466 ...result,
197 runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),467 stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`,
198 runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),468 };
199 ]);469}
200 const defaultBranch =
201 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
202
203 // Build branch-name → worktree-path map from porcelain output.
204 // Only include worktrees whose directories still exist on disk (skip prunable/stale ones).
205 const worktreeMap = new Map<string, string>();
206 if (worktreeList.code === 0) {
207 let currentPath: string | null = null;
208 for (const line of worktreeList.stdout.split("\n")) {
209 if (line.startsWith("worktree ")) {
210 const candidatePath = line.slice("worktree ".length);
211 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
212 } else if (line.startsWith("branch refs/heads/") && currentPath) {
213 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
214 } else if (line === "") {
215 currentPath = null;
216 }
217 }
218 }
219
220 const branches = result.stdout
221 .split("\n")
222 .map((line) => line.trim())
223 .filter((line) => line.length > 0)
224 .map((line) => {
225 const name = line.replace(/^[*+]\s+/, "");
226 return {
227 name,
228 current: line.startsWith("* "),
229 isDefault: name === defaultBranch,
230 worktreePath: worktreeMap.get(name) ?? null,
231 };
232 })
233 .toSorted((a, b) => {
234 if (a.current !== b.current) return a.current ? -1 : 1;
235 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
236 return a.name.localeCompare(b.name);
237 });
238470
239 return { branches, isRepo: true };471export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
472 return defaultGitCoreService.listBranches(input);
240}473}
241474
242export async function createGitWorktree(475export async function createGitWorktree(
243 input: GitCreateWorktreeInput,476 input: GitCreateWorktreeInput,
244): Promise<GitCreateWorktreeResult> {477): Promise<GitCreateWorktreeResult> {
245 const sanitizedBranch = input.newBranch.replace(/\//g, "-");478 return defaultGitCoreService.createWorktree(input);
246 const repoName = path.basename(input.cwd);
247 const worktreePath =
248 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
249
250 // Create a new branch from the base branch in a new worktree
251 const result = await runGit(
252 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
253 input.cwd,
254 );
255
256 if (result.code !== 0) {
257 throw new Error(result.stderr.trim() || "git worktree add failed");
258 }
259
260 return {
261 worktree: {
262 path: worktreePath,
263 branch: input.newBranch,
264 },
265 };
266}479}
267480
268export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {481export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
269 const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000);482 await defaultGitCoreService.removeWorktree(input);
270
271 if (result.code !== 0) {
272 throw new Error(result.stderr.trim() || "git worktree remove failed");
273 }
274}483}
275484
276export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {485export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
277 const result = await runGit(["branch", input.branch], input.cwd, 10_000);486 await defaultGitCoreService.createBranch(input);
278
279 if (result.code !== 0) {
280 throw new Error(result.stderr.trim() || "git branch create failed");
281 }
282}487}
283488
284export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {489export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
285 const result = await runGit(["checkout", input.branch], input.cwd, 10_000);490 await defaultGitCoreService.checkoutBranch(input);
286
287 if (result.code !== 0) {
288 throw new Error(result.stderr.trim() || "git checkout failed");
289 }
290}491}
291492
292export async function initGitRepo(input: GitInitInput): Promise<void> {493export async function initGitRepo(input: GitInitInput): Promise<void> {
293 const result = await runGit(["init"], input.cwd, 10_000);494 await defaultGitCoreService.initRepo(input);
294
295 if (result.code !== 0) {
296 throw new Error(result.stderr.trim() || "git init failed");
297 }
298}495}
apps/server/src/gitManager.test.ts created+490
...@@ -0,0 +1,490 @@
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("status includes open PR metadata when branch already has an open PR", async () => {
172 const repoDir = makeTempDir("t3code-git-manager-");
173 await initRepo(repoDir);
174 await runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]);
175 const remoteDir = await createBareRemote();
176 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
177 await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
178
179 const { runner } = createRunnerWithFakeGh({
180 prListSequence: [
181 JSON.stringify([
182 {
183 number: 13,
184 title: "Existing PR",
185 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
186 baseRefName: "main",
187 headRefName: "feature/status-open-pr",
188 },
189 ]),
190 ],
191 });
192
193 const manager = new GitManager({
194 runProcess: runner,
195 textGenerator: createTextGenerator(),
196 });
197
198 const status = await manager.status({ cwd: repoDir });
199 expect(status.branch).toBe("feature/status-open-pr");
200 expect(status.openPr).toEqual({
201 number: 13,
202 title: "Existing PR",
203 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
204 baseBranch: "main",
205 headBranch: "feature/status-open-pr",
206 });
207 });
208
209 it("status is resilient to gh lookup failures and returns openPr null", async () => {
210 const repoDir = makeTempDir("t3code-git-manager-");
211 await initRepo(repoDir);
212 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
213 const remoteDir = await createBareRemote();
214 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
215 await runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]);
216
217 const { runner } = createRunnerWithFakeGh({
218 failWith: new Error("Command not found: gh"),
219 });
220 const manager = new GitManager({
221 runProcess: runner,
222 textGenerator: createTextGenerator(),
223 });
224
225 const status = await manager.status({ cwd: repoDir });
226 expect(status.branch).toBe("feature/status-no-gh");
227 expect(status.openPr).toBeNull();
228 });
229
230 it("creates a commit when working tree is dirty", async () => {
231 const repoDir = makeTempDir("t3code-git-manager-");
232 await initRepo(repoDir);
233 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n");
234
235 const manager = new GitManager({
236 textGenerator: createTextGenerator(),
237 });
238 const result = await manager.runStackedAction({
239 cwd: repoDir,
240 action: "commit",
241 });
242
243 expect(result.commit.status).toBe("created");
244 expect(result.push.status).toBe("skipped_not_requested");
245 expect(result.pr.status).toBe("skipped_not_requested");
246 expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe(
247 "Implement stacked git actions",
248 );
249 });
250
251 it("uses custom commit message when provided", async () => {
252 const repoDir = makeTempDir("t3code-git-manager-");
253 await initRepo(repoDir);
254 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n");
255 let generatedCount = 0;
256
257 const manager = new GitManager({
258 textGenerator: createTextGenerator({
259 generateCommitMessage: async () => {
260 generatedCount += 1;
261 return {
262 subject: "this should not be used",
263 body: "",
264 };
265 },
266 }),
267 });
268 const result = await manager.runStackedAction({
269 cwd: repoDir,
270 action: "commit",
271 commitMessage: "feat: custom summary line\n\n- details from user",
272 });
273
274 expect(result.commit.status).toBe("created");
275 expect(result.commit.subject).toBe("feat: custom summary line");
276 expect(generatedCount).toBe(0);
277 expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe(
278 "feat: custom summary line",
279 );
280 expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain(
281 "- details from user",
282 );
283 });
284
285 it("skips commit when there are no uncommitted changes", async () => {
286 const repoDir = makeTempDir("t3code-git-manager-");
287 await initRepo(repoDir);
288
289 const manager = new GitManager({
290 textGenerator: createTextGenerator(),
291 });
292 const result = await manager.runStackedAction({
293 cwd: repoDir,
294 action: "commit",
295 });
296
297 expect(result.commit.status).toBe("skipped_no_changes");
298 expect(result.push.status).toBe("skipped_not_requested");
299 expect(result.pr.status).toBe("skipped_not_requested");
300 });
301
302 it("commits and pushes with upstream auto-setup when needed", async () => {
303 const repoDir = makeTempDir("t3code-git-manager-");
304 await initRepo(repoDir);
305 await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]);
306 const remoteDir = await createBareRemote();
307 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
308 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
309
310 const manager = new GitManager({
311 textGenerator: createTextGenerator(),
312 });
313 const result = await manager.runStackedAction({
314 cwd: repoDir,
315 action: "commit_push",
316 });
317
318 expect(result.commit.status).toBe("created");
319 expect(result.push.status).toBe("pushed");
320 expect(result.push.setUpstream).toBe(true);
321 expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
322 "origin/feature/stacked-flow",
323 );
324 });
325
326 it("skips push when branch is already up to date", async () => {
327 const repoDir = makeTempDir("t3code-git-manager-");
328 await initRepo(repoDir);
329 await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]);
330 const remoteDir = await createBareRemote();
331 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
332 await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]);
333
334 const manager = new GitManager({
335 textGenerator: createTextGenerator(),
336 });
337 const result = await manager.runStackedAction({
338 cwd: repoDir,
339 action: "commit_push",
340 });
341
342 expect(result.commit.status).toBe("skipped_no_changes");
343 expect(result.push.status).toBe("skipped_up_to_date");
344 });
345
346 it("opens existing PR for commit/push/pr action", async () => {
347 const repoDir = makeTempDir("t3code-git-manager-");
348 await initRepo(repoDir);
349 await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);
350 const remoteDir = await createBareRemote();
351 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
352 await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]);
353
354 const { runner, ghCalls } = createRunnerWithFakeGh({
355 prListSequence: [
356 JSON.stringify([
357 {
358 number: 42,
359 title: "Existing PR",
360 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
361 baseRefName: "main",
362 headRefName: "feature/existing-pr",
363 },
364 ]),
365 ],
366 });
367
368 const manager = new GitManager({
369 runProcess: runner,
370 textGenerator: createTextGenerator(),
371 });
372 const result = await manager.runStackedAction({
373 cwd: repoDir,
374 action: "commit_push_pr",
375 });
376
377 expect(result.pr.status).toBe("opened_existing");
378 expect(result.pr.number).toBe(42);
379 expect(
380 ghCalls.some((call) => call.startsWith("pr view feature/existing-pr --web")),
381 ).toBe(true);
382 });
383
384 it("creates PR when one does not already exist", async () => {
385 const repoDir = makeTempDir("t3code-git-manager-");
386 await initRepo(repoDir);
387 await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
388 const remoteDir = await createBareRemote();
389 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
390 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
391 await runGit(repoDir, ["add", "changes.txt"]);
392 await runGit(repoDir, ["commit", "-m", "Feature commit"]);
393 await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
394 await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
395
396 const { runner, ghCalls } = createRunnerWithFakeGh({
397 prListSequence: [
398 "[]",
399 JSON.stringify([
400 {
401 number: 88,
402 title: "Add stacked git actions",
403 url: "https://github.com/pingdotgg/codething-mvp/pull/88",
404 baseRefName: "main",
405 headRefName: "feature-create-pr",
406 },
407 ]),
408 ],
409 });
410
411 const manager = new GitManager({
412 runProcess: runner,
413 textGenerator: createTextGenerator(),
414 });
415 const result = await manager.runStackedAction({
416 cwd: repoDir,
417 action: "commit_push_pr",
418 });
419
420 expect(result.pr.status).toBe("created");
421 expect(result.pr.number).toBe(88);
422 expect(
423 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
424 ).toBe(true);
425 });
426
427 it("rejects push/pr actions from detached HEAD", async () => {
428 const repoDir = makeTempDir("t3code-git-manager-");
429 await initRepo(repoDir);
430 await runGit(repoDir, ["checkout", "--detach", "HEAD"]);
431
432 const manager = new GitManager({
433 textGenerator: createTextGenerator(),
434 });
435 await expect(
436 manager.runStackedAction({
437 cwd: repoDir,
438 action: "commit_push",
439 }),
440 ).rejects.toThrow("detached HEAD");
441 });
442
443 it("surfaces missing gh binary errors", async () => {
444 const repoDir = makeTempDir("t3code-git-manager-");
445 await initRepo(repoDir);
446 await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]);
447 const remoteDir = await createBareRemote();
448 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
449 await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]);
450
451 const { runner } = createRunnerWithFakeGh({
452 failWith: new Error("Command not found: gh"),
453 });
454 const manager = new GitManager({
455 runProcess: runner,
456 textGenerator: createTextGenerator(),
457 });
458
459 await expect(
460 manager.runStackedAction({
461 cwd: repoDir,
462 action: "commit_push_pr",
463 }),
464 ).rejects.toThrow("GitHub CLI (`gh`) is required");
465 });
466
467 it("surfaces gh auth errors with guidance", async () => {
468 const repoDir = makeTempDir("t3code-git-manager-");
469 await initRepo(repoDir);
470 await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]);
471 const remoteDir = await createBareRemote();
472 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
473 await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]);
474
475 const { runner } = createRunnerWithFakeGh({
476 failWith: new Error("authentication failed"),
477 });
478 const manager = new GitManager({
479 runProcess: runner,
480 textGenerator: createTextGenerator(),
481 });
482
483 await expect(
484 manager.runStackedAction({
485 cwd: repoDir,
486 action: "commit_push_pr",
487 }),
488 ).rejects.toThrow("gh auth login");
489 });
490});
apps/server/src/gitManager.ts created+506
...@@ -0,0 +1,506 @@
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 type GitRunStackedActionInput,
11 type GitRunStackedActionResult,
12 type GitStatusInput,
13 type GitStatusResult,
14} from "@t3tools/contracts";
15
16import type {
17 CommitMessageGenerationResult,
18 PrContentGenerationResult,
19 TextGenerationService,
20} from "./coreServices";
21import { CodexTextGenerator } from "./codexTextGenerator";
22import { GitCoreService } from "./git";
23import {
24 type ProcessRunOptions,
25 type ProcessRunResult,
26 runProcess,
27} from "./processRunner";
28
29const GH_CLI_TIMEOUT_MS = 30_000;
30
31interface GitManagerDeps {
32 runProcess?: (
33 command: string,
34 args: readonly string[],
35 options?: ProcessRunOptions,
36 ) => Promise<ProcessRunResult>;
37 textGenerator?: TextGenerationService;
38 gitCore?: GitCoreService;
39}
40
41interface OpenPrInfo {
42 number: number;
43 title: string;
44 url: string;
45 baseRefName: string;
46 headRefName: string;
47}
48
49function parseOpenPrList(raw: unknown): OpenPrInfo[] {
50 if (!Array.isArray(raw)) return [];
51
52 const parsed: OpenPrInfo[] = [];
53 for (const entry of raw) {
54 if (!entry || typeof entry !== "object") continue;
55 const record = entry as Record<string, unknown>;
56 const number = record.number;
57 const title = record.title;
58 const url = record.url;
59 const baseRefName = record.baseRefName;
60 const headRefName = record.headRefName;
61 if (
62 typeof number !== "number" ||
63 !Number.isInteger(number) ||
64 number <= 0
65 ) {
66 continue;
67 }
68 if (
69 typeof title !== "string" ||
70 typeof url !== "string" ||
71 typeof baseRefName !== "string" ||
72 typeof headRefName !== "string"
73 ) {
74 continue;
75 }
76 parsed.push({
77 number,
78 title,
79 url,
80 baseRefName,
81 headRefName,
82 });
83 }
84 return parsed;
85}
86
87function trimStdout(value: string): string {
88 return value.trim();
89}
90
91function limitContext(value: string, maxChars: number): string {
92 if (value.length <= maxChars) return value;
93 return `${value.slice(0, maxChars)}\n\n[truncated]`;
94}
95
96function sanitizeCommitMessage(
97 generated: CommitMessageGenerationResult,
98): CommitMessageGenerationResult {
99 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
100 const subject = rawSubject.replace(/[.]+$/g, "").trim();
101 const safeSubject =
102 subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files";
103 return {
104 subject: safeSubject,
105 body: generated.body.trim(),
106 };
107}
108
109function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | null {
110 const normalized = raw.replace(/\r\n/g, "\n").trim();
111 if (normalized.length === 0) {
112 return null;
113 }
114
115 const [firstLine, ...rest] = normalized.split("\n");
116 const subject = firstLine?.trim() ?? "";
117 if (subject.length === 0) {
118 return null;
119 }
120
121 return {
122 subject,
123 body: rest.join("\n").trim(),
124 };
125}
126
127function extractBranchFromRef(ref: string): string {
128 const normalized = ref.trim();
129
130 if (normalized.startsWith("refs/remotes/")) {
131 const withoutPrefix = normalized.slice("refs/remotes/".length);
132 const firstSlash = withoutPrefix.indexOf("/");
133 if (firstSlash === -1) {
134 return withoutPrefix.trim();
135 }
136 return withoutPrefix.slice(firstSlash + 1).trim();
137 }
138
139 const firstSlash = normalized.indexOf("/");
140 if (firstSlash === -1) {
141 return normalized;
142 }
143 return normalized.slice(firstSlash + 1).trim();
144}
145
146function asCommandNotFound(
147 command: string,
148 error: unknown,
149): Error | undefined {
150 if (!(error instanceof Error)) return undefined;
151 if (!error.message.includes(`Command not found: ${command}`)) return undefined;
152 if (command === "gh") {
153 return new Error("GitHub CLI (`gh`) is required but not available on PATH.");
154 }
155 if (command === "codex") {
156 return new Error("Codex CLI (`codex`) is required but not available on PATH.");
157 }
158 return new Error(`${command} is required but not available on PATH.`);
159}
160
161function normalizeGitHubAuthError(error: unknown): Error | undefined {
162 if (!(error instanceof Error)) return undefined;
163 const lower = error.message.toLowerCase();
164 if (
165 lower.includes("authentication failed") ||
166 lower.includes("not logged in") ||
167 lower.includes("gh auth login") ||
168 lower.includes("no oauth token")
169 ) {
170 return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry.");
171 }
172 return undefined;
173}
174
175function toStatusOpenPr(pr: OpenPrInfo): {
176 number: number;
177 title: string;
178 url: string;
179 baseBranch: string;
180 headBranch: string;
181} {
182 return {
183 number: pr.number,
184 title: pr.title,
185 url: pr.url,
186 baseBranch: pr.baseRefName,
187 headBranch: pr.headRefName,
188 };
189}
190
191export class GitManager {
192 private readonly run: NonNullable<GitManagerDeps["runProcess"]>;
193 private readonly textGenerator: TextGenerationService;
194 private readonly gitCore: GitCoreService;
195
196 constructor(deps: GitManagerDeps = {}) {
197 this.run = deps.runProcess ?? runProcess;
198 this.textGenerator = deps.textGenerator ?? new CodexTextGenerator();
199 this.gitCore = deps.gitCore ?? new GitCoreService();
200 }
201
202 async status(raw: GitStatusInput): Promise<GitStatusResult> {
203 const input = gitStatusInputSchema.parse(raw);
204 const details = await this.gitCore.statusDetails(input.cwd);
205
206 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;
207 if (details.branch && details.hasUpstream) {
208 try {
209 const existing = await this.findOpenPr(input.cwd, details.branch);
210 if (existing) {
211 openPr = toStatusOpenPr(existing);
212 }
213 } catch {
214 // PR lookup is best-effort for status rendering.
215 }
216 }
217
218 return {
219 branch: details.branch,
220 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
221 hasUpstream: details.hasUpstream,
222 aheadCount: details.aheadCount,
223 behindCount: details.behindCount,
224 openPr,
225 };
226 }
227
228 async runStackedAction(
229 raw: GitRunStackedActionInput,
230 ): Promise<GitRunStackedActionResult> {
231 const input = gitRunStackedActionInputSchema.parse(raw);
232 const wantsPush = input.action !== "commit";
233 const wantsPr = input.action === "commit_push_pr";
234
235 const initialStatus = await this.gitCore.statusDetails(input.cwd);
236 if (wantsPush && !initialStatus.branch) {
237 throw new Error("Cannot push from detached HEAD.");
238 }
239 if (wantsPr && !initialStatus.branch) {
240 throw new Error("Cannot create a pull request from detached HEAD.");
241 }
242
243 const commit = await this.runCommitStep(
244 input.cwd,
245 initialStatus.branch,
246 input.commitMessage,
247 );
248
249 const push = wantsPush
250 ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)
251 : { status: "skipped_not_requested" as const };
252
253 const pr = wantsPr
254 ? await this.runPrStep(input.cwd, initialStatus.branch)
255 : { status: "skipped_not_requested" as const };
256
257 return gitRunStackedActionResultSchema.parse({
258 action: input.action,
259 commit,
260 push,
261 pr,
262 });
263 }
264
265 private async runCommitStep(
266 cwd: string,
267 branch: string | null,
268 commitMessage?: string,
269 ): Promise<{
270 status: "created" | "skipped_no_changes";
271 commitSha?: string | undefined;
272 subject?: string | undefined;
273 }> {
274 const context = await this.gitCore.prepareCommitContext(cwd);
275 if (!context) {
276 return { status: "skipped_no_changes" };
277 }
278
279 let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage(
280 commitMessage ?? "",
281 );
282
283 if (!generated) {
284 try {
285 generated = sanitizeCommitMessage(
286 await this.textGenerator.generateCommitMessage({
287 cwd,
288 branch,
289 stagedSummary: limitContext(context.stagedSummary, 8_000),
290 stagedPatch: limitContext(context.stagedPatch, 50_000),
291 }),
292 );
293 } catch (error) {
294 throw asCommandNotFound("codex", error) ?? error;
295 }
296 }
297
298 const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);
299 return {
300 status: "created",
301 commitSha,
302 subject: generated.subject,
303 };
304 }
305
306 private async runPrStep(
307 cwd: string,
308 fallbackBranch: string | null,
309 ): Promise<{
310 status: "created" | "opened_existing" | "skipped_not_requested";
311 url?: string | undefined;
312 number?: number | undefined;
313 baseBranch?: string | undefined;
314 headBranch?: string | undefined;
315 title?: string | undefined;
316 }> {
317 const details = await this.gitCore.statusDetails(cwd);
318 const branch = details.branch ?? fallbackBranch;
319 if (!branch) {
320 throw new Error("Cannot create a pull request from detached HEAD.");
321 }
322 if (!details.hasUpstream) {
323 throw new Error("Current branch has not been pushed. Push before creating a PR.");
324 }
325
326 const existing = await this.findOpenPr(cwd, branch);
327 if (existing) {
328 await this.openPrInBrowser(cwd, branch);
329 return {
330 status: "opened_existing",
331 url: existing.url,
332 number: existing.number,
333 baseBranch: existing.baseRefName,
334 headBranch: existing.headRefName,
335 title: existing.title,
336 };
337 }
338
339 const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef);
340 const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch);
341
342 let generated: PrContentGenerationResult;
343 try {
344 generated = await this.textGenerator.generatePrContent({
345 cwd,
346 baseBranch,
347 headBranch: branch,
348 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
349 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
350 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
351 });
352 } catch (error) {
353 throw asCommandNotFound("codex", error) ?? error;
354 }
355
356 const bodyFile = path.join(
357 os.tmpdir(),
358 `t3code-pr-body-${process.pid}-${randomUUID()}.md`,
359 );
360 await fs.writeFile(bodyFile, generated.body, "utf8");
361
362 try {
363 await this.runGh(cwd, [
364 "pr",
365 "create",
366 "--base",
367 baseBranch,
368 "--head",
369 branch,
370 "--title",
371 generated.title,
372 "--body-file",
373 bodyFile,
374 ]);
375 } finally {
376 try {
377 await fs.unlink(bodyFile);
378 } catch {
379 // Best-effort cleanup.
380 }
381 }
382
383 const created = await this.findOpenPr(cwd, branch);
384 await this.openPrInBrowser(cwd, branch);
385
386 if (!created) {
387 return {
388 status: "created",
389 baseBranch,
390 headBranch: branch,
391 title: generated.title,
392 };
393 }
394
395 return {
396 status: "created",
397 url: created.url,
398 number: created.number,
399 baseBranch: created.baseRefName,
400 headBranch: created.headRefName,
401 title: created.title,
402 };
403 }
404
405 private async openPrInBrowser(cwd: string, branch: string): Promise<void> {
406 try {
407 await this.runGh(cwd, ["pr", "view", branch, "--web"]);
408 } catch {
409 // Opening the browser is best-effort.
410 }
411 }
412
413 private async findOpenPr(
414 cwd: string,
415 branch: string,
416 ): Promise<OpenPrInfo | null> {
417 const stdout = await this.runGhStdout(cwd, [
418 "pr",
419 "list",
420 "--head",
421 branch,
422 "--state",
423 "open",
424 "--limit",
425 "1",
426 "--json",
427 "number,title,url,baseRefName,headRefName",
428 ]);
429
430 const raw = trimStdout(stdout);
431 if (raw.length === 0) return null;
432
433 let parsedJson: unknown;
434 try {
435 parsedJson = JSON.parse(raw);
436 } catch {
437 throw new Error("GitHub CLI returned invalid PR list JSON.");
438 }
439
440 const parsed = parseOpenPrList(parsedJson);
441 return parsed[0] ?? null;
442 }
443
444 private async resolveBaseBranch(
445 cwd: string,
446 branch: string,
447 upstreamRef: string | null,
448 ): Promise<string> {
449 const configured = await this.gitCore.readConfigValue(
450 cwd,
451 `branch.${branch}.gh-merge-base`,
452 );
453 if (configured) return configured;
454
455 if (upstreamRef) {
456 const upstreamBranch = extractBranchFromRef(upstreamRef);
457 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
458 return upstreamBranch;
459 }
460 }
461
462 try {
463 const ghDefault = await this.runGhStdout(cwd, [
464 "repo",
465 "view",
466 "--json",
467 "defaultBranchRef",
468 "--jq",
469 ".defaultBranchRef.name",
470 ]);
471 const defaultBranch = trimStdout(ghDefault);
472 if (defaultBranch.length > 0) return defaultBranch;
473 } catch {
474 // Fall through to deterministic fallback.
475 }
476
477 return "main";
478 }
479
480 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
481 await this.runGhCommand(cwd, args);
482 }
483
484 private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {
485 const result = await this.runGhCommand(cwd, args);
486 return result.stdout;
487 }
488
489 private async runGhCommand(
490 cwd: string,
491 args: readonly string[],
492 ): Promise<ProcessRunResult> {
493 try {
494 return await this.run("gh", args, {
495 cwd,
496 timeoutMs: GH_CLI_TIMEOUT_MS,
497 });
498 } catch (error) {
499 throw (
500 asCommandNotFound("gh", error) ??
501 normalizeGitHubAuthError(error) ??
502 (error instanceof Error ? error : new Error("GitHub CLI command failed."))
503 );
504 }
505 }
506}
apps/server/src/processRunner.test.ts created+31
...@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vitest";
2
3import { runProcess } from "./processRunner";
4
5describe("runProcess", () => {
6 it("fails when output exceeds max buffer in default mode", async () => {
7 await expect(
8 runProcess(
9 "node",
10 ["-e", "process.stdout.write('x'.repeat(2048))"],
11 { maxBufferBytes: 128 },
12 ),
13 ).rejects.toThrow("exceeded stdout buffer limit");
14 });
15
16 it("truncates output when outputMode is truncate", async () => {
17 const result = await runProcess(
18 "node",
19 ["-e", "process.stdout.write('x'.repeat(2048))"],
20 {
21 maxBufferBytes: 128,
22 outputMode: "truncate",
23 },
24 );
25
26 expect(result.code).toBe(0);
27 expect(result.stdout.length).toBeLessThanOrEqual(128);
28 expect(result.stdoutTruncated).toBe(true);
29 expect(result.stderrTruncated).toBe(false);
30 });
31});
apps/server/src/processRunner.ts created+271
...@@ -0,0 +1,271 @@
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 maxBufferBytes?: number | undefined;
10 outputMode?: "error" | "truncate" | undefined;
11}
12
13export interface ProcessRunResult {
14 stdout: string;
15 stderr: string;
16 code: number | null;
17 signal: NodeJS.Signals | null;
18 timedOut: boolean;
19 stdoutTruncated?: boolean | undefined;
20 stderrTruncated?: boolean | undefined;
21}
22
23function commandLabel(command: string, args: readonly string[]): string {
24 return [command, ...args].join(" ");
25}
26
27function normalizeSpawnError(
28 command: string,
29 args: readonly string[],
30 error: unknown,
31): Error {
32 if (!(error instanceof Error)) {
33 return new Error(`Failed to run ${commandLabel(command, args)}.`);
34 }
35
36 const maybeCode = (error as NodeJS.ErrnoException).code;
37 if (maybeCode === "ENOENT") {
38 return new Error(`Command not found: ${command}`);
39 }
40
41 return new Error(
42 `Failed to run ${commandLabel(command, args)}: ${error.message}`,
43 );
44}
45
46function normalizeExitError(
47 command: string,
48 args: readonly string[],
49 result: ProcessRunResult,
50): Error {
51 const reason = result.timedOut
52 ? "timed out"
53 : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`;
54 const stderr = result.stderr.trim();
55 const detail = stderr.length > 0 ? ` ${stderr}` : "";
56 return new Error(`${commandLabel(command, args)} ${reason}.${detail}`);
57}
58
59function normalizeStdinError(
60 command: string,
61 args: readonly string[],
62 error: unknown,
63): Error {
64 if (!(error instanceof Error)) {
65 return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`);
66 }
67 return new Error(
68 `Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`,
69 );
70}
71
72function normalizeBufferError(
73 command: string,
74 args: readonly string[],
75 stream: "stdout" | "stderr",
76 maxBufferBytes: number,
77): Error {
78 return new Error(
79 `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`,
80 );
81}
82
83const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
84
85function appendChunkWithinLimit(
86 target: string,
87 currentBytes: number,
88 chunk: Buffer,
89 maxBytes: number,
90): {
91 next: string;
92 nextBytes: number;
93 truncated: boolean;
94} {
95 const remaining = maxBytes - currentBytes;
96 if (remaining <= 0) {
97 return { next: target, nextBytes: currentBytes, truncated: true };
98 }
99 if (chunk.length <= remaining) {
100 return {
101 next: `${target}${chunk.toString()}`,
102 nextBytes: currentBytes + chunk.length,
103 truncated: false,
104 };
105 }
106 return {
107 next: `${target}${chunk.subarray(0, remaining).toString()}`,
108 nextBytes: currentBytes + remaining,
109 truncated: true,
110 };
111}
112
113export async function runProcess(
114 command: string,
115 args: readonly string[],
116 options: ProcessRunOptions = {},
117): Promise<ProcessRunResult> {
118 const timeoutMs = options.timeoutMs ?? 60_000;
119 const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
120 const outputMode = options.outputMode ?? "error";
121
122 return new Promise<ProcessRunResult>((resolve, reject) => {
123 const child = spawn(command, args, {
124 cwd: options.cwd,
125 env: options.env,
126 stdio: "pipe",
127 });
128
129 let stdout = "";
130 let stderr = "";
131 let stdoutBytes = 0;
132 let stderrBytes = 0;
133 let stdoutTruncated = false;
134 let stderrTruncated = false;
135 let timedOut = false;
136 let settled = false;
137 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
138
139 const timeoutTimer = setTimeout(() => {
140 timedOut = true;
141 child.kill("SIGTERM");
142 forceKillTimer = setTimeout(() => {
143 child.kill("SIGKILL");
144 }, 1_000);
145 }, timeoutMs);
146
147 const finalize = (callback: () => void): void => {
148 if (settled) return;
149 settled = true;
150 clearTimeout(timeoutTimer);
151 if (forceKillTimer) {
152 clearTimeout(forceKillTimer);
153 }
154 callback();
155 };
156
157 const fail = (error: Error): void => {
158 child.kill("SIGTERM");
159 finalize(() => {
160 reject(error);
161 });
162 };
163
164 const appendOutput = (
165 stream: "stdout" | "stderr",
166 chunk: Buffer | string,
167 ): Error | null => {
168 const chunkBuffer =
169 typeof chunk === "string" ? Buffer.from(chunk) : chunk;
170 const text = chunkBuffer.toString();
171 const byteLength = chunkBuffer.length;
172 if (stream === "stdout") {
173 if (outputMode === "truncate") {
174 const appended = appendChunkWithinLimit(
175 stdout,
176 stdoutBytes,
177 chunkBuffer,
178 maxBufferBytes,
179 );
180 stdout = appended.next;
181 stdoutBytes = appended.nextBytes;
182 stdoutTruncated = stdoutTruncated || appended.truncated;
183 return null;
184 }
185 stdout += text;
186 stdoutBytes += byteLength;
187 if (stdoutBytes > maxBufferBytes) {
188 return normalizeBufferError(command, args, "stdout", maxBufferBytes);
189 }
190 } else {
191 if (outputMode === "truncate") {
192 const appended = appendChunkWithinLimit(
193 stderr,
194 stderrBytes,
195 chunkBuffer,
196 maxBufferBytes,
197 );
198 stderr = appended.next;
199 stderrBytes = appended.nextBytes;
200 stderrTruncated = stderrTruncated || appended.truncated;
201 return null;
202 }
203 stderr += text;
204 stderrBytes += byteLength;
205 if (stderrBytes > maxBufferBytes) {
206 return normalizeBufferError(command, args, "stderr", maxBufferBytes);
207 }
208 }
209 return null;
210 };
211
212 child.stdout.on("data", (chunk: Buffer | string) => {
213 const error = appendOutput("stdout", chunk);
214 if (error) {
215 fail(error);
216 }
217 });
218
219 child.stderr.on("data", (chunk: Buffer | string) => {
220 const error = appendOutput("stderr", chunk);
221 if (error) {
222 fail(error);
223 }
224 });
225
226 child.once("error", (error) => {
227 finalize(() => {
228 reject(normalizeSpawnError(command, args, error));
229 });
230 });
231
232 child.once("close", (code, signal) => {
233 const result: ProcessRunResult = {
234 stdout,
235 stderr,
236 code,
237 signal,
238 timedOut,
239 stdoutTruncated,
240 stderrTruncated,
241 };
242
243 finalize(() => {
244 if (
245 !options.allowNonZeroExit &&
246 (timedOut || (code !== null && code !== 0))
247 ) {
248 reject(normalizeExitError(command, args, result));
249 return;
250 }
251 resolve(result);
252 });
253 });
254
255 child.stdin.once("error", (error) => {
256 fail(normalizeStdinError(command, args, error));
257 });
258
259 if (options.stdin !== undefined) {
260 child.stdin.write(options.stdin, (error) => {
261 if (error) {
262 fail(normalizeStdinError(command, args, error));
263 return;
264 }
265 child.stdin.end();
266 });
267 return;
268 }
269 child.stdin.end();
270 });
271}
apps/server/src/ptyAdapter.test.ts created+54
...@@ -0,0 +1,54 @@
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 { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";
8
9function fileMode(filePath: string): number {
10 return fs.statSync(filePath).mode & 0o777;
11}
12
13describe("ensureNodePtySpawnHelperExecutable", () => {
14 const tempDirs: string[] = [];
15
16 afterEach(() => {
17 for (const dir of tempDirs.splice(0, tempDirs.length)) {
18 fs.rmSync(dir, { recursive: true, force: true });
19 }
20 });
21
22 it("adds executable bits when helper exists but is not executable", () => {
23 if (process.platform === "win32") return;
24
25 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
26 tempDirs.push(dir);
27 const helperPath = path.join(dir, "spawn-helper");
28 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
29 fs.chmodSync(helperPath, 0o644);
30
31 ensureNodePtySpawnHelperExecutable(helperPath);
32
33 expect(fileMode(helperPath) & 0o111).toBe(0o111);
34 });
35
36 it("keeps executable helper as executable", () => {
37 if (process.platform === "win32") return;
38
39 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
40 tempDirs.push(dir);
41 const helperPath = path.join(dir, "spawn-helper");
42 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
43 fs.chmodSync(helperPath, 0o755);
44
45 ensureNodePtySpawnHelperExecutable(helperPath);
46
47 expect(fileMode(helperPath) & 0o111).toBe(0o111);
48 });
49
50 it("does not throw when helper path is missing", () => {
51 const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`);
52 expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow();
53 });
54});
apps/server/src/ptyAdapter.ts+60-1
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import { createRequire } from "node:module";
3import path from "node:path";
4
1import * as nodePty from "node-pty";5import * as nodePty from "node-pty";
26
3export interface PtyExitEvent {7export interface PtyExitEvent {
...@@ -26,6 +30,57 @@ export interface PtyAdapter {...@@ -26,6 +30,57 @@ export interface PtyAdapter {
26 spawn(input: PtySpawnInput): PtyProcess;30 spawn(input: PtySpawnInput): PtyProcess;
27}31}
2832
33const requireForNodePty = createRequire(import.meta.url);
34let didEnsureSpawnHelperExecutable = false;
35
36function resolveNodePtySpawnHelperPath(): string | null {
37 try {
38 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
39 const packageDir = path.dirname(packageJsonPath);
40 const candidates = [
41 path.join(packageDir, "build", "Release", "spawn-helper"),
42 path.join(packageDir, "build", "Debug", "spawn-helper"),
43 path.join(
44 packageDir,
45 "prebuilds",
46 `${process.platform}-${process.arch}`,
47 "spawn-helper",
48 ),
49 ];
50
51 for (const candidate of candidates) {
52 if (fs.existsSync(candidate)) {
53 return candidate;
54 }
55 }
56
57 return null;
58 } catch {
59 return null;
60 }
61}
62
63export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void {
64 if (process.platform === "win32") return;
65 if (!explicitPath && didEnsureSpawnHelperExecutable) return;
66
67 const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath();
68 if (!helperPath) return;
69 if (!explicitPath) {
70 didEnsureSpawnHelperExecutable = true;
71 }
72
73 try {
74 const stat = fs.statSync(helperPath);
75 const mode = stat.mode & 0o777;
76 if ((mode & 0o111) === 0) {
77 fs.chmodSync(helperPath, mode | 0o111);
78 }
79 } catch {
80 // Best effort only. If chmod fails, node-pty spawn will surface the real error.
81 }
82}
83
29class NodePtyProcess implements PtyProcess {84class NodePtyProcess implements PtyProcess {
30 constructor(private readonly process: nodePty.IPty) {}85 constructor(private readonly process: nodePty.IPty) {}
3186
...@@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess {...@@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess {
67122
68export class NodePtyAdapter implements PtyAdapter {123export class NodePtyAdapter implements PtyAdapter {
69 spawn(input: PtySpawnInput): PtyProcess {124 spawn(input: PtySpawnInput): PtyProcess {
125 ensureNodePtySpawnHelperExecutable();
70 const ptyProcess = nodePty.spawn(input.shell, [], {126 const ptyProcess = nodePty.spawn(input.shell, [], {
71 cwd: input.cwd,127 cwd: input.cwd,
72 cols: input.cols,128 cols: input.cols,
73 rows: input.rows,129 rows: input.rows,
74 env: input.env,130 env: input.env,
75 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",131 name:
132 globalThis.process.platform === "win32"
133 ? "xterm-color"
134 : "xterm-256color",
76 });135 });
77 return new NodePtyProcess(ptyProcess);136 return new NodePtyProcess(ptyProcess);
78 }137 }
apps/server/src/terminalManager.test.ts+41-2
...@@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess {...@@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess {
62class FakePtyAdapter implements PtyAdapter {62class FakePtyAdapter implements PtyAdapter {
63 readonly spawnInputs: PtySpawnInput[] = [];63 readonly spawnInputs: PtySpawnInput[] = [];
64 readonly processes: FakePtyProcess[] = [];64 readonly processes: FakePtyProcess[] = [];
65 readonly spawnFailures: Error[] = [];
65 private nextPid = 9000;66 private nextPid = 9000;
6667
67 spawn(input: PtySpawnInput): PtyProcess {68 spawn(input: PtySpawnInput): PtyProcess {
68 this.spawnInputs.push(input);69 this.spawnInputs.push(input);
70 const failure = this.spawnFailures.shift();
71 if (failure) {
72 throw failure;
73 }
69 const process = new FakePtyProcess(this.nextPid++);74 const process = new FakePtyProcess(this.nextPid++);
70 this.processes.push(process);75 this.processes.push(process);
71 return process;76 return process;
...@@ -117,7 +122,10 @@ describe("TerminalManager", () => {...@@ -117,7 +122,10 @@ describe("TerminalManager", () => {
117 }122 }
118 });123 });
119124
120 function makeManager(historyLineLimit = 5) {125 function makeManager(
126 historyLineLimit = 5,
127 options: { shellResolver?: () => string } = {},
128 ) {
121 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));129 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
122 tempDirs.push(logsDir);130 tempDirs.push(logsDir);
123 const ptyAdapter = new FakePtyAdapter();131 const ptyAdapter = new FakePtyAdapter();
...@@ -125,7 +133,7 @@ describe("TerminalManager", () => {...@@ -125,7 +133,7 @@ describe("TerminalManager", () => {
125 logsDir,133 logsDir,
126 ptyAdapter,134 ptyAdapter,
127 historyLineLimit,135 historyLineLimit,
128 shellResolver: () => "/bin/bash",136 shellResolver: options.shellResolver ?? (() => "/bin/bash"),
129 });137 });
130 return { logsDir, ptyAdapter, manager };138 return { logsDir, ptyAdapter, manager };
131 }139 }
...@@ -266,4 +274,35 @@ describe("TerminalManager", () => {...@@ -266,4 +274,35 @@ describe("TerminalManager", () => {
266274
267 manager.dispose();275 manager.dispose();
268 });276 });
277
278 it("retries with fallback shells when preferred shell spawn fails", async () => {
279 const { manager, ptyAdapter } = makeManager(5, {
280 shellResolver: () => "/definitely/missing-shell -l",
281 });
282 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
283
284 const snapshot = await manager.open(openInput());
285
286 expect(snapshot.status).toBe("running");
287 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
288 expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
289
290 if (process.platform === "win32") {
291 expect(
292 ptyAdapter.spawnInputs.some(
293 (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",
294 ),
295 ).toBe(true);
296 } else {
297 expect(
298 ptyAdapter.spawnInputs.some((input) =>
299 ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(
300 input.shell,
301 ),
302 ),
303 ).toBe(true);
304 }
305
306 manager.dispose();
307 });
269});308});
apps/server/src/terminalManager.ts+98-8
...@@ -57,6 +57,68 @@ function defaultShellResolver(): string {...@@ -57,6 +57,68 @@ function defaultShellResolver(): string {
57 return process.env.SHELL ?? "bash";57 return process.env.SHELL ?? "bash";
58}58}
5959
60function normalizeShellCommand(value: string | undefined): string | null {
61 if (!value) return null;
62 const trimmed = value.trim();
63 if (trimmed.length === 0) return null;
64
65 if (process.platform === "win32") {
66 return trimmed;
67 }
68
69 const firstToken = trimmed.split(/\s+/g)[0]?.trim();
70 if (!firstToken) return null;
71 return firstToken.replace(/^['"]|['"]$/g, "");
72}
73
74function uniqueShells(shells: Array<string | null>): string[] {
75 const seen = new Set<string>();
76 const ordered: string[] = [];
77 for (const shell of shells) {
78 if (!shell || shell.length === 0) continue;
79 if (seen.has(shell)) continue;
80 seen.add(shell);
81 ordered.push(shell);
82 }
83 return ordered;
84}
85
86function resolveShellCandidates(shellResolver: () => string): string[] {
87 const requested = normalizeShellCommand(shellResolver());
88
89 if (process.platform === "win32") {
90 return uniqueShells([
91 requested,
92 process.env.ComSpec ?? null,
93 "powershell.exe",
94 "cmd.exe",
95 ]);
96 }
97
98 return uniqueShells([
99 requested,
100 normalizeShellCommand(process.env.SHELL),
101 "/bin/zsh",
102 "/bin/bash",
103 "/bin/sh",
104 "zsh",
105 "bash",
106 "sh",
107 ]);
108}
109
110function isRetryableShellSpawnError(error: unknown): boolean {
111 if (!(error instanceof Error)) return false;
112 const message = error.message.toLowerCase();
113 return (
114 message.includes("posix_spawnp failed") ||
115 message.includes("enoent") ||
116 message.includes("not found") ||
117 message.includes("file not found") ||
118 message.includes("no such file")
119 );
120}
121
60function capHistory(history: string, maxLines: number): string {122function capHistory(history: string, maxLines: number): string {
61 if (history.length === 0) return history;123 if (history.length === 0) return history;
62 const hasTrailingNewline = history.endsWith("\n");124 const hasTrailingNewline = history.endsWith("\n");
...@@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
270 session.exitSignal = null;332 session.exitSignal = null;
271 session.updatedAt = new Date().toISOString();333 session.updatedAt = new Date().toISOString();
272334
273 const shell = this.shellResolver();
274 let ptyProcess: PtyProcess | null = null;335 let ptyProcess: PtyProcess | null = null;
336 let startedShell: string | null = null;
275 try {337 try {
276 ptyProcess = this.ptyAdapter.spawn({338 const shellCandidates = resolveShellCandidates(this.shellResolver);
277 shell,339 let lastSpawnError: unknown = null;
278 cwd: session.cwd,340
279 cols: session.cols,341 for (const shell of shellCandidates) {
280 rows: session.rows,342 try {
281 env: process.env,343 ptyProcess = this.ptyAdapter.spawn({
282 });344 shell,
345 cwd: session.cwd,
346 cols: session.cols,
347 rows: session.rows,
348 env: process.env,
349 });
350 startedShell = shell;
351 break;
352 } catch (error) {
353 lastSpawnError = error;
354 if (!isRetryableShellSpawnError(error)) {
355 throw error;
356 }
357 }
358 }
359
360 if (!ptyProcess) {
361 const detail =
362 lastSpawnError instanceof Error
363 ? lastSpawnError.message
364 : "Terminal start failed";
365 const tried =
366 shellCandidates.length > 0
367 ? ` Tried shells: ${shellCandidates.join(", ")}.`
368 : "";
369 throw new Error(`${detail}.${tried}`.trim());
370 }
371
283 session.process = ptyProcess;372 session.process = ptyProcess;
284 session.pid = ptyProcess.pid;373 session.pid = ptyProcess.pid;
285 session.status = "running";374 session.status = "running";
...@@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
318 this.logger.error("failed to start terminal", {407 this.logger.error("failed to start terminal", {
319 threadId: session.threadId,408 threadId: session.threadId,
320 error: message,409 error: message,
410 ...(startedShell ? { shell: startedShell } : {}),
321 });411 });
322 }412 }
323 }413 }
apps/server/src/wsServer.test.ts+71
...@@ -180,6 +180,10 @@ describe("WebSocket Server", () => {...@@ -180,6 +180,10 @@ describe("WebSocket Server", () => {
180 devUrl?: string;180 devUrl?: string;
181 authToken?: string;181 authToken?: string;
182 stateDir?: string;182 stateDir?: string;
183 gitManager?: {
184 status: (input: { cwd: string }) => Promise<unknown>;
185 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
186 };
183 terminalManager?: TerminalManager;187 terminalManager?: TerminalManager;
184 } = {},188 } = {},
185 ): ReturnType<typeof createServer> {189 ): ReturnType<typeof createServer> {
...@@ -190,6 +194,7 @@ describe("WebSocket Server", () => {...@@ -190,6 +194,7 @@ describe("WebSocket Server", () => {
190 ...(options.devUrl ? { devUrl: options.devUrl } : {}),194 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
191 ...(options.authToken ? { authToken: options.authToken } : {}),195 ...(options.authToken ? { authToken: options.authToken } : {}),
192 projectRegistry: new ProjectRegistry(stateDir),196 projectRegistry: new ProjectRegistry(stateDir),
197 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
193 ...(options.terminalManager198 ...(options.terminalManager
194 ? { terminalManager: options.terminalManager }199 ? { terminalManager: options.terminalManager }
195 : {}),200 : {}),
...@@ -511,6 +516,72 @@ describe("WebSocket Server", () => {...@@ -511,6 +516,72 @@ describe("WebSocket Server", () => {
511 expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true);516 expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true);
512 });517 });
513518
519 it("responds to git.status via the git manager", async () => {
520 const gitManager = {
521 status: vi.fn().mockResolvedValue({
522 branch: "feature/test",
523 hasWorkingTreeChanges: true,
524 hasUpstream: false,
525 aheadCount: 0,
526 behindCount: 0,
527 openPr: null,
528 }),
529 runStackedAction: vi.fn(),
530 };
531
532 server = createTestServer({ cwd: "/test", gitManager });
533 await server.start();
534 const addr = server.httpServer.address();
535 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
536
537 const ws = await connectWs(port);
538 connections.push(ws);
539 await waitForMessage(ws);
540
541 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
542 cwd: "/test",
543 });
544 expect(response.error).toBeUndefined();
545 expect(response.result).toEqual({
546 branch: "feature/test",
547 hasWorkingTreeChanges: true,
548 hasUpstream: false,
549 aheadCount: 0,
550 behindCount: 0,
551 openPr: null,
552 });
553 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
554 });
555
556 it("returns errors from git.runStackedAction", async () => {
557 const gitManager = {
558 status: vi.fn(),
559 runStackedAction: vi
560 .fn()
561 .mockRejectedValue(new Error("Cannot push from detached HEAD.")),
562 };
563
564 server = createTestServer({ cwd: "/test", gitManager });
565 await server.start();
566 const addr = server.httpServer.address();
567 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
568
569 const ws = await connectWs(port);
570 connections.push(ws);
571 await waitForMessage(ws);
572
573 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
574 cwd: "/test",
575 action: "commit_push",
576 });
577 expect(response.result).toBeUndefined();
578 expect(response.error?.message).toContain("detached HEAD");
579 expect(gitManager.runStackedAction).toHaveBeenCalledWith({
580 cwd: "/test",
581 action: "commit_push",
582 });
583 });
584
514 it("prunes missing projects on startup", async () => {585 it("prunes missing projects on startup", async () => {
515 const stateDir = makeTempDir("t3code-ws-prune-state-");586 const stateDir = makeTempDir("t3code-ws-prune-state-");
516 const existing = makeTempDir("t3code-ws-existing-project-");587 const existing = makeTempDir("t3code-ws-existing-project-");
apps/server/src/wsServer.ts+9
...@@ -20,6 +20,7 @@ import { WebSocketServer, type WebSocket } from "ws";...@@ -20,6 +20,7 @@ import { WebSocketServer, type WebSocket } from "ws";
20import { createLogger } from "./logger";20import { createLogger } from "./logger";
21import { ProjectRegistry } from "./projectRegistry";21import { ProjectRegistry } from "./projectRegistry";
22import { ProviderManager } from "./providerManager";22import { ProviderManager } from "./providerManager";
23import { GitManager } from "./gitManager";
23import {24import {
24 checkoutGitBranch,25 checkoutGitBranch,
25 createGitBranch,26 createGitBranch,
...@@ -53,6 +54,7 @@ export interface ServerOptions {...@@ -53,6 +54,7 @@ export interface ServerOptions {
53 devUrl?: string | undefined;54 devUrl?: string | undefined;
54 logWebSocketEvents?: boolean | undefined;55 logWebSocketEvents?: boolean | undefined;
55 projectRegistry?: ProjectRegistry | undefined;56 projectRegistry?: ProjectRegistry | undefined;
57 gitManager?: GitManager | undefined;
56 terminalManager?: TerminalManager | undefined;58 terminalManager?: TerminalManager | undefined;
57 authToken?: string | undefined;59 authToken?: string | undefined;
58}60}
...@@ -74,6 +76,7 @@ export function createServer(options: ServerOptions) {...@@ -74,6 +76,7 @@ export function createServer(options: ServerOptions) {
74 devUrl,76 devUrl,
75 logWebSocketEvents: explicitLogWsEvents,77 logWebSocketEvents: explicitLogWsEvents,
76 projectRegistry: providedRegistry,78 projectRegistry: providedRegistry,
79 gitManager: providedGitManager,
77 terminalManager: providedTerminalManager,80 terminalManager: providedTerminalManager,
78 authToken,81 authToken,
79 } = options;82 } = options;
...@@ -81,6 +84,7 @@ export function createServer(options: ServerOptions) {...@@ -81,6 +84,7 @@ export function createServer(options: ServerOptions) {
81 const terminalManager = providedTerminalManager ?? new TerminalManager();84 const terminalManager = providedTerminalManager ?? new TerminalManager();
82 const projectRegistry =85 const projectRegistry =
83 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));86 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
87 const gitManager = providedGitManager ?? new GitManager();
84 const clients = new Set<WebSocket>();88 const clients = new Set<WebSocket>();
85 const logger = createLogger("ws");89 const logger = createLogger("ws");
86 const logWebSocketEvents =90 const logWebSocketEvents =
...@@ -335,6 +339,11 @@ export function createServer(options: ServerOptions) {...@@ -335,6 +339,11 @@ export function createServer(options: ServerOptions) {
335 return undefined;339 return undefined;
336 }340 }
337341
342 case WS_METHODS.gitStatus:
343 return gitManager.status(request.params as never);
344
345 case WS_METHODS.gitRunStackedAction:
346 return gitManager.runStackedAction(request.params as never);
338 case WS_METHODS.gitListBranches:347 case WS_METHODS.gitListBranches:
339 return listGitBranches(request.params as never);348 return listGitBranches(request.params as never);
340349
apps/web/src/App.tsx+5-5
...@@ -1,18 +1,18 @@...@@ -1,18 +1,18 @@
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { useEffect, useMemo, useRef } from "react";2import { useEffect, useRef } from "react";
33
4import ChatView from "./components/ChatView";4import ChatView from "./components/ChatView";
5import DiffPanel from "./components/DiffPanel";5import DiffPanel from "./components/DiffPanel";
6import Sidebar from "./components/Sidebar";6import Sidebar from "./components/Sidebar";
7import { isElectron } from "./env";7import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";8import { DEFAULT_MODEL } from "./model-logic";
9import { readNativeApi } from "./session-logic";
10import { StoreProvider, useStore } from "./store";9import { StoreProvider, useStore } from "./store";
11import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";10import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
12import { onServerWelcome } from "./wsNativeApi";11import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";
1313
14function EventRouter() {14function EventRouter() {
15 const api = useMemo(() => readNativeApi(), []);15 const api = useNativeApi();
16 const { dispatch } = useStore();16 const { dispatch } = useStore();
17 const activeAssistantItemRef = useRef<string | null>(null);17 const activeAssistantItemRef = useRef<string | null>(null);
1818
...@@ -109,7 +109,7 @@ function AutoProjectBootstrap() {...@@ -109,7 +109,7 @@ function AutoProjectBootstrap() {
109}109}
110110
111function DesktopProjectBootstrap() {111function DesktopProjectBootstrap() {
112 const api = useMemo(() => readNativeApi(), []);112 const api = useNativeApi();
113 const { dispatch } = useStore();113 const { dispatch } = useStore();
114 const bootstrappedRef = useRef(false);114 const bootstrappedRef = useRef(false);
115115
...@@ -159,7 +159,7 @@ function DesktopProjectBootstrap() {...@@ -159,7 +159,7 @@ function DesktopProjectBootstrap() {
159}159}
160160
161function Layout() {161function Layout() {
162 const api = useMemo(() => readNativeApi(), []);162 const api = useNativeApi();
163 const { state } = useStore();163 const { state } = useStore();
164164
165 if (!api) {165 if (!api) {
apps/web/src/components/BranchToolbar.tsx+8-4
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useMemo, useRef, useState } from "react";3import { useEffect, useRef, useState } from "react";
44
5import { readNativeApi } from "../session-logic";
6import { useStore } from "../store";5import { useStore } from "../store";
6import { useNativeApi } from "../hooks/useNativeApi";
77
8interface BranchToolbarProps {8interface BranchToolbarProps {
9 envMode: "local" | "worktree";9 envMode: "local" | "worktree";
...@@ -13,7 +13,7 @@ interface BranchToolbarProps {...@@ -13,7 +13,7 @@ interface BranchToolbarProps {
1313
14export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {14export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
15 const { state, dispatch } = useStore();15 const { state, dispatch } = useStore();
16 const api = useMemo(() => readNativeApi(), []);16 const api = useNativeApi();
17 const queryClient = useQueryClient();17 const queryClient = useQueryClient();
1818
19 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);19 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
...@@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
254 <span className="truncate">{branch.name}</span>254 <span className="truncate">{branch.name}</span>
255 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (255 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
256 <span className="shrink-0 text-[10px] text-muted-foreground/45">256 <span className="shrink-0 text-[10px] text-muted-foreground/45">
257 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}257 {branch.current
258 ? "current"
259 : hasSecondaryWorktree
260 ? "worktree"
261 : "default"}
258 </span>262 </span>
259 )}263 )}
260 </button>264 </button>
apps/web/src/components/ChatView.tsx+13-25
...@@ -31,13 +31,14 @@ import {...@@ -31,13 +31,14 @@ import {
31 formatDuration,31 formatDuration,
32 formatElapsed,32 formatElapsed,
33 formatTimestamp,33 formatTimestamp,
34 readNativeApi,
35} from "../session-logic";34} from "../session-logic";
36import { useStore } from "../store";35import { useStore } from "../store";
37import BranchToolbar from "./BranchToolbar";36import BranchToolbar from "./BranchToolbar";
37import GitActionsControl from "./GitActionsControl";
38import { isTerminalToggleShortcut } from "../terminal-shortcuts";38import { isTerminalToggleShortcut } from "../terminal-shortcuts";
39import ChatMarkdown from "./ChatMarkdown";39import ChatMarkdown from "./ChatMarkdown";
40import ThreadTerminalDrawer from "./ThreadTerminalDrawer";40import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
41import { useNativeApi } from "../hooks/useNativeApi";
4142
42function formatMessageMeta(createdAt: string, duration: string | null): string {43function formatMessageMeta(createdAt: string, duration: string | null): string {
43 if (!duration) return formatTimestamp(createdAt);44 if (!duration) return formatTimestamp(createdAt);
...@@ -132,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]...@@ -132,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[]
132133
133export default function ChatView() {134export default function ChatView() {
134 const { state, dispatch } = useStore();135 const { state, dispatch } = useStore();
135 const api = useMemo(() => readNativeApi(), []);136 const api = useNativeApi();
136 const [prompt, setPrompt] = useState("");137 const [prompt, setPrompt] = useState("");
137 const [isSending, setIsSending] = useState(false);138 const [isSending, setIsSending] = useState(false);
138 const [isConnecting, setIsConnecting] = useState(false);139 const [isConnecting, setIsConnecting] = useState(false);
...@@ -256,14 +257,14 @@ export default function ChatView() {...@@ -256,14 +257,14 @@ export default function ChatView() {
256 approvalPolicy: "on-request",257 approvalPolicy: "on-request",
257 sandboxMode: "workspace-write",258 sandboxMode: "workspace-write",
258 } as const);259 } as const);
260 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
261
259 const envLocked = Boolean(262 const envLocked = Boolean(
260 activeThread &&263 activeThread &&
261 (activeThread.messages.length > 0 ||264 (activeThread.messages.length > 0 ||
262 (activeThread.session !== null && activeThread.session.status !== "closed")),265 (activeThread.session !== null && activeThread.session.status !== "closed")),
263 );266 );
264 const terminalShortcutHint = navigator.platform.includes("Mac")267 const terminalShortcutHint = navigator.platform.includes("Mac") ? "\u2318J" : "Ctrl+J";
265 ? "\u2318J"
266 : "Ctrl+J";
267 const focusComposer = useCallback(() => {268 const focusComposer = useCallback(() => {
268 const textarea = textareaRef.current;269 const textarea = textareaRef.current;
269 if (!textarea) return;270 if (!textarea) return;
...@@ -319,6 +320,7 @@ export default function ChatView() {...@@ -319,6 +320,7 @@ export default function ChatView() {
319 }, [activeThread?.id]);320 }, [activeThread?.id]);
320321
321 const activeWorktreePath = activeThread?.worktreePath;322 const activeWorktreePath = activeThread?.worktreePath;
323
322 useEffect(() => {324 useEffect(() => {
323 if (!activeThread?.id) return;325 if (!activeThread?.id) return;
324 setEnvMode(activeWorktreePath ? "worktree" : "local");326 setEnvMode(activeWorktreePath ? "worktree" : "local");
...@@ -652,12 +654,6 @@ export default function ChatView() {...@@ -652,12 +654,6 @@ export default function ChatView() {
652 {activeProject.name}654 {activeProject.name}
653 </span>655 </span>
654 )}656 )}
655 {activeThread.branch && (
656 <span className="rounded-full bg-accent px-2 py-0.5 font-mono text-[10px] text-muted-foreground/50">
657 {activeThread.branch}
658 {activeThread.worktreePath ? " (worktree)" : ""}
659 </span>
660 )}
661 </div>657 </div>
662 <div className="flex items-center gap-3">658 <div className="flex items-center gap-3">
663 {/* Open in editor */}659 {/* Open in editor */}
...@@ -691,18 +687,10 @@ export default function ChatView() {...@@ -691,18 +687,10 @@ export default function ChatView() {
691 )}687 )}
692 </div>688 </div>
693 )}689 )}
690 {/* Git actions */}
691 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
692
694 {/* Diff toggle */}693 {/* Diff toggle */}
695 <button
696 type="button"
697 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
698 activeThread.terminalOpen
699 ? "bg-accent text-foreground"
700 : "text-muted-foreground/40 hover:text-muted-foreground/60"
701 }`}
702 onClick={toggleTerminalVisibility}
703 >
704 Terminal <span className="text-muted-foreground/50">{terminalShortcutHint}</span>
705 </button>
706 <button694 <button
707 type="button"695 type="button"
708 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${696 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
...@@ -757,7 +745,7 @@ export default function ChatView() {...@@ -757,7 +745,7 @@ export default function ChatView() {
757 </button>745 </button>
758 <button746 <button
759 type="button"747 type="button"
760 className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50"748 className="rounded-md border border-sky-300/30 bg-sky-500/15 px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/22 disabled:cursor-not-allowed disabled:opacity-50"
761 disabled={isResponding}749 disabled={isResponding}
762 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}750 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
763 >751 >
...@@ -773,7 +761,7 @@ export default function ChatView() {...@@ -773,7 +761,7 @@ export default function ChatView() {
773 </button>761 </button>
774 <button762 <button
775 type="button"763 type="button"
776 className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50"764 className="rounded-md border border-rose-300/30 bg-rose-500/12 px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
777 disabled={isResponding}765 disabled={isResponding}
778 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}766 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
779 >767 >
...@@ -892,7 +880,7 @@ export default function ChatView() {...@@ -892,7 +880,7 @@ export default function ChatView() {
892 <Fragment key={timelineEntry.id}>880 <Fragment key={timelineEntry.id}>
893 <div className="flex justify-end">881 <div className="flex justify-end">
894 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">882 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
895 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">883 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
896 {timelineEntry.message.text}884 {timelineEntry.message.text}
897 </pre>885 </pre>
898 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">886 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
apps/web/src/components/GitActionsControl.tsx created+831
...@@ -0,0 +1,831 @@
1import {
2 type GitRunStackedActionResult,
3 type GitStackedAction,
4 type GitStatusResult,
5 type NativeApi,
6} from "@t3tools/contracts";
7import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9import {
10 CheckIcon,
11 CircleIcon,
12 CloudUploadIcon,
13 GitCommitIcon,
14 Loader2Icon,
15 MinusIcon,
16 XIcon,
17} from "lucide-react";
18import { GitHubIcon } from "./Icons";
19
20interface GitActionsControlProps {
21 api: NativeApi | undefined;
22 gitCwd: string | null;
23}
24
25type GitActionIconName = "commit" | "push" | "pr";
26
27interface GitActionMenuItem {
28 id: "commit" | "push" | "pr";
29 label: string;
30 disabled: boolean;
31 icon: GitActionIconName;
32 kind: "open_modal" | "run_action" | "open_pr";
33 action?: GitStackedAction;
34}
35
36interface GitModalActionOption {
37 action: GitStackedAction;
38 label: string;
39 disabled: boolean;
40 icon: GitActionIconName;
41 detail?: string;
42}
43
44type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed";
45
46interface GitProgressStep {
47 id: "generate" | "commit" | "push" | "pr";
48 label: string;
49 status: GitProgressStepStatus;
50 detail?: string;
51}
52
53function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) {
54 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
55
56 if (props.icon === "commit") {
57 return <GitCommitIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
58 }
59
60 if (props.icon === "push") {
61 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
62 }
63
64 return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
65}
66
67function gitActionModalTitle(): string {
68 return "Commit your changes";
69}
70
71function initialGitProgressSteps(
72 action: GitStackedAction,
73 commitMessage: string,
74): GitProgressStep[] {
75 const hasCustomMessage = commitMessage.trim().length > 0;
76 const steps: GitProgressStep[] = [];
77
78 if (!hasCustomMessage) {
79 steps.push({
80 id: "generate",
81 label: "Generate commit message",
82 status: "pending",
83 });
84 }
85
86 steps.push({
87 id: "commit",
88 label: "Commit changes",
89 status: "pending",
90 });
91
92 if (action !== "commit") {
93 steps.push({
94 id: "push",
95 label: "Push branch",
96 status: "pending",
97 });
98 }
99
100 if (action === "commit_push_pr") {
101 steps.push({
102 id: "pr",
103 label: "Create or open PR",
104 status: "pending",
105 });
106 }
107
108 return steps;
109}
110
111function updateProgressStep(
112 steps: GitProgressStep[],
113 id: GitProgressStep["id"],
114 status: GitProgressStepStatus,
115 detail?: string,
116): GitProgressStep[] {
117 return steps.map((step) => {
118 if (step.id !== id) return step;
119 return {
120 ...step,
121 status,
122 ...(detail ? { detail } : {}),
123 };
124 });
125}
126
127function runActionLabel(action: GitStackedAction): string {
128 if (action === "commit") return "Commit changes";
129 if (action === "commit_push") return "Commit and push";
130 return "Commit and create PR";
131}
132
133function buildGitActionMenuItems(
134 gitStatus: GitStatusResult | null,
135 isDisabled: boolean,
136): GitActionMenuItem[] {
137 if (!gitStatus) return [];
138
139 const hasBranch = gitStatus.branch !== null;
140 const hasOpenPr = gitStatus.openPr !== null;
141 const hasChanges = gitStatus.hasWorkingTreeChanges;
142 const hasAhead = gitStatus.aheadCount > 0;
143 const canCommit = !isDisabled && hasChanges;
144 const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead;
145 const canOpenPr = !isDisabled && hasOpenPr;
146 const canCreatePr =
147 !isDisabled &&
148 hasBranch &&
149 !hasOpenPr &&
150 !hasChanges &&
151 gitStatus.hasUpstream &&
152 gitStatus.behindCount === 0;
153
154 return [
155 {
156 id: "commit",
157 label: "Commit",
158 disabled: !canCommit,
159 icon: "commit",
160 kind: "open_modal",
161 },
162 {
163 id: "push",
164 label: "Push",
165 disabled: !canPush,
166 icon: "push",
167 kind: "run_action",
168 action: "commit_push",
169 },
170 hasOpenPr
171 ? {
172 id: "pr",
173 label: "View PR",
174 disabled: !canOpenPr,
175 icon: "pr",
176 kind: "open_pr",
177 }
178 : {
179 id: "pr",
180 label: "Create PR",
181 disabled: !canCreatePr,
182 icon: "pr",
183 kind: "run_action",
184 action: "commit_push_pr",
185 },
186 ];
187}
188
189function buildGitModalActionOptions(
190 gitStatus: GitStatusResult | null,
191 isDisabled: boolean,
192): GitModalActionOption[] {
193 const hasBranch = gitStatus?.branch !== null;
194 const hasOpenPr = gitStatus?.openPr !== null;
195
196 return [
197 {
198 action: "commit",
199 label: "Commit",
200 disabled: isDisabled,
201 icon: "commit",
202 },
203 {
204 action: "commit_push",
205 label: "Commit and push",
206 disabled: isDisabled || !hasBranch,
207 icon: "push",
208 ...(!hasBranch ? { detail: "Requires an attached branch." } : {}),
209 },
210 {
211 action: "commit_push_pr",
212 label: "Commit and create PR",
213 disabled: isDisabled || !hasBranch || hasOpenPr,
214 icon: "pr",
215 ...(!hasBranch
216 ? { detail: "Requires an attached branch." }
217 : hasOpenPr
218 ? { detail: "A PR is already open for this branch." }
219 : {}),
220 },
221 ];
222}
223
224function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
225 return ["git", "status", gitCwd];
226}
227
228function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) {
229 return queryOptions({
230 queryKey: getGitStatusQueryKey(gitCwd),
231 queryFn: async () => {
232 if (!api || !gitCwd) {
233 throw new Error("Git status is unavailable.");
234 }
235 return api.git.status({ cwd: gitCwd });
236 },
237 enabled: !!api && !!gitCwd,
238 });
239}
240
241export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
242 const queryClient = useQueryClient();
243 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
244 const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false);
245 const [gitActionError, setGitActionError] = useState<string | null>(null);
246 const [isGitModalOpen, setIsGitModalOpen] = useState(false);
247 const [gitModalSelectedAction, setGitModalSelectedAction] = useState<GitStackedAction>("commit");
248 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
249 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
250 const [gitModalError, setGitModalError] = useState<string | null>(null);
251 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
252 const gitMenuRef = useRef<HTMLDivElement>(null);
253
254 const { data: gitStatus = null, error: gitStatusError } = useQuery(
255 gitStatusQueryOptions(api, gitCwd),
256 );
257
258 const runImmediateGitActionMutation = useMutation({
259 mutationFn: async (action: GitStackedAction) => {
260 if (!api || !gitCwd) {
261 throw new Error("Git action is unavailable.");
262 }
263 return api.git.runStackedAction({ cwd: gitCwd, action });
264 },
265 onMutate: () => {
266 setIsGitMenuOpen(false);
267 setGitActionError(null);
268 setGitModalError(null);
269 },
270 onSuccess: async () => {
271 await queryClient.invalidateQueries({ queryKey: ["git"] });
272 },
273 onError: (error) => {
274 setGitActionError(error instanceof Error ? error.message : "Git action failed.");
275 },
276 });
277
278 const isGitActionRunning = isGitModalActionRunning || runImmediateGitActionMutation.isPending;
279
280 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
281 const gitActionMenuItems = useMemo(
282 () => buildGitActionMenuItems(gitStatus, gitBaseDisabled),
283 [gitBaseDisabled, gitStatus],
284 );
285 const gitModalActionOptions = useMemo(
286 () => buildGitModalActionOptions(gitStatus, !gitStatus || isGitActionRunning),
287 [gitStatus, isGitActionRunning],
288 );
289 const selectedGitModalActionOption = useMemo(
290 () => gitModalActionOptions.find((option) => option.action === gitModalSelectedAction) ?? null,
291 [gitModalActionOptions, gitModalSelectedAction],
292 );
293 const visibleGitActionError = gitActionError ?? gitStatusError?.message;
294 const gitModalOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null;
295 const gitModalHasProgress = gitModalProgress.length > 0;
296 const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null;
297 const gitModalSteps = gitModalProgress;
298
299 const refreshGitStatus = useCallback(async () => {
300 if (!api || !gitCwd) return;
301 await queryClient.invalidateQueries({ queryKey: ["git"] });
302 setGitActionError(null);
303 }, [api, gitCwd, queryClient]);
304
305 const openGitActionModal = useCallback(() => {
306 setIsGitMenuOpen(false);
307 setIsGitModalOpen(true);
308 setGitModalSelectedAction("commit");
309 setGitModalCommitMessage("");
310 setGitModalProgress([]);
311 setGitModalError(null);
312 setGitModalResult(null);
313 setGitActionError(null);
314 }, []);
315
316 const closeGitActionModal = useCallback(() => {
317 if (isGitActionRunning) return;
318 setIsGitModalOpen(false);
319 setGitModalSelectedAction("commit");
320 setGitModalCommitMessage("");
321 setGitModalProgress([]);
322 setGitModalError(null);
323 setGitModalResult(null);
324 }, [isGitActionRunning]);
325
326 const runGitActionImmediately = useCallback(
327 (action: GitStackedAction) => {
328 runImmediateGitActionMutation.mutate(action);
329 },
330 [runImmediateGitActionMutation],
331 );
332
333 const openExistingPr = useCallback(() => {
334 setIsGitMenuOpen(false);
335 setGitActionError(null);
336 if (!api) {
337 setGitActionError("Link opening is unavailable.");
338 return;
339 }
340
341 const prUrl = gitStatus?.openPr?.url ?? null;
342 if (!prUrl) {
343 setGitActionError("No open PR found for the current branch.");
344 return;
345 }
346
347 void api.shell.openExternal(prUrl).catch((error) => {
348 setGitActionError(error instanceof Error ? error.message : "Unable to open PR link.");
349 });
350 }, [api, gitStatus?.openPr?.url]);
351
352 const openPrFromModal = useCallback(() => {
353 if (!api) {
354 setGitModalError("Link opening is unavailable.");
355 return;
356 }
357
358 const prUrl = gitModalOpenPrUrl;
359 if (!prUrl) return;
360
361 void api.shell.openExternal(prUrl).catch((error) => {
362 setGitModalError(error instanceof Error ? error.message : "Unable to open PR link.");
363 });
364 }, [api, gitModalOpenPrUrl]);
365
366 const runGitAction = useCallback(async () => {
367 if (!api || !gitCwd || !isGitModalOpen) return;
368 if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return;
369 const actionCwd = gitCwd;
370 const action = selectedGitModalActionOption.action;
371 const commitMessage = gitModalCommitMessage.trim();
372 const includeGeneratedCommitMessage = commitMessage.length === 0;
373
374 setIsGitModalActionRunning(true);
375 setGitModalError(null);
376 setGitActionError(null);
377 setGitModalResult(null);
378 setGitModalProgress(initialGitProgressSteps(action, commitMessage));
379
380 let commit: GitRunStackedActionResult["commit"] = {
381 status: "skipped_no_changes",
382 };
383 let push: GitRunStackedActionResult["push"] = {
384 status: "skipped_not_requested",
385 };
386 let pr: GitRunStackedActionResult["pr"] = {
387 status: "skipped_not_requested",
388 };
389
390 const updateStep = (
391 id: GitProgressStep["id"],
392 status: GitProgressStepStatus,
393 detail?: string,
394 ) => {
395 setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail));
396 };
397
398 try {
399 if (includeGeneratedCommitMessage) {
400 updateStep("generate", "running");
401 } else {
402 updateStep("commit", "running");
403 }
404
405 const commitRun = await api.git.runStackedAction({
406 cwd: actionCwd,
407 action: "commit",
408 ...(commitMessage.length > 0 ? { commitMessage } : {}),
409 });
410 commit = commitRun.commit;
411
412 if (includeGeneratedCommitMessage) {
413 if (commitRun.commit.status === "created") {
414 if (commitRun.commit.subject) {
415 setGitModalCommitMessage(commitRun.commit.subject);
416 }
417 updateStep("generate", "completed");
418 } else {
419 updateStep("generate", "skipped", "No local changes to commit.");
420 }
421 }
422
423 if (commitRun.commit.status === "created") {
424 updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes.");
425 } else {
426 updateStep("commit", "skipped", "No local changes to commit.");
427 }
428
429 if (action !== "commit") {
430 updateStep("push", "running");
431 const pushRun = await api.git.runStackedAction({
432 cwd: actionCwd,
433 action: "commit_push",
434 });
435 push = pushRun.push;
436 if (pushRun.push.status === "pushed") {
437 updateStep(
438 "push",
439 "completed",
440 pushRun.push.upstreamBranch
441 ? `Pushed to ${pushRun.push.upstreamBranch}.`
442 : "Pushed latest commits.",
443 );
444 } else {
445 updateStep("push", "skipped", "Branch already up to date.");
446 }
447 }
448
449 if (action === "commit_push_pr") {
450 updateStep("pr", "running");
451 const prRun = await api.git.runStackedAction({
452 cwd: actionCwd,
453 action: "commit_push_pr",
454 });
455 pr = prRun.pr;
456 if (prRun.pr.status === "opened_existing") {
457 updateStep(
458 "pr",
459 "completed",
460 prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.",
461 );
462 } else if (prRun.pr.status === "created") {
463 updateStep(
464 "pr",
465 "completed",
466 prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.",
467 );
468 } else {
469 updateStep("pr", "skipped", "PR step was not requested.");
470 }
471 }
472
473 setGitModalResult({ action, commit, push, pr });
474 } catch (error) {
475 const message = error instanceof Error ? error.message : "Git action failed.";
476 setGitModalError(message);
477 setGitActionError(message);
478 setGitModalProgress((steps) => {
479 const active = steps.find((step) => step.status === "running");
480 if (!active) return steps;
481 return updateProgressStep(steps, active.id, "failed", message);
482 });
483 } finally {
484 setIsGitModalActionRunning(false);
485 await refreshGitStatus().catch(() => undefined);
486 }
487 }, [
488 api,
489 gitCwd,
490 gitModalCommitMessage,
491 isGitModalOpen,
492 refreshGitStatus,
493 selectedGitModalActionOption,
494 ]);
495
496 useEffect(() => {
497 setGitActionError(null);
498 setGitModalError(null);
499 setIsGitModalOpen(false);
500 setGitModalSelectedAction("commit");
501 setGitModalCommitMessage("");
502 setGitModalProgress([]);
503 setGitModalResult(null);
504 }, [gitCwd]);
505
506 useEffect(() => {
507 if (!isGitModalOpen) return;
508 if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return;
509
510 const fallback = gitModalActionOptions.find((option) => !option.disabled);
511 if (fallback) {
512 setGitModalSelectedAction(fallback.action);
513 }
514 }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]);
515
516 useEffect(() => {
517 if (!isGitMenuOpen) return;
518
519 const handleClickOutside = (event: MouseEvent) => {
520 if (!gitMenuRef.current) return;
521 if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) {
522 setIsGitMenuOpen(false);
523 }
524 };
525
526 window.addEventListener("mousedown", handleClickOutside);
527 return () => {
528 window.removeEventListener("mousedown", handleClickOutside);
529 };
530 }, [isGitMenuOpen]);
531
532 useEffect(() => {
533 if (!isGitModalOpen) return;
534
535 const handleEscape = (event: globalThis.KeyboardEvent) => {
536 if (event.key !== "Escape") return;
537 if (isGitActionRunning) return;
538 closeGitActionModal();
539 };
540
541 window.addEventListener("keydown", handleEscape);
542 return () => {
543 window.removeEventListener("keydown", handleEscape);
544 };
545 }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]);
546
547 if (!gitCwd) return null;
548
549 return (
550 <>
551 <div className="relative" ref={gitMenuRef}>
552 <button
553 type="button"
554 className="inline-flex items-center gap-1.5 rounded-md 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"
555 onClick={() => {
556 if (!isGitMenuOpen) {
557 void refreshGitStatus().catch(() => undefined);
558 }
559 setIsGitMenuOpen((v) => !v);
560 }}
561 disabled={!gitStatus || isGitActionRunning}
562 >
563 {isGitActionRunning ? "Running..." : "Git actions"}
564 <span aria-hidden="true">▾</span>
565 </button>
566 {isGitMenuOpen && (
567 <div className="absolute right-0 top-full z-50 mt-1 w-[280px] rounded-3xl border border-border bg-popover p-3 shadow-xl">
568 <p className="px-3 pb-2 text-[13px] text-muted-foreground/75">Git actions</p>
569 {gitActionMenuItems.map((item) => {
570 return (
571 <button
572 key={`${item.id}-${item.label}`}
573 type="button"
574 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"
575 disabled={item.disabled}
576 onClick={() => {
577 if (item.kind === "open_modal") {
578 openGitActionModal();
579 return;
580 }
581
582 if (item.kind === "open_pr") {
583 openExistingPr();
584 return;
585 }
586
587 if (item.action) {
588 void runGitActionImmediately(item.action);
589 }
590 }}
591 >
592 <GitActionIcon icon={item.icon} disabled={item.disabled} />
593 <span>{item.label}</span>
594 </button>
595 );
596 })}
597 {gitStatus?.branch === null && (
598 <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
599 Detached HEAD: push and PR actions are unavailable.
600 </p>
601 )}
602 {gitStatus &&
603 gitStatus.branch !== null &&
604 !gitStatus.hasWorkingTreeChanges &&
605 gitStatus.aheadCount === 0 &&
606 gitStatus.behindCount > 0 && (
607 <p className="px-3 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
608 Branch is behind upstream. Pull/rebase before opening a PR.
609 </p>
610 )}
611 {visibleGitActionError && (
612 <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300">
613 {visibleGitActionError}
614 </p>
615 )}
616 </div>
617 )}
618 </div>
619
620 {isGitModalOpen && (
621 <div
622 className="fixed inset-0 z-80 flex items-center justify-center bg-black/35 px-4 py-6"
623 onMouseDown={() => {
624 closeGitActionModal();
625 }}
626 >
627 <div
628 className="w-full max-w-[640px] rounded-3xl border border-border bg-popover p-6 shadow-2xl"
629 role="dialog"
630 aria-modal="true"
631 aria-label="Git action confirmation"
632 onMouseDown={(event) => {
633 event.stopPropagation();
634 }}
635 >
636 <div className="flex items-start justify-between gap-4">
637 <div className="flex items-center gap-3">
638 <div className="rounded-2xl bg-accent p-3">
639 <GitActionIcon
640 icon={selectedGitModalActionOption?.icon ?? "commit"}
641 disabled={false}
642 />
643 </div>
644 <div>
645 <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground/70">
646 Git actions
647 </p>
648 <h3 className="text-3xl font-semibold tracking-tight text-foreground">
649 {gitActionModalTitle()}
650 </h3>
651 </div>
652 </div>
653 <button
654 type="button"
655 className="rounded-md p-1 text-muted-foreground/60 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
656 onClick={closeGitActionModal}
657 disabled={isGitActionRunning}
658 aria-label="Close git action dialog"
659 >
660 <XIcon className="h-5 w-5" />
661 </button>
662 </div>
663 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">
664 <div className="flex items-center justify-between gap-4 text-sm">
665 <span className="text-muted-foreground/70">Branch</span>
666 <span className="font-mono text-foreground">
667 {gitStatus?.branch ?? "(detached HEAD)"}
668 </span>
669 </div>
670 <div className="flex items-center justify-between gap-4 text-sm">
671 <span className="text-muted-foreground/70">Changes</span>
672 <span className="text-foreground">
673 {gitStatus?.hasWorkingTreeChanges
674 ? "Working tree has changes"
675 : "No local changes"}
676 </span>
677 </div>
678 </div>
679 <div className="mt-6">
680 <label
681 htmlFor="git-commit-message"
682 className="mb-2 block text-sm font-medium text-foreground"
683 >
684 Commit message
685 </label>
686 <textarea
687 id="git-commit-message"
688 rows={3}
689 className="w-full resize-none rounded-2xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none disabled:cursor-not-allowed disabled:opacity-70"
690 placeholder="Leave blank to autogenerate a commit message"
691 value={gitModalCommitMessage}
692 onChange={(event) => setGitModalCommitMessage(event.target.value)}
693 disabled={!gitModalSelectionMode || isGitActionRunning}
694 />
695 </div>
696 <div className="mt-6">
697 <p className="text-sm font-medium text-foreground">Next steps</p>
698 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
699 {gitModalSelectionMode &&
700 gitModalActionOptions.map((option, index) => {
701 const borderClass =
702 index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
703 const selected = option.action === gitModalSelectedAction && !option.disabled;
704 const disabled = option.disabled || isGitActionRunning;
705
706 return (
707 <button
708 key={option.action}
709 type="button"
710 className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${
711 selected
712 ? "bg-accent/55"
713 : disabled
714 ? "bg-card/25"
715 : "bg-card/45 hover:bg-accent/35"
716 } disabled:cursor-not-allowed`}
717 disabled={disabled}
718 onClick={() => {
719 setGitModalSelectedAction(option.action);
720 }}
721 >
722 <span className="mt-0.5">
723 <GitActionIcon icon={option.icon} disabled={option.disabled} />
724 </span>
725 <div className="min-w-0 flex-1">
726 <p
727 className={`text-sm ${disabled ? "text-muted-foreground/60" : "text-foreground"}`}
728 >
729 {option.label}
730 </p>
731 {option.detail && (
732 <p
733 className={`mt-0.5 text-xs ${disabled ? "text-muted-foreground/55" : "text-muted-foreground/70"}`}
734 >
735 {option.detail}
736 </p>
737 )}
738 </div>
739 {selected && !disabled ? (
740 <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
741 ) : null}
742 </button>
743 );
744 })}
745 {!gitModalSelectionMode &&
746 gitModalSteps.map((step, index) => {
747 const borderClass =
748 index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
749
750 return (
751 <div
752 key={step.id}
753 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}
754 >
755 <span className="mt-0.5">
756 {step.status === "running" ? (
757 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
758 ) : step.status === "completed" ? (
759 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
760 ) : step.status === "skipped" ? (
761 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />
762 ) : step.status === "failed" ? (
763 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
764 ) : (
765 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />
766 )}
767 </span>
768 <div className="min-w-0">
769 <p className="text-sm text-foreground">{step.label}</p>
770 {step.status === "failed" && step.detail && (
771 <p className="mt-0.5 text-xs text-rose-500 dark:text-rose-300">
772 {step.detail}
773 </p>
774 )}
775 </div>
776 </div>
777 );
778 })}
779 </div>
780 </div>
781 {(gitModalError ?? visibleGitActionError) && (
782 <div className="mt-4 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
783 {gitModalError ?? visibleGitActionError}
784 </div>
785 )}
786 <div className="mt-6 flex justify-end gap-2">
787 {gitModalResult && gitModalOpenPrUrl && (
788 <button
789 type="button"
790 className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
791 onClick={openPrFromModal}
792 disabled={isGitActionRunning}
793 >
794 Open PR
795 </button>
796 )}
797 <button
798 type="button"
799 className={
800 gitModalResult
801 ? "rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
802 : "rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
803 }
804 onClick={closeGitActionModal}
805 disabled={isGitActionRunning}
806 >
807 {gitModalResult ? "Done" : "Cancel"}
808 </button>
809 {!gitModalResult && (
810 <button
811 type="button"
812 className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
813 onClick={() => {
814 void runGitAction();
815 }}
816 disabled={isGitActionRunning || !selectedGitModalActionOption}
817 >
818 {isGitActionRunning
819 ? "Running..."
820 : gitModalSelectionMode
821 ? "Continue"
822 : runActionLabel(gitModalSelectedAction)}
823 </button>
824 )}
825 </div>
826 </div>
827 </div>
828 )}
829 </>
830 );
831}
apps/web/src/components/Icons.tsx created+15
...@@ -0,0 +1,15 @@
1import type { SVGProps } from "react";
2
3export type Icon = React.FC<SVGProps<SVGSVGElement>>;
4
5export const GitHubIcon: Icon = (props) => (
6 <svg {...props} viewBox="0 0 1024 1024" fill="none">
7 <path
8 fillRule="evenodd"
9 clipRule="evenodd"
10 d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z"
11 transform="scale(64)"
12 fill="#1B1F23"
13 />
14 </svg>
15);
apps/web/src/components/Sidebar.tsx+3-3
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";1import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useState } from "react";
3import { isElectron } from "../env";3import { isElectron } from "../env";
4import { useTheme } from "../hooks/useTheme";4import { useTheme } from "../hooks/useTheme";
5import { DEFAULT_MODEL } from "../model-logic";5import { DEFAULT_MODEL } from "../model-logic";
6import { readNativeApi } from "../session-logic";
7import { useStore } from "../store";6import { useStore } from "../store";
8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";7import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
8import { useNativeApi } from "../hooks/useNativeApi";
99
10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
11function formatRelativeTime(iso: string): string {11function formatRelativeTime(iso: string): string {
...@@ -35,7 +35,7 @@ function threadStatusLabel(...@@ -35,7 +35,7 @@ function threadStatusLabel(
3535
36export default function Sidebar() {36export default function Sidebar() {
37 const { state, dispatch } = useStore();37 const { state, dispatch } = useStore();
38 const api = useMemo(() => readNativeApi(), []);38 const api = useNativeApi();
39 const { theme, setTheme } = useTheme();39 const { theme, setTheme } = useTheme();
40 const [addingProject, setAddingProject] = useState(false);40 const [addingProject, setAddingProject] = useState(false);
41 const [newCwd, setNewCwd] = useState("");41 const [newCwd, setNewCwd] = useState("");
apps/web/src/hooks/useNativeApi.ts created+6
...@@ -0,0 +1,6 @@
1import { useMemo } from "react";
2import { readNativeApi } from "../session-logic";
3
4export function useNativeApi() {
5 return useMemo(() => readNativeApi(), []);
6}
apps/web/src/vite-env.d.ts+1
...@@ -6,6 +6,7 @@ interface DesktopBridge {...@@ -6,6 +6,7 @@ interface DesktopBridge {
6 getWsUrl: () => string | null;6 getWsUrl: () => string | null;
7 pickFolder: () => Promise<string | null>;7 pickFolder: () => Promise<string | null>;
8 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;8 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
9 openExternal: (url: string) => Promise<boolean>;
9}10}
1011
11declare global {12declare global {
apps/web/src/wsNativeApi.ts+16
...@@ -96,8 +96,24 @@ export function createWsNativeApi(): NativeApi {...@@ -96,8 +96,24 @@ export function createWsNativeApi(): NativeApi {
96 shell: {96 shell: {
97 openInEditor: (cwd, editor) =>97 openInEditor: (cwd, editor) =>
98 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),98 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
99 openExternal: async (url) => {
100 if (window.desktopBridge) {
101 const opened = await window.desktopBridge.openExternal(url);
102 if (!opened) {
103 throw new Error("Unable to open link.");
104 }
105 return;
106 }
107
108 const popup = window.open(url, "_blank", "noopener,noreferrer");
109 if (!popup) {
110 throw new Error("Unable to open link. Allow popups and try again.");
111 }
112 },
99 },113 },
100 git: {114 git: {
115 status: (input) => transport.request(WS_METHODS.gitStatus, input),
116 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
101 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),117 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
102 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),118 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
103 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),119 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
packages/contracts/src/git.test.ts created+82
...@@ -0,0 +1,82 @@
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 openPr: null,
25 });
26 expect(parsed.branch).toBe("feature/git-actions");
27 expect(parsed.hasWorkingTreeChanges).toBe(true);
28 });
29
30 it("accepts supported stacked actions", () => {
31 expect(gitStackedActionSchema.parse("commit")).toBe("commit");
32 expect(gitStackedActionSchema.parse("commit_push")).toBe("commit_push");
33 expect(gitStackedActionSchema.parse("commit_push_pr")).toBe(
34 "commit_push_pr",
35 );
36 });
37
38 it("parses stacked action input", () => {
39 const parsed = gitRunStackedActionInputSchema.parse({
40 cwd: "/tmp/repo",
41 action: "commit_push",
42 });
43 expect(parsed.action).toBe("commit_push");
44 });
45
46 it("accepts custom commit message input", () => {
47 const parsed = gitRunStackedActionInputSchema.parse({
48 cwd: "/tmp/repo",
49 action: "commit",
50 commitMessage: " chore: update git action modal ",
51 });
52 expect(parsed.commitMessage).toBe("chore: update git action modal");
53 });
54
55 it("parses stacked action result with metadata", () => {
56 const parsed = gitRunStackedActionResultSchema.parse({
57 action: "commit_push_pr",
58 commit: {
59 status: "created",
60 commitSha: "abc123",
61 subject: "Implement stacked git actions",
62 },
63 push: {
64 status: "pushed",
65 branch: "feature/git-actions",
66 upstreamBranch: "origin/feature/git-actions",
67 setUpstream: true,
68 },
69 pr: {
70 status: "created",
71 url: "https://github.com/pingdotgg/codething-mvp/pull/123",
72 number: 123,
73 baseBranch: "main",
74 headBranch: "feature/git-actions",
75 title: "Add stacked git actions workflow",
76 },
77 });
78
79 expect(parsed.commit.status).toBe("created");
80 expect(parsed.pr.number).toBe(123);
81 });
82});
packages/contracts/src/git.ts+77-7
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { z } from "zod";1import { z } from "zod";
22
3// ── Input schemas ──3// ── Existing branch/worktree Git API ─────────────────────────────────
44
5export const gitListBranchesInputSchema = z.object({5export const gitListBranchesInputSchema = z.object({
6 cwd: z.string().min(1),6 cwd: z.string().min(1),
...@@ -8,9 +8,7 @@ export const gitListBranchesInputSchema = z.object({...@@ -8,9 +8,7 @@ export const gitListBranchesInputSchema = z.object({
88
9export const gitCreateWorktreeInputSchema = z.object({9export const gitCreateWorktreeInputSchema = z.object({
10 cwd: z.string().min(1),10 cwd: z.string().min(1),
11 /** Base branch to create the worktree from. */
12 branch: z.string().min(1),11 branch: z.string().min(1),
13 /** New branch name to create in the worktree. */
14 newBranch: z.string().min(1),12 newBranch: z.string().min(1),
15 path: z.string().min(1).optional(),13 path: z.string().min(1).optional(),
16});14});
...@@ -34,8 +32,6 @@ export const gitInitInputSchema = z.object({...@@ -34,8 +32,6 @@ export const gitInitInputSchema = z.object({
34 cwd: z.string().min(1),32 cwd: z.string().min(1),
35});33});
3634
37// ── Output schemas ──
38
39export const gitBranchSchema = z.object({35export const gitBranchSchema = z.object({
40 name: z.string().min(1),36 name: z.string().min(1),
41 current: z.boolean(),37 current: z.boolean(),
...@@ -48,8 +44,6 @@ export const gitWorktreeSchema = z.object({...@@ -48,8 +44,6 @@ export const gitWorktreeSchema = z.object({
48 branch: z.string().min(1),44 branch: z.string().min(1),
49});45});
5046
51// ── Types ──
52
53export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;47export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;
54export type GitBranch = z.infer<typeof gitBranchSchema>;48export type GitBranch = z.infer<typeof gitBranchSchema>;
55export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;49export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;
...@@ -67,3 +61,79 @@ export interface GitListBranchesResult {...@@ -67,3 +61,79 @@ export interface GitListBranchesResult {
67export interface GitCreateWorktreeResult {61export interface GitCreateWorktreeResult {
68 worktree: GitWorktree;62 worktree: GitWorktree;
69}63}
64
65// ── Stacked action Git API ───────────────────────────────────────────
66
67export const gitStatusInputSchema = z.object({
68 cwd: z.string().trim().min(1),
69});
70
71export const gitStatusResultSchema = z.object({
72 branch: z.string().min(1).nullable(),
73 hasWorkingTreeChanges: z.boolean(),
74 hasUpstream: z.boolean(),
75 aheadCount: z.number().int().nonnegative(),
76 behindCount: z.number().int().nonnegative(),
77 openPr: z
78 .object({
79 number: z.number().int().positive(),
80 title: z.string().min(1),
81 url: z.string().url(),
82 baseBranch: z.string().min(1),
83 headBranch: z.string().min(1),
84 })
85 .nullable(),
86});
87
88export const gitStackedActionSchema = z.enum([
89 "commit",
90 "commit_push",
91 "commit_push_pr",
92]);
93
94const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]);
95const gitPushStepStatusSchema = z.enum([
96 "pushed",
97 "skipped_not_requested",
98 "skipped_up_to_date",
99]);
100const gitPrStepStatusSchema = z.enum([
101 "created",
102 "opened_existing",
103 "skipped_not_requested",
104]);
105
106export const gitRunStackedActionInputSchema = z.object({
107 cwd: z.string().trim().min(1),
108 action: gitStackedActionSchema,
109 commitMessage: z.string().trim().min(1).max(10_000).optional(),
110});
111
112export const gitRunStackedActionResultSchema = z.object({
113 action: gitStackedActionSchema,
114 commit: z.object({
115 status: gitCommitStepStatusSchema,
116 commitSha: z.string().min(1).optional(),
117 subject: z.string().min(1).optional(),
118 }),
119 push: z.object({
120 status: gitPushStepStatusSchema,
121 branch: z.string().min(1).optional(),
122 upstreamBranch: z.string().min(1).optional(),
123 setUpstream: z.boolean().optional(),
124 }),
125 pr: z.object({
126 status: gitPrStepStatusSchema,
127 url: z.string().url().optional(),
128 number: z.number().int().positive().optional(),
129 baseBranch: z.string().min(1).optional(),
130 headBranch: z.string().min(1).optional(),
131 title: z.string().min(1).optional(),
132 }),
133});
134
135export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
136export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
137export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
138export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
139export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
packages/contracts/src/ipc.ts+11
...@@ -8,6 +8,10 @@ import type {...@@ -8,6 +8,10 @@ import type {
8 GitListBranchesInput,8 GitListBranchesInput,
9 GitListBranchesResult,9 GitListBranchesResult,
10 GitRemoveWorktreeInput,10 GitRemoveWorktreeInput,
11 GitRunStackedActionInput,
12 GitRunStackedActionResult,
13 GitStatusInput,
14 GitStatusResult,
11} from "./git";15} from "./git";
12import type {16import type {
13 ProviderEvent,17 ProviderEvent,
...@@ -85,14 +89,21 @@ export interface NativeApi {...@@ -85,14 +89,21 @@ export interface NativeApi {
85 };89 };
86 shell: {90 shell: {
87 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;91 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
92 openExternal: (url: string) => Promise<void>;
88 };93 };
89 git: {94 git: {
95 // Existing branch/worktree API
90 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;96 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
91 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;97 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
92 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;98 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
93 createBranch: (input: GitCreateBranchInput) => Promise<void>;99 createBranch: (input: GitCreateBranchInput) => Promise<void>;
94 checkout: (input: GitCheckoutInput) => Promise<void>;100 checkout: (input: GitCheckoutInput) => Promise<void>;
95 init: (input: GitInitInput) => Promise<void>;101 init: (input: GitInitInput) => Promise<void>;
102 // Stacked action API
103 status: (input: GitStatusInput) => Promise<GitStatusResult>;
104 runStackedAction: (
105 input: GitRunStackedActionInput,
106 ) => Promise<GitRunStackedActionResult>;
96 };107 };
97 contextMenu: {108 contextMenu: {
98 show: <T extends string>(109 show: <T extends string>(
packages/contracts/src/ws.ts+2
...@@ -20,6 +20,8 @@ export const WS_METHODS = {...@@ -20,6 +20,8 @@ export const WS_METHODS = {
20 shellOpenInEditor: "shell.openInEditor",20 shellOpenInEditor: "shell.openInEditor",
2121
22 // Git methods22 // Git methods
23 gitStatus: "git.status",
24 gitRunStackedAction: "git.runStackedAction",
23 gitListBranches: "git.listBranches",25 gitListBranches: "git.listBranches",
24 gitCreateWorktree: "git.createWorktree",26 gitCreateWorktree: "git.createWorktree",
25 gitRemoveWorktree: "git.removeWorktree",27 gitRemoveWorktree: "git.removeWorktree",