fix: skip merger agent when squash merge stages nothing
This commit is contained in:
5
.changeset/skip-empty-squash-merge.md
Normal file
5
.changeset/skip-empty-squash-merge.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Skip merger agent when squash merge stages nothing (branch already merged via dependency)
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user