@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
148 lines (147 loc) • 7.08 kB
JavaScript
import { createHash } from "node:crypto";
import { realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { BridgeError } from "./protocol.js";
import { git, gitHead, gitRoot, withGitReadDeadline } from "./projects.js";
/** Workers edit in their own worktrees, so the pane's repository diff never
* shows their work. These are the repository's other worktrees, exactly as
* `git worktree list` reports them, each addressed by an opaque id the phone
* sends back. A path the phone names is never trusted: an id resolves only to
* a worktree the listing reports for this pane's repository. */
const MAX_WORKTREES = 64;
export const worktreeIdPattern = /^[a-f0-9]{32}$/;
export function worktreeId(abs) {
return createHash("sha256").update(`worktree\0${abs}`).digest("hex").slice(0, 32);
}
/** `git worktree list --porcelain`: one block per worktree. Bare entries and
* worktrees missing on disk are left out; nothing there can be shown. */
async function listed(root) {
const output = await git(root, "worktree", "list", "--porcelain", "-z");
const blocks = [];
let current;
for (const line of output.split("\0")) {
if (!line) {
current = undefined;
continue;
}
const space = line.indexOf(" ");
const key = space < 0 ? line : line.slice(0, space), value = space < 0 ? "" : line.slice(space + 1);
if (key === "worktree") {
current = { worktree: value };
blocks.push(current);
continue;
}
if (current)
current[key] = value;
}
const worktrees = [];
let main;
for (const [index, block] of blocks.entries()) {
if (block.bare !== undefined || block.prunable !== undefined)
continue;
const abs = await realpath(block.worktree).catch(() => undefined);
if (!abs || !(await stat(abs).catch(() => undefined))?.isDirectory())
continue;
if (index === 0)
main = abs;
const branch = block.branch?.startsWith("refs/heads/") ? block.branch.slice("refs/heads/".length) : null;
worktrees.push({ abs, head: block.HEAD ?? "", branch, locked: block.locked !== undefined });
if (worktrees.length >= MAX_WORKTREES)
break;
}
return { main, worktrees };
}
/** Relative to the repository when the worktree lives inside it (the usual
* `.claude/worktrees/agent-*`), otherwise with the home folder shortened. */
function displayPath(abs, roots) {
for (const root of roots) {
const rel = path.relative(root, abs);
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel))
return rel.split(path.sep).join("/");
}
const home = homedir();
return abs === home ? "~" : abs.startsWith(home + path.sep) ? "~/" + path.relative(home, abs).split(path.sep).join("/") : abs;
}
function uncommitted(status) {
const tokens = status.split("\0");
let count = 0;
for (let index = 0; index < tokens.length; index++) {
const record = tokens[index];
if (!record)
continue;
count++;
if (/[RC]/.test(record.slice(0, 2)))
index++;
}
return count;
}
async function workerFor(abs, workers, all = []) {
// The primary checkout holds `.claude/worktrees/*`: a worker in one of those
// belongs to that worktree, not to the checkout around it.
const nested = all.filter(other => other.startsWith(abs + path.sep));
let best;
for (const worker of workers) {
const cwd = await realpath(worker.cwd).catch(() => path.resolve(worker.cwd));
if (cwd !== abs && !cwd.startsWith(abs + path.sep))
continue;
if (nested.some(inner => cwd === inner || cwd.startsWith(inner + path.sep)))
continue;
// An exact checkout beats a directory inside it; this conversation's own
// agent beats a manifest that only names the checkout.
const score = (cwd === abs ? 2 : 0) + (worker.child ? 1 : 0);
if (!best || score > best.score)
best = { worker, score };
}
return best?.worker;
}
/** The pane repository's other worktrees with branch, HEAD, commits ahead of
* and behind this pane's HEAD, uncommitted file count and, when known, the
* worker editing there. */
export function gitWorktrees(cwd, workers = []) {
return withGitReadDeadline(() => readGitWorktrees(cwd, workers));
}
async function readGitWorktrees(cwd, workers) {
const root = await gitRoot(cwd);
if (!root)
throw new BridgeError(409, "This pane is not in a Git repository.", { code: "git-not-repository" });
const { main, worktrees } = await listed(root);
const base = await gitHead(root);
const roots = [root, ...(main && main !== root ? [main] : [])];
const others = worktrees.filter(worktree => worktree.abs !== root);
const rows = await Promise.all(others.map(async (worktree) => {
let ahead = 0, behind = 0;
if (base && worktree.head && worktree.head !== base) {
const counts = (await git(root, "rev-list", "--left-right", "--count", `${base}...${worktree.head}`)).trim().split(/\s+/);
behind = Number(counts[0]);
ahead = Number(counts[1]);
}
const changed = uncommitted(await git(worktree.abs, "status", "--porcelain=v1", "-z", "--untracked-files=normal"));
const worker = await workerFor(worktree.abs, workers, worktrees.map(item => item.abs));
return {
id: worktreeId(worktree.abs), path: displayPath(worktree.abs, roots), branch: worktree.branch,
head: worktree.head, ahead, behind, changed,
...(worktree.abs === main ? { main: true } : {}), ...(worktree.locked ? { locked: true } : {}),
...(worker ? { worker: { label: worker.label.slice(0, 200), provider: worker.provider,
...(worker.child ? { child: worker.child } : {}), ...(worker.state ? { state: worker.state } : {}) } } : {}),
};
}));
// Git lists linked worktrees by their admin folder; the phone reads them
// the repository's own first, then by path.
const outside = (value) => value.startsWith("/") || value.startsWith("~") ? 1 : 0;
rows.sort((a, b) => outside(a.path) - outside(b.path) || a.path.localeCompare(b.path));
return { worktrees: rows };
}
/** The checkout for one listed worktree id of the pane's repository, or a
* 404; never a path the phone supplied. */
export async function resolveWorktree(cwd, id) {
if (typeof id !== "string" || !worktreeIdPattern.test(id))
throw new BridgeError(400, "Invalid worktree.");
const root = await gitRoot(cwd);
if (!root)
throw new BridgeError(409, "This pane is not in a Git repository.", { code: "git-not-repository" });
const match = (await listed(root)).worktrees.find(worktree => worktreeId(worktree.abs) === id);
if (!match)
throw new BridgeError(404, "That worktree is not part of this repository.");
return match.abs;
}