| author | |
| committer | |
| log | 44f3b3cb7c82c6a2cfd33dad92bb76260f9e32ff |
| tree | 669d65674e0bbfb6fdee4ab5a20faaf76b995725 |
| parent | 36cdceb4bff336dbb53ad638806e4e1a88043b73 |
| parent | dd920846b9195275e6d8605a0f61cf8786cfa469 |
| signature | Signed by PGP key B5690EEEBB952194 |
30 files changed, 3667 insertions(+), 289 deletions(-)
apps/desktop/src/main.ts+27-1| ... | ... | @@ -5,7 +5,7 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron"; | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; | |
| 9 | 9 | |
| 10 | 10 | import { fixPath } from "./fixPath"; |
| 11 | 11 | |
| ... | ... | @@ -13,6 +13,7 @@ fixPath(); |
| 13 | 13 | |
| 14 | 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 15 | 15 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 16 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | |
| 16 | 17 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 17 | 18 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); |
| 18 | 19 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| ... | ... | @@ -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 | } |
| 163 | 189 | |
| 164 | 190 | function createWindow(): BrowserWindow { |
apps/desktop/src/preload.ts+2| ... | ... | @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron"; |
| 2 | 2 | |
| 3 | 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | 4 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | |
| 5 | 6 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 6 | 7 | |
| 7 | 8 | contextBridge.exposeInMainWorld("desktopBridge", { |
| ... | ... | @@ -9,4 +10,5 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 9 | 10 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, |
| 10 | 11 | showContextMenu: (items: { id: string; label: string }[]) => |
| 11 | 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 @@ |
| 1 | import fs from "node:fs/promises"; | |
| 2 | ||
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { CodexTextGenerator } from "./codexTextGenerator"; | |
| 6 | import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; | |
| 7 | ||
| 8 | type ProcessRunner = ( | |
| 9 | command: string, | |
| 10 | args: readonly string[], | |
| 11 | options?: ProcessRunOptions, | |
| 12 | ) => Promise<ProcessRunResult>; | |
| 13 | ||
| 14 | function 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 | ||
| 26 | function okResult(): ProcessRunResult { | |
| 27 | return { | |
| 28 | stdout: "", | |
| 29 | stderr: "", | |
| 30 | code: 0, | |
| 31 | signal: null, | |
| 32 | timedOut: false, | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | function 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 | ||
| 45 | describe("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 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import type { | |
| 7 | CommitMessageGenerationInput, | |
| 8 | CommitMessageGenerationResult, | |
| 9 | PrContentGenerationInput, | |
| 10 | PrContentGenerationResult, | |
| 11 | TextGenerationService, | |
| 12 | } from "./coreServices"; | |
| 13 | import { | |
| 14 | type ProcessRunOptions, | |
| 15 | type ProcessRunResult, | |
| 16 | runProcess, | |
| 17 | } from "./processRunner"; | |
| 18 | ||
| 19 | type ProcessRunner = ( | |
| 20 | command: string, | |
| 21 | args: readonly string[], | |
| 22 | options?: ProcessRunOptions, | |
| 23 | ) => Promise<ProcessRunResult>; | |
| 24 | ||
| 25 | const CODEX_MODEL = "gpt-5.3-codex-spark"; | |
| 26 | const CODEX_REASONING_EFFORT = "medium"; | |
| 27 | ||
| 28 | const 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 | ||
| 38 | const 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 | ||
| 48 | function 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 | ||
| 61 | function 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 | ||
| 74 | function 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 | ||
| 80 | async 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 | ||
| 89 | async function safeUnlink(filePath: string): Promise<void> { | |
| 90 | try { | |
| 91 | await fs.unlink(filePath); | |
| 92 | } catch { | |
| 93 | // Best-effort cleanup. | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | function 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 | ||
| 110 | function 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 | ||
| 118 | async 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 | ||
| 184 | interface CodexTextGeneratorDeps { | |
| 185 | runProcess?: ProcessRunner; | |
| 186 | } | |
| 187 | ||
| 188 | export 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 @@ |
| 1 | export interface CommitMessageGenerationInput { | |
| 2 | cwd: string; | |
| 3 | branch: string | null; | |
| 4 | stagedSummary: string; | |
| 5 | stagedPatch: string; | |
| 6 | } | |
| 7 | ||
| 8 | export interface CommitMessageGenerationResult { | |
| 9 | subject: string; | |
| 10 | body: string; | |
| 11 | } | |
| 12 | ||
| 13 | export interface PrContentGenerationInput { | |
| 14 | cwd: string; | |
| 15 | baseBranch: string; | |
| 16 | headBranch: string; | |
| 17 | commitSummary: string; | |
| 18 | diffSummary: string; | |
| 19 | diffPatch: string; | |
| 20 | } | |
| 21 | ||
| 22 | export interface PrContentGenerationResult { | |
| 23 | title: string; | |
| 24 | body: string; | |
| 25 | } | |
| 26 | ||
| 27 | export 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 | 5 | import { describe, expect, it } from "vitest"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | GitCoreService, | |
| 8 | 9 | checkoutGitBranch, |
| 9 | 10 | createGitBranch, |
| 10 | 11 | createGitWorktree, |
| ... | ... | @@ -435,4 +436,85 @@ describe("git integration", () => { |
| 435 | 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 | import { spawn } from "node:child_process"; | |
| 2 | 1 | import fs from "node:fs"; |
| 3 | 2 | import os from "node:os"; |
| 4 | 3 | import path from "node:path"; |
| 5 | 4 | |
| 6 | import type { | |
| 7 | GitCheckoutInput, | |
| 8 | GitCreateBranchInput, | |
| 9 | GitCreateWorktreeInput, | |
| 10 | GitCreateWorktreeResult, | |
| 11 | GitInitInput, | |
| 12 | GitListBranchesInput, | |
| 13 | GitListBranchesResult, | |
| 14 | GitRemoveWorktreeInput, | |
| 5 | import { | |
| 6 | gitStatusInputSchema, | |
| 7 | gitStatusResultSchema, | |
| 8 | type GitCheckoutInput, | |
| 9 | type GitCreateBranchInput, | |
| 10 | type GitCreateWorktreeInput, | |
| 11 | type GitCreateWorktreeResult, | |
| 12 | type GitInitInput, | |
| 13 | type GitListBranchesInput, | |
| 14 | type GitListBranchesResult, | |
| 15 | type GitRemoveWorktreeInput, | |
| 16 | type GitStatusInput, | |
| 17 | type GitStatusResult, | |
| 15 | 18 | } from "@t3tools/contracts"; |
| 19 | import { type ProcessRunResult, runProcess } from "./processRunner"; | |
| 16 | 20 | |
| 17 | 21 | export interface TerminalCommandInput { |
| 18 | 22 | command: string; |
| ... | ... | @@ -21,95 +25,419 @@ export interface TerminalCommandInput { |
| 21 | 25 | maxOutputBytes?: number; |
| 22 | 26 | } |
| 23 | 27 | |
| 24 | export interface TerminalCommandResult { | |
| 25 | stdout: string; | |
| 26 | stderr: string; | |
| 27 | code: number | null; | |
| 28 | signal: NodeJS.Signals | null; | |
| 29 | timedOut: boolean; | |
| 28 | export type TerminalCommandResult = ProcessRunResult; | |
| 29 | ||
| 30 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | |
| 31 | upstreamRef: string | null; | |
| 32 | } | |
| 33 | ||
| 34 | export interface GitPreparedCommitContext { | |
| 35 | stagedSummary: string; | |
| 36 | stagedPatch: string; | |
| 37 | } | |
| 38 | ||
| 39 | export interface GitPushResult { | |
| 40 | status: "pushed" | "skipped_up_to_date"; | |
| 41 | branch: string; | |
| 42 | upstreamBranch?: string | undefined; | |
| 43 | setUpstream?: boolean | undefined; | |
| 44 | } | |
| 45 | ||
| 46 | export interface GitRangeContext { | |
| 47 | commitSummary: string; | |
| 48 | diffSummary: string; | |
| 49 | diffPatch: string; | |
| 50 | } | |
| 51 | ||
| 52 | interface RunGitOptions { | |
| 53 | timeoutMs?: number | undefined; | |
| 54 | allowNonZeroExit?: boolean | undefined; | |
| 55 | } | |
| 56 | ||
| 57 | interface ExecuteGitOptions { | |
| 58 | timeoutMs?: number | undefined; | |
| 59 | allowNonZeroExit?: boolean | undefined; | |
| 60 | fallbackErrorMessage?: string | undefined; | |
| 30 | 61 | } |
| 31 | 62 | |
| 32 | 63 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| 33 | 64 | |
| 34 | function appendChunkWithinLimit( | |
| 35 | target: string, | |
| 36 | currentBytes: number, | |
| 37 | chunk: Buffer, | |
| 38 | maxBytes: number, | |
| 39 | ): { | |
| 40 | next: string; | |
| 41 | nextBytes: number; | |
| 42 | truncated: boolean; | |
| 43 | } { | |
| 44 | const remaining = maxBytes - currentBytes; | |
| 45 | if (remaining <= 0) { | |
| 46 | return { next: target, nextBytes: currentBytes, truncated: true }; | |
| 65 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | |
| 66 | function runGit( | |
| 67 | args: readonly string[], | |
| 68 | cwd: string, | |
| 69 | timeoutMs = 30_000, | |
| 70 | ): Promise<ProcessRunResult> { | |
| 71 | return runProcess("git", args, { | |
| 72 | cwd, | |
| 73 | timeoutMs, | |
| 74 | allowNonZeroExit: true, | |
| 75 | maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, | |
| 76 | outputMode: "truncate", | |
| 77 | }); | |
| 78 | } | |
| 79 | ||
| 80 | function trimStdout(value: string): string { | |
| 81 | return value.trim(); | |
| 82 | } | |
| 83 | ||
| 84 | function 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 | ||
| 93 | function commandLabel(args: readonly string[]): string { | |
| 94 | return `git ${args.join(" ")}`; | |
| 95 | } | |
| 96 | ||
| 97 | function 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 | ||
| 110 | function 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 | ||
| 123 | async 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 | ||
| 144 | async 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 | ||
| 168 | export 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 | 214 | return { |
| 50 | next: `${target}${chunk.toString()}`, | |
| 51 | nextBytes: currentBytes + chunk.length, | |
| 52 | truncated: false, | |
| 215 | branch, | |
| 216 | upstreamRef, | |
| 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 | } | |
| 61 | 223 | |
| 62 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | |
| 63 | function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> { | |
| 64 | return new Promise((resolve, reject) => { | |
| 65 | const child = spawn("git", args, { | |
| 66 | cwd, | |
| 67 | env: process.env, | |
| 68 | stdio: ["ignore", "pipe", "pipe"], | |
| 224 | async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> { | |
| 225 | await this.git(cwd, ["add", "-A"]); | |
| 226 | ||
| 227 | const stagedSummary = await this.gitStdout(cwd, [ | |
| 228 | "diff", | |
| 229 | "--cached", | |
| 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 | }); |
| 70 | 320 | |
| 71 | const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES; | |
| 72 | let stdout = ""; | |
| 73 | let stderr = ""; | |
| 74 | let stdoutBytes = 0; | |
| 75 | let stderrBytes = 0; | |
| 76 | let timedOut = false; | |
| 77 | let outputTruncated = false; | |
| 78 | ||
| 79 | const timeout = setTimeout(() => { | |
| 80 | timedOut = true; | |
| 81 | child.kill("SIGTERM"); | |
| 82 | setTimeout(() => { | |
| 83 | if (!child.killed) child.kill("SIGKILL"); | |
| 84 | }, 1_000).unref(); | |
| 85 | }, timeoutMs); | |
| 86 | ||
| 87 | child.stdout?.on("data", (chunk: Buffer) => { | |
| 88 | const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes); | |
| 89 | stdout = appended.next; | |
| 90 | stdoutBytes = appended.nextBytes; | |
| 91 | outputTruncated = outputTruncated || appended.truncated; | |
| 321 | if (result.code !== 0) { | |
| 322 | const stderr = result.stderr.trim(); | |
| 323 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 324 | return { branches: [], isRepo: false }; | |
| 325 | } | |
| 326 | throw new Error(stderr || "git branch failed"); | |
| 327 | } | |
| 328 | ||
| 329 | const [defaultRef, worktreeList] = await Promise.all([ | |
| 330 | executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { | |
| 331 | timeoutMs: 5_000, | |
| 332 | allowNonZeroExit: true, | |
| 333 | }), | |
| 334 | executeGit(input.cwd, ["worktree", "list", "--porcelain"], { | |
| 335 | timeoutMs: 5_000, | |
| 336 | allowNonZeroExit: true, | |
| 337 | }), | |
| 338 | ]); | |
| 339 | const defaultBranch = | |
| 340 | defaultRef.code === 0 | |
| 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) => { | |
| 94 | const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes); | |
| 95 | stderr = appended.next; | |
| 96 | stderrBytes = appended.nextBytes; | |
| 97 | outputTruncated = outputTruncated || appended.truncated; | |
| 406 | } | |
| 407 | ||
| 408 | async createBranch(input: GitCreateBranchInput): Promise<void> { | |
| 409 | await executeGit(input.cwd, ["branch", input.branch], { | |
| 410 | timeoutMs: 10_000, | |
| 411 | fallbackErrorMessage: "git branch create failed", | |
| 98 | 412 | }); |
| 99 | child.on("error", (error) => { | |
| 100 | clearTimeout(timeout); | |
| 101 | reject(error); | |
| 413 | } | |
| 414 | ||
| 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) => { | |
| 104 | clearTimeout(timeout); | |
| 105 | if (outputTruncated) { | |
| 106 | stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`; | |
| 107 | } | |
| 108 | resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut }); | |
| 420 | } | |
| 421 | ||
| 422 | async initRepo(input: GitInitInput): Promise<void> { | |
| 423 | await executeGit(input.cwd, ["init"], { | |
| 424 | timeoutMs: 10_000, | |
| 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 | } |
| 112 | 438 | |
| 439 | const defaultGitCoreService = new GitCoreService(); | |
| 440 | ||
| 113 | 441 | export async function runTerminalCommand( |
| 114 | 442 | input: TerminalCommandInput, |
| 115 | 443 | ): Promise<TerminalCommandResult> { |
| ... | ... | @@ -122,177 +450,46 @@ export async function runTerminalCommand( |
| 122 | 450 | const args = |
| 123 | 451 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; |
| 124 | 452 | |
| 125 | return new Promise((resolve, reject) => { | |
| 126 | const child = spawn(shellPath, args, { | |
| 127 | cwd: input.cwd, | |
| 128 | env: process.env, | |
| 129 | stdio: ["ignore", "pipe", "pipe"], | |
| 130 | }); | |
| 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 | }); | |
| 453 | const result = await runProcess(shellPath, args, { | |
| 454 | cwd: input.cwd, | |
| 455 | timeoutMs: input.timeoutMs ?? 30_000, | |
| 456 | allowNonZeroExit: true, | |
| 457 | maxBufferBytes: maxOutputBytes, | |
| 458 | outputMode: "truncate", | |
| 181 | 459 | }); |
| 182 | } | |
| 183 | ||
| 184 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | |
| 185 | const result = await runGit(["branch", "--no-color"], input.cwd, 10_000); | |
| 186 | 460 | |
| 187 | if (result.code !== 0) { | |
| 188 | const stderr = result.stderr.trim(); | |
| 189 | if (stderr.includes("not a git repository")) { | |
| 190 | return { branches: [], isRepo: false }; | |
| 191 | } | |
| 192 | throw new Error(stderr || "git branch failed"); | |
| 461 | if (!result.stdoutTruncated && !result.stderrTruncated) { | |
| 462 | return result; | |
| 193 | 463 | } |
| 194 | 464 | |
| 195 | // Resolve the real default branch from the remote | |
| 196 | const [defaultRef, worktreeList] = await Promise.all([ | |
| 197 | runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000), | |
| 198 | runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000), | |
| 199 | ]); | |
| 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 | }); | |
| 465 | return { | |
| 466 | ...result, | |
| 467 | stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`, | |
| 468 | }; | |
| 469 | } | |
| 238 | 470 | |
| 239 | return { branches, isRepo: true }; | |
| 471 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | |
| 472 | return defaultGitCoreService.listBranches(input); | |
| 240 | 473 | } |
| 241 | 474 | |
| 242 | 475 | export async function createGitWorktree( |
| 243 | 476 | input: GitCreateWorktreeInput, |
| 244 | 477 | ): Promise<GitCreateWorktreeResult> { |
| 245 | const sanitizedBranch = input.newBranch.replace(/\//g, "-"); | |
| 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 | }; | |
| 478 | return defaultGitCoreService.createWorktree(input); | |
| 266 | 479 | } |
| 267 | 480 | |
| 268 | 481 | export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> { |
| 269 | const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000); | |
| 270 | ||
| 271 | if (result.code !== 0) { | |
| 272 | throw new Error(result.stderr.trim() || "git worktree remove failed"); | |
| 273 | } | |
| 482 | await defaultGitCoreService.removeWorktree(input); | |
| 274 | 483 | } |
| 275 | 484 | |
| 276 | 485 | export async function createGitBranch(input: GitCreateBranchInput): Promise<void> { |
| 277 | const result = await runGit(["branch", input.branch], input.cwd, 10_000); | |
| 278 | ||
| 279 | if (result.code !== 0) { | |
| 280 | throw new Error(result.stderr.trim() || "git branch create failed"); | |
| 281 | } | |
| 486 | await defaultGitCoreService.createBranch(input); | |
| 282 | 487 | } |
| 283 | 488 | |
| 284 | 489 | export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> { |
| 285 | const result = await runGit(["checkout", input.branch], input.cwd, 10_000); | |
| 286 | ||
| 287 | if (result.code !== 0) { | |
| 288 | throw new Error(result.stderr.trim() || "git checkout failed"); | |
| 289 | } | |
| 490 | await defaultGitCoreService.checkoutBranch(input); | |
| 290 | 491 | } |
| 291 | 492 | |
| 292 | 493 | export async function initGitRepo(input: GitInitInput): Promise<void> { |
| 293 | const result = await runGit(["init"], input.cwd, 10_000); | |
| 294 | ||
| 295 | if (result.code !== 0) { | |
| 296 | throw new Error(result.stderr.trim() || "git init failed"); | |
| 297 | } | |
| 494 | await defaultGitCoreService.initRepo(input); | |
| 298 | 495 | } |
apps/server/src/gitManager.test.ts created+490| ... | ... | @@ -0,0 +1,490 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { GitManager } from "./gitManager"; | |
| 8 | import { | |
| 9 | type ProcessRunOptions, | |
| 10 | type ProcessRunResult, | |
| 11 | runProcess, | |
| 12 | } from "./processRunner"; | |
| 13 | ||
| 14 | interface FakeGhScenario { | |
| 15 | prListSequence?: string[]; | |
| 16 | createdPrUrl?: string; | |
| 17 | defaultBranch?: string; | |
| 18 | failWith?: Error; | |
| 19 | } | |
| 20 | ||
| 21 | interface 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 | ||
| 38 | const tempDirs: string[] = []; | |
| 39 | ||
| 40 | function makeTempDir(prefix: string): string { | |
| 41 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 42 | tempDirs.push(dir); | |
| 43 | return dir; | |
| 44 | } | |
| 45 | ||
| 46 | async function runGit( | |
| 47 | cwd: string, | |
| 48 | args: readonly string[], | |
| 49 | allowNonZeroExit = false, | |
| 50 | ): Promise<ProcessRunResult> { | |
| 51 | return runProcess("git", args, { cwd, allowNonZeroExit }); | |
| 52 | } | |
| 53 | ||
| 54 | async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 55 | const result = await runGit(cwd, args); | |
| 56 | return result.stdout.trim(); | |
| 57 | } | |
| 58 | ||
| 59 | async 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 | ||
| 68 | async function createBareRemote(): Promise<string> { | |
| 69 | const remoteDir = makeTempDir("t3code-git-remote-"); | |
| 70 | await runProcess("git", ["init", "--bare"], { cwd: remoteDir }); | |
| 71 | return remoteDir; | |
| 72 | } | |
| 73 | ||
| 74 | function 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 | ||
| 90 | function 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 | ||
| 164 | afterEach(() => { | |
| 165 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 166 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 167 | } | |
| 168 | }); | |
| 169 | ||
| 170 | describe("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 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { | |
| 7 | gitRunStackedActionInputSchema, | |
| 8 | gitRunStackedActionResultSchema, | |
| 9 | gitStatusInputSchema, | |
| 10 | type GitRunStackedActionInput, | |
| 11 | type GitRunStackedActionResult, | |
| 12 | type GitStatusInput, | |
| 13 | type GitStatusResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | ||
| 16 | import type { | |
| 17 | CommitMessageGenerationResult, | |
| 18 | PrContentGenerationResult, | |
| 19 | TextGenerationService, | |
| 20 | } from "./coreServices"; | |
| 21 | import { CodexTextGenerator } from "./codexTextGenerator"; | |
| 22 | import { GitCoreService } from "./git"; | |
| 23 | import { | |
| 24 | type ProcessRunOptions, | |
| 25 | type ProcessRunResult, | |
| 26 | runProcess, | |
| 27 | } from "./processRunner"; | |
| 28 | ||
| 29 | const GH_CLI_TIMEOUT_MS = 30_000; | |
| 30 | ||
| 31 | interface GitManagerDeps { | |
| 32 | runProcess?: ( | |
| 33 | command: string, | |
| 34 | args: readonly string[], | |
| 35 | options?: ProcessRunOptions, | |
| 36 | ) => Promise<ProcessRunResult>; | |
| 37 | textGenerator?: TextGenerationService; | |
| 38 | gitCore?: GitCoreService; | |
| 39 | } | |
| 40 | ||
| 41 | interface OpenPrInfo { | |
| 42 | number: number; | |
| 43 | title: string; | |
| 44 | url: string; | |
| 45 | baseRefName: string; | |
| 46 | headRefName: string; | |
| 47 | } | |
| 48 | ||
| 49 | function 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 | ||
| 87 | function trimStdout(value: string): string { | |
| 88 | return value.trim(); | |
| 89 | } | |
| 90 | ||
| 91 | function limitContext(value: string, maxChars: number): string { | |
| 92 | if (value.length <= maxChars) return value; | |
| 93 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | |
| 94 | } | |
| 95 | ||
| 96 | function 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 | ||
| 109 | function 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 | ||
| 127 | function 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 | ||
| 146 | function 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 | ||
| 161 | function 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 | ||
| 175 | function 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 | ||
| 191 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { runProcess } from "./processRunner"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | ||
| 3 | export 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 | ||
| 13 | export 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 | ||
| 23 | function commandLabel(command: string, args: readonly string[]): string { | |
| 24 | return [command, ...args].join(" "); | |
| 25 | } | |
| 26 | ||
| 27 | function 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 | ||
| 46 | function 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 | ||
| 59 | function 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 | ||
| 72 | function 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 | ||
| 83 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; | |
| 84 | ||
| 85 | function 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 | ||
| 113 | export 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 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; | |
| 8 | ||
| 9 | function fileMode(filePath: string): number { | |
| 10 | return fs.statSync(filePath).mode & 0o777; | |
| 11 | } | |
| 12 | ||
| 13 | describe("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 | import fs from "node:fs"; | |
| 2 | import { createRequire } from "node:module"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import * as nodePty from "node-pty"; |
| 2 | 6 | |
| 3 | 7 | export interface PtyExitEvent { |
| ... | ... | @@ -26,6 +30,57 @@ export interface PtyAdapter { |
| 26 | 30 | spawn(input: PtySpawnInput): PtyProcess; |
| 27 | 31 | } |
| 28 | 32 | |
| 33 | const requireForNodePty = createRequire(import.meta.url); | |
| 34 | let didEnsureSpawnHelperExecutable = false; | |
| 35 | ||
| 36 | function 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 | ||
| 63 | export 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 | ||
| 29 | 84 | class NodePtyProcess implements PtyProcess { |
| 30 | 85 | constructor(private readonly process: nodePty.IPty) {} |
| 31 | 86 | |
| ... | ... | @@ -67,12 +122,16 @@ class NodePtyProcess implements PtyProcess { |
| 67 | 122 | |
| 68 | 123 | export class NodePtyAdapter implements PtyAdapter { |
| 69 | 124 | spawn(input: PtySpawnInput): PtyProcess { |
| 125 | ensureNodePtySpawnHelperExecutable(); | |
| 70 | 126 | const ptyProcess = nodePty.spawn(input.shell, [], { |
| 71 | 127 | cwd: input.cwd, |
| 72 | 128 | cols: input.cols, |
| 73 | 129 | rows: input.rows, |
| 74 | 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 | 136 | return new NodePtyProcess(ptyProcess); |
| 78 | 137 | } |
apps/server/src/terminalManager.test.ts+41-2| ... | ... | @@ -62,10 +62,15 @@ class FakePtyProcess implements PtyProcess { |
| 62 | 62 | class FakePtyAdapter implements PtyAdapter { |
| 63 | 63 | readonly spawnInputs: PtySpawnInput[] = []; |
| 64 | 64 | readonly processes: FakePtyProcess[] = []; |
| 65 | readonly spawnFailures: Error[] = []; | |
| 65 | 66 | private nextPid = 9000; |
| 66 | 67 | |
| 67 | 68 | spawn(input: PtySpawnInput): PtyProcess { |
| 68 | 69 | this.spawnInputs.push(input); |
| 70 | const failure = this.spawnFailures.shift(); | |
| 71 | if (failure) { | |
| 72 | throw failure; | |
| 73 | } | |
| 69 | 74 | const process = new FakePtyProcess(this.nextPid++); |
| 70 | 75 | this.processes.push(process); |
| 71 | 76 | return process; |
| ... | ... | @@ -117,7 +122,10 @@ describe("TerminalManager", () => { |
| 117 | 122 | } |
| 118 | 123 | }); |
| 119 | 124 | |
| 120 | function makeManager(historyLineLimit = 5) { | |
| 125 | function makeManager( | |
| 126 | historyLineLimit = 5, | |
| 127 | options: { shellResolver?: () => string } = {}, | |
| 128 | ) { | |
| 121 | 129 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); |
| 122 | 130 | tempDirs.push(logsDir); |
| 123 | 131 | const ptyAdapter = new FakePtyAdapter(); |
| ... | ... | @@ -125,7 +133,7 @@ describe("TerminalManager", () => { |
| 125 | 133 | logsDir, |
| 126 | 134 | ptyAdapter, |
| 127 | 135 | historyLineLimit, |
| 128 | shellResolver: () => "/bin/bash", | |
| 136 | shellResolver: options.shellResolver ?? (() => "/bin/bash"), | |
| 129 | 137 | }); |
| 130 | 138 | return { logsDir, ptyAdapter, manager }; |
| 131 | 139 | } |
| ... | ... | @@ -266,4 +274,35 @@ describe("TerminalManager", () => { |
| 266 | 274 | |
| 267 | 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 | 57 | return process.env.SHELL ?? "bash"; |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | function 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 | ||
| 74 | function 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 | ||
| 86 | function 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 | ||
| 110 | function 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 | ||
| 60 | 122 | function capHistory(history: string, maxLines: number): string { |
| 61 | 123 | if (history.length === 0) return history; |
| 62 | 124 | const hasTrailingNewline = history.endsWith("\n"); |
| ... | ... | @@ -270,16 +332,43 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 270 | 332 | session.exitSignal = null; |
| 271 | 333 | session.updatedAt = new Date().toISOString(); |
| 272 | 334 | |
| 273 | const shell = this.shellResolver(); | |
| 274 | 335 | let ptyProcess: PtyProcess | null = null; |
| 336 | let startedShell: string | null = null; | |
| 275 | 337 | try { |
| 276 | ptyProcess = this.ptyAdapter.spawn({ | |
| 277 | shell, | |
| 278 | cwd: session.cwd, | |
| 279 | cols: session.cols, | |
| 280 | rows: session.rows, | |
| 281 | env: process.env, | |
| 282 | }); | |
| 338 | const shellCandidates = resolveShellCandidates(this.shellResolver); | |
| 339 | let lastSpawnError: unknown = null; | |
| 340 | ||
| 341 | for (const shell of shellCandidates) { | |
| 342 | try { | |
| 343 | ptyProcess = this.ptyAdapter.spawn({ | |
| 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 | 372 | session.process = ptyProcess; |
| 284 | 373 | session.pid = ptyProcess.pid; |
| 285 | 374 | session.status = "running"; |
| ... | ... | @@ -318,6 +407,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 318 | 407 | this.logger.error("failed to start terminal", { |
| 319 | 408 | threadId: session.threadId, |
| 320 | 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 | 180 | devUrl?: string; |
| 181 | 181 | authToken?: string; |
| 182 | 182 | stateDir?: string; |
| 183 | gitManager?: { | |
| 184 | status: (input: { cwd: string }) => Promise<unknown>; | |
| 185 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; | |
| 186 | }; | |
| 183 | 187 | terminalManager?: TerminalManager; |
| 184 | 188 | } = {}, |
| 185 | 189 | ): ReturnType<typeof createServer> { |
| ... | ... | @@ -190,6 +194,7 @@ describe("WebSocket Server", () => { |
| 190 | 194 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), |
| 191 | 195 | ...(options.authToken ? { authToken: options.authToken } : {}), |
| 192 | 196 | projectRegistry: new ProjectRegistry(stateDir), |
| 197 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | |
| 193 | 198 | ...(options.terminalManager |
| 194 | 199 | ? { terminalManager: options.terminalManager } |
| 195 | 200 | : {}), |
| ... | ... | @@ -511,6 +516,72 @@ describe("WebSocket Server", () => { |
| 511 | 516 | expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); |
| 512 | 517 | }); |
| 513 | 518 | |
| 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 | 585 | it("prunes missing projects on startup", async () => { |
| 515 | 586 | const stateDir = makeTempDir("t3code-ws-prune-state-"); |
| 516 | 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 | 20 | import { createLogger } from "./logger"; |
| 21 | 21 | import { ProjectRegistry } from "./projectRegistry"; |
| 22 | 22 | import { ProviderManager } from "./providerManager"; |
| 23 | import { GitManager } from "./gitManager"; | |
| 23 | 24 | import { |
| 24 | 25 | checkoutGitBranch, |
| 25 | 26 | createGitBranch, |
| ... | ... | @@ -53,6 +54,7 @@ export interface ServerOptions { |
| 53 | 54 | devUrl?: string | undefined; |
| 54 | 55 | logWebSocketEvents?: boolean | undefined; |
| 55 | 56 | projectRegistry?: ProjectRegistry | undefined; |
| 57 | gitManager?: GitManager | undefined; | |
| 56 | 58 | terminalManager?: TerminalManager | undefined; |
| 57 | 59 | authToken?: string | undefined; |
| 58 | 60 | } |
| ... | ... | @@ -74,6 +76,7 @@ export function createServer(options: ServerOptions) { |
| 74 | 76 | devUrl, |
| 75 | 77 | logWebSocketEvents: explicitLogWsEvents, |
| 76 | 78 | projectRegistry: providedRegistry, |
| 79 | gitManager: providedGitManager, | |
| 77 | 80 | terminalManager: providedTerminalManager, |
| 78 | 81 | authToken, |
| 79 | 82 | } = options; |
| ... | ... | @@ -81,6 +84,7 @@ export function createServer(options: ServerOptions) { |
| 81 | 84 | const terminalManager = providedTerminalManager ?? new TerminalManager(); |
| 82 | 85 | const projectRegistry = |
| 83 | 86 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); |
| 87 | const gitManager = providedGitManager ?? new GitManager(); | |
| 84 | 88 | const clients = new Set<WebSocket>(); |
| 85 | 89 | const logger = createLogger("ws"); |
| 86 | 90 | const logWebSocketEvents = |
| ... | ... | @@ -335,6 +339,11 @@ export function createServer(options: ServerOptions) { |
| 335 | 339 | return undefined; |
| 336 | 340 | } |
| 337 | 341 | |
| 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 | 347 | case WS_METHODS.gitListBranches: |
| 339 | 348 | return listGitBranches(request.params as never); |
| 340 | 349 |
apps/web/src/App.tsx+5-5| ... | ... | @@ -1,18 +1,18 @@ |
| 1 | 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 2 | import { useEffect, useMemo, useRef } from "react"; | |
| 2 | import { useEffect, useRef } from "react"; | |
| 3 | 3 | |
| 4 | 4 | import ChatView from "./components/ChatView"; |
| 5 | 5 | import DiffPanel from "./components/DiffPanel"; |
| 6 | 6 | import Sidebar from "./components/Sidebar"; |
| 7 | 7 | import { isElectron } from "./env"; |
| 8 | 8 | import { DEFAULT_MODEL } from "./model-logic"; |
| 9 | import { readNativeApi } from "./session-logic"; | |
| 10 | 9 | import { StoreProvider, useStore } from "./store"; |
| 11 | 10 | import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; |
| 12 | 11 | import { onServerWelcome } from "./wsNativeApi"; |
| 12 | import { useNativeApi } from "./hooks/useNativeApi"; | |
| 13 | 13 | |
| 14 | 14 | function EventRouter() { |
| 15 | const api = useMemo(() => readNativeApi(), []); | |
| 15 | const api = useNativeApi(); | |
| 16 | 16 | const { dispatch } = useStore(); |
| 17 | 17 | const activeAssistantItemRef = useRef<string | null>(null); |
| 18 | 18 | |
| ... | ... | @@ -109,7 +109,7 @@ function AutoProjectBootstrap() { |
| 109 | 109 | } |
| 110 | 110 | |
| 111 | 111 | function DesktopProjectBootstrap() { |
| 112 | const api = useMemo(() => readNativeApi(), []); | |
| 112 | const api = useNativeApi(); | |
| 113 | 113 | const { dispatch } = useStore(); |
| 114 | 114 | const bootstrappedRef = useRef(false); |
| 115 | 115 | |
| ... | ... | @@ -159,7 +159,7 @@ function DesktopProjectBootstrap() { |
| 159 | 159 | } |
| 160 | 160 | |
| 161 | 161 | function Layout() { |
| 162 | const api = useMemo(() => readNativeApi(), []); | |
| 162 | const api = useNativeApi(); | |
| 163 | 163 | const { state } = useStore(); |
| 164 | 164 | |
| 165 | 165 | if (!api) { |
apps/web/src/components/BranchToolbar.tsx+8-4| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | import { useEffect, useRef, useState } from "react"; | |
| 4 | 4 | |
| 5 | import { readNativeApi } from "../session-logic"; | |
| 6 | 5 | import { useStore } from "../store"; |
| 6 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 7 | 7 | |
| 8 | 8 | interface BranchToolbarProps { |
| 9 | 9 | envMode: "local" | "worktree"; |
| ... | ... | @@ -13,7 +13,7 @@ interface BranchToolbarProps { |
| 13 | 13 | |
| 14 | 14 | export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { |
| 15 | 15 | const { state, dispatch } = useStore(); |
| 16 | const api = useMemo(() => readNativeApi(), []); | |
| 16 | const api = useNativeApi(); | |
| 17 | 17 | const queryClient = useQueryClient(); |
| 18 | 18 | |
| 19 | 19 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| ... | ... | @@ -254,7 +254,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 254 | 254 | <span className="truncate">{branch.name}</span> |
| 255 | 255 | {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( |
| 256 | 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 | 262 | </span> |
| 259 | 263 | )} |
| 260 | 264 | </button> |
apps/web/src/components/ChatView.tsx+13-25| ... | ... | @@ -31,13 +31,14 @@ import { |
| 31 | 31 | formatDuration, |
| 32 | 32 | formatElapsed, |
| 33 | 33 | formatTimestamp, |
| 34 | readNativeApi, | |
| 35 | 34 | } from "../session-logic"; |
| 36 | 35 | import { useStore } from "../store"; |
| 37 | 36 | import BranchToolbar from "./BranchToolbar"; |
| 37 | import GitActionsControl from "./GitActionsControl"; | |
| 38 | 38 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; |
| 39 | 39 | import ChatMarkdown from "./ChatMarkdown"; |
| 40 | 40 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 41 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 41 | 42 | |
| 42 | 43 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 43 | 44 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -132,7 +133,7 @@ function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] |
| 132 | 133 | |
| 133 | 134 | export default function ChatView() { |
| 134 | 135 | const { state, dispatch } = useStore(); |
| 135 | const api = useMemo(() => readNativeApi(), []); | |
| 136 | const api = useNativeApi(); | |
| 136 | 137 | const [prompt, setPrompt] = useState(""); |
| 137 | 138 | const [isSending, setIsSending] = useState(false); |
| 138 | 139 | const [isConnecting, setIsConnecting] = useState(false); |
| ... | ... | @@ -256,14 +257,14 @@ export default function ChatView() { |
| 256 | 257 | approvalPolicy: "on-request", |
| 257 | 258 | sandboxMode: "workspace-write", |
| 258 | 259 | } as const); |
| 260 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | |
| 261 | ||
| 259 | 262 | const envLocked = Boolean( |
| 260 | 263 | activeThread && |
| 261 | 264 | (activeThread.messages.length > 0 || |
| 262 | 265 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 263 | 266 | ); |
| 264 | const terminalShortcutHint = navigator.platform.includes("Mac") | |
| 265 | ? "\u2318J" | |
| 266 | : "Ctrl+J"; | |
| 267 | const terminalShortcutHint = navigator.platform.includes("Mac") ? "\u2318J" : "Ctrl+J"; | |
| 267 | 268 | const focusComposer = useCallback(() => { |
| 268 | 269 | const textarea = textareaRef.current; |
| 269 | 270 | if (!textarea) return; |
| ... | ... | @@ -319,6 +320,7 @@ export default function ChatView() { |
| 319 | 320 | }, [activeThread?.id]); |
| 320 | 321 | |
| 321 | 322 | const activeWorktreePath = activeThread?.worktreePath; |
| 323 | ||
| 322 | 324 | useEffect(() => { |
| 323 | 325 | if (!activeThread?.id) return; |
| 324 | 326 | setEnvMode(activeWorktreePath ? "worktree" : "local"); |
| ... | ... | @@ -652,12 +654,6 @@ export default function ChatView() { |
| 652 | 654 | {activeProject.name} |
| 653 | 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 | 657 | </div> |
| 662 | 658 | <div className="flex items-center gap-3"> |
| 663 | 659 | {/* Open in editor */} |
| ... | ... | @@ -691,18 +687,10 @@ export default function ChatView() { |
| 691 | 687 | )} |
| 692 | 688 | </div> |
| 693 | 689 | )} |
| 690 | {/* Git actions */} | |
| 691 | {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />} | |
| 692 | ||
| 694 | 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 | 694 | <button |
| 707 | 695 | type="button" |
| 708 | 696 | className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${ |
| ... | ... | @@ -757,7 +745,7 @@ export default function ChatView() { |
| 757 | 745 | </button> |
| 758 | 746 | <button |
| 759 | 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 | 749 | disabled={isResponding} |
| 762 | 750 | onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} |
| 763 | 751 | > |
| ... | ... | @@ -773,7 +761,7 @@ export default function ChatView() { |
| 773 | 761 | </button> |
| 774 | 762 | <button |
| 775 | 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 | 765 | disabled={isResponding} |
| 778 | 766 | onClick={() => void onRespondToApproval(approval.requestId, "cancel")} |
| 779 | 767 | > |
| ... | ... | @@ -892,7 +880,7 @@ export default function ChatView() { |
| 892 | 880 | <Fragment key={timelineEntry.id}> |
| 893 | 881 | <div className="flex justify-end"> |
| 894 | 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 | 884 | {timelineEntry.message.text} |
| 897 | 885 | </pre> |
| 898 | 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 @@ |
| 1 | import { | |
| 2 | type GitRunStackedActionResult, | |
| 3 | type GitStackedAction, | |
| 4 | type GitStatusResult, | |
| 5 | type NativeApi, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 8 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 9 | import { | |
| 10 | CheckIcon, | |
| 11 | CircleIcon, | |
| 12 | CloudUploadIcon, | |
| 13 | GitCommitIcon, | |
| 14 | Loader2Icon, | |
| 15 | MinusIcon, | |
| 16 | XIcon, | |
| 17 | } from "lucide-react"; | |
| 18 | import { GitHubIcon } from "./Icons"; | |
| 19 | ||
| 20 | interface GitActionsControlProps { | |
| 21 | api: NativeApi | undefined; | |
| 22 | gitCwd: string | null; | |
| 23 | } | |
| 24 | ||
| 25 | type GitActionIconName = "commit" | "push" | "pr"; | |
| 26 | ||
| 27 | interface 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 | ||
| 36 | interface GitModalActionOption { | |
| 37 | action: GitStackedAction; | |
| 38 | label: string; | |
| 39 | disabled: boolean; | |
| 40 | icon: GitActionIconName; | |
| 41 | detail?: string; | |
| 42 | } | |
| 43 | ||
| 44 | type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed"; | |
| 45 | ||
| 46 | interface GitProgressStep { | |
| 47 | id: "generate" | "commit" | "push" | "pr"; | |
| 48 | label: string; | |
| 49 | status: GitProgressStepStatus; | |
| 50 | detail?: string; | |
| 51 | } | |
| 52 | ||
| 53 | function 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 | ||
| 67 | function gitActionModalTitle(): string { | |
| 68 | return "Commit your changes"; | |
| 69 | } | |
| 70 | ||
| 71 | function 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 | ||
| 111 | function 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 | ||
| 127 | function 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 | ||
| 133 | function 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 | ||
| 189 | function 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 | ||
| 224 | function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { | |
| 225 | return ["git", "status", gitCwd]; | |
| 226 | } | |
| 227 | ||
| 228 | function 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 | ||
| 241 | export 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 @@ |
| 1 | import type { SVGProps } from "react"; | |
| 2 | ||
| 3 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; | |
| 4 | ||
| 5 | export 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 | 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 2 | import { useCallback, useEffect, useState } from "react"; | |
| 3 | 3 | import { isElectron } from "../env"; |
| 4 | 4 | import { useTheme } from "../hooks/useTheme"; |
| 5 | 5 | import { DEFAULT_MODEL } from "../model-logic"; |
| 6 | import { readNativeApi } from "../session-logic"; | |
| 7 | 6 | import { useStore } from "../store"; |
| 8 | 7 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types"; |
| 8 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 9 | 9 | |
| 10 | 10 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| 11 | 11 | function formatRelativeTime(iso: string): string { |
| ... | ... | @@ -35,7 +35,7 @@ function threadStatusLabel( |
| 35 | 35 | |
| 36 | 36 | export default function Sidebar() { |
| 37 | 37 | const { state, dispatch } = useStore(); |
| 38 | const api = useMemo(() => readNativeApi(), []); | |
| 38 | const api = useNativeApi(); | |
| 39 | 39 | const { theme, setTheme } = useTheme(); |
| 40 | 40 | const [addingProject, setAddingProject] = useState(false); |
| 41 | 41 | const [newCwd, setNewCwd] = useState(""); |
apps/web/src/hooks/useNativeApi.ts created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | import { readNativeApi } from "../session-logic"; | |
| 3 | ||
| 4 | export function useNativeApi() { | |
| 5 | return useMemo(() => readNativeApi(), []); | |
| 6 | } |
apps/web/src/vite-env.d.ts+1| ... | ... | @@ -6,6 +6,7 @@ interface DesktopBridge { |
| 6 | 6 | getWsUrl: () => string | null; |
| 7 | 7 | pickFolder: () => Promise<string | null>; |
| 8 | 8 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; |
| 9 | openExternal: (url: string) => Promise<boolean>; | |
| 9 | 10 | } |
| 10 | 11 | |
| 11 | 12 | declare global { |
apps/web/src/wsNativeApi.ts+16| ... | ... | @@ -96,8 +96,24 @@ export function createWsNativeApi(): NativeApi { |
| 96 | 96 | shell: { |
| 97 | 97 | openInEditor: (cwd, editor) => |
| 98 | 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 | 114 | git: { |
| 115 | status: (input) => transport.request(WS_METHODS.gitStatus, input), | |
| 116 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), | |
| 101 | 117 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 102 | 118 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 103 | 119 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
packages/contracts/src/git.test.ts created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitRunStackedActionInputSchema, | |
| 5 | gitRunStackedActionResultSchema, | |
| 6 | gitStackedActionSchema, | |
| 7 | gitStatusInputSchema, | |
| 8 | gitStatusResultSchema, | |
| 9 | } from "./git"; | |
| 10 | ||
| 11 | describe("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 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | // ── Input schemas ── | |
| 3 | // ── Existing branch/worktree Git API ───────────────────────────────── | |
| 4 | 4 | |
| 5 | 5 | export const gitListBranchesInputSchema = z.object({ |
| 6 | 6 | cwd: z.string().min(1), |
| ... | ... | @@ -8,9 +8,7 @@ export const gitListBranchesInputSchema = z.object({ |
| 8 | 8 | |
| 9 | 9 | export const gitCreateWorktreeInputSchema = z.object({ |
| 10 | 10 | cwd: z.string().min(1), |
| 11 | /** Base branch to create the worktree from. */ | |
| 12 | 11 | branch: z.string().min(1), |
| 13 | /** New branch name to create in the worktree. */ | |
| 14 | 12 | newBranch: z.string().min(1), |
| 15 | 13 | path: z.string().min(1).optional(), |
| 16 | 14 | }); |
| ... | ... | @@ -34,8 +32,6 @@ export const gitInitInputSchema = z.object({ |
| 34 | 32 | cwd: z.string().min(1), |
| 35 | 33 | }); |
| 36 | 34 | |
| 37 | // ── Output schemas ── | |
| 38 | ||
| 39 | 35 | export const gitBranchSchema = z.object({ |
| 40 | 36 | name: z.string().min(1), |
| 41 | 37 | current: z.boolean(), |
| ... | ... | @@ -48,8 +44,6 @@ export const gitWorktreeSchema = z.object({ |
| 48 | 44 | branch: z.string().min(1), |
| 49 | 45 | }); |
| 50 | 46 | |
| 51 | // ── Types ── | |
| 52 | ||
| 53 | 47 | export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>; |
| 54 | 48 | export type GitBranch = z.infer<typeof gitBranchSchema>; |
| 55 | 49 | export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>; |
| ... | ... | @@ -67,3 +61,79 @@ export interface GitListBranchesResult { |
| 67 | 61 | export interface GitCreateWorktreeResult { |
| 68 | 62 | worktree: GitWorktree; |
| 69 | 63 | } |
| 64 | ||
| 65 | // ── Stacked action Git API ─────────────────────────────────────────── | |
| 66 | ||
| 67 | export const gitStatusInputSchema = z.object({ | |
| 68 | cwd: z.string().trim().min(1), | |
| 69 | }); | |
| 70 | ||
| 71 | export 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 | ||
| 88 | export const gitStackedActionSchema = z.enum([ | |
| 89 | "commit", | |
| 90 | "commit_push", | |
| 91 | "commit_push_pr", | |
| 92 | ]); | |
| 93 | ||
| 94 | const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]); | |
| 95 | const gitPushStepStatusSchema = z.enum([ | |
| 96 | "pushed", | |
| 97 | "skipped_not_requested", | |
| 98 | "skipped_up_to_date", | |
| 99 | ]); | |
| 100 | const gitPrStepStatusSchema = z.enum([ | |
| 101 | "created", | |
| 102 | "opened_existing", | |
| 103 | "skipped_not_requested", | |
| 104 | ]); | |
| 105 | ||
| 106 | export 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 | ||
| 112 | export 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 | ||
| 135 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; | |
| 136 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; | |
| 137 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | |
| 138 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | |
| 139 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; |
packages/contracts/src/ipc.ts+11| ... | ... | @@ -8,6 +8,10 @@ import type { |
| 8 | 8 | GitListBranchesInput, |
| 9 | 9 | GitListBranchesResult, |
| 10 | 10 | GitRemoveWorktreeInput, |
| 11 | GitRunStackedActionInput, | |
| 12 | GitRunStackedActionResult, | |
| 13 | GitStatusInput, | |
| 14 | GitStatusResult, | |
| 11 | 15 | } from "./git"; |
| 12 | 16 | import type { |
| 13 | 17 | ProviderEvent, |
| ... | ... | @@ -85,14 +89,21 @@ export interface NativeApi { |
| 85 | 89 | }; |
| 86 | 90 | shell: { |
| 87 | 91 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| 92 | openExternal: (url: string) => Promise<void>; | |
| 88 | 93 | }; |
| 89 | 94 | git: { |
| 95 | // Existing branch/worktree API | |
| 90 | 96 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 91 | 97 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; |
| 92 | 98 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| 93 | 99 | createBranch: (input: GitCreateBranchInput) => Promise<void>; |
| 94 | 100 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| 95 | 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 | 108 | contextMenu: { |
| 98 | 109 | show: <T extends string>( |
packages/contracts/src/ws.ts+2| ... | ... | @@ -20,6 +20,8 @@ export const WS_METHODS = { |
| 20 | 20 | shellOpenInEditor: "shell.openInEditor", |
| 21 | 21 | |
| 22 | 22 | // Git methods |
| 23 | gitStatus: "git.status", | |
| 24 | gitRunStackedAction: "git.runStackedAction", | |
| 23 | 25 | gitListBranches: "git.listBranches", |
| 24 | 26 | gitCreateWorktree: "git.createWorktree", |
| 25 | 27 | gitRemoveWorktree: "git.removeWorktree", |