/** * @file Git worktree management for lanes: creation, reset, removal, and the * three-check destroy guard that stands between a mis-click and a user's real * project directory. Every destructive function (resetWorktree, removeWorktree) * verifies the lane against all three safety checks before touching git. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFile } = require("node:child_process"); const { promisify } = require("node:util"); const execFileAsync = promisify(execFile); const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes"); const PROTECTED_BRANCHES = new Set(["main", "master"]); /** * Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }. * Treats zero exit code as success even if stderr has hints. * * CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) * that leak from parent processes. Without this, git operations on a worktree (where * .git is a file, not a directory) fail with ".git/index: index file open failed: * Not a directory" when run from within a git hook or from a shell that inherited * these variables. This module's whole job is to run git safely against repos other * than the one enclosing the current working directory. */ async function git(cwd, args) { // Build a clean environment: copy process.env but scrub git hook variables // that could point to the outer repo's git directory or index. const env = { ...process.env }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_COMMON_DIR; delete env.GIT_OBJECT_DIRECTORY; delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES; delete env.GIT_PREFIX; delete env.GIT_NAMESPACE; delete env.GIT_CONFIG_PARAMETERS; // GIT_CONFIG_COUNT + GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n inject arbitrary git // config into every invocation — including core.hooksPath, which would make an // untrusted repo run our git commands' hooks. GIT_CONFIG_GLOBAL/SYSTEM do the // same by redirecting which config files are read. All are scrubbed. for (const name of Object.keys(env)) { if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name]; } // Prevent credential prompts from hanging a background provisioning job env.GIT_TERMINAL_PROMPT = "0"; try { const result = await execFileAsync("git", args, { cwd, env, maxBuffer: 8 * 1024 * 1024, }); return { stdout: result.stdout, stderr: result.stderr }; } catch (err) { const error = new Error(`git ${args[0]} failed`); error.code = err.code; error.git = { args, code: err.code, stderr: err.stderr }; throw error; } } /** * Check if a directory is a git repository. */ async function isGitRepo(dir) { try { await git(dir, ["rev-parse", "--git-dir"]); return true; } catch { return false; } } /** * Whether `name` is a legal git branch name, per git's own rules rather than * a hand-rolled regex. `cwd` need not be `sourceRepo` specifically — the * check is not repo-dependent — but `git()` requires a directory to run in. */ async function isValidBranchName(cwd, name) { if (typeof name !== "string" || !name) return false; try { await git(cwd, ["check-ref-format", "--branch", name]); return true; } catch { return false; } } /** * List a repo's local branches plus its current HEAD branch, so a caller can * offer a real picker instead of asking someone to remember a branch name. * Local branches only (not `origin/*` refs) — those are what `addWorktree` * can actually check a new worktree out onto without a fetch first. */ async function listBranches(sourceRepo) { const result = await git(sourceRepo, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]); const branches = result.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean); let current = null; try { const head = await git(sourceRepo, ["symbolic-ref", "--short", "HEAD"]); current = head.stdout.trim(); } catch { // Detached HEAD: no current branch, and that's fine - the caller still // gets the full branch list to choose a base from. } return { branches, current }; } /** * Resolve the base branch: try origin/, then , then HEAD. */ async function resolveBase(sourceRepo, wanted) { // Try origin/ try { await git(sourceRepo, ["rev-parse", "--verify", "--quiet", `origin/${wanted}`]); return wanted; } catch { // Fall through to next attempt } // Try try { await git(sourceRepo, ["rev-parse", "--verify", "--quiet", wanted]); return wanted; } catch { // Fall through to next attempt } // Fall back to current HEAD const result = await git(sourceRepo, ["rev-parse", "--abbrev-ref", "HEAD"]); return result.stdout.trim(); } /** * Slugify a title into a safe branch-name segment: lowercase, non-alphanumerics to `-`, * collapsed, trimmed, max 40 chars. Throws EBADSLUG if result is empty. */ function slugify(text) { const result = text .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40); if (!result) { const err = new Error("slug is empty"); err.code = "EBADSLUG"; throw err; } return result; } /** * Parse `git worktree list --porcelain` output. * Records are separated by blank lines, with keys like: * - worktree * - branch refs/heads/ * - locked (optional, bare line) */ async function listWorktrees(sourceRepo) { const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]); const lines = result.stdout.split("\n"); const worktrees = []; let current = {}; for (const line of lines) { if (!line.trim()) { if (current.path) { worktrees.push(current); current = {}; } continue; } if (line.startsWith("worktree ")) { current.path = line.slice("worktree ".length); } else if (line.startsWith("branch ")) { const branchPath = line.slice("branch ".length); // Strip refs/heads/ prefix current.branch = branchPath.replace(/^refs\/heads\//, ""); } else if (line === "locked") { current.locked = true; } } if (current.path) { worktrees.push(current); } return worktrees; } /** * Find which worktree (if any) has a given branch checked out. */ async function branchCheckedOutAt(sourceRepo, branch) { const worktrees = await listWorktrees(sourceRepo); const found = worktrees.find((w) => w.branch === branch); return found ? found.path : null; } /** * Create a new worktree, or add an existing branch to a new worktree. * - If branch is already checked out elsewhere, throw EBRANCHBUSY. * - If branch doesn't exist, create it with -b from base. * - If branch exists, add without -b (reuse existing). */ async function addWorktree({ sourceRepo, dir, branch, base }) { // Check if branch is already checked out elsewhere const checkedOutAt = await branchCheckedOutAt(sourceRepo, branch); if (checkedOutAt) { const err = new Error(`branch ${branch} already checked out at ${checkedOutAt}`); err.code = "EBRANCHBUSY"; err.checkedOutAt = checkedOutAt; throw err; } // Ensure parent directory exists fs.mkdirSync(path.dirname(dir), { recursive: true }); // Check if branch already exists let branchExists = false; try { await git(sourceRepo, ["rev-parse", "--verify", "--quiet", branch]); branchExists = true; } catch { // Branch doesn't exist, we'll create it with -b } if (branchExists) { // Branch exists, use it await git(sourceRepo, ["worktree", "add", dir, branch]); return { dir, branch, created: false }; } else { // Branch doesn't exist, create it from base await git(sourceRepo, ["worktree", "add", "-b", branch, dir, base]); return { dir, branch, created: true }; } } /** * Delete a branch safely: never delete protected branches or falsy branch. */ async function deleteBranchSafely(sourceRepo, branch, baseBranch) { if (!branch) { return; // Branch is falsy, don't delete } if (PROTECTED_BRANCHES.has(branch)) { return; // Protected branch } if (baseBranch && branch === baseBranch) { return; // Never delete the base branch } try { await git(sourceRepo, ["branch", "-D", branch]); } catch { // Ignore deletion failures } } /** * Check 1 on its own: only a dashboard-provisioned worktree may ever be * destroyed. Shared with removeWorktree's prune path, which cannot run checks 2 * and 3 as written (there is no directory left to resolve) but must still refuse * an adopted lane outright. */ function assertManaged(lane) { if (lane.kind !== "managed") { const err = new Error(`lane kind is ${lane.kind}, not managed`); err.code = "ENOTMANAGED"; throw err; } } /** * Check 2 for a path that may no longer exist: is this lane's RECORDED cwd * inside LANES_ROOT on a path boundary? Purely lexical after resolving * LANES_ROOT itself, because a hand-deleted worktree cannot be realpath'd. * assertDestroyable still realpaths a live cwd, which additionally defeats * symlinks; this weaker form only ever gates operations that touch git * bookkeeping, never a directory. */ function isInsideLanesRoot(cwd) { let resolvedRoot; try { resolvedRoot = fs.realpathSync(LANES_ROOT); } catch { return false; } const relativePath = path.relative(resolvedRoot, path.resolve(cwd)); return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); } /** * Three checks for whether a lane can be safely destroyed: * 1. kind must be "managed" (not "adopted") * 2. cwd must resolve to a path inside LANES_ROOT * 3. The path must be listed in git worktree list for the source repo */ async function assertDestroyable(lane) { // Check 1: kind must be "managed" assertManaged(lane); // Check 2: cwd must be inside LANES_ROOT on a path boundary let resolvedCwd; let resolvedRoot; try { resolvedCwd = fs.realpathSync(lane.cwd); } catch { // Path doesn't exist, which means it's not a live worktree const err = new Error(`lane cwd does not exist: ${lane.cwd}`); err.code = "EOUTSIDEROOT"; throw err; } try { resolvedRoot = fs.realpathSync(LANES_ROOT); } catch { // LANES_ROOT doesn't exist, so cwd can't be inside it const err = new Error(`LANES_ROOT does not exist: ${LANES_ROOT}`); err.code = "EOUTSIDEROOT"; throw err; } // Check that cwd is inside LANES_ROOT on a path boundary const relativePath = path.relative(resolvedRoot, resolvedCwd); if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { const err = new Error(`lane cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`); err.code = "EOUTSIDEROOT"; throw err; } // Check 3: path must be in worktree list. // git keeps listing a hand-deleted worktree (as `prunable`), so realpath must // be tolerated per entry: an entry we cannot resolve is simply not this lane. // Throwing here failed reset/remove for every OTHER lane in the same repo with // an ENOENT naming an unrelated directory. const worktrees = await listWorktrees(lane.source_repo); const exists = worktrees.some((w) => { try { return fs.realpathSync(w.path) === resolvedCwd; } catch { return false; } }); if (!exists) { const err = new Error(`lane is not listed as a worktree in ${lane.source_repo}`); err.code = "ENOTWORKTREE"; throw err; } } /** * Reset a worktree to its base branch: checkout base, reset hard, clean files, * then reset the feature branch to the base. This uses git branch -f from a * separate working directory context to avoid worktree association restrictions. * * Verifies the base branch exists BEFORE any mutations, and verifies the final * state ends on the feature branch (not left on base or detached). */ async function resetWorktree(lane) { // Safety check await assertDestroyable(lane); const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane; // CRITICAL: Verify base branch exists BEFORE any mutations. // If base doesn't exist, we can't safely reset anything. try { await git(cwd, ["rev-parse", "--verify", baseBranch]); } catch { const err = new Error(`base branch does not exist: ${baseBranch}`); err.code = "ENOBASE"; throw err; } // Fetch and prune (tolerate failure if no remote) try { await git(cwd, ["fetch", "origin", "--prune"]); } catch { // Ignore: may not have a remote } // Checkout base. Should succeed now that we've verified it exists. try { await git(cwd, ["checkout", baseBranch]); } catch { // Doesn't exist locally, try to create from remote (may fail if no remote) try { await git(cwd, ["checkout", "-b", baseBranch, `origin/${baseBranch}`]); } catch { // If both failed but rev-parse passed, the branch exists but we can't check it out // Try the reset anyway - it might work even if checkout failed } } // Reset hard to base await git(cwd, ["reset", "--hard", baseBranch]); // Clean untracked files (but NOT ignored files, so -x is omitted) await git(cwd, ["clean", "-fd"]); // Reset the feature branch. Git worktrees prevent deletion/force-update of // "their" branch, so we reset it in-place instead: checkout → reset hard. // ponytail: worktree association blocks deletion, reset-in-place instead try { // Try to checkout the feature branch await git(cwd, ["checkout", branch]); // Reset the current branch (feat/branch) to base await git(cwd, ["reset", "--hard", baseBranch]); } catch { // If checkout fails, the branch might not exist. Create it. try { await git(cwd, ["checkout", "-b", branch, baseBranch]); } catch (err) { // If both checkout and create failed, we're in trouble throw err; } } // CRITICAL: Verify we actually ended on the feature branch. // If this fails, the reset succeeded but left us on the wrong branch. const currentBranch = (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim(); if (currentBranch !== branch) { const err = new Error(`reset ended on wrong branch: expected ${branch}, got ${currentBranch}`); err.code = "ERESETBRANCH"; throw err; } // Try to clean up by deleting the branch from source repo (may fail if still in use) await deleteBranchSafely(sourceRepo, branch, baseBranch); } /** * Locate a worktree's administrative directory under the source repo's common * dir (`/worktrees/`) by matching the `gitdir` file each entry * points at against the worktree's cwd. That file's content is the absolute * path to the worktree's OWN `.git` file, so its dirname is the worktree path * — this still works when that `.git` file is corrupt, since we only ever * read it from the source repo's side. Returns null if no entry matches. */ async function findWorktreeAdminDir(sourceRepo, cwd) { const common = (await git(sourceRepo, ["rev-parse", "--git-common-dir"])).stdout.trim(); const worktreesDir = path.join(path.resolve(sourceRepo, common), "worktrees"); let entries; try { entries = fs.readdirSync(worktreesDir); } catch { return null; } const resolvedCwd = path.resolve(cwd); for (const name of entries) { let pointer; try { pointer = fs.readFileSync(path.join(worktreesDir, name, "gitdir"), "utf8").trim(); } catch { continue; } if (path.resolve(path.dirname(pointer)) === resolvedCwd) { return path.join(worktreesDir, name); } } return null; } /** * Remove a worktree completely: unlock, remove, prune, delete branch. * * When the directory was deleted by hand there is nothing on disk to destroy, * but git still registers the worktree and the branch — so that case takes the * prune path instead of the full three checks, which cannot resolve a path that * no longer exists. Checks 1 and 2 still hold there (an adopted lane is refused * outright; the recorded cwd must still be inside LANES_ROOT), and the operation * touches only git bookkeeping in the source repo. Check 3 is what the prune * replaces: a worktree git no longer lists needs no removal at all. */ async function removeWorktree(lane) { const { cwd, branch, source_repo: sourceRepo, base_branch: baseBranch } = lane; if (!fs.existsSync(cwd)) { assertManaged(lane); if (!isInsideLanesRoot(cwd)) { const err = new Error(`lane cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`); err.code = "EOUTSIDEROOT"; throw err; } // Drops git's record of the vanished worktree. A no-op when git never knew // it, which leaves only the branch to clean up. await git(sourceRepo, ["worktree", "prune"]); const stillListed = (await listWorktrees(sourceRepo)).some( (w) => path.resolve(w.path) === path.resolve(cwd) ); if (stillListed) { await git(sourceRepo, ["worktree", "remove", "--force", cwd]); } await deleteBranchSafely(sourceRepo, branch, baseBranch); return; } // Safety check await assertDestroyable(lane); // Unlock (ignore failure) try { await git(sourceRepo, ["worktree", "unlock", cwd]); } catch { // Ignore } // Remove the worktree (force). Git validates the worktree's OWN `.git` // pointer before it will touch it, and refuses outright (even with a // second --force) when that pointer is corrupt — the three checks above // already proved this is a real, managed worktree of this repo, so fall // back to deregistering it directly from the source repo's bookkeeping // rather than leaving the lane permanently stuck. This never touches the // worktree directory itself — only `/.git/worktrees/`. try { await git(sourceRepo, ["worktree", "remove", "--force", cwd]); } catch (removeErr) { const adminDir = await findWorktreeAdminDir(sourceRepo, cwd); if (!adminDir) throw removeErr; fs.rmSync(adminDir, { recursive: true, force: true }); } // Prune dead worktree entries await git(sourceRepo, ["worktree", "prune"]); // Delete the branch safely await deleteBranchSafely(sourceRepo, branch, baseBranch); } /** * Parse git status --porcelain to count dirty, untracked, and get HEAD commit. * Lines starting with ?? are untracked; others are dirty. */ async function statusCounts(dir) { const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]); let dirty = 0; let untracked = 0; for (const line of result.stdout.split("\n")) { if (!line.trim()) continue; if (line.startsWith("??")) { untracked++; } else { dirty++; } } // Get short commit hash const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]); const head = headResult.stdout.trim(); return { dirty, untracked, head }; } /** * What a lane's working copy looks like right now: which branch it is on, the * short HEAD, that commit's subject, and how much is uncommitted. * * Read-only and cheap, but it is three subprocesses, which is why it lives * behind its own endpoint rather than inside the polled `GET /api/lanes` * payload. A detached HEAD reports the literal `HEAD` that git returns — the * caller shows what git says rather than inventing a nicer word for it. */ async function gitFacts(dir) { const { dirty, untracked, head } = await statusCounts(dir); const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim(); const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim(); return { branch, head, subject, dirty, untracked }; } /** * Count the commits a destructive action would actually discard. * * With remotes configured: commits on no remote (`--not --remotes HEAD`). * * With NO remotes: the commits ahead of the lane's base branch * (`..HEAD`) — the work that belongs to this lane. Counting the whole * history instead made a freshly provisioned worktree in a local-only repo * report every commit in the repo as unpushed and demand Force to discard * commits a `reset --hard ` would never touch. The `no-remote` warning is * what tells the user nothing is backed up. * * Falls back to the total commit count only when there is no usable base to * measure against (an adopted lane has no `base_branch` at all). * Returns 0 if the repository is corrupt or unborn. * * @param {string} dir - Working directory to count in. * @param {string|null} [baseBranch] - The lane's base branch, when it has one. */ async function unpushedCount(dir, baseBranch = null) { try { // First check if there are any remotes const remotesResult = await git(dir, ["remote"]); const hasRemotes = !!remotesResult.stdout.trim(); if (hasRemotes) { // Has remotes: count commits not on any remote const result = await git(dir, ["rev-list", "--count", "--not", "--remotes", "HEAD"]); return parseInt(result.stdout.trim(), 10); } if (baseBranch) { try { const result = await git(dir, ["rev-list", "--count", `${baseBranch}..HEAD`]); return parseInt(result.stdout.trim(), 10); } catch { // Base branch is gone or never existed — fall through to the total. } } // No remote and no usable base: every commit is at risk, so count them all. const result = await git(dir, ["rev-list", "--count", "HEAD"]); return parseInt(result.stdout.trim(), 10); } catch { // Repository error (unborn HEAD, corrupt, etc.) return 0; } } /** * Check if the repository has no remotes configured at all. * Returns true if `git remote` output is empty, false otherwise. */ async function hasNoRemotes(dir) { try { const result = await git(dir, ["remote"]); return !result.stdout.trim(); } catch { // Assume remotes exist if we can't query return false; } } module.exports = { LANES_ROOT, git, isGitRepo, isValidBranchName, listBranches, resolveBase, slugify, listWorktrees, branchCheckedOutAt, addWorktree, assertManaged, isInsideLanesRoot, assertDestroyable, resetWorktree, removeWorktree, statusCounts, gitFacts, unpushedCount, hasNoRemotes, };