@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
144 lines (143 loc) • 7.9 kB
JavaScript
import { BridgeError } from "./protocol.js";
import { git, gitRoot } from "./projects.js";
import { countGit } from "./metrics.js";
import { invalidateTree } from "./git.js";
import { runCombined } from "./git-publish.js";
/** Moving the checkout from the phone: switch or create a branch, fetch the
* upstream remote, fast-forward to it. Like commit and push, a refusal from
* Git itself is a normal `{ ok: false, output }` answer with its own words.
* Nothing here rewrites history, forces, resets or stashes. */
const SYNC_TIMEOUT_MS = 110_000;
const SWITCH_TIMEOUT_MS = 60_000;
async function repository(cwd) {
const root = await gitRoot(cwd);
if (!root)
throw new BridgeError(409, "This pane is not in a Git repository.", { code: "git-not-repository" });
return root;
}
function refused(run, command, timeout) {
return { ok: false, output: run.timedOut ? `${run.output}\n[${command} did not finish within ${timeout / 1000} seconds]`.trim() : run.output || `${command} failed.` };
}
/** A branch name Git itself accepts, never an option. */
async function branchName(root, raw) {
if (typeof raw !== "string" || !raw || raw.length > 250 || raw.startsWith("-") || /[\x00-\x1f\x7f\s]/.test(raw))
throw new BridgeError(400, "Enter a valid branch name.");
try {
await git(root, "check-ref-format", "--branch", raw);
}
catch {
throw new BridgeError(400, `${raw} is not a valid branch name.`);
}
return raw;
}
async function exists(root, ref) {
return git(root, "show-ref", "--verify", "--quiet", ref).then(() => true, () => false);
}
/** Tracked files with staged or unstaged edits. Untracked files never block a
* switch unless Git itself refuses to overwrite one. */
async function trackedChanges(root) {
const rows = (await git(root, "status", "--porcelain=v1", "-z", "--untracked-files=no")).split("\0");
let count = 0;
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
if (!row)
continue;
count++;
if (/[RC]/.test(row.slice(0, 2)))
index++;
}
return count;
}
/** Switch to a local branch, or create one (from `startPoint`, a branch or
* remote-tracking branch, else HEAD) and switch to it. A remote-tracking
* start point sets it as the upstream. Uncommitted tracked edits travel with
* a switch in Git; the phone must say it knows (`carryChanges`), and Git
* still refuses whatever would be overwritten. */
export async function gitCheckout(cwd, body) {
const root = await repository(cwd);
const branch = await branchName(root, body.branch);
const create = body.create === true;
let startPoint, track = false;
if (body.startPoint !== undefined && body.startPoint !== null) {
if (!create)
throw new BridgeError(400, "A start point only applies to a new branch.");
const raw = body.startPoint;
if (typeof raw !== "string" || !raw || raw.length > 512 || raw.startsWith("-") || /[\x00-\x1f\x7f\s]/.test(raw))
throw new BridgeError(400, "Enter a valid start point.");
if (await exists(root, `refs/heads/${raw}`))
startPoint = `refs/heads/${raw}`;
else if (await exists(root, `refs/remotes/${raw}`)) {
startPoint = `refs/remotes/${raw}`;
track = true;
}
else
throw new BridgeError(404, `There is no branch named ${raw}.`, { code: "git-unknown-ref" });
}
const local = await exists(root, `refs/heads/${branch}`);
if (create && local)
throw new BridgeError(409, `A branch named ${branch} already exists.`, { code: "git-branch-exists" });
if (!create && !local)
throw new BridgeError(404, `There is no local branch named ${branch}.`, { code: "git-unknown-ref" });
const previous = (await git(root, "branch", "--show-current")).trim();
if (!create && previous === branch)
return { ok: true, branch, previous, changed: false };
const dirty = await trackedChanges(root);
if (dirty > 0 && body.carryChanges !== true) {
throw new BridgeError(409, `${dirty === 1 ? "1 file has" : `${dirty} files have`} uncommitted changes that would move to ${branch}. Confirm to carry them.`, { code: "git-dirty", changes: dirty });
}
const args = ["-C", root, "--no-pager", "switch", "--no-guess", ...(create ? [...(track ? ["--track"] : ["--no-track"]), "-c", branch, ...(startPoint ? [startPoint] : [])] : [branch])];
invalidateTree(root);
countGit("projects");
const run = await runCombined("git", args, root, SWITCH_TIMEOUT_MS);
invalidateTree(root);
if (run.code !== 0)
return refused(run, "git switch", SWITCH_TIMEOUT_MS);
const upstream = (await git(root, "for-each-ref", "--format=%(upstream:short)", `refs/heads/${branch}`)).trim();
return { ok: true, branch, previous, changed: true, created: create, ...(upstream ? { upstream } : {}), carried: dirty };
}
/** The current branch's upstream, as remote and `refs/heads/…` merge ref. */
async function upstreamOf(root) {
const branch = (await git(root, "branch", "--show-current")).trim();
if (!branch)
return { branch };
const remote = (await git(root, "config", "--get", `branch.${branch}.remote`).catch(() => "")).trim();
const merge = (await git(root, "config", "--get", `branch.${branch}.merge`).catch(() => "")).trim();
return remote && remote !== "." && merge.startsWith("refs/heads/") ? { branch, remote, merge } : { branch };
}
/** Fetch the upstream's remote (else `origin`) and prune what it deleted, so
* ahead/behind counts and remote branches are current. Tags are not fetched. */
export async function gitFetch(cwd) {
const root = await repository(cwd);
const { remote: upstreamRemote } = await upstreamOf(root);
const remotes = (await git(root, "remote")).split("\n").map(line => line.trim()).filter(Boolean);
const remote = upstreamRemote ?? (remotes.includes("origin") ? "origin" : remotes[0]);
if (!remote || !remotes.includes(remote))
throw new BridgeError(409, "This repository has no remote to fetch from.", { code: "git-no-remote" });
countGit("projects");
const run = await runCombined("git", ["-C", root, "--no-pager", "fetch", "--prune", "--no-tags", "--quiet", remote], root, SYNC_TIMEOUT_MS);
invalidateTree(root);
if (run.code !== 0)
return refused(run, "git fetch", SYNC_TIMEOUT_MS);
return { ok: true, remote, ...(run.output ? { output: run.output } : {}) };
}
/** Fetch, then fast-forward the current branch to its upstream. Never a merge
* commit or a rebase: a branch that has diverged is refused by Git and the
* phone shows why. */
export async function gitPull(cwd) {
const root = await repository(cwd);
const { branch, remote, merge } = await upstreamOf(root);
if (!branch)
throw new BridgeError(409, "HEAD is detached. Check out a branch on the computer first.", { code: "git-detached" });
if (!remote || !merge)
throw new BridgeError(409, `${branch} has no upstream to pull from. Push it first.`, { code: "git-no-upstream" });
const before = (await git(root, "rev-parse", "HEAD")).trim();
countGit("projects");
invalidateTree(root);
const run = await runCombined("git", ["-C", root, "--no-pager", "pull", "--ff-only", "--no-rebase", "--no-tags", "--quiet", remote, merge], root, SYNC_TIMEOUT_MS);
invalidateTree(root);
if (run.code !== 0)
return refused(run, "git pull", SYNC_TIMEOUT_MS);
const after = (await git(root, "rev-parse", "HEAD")).trim();
const commits = before === after ? 0 : Number((await git(root, "rev-list", "--count", `${before}..${after}`)).trim()) || 0;
return { ok: true, branch, upstream: `${remote}/${merge.slice("refs/heads/".length)}`, commits, ...(run.output ? { output: run.output } : {}) };
}