| author | |
| committer | |
| log | 533dbdfbdacc82edf06cbe8bf0ff12d4c04edfd4 |
| tree | 97951dc186d82217839b11cd79f5f7fb7d03eaf8 |
| parent | 98eb87f5cd1a448bd4d237242bf87d4bad0a1b31 |
Co-authored-by: codex <codex@users.noreply.github.com>6 files changed, 135 insertions(+), 21 deletions(-)
apps/server/src/codexTextGenerator.ts+7-2| ... | ... | @@ -117,9 +117,11 @@ async function runCodexJson<T>({ |
| 117 | 117 | "codex-schema", |
| 118 | 118 | JSON.stringify(outputSchemaJson), |
| 119 | 119 | ); |
| 120 | const outputPath = await writeTempFile("codex-output", ""); | |
| 120 | let outputPath: string | null = null; | |
| 121 | 121 | |
| 122 | 122 | try { |
| 123 | outputPath = await writeTempFile("codex-output", ""); | |
| 124 | ||
| 123 | 125 | await runProcess( |
| 124 | 126 | "codex", |
| 125 | 127 | [ |
| ... | ... | @@ -154,7 +156,10 @@ async function runCodexJson<T>({ |
| 154 | 156 | |
| 155 | 157 | return parse(parsedJson); |
| 156 | 158 | } finally { |
| 157 | await Promise.all([safeUnlink(schemaPath), safeUnlink(outputPath)]); | |
| 159 | await Promise.all([ | |
| 160 | safeUnlink(schemaPath), | |
| 161 | ...(outputPath ? [safeUnlink(outputPath)] : []), | |
| 162 | ]); | |
| 158 | 163 | } |
| 159 | 164 | } |
| 160 | 165 |
apps/server/src/git.test.ts+1-1| ... | ... | @@ -462,7 +462,7 @@ describe("git integration", () => { |
| 462 | 462 | await using remote = await makeTmpDir(); |
| 463 | 463 | await initRepoWithCommit(tmp.path); |
| 464 | 464 | await git(remote.path, "init --bare"); |
| 465 | await git(tmp.path, `remote add origin '${remote.path}'`); | |
| 465 | await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 466 | 466 | await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); |
| 467 | 467 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); |
| 468 | 468 |
apps/server/src/git.ts+10-4| ... | ... | @@ -102,12 +102,15 @@ function commandLabel(args: readonly string[]): string { |
| 102 | 102 | return `git ${args.join(" ")}`; |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | function normalizeGitSpawnError(error: unknown): Error { | |
| 105 | function normalizeGitSpawnError(cwd: string, error: unknown): Error { | |
| 106 | 106 | if (!(error instanceof Error)) { |
| 107 | 107 | return new Error("Failed to run git command."); |
| 108 | 108 | } |
| 109 | 109 | const code = (error as NodeJS.ErrnoException).code; |
| 110 | 110 | if (code === "ENOENT") { |
| 111 | if (!fs.existsSync(cwd)) { | |
| 112 | return new Error(`Working directory does not exist: ${cwd}`); | |
| 113 | } | |
| 111 | 114 | return new Error("Git is required but not available on PATH."); |
| 112 | 115 | } |
| 113 | 116 | return new Error(`Failed to run git command: ${error.message}`); |
| ... | ... | @@ -135,10 +138,13 @@ async function runGitOrThrow( |
| 135 | 138 | try { |
| 136 | 139 | result = await runGit(args, cwd, options.timeoutMs); |
| 137 | 140 | } catch (error) { |
| 138 | throw normalizeGitSpawnError(error); | |
| 141 | throw normalizeGitSpawnError(cwd, error); | |
| 139 | 142 | } |
| 140 | 143 | |
| 141 | if (!options.allowNonZeroExit && (result.code !== 0 || result.timedOut)) { | |
| 144 | if (result.timedOut) { | |
| 145 | throw normalizeGitExecutionError(args, result); | |
| 146 | } | |
| 147 | if (!options.allowNonZeroExit && result.code !== 0) { | |
| 142 | 148 | throw normalizeGitExecutionError(args, result); |
| 143 | 149 | } |
| 144 | 150 | return result; |
| ... | ... | @@ -242,7 +248,7 @@ export class GitCoreService { |
| 242 | 248 | throw new Error("Cannot push from detached HEAD."); |
| 243 | 249 | } |
| 244 | 250 | |
| 245 | if (details.hasUpstream && details.aheadCount === 0) { | |
| 251 | if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { | |
| 246 | 252 | return { |
| 247 | 253 | status: "skipped_up_to_date", |
| 248 | 254 | branch, |
apps/server/src/gitManager.ts+22-6| ... | ... | @@ -21,6 +21,8 @@ import { CodexTextGenerator } from "./codexTextGenerator"; |
| 21 | 21 | import { GitCoreService } from "./git"; |
| 22 | 22 | import { type ProcessRunOptions, runProcess } from "./processRunner"; |
| 23 | 23 | |
| 24 | const GH_CLI_TIMEOUT_MS = 30_000; | |
| 25 | ||
| 24 | 26 | interface GitManagerDeps { |
| 25 | 27 | runProcess?: ( |
| 26 | 28 | command: string, |
| ... | ... | @@ -107,10 +109,21 @@ function sanitizeCommitMessage( |
| 107 | 109 | |
| 108 | 110 | function extractBranchFromRef(ref: string): string { |
| 109 | 111 | const normalized = ref.trim(); |
| 110 | const afterSlash = normalized.includes("/") | |
| 111 | ? normalized.slice(normalized.lastIndexOf("/") + 1) | |
| 112 | : normalized; | |
| 113 | return afterSlash.trim(); | |
| 112 | ||
| 113 | if (normalized.startsWith("refs/remotes/")) { | |
| 114 | const withoutPrefix = normalized.slice("refs/remotes/".length); | |
| 115 | const firstSlash = withoutPrefix.indexOf("/"); | |
| 116 | if (firstSlash === -1) { | |
| 117 | return withoutPrefix.trim(); | |
| 118 | } | |
| 119 | return withoutPrefix.slice(firstSlash + 1).trim(); | |
| 120 | } | |
| 121 | ||
| 122 | const firstSlash = normalized.indexOf("/"); | |
| 123 | if (firstSlash === -1) { | |
| 124 | return normalized; | |
| 125 | } | |
| 126 | return normalized.slice(firstSlash + 1).trim(); | |
| 114 | 127 | } |
| 115 | 128 | |
| 116 | 129 | function asCommandNotFound( |
| ... | ... | @@ -401,7 +414,7 @@ export class GitManager { |
| 401 | 414 | |
| 402 | 415 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { |
| 403 | 416 | try { |
| 404 | await this.run("gh", args, { cwd }); | |
| 417 | await this.run("gh", args, { cwd, timeoutMs: GH_CLI_TIMEOUT_MS }); | |
| 405 | 418 | } catch (error) { |
| 406 | 419 | throw ( |
| 407 | 420 | asCommandNotFound("gh", error) ?? |
| ... | ... | @@ -413,7 +426,10 @@ export class GitManager { |
| 413 | 426 | |
| 414 | 427 | private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> { |
| 415 | 428 | try { |
| 416 | const result = await this.run("gh", args, { cwd }); | |
| 429 | const result = await this.run("gh", args, { | |
| 430 | cwd, | |
| 431 | timeoutMs: GH_CLI_TIMEOUT_MS, | |
| 432 | }); | |
| 417 | 433 | return result.stdout; |
| 418 | 434 | } catch (error) { |
| 419 | 435 | throw ( |
apps/server/src/processRunner.ts+79-4| ... | ... | @@ -6,6 +6,7 @@ export interface ProcessRunOptions { |
| 6 | 6 | env?: NodeJS.ProcessEnv | undefined; |
| 7 | 7 | stdin?: string | undefined; |
| 8 | 8 | allowNonZeroExit?: boolean | undefined; |
| 9 | maxBufferBytes?: number | undefined; | |
| 9 | 10 | } |
| 10 | 11 | |
| 11 | 12 | export interface ProcessRunResult { |
| ... | ... | @@ -52,12 +53,39 @@ function normalizeExitError( |
| 52 | 53 | return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); |
| 53 | 54 | } |
| 54 | 55 | |
| 56 | function normalizeStdinError( | |
| 57 | command: string, | |
| 58 | args: readonly string[], | |
| 59 | error: unknown, | |
| 60 | ): Error { | |
| 61 | if (!(error instanceof Error)) { | |
| 62 | return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`); | |
| 63 | } | |
| 64 | return new Error( | |
| 65 | `Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`, | |
| 66 | ); | |
| 67 | } | |
| 68 | ||
| 69 | function normalizeBufferError( | |
| 70 | command: string, | |
| 71 | args: readonly string[], | |
| 72 | stream: "stdout" | "stderr", | |
| 73 | maxBufferBytes: number, | |
| 74 | ): Error { | |
| 75 | return new Error( | |
| 76 | `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`, | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; | |
| 81 | ||
| 55 | 82 | export async function runProcess( |
| 56 | 83 | command: string, |
| 57 | 84 | args: readonly string[], |
| 58 | 85 | options: ProcessRunOptions = {}, |
| 59 | 86 | ): Promise<ProcessRunResult> { |
| 60 | 87 | const timeoutMs = options.timeoutMs ?? 60_000; |
| 88 | const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; | |
| 61 | 89 | |
| 62 | 90 | return new Promise<ProcessRunResult>((resolve, reject) => { |
| 63 | 91 | const child = spawn(command, args, { |
| ... | ... | @@ -68,6 +96,8 @@ export async function runProcess( |
| 68 | 96 | |
| 69 | 97 | let stdout = ""; |
| 70 | 98 | let stderr = ""; |
| 99 | let stdoutBytes = 0; | |
| 100 | let stderrBytes = 0; | |
| 71 | 101 | let timedOut = false; |
| 72 | 102 | let settled = false; |
| 73 | 103 | let forceKillTimer: ReturnType<typeof setTimeout> | null = null; |
| ... | ... | @@ -90,12 +120,47 @@ export async function runProcess( |
| 90 | 120 | callback(); |
| 91 | 121 | }; |
| 92 | 122 | |
| 123 | const fail = (error: Error): void => { | |
| 124 | child.kill("SIGTERM"); | |
| 125 | finalize(() => { | |
| 126 | reject(error); | |
| 127 | }); | |
| 128 | }; | |
| 129 | ||
| 130 | const appendOutput = ( | |
| 131 | stream: "stdout" | "stderr", | |
| 132 | chunk: Buffer | string, | |
| 133 | ): Error | null => { | |
| 134 | const text = chunk.toString(); | |
| 135 | const byteLength = Buffer.byteLength(text); | |
| 136 | if (stream === "stdout") { | |
| 137 | stdout += text; | |
| 138 | stdoutBytes += byteLength; | |
| 139 | if (stdoutBytes > maxBufferBytes) { | |
| 140 | return normalizeBufferError(command, args, "stdout", maxBufferBytes); | |
| 141 | } | |
| 142 | } else { | |
| 143 | stderr += text; | |
| 144 | stderrBytes += byteLength; | |
| 145 | if (stderrBytes > maxBufferBytes) { | |
| 146 | return normalizeBufferError(command, args, "stderr", maxBufferBytes); | |
| 147 | } | |
| 148 | } | |
| 149 | return null; | |
| 150 | }; | |
| 151 | ||
| 93 | 152 | child.stdout.on("data", (chunk: Buffer | string) => { |
| 94 | stdout += chunk.toString(); | |
| 153 | const error = appendOutput("stdout", chunk); | |
| 154 | if (error) { | |
| 155 | fail(error); | |
| 156 | } | |
| 95 | 157 | }); |
| 96 | 158 | |
| 97 | 159 | child.stderr.on("data", (chunk: Buffer | string) => { |
| 98 | stderr += chunk.toString(); | |
| 160 | const error = appendOutput("stderr", chunk); | |
| 161 | if (error) { | |
| 162 | fail(error); | |
| 163 | } | |
| 99 | 164 | }); |
| 100 | 165 | |
| 101 | 166 | child.once("error", (error) => { |
| ... | ... | @@ -125,10 +190,20 @@ export async function runProcess( |
| 125 | 190 | }); |
| 126 | 191 | }); |
| 127 | 192 | |
| 193 | child.stdin.once("error", (error) => { | |
| 194 | fail(normalizeStdinError(command, args, error)); | |
| 195 | }); | |
| 196 | ||
| 128 | 197 | if (options.stdin !== undefined) { |
| 129 | child.stdin.write(options.stdin); | |
| 198 | child.stdin.write(options.stdin, (error) => { | |
| 199 | if (error) { | |
| 200 | fail(normalizeStdinError(command, args, error)); | |
| 201 | return; | |
| 202 | } | |
| 203 | child.stdin.end(); | |
| 204 | }); | |
| 205 | return; | |
| 130 | 206 | } |
| 131 | 207 | child.stdin.end(); |
| 132 | 208 | }); |
| 133 | 209 | } |
| 134 |
apps/web/src/components/ChatView.tsx+16-4| ... | ... | @@ -196,6 +196,7 @@ export default function ChatView() { |
| 196 | 196 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 197 | 197 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 198 | 198 | const gitMenuRef = useRef<HTMLDivElement>(null); |
| 199 | const latestGitCwdRef = useRef<string | null>(null); | |
| 199 | 200 | |
| 200 | 201 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 201 | 202 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| ... | ... | @@ -302,19 +303,27 @@ export default function ChatView() { |
| 302 | 303 | !api || !gitCwd || !gitStatus || isGitActionRunning; |
| 303 | 304 | const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null; |
| 304 | 305 | |
| 306 | useEffect(() => { | |
| 307 | latestGitCwdRef.current = gitCwd; | |
| 308 | }, [gitCwd]); | |
| 309 | ||
| 305 | 310 | const refreshGitStatus = useCallback(async () => { |
| 306 | if (!api || !gitCwd) { | |
| 311 | const requestCwd = gitCwd; | |
| 312 | if (!api || !requestCwd) { | |
| 307 | 313 | setGitStatus(null); |
| 308 | 314 | return; |
| 309 | 315 | } |
| 310 | 316 | |
| 311 | const nextStatus = await api.git.status({ cwd: gitCwd }); | |
| 317 | const nextStatus = await api.git.status({ cwd: requestCwd }); | |
| 318 | if (latestGitCwdRef.current !== requestCwd) return; | |
| 312 | 319 | setGitStatus(nextStatus); |
| 320 | setGitActionError(null); | |
| 313 | 321 | }, [api, gitCwd]); |
| 314 | 322 | |
| 315 | 323 | const runGitAction = useCallback( |
| 316 | 324 | async (action: GitStackedAction) => { |
| 317 | 325 | if (!api || !gitCwd) return; |
| 326 | const actionCwd = gitCwd; | |
| 318 | 327 | |
| 319 | 328 | setIsGitMenuOpen(false); |
| 320 | 329 | setIsGitActionRunning(true); |
| ... | ... | @@ -322,7 +331,7 @@ export default function ChatView() { |
| 322 | 331 | setGitActionNotice(null); |
| 323 | 332 | try { |
| 324 | 333 | const result = await api.git.runStackedAction({ |
| 325 | cwd: gitCwd, | |
| 334 | cwd: actionCwd, | |
| 326 | 335 | action, |
| 327 | 336 | }); |
| 328 | 337 | setGitActionNotice(summarizeGitActionResult(result)); |
| ... | ... | @@ -333,7 +342,9 @@ export default function ChatView() { |
| 333 | 342 | } finally { |
| 334 | 343 | setIsGitActionRunning(false); |
| 335 | 344 | try { |
| 336 | await refreshGitStatus(); | |
| 345 | if (latestGitCwdRef.current === actionCwd) { | |
| 346 | await refreshGitStatus(); | |
| 347 | } | |
| 337 | 348 | } catch { |
| 338 | 349 | setGitStatus(null); |
| 339 | 350 | } |
| ... | ... | @@ -404,6 +415,7 @@ export default function ChatView() { |
| 404 | 415 | const nextStatus = await api.git.status({ cwd: gitCwd }); |
| 405 | 416 | if (!cancelled) { |
| 406 | 417 | setGitStatus(nextStatus); |
| 418 | setGitActionError(null); | |
| 407 | 419 | } |
| 408 | 420 | } catch (error) { |
| 409 | 421 | if (!cancelled) { |