Skip to content

Commit 50024d5

Browse files
tkowalczykclaude
andcommitted
fix(orchestrator): commit agent changes before pushing branch
The orchestrator pushed the feature branch without committing the agent's file changes first, resulting in an empty branch and a 422 from GitHub ("No commits between main and branch"). - Add commitChanges() to stage and commit all workspace changes - Call it in runPipeline after validation passes, before git push Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 49e0fb0 commit 50024d5

File tree

8 files changed

+105
-6
lines changed

8 files changed

+105
-6
lines changed

dist/cli.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,15 @@ async function runAgent(options) {
300300

301301
// src/lib/pr.ts
302302
import { execaCommand } from "execa";
303+
async function commitChanges(cwd, issueNumber, title) {
304+
await execaCommand("git add -A", { cwd });
305+
try {
306+
await execaCommand(`git commit -m "feat(#${issueNumber}): ${title}"`, { cwd });
307+
return true;
308+
} catch {
309+
return false;
310+
}
311+
}
303312
async function pushBranch(cwd, branch, force = false) {
304313
const forceFlag = force ? "--force-with-lease " : "";
305314
await execaCommand(`git push ${forceFlag}-u origin ${branch}`, { cwd });
@@ -495,6 +504,7 @@ async function runPipeline(deps, issue, isRework, existingState) {
495504
}
496505
state = updateIssue(state, issue.number, { phase: "PR" });
497506
await saveState(statePath, state);
507+
await commitChanges(dir, issue.number, issue.title);
498508
const force = isRework;
499509
await pushBranch(dir, branch, force);
500510
const validationOutput = "All checks passed";

dist/index.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ interface CliOptions {
150150
}
151151
declare function startCli(argv: string[], options?: CliOptions): Promise<void>;
152152

153+
declare function commitChanges(cwd: string, issueNumber: number, title: string): Promise<boolean>;
153154
declare function pushBranch(cwd: string, branch: string, force?: boolean): Promise<void>;
154155
declare function buildPRBody(issue: Issue, validationOutput: string): string;
155156
declare function createPR(github: GitHubClient, config: ConductorConfig, issue: Issue, branch: string, validationOutput: string): Promise<number>;
@@ -178,4 +179,4 @@ declare function createWorkspace(config: ConductorConfig, issueNumber: number, t
178179
}>;
179180
declare function cleanupWorkspace(dir: string): Promise<void>;
180181

181-
export { type AgentResult, type CliOptions, type ConductorConfig, GitHubClient, type Issue, type IssueState, type OrchestratorDeps, type Phase, type QAResult, type State, type ValidationResult, buildPRBody, buildPrompt, cleanupWorkspace, createPR, createWorkspace, loadState, parseArgs, parseBlockedBy, parseConfig, pushBranch, renderTemplate, run, runAgent, runQA, runValidation, saveState, slugify, startCli, tick, updateIssue };
182+
export { type AgentResult, type CliOptions, type ConductorConfig, GitHubClient, type Issue, type IssueState, type OrchestratorDeps, type Phase, type QAResult, type State, type ValidationResult, buildPRBody, buildPrompt, cleanupWorkspace, commitChanges, createPR, createWorkspace, loadState, parseArgs, parseBlockedBy, parseConfig, pushBranch, renderTemplate, run, runAgent, runQA, runValidation, saveState, slugify, startCli, tick, updateIssue };

dist/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,15 @@ var GitHubClient = class {
298298

299299
// src/lib/pr.ts
300300
import { execaCommand } from "execa";
301+
async function commitChanges(cwd, issueNumber, title) {
302+
await execaCommand("git add -A", { cwd });
303+
try {
304+
await execaCommand(`git commit -m "feat(#${issueNumber}): ${title}"`, { cwd });
305+
return true;
306+
} catch {
307+
return false;
308+
}
309+
}
301310
async function pushBranch(cwd, branch, force = false) {
302311
const forceFlag = force ? "--force-with-lease " : "";
303312
await execaCommand(`git push ${forceFlag}-u origin ${branch}`, { cwd });
@@ -493,6 +502,7 @@ async function runPipeline(deps, issue, isRework, existingState) {
493502
}
494503
state = updateIssue(state, issue.number, { phase: "PR" });
495504
await saveState(statePath, state);
505+
await commitChanges(dir, issue.number, issue.title);
496506
const force = isRework;
497507
await pushBranch(dir, branch, force);
498508
const validationOutput = "All checks passed";
@@ -593,6 +603,7 @@ export {
593603
buildPRBody,
594604
buildPrompt,
595605
cleanupWorkspace,
606+
commitChanges,
596607
createPR,
597608
createWorkspace,
598609
loadState,

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type { Issue } from "./lib/github.js";
88
export { GitHubClient } from "./lib/github.js";
99
export type { OrchestratorDeps, Phase } from "./lib/orchestrator.js";
1010
export { parseBlockedBy, run, tick } from "./lib/orchestrator.js";
11-
export { buildPRBody, createPR, pushBranch } from "./lib/pr.js";
11+
export { buildPRBody, commitChanges, createPR, pushBranch } from "./lib/pr.js";
1212
export type { QAResult } from "./lib/qa.js";
1313
export { runQA } from "./lib/qa.js";
1414
export type { IssueState, State } from "./lib/state.js";

src/lib/orchestrator.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { buildPrompt, runAgent } from "./agent.js";
33
import type { ConductorConfig } from "./config.js";
44
import type { GitHubClient, Issue } from "./github.js";
55
import { parseBlockedBy, run, tick } from "./orchestrator.js";
6-
import { createPR, pushBranch } from "./pr.js";
6+
import { commitChanges, createPR, pushBranch } from "./pr.js";
77
import { runQA } from "./qa.js";
88
import { loadState, saveState } from "./state.js";
99
import { runValidation } from "./validation.js";
@@ -34,6 +34,7 @@ vi.mock("./qa.js", () => ({
3434
}));
3535

3636
vi.mock("./pr.js", () => ({
37+
commitChanges: vi.fn(),
3738
pushBranch: vi.fn(),
3839
createPR: vi.fn(),
3940
}));
@@ -306,6 +307,31 @@ describe("tick", () => {
306307
expect(secondCall?.[0].prompt).toContain("Unexpected token");
307308
});
308309

310+
it("commits changes before pushing branch", async () => {
311+
const github = createMockGitHub({
312+
listIssues: mockListIssues([issue]),
313+
});
314+
vi.mocked(createWorkspace).mockResolvedValue({
315+
dir: "/tmp/ws",
316+
branch: "conductor/42-fix-login-bug",
317+
});
318+
vi.mocked(runAgent).mockResolvedValue({ ok: true, attempts: 1 });
319+
vi.mocked(runValidation).mockResolvedValue({ ok: true });
320+
vi.mocked(runQA).mockReturnValue({ ok: true, skipped: true });
321+
vi.mocked(commitChanges).mockResolvedValue(true);
322+
vi.mocked(createPR).mockResolvedValue(101);
323+
324+
await tick({ github, config: makeConfig(), statePath: "/tmp/state.json" });
325+
326+
expect(commitChanges).toHaveBeenCalledWith("/tmp/ws", 42, "Fix login bug");
327+
expect(pushBranch).toHaveBeenCalled();
328+
329+
// commitChanges must be called before pushBranch
330+
const commitOrder = vi.mocked(commitChanges).mock.invocationCallOrder[0];
331+
const pushOrder = vi.mocked(pushBranch).mock.invocationCallOrder[0];
332+
expect(commitOrder).toBeLessThan(pushOrder ?? 0);
333+
});
334+
309335
it("passes validate.timeout_ms to runValidation", async () => {
310336
const github = createMockGitHub({
311337
listIssues: mockListIssues([issue]),

src/lib/orchestrator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { buildPrompt, runAgent } from "./agent.js";
22
import type { ConductorConfig } from "./config.js";
33
import type { GitHubClient, Issue } from "./github.js";
4-
import { createPR, pushBranch } from "./pr.js";
4+
import { commitChanges, createPR, pushBranch } from "./pr.js";
55
import { runQA } from "./qa.js";
66
import type { IssueState } from "./state.js";
77
import { loadState, saveState, updateIssue } from "./state.js";
@@ -140,7 +140,8 @@ async function runPipeline(
140140
state = updateIssue(state, issue.number, { phase: "PR" });
141141
await saveState(statePath, state);
142142

143-
// PR: push and create
143+
// PR: commit, push, and create
144+
await commitChanges(dir, issue.number, issue.title);
144145
const force = isRework;
145146
await pushBranch(dir, branch, force);
146147
const validationOutput = "All checks passed";

src/lib/pr.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { execaCommand } from "execa";
22
import { describe, expect, it, type MockedFunction, vi } from "vitest";
33
import type { ConductorConfig } from "./config.js";
44
import type { Issue } from "./github.js";
5-
import { buildPRBody, createPR, pushBranch } from "./pr.js";
5+
import { buildPRBody, commitChanges, createPR, pushBranch } from "./pr.js";
66

77
vi.mock("execa");
88

@@ -31,6 +31,42 @@ describe("pushBranch", () => {
3131
});
3232
});
3333

34+
describe("commitChanges", () => {
35+
it("stages all changes and commits with issue-based message", async () => {
36+
mockExecaCommand.mockResolvedValue({} as never);
37+
38+
await commitChanges("/tmp/ws", 42, "Fix login bug");
39+
40+
expect(mockExecaCommand).toHaveBeenCalledWith("git add -A", { cwd: "/tmp/ws" });
41+
expect(mockExecaCommand).toHaveBeenCalledWith(
42+
expect.stringContaining("git commit"),
43+
expect.objectContaining({ cwd: "/tmp/ws" })
44+
);
45+
const commitCall = mockExecaCommand.mock.calls.find((c) =>
46+
(c[0] as string).includes("git commit")
47+
);
48+
expect(commitCall?.[0]).toContain("#42");
49+
});
50+
51+
it("returns true when there are changes to commit", async () => {
52+
mockExecaCommand.mockResolvedValue({} as never);
53+
54+
const result = await commitChanges("/tmp/ws", 42, "Fix login bug");
55+
56+
expect(result).toBe(true);
57+
});
58+
59+
it("returns false when there are no changes to commit", async () => {
60+
mockExecaCommand
61+
.mockResolvedValueOnce({} as never) // git add -A
62+
.mockRejectedValueOnce(new Error("nothing to commit")); // git commit fails
63+
64+
const result = await commitChanges("/tmp/ws", 42, "Fix login bug");
65+
66+
expect(result).toBe(false);
67+
});
68+
});
69+
3470
const issue: Issue = {
3571
number: 42,
3672
title: "Fix login bug",

src/lib/pr.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ import { execaCommand } from "execa";
22
import type { ConductorConfig } from "./config.js";
33
import type { GitHubClient, Issue } from "./github.js";
44

5+
export async function commitChanges(
6+
cwd: string,
7+
issueNumber: number,
8+
title: string
9+
): Promise<boolean> {
10+
await execaCommand("git add -A", { cwd });
11+
try {
12+
await execaCommand(`git commit -m "feat(#${issueNumber}): ${title}"`, { cwd });
13+
return true;
14+
} catch {
15+
return false;
16+
}
17+
}
18+
519
export async function pushBranch(cwd: string, branch: string, force = false): Promise<void> {
620
const forceFlag = force ? "--force-with-lease " : "";
721
await execaCommand(`git push ${forceFlag}-u origin ${branch}`, { cwd });

0 commit comments

Comments
 (0)