UNPKG

@phren/cli

Version:

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

146 lines (145 loc) • 8.13 kB
import { spawn } from "node:child_process"; import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { BridgeError } from "./protocol.js"; import { git, gitRoot } from "./projects.js"; import { countGit } from "./metrics.js"; import { defaultBranch, invalidateTree } from "./git.js"; /** Finishing a session from the phone: commit what is staged, push the * current branch, open a pull request. Each acts on the repository the * existing /v1/git/* routes resolve (the pane's, a child's worktree, or a * listed worktree id). A refusal from Git itself (a hook, a rejected push, * gh's own error) is a normal answer carrying the output verbatim, since the * phone's error channel flattens and truncates text. */ const MAX_OUTPUT = 65_536; const COMMIT_TIMEOUT_MS = 110_000; const PUSH_TIMEOUT_MS = 110_000; const PR_TIMEOUT_MS = 60_000; const MAX_MESSAGE = 20_000; /** One process with stdout and stderr interleaved in arrival order, bounded, * never attached to a terminal and never prompting. */ export function runCombined(command, args, cwd, timeout) { return new Promise(resolve => { let output = "", truncated = false, timedOut = false, settled = false; const finish = (run) => { if (!settled) { settled = true; resolve(run); } }; const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: nonInteractiveGitEnv({ ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_EDITOR: "true", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1" }), }); const append = (chunk) => { if (truncated) return; output += chunk.toString("utf8"); if (output.length > MAX_OUTPUT) { output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]"; truncated = true; } }; child.stdout.on("data", append); child.stderr.on("data", append); const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeout); child.on("error", (error) => { clearTimeout(timer); finish({ code: null, output: error.message, missing: error.code === "ENOENT", timedOut: false }); }); child.on("close", code => { clearTimeout(timer); finish({ code, output: output.trimEnd(), missing: false, timedOut }); }); }); } 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; } async function currentBranch(root) { const branch = (await git(root, "branch", "--show-current")).trim(); if (!branch) throw new BridgeError(409, "HEAD is detached. Check out a branch on the computer first."); return branch; } async function config(root, key) { return (await git(root, "config", "--get", key).catch(() => "")).trim() || undefined; } /** `git commit` of the index only: no `-a`, no `--no-verify`, no amend. A * hook's refusal comes back as `{ ok: false, output }`, exactly as printed. */ export async function gitCommit(cwd, message) { if (typeof message !== "string" || !message.trim()) throw new BridgeError(400, "Write a commit message first."); if (message.length > MAX_MESSAGE || message.includes("\0")) throw new BridgeError(400, "The commit message is too long."); const root = await repository(cwd); // `diff --cached --quiet` exits 1 when something is staged. const staged = await git(root, "diff", "--cached", "--quiet").then(() => false, () => true); if (!staged) throw new BridgeError(409, "Nothing is staged. Stage the files to commit first."); invalidateTree(root); try { countGit("projects"); const run = await runCombined("git", ["-C", root, "--no-pager", "commit", "--quiet", "--cleanup=strip", "-m", message], root, COMMIT_TIMEOUT_MS); if (run.code !== 0) { return { ok: false, output: run.timedOut ? `${run.output}\n[git commit did not finish within ${COMMIT_TIMEOUT_MS / 1000} seconds]`.trim() : run.output || "git commit failed." }; } const [sha, short, subject] = (await git(root, "log", "-1", "--format=%H%x00%h%x00%s")).trim().split("\0"); return { ok: true, sha, short, subject, branch: (await git(root, "branch", "--show-current")).trim(), ...(run.output ? { output: run.output } : {}) }; } finally { invalidateTree(root); } } /** The current branch to its upstream, or to `origin` with the upstream set. * Never forced: no `--force`, no `+` refspec, no `--no-verify`. The default * branch needs the phone's explicit `confirmDefault`. */ export async function gitPush(cwd, confirmDefault) { const root = await repository(cwd); const branch = await currentBranch(root); const upstreamRemote = await config(root, `branch.${branch}.remote`); const merge = await config(root, `branch.${branch}.merge`); const tracked = !!upstreamRemote && upstreamRemote !== "." && !!merge?.startsWith("refs/heads/"); const remote = tracked ? upstreamRemote : "origin"; const remotes = (await git(root, "remote").catch(() => "")).split("\n").map(line => line.trim()).filter(Boolean); if (!remotes.includes(remote)) { throw new BridgeError(409, tracked ? `The upstream remote ${remote} is not configured.` : "This branch has no upstream and the repository has no origin remote."); } const destination = tracked ? merge.slice("refs/heads/".length) : branch; const fallback = await defaultBranch(root, remote); if ((destination === fallback || branch === fallback) && confirmDefault !== true) { throw new BridgeError(409, `${branch} is the default branch. Confirm to push it.`, { defaultBranch: fallback }); } const args = ["-C", root, "--no-pager", "push", "--porcelain", ...(tracked ? [] : ["--set-upstream"]), remote, `refs/heads/${branch}:refs/heads/${destination}`]; countGit("projects"); const run = await runCombined("git", args, root, PUSH_TIMEOUT_MS); invalidateTree(root); if (run.code !== 0) { return { ok: false, output: run.timedOut ? `${run.output}\n[git push did not finish within ${PUSH_TIMEOUT_MS / 1000} seconds]`.trim() : run.output || "git push failed." }; } return { ok: true, branch, remote, upstream: `${remote}/${destination}`, setUpstream: !tracked, output: run.output }; } const PULL_URL = /https:\/\/[^\s"'<>]+\/pull\/\d+/; /** `gh pr create --fill` for the current branch. A missing or signed-out gh * is `{ ok: false, reason: "missing" | "auth" }`; gh's own refusal is * `reason: "failed"` with its output. A pull request that already exists for * the branch comes back as success with `existing: true`. */ export async function gitPullRequest(cwd, draft) { const root = await repository(cwd); const branch = await currentBranch(root); const version = await runCombined("gh", ["--version"], root, 10_000); if (version.missing || version.code !== 0) { return { ok: false, reason: "missing", message: "The GitHub CLI (gh) is not installed on this computer. Install it, then run gh auth login there." }; } const auth = await runCombined("gh", ["auth", "status"], root, 15_000); if (auth.code !== 0) { return { ok: false, reason: "auth", message: "The GitHub CLI is not signed in on this computer. Run gh auth login there.", output: auth.output }; } const run = await runCombined("gh", ["pr", "create", "--fill", "--head", branch, ...(draft === true ? ["--draft"] : [])], root, PR_TIMEOUT_MS); const url = PULL_URL.exec(run.output)?.[0]; if (run.code === 0 && url) return { ok: true, url, branch, draft: draft === true }; if (url && /already exists/i.test(run.output)) return { ok: true, url, branch, existing: true }; return { ok: false, reason: "failed", output: run.timedOut ? `${run.output}\n[gh did not finish within ${PR_TIMEOUT_MS / 1000} seconds]`.trim() : run.output || "gh pr create failed." }; }