/** * Git plumbing for the worktree extension. * * Everything goes through `pi.exec`, so commands run with the same shell * configuration (and abort/timeout handling) as the rest of Pi. */ import { existsSync, statSync } from "node:fs"; import { resolve } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createExecRunner, currentBranch as sharedCurrentBranch, gitCommonDir, hasCommits as sharedHasCommits, repoRootFor, revParse, type RunGitOptions, } from "../../lib/git/index.ts"; import { isInside, realPathOfNearest } from "../../lib/path.ts"; import { isPidAlive } from "../../lib/spawn.ts"; import type { WorktreeConfig } from "./config.ts"; type GitCallOptions = Omit; async function git(pi: ExtensionAPI, args: string[], cwd: string, options: GitCallOptions = {}) { return createExecRunner(pi)(args, { cwd, ...options }); } type GitOutcome = { ok: true; stdout: string } | { ok: false; code: number; error: string }; /** Run git and classify the outcome, deriving `error` from stderr/stdout. */ async function gitOutcome(pi: ExtensionAPI, args: string[], cwd: string): Promise { const result = await git(pi, args, cwd); if (result.code === 0) return { ok: true, stdout: result.stdout }; return { ok: false, code: result.code, error: (result.stderr || result.stdout).trim() }; } /** * A network fetch: terminal prompting is disabled through the runner's env seam, * so a credentials prompt fails fast instead of hanging until its timeout. */ const NO_TERMINAL_PROMPT: GitCallOptions = { env: { GIT_TERMINAL_PROMPT: "0" } }; /** Absolute path of the repository root containing `cwd`, or undefined when not a git repo. */ export async function repoRoot(pi: ExtensionAPI, cwd: string): Promise { const root = await repoRootFor(pi, cwd); return root ? realPathOfNearest(root) : undefined; } /** Whether the repository has at least one commit (a worktree needs a commit to branch from). */ export async function hasCommits(pi: ExtensionAPI, repoRoot: string): Promise { return sharedHasCommits(createExecRunner(pi), repoRoot); } /** The branch currently checked out in a worktree. */ export async function currentBranch(pi: ExtensionAPI, dir: string): Promise { return sharedCurrentBranch(createExecRunner(pi), dir); } /** * Resolve the base ref to branch a new worktree from. * * "fresh": prefer the remote default branch (origin/HEAD). Refresh it when the * repository was not fetched recently, but never block on input; fall back to * whatever is cached, then to local HEAD. * "head": use local HEAD of the repository. */ export async function resolveBaseRef( pi: ExtensionAPI, repoRoot: string, config: WorktreeConfig, ): Promise<{ ref: string; mode: "fresh" | "head" }> { if (config.baseRef === "head") return { ref: "HEAD", mode: "head" }; const branch = await defaultBranch(pi, repoRoot); if (branch) { if (config.fetchRemote && (await fetchStale(pi, repoRoot))) { // Never block on credentials: treat an error or timeout as "keep using the cache". await git(pi, ["fetch", "--no-tags", "origin", branch], repoRoot, { ...NO_TERMINAL_PROMPT, timeoutMs: config.fetchTimeoutMs, }); } const remoteRef = `origin/${branch}`; if (await refExists(pi, repoRoot, remoteRef)) return { ref: remoteRef, mode: "fresh" }; } return { ref: "HEAD", mode: "head" }; } /** The repository's default branch name from `origin/HEAD`, if known. */ export async function defaultBranch(pi: ExtensionAPI, repoRoot: string): Promise { const symbolic = await git(pi, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], repoRoot); const branch = symbolic.stdout.trim().replace(/^refs\/remotes\/origin\//, ""); return branch || undefined; } const FETCH_MAX_AGE_MS = 24 * 60 * 60 * 1000; /** Whether the repository has not been fetched in the last 24 hours. */ export async function fetchStale(pi: ExtensionAPI, repoRoot: string, maxAgeMs = FETCH_MAX_AGE_MS): Promise { const common = await gitCommonDir(createExecRunner(pi), repoRoot); if (common === undefined) return true; const fetchHead = resolve(common, "FETCH_HEAD"); try { return Date.now() - statSync(fetchHead).mtimeMs > maxAgeMs; } catch { return true; } } /** Hard-reset a worktree to a ref (used when reopening a clean, unused worktree). */ export async function resetHard(pi: ExtensionAPI, dir: string, ref: string): Promise { await git(pi, ["reset", "--hard", ref], dir); } export interface PrReference { number: number; host: "github" | "gitlab" | "other"; } /** Parse `#1234`, a GitHub pull request URL, or a GitLab merge request URL. */ export function parsePrReference(input: string): PrReference | undefined { const hash = input.match(/^#(\d+)$/); if (hash) return { number: Number.parseInt(hash[1], 10), host: "other" }; try { const url = new URL(input); const pull = url.pathname.match(/\/pull\/(\d+)/); if (pull) return { number: Number.parseInt(pull[1], 10), host: url.hostname === "github.com" ? "github" : "other" }; const merge = url.pathname.match(/\/merge_requests\/(\d+)/); if (merge) return { number: Number.parseInt(merge[1], 10), host: url.hostname === "gitlab.com" ? "gitlab" : "other" }; } catch { // Not a URL. } return undefined; } /** Fetch a pull/merge request's head commit from origin. Never waits for credentials. */ export async function fetchPrRef( pi: ExtensionAPI, repoRoot: string, pr: PrReference, timeoutMs: number, ): Promise<{ ok: true } | { ok: false; error: string }> { const specs = pr.host === "gitlab" ? [`merge-requests/${pr.number}/head`] : [`pull/${pr.number}/head`, `merge-requests/${pr.number}/head`]; for (const spec of specs) { const result = await git(pi, ["fetch", "--no-tags", "origin", spec], repoRoot, { ...NO_TERMINAL_PROMPT, timeoutMs, }); if (result.code === 0) return { ok: true }; } return { ok: false, error: `Failed to fetch PR/MR #${pr.number}` }; } /** Path of a managed worktree directory from its name. */ export function worktreePath(repoRoot: string, config: WorktreeConfig, name: string): string { return realPathOfNearest(resolve(repoRoot, config.dir, name)); } export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise { const want = realPathOfNearest(dir); const result = await git(pi, ["worktree", "list", "--porcelain"], dir); if (result.code !== 0) return false; return result.stdout .split("\n") .some((line) => line.startsWith("worktree ") && realPathOfNearest(line.slice("worktree ".length).trim()) === want); } /** * `rev-list --count` for one range, e.g. `main..HEAD`; 0 when git cannot answer. * * The three counts below are the same command with a different range, and a caller that * reads a count as "no work here" must not have a failed read look like zero work. */ async function revListCount(pi: ExtensionAPI, dir: string, range: string): Promise { const result = await git(pi, ["rev-list", "--count", range], dir); if (result.code !== 0) return 0; const count = Number.parseInt(result.stdout.trim(), 10); return Number.isFinite(count) ? count : 0; } /** * Count of commits on `branch` that `base` does not contain. * * Takes the branch by name rather than reading `HEAD`, because a handoff is read * after the worktree may already be gone while its branch refs outlive it. */ export async function commitsAheadOf(pi: ExtensionAPI, dir: string, branch: string, base: string): Promise { return revListCount(pi, dir, `${base}..${branch}`); } /** Whether `ref` resolves here: a branch, a remote-tracking ref, or a tag. */ export async function refExists(pi: ExtensionAPI, dir: string, ref: string): Promise { return (await revParse(createExecRunner(pi), dir, ref)) !== undefined; } /** Whether a local branch exists. */ export async function branchExists(pi: ExtensionAPI, repoRoot: string, branch: string): Promise { const result = await git(pi, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot); return result.code === 0; } /** * Create a worktree. With `createBranch` (default) it adds a new branch from * `base`; otherwise it attaches an existing branch and `base` is ignored. * Returns an error message on failure. */ export async function worktreeAdd( pi: ExtensionAPI, repoRoot: string, dir: string, branch: string, base: string, createBranch = true, ): Promise<{ ok: true } | { ok: false; error: string }> { const args = createBranch ? ["worktree", "add", "-b", branch, dir, base] : ["worktree", "add", dir, branch]; const outcome = await gitOutcome(pi, args, repoRoot); if (outcome.ok) return { ok: true }; return { ok: false, error: outcome.error || `git worktree add exited with code ${outcome.code}` }; } export async function worktreeRemove( pi: ExtensionAPI, repoRoot: string, dir: string, force: boolean, ): Promise<{ ok: true } | { ok: false; error: string }> { const args = ["worktree", "remove"]; if (force) args.push("--force"); args.push(dir); const outcome = await gitOutcome(pi, args, repoRoot); if (outcome.ok) return { ok: true }; return { ok: false, error: outcome.error || "git worktree remove failed" }; } export async function deleteBranch( pi: ExtensionAPI, repoRoot: string, branch: string, force: boolean, ): Promise<{ ok: true } | { ok: false; error: string }> { const outcome = await gitOutcome(pi, ["branch", force ? "-D" : "-d", branch], repoRoot); if (outcome.ok) return { ok: true }; return { ok: false, error: outcome.error || `Could not delete branch ${branch}` }; } /** * Whether git ignores `path` in `dir`. Asks git rather than matching ignore * text, so a directory covered by a wider rule also counts as ignored. */ export async function isIgnored(pi: ExtensionAPI, dir: string, path: string): Promise { const result = await git(pi, ["check-ignore", "-q", "--", path], dir); return result.code === 0; } /** Porcelain status lines for a working tree; empty when clean. */ export async function statusEntries(pi: ExtensionAPI, dir: string): Promise { const result = await git(pi, ["status", "--porcelain"], dir); if (result.code !== 0) return []; return result.stdout.split("\n").filter((line) => line.trim().length > 0); } /** Count of commits reachable from HEAD but not from `base`. */ export async function commitsAhead(pi: ExtensionAPI, dir: string, base: string): Promise { return revListCount(pi, dir, `${base}..HEAD`); } /** Count of commits reachable from `base` but not from HEAD. */ export async function commitsBehind(pi: ExtensionAPI, dir: string, base: string): Promise { return revListCount(pi, dir, `HEAD..${base}`); } /** Whether `branch` is fully contained in `ref` (already merged). */ export async function isBranchMerged(pi: ExtensionAPI, dir: string, branch: string, ref: string): Promise { const result = await git(pi, ["merge-base", "--is-ancestor", branch, ref], dir); return result.code === 0; } /** Merge base of `ref` and HEAD, used as the fork point of a reopened worktree. */ export async function mergeBase(pi: ExtensionAPI, dir: string, ref: string): Promise { const result = await git(pi, ["merge-base", ref, "HEAD"], dir); return result.code === 0 ? result.stdout.trim() || undefined : undefined; } /** Why a checkout is unusable, as `checkCheckout` reports it. */ export type CheckoutRefusal = { ok: false; reason: "gone" | "unsafe" | "unverified"; detail: string }; /** The verdict on a recorded worktree: usable, or the refusal that says why not. */ export type CheckoutCheck = { ok: true } | CheckoutRefusal; /** * Verify a directory is a usable, separate checkout before restoring it. * Mirrors Claude Code's refusal checks. */ export async function checkCheckout(pi: ExtensionAPI, dir: string, mainRoot: string): Promise { if (!existsSync(dir) || !statSync(dir).isDirectory()) { return { ok: false, reason: "gone", detail: `Worktree directory ${dir} no longer exists.` }; } const top = await repoRoot(pi, dir); if (!top) { return { ok: false, reason: "unsafe", detail: `${dir} is not a git worktree.` }; } if (realPathOfNearest(top) === realPathOfNearest(mainRoot)) { return { ok: false, reason: "unsafe", detail: `${dir} resolves its working tree to the main checkout ${mainRoot}.`, }; } const commonDir = await gitCommonDir(createExecRunner(pi), dir); if (commonDir === undefined) { return { ok: false, reason: "unverified", detail: `Could not read git metadata for ${dir}.` }; } return { ok: true }; } /** * Inspect submodules recursively for uncommitted work. `known: false` means git * could not inspect them, so cleanup should not assume the worktree is clean. */ export async function submoduleChanges(pi: ExtensionAPI, dir: string): Promise<{ known: boolean; count: number }> { const list = await git(pi, ["submodule", "status", "--recursive"], dir); if (list.code !== 0) return { known: false, count: 0 }; const modules = list.stdout.split("\n").filter((line) => line.trim().length > 0); if (modules.length === 0) return { known: true, count: 0 }; const foreach = await git(pi, ["submodule", "foreach", "--recursive", "--quiet", "git status --porcelain"], dir); if (foreach.code !== 0) return { known: false, count: 0 }; const count = foreach.stdout .split("\n") .filter((line) => line.trim().length > 0 && !line.startsWith("Entering ")).length; return { known: true, count }; } export interface WorkInspection { dirty: string[]; ahead: number; sub: { known: boolean; count: number }; /** Any uncommitted change, commit ahead of `base`, or submodule change. */ hasWork: boolean; /** Submodules could not be inspected, so "clean" is unverified. */ unverifiable: boolean; } /** Inspect a worktree for work relative to `base` (commits reachable from HEAD but not it). */ export async function inspectWork(pi: ExtensionAPI, dir: string, base?: string): Promise { const dirty = await statusEntries(pi, dir); const ahead = base ? await commitsAhead(pi, dir, base) : 0; const sub = await submoduleChanges(pi, dir); return { dirty, ahead, sub, unverifiable: !sub.known, hasWork: dirty.length > 0 || ahead > 0 || sub.count > 0, }; } export interface ManagedWorktree { /** * Canonical path, resolved through symlinks by `listManagedWorktrees`. Every * caller takes that as given: re-resolving it here would repeat a sync ancestor * walk the listing already paid for, once per worktree per status read. */ path: string; branch?: string; head?: string; /** Lock reason when locked, empty string when locked without one. */ locked?: string; } /** List managed worktrees under the configured directory. */ export async function listManagedWorktrees( pi: ExtensionAPI, repoRoot: string, config: WorktreeConfig, ): Promise { const result = await git(pi, ["worktree", "list", "--porcelain"], repoRoot); if (result.code !== 0) return []; const managedRoot = realPathOfNearest(resolve(repoRoot, config.dir)); const entries: ManagedWorktree[] = []; let current: ManagedWorktree = { path: "" }; const flush = () => { if (!current.path) return; const path = realPathOfNearest(current.path); if (isInside(managedRoot, path)) { entries.push({ ...current, path }); } current = { path: "" }; }; for (const line of result.stdout.split("\n")) { if (line.startsWith("worktree ")) { flush(); current.path = line.slice("worktree ".length).trim(); } else if (line.startsWith("branch ")) { current.branch = line .slice("branch ".length) .trim() .replace(/^refs\/heads\//, ""); } else if (line.startsWith("HEAD ")) { current.head = line.slice("HEAD ".length).trim().slice(0, 8); } else if (line === "locked" || line.startsWith("locked ")) { current.locked = line === "locked" ? "" : line.slice("locked ".length).trim(); } else if (line.trim() === "") { flush(); } } flush(); return entries; } /** Lock a worktree so a concurrent sweep does not remove it. */ export async function lockWorktree(pi: ExtensionAPI, repoRoot: string, dir: string, reason: string): Promise { const result = await git(pi, ["worktree", "lock", "--reason", reason, dir], repoRoot); return result.code === 0; } /** Release a lock we hold. Failure is ignored (it may already be unlocked). */ export async function unlockWorktree(pi: ExtensionAPI, repoRoot: string, dir: string): Promise { await git(pi, ["worktree", "unlock", dir], repoRoot); } /** Owning pid from a `pi::` lock reason, when present. */ export function lockOwnerPid(reason: string): number | undefined { const match = reason.match(/^pi:(\d+):/); return match ? Number.parseInt(match[1], 10) : undefined; } /** Whether a lock reason names a `pi` process that is no longer alive. */ export function isStaleLock(reason: string): boolean { const pid = lockOwnerPid(reason); return pid !== undefined && !isPidAlive(pid); }