UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

788 lines (787 loc) • 38.4 kB
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 }; }