/** * @file Projects: lanes grouped by the git repository they belong to, for the * sidebar's project → workspace tree. A project is a repo's MAIN worktree root * (the directory holding the common `.git`); a lane outside any repo lands in * the `root: null` pseudo-project ("Folders"). Each project also lists the * repo's worktrees from `git worktree list`, so a worktree no lane covers can * be shown and adopted. * * Both reads shell out to git, so both are cached in memory: a cwd's root for * the life of the process (a directory does not move between repos), a repo's * worktree list for 30 s. `clearProjectCaches` (POST /api/projects/refresh) * drops both. Which lane owns a worktree is recomputed on every call from the * lane rows, so creating or removing a lane needs no invalidation. None of * this runs on the polled `GET /api/lanes`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { git, listWorktrees, LANES_ROOT } = require("./worktree"); const WORKTREE_TTL_MS = 30_000; const rootCache = new Map(); const worktreeCache = new Map(); function real(p) { try { return fs.realpathSync(p); } catch { return p; } } function isUnder(p, dir) { const rel = path.relative(dir, p); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); } /** * The repository a directory belongs to: its main worktree's top level * (realpath), or null when the directory is not inside a git repo. * @param {string} cwd * @returns {Promise} */ async function projectRootFor(cwd) { if (rootCache.has(cwd)) return rootCache.get(cwd); let root = null; try { const { stdout } = await git(cwd, ["rev-parse", "--git-common-dir"]); // Relative (".git") from the main worktree itself, absolute from a linked one. const common = path.resolve(cwd, stdout.trim()); root = real(path.basename(common) === ".git" ? path.dirname(common) : common); } catch { root = null; } // A lane still being provisioned has no directory yet: ask again next time. if (root || fs.existsSync(cwd)) rootCache.set(cwd, root); return root; } async function worktreesFor(root) { const hit = worktreeCache.get(root); if (hit && Date.now() - hit.at < WORKTREE_TTL_MS) return hit.list; let list = []; try { list = await listWorktrees(root); } catch { list = []; } worktreeCache.set(root, { at: Date.now(), list }); return list; } function clearProjectCaches() { rootCache.clear(); worktreeCache.clear(); } /** * @param {{id:number, cwd:string}[]} lanes - lane rows, in id order * @returns {Promise} projects by name, the Folders pseudo-project last */ async function listProjects(lanes) { const roots = await Promise.all(lanes.map((l) => projectRootFor(l.cwd))); const byRoot = new Map(); lanes.forEach((lane, i) => { const r = roots[i]; if (!byRoot.has(r)) byRoot.set(r, []); byRoot.get(r).push(lane); }); const lanesRoot = real(LANES_ROOT); const projects = await Promise.all( [...byRoot] .filter(([root]) => root !== null) .map(async ([root, own]) => { const claudeDir = path.join(root, ".claude", "worktrees"); const worktrees = (await worktreesFor(root)) .map((w, i) => ({ ...w, path: real(w.path), main: i === 0 })) // A worktree whose directory is gone (`prunable`) cannot be adopted. .filter((w) => fs.existsSync(w.path)) .map((w) => ({ path: w.path, branch: w.branch || null, main: w.main, workspace_id: own.find((l) => l.cwd === w.path)?.id ?? null, ...(isUnder(w.path, claudeDir) ? { source: "claude" } : isUnder(w.path, lanesRoot) ? { source: "agenthub" } : {}), })); return { root, name: path.basename(root).replace(/\.git$/, "") || root, workspaces: own.map((l) => l.id), worktrees, }; }) ); projects.sort((a, b) => a.name.localeCompare(b.name)); const folders = byRoot.get(null); if (folders) { projects.push({ root: null, name: "Folders", workspaces: folders.map((l) => l.id), worktrees: [], }); } return projects; } module.exports = { projectRootFor, listProjects, clearProjectCaches };