fix: skip merger agent when squash merge stages nothing
This commit is contained in:
@@ -55,7 +55,8 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
@@ -64,6 +65,9 @@ function setupHappyPathExecSync() {
|
||||
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("");
|
||||
// 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("branch -d") || cmdStr.includes("branch -D")) 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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -194,6 +194,19 @@ export async function aiMergeTask(
|
||||
cwd: rootDir,
|
||||
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 {
|
||||
// Conflicts or other merge issue — check if it's conflicts
|
||||
try {
|
||||
@@ -220,79 +233,82 @@ export async function aiMergeTask(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message
|
||||
await store.updateTask(taskId, { status: "merging" });
|
||||
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message.
|
||||
// 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({
|
||||
store,
|
||||
taskId,
|
||||
agent: "merger",
|
||||
// Merger callbacks don't include taskId — wrap to match AgentLogger signature
|
||||
onAgentText: options.onAgentText
|
||||
? (_id, delta) => options.onAgentText!(delta)
|
||||
: undefined,
|
||||
onAgentTool: options.onAgentTool
|
||||
? (_id, name) => options.onAgentTool!(name)
|
||||
: undefined,
|
||||
});
|
||||
const agentLogger = new AgentLogger({
|
||||
store,
|
||||
taskId,
|
||||
agent: "merger",
|
||||
// Merger callbacks don't include taskId — wrap to match AgentLogger signature
|
||||
onAgentText: options.onAgentText
|
||||
? (_id, delta) => options.onAgentText!(delta)
|
||||
: undefined,
|
||||
onAgentTool: options.onAgentTool
|
||||
? (_id, name) => options.onAgentTool!(name)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Forward model settings from store so the merger honours the user's model choice
|
||||
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 $?", {
|
||||
// Forward model settings from store so the merger honours the user's model choice
|
||||
const { session } = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
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,
|
||||
});
|
||||
|
||||
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" },
|
||||
);
|
||||
}
|
||||
// Notify the caller so it can track/dispose the session externally (e.g. on global pause)
|
||||
options.onSession?.(session);
|
||||
|
||||
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();
|
||||
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,
|
||||
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)
|
||||
|
||||
@@ -291,10 +291,11 @@ describe("In-review merge handling after restart", () => {
|
||||
|
||||
// Branch exists, merge succeeds, no conflicts
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
// git diff --cached --quiet check: return "0" (clean)
|
||||
if (typeof cmd === "string" && cmd.includes("git diff --cached")) {
|
||||
return "0" as any;
|
||||
}
|
||||
const cmdStr = String(cmd);
|
||||
// Post-squash check: squash staged changes → "1"
|
||||
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("");
|
||||
});
|
||||
|
||||
@@ -317,9 +318,9 @@ describe("In-review merge handling after restart", () => {
|
||||
store.moveTask.mockResolvedValue(makeTask(taskId, "done"));
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git diff --cached")) {
|
||||
return "0" as any;
|
||||
}
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
return Buffer.from("");
|
||||
});
|
||||
mockAgentSuccess();
|
||||
@@ -336,9 +337,9 @@ describe("In-review merge handling after restart", () => {
|
||||
|
||||
// Branch exists, merge starts, agent creates but prompt fails
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git diff --cached")) {
|
||||
return "0" as any;
|
||||
}
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
@@ -616,7 +617,12 @@ describe("Crash scenario edge cases", () => {
|
||||
const store = createMockStore();
|
||||
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)
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user