f514602697
Part A — a fetch/pull/push against a remote that needs a login no longer dies on "terminal prompts disabled": the server answers 409 EGITAUTH with the host, the git bar asks for a username + password/access token and resends the same action. AgentHub keeps its OWN credential store (<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host, shared by every user of the install, managed from Settings -> Git credentials. A login is only sent to one of the repo's own remote hosts, saved only after git accepted it, never returned by any route, and reaches git through an exclusive credential helper (never argv). Part B — merge a branch (or the diverged upstream) from the git bar; a merge that stops on conflicts lists them in the Changes panel with Keep mine / Take theirs / Mark resolved, then Complete merge or Abort merge (confirm gate). Clean tree, repo-root lane and an existing ref only; pull/switch/hunk actions are blocked mid-merge; staging a conflicted file with markers left is refused. Rebase/reset/stash stay out.
852 lines
29 KiB
JavaScript
852 lines
29 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", "agenthub-lanes");
|
|
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
|
const STATUS_FILE_CAP = 20;
|
|
|
|
/**
|
|
* 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,
|
|
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
|
|
) {
|
|
// 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";
|
|
// Pathspec behaviour is ours to choose, never the parent's. `literal` makes a
|
|
// path argument name exactly one file: without it `new*.js` or `app/[id]/`
|
|
// is a GLOB, and `git clean -- 'new*.js'` deletes every match.
|
|
delete env.GIT_GLOB_PATHSPECS;
|
|
delete env.GIT_NOGLOB_PATHSPECS;
|
|
delete env.GIT_ICASE_PATHSPECS;
|
|
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
|
|
else delete env.GIT_LITERAL_PATHSPECS;
|
|
// Caller-supplied last: the git credential helper's inputs (git-credentials.js).
|
|
Object.assign(env, extraEnv);
|
|
|
|
try {
|
|
const result = await execFileAsync("git", args, {
|
|
cwd,
|
|
env,
|
|
// A read with its own size cap passes a bigger buffer and treats an
|
|
// overflow (err.code ERR_CHILD_PROCESS_STDIO_MAXBUFFER, partial output in
|
|
// err.git.stdout) as truncated rather than as a failure.
|
|
maxBuffer,
|
|
// 0 = no limit, node's own default. A network command (fetch/pull/push)
|
|
// passes one so an unreachable remote fails instead of pinning a request
|
|
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
|
timeout,
|
|
});
|
|
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, stdout: err.stdout };
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Per-file line deltas against HEAD, keyed by path.
|
|
*
|
|
* One `git diff --numstat HEAD` covers staged AND unstaged work in a single
|
|
* subprocess, which is the whole reason it is done this way: the alternative
|
|
* is one process per file. It says nothing about UNTRACKED files — git has
|
|
* never seen them, so they have no delta to report and their counts stay null
|
|
* rather than being invented by reading the file off disk (a lane mid-build
|
|
* can have an untracked 100 MB artifact).
|
|
*
|
|
* A repo with no commits yet has no HEAD to diff against; that throw is not a
|
|
* fault, it just means every count is unknown.
|
|
*/
|
|
async function numstatByPath(dir) {
|
|
const stats = new Map();
|
|
try {
|
|
const result = await git(dir, ["diff", "--numstat", "HEAD"]);
|
|
for (const line of result.stdout.split("\n")) {
|
|
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
// A binary file reports "-" for both, not a number.
|
|
if (m) {
|
|
stats.set(m[3], {
|
|
added: m[1] === "-" ? null : Number(m[1]),
|
|
deleted: m[2] === "-" ? null : Number(m[2]),
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
/* no HEAD to diff against -> no per-file deltas */
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
|
* Lines starting with ?? are untracked; others are dirty.
|
|
*
|
|
* `files` names WHICH files the counts mean, each with its porcelain status
|
|
* code and its line delta, capped because this rides a polled endpoint and a
|
|
* lane mid-`npm install` can report thousands. The COUNTS stay exact — only
|
|
* the listing is capped.
|
|
*/
|
|
async function statusCounts(dir) {
|
|
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
|
const stats = await numstatByPath(dir);
|
|
let dirty = 0;
|
|
let untracked = 0;
|
|
const files = [];
|
|
|
|
for (const line of result.stdout.split("\n")) {
|
|
if (!line.trim()) continue;
|
|
if (line.startsWith("??")) {
|
|
untracked++;
|
|
} else {
|
|
dirty++;
|
|
}
|
|
if (files.length < STATUS_FILE_CAP) {
|
|
// "XY path", and for a rename "XY old -> new" — the new path is the one
|
|
// that exists now and the one numstat is keyed by. git quotes a path
|
|
// containing anything unusual; the quotes are presentation, not name.
|
|
const raw = line.slice(3).trimEnd();
|
|
const path = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
|
files.push({
|
|
path,
|
|
status: line.slice(0, 2),
|
|
added: null,
|
|
deleted: null,
|
|
...stats.get(path),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get short commit hash
|
|
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
|
const head = headResult.stdout.trim();
|
|
|
|
return { dirty, untracked, head, files };
|
|
}
|
|
|
|
/**
|
|
* The lane card's view of the working copy: only entries inside the lane's own
|
|
* cwd, cwd-relative and read with `-z` — the same scoping the Changes panel
|
|
* uses (`laneStatus`), so a subdirectory lane's card never counts or lists a
|
|
* sibling's files, and a non-ASCII name is not shown C-quoted. `statusCounts`
|
|
* stays whole-repo on purpose: `switch` and the preflight need to know about
|
|
* ANY dirt a branch change would carry across.
|
|
*/
|
|
async function laneFiles(dir) {
|
|
// Lazy: git-diff.js requires this module, so a top-level require would cycle.
|
|
const { laneStatus, parseNumstat } = require("./git-diff");
|
|
const entries = await laneStatus(dir);
|
|
let stats = new Map();
|
|
try {
|
|
// -z --relative: cwd-scoped, cwd-relative, unquoted — keys the entries.
|
|
stats = parseNumstat(
|
|
(await git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"])).stdout
|
|
);
|
|
} catch {
|
|
/* no HEAD to diff against -> no per-file deltas */
|
|
}
|
|
let dirty = 0;
|
|
let untracked = 0;
|
|
const files = [];
|
|
for (const e of entries) {
|
|
if (e.code === "??") untracked++;
|
|
else dirty++;
|
|
if (files.length < STATUS_FILE_CAP) {
|
|
files.push({
|
|
path: e.path,
|
|
status: e.code,
|
|
// Counts only: the card's file record has no `binary` field.
|
|
added: stats.get(e.path)?.added ?? null,
|
|
deleted: stats.get(e.path)?.deleted ?? null,
|
|
});
|
|
}
|
|
}
|
|
return { dirty, untracked, files };
|
|
}
|
|
|
|
/**
|
|
* 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 five 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 { head } = await statusCounts(dir);
|
|
const { dirty, untracked, files } = await laneFiles(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,
|
|
files,
|
|
...(await trackingFacts(dir)),
|
|
...(await mergeFacts(dir)),
|
|
};
|
|
}
|
|
|
|
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
|
|
async function mergeFacts(dir) {
|
|
try {
|
|
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
} catch {
|
|
return { merging: false, conflicts: 0 };
|
|
}
|
|
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
|
|
stdout: "",
|
|
}));
|
|
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
|
|
}
|
|
|
|
/**
|
|
* Where this branch stands against its upstream: the upstream's name and how
|
|
* many commits each side has that the other does not. `unpushedCount` already
|
|
* counted one half of this, but only the destroy guard ever saw it — a lane
|
|
* that is three commits ahead of origin looks identical to a pushed one in the
|
|
* UI, which is the state most worth seeing before a machine is shut down.
|
|
*
|
|
* A branch with no upstream (never pushed) reports `upstream: null` and null
|
|
* counts — not zeros, which would claim "in sync" about a comparison that was
|
|
* never possible. Never throws: these are extra fields on a polled endpoint.
|
|
*/
|
|
async function trackingFacts(dir) {
|
|
let hasRemote = false;
|
|
try {
|
|
hasRemote = !!(await git(dir, ["remote"])).stdout.trim();
|
|
} catch {
|
|
/* unreadable repo — the caller already has the facts that matter */
|
|
}
|
|
try {
|
|
const upstream = (
|
|
await git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
|
|
).stdout.trim();
|
|
const counts = (
|
|
await git(dir, ["rev-list", "--left-right", "--count", `${upstream}...HEAD`])
|
|
).stdout.trim();
|
|
const [behind, ahead] = counts.split(/\s+/).map((n) => Number(n));
|
|
// Anything but the two numbers git documents reports as unknown, not as
|
|
// NaN: the UI treats a falsy count as "nothing to push", and NaN is falsy,
|
|
// so a malformed line would render a diverged branch as in sync.
|
|
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
|
|
return { hasRemote, upstream, ahead: null, behind: null };
|
|
}
|
|
return { hasRemote, upstream, ahead, behind };
|
|
} catch {
|
|
return { hasRemote, upstream: null, ahead: null, behind: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|