Skip to content

Commit c119250

Browse files
authored
fix: reuse codex-created commits in autocommit (#215)
* fix: detect codex-created commits in autocommit * fix: reuse codex-created commits in autocommit
1 parent 775cd17 commit c119250

2 files changed

Lines changed: 218 additions & 51 deletions

File tree

packages/cli/src/utils/codex-runtime.ts

Lines changed: 105 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ export interface WorkspaceSyncDeps {
336336
warn?: (message?: any, ...optionalParams: any[]) => void;
337337
}
338338

339+
export function hasWorkspaceChangesOrNewCommit(statusOutput: string, baseline?: string | null, currentHead?: string | null): boolean {
340+
return statusOutput.trim().length > 0 || currentHead !== baseline;
341+
}
342+
339343
interface AutoCommitFlair {
340344
publishEvent(event: { kind: string; summary: string; detail?: string; refId?: string }): Promise<void>;
341345
}
@@ -411,6 +415,74 @@ export async function syncWorkspaceBeforeTask(
411415
console.log(`[${config.agentId}] Workspace synced to origin/${branch}.`);
412416
}
413417

418+
function openPullRequest(
419+
runSync: typeof spawnSync,
420+
flair: AutoCommitFlair,
421+
options: AutoCommitOptions,
422+
authorEmail: string,
423+
repo: string,
424+
): Promise<void> {
425+
const {
426+
taskId,
427+
branchName,
428+
commitMessage,
429+
prRepo,
430+
ghAgent,
431+
prTitle,
432+
prBody,
433+
authorName,
434+
} = options;
435+
436+
return (async () => {
437+
if (!options.push || !options.openPr || !prRepo) return;
438+
const prArgs = [
439+
ghAgent ?? authorEmail.split("@")[0] ?? "",
440+
"pr",
441+
"create",
442+
"--repo",
443+
prRepo,
444+
"--head",
445+
branchName,
446+
"--title", prTitle ?? `task: ${taskId}`,
447+
"--body",
448+
prBody ?? commitMessage,
449+
];
450+
console.log(`[autoCommit] opening PR: gh-as ${prArgs[0]} pr create --repo ${prRepo} --head ${branchName}`);
451+
const prResult = runSync("gh-as", prArgs, { cwd: repo, encoding: "utf-8" });
452+
const prStdout2 = typeof prResult.stdout === "string" ? prResult.stdout.trim() : "";
453+
const prStderr2 = typeof prResult.stderr === "string" ? prResult.stderr.trim() : "";
454+
console.log(`[autoCommit] gh-as pr create exit=${prResult.status} stdout=${prStdout2.slice(0, 200)}`);
455+
if (prStderr2) console.log(`[autoCommit] gh-as stderr: ${prStderr2.slice(0, 200)}`);
456+
if ((prResult.status ?? 1) === 0) {
457+
const prUrl = prStdout2.trim();
458+
const prNumber = prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?";
459+
if (options.reviewNotify?.length && options.mailDir) {
460+
for (const reviewer of options.reviewNotify) {
461+
try {
462+
const { sendMessage } = await import("../utils/mail.js");
463+
sendMessage(reviewer, `PR #${prNumber} for review: ${prUrl}`, authorName.toLowerCase());
464+
console.log(`[autoCommit] Notified reviewer: ${reviewer} (PR #${prNumber})`);
465+
} catch (notifyErr: any) {
466+
console.warn(`[autoCommit] Failed to notify ${reviewer}: ${notifyErr.message}`);
467+
}
468+
}
469+
}
470+
return;
471+
}
472+
473+
const prErrMsg = prStderr2 || prStdout2 || `exit ${prResult.status ?? "unknown"}`;
474+
try {
475+
await flair.publishEvent({
476+
kind: "blocker",
477+
summary: `PR creation failed for ${taskId}`,
478+
detail: prErrMsg,
479+
refId: taskId,
480+
});
481+
} catch { /* non-fatal */ }
482+
throw new Error(`gh-as pr create failed: ${prErrMsg}`);
483+
})();
484+
}
485+
414486
export async function runAutoCommit(
415487
config: Pick<CodexRuntimeConfig, "workspace">,
416488
flair: AutoCommitFlair,
@@ -444,6 +516,34 @@ export async function runAutoCommit(
444516
}
445517
}
446518

519+
const statusResult = runSync(GIT_BIN, ["status", "--porcelain"], { cwd: repo, encoding: "utf-8" });
520+
const hasWorkingTreeChanges = ((statusResult.stdout ?? "") as string).trim().length > 0;
521+
const remoteRefCheck = runSync(GIT_BIN, ["rev-parse", "--verify", `refs/remotes/origin/${branchName}`], { cwd: repo, encoding: "utf-8" });
522+
const aheadResult = runSync(
523+
GIT_BIN,
524+
["rev-list", "--count", remoteRefCheck.status === 0 ? `origin/${branchName}..HEAD` : "HEAD"],
525+
{ cwd: repo, encoding: "utf-8" },
526+
);
527+
const aheadCount = parseInt(((aheadResult.stdout ?? "") as string).trim(), 10);
528+
const hasExistingCommits = !Number.isNaN(aheadCount) && aheadCount > 0;
529+
530+
if (!hasWorkingTreeChanges && hasExistingCommits) {
531+
console.log(`[autoCommit] reusing existing commits on ${branchName}`);
532+
if (push) {
533+
const pushResult = runSync(GIT_BIN, ["push", "-u", "origin", branchName], { cwd: repo, encoding: "utf-8" });
534+
const pushStdout = typeof pushResult.stdout === "string" ? pushResult.stdout.trim() : "";
535+
const pushStderr = typeof pushResult.stderr === "string" ? pushResult.stderr.trim() : "";
536+
console.log(`[autoCommit] git push exit=${pushResult.status} branch=${branchName}`);
537+
if (pushStdout) console.log(`[autoCommit] push stdout: ${pushStdout.slice(0, 200)}`);
538+
if (pushStderr) console.log(`[autoCommit] push stderr: ${pushStderr.slice(0, 200)}`);
539+
if ((pushResult.status ?? 1) !== 0) {
540+
throw new Error(`git push failed: ${pushStderr || pushStdout || `exit ${pushResult.status ?? "unknown"}`}`);
541+
}
542+
}
543+
await openPullRequest(runSync, flair, options, authorEmail, repo);
544+
return;
545+
}
546+
447547
const args = [
448548
"agent", "commit",
449549
"--repo", repo,
@@ -461,55 +561,7 @@ export async function runAutoCommit(
461561
if (resultStdout) console.log(`[autoCommit] stdout: ${resultStdout.slice(0, 200)}`);
462562
if (resultStderr) console.log(`[autoCommit] stderr: ${resultStderr.slice(0, 200)}`);
463563
if (result.status === 0) {
464-
if (push && openPr && prRepo) {
465-
const prArgs = [
466-
ghAgent ?? authorEmail.split("@")[0] ?? "",
467-
"pr",
468-
"create",
469-
"--repo",
470-
prRepo,
471-
"--head",
472-
branchName,
473-
"--title", prTitle ?? `task: ${taskId}`,
474-
"--body",
475-
prBody ?? commitMessage,
476-
];
477-
console.log(`[autoCommit] opening PR: gh-as ${prArgs[0]} pr create --repo ${prRepo} --head ${branchName}`);
478-
const prResult = runSync("gh-as", prArgs, { cwd: repo, encoding: "utf-8" });
479-
const prStdout2 = typeof prResult.stdout === "string" ? prResult.stdout.trim() : "";
480-
const prStderr2 = typeof prResult.stderr === "string" ? prResult.stderr.trim() : "";
481-
console.log(`[autoCommit] gh-as pr create exit=${prResult.status} stdout=${prStdout2.slice(0, 200)}`);
482-
if (prStderr2) console.log(`[autoCommit] gh-as stderr: ${prStderr2.slice(0, 200)}`);
483-
if ((prResult.status ?? 1) === 0) {
484-
const prUrl = prStdout2.trim();
485-
const prNumber = prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?";
486-
if (options.reviewNotify?.length && options.mailDir) {
487-
for (const reviewer of options.reviewNotify) {
488-
try {
489-
const { sendMessage } = await import("../utils/mail.js");
490-
sendMessage(reviewer, `PR #${prNumber} for review: ${prUrl}`, authorName.toLowerCase());
491-
console.log(`[autoCommit] Notified reviewer: ${reviewer} (PR #${prNumber})`);
492-
} catch (notifyErr: any) {
493-
console.warn(`[autoCommit] Failed to notify ${reviewer}: ${notifyErr.message}`);
494-
}
495-
}
496-
}
497-
return;
498-
}
499-
500-
const prStderr = prStderr2;
501-
const prStdout = prStdout2;
502-
const prErrMsg = prStderr || prStdout || `exit ${prResult.status ?? "unknown"}`;
503-
try {
504-
await flair.publishEvent({
505-
kind: "blocker",
506-
summary: `PR creation failed for ${taskId}`,
507-
detail: prErrMsg,
508-
refId: taskId,
509-
});
510-
} catch { /* non-fatal */ }
511-
throw new Error(`gh-as pr create failed: ${prErrMsg}`);
512-
}
564+
await openPullRequest(runSync, flair, options, authorEmail, repo);
513565
return;
514566
}
515567

@@ -751,6 +803,7 @@ export async function runCodexRuntime(config: CodexRuntimeConfig): Promise<void>
751803
const flairPub2 = { publishEvent: async (ev: Record<string, unknown>) => {
752804
try { await (flair as any).request("POST", "/OrgEvent", { ...ev, authorId: agentId }); } catch { /* non-fatal */ }
753805
}};
806+
const baseline = spawnSync(GIT_BIN, ["rev-parse", "HEAD"], { cwd: config.workspace, encoding: "utf-8" }).stdout?.trim();
754807
const result = await runCodex(msg, config, config.taskTimeoutMs ?? 30 * 60 * 1000, {
755808
flairPublisher: flairPub2,
756809
onStall: () => { sendMail(mailDir, agentId, msg.from, `Task stalled: no Codex output for ${Math.round((config.watchdogTimeoutMs ?? 300000) / 60000)}m — process killed. Please resend the task.`); },
@@ -799,9 +852,10 @@ export async function runCodexRuntime(config: CodexRuntimeConfig): Promise<void>
799852
console.warn(`[${agentId}] Stale rebase state detected before autoCommit — aborting rebase and proceeding`);
800853
spawnSync(GIT_BIN, ["rebase", "--abort"], { cwd: config.workspace, encoding: "utf-8" });
801854
}
802-
// Check for file changes before attempting autoCommit
855+
// Check for file changes or Codex-created commits before attempting autoCommit
803856
const gitStatusResult = spawnSync(GIT_BIN, ["status", "--porcelain"], { cwd: config.workspace, encoding: "utf-8" });
804-
const hasChanges = (gitStatusResult.stdout ?? "").trim().length > 0;
857+
const currentHead = spawnSync(GIT_BIN, ["rev-parse", "HEAD"], { cwd: config.workspace, encoding: "utf-8" }).stdout?.trim();
858+
const hasChanges = hasWorkspaceChangesOrNewCommit(gitStatusResult.stdout ?? "", baseline, currentHead);
805859
if (!hasChanges) {
806860
console.warn(`[${agentId}] Task produced no file changes — skipping autoCommit`);
807861
sendMail(mailDir, agentId, msg.from,

packages/cli/test/auto-commit.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, mock, test } from "bun:test";
22
import {
33
composeSystemPrompt,
4+
hasWorkspaceChangesOrNewCommit,
45
publishTaskOutcomeEvent,
56
runAutoCommit,
67
syncWorkspaceBeforeTask,
@@ -25,6 +26,14 @@ describe("composeSystemPrompt", () => {
2526
});
2627
});
2728

29+
describe("hasWorkspaceChangesOrNewCommit", () => {
30+
test("detects a new HEAD commit even when the working tree is clean", () => {
31+
expect(hasWorkspaceChangesOrNewCommit("", "abc123", "def456")).toBe(true);
32+
expect(hasWorkspaceChangesOrNewCommit("", "abc123", "abc123")).toBe(false);
33+
expect(hasWorkspaceChangesOrNewCommit(" M file.ts", "abc123", "abc123")).toBe(true);
34+
});
35+
});
36+
2837
describe("runAutoCommit", () => {
2938
test("creates the branch before invoking tps agent commit when HEAD is detached", async () => {
3039
const calls: Array<{ cmd: string; args: string[] }> = [];
@@ -36,6 +45,15 @@ describe("runAutoCommit", () => {
3645
if (cmd === "/usr/bin/git" && args.join(" ") === "checkout -b feat/task-123") {
3746
return { status: 0, stdout: "", stderr: "" };
3847
}
48+
if (cmd === "/usr/bin/git" && args.join(" ") === "status --porcelain") {
49+
return { status: 0, stdout: " M file.ts\n", stderr: "" };
50+
}
51+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-parse --verify refs/remotes/origin/feat/task-123") {
52+
return { status: 1, stdout: "", stderr: "" };
53+
}
54+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-list --count HEAD") {
55+
return { status: 0, stdout: "0\n", stderr: "" };
56+
}
3957
if (cmd === "tps") {
4058
return { status: 0, stdout: "", stderr: "" };
4159
}
@@ -60,6 +78,9 @@ describe("runAutoCommit", () => {
6078
expect(calls).toEqual([
6179
{ cmd: "/usr/bin/git", args: ["symbolic-ref", "--quiet", "HEAD"] },
6280
{ cmd: "/usr/bin/git", args: ["checkout", "-b", "feat/task-123"] },
81+
{ cmd: "/usr/bin/git", args: ["status", "--porcelain"] },
82+
{ cmd: "/usr/bin/git", args: ["rev-parse", "--verify", "refs/remotes/origin/feat/task-123"] },
83+
{ cmd: "/usr/bin/git", args: ["rev-list", "--count", "HEAD"] },
6384
{
6485
cmd: "tps",
6586
args: [
@@ -89,6 +110,15 @@ describe("runAutoCommit", () => {
89110
if (cmd === "/usr/bin/git" && args.join(" ") === "symbolic-ref --quiet HEAD") {
90111
return { status: 0, stdout: "refs/heads/feat/task-456\n", stderr: "" };
91112
}
113+
if (cmd === "/usr/bin/git" && args.join(" ") === "status --porcelain") {
114+
return { status: 0, stdout: " M file.ts\n", stderr: "" };
115+
}
116+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-parse --verify refs/remotes/origin/feat/task-456") {
117+
return { status: 1, stdout: "", stderr: "" };
118+
}
119+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-list --count HEAD") {
120+
return { status: 0, stdout: "0\n", stderr: "" };
121+
}
92122
if (cmd === "tps") {
93123
return { status: 0, stdout: "", stderr: "" };
94124
}
@@ -118,6 +148,9 @@ describe("runAutoCommit", () => {
118148

119149
expect(calls).toEqual([
120150
{ cmd: "/usr/bin/git", args: ["symbolic-ref", "--quiet", "HEAD"] },
151+
{ cmd: "/usr/bin/git", args: ["status", "--porcelain"] },
152+
{ cmd: "/usr/bin/git", args: ["rev-parse", "--verify", "refs/remotes/origin/feat/task-456"] },
153+
{ cmd: "/usr/bin/git", args: ["rev-list", "--count", "HEAD"] },
121154
{
122155
cmd: "tps",
123156
args: [
@@ -154,12 +187,92 @@ describe("runAutoCommit", () => {
154187
]);
155188
});
156189

190+
test("pushes existing commits and opens a PR when Codex already committed", async () => {
191+
const calls: Array<{ cmd: string; args: string[] }> = [];
192+
const spawnSyncImpl = mock((cmd: string, args: string[]) => {
193+
calls.push({ cmd, args });
194+
if (cmd === "/usr/bin/git" && args.join(" ") === "symbolic-ref --quiet HEAD") {
195+
return { status: 0, stdout: "refs/heads/feat/task-789\n", stderr: "" };
196+
}
197+
if (cmd === "/usr/bin/git" && args.join(" ") === "status --porcelain") {
198+
return { status: 0, stdout: "", stderr: "" };
199+
}
200+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-parse --verify refs/remotes/origin/feat/task-789") {
201+
return { status: 0, stdout: "originref\n", stderr: "" };
202+
}
203+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-list --count origin/feat/task-789..HEAD") {
204+
return { status: 0, stdout: "1\n", stderr: "" };
205+
}
206+
if (cmd === "/usr/bin/git" && args.join(" ") === "push -u origin feat/task-789") {
207+
return { status: 0, stdout: "pushed\n", stderr: "" };
208+
}
209+
if (cmd === "gh-as") {
210+
return { status: 0, stdout: "https://github.com/tpsdev-ai/cli/pull/789\n", stderr: "" };
211+
}
212+
if (cmd === "tps") {
213+
throw new Error("tps agent commit should not run when reusing existing commits");
214+
}
215+
throw new Error(`unexpected command: ${cmd} ${args.join(" ")}`);
216+
});
217+
218+
await runAutoCommit(
219+
config,
220+
{ publishEvent: mock(async () => {}) },
221+
{
222+
taskId: "task-789",
223+
branchName: "feat/task-789",
224+
commitMessage: "feat: ship task 789",
225+
authorName: "Ember",
226+
authorEmail: "[email protected]",
227+
push: true,
228+
openPr: true,
229+
prRepo: "tpsdev-ai/cli",
230+
ghAgent: "ember",
231+
prTitle: "feat: ship task 789",
232+
},
233+
{ spawnSyncImpl },
234+
);
235+
236+
expect(calls).toEqual([
237+
{ cmd: "/usr/bin/git", args: ["symbolic-ref", "--quiet", "HEAD"] },
238+
{ cmd: "/usr/bin/git", args: ["status", "--porcelain"] },
239+
{ cmd: "/usr/bin/git", args: ["rev-parse", "--verify", "refs/remotes/origin/feat/task-789"] },
240+
{ cmd: "/usr/bin/git", args: ["rev-list", "--count", "origin/feat/task-789..HEAD"] },
241+
{ cmd: "/usr/bin/git", args: ["push", "-u", "origin", "feat/task-789"] },
242+
{
243+
cmd: "gh-as",
244+
args: [
245+
"ember",
246+
"pr",
247+
"create",
248+
"--repo",
249+
"tpsdev-ai/cli",
250+
"--head",
251+
"feat/task-789",
252+
"--title",
253+
"feat: ship task 789",
254+
"--body",
255+
"feat: ship task 789",
256+
],
257+
},
258+
]);
259+
});
260+
157261
test("publishes a blocker OrgEvent when runtime PR creation fails", async () => {
158262
const publishEvent = mock(async () => {});
159263
const spawnSyncImpl = mock((cmd: string, args: string[]) => {
160264
if (cmd === "/usr/bin/git" && args.join(" ") === "symbolic-ref --quiet HEAD") {
161265
return { status: 0, stdout: "refs/heads/feat/task-456\n", stderr: "" };
162266
}
267+
if (cmd === "/usr/bin/git" && args.join(" ") === "status --porcelain") {
268+
return { status: 0, stdout: " M file.ts\n", stderr: "" };
269+
}
270+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-parse --verify refs/remotes/origin/feat/task-456") {
271+
return { status: 1, stdout: "", stderr: "" };
272+
}
273+
if (cmd === "/usr/bin/git" && args.join(" ") === "rev-list --count HEAD") {
274+
return { status: 0, stdout: "0\n", stderr: "" };
275+
}
163276
if (cmd === "tps") {
164277
return { status: 0, stdout: "", stderr: "" };
165278
}

0 commit comments

Comments
 (0)