UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

184 lines (183 loc) • 7.97 kB
/** * Resolves the content conflicts a store merge can settle without a person: * tasks.md per task id against the merge base, generated summaries from the * incoming side, and the append-only record files (findings, notes, journal, * review queue, topic archives) as a union of both sides. Everything else * stays conflicted so the caller aborts and names it. * * Any file left unresolved aborts the whole merge, and every later auto-save * then commits locally and fails to push, so a store drifts further from its * remote each session. Every file phren itself writes during normal use needs * a strategy here. */ import { execFileSync, spawnSync } from "child_process"; import * as crypto from "crypto"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { isTaskFileName } from "../filenames.js"; import { debugLog, EXEC_TIMEOUT_MS } from "../shared.js"; import { errorMessage } from "../utils.js"; import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { mergeFindings, mergeTask } from "../content/validate.js"; import { KNOWS_END, KNOWS_START, NOW_END, NOW_START, upsertBlock } from "../content/summarize.js"; import { mergeTasksByBid } from "./task-merge.js"; export function conflictStrategy(relFile) { const file = relFile.replace(/\\/g, "/"); const name = path.posix.basename(file).toLowerCase(); if (isTaskFileName(name)) return "tasks"; if (name === "findings.md") return "findings"; if (/^\.config\/task-archive\/[^/]+\.md$/i.test(file)) return "archive"; if (/(^|\/)reference\/topics\/[^/]+\.md$/i.test(file)) return "topic"; if (name === "summary.md") return "summary"; if (name === "review.md") return "union"; if (/(^|\/)(notes|journal)\/[^/]+\.md$/i.test(file)) return "union"; return null; } function git(cwd, args) { return execFileSync("git", args, { env: nonInteractiveGitEnv(), cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: EXEC_TIMEOUT_MS, }); } /** One index stage of a conflicted path (1 base, 2 local, 3 incoming), or null when that side has no file. */ function stage(cwd, n, relFile) { try { return git(cwd, ["show", `:${n}:${relFile}`]); } catch { return null; } } const BLOCK_RE = (start, end) => new RegExp(`${start.replace("-->", "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*-->[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n*`); function splitBlock(content, start, end) { const match = BLOCK_RE(start, end).exec(content); if (!match) return { rest: content, block: null }; return { rest: content.slice(0, match.index) + content.slice(match.index + match[0].length), block: match[0].replace(/\n+$/, "") }; } /** A line-level three-way merge through `git merge-file`; null when lines still conflict. */ function mergeText(base, ours, theirs) { if (ours === theirs || ours === base) return theirs; if (theirs === base) return ours; const dir = fs.mkdtempSync(path.join(os.tmpdir(), "phren-merge-")); try { const [o, b, t] = ["ours", "base", "theirs"].map((name) => path.join(dir, name)); fs.writeFileSync(o, ours); fs.writeFileSync(b, base); fs.writeFileSync(t, theirs); const result = spawnSync("git", ["merge-file", "-p", o, b, t], { encoding: "utf8", env: nonInteractiveGitEnv(), timeout: EXEC_TIMEOUT_MS }); return result.status === 0 ? result.stdout : null; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } /** * A line-level three-way merge that keeps both sides of a conflicting hunk, * local lines first (`git merge-file --union`). For append-only records two * machines adding entries at the same spot is not a real conflict. */ function unionText(base, ours, theirs) { const clean = mergeText(base, ours, theirs); if (clean !== null) return clean; const dir = fs.mkdtempSync(path.join(os.tmpdir(), "phren-merge-")); try { const [o, b, t] = ["ours", "base", "theirs"].map((name) => path.join(dir, name)); fs.writeFileSync(o, ours); fs.writeFileSync(b, base); fs.writeFileSync(t, theirs); const result = spawnSync("git", ["merge-file", "--union", "-p", o, b, t], { encoding: "utf8", env: nonInteractiveGitEnv(), timeout: EXEC_TIMEOUT_MS }); return result.status === 0 ? result.stdout : null; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } /** * Generated blocks regenerate, so the incoming block wins; the rest of the * file merges with `merge`: a union of both sides by default (a topic * archive's bullets). */ function mergeWithGeneratedBlock(base, ours, theirs, start, end, where, merge = unionText) { const b = splitBlock(base, start, end); const o = splitBlock(ours, start, end); const t = splitBlock(theirs, start, end); const rest = merge(b.rest, o.rest, t.rest); if (rest === null) return null; const block = t.block ?? o.block; if (!block) return rest; return upsertBlock(rest, start, end, block, where); } function resolveContent(strategy, base, ours, theirs) { switch (strategy) { case "tasks": return mergeTasksByBid(base, ours, theirs); case "findings": return mergeFindings(ours, theirs); // Archived tasks only accumulate: keep both sides, the incoming version of any duplicate. case "archive": return mergeTask(theirs, ours); case "topic": return mergeWithGeneratedBlock(base, ours, theirs, NOW_START, NOW_END, "top"); // Summaries are regenerated from the store: when the hand-written part // doesn't merge cleanly, take the incoming file whole and let the next // summarize pass fold in anything only this machine had. case "summary": return mergeWithGeneratedBlock(base, ours, theirs, KNOWS_START, KNOWS_END, "bottom", mergeText) ?? theirs; case "union": return unionText(base, ours, theirs); } } /** Resolves what it can in the store's in-progress merge and stages it. */ export function resolveStoreConflicts(cwd) { let conflicted; try { conflicted = git(cwd, ["diff", "--name-only", "--diff-filter=U"]).split("\n").map((line) => line.trim()).filter(Boolean); } catch (err) { debugLog(`resolveStoreConflicts: cannot list conflicts: ${errorMessage(err)}`); return { resolved: [], unresolved: [] }; } const resolved = []; const unresolved = []; for (const relFile of conflicted) { const strategy = conflictStrategy(relFile); if (!strategy) { unresolved.push(relFile); continue; } try { const base = stage(cwd, 1, relFile) ?? ""; const ours = stage(cwd, 2, relFile); const theirs = stage(cwd, 3, relFile); const fullPath = path.join(cwd, relFile); if (ours === null && theirs === null) { unresolved.push(relFile); continue; } // Removed on one side and edited on the other: keep the edited file. const merged = ours === null ? theirs : theirs === null ? ours : resolveContent(strategy, base, ours, theirs); if (merged === null) { unresolved.push(relFile); continue; } const tmp = `${fullPath}.tmp-${crypto.randomUUID()}`; fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(tmp, merged); fs.renameSync(tmp, fullPath); git(cwd, ["add", "--", relFile]); resolved.push(relFile); } catch (err) { debugLog(`resolveStoreConflicts: ${relFile}: ${errorMessage(err)}`); unresolved.push(relFile); } } return { resolved, unresolved }; }