@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
788 lines (787 loc) • 38.4 kB
JavaScript
import { createHash } from "node:crypto";
import { readFileSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { lstat, open, readdir, realpath } from "node:fs/promises";
import path from "node:path";
import { BridgeError } from "./protocol.js";
import { git, gitHead, gitRoot, withGitReadDeadline, withIndependentGitReadDeadline, withoutGitReadDeadline } from "./projects.js";
import { tokenDestination } from "./git-host-http.js";
import { detectHost, providerFor, unknownHost } from "./git-hosts.js";
import { hostCredentialIdentity } from "./git-host-auth.js";
import { LookupCache } from "./lookup-cache.js";
export { checkRollup, checkRuns } from "./git-hosts.js";
/** 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";
}
/** NUL records preserve tabs/newlines in names and both sides of renames. */
export 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 < 3)
throw new BridgeError(503, "Git returned invalid line counts.", { code: "git-failed" });
const binary = parts[0] === "-" || parts[1] === "-";
let file = parts.slice(2).join("\t");
if (!file) {
index++;
file = tokens[++index] ?? "";
}
const additions = binary ? 0 : Number(parts[0]), deletions = binary ? 0 : Number(parts[1]);
if (!file || !Number.isSafeInteger(additions) || additions < 0 || !Number.isSafeInteger(deletions) || deletions < 0)
throw new BridgeError(503, "Git returned invalid line counts.", { code: "git-failed" });
map.set(file, { additions, deletions, binary, countsComplete: true });
}
return map;
}
const UNKNOWN_COUNTS = { additions: 0, deletions: 0, countsComplete: false };
const NO_DIFF = ["diff", "--no-ext-diff", "--no-textconv", "--no-color"];
const MAX_STATUS_ROWS = 500;
/** Git owns text/binary classification (including attributes) and line counts.
* Oversized files are only probed; symlink contents are never followed. */
async function untrackedCounts(root, rel) {
const abs = path.join(root, rel);
const info = await lstat(abs);
if (!info.isFile())
return { ...UNKNOWN_COUNTS };
await repositoryPath(root, rel);
if (info.size > 5_000_000) {
const handle = await open(abs, "r");
try {
const bytes = Buffer.alloc(8000);
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
return { ...UNKNOWN_COUNTS, ...(bytes.subarray(0, bytesRead).includes(0) ? { binary: true } : {}) };
}
finally {
await handle.close();
}
}
const out = await git(root, ...NO_DIFF, "--no-index", "--numstat", "-z", "--", "/dev/null", rel);
return [...parseNumstat(out).values()][0] ?? { additions: 0, deletions: 0, binary: false, countsComplete: true };
}
/** Status, rather than diff alone, owns membership (including conflicts and
* intent-to-add). Normal mode collapses wholly untracked directories. */
async function collect(root) {
const branch = (await git(root, "branch", "--show-current")).trim();
const upstream = branch ? (await git(root, "for-each-ref", "--format=%(upstream:short)", `refs/heads/${branch}`)).trim() || undefined : undefined;
let ahead = 0, behind = 0;
if (upstream) {
const counts = (await git(root, "rev-list", "--left-right", "--count", `${upstream}...HEAD`)).trim().split(/\s+/);
behind = Number(counts[0]);
ahead = Number(counts[1]);
}
const [raw, stagedCounts, unstagedCounts] = await Promise.all([
git(root, "status", "--porcelain=v1", "-z", "--untracked-files=normal"),
git(root, ...NO_DIFF, "--cached", "--numstat", "-z"),
git(root, ...NO_DIFF, "--numstat", "-z"),
]);
const staged = new Map(), unstaged = new Map(), untracked = [];
const tokens = raw.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);
if (xy === "??")
untracked.push(file);
else {
if (xy[0] !== " ")
staged.set(file, xy[0]);
if (xy[1] !== " ")
unstaged.set(file, xy[1]);
if (/[RC]/.test(xy))
i++;
}
}
return { branch, upstream, ahead, behind, staged, unstaged,
stagedStats: parseNumstat(stagedCounts), unstagedStats: parseNumstat(unstagedCounts), 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(error => {
if (error instanceof BridgeError && error.details?.exitCode === "1")
return "";
throw error;
})).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")).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.", { code: "git-not-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 function gitStatus(cwd, untrackedPath) {
return withGitReadDeadline(() => readGitStatus(cwd, untrackedPath));
}
async function readGitStatus(cwd, untrackedPath) {
const root = await repository(cwd);
treeCache.delete(root);
const data = await collect(root);
const fallback = await defaultBranch(root, data.upstream?.split("/")[0] || "origin");
const host = await detectHost(root, data.branch);
const { apiHost } = await tokenDestination(root, host);
host.apiHost = apiHost;
if (untrackedPath !== undefined) {
const prefix = await repositoryPath(root, untrackedPath);
if (!data.untracked.some(file => file.endsWith("/") && (prefix + "/").startsWith(file))
|| !(await lstat(path.join(root, prefix))).isDirectory()
|| await gitRoot(path.join(root, prefix)) !== root)
throw new BridgeError(409, "This is no longer an untracked directory in this repository.");
const ignored = await git(root, "check-ignore", "--", prefix).catch(error => {
if (error instanceof BridgeError && error.details?.exitCode === "1")
return "";
throw error;
});
if (ignored)
throw new BridgeError(409, "This directory is ignored by Git.");
// --directory can collapse the requested folder itself, even with -C.
// Let Git filter ignores, then collapse descendants to immediate children.
const descendants = (await git(root, "ls-files", "--others", "--exclude-standard", "--full-name", "-z", "--", `:(literal)${prefix}/`)).split("\0").filter(Boolean);
data.untracked = [...new Set(descendants.map(file => {
const slash = file.indexOf("/", prefix.length + 1);
return slash < 0 ? file : file.slice(0, slash + 1);
}))];
data.staged.clear();
data.unstaged.clear();
}
const files = [];
for (const [file, status] of data.staged) {
if (files.length >= MAX_STATUS_ROWS)
break;
files.push({ path: file, status: statusLetter(status), staged: true, ...(data.stagedStats.get(file) ?? UNKNOWN_COUNTS) });
}
for (const [file, status] of data.unstaged) {
if (files.length >= MAX_STATUS_ROWS)
break;
files.push({ path: file, status: statusLetter(status), staged: false, ...(data.unstagedStats.get(file) ?? UNKNOWN_COUNTS) });
}
for (const file of data.untracked) {
if (files.length >= MAX_STATUS_ROWS)
break;
const directory = file.endsWith("/");
files.push({ path: file, status: "?", staged: false, ...(directory ? { ...UNKNOWN_COUNTS, directory: true } : await untrackedCounts(root, file)) });
}
const truncated = data.staged.size + data.unstaged.size + data.untracked.length > files.length;
return { repository: root, observedAt: new Date().toISOString(), truncated,
countsComplete: !truncated && files.every(file => file.countsComplete === true),
totalFiles: new Set([...data.staged.keys(), ...data.unstaged.keys(), ...data.untracked]).size,
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: files.reduce((total, file) => total + file.additions, 0), deletions: files.reduce((total, file) => total + file.deletions, 0), files,
defaultBranch: fallback, host };
}
/** 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")).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 gitHead(root);
}
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: status.totalFiles, additions: status.additions, deletions: status.deletions, truncated: status.truncated, countsComplete: status.countsComplete };
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 };
}
// An auth or not-found refusal means access was lost: it replaces a cached success at once.
const REVOKING = new Set(["auth", "not-found"]);
const pullsCache = new LookupCache(30_000, 300_000, 64, value => value.available === true, 45_000, value => value.available === false && REVOKING.has(String(value.reason)));
// GitLab's fingerprint can need a glab subprocess, so it is read at most every
// few seconds per host with concurrent polls sharing one read. The others are
// plain file reads and stay exact, so a sign-out is seen on the next poll.
const credentialReads = new Map();
const CREDENTIAL_TTL_MS = 5_000;
function credentialIdentity(host, fresh = false) {
const key = JSON.stringify([host.kind ?? null, (host.domain ?? "").toLowerCase()]);
const hit = credentialReads.get(key);
if (host.kind !== "gitlab")
return hostCredentialIdentity(host);
if (!fresh && hit && Date.now() - hit.at < CREDENTIAL_TTL_MS)
return hit.value;
const value = hostCredentialIdentity(host).catch(error => { credentialReads.delete(key); throw error; });
credentialReads.set(key, { at: Date.now(), value });
if (credentialReads.size > 64)
credentialReads.delete(credentialReads.keys().next().value);
return value;
}
class CredentialsChanged extends Error {
}
/** The repository a checkout belongs to: its common git directory, which every worktree of
* it shares, so a pull-request write seen from one checkout reaches the others. A path that
* cannot be read stands for itself. */
function repositoryId(root) {
try {
const dotGit = path.join(root, ".git");
let gitDir = dotGit;
try {
const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
if (link)
gitDir = path.resolve(root, link[1].trim());
}
catch { /* a directory: the main checkout */ }
let common = gitDir;
try {
common = path.resolve(gitDir, readFileSync(path.join(gitDir, "commondir"), "utf8").trim());
}
catch { /* no commondir: gitDir is the common one */ }
return realpathSync(common);
}
catch {
return root;
}
}
/** Invalidate every branch/host lookup of this checkout's repository after a Hook write. */
export function invalidatePulls(root) {
const prefix = repositoryId(root) + "\0";
pullsCache.invalidate(key => key.startsWith(prefix));
}
/** Tokens belong to a domain, so revoke every checkout and pending generation. */
export function invalidatePullsForDomain(domain) {
const normalized = domain.trim().toLowerCase().replace(/\.$/, "");
for (const key of credentialReads.keys())
if (JSON.parse(key)[1] === normalized)
credentialReads.delete(key);
pullsCache.invalidate(key => key.split("\0")[1] === normalized);
pullsTargets.clear();
}
// Finding a checkout's root, branch and host costs about eight git subprocesses. The answer
// only changes when HEAD or a git config file does, so it is reused per cwd while those are
// unchanged, for at most PULLS_TARGET_TTL_MS (which also bounds what a config `include` or a
// nested repository could hide). Concurrent polls share one lookup.
const PULLS_TARGET_TTL_MS = 10_000;
const pullsTargets = new Map();
function fileStamp(file) {
try {
const info = statSync(file);
return `${info.mtimeMs}:${info.size}:${info.ino}`;
}
catch {
return "-";
}
}
/** A cheap freshness signal: HEAD's text and the stamps of the repository's and the user's git config files. */
function pullsSignature(root) {
const parts = [];
const dotGit = path.join(root, ".git");
let gitDir = dotGit;
try {
const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
if (link)
gitDir = path.resolve(root, link[1].trim());
}
catch { /* a directory: the main checkout */ }
let common = gitDir;
try {
common = path.resolve(gitDir, readFileSync(path.join(gitDir, "commondir"), "utf8").trim());
}
catch { /* gitDir is the common one */ }
let head;
try {
head = readFileSync(path.join(gitDir, "HEAD"), "utf8");
}
catch {
head = "?";
}
const home = homedir();
parts.push(head, fileStamp(path.join(common, "config")), fileStamp(path.join(gitDir, "config.worktree")), fileStamp(process.env.GIT_CONFIG_GLOBAL || path.join(home, ".gitconfig")), fileStamp(path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "git", "config")));
return parts.join("\0");
}
async function pullsTarget(cwd) {
const hit = pullsTargets.get(cwd);
if (hit && Date.now() - hit.at < PULLS_TARGET_TTL_MS) {
try {
const { target, signature } = await hit.value;
if (pullsSignature(target.root) === signature)
return target;
}
catch (error) {
// A shared lookup that failed fails its own callers; the next call looks again.
if (pullsTargets.get(cwd) === hit)
pullsTargets.delete(cwd);
throw error;
}
}
const entry = { at: Date.now(), value: withIndependentGitReadDeadline(async () => {
const root = await repository(cwd);
// Signed before the reads below, so a change made while they run is seen by the next call.
const signature = pullsSignature(root);
const branch = (await git(root, "branch", "--show-current").catch(() => "")).trim();
const host = await detectHost(root, branch);
const { apiHost } = await tokenDestination(root, host);
return { target: { root, branch, host: { ...host, apiHost } }, signature };
}) };
pullsTargets.set(cwd, entry);
if (pullsTargets.size > 64)
pullsTargets.delete(pullsTargets.keys().next().value);
try {
return (await entry.value).target;
}
catch (error) {
if (pullsTargets.get(cwd) === entry)
pullsTargets.delete(cwd);
throw error;
}
}
/** Open pull or merge requests and the checked-out branch's own (any state)
* with its checks, from whichever host the remote is on (git-hosts.ts). A
* host that cannot answer (a missing CLI, a stubbed provider, an unknown
* domain) is `{ available: false, reason, message }`, never an error. */
export async function gitPulls(cwd, retried = false) {
const { root, branch, host } = await pullsTarget(cwd);
const provider = providerFor(host);
const credential = await credentialIdentity(host);
const key = repositoryId(root) + "\0" + (host.domain ?? "").toLowerCase().replace(/\.$/, "") + "\0" + JSON.stringify([branch, host, credential]);
try {
return await pullsCache.get(key, () => withoutGitReadDeadline(async () => {
const list = provider ? await provider.list(root, branch, host) : unknownHost(host);
// A sign-in or token change during the lookup: never cache what the old credentials returned.
if (await credentialIdentity(host, true) !== credential) {
pullsCache.invalidate(candidate => candidate === key);
throw new CredentialsChanged();
}
return { ...list, branch: branch || null, host };
}));
}
catch (error) {
if (!(error instanceof CredentialsChanged))
throw error;
// Read again once under the new credentials rather than failing the request.
if (!retried)
return gitPulls(cwd, true);
throw new BridgeError(409, "Forge credentials keep changing. Retry.");
}
}
/** A repo-relative tree path, or the repository root for `""`. */
export 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("..") || segments.includes(".git"))
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); invalidatePulls(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(".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 gitHead(root) ?? "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, limit = 64) {
const list = Array.isArray(raw) ? raw : [];
if (!list.length || list.length > limit)
throw new BridgeError(400, `Choose between 1 and ${limit} 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 ${limit} paths.`);
return paths;
}
export async function gitStage(cwd, paths, confirmBulk = false) {
const root = await repository(cwd);
treeCache.delete(root);
try {
const files = await repositoryPaths(root, paths, 500);
if (!confirmBulk) {
if (files.length > 100)
throw new BridgeError(409, "Confirm staging more than 100 paths with confirmBulk=true.");
const untracked = new Set((await git(root, "ls-files", "--others", "--exclude-standard", "-z", "--", ...files.map(file => `:(literal)${file}`))).split("\0").filter(Boolean));
for (const file of files) {
const info = await lstat(path.join(root, file)).catch(error => { if (error.code === "ENOENT")
return undefined; throw error; });
if (info?.isDirectory())
throw new BridgeError(409, "Confirm staging this directory and its contents with confirmBulk=true.");
if (untracked.has(file)) {
const counts = await untrackedCounts(root, file);
if (counts.binary || !counts.countsComplete)
throw new BridgeError(409, "Confirm staging this binary or uncounted untracked file with confirmBulk=true.");
}
}
}
await git(root, "add", "--", ...files.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 gitHead(root);
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);
// A directory pathspec would revert or remove everything under it.
for (const file of files) {
const info = await lstat(path.join(root, file)).catch(error => { if (error.code === "ENOENT")
return undefined; throw error; });
// A deleted folder is gone from disk but still a directory in the index,
// which is what `git checkout` restores from.
const indexed = info ? "" : await git(root, "ls-files", "-z", "--", `:(literal)${file}`);
if (info?.isDirectory() || indexed.split("\0").some(entry => entry.startsWith(`${file}/`))) {
throw new BridgeError(409, "Discard the files in this directory one at a time.");
}
}
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);
}
}
/** The empty tree's id in this repository's hash, the parent a root commit is
* compared with. */
async function emptyTree(root) {
return (await git(root, "hash-object", "-t", "tree", "/dev/null")).trim();
}
const MAX_SHOW_FILES = 300, MAX_SHOW_PATCH = 1_000_000, MAX_FILE_PATCH = 200_000;
/** One commit as the phone's commit screen shows it: message, author and
* committer, parents and refs, and every file it changed against its first
* parent (what GitHub shows for a merge) with counts and a bounded patch in
* the same section shape `/v1/diff` uses. */
export function gitShow(cwd, sha) {
return withGitReadDeadline(() => readGitShow(cwd, sha));
}
async function readGitShow(cwd, sha) {
if (typeof sha !== "string" || !/^[0-9a-fA-F]{4,64}$/.test(sha))
throw new BridgeError(400, "Name a commit by its hash.");
const root = await repository(cwd);
let commit;
try {
commit = (await git(root, "rev-parse", "--verify", "--end-of-options", `${sha}^{commit}`)).trim();
}
catch {
throw new BridgeError(404, "This repository has no such commit.", { code: "git-unknown-commit" });
}
const meta = await git(root, "show", "-s", "--decorate=short", "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%cn%x00%cI%x00%P%x00%D%x00%B", commit);
const [full, short, author, authorEmail, date, committer, committed, parentList, spec, ...message] = meta.split("\0");
const body = message.join("\0").replace(/\n+$/, "");
const newline = body.indexOf("\n");
const subject = newline < 0 ? body : body.slice(0, newline);
const parents = (parentList ?? "").split(" ").filter(Boolean);
const base = parents[0] ?? await emptyTree(root);
const range = [base, commit];
const [names, counts] = await Promise.all([
git(root, "diff-tree", "-r", "-z", "-M", "--no-commit-id", "--name-status", ...range),
git(root, "diff-tree", "-r", "-z", "-M", "--no-commit-id", "--numstat", ...range),
]);
const stats = parseNumstat(counts);
const tokens = names.split("\0");
const files = [];
let totalFiles = 0, truncated = false, remaining = MAX_SHOW_PATCH, additions = 0, deletions = 0;
for (let index = 0; index < tokens.length; index++) {
const code = tokens[index];
if (!code)
continue;
const renamed = /^[RC]/.test(code);
const oldPath = renamed ? tokens[++index] : undefined, file = tokens[++index];
if (!file)
break;
totalFiles++;
const count = stats.get(file) ?? { additions: 0, deletions: 0, binary: false, countsComplete: false };
additions += count.additions;
deletions += count.deletions;
if (files.length >= MAX_SHOW_FILES) {
truncated = true;
continue;
}
const status = statusLetter(code);
let section;
if (remaining <= 0 || count.binary) {
section = { id: `commit:${file}`, kind: "commit", binary: count.binary, loadState: "loaded", patch: "", truncated: !count.binary };
truncated ||= !count.binary;
}
else {
const patch = await git(root, ...NO_DIFF, "-M", ...range, "--", ...[file, ...(oldPath ? [oldPath] : [])].map(name => `:(literal)${name}`));
const limit = Math.min(MAX_FILE_PATCH, remaining), clipped = patch.length > limit;
section = { id: `commit:${file}`, kind: "commit", binary: /^(?:Binary files |GIT binary patch)/m.test(patch), loadState: "loaded", patch: patch.slice(0, limit), truncated: clipped };
remaining -= Math.min(patch.length, limit);
truncated ||= clipped;
}
files.push({ path: file, ...(oldPath ? { oldPath } : {}), status, additions: count.additions, deletions: count.deletions,
binary: count.binary, countsComplete: count.countsComplete, sections: [section] });
}
return { sha: full, short, subject, body: newline < 0 ? "" : body.slice(newline + 1).replace(/^\n+/, ""), author, authorEmail, date,
committer, committed, parents, refs: refEntries(spec ?? "", await remotes(root)), files, totalFiles, additions, deletions, truncated };
}