@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
553 lines (552 loc) • 25.5 kB
JavaScript
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { BridgeError } from "./protocol.js";
import { git, gitRoot } from "./projects.js";
const exec = promisify(execFile);
/** A status code from `--name-status`: the letter alone, with `T` (type change)
* shown as an edit and anything unrecognized treated as one. */
function statusLetter(code) {
const letter = code[0];
return ["M", "A", "D", "R", "C", "U"].includes(letter) ? letter : "M";
}
/** `--name-status -z` records as path-to-status; a rename carries old then new. */
function parseNameStatus(out) {
const map = new Map();
const tokens = out.split("\0");
for (let index = 0; index < tokens.length; index++) {
const code = tokens[index];
if (!code)
continue;
if (code[0] === "R" || code[0] === "C") {
index++;
const destination = tokens[++index];
if (destination)
map.set(destination, code[0]);
}
else {
const file = tokens[++index];
if (file)
map.set(file, code[0]);
}
}
return map;
}
/** `--numstat -z` per-path line counts; a rename leaves the path empty and the
* two names follow as their own tokens. */
function parseNumstat(out) {
const map = new Map();
const tokens = out.split("\0");
for (let index = 0; index < tokens.length; index++) {
const record = tokens[index];
if (!record)
continue;
const parts = record.split("\t");
if (parts.length < 2)
continue;
const additions = parts[0] === "-" ? 0 : Number(parts[0]) || 0;
const deletions = parts[1] === "-" ? 0 : Number(parts[1]) || 0;
let file = parts.slice(2).join("\t");
if (!file) {
index++;
const destination = tokens[++index];
file = destination ?? "";
}
if (file)
map.set(file, { additions, deletions });
}
return map;
}
/** A count of a text file's lines, for an untracked file whose diff has no base. */
async function countLines(root, rel) {
try {
const abs = path.join(root, await repositoryPath(root, rel));
if ((await stat(abs)).size > 5_000_000)
return 0;
const bytes = await readFile(abs);
if (!bytes.length || bytes.includes(0))
return 0;
let lines = 0;
for (const byte of bytes)
if (byte === 10)
lines++;
return bytes[bytes.length - 1] === 10 ? lines : lines + 1;
}
catch {
return 0;
}
}
/** Everything `git status` reports, kept apart by section so `gitLog` and
* `gitTree` can reuse it. */
async function collect(root) {
const branch = (await git(root, "branch", "--show-current")).trim();
let upstream;
let ahead = 0, behind = 0;
try {
upstream = (await git(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")).trim() || undefined;
}
catch {
upstream = undefined;
}
if (upstream) {
try {
const counts = (await git(root, "rev-list", "--left-right", "--count", `${upstream}...HEAD`)).trim().split(/\s+/);
behind = Number(counts[0]) || 0;
ahead = Number(counts[1]) || 0;
}
catch {
ahead = 0;
behind = 0;
}
}
const [stagedRaw, unstagedRaw, stagedCounts, unstagedCounts, untrackedRaw] = await Promise.all([
git(root, "diff", "--cached", "--name-status", "-z"),
git(root, "diff", "--name-status", "-z"),
git(root, "diff", "--cached", "--numstat", "-z"),
git(root, "diff", "--numstat", "-z"),
git(root, "ls-files", "--others", "--exclude-standard", "-z"),
]);
const staged = parseNameStatus(stagedRaw), unstaged = parseNameStatus(unstagedRaw);
const stagedStats = parseNumstat(stagedCounts), unstagedStats = parseNumstat(unstagedCounts);
const untracked = untrackedRaw.split("\0").filter(Boolean);
return { branch, upstream, ahead, behind, staged, unstaged, stagedStats, unstagedStats, untracked };
}
/** The remote's default branch as its `HEAD` records it. With no recorded
* `HEAD`, `main` and `master` are treated as default so the guard errs on the
* side of asking. */
export async function defaultBranch(root, remote = "origin") {
const head = (await git(root, "symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`).catch(() => "")).trim();
if (head.startsWith(remote + "/"))
return head.slice(remote.length + 1);
const branches = new Set((await git(root, "for-each-ref", "--format=%(refname:short)", "refs/heads").catch(() => "")).split("\n").filter(Boolean));
return branches.has("main") ? "main" : branches.has("master") ? "master" : null;
}
async function repository(cwd) {
const root = await gitRoot(cwd);
if (!root)
throw new BridgeError(409, "This pane is not in a Git repository.");
return root;
}
/** Working-tree status, one record per section: a file edited in both the index
* and the working tree appears once with `staged: true` and once with `false`. */
export async function gitStatus(cwd) {
const root = await repository(cwd);
treeCache.delete(root);
const data = await collect(root);
const fallback = await defaultBranch(root, data.upstream?.split("/")[0] || "origin");
const files = [];
for (const [file, status] of data.staged) {
const counts = data.stagedStats.get(file) ?? { additions: 0, deletions: 0 };
files.push({ path: file, status: statusLetter(status), staged: true, ...counts });
}
for (const [file, status] of data.unstaged) {
const counts = data.unstagedStats.get(file) ?? { additions: 0, deletions: 0 };
files.push({ path: file, status: statusLetter(status), staged: false, ...counts });
}
for (const file of data.untracked)
files.push({ path: file, status: "?", staged: false, additions: await countLines(root, file), deletions: 0 });
const additions = files.reduce((total, file) => total + file.additions, 0);
const deletions = files.reduce((total, file) => total + file.deletions, 0);
return { branch: data.branch, upstream: data.upstream ?? null, ahead: data.ahead, behind: data.behind,
staged: data.staged.size, unstaged: data.unstaged.size, untracked: data.untracked.length, additions, deletions, files,
defaultBranch: fallback };
}
/** The repository's remotes, so a branch ref can be told from a remote-tracking
* one even when a local branch name contains a slash. */
async function remotes(root) {
return (await git(root, "remote").catch(() => "")).split("\n").map(line => line.trim()).filter(Boolean);
}
function refEntries(spec, remotes) {
const refs = [];
for (const raw of spec.split(",").map(part => part.trim()).filter(Boolean)) {
if (raw === "HEAD") {
refs.push({ name: "HEAD", kind: "head" });
continue;
}
if (raw.startsWith("HEAD -> ")) {
refs.push({ name: "HEAD", kind: "head" });
const name = raw.slice(8);
refs.push({ name, kind: remotes.some(remote => name.startsWith(remote + "/")) ? "remote" : "local" });
}
else if (raw.startsWith("tag: "))
refs.push({ name: raw.slice(5), kind: "tag" });
else
refs.push({ name: raw, kind: remotes.some(remote => raw.startsWith(remote + "/")) ? "remote" : "local" });
}
return refs;
}
/** Commits newest first, plus a one-line summary of what is not committed yet. */
export async function gitLog(cwd, limit = 60, ref) {
const root = await repository(cwd);
const count = Math.min(200, Math.max(1, Math.trunc(limit) || 60));
let revision;
if (ref !== undefined) {
if (!ref || ref.length > 512 || ref.startsWith("-") || /[\x00-\x1f\x7f]/.test(ref))
throw new BridgeError(400, "Invalid branch ref.");
// Resolve to a commit first so Git never interprets a ref as an option or path.
try {
revision = (await git(root, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`)).trim();
}
catch {
throw new BridgeError(400, "That branch ref does not name a commit.");
}
}
else {
revision = (await git(root, "rev-parse", "--verify", "HEAD").catch(() => "")).trim();
}
const known = await remotes(root);
const output = revision ? await git(root, "log", "--decorate=short", "--date=iso-strict", "--format=%H%x00%h%x00%s%x00%an%x00%cI%x00%D%x00%P", "-n", String(count), revision, "--") : "";
const commits = [];
for (const line of output.split("\n")) {
if (!line)
continue;
const [sha, short, subject, author, date, spec, parents] = line.split("\0");
commits.push({ sha, short, subject, author, date, refs: refEntries(spec ?? "", known), parents: (parents ?? "").split(" ").filter(Boolean) });
}
const status = await gitStatus(root);
const uncommitted = { files: new Set(status.files.map(file => file.path)).size, additions: status.additions, deletions: status.deletions };
return { commits, uncommitted };
}
/** Local branches with their upstream and ahead/behind counts, and remote refs. */
export async function gitBranches(cwd) {
const root = await repository(cwd);
const format = "%(refname)%00%(refname:short)%00%(upstream:short)%00%(upstream:track)%00%(committerdate:iso-strict)%00%(HEAD)";
const output = await git(root, "for-each-ref", `--format=${format}`, "refs/heads", "refs/remotes");
const local = [], remote = [];
let current = null;
for (const line of output.split("\n")) {
if (!line)
continue;
const [refname, short, upstream, track, date, head] = line.split("\0");
if (refname.startsWith("refs/remotes/")) {
if (short.endsWith("/HEAD"))
continue;
remote.push({ name: short, date });
}
else if (refname.startsWith("refs/heads/")) {
const gone = (track ?? "").includes("gone");
const ahead = Number(/ahead (\d+)/.exec(track ?? "")?.[1]) || 0;
const behind = Number(/behind (\d+)/.exec(track ?? "")?.[1]) || 0;
if (head === "*")
current = short;
local.push({ name: short, ...(upstream && !gone ? { upstream } : {}), ahead, behind, date });
}
}
return { current, local, remote };
}
const ghEnv = () => ({ ...process.env, GIT_CONFIG_NOSYSTEM: "1", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" });
/** A check rollup as one word: any failure fails, anything unfinished is
* pending, and only finished successes (or skips) pass. No checks is null. */
export function checkRollup(items) {
if (!Array.isArray(items) || !items.length)
return null;
let pending = false;
for (const raw of items) {
const item = raw && typeof raw === "object" ? raw : {};
const conclusion = String(item.conclusion ?? "").toUpperCase(), state = String(item.state ?? "").toUpperCase();
const status = String(item.status ?? "").toUpperCase();
if (["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion) || ["FAILURE", "ERROR"].includes(state))
return "failing";
// A commit status has a state; a check run has a status and, once
// completed, a conclusion.
if (state && !status) {
if (state !== "SUCCESS")
pending = true;
}
else if (status !== "COMPLETED")
pending = true;
}
return pending ? "pending" : "passing";
}
/** The checked-out branch's pull request in any state, with its checks, or
* null when the branch has none (or gh cannot say). */
async function currentPull(root, branch) {
try {
const { stdout } = await exec("gh", ["pr", "view", "--json", "number,title,url,state,isDraft,headRefName,baseRefName,statusCheckRollup"], {
cwd: root, timeout: 15_000, maxBuffer: 4_194_304, env: ghEnv(),
});
const pull = JSON.parse(stdout || "null");
if (!pull || typeof pull !== "object" || typeof pull.number !== "number" || pull.headRefName !== branch)
return null;
return { number: pull.number, title: String(pull.title ?? ""), url: String(pull.url ?? ""), head: branch,
base: String(pull.baseRefName ?? ""), draft: pull.isDraft === true, state: String(pull.state ?? ""), checks: checkRollup(pull.statusCheckRollup) };
}
catch {
return null;
}
}
/** Open pull requests through `gh`, or `{ available: false }` when the tool is
* missing or not signed in. A failure is a normal answer, never an error.
* `current` is the checked-out branch's own pull request in any state (open,
* draft, merged or closed) with its checks, which the session card shows. */
export async function gitPulls(cwd) {
const root = await repository(cwd);
const branch = (await git(root, "branch", "--show-current").catch(() => "")).trim();
try {
const [{ stdout }, current] = await Promise.all([
exec("gh", ["pr", "list", "--json", "number,title,headRefName,baseRefName,author,url,isDraft,state,updatedAt", "--limit", "50"], {
cwd: root, timeout: 15_000, maxBuffer: 4_194_304, env: ghEnv(),
}),
branch ? currentPull(root, branch) : Promise.resolve(null),
]);
const parsed = JSON.parse(stdout || "[]");
const pulls = (Array.isArray(parsed) ? parsed : []).map((pull) => {
const author = pull.author && typeof pull.author === "object" ? pull.author : {};
return { number: pull.number, title: pull.title, head: pull.headRefName, base: pull.baseRefName,
author: String(author.login ?? author.name ?? ""), url: pull.url, draft: pull.isDraft === true, state: pull.state, updated: pull.updatedAt };
});
return { available: true, pulls, branch: branch || null, current };
}
catch {
return { available: false, pulls: [], branch: branch || null, current: null };
}
}
/** A repo-relative tree path, or the repository root for `""`. */
async function repositoryPath(root, raw, allowRoot = false) {
if (typeof raw !== "string" || raw.length > 4096 || raw.includes("\0") || path.isAbsolute(raw) || path.win32.isAbsolute(raw) || raw.startsWith("\\"))
throw new BridgeError(400, "Invalid path.");
const segments = raw.split(/[\\/]/);
if (segments.includes(".."))
throw new BridgeError(400, "Invalid path.");
const clean = segments.filter(part => part && part !== ".").join("/");
if ((!clean && !allowRoot) || clean.startsWith("-"))
throw new BridgeError(400, "Invalid path.");
const abs = path.resolve(root, clean);
if (abs !== root && !abs.startsWith(root + path.sep))
throw new BridgeError(403, "This path is outside the repository.");
// Deleted files still need stage/restore. Check their nearest existing
// ancestor, but never treat a dangling symlink as a missing ordinary path.
let existing = abs;
while (true) {
try {
await lstat(existing);
break;
}
catch (error) {
if (error.code !== "ENOENT" || existing === root)
throw new BridgeError(400, "Invalid path.");
existing = path.dirname(existing);
}
}
let resolved;
try {
resolved = await realpath(existing);
}
catch {
throw new BridgeError(403, "This path cannot be resolved inside the repository.");
}
if (resolved !== root && !resolved.startsWith(root + path.sep))
throw new BridgeError(403, "This path is outside the repository.");
return clean;
}
const treeCache = new Map();
/** Drop the cached tree after a write (commit, push) outside this module. */
export function invalidateTree(root) { treeCache.delete(root); }
const TREE_TTL_MS = 2_000;
/** Build directory children once, without diff hunks, line counts or upstream walks. */
async function treeSnapshot(root, head) {
const [tracked, porcelain] = await Promise.all([
git(root, "ls-files", "-z"),
git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all"),
]);
const changed = new Map();
const files = new Set(tracked.split("\0").filter(Boolean));
const tokens = porcelain.split("\0");
for (let i = 0; i < tokens.length; i++) {
const row = tokens[i];
if (!row)
continue;
const xy = row.slice(0, 2), file = row.slice(3);
const letter = xy === "??" ? "?" : statusLetter(xy[1] !== " " ? xy[1] : xy[0]);
changed.set(file, letter);
files.add(file);
// Porcelain -z places the destination before the source of a rename.
if (/[RC]/.test(xy))
i++;
}
const levels = new Map();
levels.set("", new Map());
for (const file of files) {
const parts = file.split("/");
if (parts.includes("node_modules") || parts.includes(".git"))
continue;
let parent = "";
for (let i = 0; i < parts.length; i++) {
const name = parts[i], full = parent ? `${parent}/${name}` : name;
const directory = i < parts.length - 1;
let siblings = levels.get(parent);
if (!siblings) {
siblings = new Map();
levels.set(parent, siblings);
}
let entry = siblings.get(name);
if (!entry || (directory && entry.kind === "file")) {
// A deleted tracked file can coexist with untracked descendants at
// the same path. Descendants make this a navigable directory.
entry = { name, path: full, kind: directory ? "dir" : "file", ...(directory ? { fileCount: 0 } : {}) };
siblings.set(name, entry);
}
if (directory) {
entry.fileCount = (entry.fileCount ?? 0) + 1;
if (changed.has(file))
entry.status = "changed";
}
else if (changed.has(file))
entry.status = entry.kind === "dir" ? "changed" : changed.get(file);
parent = full;
}
}
const version = createHash("sha256").update(head).update(tracked).update(porcelain).digest("hex");
return { version, levels: new Map([...levels].map(([directory, entries]) => [directory,
[...entries.values()].sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1)])) };
}
const MAX_IGNORED_ENTRIES = 1_000;
const ignoredCache = new Map();
/** Git-ignored paths as `git ls-files --others --ignored --exclude-standard
* --directory` reports them: a wholly ignored folder once, with a trailing
* slash, instead of every file inside it. */
async function ignoredListing(root) {
let cached = ignoredCache.get(root);
if (!cached || cached.expires <= Date.now()) {
if (ignoredCache.size >= 32)
ignoredCache.delete(ignoredCache.keys().next().value);
cached = { expires: Date.now() + TREE_TTL_MS,
listing: git(root, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z").then(out => out.split("\0").filter(Boolean)) };
ignoredCache.set(root, cached);
}
try {
return await cached.listing;
}
catch (error) {
ignoredCache.delete(root);
throw error;
}
}
/** The ignored entries directly under `prefix`, marked `ignored`. Inside a
* wholly ignored folder Git lists nothing further, so that level is read from
* the disk itself (no `.git`, symlinks shown as files and never followed). */
async function ignoredEntries(root, prefix, shown) {
const listing = await ignoredListing(root);
const taken = new Set(shown.map(entry => entry.name));
const entries = new Map();
const inside = prefix && listing.some(item => item.endsWith("/") && (prefix + "/").startsWith(item));
if (inside) {
const dirents = await readdir(path.join(root, prefix), { withFileTypes: true }).catch(() => []);
for (const dirent of dirents) {
if (dirent.name === ".git" || taken.has(dirent.name) || entries.size >= MAX_IGNORED_ENTRIES)
continue;
entries.set(dirent.name, { name: dirent.name, path: `${prefix}/${dirent.name}`, kind: dirent.isDirectory() ? "dir" : "file", ignored: true });
}
}
else {
const base = prefix ? prefix + "/" : "";
for (const item of listing) {
if (!item.startsWith(base) || item.length <= base.length)
continue;
const rest = item.slice(base.length), slash = rest.indexOf("/");
const name = slash < 0 ? rest : rest.slice(0, slash);
if (!name || name === ".git" || taken.has(name) || entries.has(name))
continue;
if (entries.size >= MAX_IGNORED_ENTRIES)
break;
entries.set(name, { name, path: base + name, kind: slash < 0 ? "file" : "dir", ignored: true });
}
}
return [...entries.values()];
}
/** One lazy directory response from a bounded repo/HEAD/status-hash snapshot.
* Every request still validates the pane's repo and the requested path. A HEAD
* move invalidates immediately; external working-tree edits age out after 2s.
* Status refresh and phone mutations invalidate immediately as well. With
* `ignored`, git-ignored folders and files at that level are added, marked. */
export async function gitTree(cwd, relPath = "", ignored = false) {
const root = await repository(cwd);
const prefix = await repositoryPath(root, relPath, true);
const head = await git(root, "rev-parse", "HEAD").catch(() => "unborn");
let cached = treeCache.get(root);
if (!cached || cached.head !== head || cached.expires <= Date.now()) {
if (treeCache.size >= 32)
treeCache.delete(treeCache.keys().next().value);
cached = { head, expires: Date.now() + TREE_TTL_MS, snapshot: treeSnapshot(root, head) };
treeCache.set(root, cached);
}
try {
const snapshot = await cached.snapshot;
const entries = snapshot.levels.get(prefix) ?? [];
if (!ignored)
return { path: prefix, version: snapshot.version, entries };
const extra = await ignoredEntries(root, prefix, entries);
const all = [...entries, ...extra].sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1);
return { path: prefix, version: snapshot.version, entries: all };
}
catch (error) {
if (treeCache.get(root) === cached)
treeCache.delete(root);
throw error;
}
}
/** The phone's paths are repo-relative, at most 64, and can never climb out. */
async function repositoryPaths(root, raw) {
const list = Array.isArray(raw) ? raw : [];
if (!list.length || list.length > 64)
throw new BridgeError(400, "Choose between 1 and 64 paths.");
const paths = [];
for (const entry of list) {
const clean = await repositoryPath(root, entry);
if (clean && !paths.includes(clean))
paths.push(clean);
}
if (!paths.length)
throw new BridgeError(400, "Choose between 1 and 64 paths.");
return paths;
}
export async function gitStage(cwd, paths) {
const root = await repository(cwd);
treeCache.delete(root);
try {
await git(root, "add", "--", ...(await repositoryPaths(root, paths)).map(file => `:(literal)${file}`));
return { ok: true };
}
finally {
treeCache.delete(root);
}
}
export async function gitUnstage(cwd, paths) {
const root = await repository(cwd);
treeCache.delete(root);
try {
const files = (await repositoryPaths(root, paths)).map(file => `:(literal)${file}`);
const hasHead = await git(root, "rev-parse", "--verify", "HEAD").then(() => true, () => false);
if (hasHead)
await git(root, "restore", "--staged", "--", ...files);
else
await git(root, "rm", "--force", "--cached", "--", ...files);
return { ok: true };
}
finally {
treeCache.delete(root);
}
}
/** Destructive: tracked files go back to the index, untracked files are
* removed. The phone confirms first; no `-d` (directories) and no `-x`. */
export async function gitDiscard(cwd, paths) {
const root = await repository(cwd);
treeCache.delete(root);
try {
const files = await repositoryPaths(root, paths);
const untracked = new Set((await git(root, "ls-files", "--others", "--exclude-standard", "-z")).split("\0").filter(Boolean));
for (const file of files) {
const literal = `:(literal)${file}`;
if (untracked.has(file))
await git(root, "clean", "-f", "--", literal);
else
await git(root, "checkout", "--", literal);
}
return { ok: true };
}
finally {
treeCache.delete(root);
}
}