Skip to content

Commit a68d38b

Browse files
feat: auto-detect primary branch for commit_and_push
1 parent 9b70468 commit a68d38b

File tree

4 files changed

+254
-10
lines changed

4 files changed

+254
-10
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,11 @@ Check if all workflow steps are completed and you're ready to commit & push.
378378
### `commit_and_push`
379379
Automatically run `git add`, `git commit`, and `git push` after the ready check passes.
380380

381+
**Auto-detection of primary branch:** If no `branch` is specified, the tool automatically detects your project's primary branch by checking for `origin/main` first, then falling back to `origin/master`. This eliminates the need to specify the branch parameter for most projects.
382+
381383
**Parameters:**
382384
- `commitMessage` (string, required): Conventional commit message to use
383-
- `branch` (string, optional): Target branch to push (defaults to current branch)
385+
- `branch` (string, optional): Target branch to push. If omitted, auto-detects primary branch (main or master)
384386

385387
### `perform_release`
386388
Record the release after you've committed and pushed. Required before you can complete the task.

git-helpers.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,28 @@ export async function getCurrentBranch() {
150150
return "";
151151
}
152152
}
153+
154+
export async function getPrimaryBranch() {
155+
try {
156+
// Check if 'main' exists on origin
157+
const { stdout: mainCheck } = await exec("git rev-parse --verify origin/main");
158+
if (mainCheck.trim()) {
159+
return "main";
160+
}
161+
} catch {
162+
// main doesn't exist, fall through to master check
163+
}
164+
165+
try {
166+
// Check if 'master' exists on origin
167+
const { stdout: masterCheck } = await exec("git rev-parse --verify origin/master");
168+
if (masterCheck.trim()) {
169+
return "master";
170+
}
171+
} catch {
172+
// master doesn't exist either
173+
}
174+
175+
// Fallback to current branch if neither main nor master exist
176+
return await getCurrentBranch();
177+
}

tests/handlers.test.js

Lines changed: 217 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ test("commit_and_push commits changes and pushes", async () => {
6262
hasTestChanges: async () => true,
6363
getStagedChanges: async () => [{ status: "M", path: "src/index.js" }],
6464
getCurrentBranch: async () => "main",
65+
getPrimaryBranch: async () => "main",
6566
getLastCommitMessage: async () => "",
6667
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
6768
};
@@ -129,6 +130,7 @@ test("run_full_workflow iterates until commit/release complete with clean tree",
129130
hasTestChanges: async () => true,
130131
getStagedChanges: async () => [],
131132
getCurrentBranch: async () => "main",
133+
getPrimaryBranch: async () => "main",
132134
getLastCommitMessage: async () => "chore: existing",
133135
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
134136
};
@@ -163,6 +165,172 @@ test("run_full_workflow iterates until commit/release complete with clean tree",
163165
});
164166
});
165167

