Unified · Split
| @@ -117,9 +117,11 @@ async function runCodexJson<T>({ · 116 unmodified lines | ||
| 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>({ · 28 unmodified lines | ||
| 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 | |
| @@ -462,7 +462,7 @@ describe("git integration", () => { · 461 unmodified lines | ||
| 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 | |
| @@ -102,12 +102,15 @@ function commandLabel(args: readonly string[]): string { · 101 unmodified lines | ||
| 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( · 21 unmodified lines | ||
| 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 { · 97 unmodified lines | ||
| 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, |
| @@ -21,6 +21,8 @@ import { CodexTextGenerator } from "./codexTextGenerator"; · 20 unmodified lines | ||
| 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( · 80 unmodified lines | ||
| 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 { · 284 unmodified lines | ||
| 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 { · 5 unmodified lines | ||
| 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 ( |
| @@ -6,6 +6,7 @@ export interface ProcessRunOptions { · 5 unmodified lines | ||
| 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( · 40 unmodified lines | ||
| 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( · 4 unmodified lines | ||
| 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( · 16 unmodified lines | ||
| 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( · 23 unmodified lines | ||
| 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 | − | |
| @@ -196,6 +196,7 @@ export default function ChatView() { · 195 unmodified lines | ||
| 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() { · 100 unmodified lines | ||
| 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() { · 1 unmodified line | ||
| 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() { · 4 unmodified lines | ||
| 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() { · 64 unmodified lines | ||
| 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) { |