@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
192 lines (191 loc) • 8.56 kB
JavaScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { BridgeError } from "./protocol.js";
import { git, gitRoot, withGitReadDeadline } from "./projects.js";
import { invalidateTree, repositoryPath } from "./git.js";
/** What a review screen needs beyond status and patches: a file's text at
* HEAD, in the index or at a commit (so a real diff editor can compare whole
* files), staging or unstaging single hunks, and the edits one agent session
* made. */
const MAX_FILE = 2_000_000;
const MAX_PATCH = 2_000_000;
async function repository(cwd) {
const root = await gitRoot(cwd);
if (!root)
throw new BridgeError(409, "This pane is not in a Git repository.", { code: "git-not-repository" });
return root;
}
/** `HEAD`, `INDEX` (stage 0) or a commit hash. */
function revision(raw) {
if (raw === "HEAD" || raw === "INDEX")
return raw;
if (typeof raw === "string" && /^[0-9a-fA-F]{4,64}$/.test(raw))
return raw;
throw new BridgeError(400, "Name HEAD, INDEX or a commit hash.");
}
/** One file's text at HEAD, in the index or at a commit. A path the tree does
* not hold answers `missing: true` (an added or deleted side), a file larger
* than 2 MB `tooLarge: true`, and bytes with a NUL `binary: true`, each with
* no text. */
export function gitFile(cwd, ref, rawPath) {
return withGitReadDeadline(async () => {
const root = await repository(cwd);
const rev = revision(ref);
const file = await repositoryPath(root, rawPath);
let spec;
if (rev === "INDEX")
spec = `:0:${file}`;
else {
let commit;
try {
commit = (await git(root, "rev-parse", "--verify", "--end-of-options", `${rev}^{commit}`)).trim();
}
catch {
throw new BridgeError(404, "This repository has no such commit.", { code: "git-unknown-commit" });
}
spec = `${commit}:${file}`;
}
const kind = await git(root, "cat-file", "-t", spec).then(out => out.trim(), () => "");
if (!kind)
return { path: file, ref: rev, missing: true, text: "" };
if (kind !== "blob")
return { path: file, ref: rev, missing: true, text: "", kindOf: kind };
const size = Number((await git(root, "cat-file", "-s", spec)).trim()) || 0;
if (size > MAX_FILE)
return { path: file, ref: rev, size, tooLarge: true, text: "" };
const text = await git(root, "cat-file", "blob", spec);
if (text.slice(0, 8000).includes("\0"))
return { path: file, ref: rev, size, binary: true, text: "" };
return { path: file, ref: rev, size, text };
});
}
/** A name in a patch header: `a/x` or a C-quoted `"a/x\ty"`. */
function headerName(raw) {
let name = raw.trim();
if (name === "/dev/null")
return null;
if (name.startsWith("\"")) {
try {
name = JSON.parse(name);
}
catch {
throw new BridgeError(400, "This patch names a file Phren cannot read.");
}
}
if (!/^[ab]\//.test(name))
throw new BridgeError(400, "This patch is not a Git patch.");
return name.slice(2);
}
/** The files a patch touches, from its `---`/`+++` lines. Rename, mode and
* binary patches are refused: only text hunks are staged one at a time. */
export function patchFiles(patch) {
const files = new Set();
let hunks = 0;
// Lines still owed to the current hunk's old and new sides: inside a hunk
// body, `--- x` is a removed line `-- x`, never a file header.
let oldLeft = 0, newLeft = 0;
for (const line of patch.split("\n")) {
if (oldLeft > 0 || newLeft > 0) {
if (line.startsWith("\\"))
continue;
if (line.startsWith("-"))
oldLeft--;
else if (line.startsWith("+"))
newLeft--;
else {
oldLeft--;
newLeft--;
}
continue;
}
const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
if (hunk) {
hunks++;
oldLeft = hunk[1] === undefined ? 1 : Number(hunk[1]);
newLeft = hunk[2] === undefined ? 1 : Number(hunk[2]);
continue;
}
if (/^(?:rename|copy) (?:from|to) |^(?:old|new|deleted file|new file) mode |^GIT binary patch|^Binary files /.test(line)) {
throw new BridgeError(400, "Only text hunks can be staged one at a time. Stage the whole file instead.");
}
if (line.startsWith("--- ") || line.startsWith("+++ ")) {
const name = headerName(line.slice(4).replace(/\t.*$/, ""));
if (name)
files.add(name);
}
}
if (!files.size || !hunks)
throw new BridgeError(400, "This patch has no hunks.");
if (files.size > 1)
throw new BridgeError(400, "Stage hunks of one file at a time.");
return [...files];
}
/** Apply a patch of one file's hunks to the index only: staging them, or with
* `reverse` unstaging them. The working tree is never touched. Git checks the
* patch first; a hunk that no longer applies (the file moved on) is Git's
* refusal as `{ ok: false, output }`. */
export async function gitApply(cwd, rawPatch, reverse) {
if (typeof rawPatch !== "string" || !rawPatch.trim())
throw new BridgeError(400, "Choose a hunk to stage.");
if (rawPatch.length > MAX_PATCH || rawPatch.includes("\0"))
throw new BridgeError(400, "This patch is too large.");
const root = await repository(cwd);
const [file] = patchFiles(rawPatch);
await repositoryPath(root, file);
const scratch = await mkdtemp(path.join(tmpdir(), "phren-apply-"));
const patchFile = path.join(scratch, "hunk.patch");
try {
await writeFile(patchFile, rawPatch.endsWith("\n") ? rawPatch : rawPatch + "\n", { mode: 0o600 });
const args = ["apply", "--cached", "--whitespace=nowarn", ...(reverse === true ? ["-R"] : []), "--", patchFile];
invalidateTree(root);
try {
await git(root, "apply", "--check", ...args.slice(1));
}
catch (error) {
return { ok: false, output: error instanceof BridgeError ? error.message.replace(/^Git apply failed: /, "") : "git apply refused this hunk." };
}
await git(root, ...args);
return { ok: true, path: file, staged: reverse !== true };
}
finally {
invalidateTree(root);
await rm(scratch, { recursive: true, force: true });
}
}
/** One session's recorded edits, oldest first, folded by file: how often each
* file changed, the lines added and removed across those edits, and each
* edit's patch. Only edits inside `root` (the session's repository) count;
* `others` says how many touched other repositories. */
export function sessionChanges(root, history) {
const files = new Map();
let others = 0, calls = 0;
for (const row of history) {
let counted = false;
for (const change of row.files) {
if (change.root !== root) {
others++;
continue;
}
counted = true;
const entry = files.get(change.path) ?? { path: change.path, status: change.status, added: 0, removed: 0, edits: [], redacted: false, binary: false };
entry.added += change.added;
entry.removed += change.removed;
entry.redacted ||= change.redacted === true;
entry.binary ||= change.binary === true;
// The first edit's status says whether the session added the file.
if (entry.edits.length && change.status === "D")
entry.status = "D";
if (entry.edits.length < 50)
entry.edits.push({ toolUseId: row.toolUseId, status: change.status, added: change.added, removed: change.removed,
patch: change.patch.slice(0, 200_000), ...(change.patch.length > 200_000 || change.truncated ? { truncated: true } : {}) });
files.delete(change.path);
files.set(change.path, entry);
}
if (counted)
calls++;
}
const list = [...files.values()].reverse().slice(0, 300);
return { root, calls, files: list, totalFiles: files.size, others,
additions: list.reduce((n, f) => n + f.added, 0), deletions: list.reduce((n, f) => n + f.removed, 0) };
}