168+
test("commit_and_push uses primary branch when no branch specified", async () => {
169+
await withWorkflowState(async (workflowState) => {
170+
workflowState.state.readyToCommit = true;
171+
workflowState.state.readyCheckCompleted = true;
172+
workflowState.state.testsSkipped = false;
173+
workflowState.state.testsCreated = true;
174+
workflowState.state.testsPassed = true;
175+
workflowState.state.currentPhase = "commit";
176+
await workflowState.save();
177+
178+
const commands = [];
179+
const execStub = async (command) => {
180+
commands.push(command);
181+
return { stdout: "" };
182+
};
183+
184+
const git = {
185+
hasWorkingChanges: async () => true,
186+
hasStagedChanges: async () => false,
187+
hasTestChanges: async () => true,
188+
getStagedChanges: async () => [{ status: "M", path: "src/index.js" }],
189+
getCurrentBranch: async () => "feature/test",
190+
getPrimaryBranch: async () => "main",
191+
getLastCommitMessage: async () => "",
192+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
193+
};
194+
195+
const response = await handleToolCall({
196+
request: createRequest("commit_and_push", { commitMessage: "feat: test" }),
197+
normalizeRequestArgs,
198+
workflowState,
199+
exec: execStub,
200+
git,
201+
utils,
202+
});
203+
204+
const pushCommand = commands.find((command) => command.startsWith("git push"));
205+
assert.ok(pushCommand, "git push should be executed");
206+
assert.ok(
207+
pushCommand.includes("main"),
208+
"push command should use detected primary branch (main)"
209+
);
210+
assert.ok(
211+
response.content[0].text.includes("Commit and push completed!"),
212+
"response should confirm commit and push"
213+
);
214+
});
215+
});
216+
217+
test("commit_and_push falls back to master when main not found", async () => {
218+
await withWorkflowState(async (workflowState) => {
219+
workflowState.state.readyToCommit = true;
220+
workflowState.state.readyCheckCompleted = true;
221+
workflowState.state.testsSkipped = false;
222+
workflowState.state.testsCreated = true;
223+
workflowState.state.testsPassed = true;
224+
workflowState.state.currentPhase = "commit";
225+
await workflowState.save();
226+
227+
const commands = [];
228+
const execStub = async (command) => {
229+
commands.push(command);
230+
return { stdout: "" };
231+
};
232+
233+
const git = {
234+
hasWorkingChanges: async () => true,
235+
hasStagedChanges: async () => false,
236+
hasTestChanges: async () => true,
237+
getStagedChanges: async () => [{ status: "M", path: "src/index.js" }],
238+
getCurrentBranch: async () => "feature/test",
239+
getPrimaryBranch: async () => "master",
240+
getLastCommitMessage: async () => "",
241+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
242+
};
243+
244+
const response = await handleToolCall({
245+
request: createRequest("commit_and_push", { commitMessage: "feat: test" }),
246+
normalizeRequestArgs,
247+
workflowState,
248+
exec: execStub,
249+
git,
250+
utils,
251+
});
252+
253+
const pushCommand = commands.find((command) => command.startsWith("git push"));
254+
assert.ok(pushCommand, "git push should be executed");
255+
assert.ok(
256+
pushCommand.includes("master"),
257+
"push command should use detected primary branch (master)"
258+
);
259+
});
260+
});
261+
262+
test("run_full_workflow uses provided branch for release push when commit is clean", async () => {
263+
await withWorkflowState(async (workflowState) => {
264+
workflowState.state.taskDescription = "Ship feature";
265+
workflowState.state.taskType = "feature";
266+
workflowState.state.currentPhase = "commit";
267+
workflowState.state.bugFixed = true;
268+
workflowState.state.testsCreated = true;
269+
workflowState.state.testsPassed = true;
270+
workflowState.state.documentationCreated = true;
271+
workflowState.state.readyToCommit = true;
272+
workflowState.state.readyCheckCompleted = true;
273+
workflowState.state.commitAndPushCompleted = false;
274+
workflowState.state.released = false;
275+
workflowState.state.documentationType = "README";
276+
workflowState.state.documentationSummary = "Docs";
277+
workflowState.state.fixSummary = "Feature done";
278+
await workflowState.save();
279+
280+
const commands = [];
281+
const execStub = async (command) => {
282+
commands.push(command);
283+
if (command === "git status --porcelain") {
284+
return { stdout: "" };
285+
}
286+
return { stdout: "" };
287+
};
288+
289+
const git = {
290+
hasWorkingChanges: async () => false,
291+
hasStagedChanges: async () => false,
292+
hasTestChanges: async () => true,
293+
getStagedChanges: async () => [],
294+
getCurrentBranch: async () => "feature/local",
295+
getPrimaryBranch: async () => "main",
296+
getLastCommitMessage: async () => "chore: existing",
297+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
298+
};
299+
300+
const branchName = "master";
301+
const response = await handleToolCall({
302+
request: createRequest("run_full_workflow", {
303+
summary: "Feature done",
304+
testCommand: "npm test",
305+
documentationType: "README",
306+
documentationSummary: "Docs",
307+
commitMessage: "feat: ship feature",
308+
branch: branchName,
309+
releaseCommand: "npm run release:patch",
310+
}),
311+
normalizeRequestArgs,
312+
workflowState,
313+
exec: execStub,
314+
git,
315+
utils,
316+
});
317+
318+
assert.ok(
319+
response.content[0].text.includes("✅ Full workflow completed successfully"),
320+
"run_full_workflow should finish successfully"
321+
);
322+
323+
const tagPushCommand = commands.find((command) => command.startsWith("git push --follow-tags"));
324+
assert.ok(tagPushCommand, "release should push tags");
325+
assert.ok(
326+
/origin\s+'?master'?/.test(tagPushCommand),
327+
`release push should target the provided branch (${branchName})`
328+
);
329+
330+
assert.equal(workflowState.state.currentPhase, "idle");
331+
});
332+
});
333+
166334
test("project_summary_data reads persisted file and falls back", async () => {
167335
await withWorkflowState(async (workflowState) => {
168336
// Seed some history
@@ -174,7 +342,16 @@ test("project_summary_data reads persisted file and falls back", async () => {
174342
normalizeRequestArgs,
175343
workflowState,
176344
exec: async () => ({ stdout: "" }),
177-
git: {},
345+
git: {
346+
hasWorkingChanges: async () => false,
347+
hasStagedChanges: async () => false,
348+
hasTestChanges: async () => true,
349+
getStagedChanges: async () => [],
350+
getCurrentBranch: async () => "main",
351+
getPrimaryBranch: async () => "main",
352+
getLastCommitMessage: async () => "",
353+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
354+
},
178355
utils,
179356
});
180357

@@ -199,7 +376,16 @@ test("project_summary aggregates task types and recent activity", async () => {
199376
normalizeRequestArgs,
200377
workflowState,
201378
exec: async () => ({ stdout: "" }),
202-
git: {},
379+
git: {
380+
hasWorkingChanges: async () => false,
381+
hasStagedChanges: async () => false,
382+
hasTestChanges: async () => true,
383+
getStagedChanges: async () => [],
384+
getCurrentBranch: async () => "main",
385+
getPrimaryBranch: async () => "main",
386+
getLastCommitMessage: async () => "",
387+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
388+
},
203389
utils,
204390
});
205391

@@ -239,6 +425,7 @@ test("run_full_workflow executes all steps successfully", async () => {
239425
hasTestChanges: async () => true,
240426
getStagedChanges: async () => [{ status: "M", path: "src/app.js" }],
241427
getCurrentBranch: async () => "main",
428+
getPrimaryBranch: async () => "main",
242429
getLastCommitMessage: async () => "",
243430
workingTreeSummary: () =>
244431
workingChanges
@@ -315,6 +502,7 @@ test("run_full_workflow resumes from current phase when steps are already comple
315502
hasTestChanges: async () => true,
316503
getStagedChanges: async () => [],
317504
getCurrentBranch: async () => "main",
505+
getPrimaryBranch: async () => "main",
318506
getLastCommitMessage: async () => "",
319507
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
320508
};
@@ -373,7 +561,16 @@ test("run_full_workflow validates required arguments", async () => {
373561
normalizeRequestArgs,
374562
workflowState,
375563
exec: async () => ({ stdout: "" }),
376-
git: {},
564+
git: {
565+
hasWorkingChanges: async () => false,
566+
hasStagedChanges: async () => false,
567+
hasTestChanges: async () => true,
568+
getStagedChanges: async () => [],
569+
getCurrentBranch: async () => "main",
570+
getPrimaryBranch: async () => "main",
571+
getLastCommitMessage: async () => "",
572+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
573+
},
377574
utils,
378575
});
379576

@@ -400,7 +597,16 @@ test("force_complete_task records entry and resets state", async () => {
400597
normalizeRequestArgs,
401598
workflowState,
402599
exec: async () => ({ stdout: "" }),
403-
git: {},
600+
git: {
601+
hasWorkingChanges: async () => false,
602+
hasStagedChanges: async () => false,
603+
hasTestChanges: async () => true,
604+
getStagedChanges: async () => [],
605+
getCurrentBranch: async () => "main",
606+
getPrimaryBranch: async () => "main",
607+
getLastCommitMessage: async () => "",
608+
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
609+
},
404610
utils,
405611
});
406612

@@ -436,7 +642,8 @@ test("perform_release blocks when new changes detected", async () => {
436642
hasTestChanges: async () => true,
437643
getStagedChanges: async () => [],
438644
getCurrentBranch: async () => "main",
439-
getLastCommitMessage: async () => "fix: adjust layout",
645+
getPrimaryBranch: async () => "main",
646+
getLastCommitMessage: async () => "",
440647
workingTreeSummary: () => ({ hasChanges: true, lines: ["?? new-file.js"] }),
441648
};
442649

@@ -466,6 +673,7 @@ test("continue_workflow warns when workflow is idle", async () => {
466673
hasTestChanges: async () => false,
467674
getStagedChanges: async () => [],
468675
getCurrentBranch: async () => "main",
676+
getPrimaryBranch: async () => "main",
469677
getLastCommitMessage: async () => "",
470678
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
471679
};
@@ -499,8 +707,9 @@ test("continue_workflow resets to commit when new changes detected", async () =>
499707
hasTestChanges: async () => true,
500708
getStagedChanges: async () => [],
501709
getCurrentBranch: async () => "main",
502-
getLastCommitMessage: async () => "fix: adjust layout",
503-
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
710+
getPrimaryBranch: async () => "main",
711+
getLastCommitMessage: async () => "",
712+
workingTreeSummary: () => ({ hasChanges: true, lines: ["?? new-file.js"] }),
504713
};
505714

506715
const response = await handleToolCall({
@@ -543,6 +752,7 @@ test("commit_and_push recognizes already committed work", async () => {
543752
hasTestChanges: async () => true,
544753
getStagedChanges: async () => [],
545754
getCurrentBranch: async () => "main",
755+
getPrimaryBranch: async () => "main",
546756
getLastCommitMessage: async () => "fix: previous work",
547757
workingTreeSummary: () => ({ hasChanges: false, lines: [] }),
548758
};

tools/handlers.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,8 +426,9 @@ async function handleCommitAndPush(args, context) {
426426
typeof args.commitMessage === "string" ? args.commitMessage.trim() : "";
427427

428428
const requestedBranch = typeof args.branch === "string" ? args.branch.trim() : "";
429+
const primaryBranch = await git.getPrimaryBranch();
429430
const currentBranch = await git.getCurrentBranch();
430-
const branchForPush = requestedBranch || currentBranch || "";
431+
const branchForPush = requestedBranch || primaryBranch || currentBranch || "";
431432

432433
const hasWorkingChanges = await git.hasWorkingChanges();
433434

@@ -817,6 +818,12 @@ async function handleRunFullWorkflow(args, { workflowState, exec, git, utils })
817818

818819
await workflowState.ensurePrimaryFile();
819820

821+
const requestedBranch = typeof args.branch === "string" ? args.branch.trim() : "";
822+
if (requestedBranch) {
823+
workflowState.state.lastPushBranch = requestedBranch;
824+
await workflowState.save();
825+
}
826+
820827
const requiredStrings = [
821828
{ key: "summary", message: "Provide a non-empty 'summary' for mark_bug_fixed." },
822829
{ key: "testCommand", message: "Provide a non-empty 'testCommand' for run_tests." },
@@ -906,7 +913,7 @@ async function handleRunFullWorkflow(args, { workflowState, exec, git, utils })
906913
const resp = await executeStep("commit_and_push", handleCommitAndPush, [
907914
{
908915
commitMessage: args.commitMessage,
909-
branch: typeof args.branch === "string" ? args.branch : "",
916+
branch: requestedBranch,
910917
},
911918
{ workflowState, exec, git, utils },
912919
]);

0 commit comments

Comments
 (0)