@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
286 lines (285 loc) • 12.5 kB
JavaScript
/**
* Resolves the content conflicts a store merge can settle without a person:
* tasks.md per task id against the merge base, daily notes per note id
* against the base, generated summaries from the incoming side, and the
* append-only record files (findings, 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 { cleanBlock, KNOWS_END, KNOWS_START, NOW_END, NOW_START, upsertBlock } from "../content/summarize.js";
import { dedupeTaskIds, mergeTasksByBid } from "./task-merge.js";
import { NOTE_HEADING_RE } from "../data/notes.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" || name === "truths.md")
return "union";
// Coordination state Phren rewrites itself (maintenance-writer.ts): the remote's copy holds.
if (file === ".config/maintenance-writer.json")
return "theirs";
if (/(^|\/)notes\/[^/]+\.md$/i.test(file))
return "notes";
if (/(^|\/)journal\/[^/]+\.md$/i.test(file))
return "union";
return null;
}
function git(cwd, args) {
return execFileSync("git", args, {
// The default 1 MiB buffer failed on a task archive that had outgrown it,
// and an unreadable side left the whole merge unresolved.
env: nonInteractiveGitEnv(), cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: EXEC_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024,
});
}
/** 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;
}
}
function splitBlock(content, start, end) {
// The whole span, first start to last end, rebuilt as one copy: an earlier
// union merge may have left two start lines (and two bodies) behind.
const clean = cleanBlock(content, start, end);
return clean ?? { rest: content, block: null };
}
/** 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, maxBuffer: 256 * 1024 * 1024 });
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, maxBuffer: 256 * 1024 * 1024 });
return result.status === 0 ? result.stdout : null;
}
finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const DELETED_FILE = Symbol("deleted");
/** A daily notes file split into its header and one block per note id; null when an id repeats. */
function noteBlocks(content) {
// The notes writer's own heading grammar: a note body may contain other `## ... nid:` lines.
const lines = content.split("\n");
const first = lines.findIndex((line) => NOTE_HEADING_RE.test(line));
const notes = new Map();
if (first === -1)
return { header: content.trimEnd(), notes };
let id = "";
let body = [];
const flush = () => { if (id)
notes.set(id, body.join("\n").trimEnd()); };
for (const line of lines.slice(first)) {
const m = NOTE_HEADING_RE.exec(line);
if (!m) {
body.push(line);
continue;
}
flush();
if (notes.has(m[2]))
return null;
id = m[2];
body = [line];
}
flush();
return { header: lines.slice(0, first).join("\n").trimEnd(), notes };
}
/**
* Daily notes merged per note id against the base: a note one side edited or
* removed takes that side's change, notes either side added are kept. A line
* union could put an edited note's old body back under the same id. A note
* both sides edited differently falls back to the line union. A side that
* deleted the file counts as one with the base's header and no notes, and
* DELETED_FILE comes back when no note survives that deletion.
*/
function mergeNotes(base, ours, theirs) {
const b = noteBlocks(base);
if (!b)
return null;
const deleted = { header: b.header, notes: new Map() };
const [o, t] = [ours === null ? deleted : noteBlocks(ours), theirs === null ? deleted : noteBlocks(theirs)];
if (!o || !t)
return null;
const header = mergeText(b.header, o.header, t.header);
if (header === null)
return null;
const out = [];
for (const id of new Set([...o.notes.keys(), ...t.notes.keys()])) {
const [was, mine, incoming] = [b.notes.get(id), o.notes.get(id), t.notes.get(id)];
let note;
if (mine === incoming || incoming === was)
note = mine;
else if (mine === was)
note = incoming;
// Removed on one side, edited on the other: the edit is kept.
else if (mine === undefined || incoming === undefined)
note = mine ?? incoming;
else
return null;
if (note !== undefined)
out.push(note);
}
if (!out.length && (ours === null || theirs === null))
return DELETED_FILE;
return [header, ...out].join("\n\n") + "\n";
}
/**
* 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);
}
const dedupe = (content) => content === null ? null : dedupeTaskIds(content).content;
function orUnion(merge, base, ours, theirs, after = (c) => c) {
try {
const merged = merge();
if (merged !== null)
return merged;
}
catch (err) {
debugLog(`store merge fell back to a line union: ${errorMessage(err)}`);
}
return after(unionText(base, ours, theirs));
}
function resolveContent(strategy, base, ours, theirs) {
switch (strategy) {
// When a smarter merge refuses, a line union still keeps every record of
// both sides (repeated task ids collapsed): a knowledge file never blocks a sync.
case "tasks": return orUnion(() => mergeTasksByBid(base, ours, theirs), base, ours, theirs, dedupe);
case "findings": return orUnion(() => mergeFindings(ours, theirs, base), base, ours, theirs);
// Archived tasks only accumulate: keep both sides, the incoming version of any duplicate.
case "archive": return dedupe(orUnion(() => mergeTask(theirs, ours), base, ours, theirs));
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 "notes": {
// Both sides have the file here, so it is never DELETED_FILE.
const merged = mergeNotes(base, ours, theirs);
return typeof merged === "string" ? merged : unionText(base, ours, theirs);
}
case "union": return unionText(base, ours, theirs);
case "theirs": return 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,
// except daily notes, whose deletion can mean their last note was removed.
const notesMerge = strategy === "notes" && (ours === null || theirs === null) ? mergeNotes(base, ours, theirs) : null;
if (notesMerge === DELETED_FILE) {
git(cwd, ["rm", "--quiet", "--force", "--ignore-unmatch", "--", relFile]);
resolved.push(relFile);
continue;
}
const merged = notesMerge ?? (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 };
}