When an extension queues a follow-up during `agent_end` it gets stuck on the follow-up queue until after the next user message. Add a hasQueuedMessages() check to _handlePostAgentRun so the existing while/continue loop drains them. - One-line fix in _handlePostAgentRun - Integration test in agent-session-queue - git-merge-and-resolve example extension with tests
116 lines
4.0 KiB
TypeScript
116 lines
4.0 KiB
TypeScript
/**
|
|
* Merge and Resolve
|
|
*
|
|
* Keeps the working branch up to date with its upstream tracking ref.
|
|
* After each agent turn, fetches and merges. Clean merges complete
|
|
* silently. When conflicts arise, the working tree is left dirty and
|
|
* the agent receives a follow-up message listing each conflict block
|
|
* with file, line range, and ours/theirs sections so it can resolve them.
|
|
* Also re-sends unresolved conflicts from a previous incomplete merge.
|
|
*
|
|
* Start pi with this extension:
|
|
* pi -e ./examples/extensions/git-merge-and-resolve.ts
|
|
*/
|
|
import { createReadStream } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
|
interface ConflictBlock {
|
|
file: string;
|
|
startLine: number;
|
|
separatorLine: number;
|
|
endLine: number;
|
|
}
|
|
|
|
/** Parse conflict markers from working tree files with unmerged paths. */
|
|
async function findConflicts(pi: ExtensionAPI, cwd: string): Promise<ConflictBlock[]> {
|
|
const { stdout, code } = await pi.exec("git", ["diff", "--name-only", "--diff-filter=U"]);
|
|
if (code !== 0 || !stdout.trim()) return [];
|
|
|
|
const blocks: ConflictBlock[] = [];
|
|
for (const file of stdout.trim().split("\n")) {
|
|
try {
|
|
const rl = createInterface({ input: createReadStream(join(cwd, file), "utf-8") });
|
|
let lineNo = 0;
|
|
let blockStart: number | undefined;
|
|
let separatorLine: number | undefined;
|
|
for await (const line of rl) {
|
|
lineNo++;
|
|
if (line.startsWith("<<<<<<<")) {
|
|
blockStart = lineNo;
|
|
separatorLine = undefined;
|
|
} else if (line.startsWith("=======") && blockStart !== undefined) {
|
|
separatorLine = lineNo;
|
|
} else if (line.startsWith(">>>>>>>") && blockStart !== undefined && separatorLine !== undefined) {
|
|
blocks.push({ file, startLine: blockStart, separatorLine, endLine: lineNo });
|
|
blockStart = undefined;
|
|
separatorLine = undefined;
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function formatRange(start: number, end: number): string {
|
|
if (start > end) return "empty";
|
|
if (start === end) return `${start}`;
|
|
return `${start}-${end}`;
|
|
}
|
|
|
|
function formatConflicts(ref: string, blocks: ConflictBlock[]): string {
|
|
const lines = [`Merged ${ref} with conflicts:`, ""];
|
|
for (const b of blocks) {
|
|
const ours = formatRange(b.startLine + 1, b.separatorLine - 1);
|
|
const theirs = formatRange(b.separatorLine + 1, b.endLine - 1);
|
|
lines.push(` ${b.file}:${b.startLine}-${b.endLine} (ours ${ours}, theirs ${theirs})`);
|
|
}
|
|
lines.push("", "Resolve these conflicts.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.on("agent_end", async (_event, ctx) => {
|
|
const { code: revParseCode } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
if (revParseCode !== 0) return;
|
|
|
|
let ref = "MERGE_HEAD";
|
|
|
|
// If not already in a merge, attempt one
|
|
const { code: mergeHeadCode } = await pi.exec("git", ["rev-parse", "MERGE_HEAD"]);
|
|
if (mergeHeadCode !== 0) {
|
|
// Only attempt a new merge if the working tree is clean
|
|
const { stdout: status } = await pi.exec("git", ["status", "--porcelain"]);
|
|
if (status.trim()) return;
|
|
|
|
const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
|
|
"rev-parse",
|
|
"--abbrev-ref",
|
|
"--symbolic-full-name",
|
|
"@{u}",
|
|
]);
|
|
if (upstreamCode !== 0) return;
|
|
|
|
ref = upstream.trim();
|
|
const remote = ref.split("/")[0];
|
|
ctx.ui.notify(`git-merge-and-resolve: fetching ${remote}, merging ${ref}`, "info");
|
|
|
|
const { code: fetchCode, stderr: fetchErr } = await pi.exec("git", ["fetch", remote]);
|
|
if (fetchCode !== 0) {
|
|
ctx.ui.notify(`git-merge-and-resolve: fetch failed: ${fetchErr.trim()}`, "warning");
|
|
return;
|
|
}
|
|
|
|
const { code: mergeCode } = await pi.exec("git", ["merge", "--no-ff", ref]);
|
|
if (mergeCode === 0) return;
|
|
}
|
|
|
|
// Either we just merged with conflicts, or we were already in an unfinished merge
|
|
const conflicts = await findConflicts(pi, ctx.cwd);
|
|
if (conflicts.length === 0) return;
|
|
|
|
pi.sendUserMessage(formatConflicts(ref, conflicts), { deliverAs: "followUp" });
|
|
});
|
|
}
|