authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 17:56:16-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 17:56:16-08:00
log533dbdfbdacc82edf06cbe8bf0ff12d4c04edfd4
tree97951dc186d82217839b11cd79f5f7fb7d03eaf8
parent98eb87f5cd1a448bd4d237242bf87d4bad0a1b31

Address PR review feedback for git actions

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,9 +117,11 @@ async function runCodexJson<T>({
117 "codex-schema",117 "codex-schema",
118 JSON.stringify(outputSchemaJson),118 JSON.stringify(outputSchemaJson),
119 );119 );
120 const outputPath = await writeTempFile("codex-output", "");120 let outputPath: string | null = null;
121121
122 try {122 try {
123 outputPath = await writeTempFile("codex-output", "");
124
123 await runProcess(125 await runProcess(
124 "codex",126 "codex",
125 [127 [
...@@ -154,7 +156,10 @@ async function runCodexJson<T>({...@@ -154,7 +156,10 @@ async function runCodexJson<T>({
154156
155 return parse(parsedJson);157 return parse(parsedJson);
156 } finally {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}
160165
apps/server/src/git.test.ts+1-1
...@@ -462,7 +462,7 @@ describe("git integration", () => {...@@ -462,7 +462,7 @@ describe("git integration", () => {
462 await using remote = await makeTmpDir();462 await using remote = await makeTmpDir();
463 await initRepoWithCommit(tmp.path);463 await initRepoWithCommit(tmp.path);
464 await git(remote.path, "init --bare");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 await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" });466 await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" });
467 await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" });467 await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" });
468468
apps/server/src/git.ts+10-4
...@@ -102,12 +102,15 @@ function commandLabel(args: readonly string[]): string {...@@ -102,12 +102,15 @@ function commandLabel(args: readonly string[]): string {
102 return `git ${args.join(" ")}`;102 return `git ${args.join(" ")}`;
103}103}
104104
105function normalizeGitSpawnError(error: unknown): Error {105function normalizeGitSpawnError(cwd: string, error: unknown): Error {
106 if (!(error instanceof Error)) {106 if (!(error instanceof Error)) {
107 return new Error("Failed to run git command.");107 return new Error("Failed to run git command.");
108 }108 }
109 const code = (error as NodeJS.ErrnoException).code;109 const code = (error as NodeJS.ErrnoException).code;
110 if (code === "ENOENT") {110 if (code === "ENOENT") {
111 if (!fs.existsSync(cwd)) {
112 return new Error(`Working directory does not exist: ${cwd}`);
113 }
111 return new Error("Git is required but not available on PATH.");114 return new Error("Git is required but not available on PATH.");
112 }115 }
113 return new Error(`Failed to run git command: ${error.message}`);116 return new Error(`Failed to run git command: ${error.message}`);
...@@ -135,10 +138,13 @@ async function runGitOrThrow(...@@ -135,10 +138,13 @@ async function runGitOrThrow(
135 try {138 try {
136 result = await runGit(args, cwd, options.timeoutMs);139 result = await runGit(args, cwd, options.timeoutMs);
137 } catch (error) {140 } catch (error) {
138 throw normalizeGitSpawnError(error);141 throw normalizeGitSpawnError(cwd, error);
139 }142 }
140143
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 throw normalizeGitExecutionError(args, result);148 throw normalizeGitExecutionError(args, result);
143 }149 }
144 return result;150 return result;
...@@ -242,7 +248,7 @@ export class GitCoreService {...@@ -242,7 +248,7 @@ export class GitCoreService {
242 throw new Error("Cannot push from detached HEAD.");248 throw new Error("Cannot push from detached HEAD.");
243 }249 }
244250
245 if (details.hasUpstream && details.aheadCount === 0) {251 if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) {
246 return {252 return {
247 status: "skipped_up_to_date",253 status: "skipped_up_to_date",
248 branch,254 branch,
apps/server/src/gitManager.ts+22-6
...@@ -21,6 +21,8 @@ import { CodexTextGenerator } from "./codexTextGenerator";...@@ -21,6 +21,8 @@ import { CodexTextGenerator } from "./codexTextGenerator";
21import { GitCoreService } from "./git";21import { GitCoreService } from "./git";
22import { type ProcessRunOptions, runProcess } from "./processRunner";22import { type ProcessRunOptions, runProcess } from "./processRunner";
2323
24const GH_CLI_TIMEOUT_MS = 30_000;
25
24interface GitManagerDeps {26interface GitManagerDeps {
25 runProcess?: (27 runProcess?: (
26 command: string,28 command: string,
...@@ -107,10 +109,21 @@ function sanitizeCommitMessage(...@@ -107,10 +109,21 @@ function sanitizeCommitMessage(
107109
108function extractBranchFromRef(ref: string): string {110function extractBranchFromRef(ref: string): string {
109 const normalized = ref.trim();111 const normalized = ref.trim();
110 const afterSlash = normalized.includes("/")112
111 ? normalized.slice(normalized.lastIndexOf("/") + 1)113 if (normalized.startsWith("refs/remotes/")) {
112 : normalized;114 const withoutPrefix = normalized.slice("refs/remotes/".length);
113 return afterSlash.trim();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}
115128
116function asCommandNotFound(129function asCommandNotFound(
...@@ -401,7 +414,7 @@ export class GitManager {...@@ -401,7 +414,7 @@ export class GitManager {
401414
402 private async runGh(cwd: string, args: readonly string[]): Promise<void> {415 private async runGh(cwd: string, args: readonly string[]): Promise<void> {
403 try {416 try {
404 await this.run("gh", args, { cwd });417 await this.run("gh", args, { cwd, timeoutMs: GH_CLI_TIMEOUT_MS });
405 } catch (error) {418 } catch (error) {
406 throw (419 throw (
407 asCommandNotFound("gh", error) ??420 asCommandNotFound("gh", error) ??
...@@ -413,7 +426,10 @@ export class GitManager {...@@ -413,7 +426,10 @@ export class GitManager {
413426
414 private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {427 private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {
415 try {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 return result.stdout;433 return result.stdout;
418 } catch (error) {434 } catch (error) {
419 throw (435 throw (
apps/server/src/processRunner.ts+79-4
...@@ -6,6 +6,7 @@ export interface ProcessRunOptions {...@@ -6,6 +6,7 @@ export interface ProcessRunOptions {
6 env?: NodeJS.ProcessEnv | undefined;6 env?: NodeJS.ProcessEnv | undefined;
7 stdin?: string | undefined;7 stdin?: string | undefined;
8 allowNonZeroExit?: boolean | undefined;8 allowNonZeroExit?: boolean | undefined;
9 maxBufferBytes?: number | undefined;
9}10}
1011
11export interface ProcessRunResult {12export interface ProcessRunResult {
...@@ -52,12 +53,39 @@ function normalizeExitError(...@@ -52,12 +53,39 @@ function normalizeExitError(
52 return new Error(`${commandLabel(command, args)} ${reason}.${detail}`);53 return new Error(`${commandLabel(command, args)} ${reason}.${detail}`);
53}54}
5455
56function 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
69function 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
80const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
81
55export async function runProcess(82export async function runProcess(
56 command: string,83 command: string,
57 args: readonly string[],84 args: readonly string[],
58 options: ProcessRunOptions = {},85 options: ProcessRunOptions = {},
59): Promise<ProcessRunResult> {86): Promise<ProcessRunResult> {
60 const timeoutMs = options.timeoutMs ?? 60_000;87 const timeoutMs = options.timeoutMs ?? 60_000;
88 const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
6189
62 return new Promise<ProcessRunResult>((resolve, reject) => {90 return new Promise<ProcessRunResult>((resolve, reject) => {
63 const child = spawn(command, args, {91 const child = spawn(command, args, {
...@@ -68,6 +96,8 @@ export async function runProcess(...@@ -68,6 +96,8 @@ export async function runProcess(
6896
69 let stdout = "";97 let stdout = "";
70 let stderr = "";98 let stderr = "";
99 let stdoutBytes = 0;
100 let stderrBytes = 0;
71 let timedOut = false;101 let timedOut = false;
72 let settled = false;102 let settled = false;
73 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;103 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
...@@ -90,12 +120,47 @@ export async function runProcess(...@@ -90,12 +120,47 @@ export async function runProcess(
90 callback();120 callback();
91 };121 };
92122
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 child.stdout.on("data", (chunk: Buffer | string) => {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 });
96158
97 child.stderr.on("data", (chunk: Buffer | string) => {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 });
100165
101 child.once("error", (error) => {166 child.once("error", (error) => {
...@@ -125,10 +190,20 @@ export async function runProcess(...@@ -125,10 +190,20 @@ export async function runProcess(
125 });190 });
126 });191 });
127192
193 child.stdin.once("error", (error) => {
194 fail(normalizeStdinError(command, args, error));
195 });
196
128 if (options.stdin !== undefined) {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 child.stdin.end();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,6 +196,7 @@ export default function ChatView() {
196 const modelMenuRef = useRef<HTMLDivElement>(null);196 const modelMenuRef = useRef<HTMLDivElement>(null);
197 const editorMenuRef = useRef<HTMLDivElement>(null);197 const editorMenuRef = useRef<HTMLDivElement>(null);
198 const gitMenuRef = useRef<HTMLDivElement>(null);198 const gitMenuRef = useRef<HTMLDivElement>(null);
199 const latestGitCwdRef = useRef<string | null>(null);
199200
200 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);201 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
201 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);202 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
...@@ -302,19 +303,27 @@ export default function ChatView() {...@@ -302,19 +303,27 @@ export default function ChatView() {
302 !api || !gitCwd || !gitStatus || isGitActionRunning;303 !api || !gitCwd || !gitStatus || isGitActionRunning;
303 const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null;304 const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null;
304305
306 useEffect(() => {
307 latestGitCwdRef.current = gitCwd;
308 }, [gitCwd]);
309
305 const refreshGitStatus = useCallback(async () => {310 const refreshGitStatus = useCallback(async () => {
306 if (!api || !gitCwd) {311 const requestCwd = gitCwd;
312 if (!api || !requestCwd) {
307 setGitStatus(null);313 setGitStatus(null);
308 return;314 return;
309 }315 }
310316
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 setGitStatus(nextStatus);319 setGitStatus(nextStatus);
320 setGitActionError(null);
313 }, [api, gitCwd]);321 }, [api, gitCwd]);
314322
315 const runGitAction = useCallback(323 const runGitAction = useCallback(
316 async (action: GitStackedAction) => {324 async (action: GitStackedAction) => {
317 if (!api || !gitCwd) return;325 if (!api || !gitCwd) return;
326 const actionCwd = gitCwd;
318327
319 setIsGitMenuOpen(false);328 setIsGitMenuOpen(false);
320 setIsGitActionRunning(true);329 setIsGitActionRunning(true);
...@@ -322,7 +331,7 @@ export default function ChatView() {...@@ -322,7 +331,7 @@ export default function ChatView() {
322 setGitActionNotice(null);331 setGitActionNotice(null);
323 try {332 try {
324 const result = await api.git.runStackedAction({333 const result = await api.git.runStackedAction({
325 cwd: gitCwd,334 cwd: actionCwd,
326 action,335 action,
327 });336 });
328 setGitActionNotice(summarizeGitActionResult(result));337 setGitActionNotice(summarizeGitActionResult(result));
...@@ -333,7 +342,9 @@ export default function ChatView() {...@@ -333,7 +342,9 @@ export default function ChatView() {
333 } finally {342 } finally {
334 setIsGitActionRunning(false);343 setIsGitActionRunning(false);
335 try {344 try {
336 await refreshGitStatus();345 if (latestGitCwdRef.current === actionCwd) {
346 await refreshGitStatus();
347 }
337 } catch {348 } catch {
338 setGitStatus(null);349 setGitStatus(null);
339 }350 }
...@@ -404,6 +415,7 @@ export default function ChatView() {...@@ -404,6 +415,7 @@ export default function ChatView() {
404 const nextStatus = await api.git.status({ cwd: gitCwd });415 const nextStatus = await api.git.status({ cwd: gitCwd });
405 if (!cancelled) {416 if (!cancelled) {
406 setGitStatus(nextStatus);417 setGitStatus(nextStatus);
418 setGitActionError(null);
407 }419 }
408 } catch (error) {420 } catch (error) {
409 if (!cancelled) {421 if (!cancelled) {