fix: skip merger agent when squash merge stages nothing

This commit is contained in:
Dustin Byrne
2026-03-28 10:54:50 -04:00
parent 00954f2bbc
commit 0e0643a99e
4 changed files with 171 additions and 78 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Skip merger agent when squash merge stages nothing (branch already merged via dependency)

View File

@@ -55,7 +55,8 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
/** /**
* Set up execSync to handle the standard merge flow: * Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached, branch -d * rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*/ */
function setupHappyPathExecSync() { function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
@@ -64,6 +65,9 @@ function setupHappyPathExecSync() {
if (cmdStr.includes("git log")) return "- feat: something" as any; if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any; if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from(""); if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any; if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from(""); if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from(""); if (cmdStr.includes("worktree remove")) return Buffer.from("");
@@ -186,6 +190,68 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
}); });
}); });
describe("aiMergeTask — empty squash merge (branch already merged via dep)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("skips agent and still completes when squash stages nothing", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Squash staged nothing → "0"
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
expect(result.merged).toBe(true);
// Agent should NOT have been spawned
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
// Task should still be moved to done
expect(store.moveTask).toHaveBeenCalledWith("KB-050", "done");
});
it("still cleans up branch and worktree when squash is empty", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
// Branch should be deleted
const branchDeleteCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("branch -d"),
);
expect(branchDeleteCall).toBeDefined();
expect(result.branchDeleted).toBe(true);
// Worktree should be removed
const worktreeRemoveCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(worktreeRemoveCall).toBeDefined();
expect(result.worktreeRemoved).toBe(true);
});
});
describe("aiMergeTask — includeTaskIdInCommit setting", () => { describe("aiMergeTask — includeTaskIdInCommit setting", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -194,6 +194,19 @@ export async function aiMergeTask(
cwd: rootDir, cwd: rootDir,
stdio: "pipe", stdio: "pipe",
}); });
// If the squash staged nothing, the branch's changes are already on main
// (e.g. branch was based on a dep that has since been merged). Skip the
// agent entirely — there is nothing to commit.
const squashIsEmpty = execSync(
"git diff --cached --quiet 2>&1; echo $?",
{ cwd: rootDir, encoding: "utf-8" },
).trim() === "0";
if (squashIsEmpty) {
mergerLog.log(`${taskId}: squash merge staged nothing — branch already merged via dependency`);
result.merged = true;
}
} catch { } catch {
// Conflicts or other merge issue — check if it's conflicts // Conflicts or other merge issue — check if it's conflicts
try { try {
@@ -220,79 +233,82 @@ export async function aiMergeTask(
} }
} }
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message // 5. Spawn pi agent to resolve conflicts (if any) and write commit message.
await store.updateTask(taskId, { status: "merging" }); // Skip entirely when the squash staged nothing (branch already merged via dep).
if (!result.merged) {
await store.updateTask(taskId, { status: "merging" });
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`); mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
const agentLogger = new AgentLogger({ const agentLogger = new AgentLogger({
store, store,
taskId, taskId,
agent: "merger", agent: "merger",
// Merger callbacks don't include taskId — wrap to match AgentLogger signature // Merger callbacks don't include taskId — wrap to match AgentLogger signature
onAgentText: options.onAgentText onAgentText: options.onAgentText
? (_id, delta) => options.onAgentText!(delta) ? (_id, delta) => options.onAgentText!(delta)
: undefined, : undefined,
onAgentTool: options.onAgentTool onAgentTool: options.onAgentTool
? (_id, name) => options.onAgentTool!(name) ? (_id, name) => options.onAgentTool!(name)
: undefined, : undefined,
}); });
// Forward model settings from store so the merger honours the user's model choice // Forward model settings from store so the merger honours the user's model choice
const { session } = await createKbAgent({ const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: buildMergeSystemPrompt(includeTaskId),
tools: "coding",
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
// Notify the caller so it can track/dispose the session externally (e.g. on global pause)
options.onSession?.(session);
try {
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
await session.prompt(prompt);
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
checkSessionError(session);
// 6. Verify the commit happened — if there are still staged changes, agent didn't commit
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", systemPrompt: buildMergeSystemPrompt(includeTaskId),
}).trim(); tools: "coding",
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
if (staged !== "0") { // Notify the caller so it can track/dispose the session externally (e.g. on global pause)
mergerLog.log("Agent didn't commit — committing with fallback message"); options.onSession?.(session);
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
}
result.merged = true;
} catch (err: any) {
// Agent failed — try to abort the merge
mergerLog.error(`Agent failed: ${err.message}`);
// Check if the error is a usage-limit error and trigger global pause
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
}
try { try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" }); const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
} catch { /* */ } await session.prompt(prompt);
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
} finally { // Re-raise errors that pi-coding-agent swallowed after exhausting retries.
await agentLogger.flush(); checkSessionError(session);
session.dispose();
// 6. Verify the commit happened — if there are still staged changes, agent didn't commit
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged !== "0") {
mergerLog.log("Agent didn't commit — committing with fallback message");
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
}
result.merged = true;
} catch (err: any) {
// Agent failed — try to abort the merge
mergerLog.error(`Agent failed: ${err.message}`);
// Check if the error is a usage-limit error and trigger global pause
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
}
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ }
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
} finally {
await agentLogger.flush();
session.dispose();
}
} }
// 7. Delete branch (always per-task, regardless of worktree sharing) // 7. Delete branch (always per-task, regardless of worktree sharing)

View File

@@ -291,10 +291,11 @@ describe("In-review merge handling after restart", () => {
// Branch exists, merge succeeds, no conflicts // Branch exists, merge succeeds, no conflicts
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
// git diff --cached --quiet check: return "0" (clean) const cmdStr = String(cmd);
if (typeof cmd === "string" && cmd.includes("git diff --cached")) { // Post-squash check: squash staged changes → "1"
return "0" as any; if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
} // Post-agent check: agent committed → "0"
if (cmdStr.includes("diff --cached")) return "0" as any;
return Buffer.from(""); return Buffer.from("");
}); });
@@ -317,9 +318,9 @@ describe("In-review merge handling after restart", () => {
store.moveTask.mockResolvedValue(makeTask(taskId, "done")); store.moveTask.mockResolvedValue(makeTask(taskId, "done"));
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("git diff --cached")) { const cmdStr = String(cmd);
return "0" as any; if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
} if (cmdStr.includes("diff --cached")) return "0" as any;
return Buffer.from(""); return Buffer.from("");
}); });
mockAgentSuccess(); mockAgentSuccess();
@@ -336,9 +337,9 @@ describe("In-review merge handling after restart", () => {
// Branch exists, merge starts, agent creates but prompt fails // Branch exists, merge starts, agent creates but prompt fails
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("git diff --cached")) { const cmdStr = String(cmd);
return "0" as any; if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
} if (cmdStr.includes("diff --cached")) return "0" as any;
return Buffer.from(""); return Buffer.from("");
}); });
@@ -616,7 +617,12 @@ describe("Crash scenario edge cases", () => {
const store = createMockStore(); const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("KB-091", "in-review")); store.getTask.mockResolvedValue(makeTaskDetail("KB-091", "in-review"));
mockedExecSync.mockReturnValue(Buffer.from("")); mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
// Post-squash check: squash staged changes → "1"
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
return Buffer.from("");
});
// Agent prompt rejects (simulating kill during merge) // Agent prompt rejects (simulating kill during merge)
mockedCreateHaiAgent.mockResolvedValue({ mockedCreateHaiAgent.mockResolvedValue({