feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
listBranches,
|
||||
resolveBase,
|
||||
slugify,
|
||||
listWorktrees,
|
||||
branchCheckedOutAt,
|
||||
addWorktree,
|
||||
assertManaged,
|
||||
isInsideLanesRoot,
|
||||
assertDestroyable,
|
||||
resetWorktree,
|
||||
removeWorktree,
|
||||
statusCounts,
|
||||
gitFacts,
|
||||
unpushedCount,
|
||||
hasNoRemotes,
|
||||
};
|
||||
Reference in New Issue
Block a user