78f6e1be8e
- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no branch fields) - the right choice for a main repo you want stage detection on. Worktree mode (default) keeps the existing provisioning flow but now requires a manually-typed branch name instead of deriving one from the title. - POST /lanes/worktree accepts an optional `branch`, validated via `git check-ref-format --branch`; omitting it preserves the CLI's existing auto-derived-branch behavior. - New GET /lanes/browse lists a directory's immediate subdirectories, backing a small folder-browse modal on both path fields - browsers cannot expose an absolute path from a native picker, so this is server-backed instead, consistent with the tool's local-first model.
674 lines
22 KiB
JavaScript
674 lines
22 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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/<wanted>, then <wanted>, then HEAD.
|
|
*/
|
|
async function resolveBase(sourceRepo, wanted) {
|
|
// Try origin/<wanted>
|
|
try {
|
|
await git(sourceRepo, ["rev-parse", "--verify", "--quiet", `origin/${wanted}`]);
|
|
return wanted;
|
|
} catch {
|
|
// Fall through to next attempt
|
|
}
|
|
|
|
// Try <wanted>
|
|
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 <path>
|
|
* - branch refs/heads/<name>
|
|
* - 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 (`<common>/worktrees/<name>`) 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 `<sourceRepo>/.git/worktrees/<name>`.
|
|
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
|
|
* (`<base>..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 <base>` 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,
|
|
};
|