diff --git a/src/cli.ts b/src/cli.ts index c5f081a..723323e 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { Command, Option } from "commander" import { yellow, reset } from "./fmt.ts" import * as git from "./git.ts" import * as state from "./state.ts" +import { action as configAction } from "./commands/config.ts" import { action as newAction } from "./commands/new.ts" import { action as listAction } from "./commands/list.ts" import { action as openAction } from "./commands/open.ts" @@ -53,8 +54,6 @@ program .description("Show all active sessions") .option("--json", "Output as JSON") .option("-a, --all", "Show sessions across all projects") - .option("--add ", "Scan a directory for sandlot projects and register them") - .option("-r, --remove ", "Remove a project from the registry") .action(listAction) program diff --git a/src/commands/list.ts b/src/commands/list.ts index be08e25..708ab97 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -4,9 +4,9 @@ import { stat } from "fs/promises" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" -import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" +import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" -// ── Shared rendering ───────────────────────────────────────────────── +// ── Rendering ──────────────────────────────────────────────────────── const styles: Record = { idle: { icon: `${dim}◯${reset}`, color: dim }, @@ -31,16 +31,12 @@ function renderSessions( const status = statusMap.get(s) ?? "idle" const { icon, color: bc } = styles[status] const maxPrompt = cols - prefixWidth - const truncated = maxPrompt < 1 ? "" : prompt.length <= maxPrompt ? prompt : maxPrompt > 3 ? prompt.slice(0, maxPrompt - 3) + "..." : prompt.slice(0, maxPrompt) + const truncated = prompt.length <= maxPrompt ? prompt : prompt.slice(0, maxPrompt - 3) + "..." console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) } } -function renderLegend() { - console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`) -} - -// ── Shared logic ───────────────────────────────────────────────────── +// ── Status resolution ──────────────────────────────────────────────── async function resolveStatus( s: { branch: string; worktree: string; in_review?: boolean }, @@ -101,13 +97,9 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[] } } -// ── Commands ───────────────────────────────────────────────────────── +// ── Command ────────────────────────────────────────────────────────── -export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) { - if (opts.add) return actionAdd(opts.add) - if (opts.remove) return actionRemove(opts.remove) - - // Load sessions with repoRoot attached +export async function action(opts: { json?: boolean; all?: boolean }) { let sessions: state.GlobalSession[] if (opts.all) { sessions = await state.loadAll() @@ -128,7 +120,7 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning))) const statusMap = new Map(sessions.map((s, i) => [s, results[i]])) - if (vmRunning) await clearStaleReviews(sessions, results) + await clearStaleReviews(sessions, results) if (opts.json) { const withStatus = sessions.map(s => ({ ...s, status: statusMap.get(s) ?? "idle" })) @@ -146,38 +138,6 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string renderSessions(sessions, statusMap) } - renderLegend() + console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`) if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`) } - -async function actionAdd(dir: string) { - let registered: string[] - try { - registered = await state.scanAndRegister(dir) - } catch (e) { - return die(e instanceof Error ? e.message : `Failed to scan ${dir}`) - } - if (registered.length === 0) { - console.log(`No sandlot projects found under ${dir}`) - } else { - for (const p of registered) { - console.log(`${green}+${reset} ${p}`) - } - console.log(`\nRegistered ${registered.length} project${registered.length === 1 ? "" : "s"}.`) - } -} - -async function actionRemove(dir: string) { - const resolved = state.normalizePath(dir) - let removed = false - try { - removed = await state.unregisterProject(dir) - } catch { - return die("Could not acquire registry lock") - } - if (removed) { - console.log(`${red}-${reset} ${resolved}`) - } else { - die(`Project not found in registry: ${resolved}`) - } -} diff --git a/src/state.ts b/src/state.ts index 0e3944e..452cd92 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,5 @@ -import { join, basename, resolve } from "path" -import { readdir, rename, mkdir, rmdir, stat } from "fs/promises" +import { join, dirname } from "path" +import { readdir, rename } from "fs/promises" import { homedir } from "os" export interface Session { @@ -45,7 +45,6 @@ export async function setSession(repoRoot: string, session: Session): Promise {}) } export async function removeSession(repoRoot: string, branch: string): Promise { @@ -54,171 +53,51 @@ export async function removeSession(repoRoot: string, branch: string): Promise(fn: () => Promise): Promise { - const lockPath = join(GLOBAL_DIR, "registry.lock") - await mkdir(GLOBAL_DIR, { recursive: true }) - for (let i = 0; i < 20; i++) { - try { - await mkdir(lockPath) - } catch { - // If the lock is older than 5 minutes, assume it's stale (crashed process) - try { - const info = await stat(lockPath) - if (Date.now() - info.mtimeMs > 300_000) { - await rmdir(lockPath).catch(() => {}) - // Retry mkdir immediately to close the TOCTOU window - try { await mkdir(lockPath) } catch { continue } - } else { - if (i === 19) throw new Error("Could not acquire registry lock") - await Bun.sleep(50) - continue - } - } catch { - // Lock dir vanished between our mkdir and stat — retry immediately - continue - } - } - try { - return await fn() - } finally { - await rmdir(lockPath).catch(() => {}) - } - } - throw new Error("Could not acquire registry lock") -} - -async function loadGlobal(): Promise { - const file = Bun.file(GLOBAL_STATE_PATH) - if (await file.exists()) { - try { - const data = await file.json() - if (data && Array.isArray(data.projects)) return { projects: data.projects.filter((p: unknown) => typeof p === "string") } - } catch {} - } - return { projects: [] } -} - -async function saveGlobal(gs: GlobalState): Promise { - const tmpPath = GLOBAL_STATE_PATH + ".tmp" - await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") - await rename(tmpPath, GLOBAL_STATE_PATH) -} - -export function normalizePath(dir: string): string { - return resolve(dir.replace(/^~(?=\/|$)/, homedir())) -} - -async function registerPaths(paths: string[]): Promise { - if (paths.length === 0) return - await withGlobalLock(async () => { - const gs = await loadGlobal() - const existing = new Set(gs.projects) - let changed = false - for (const p of paths) { - if (!existing.has(p)) { - gs.projects.push(p) - changed = true - } - } - if (changed) await saveGlobal(gs) - }) -} - -/** Register a project directory in the global state. */ -export async function registerProject(repoRoot: string): Promise { - await registerPaths([normalizePath(repoRoot)]) -} - -/** Remove a project directory from the global state. */ -export async function unregisterProject(dir: string): Promise { - const target = normalizePath(dir) - return withGlobalLock(async () => { - const gs = await loadGlobal() - const idx = gs.projects.indexOf(target) - if (idx === -1) return false - gs.projects.splice(idx, 1) - await saveGlobal(gs) - return true - }) -} - -/** Recursively scan a directory for .sandlot dirs and register their parent projects. */ -export async function scanAndRegister(dir: string, maxDepth = 5): Promise { - const root = normalizePath(dir) - const found: string[] = [] - - async function walk(d: string, depth: number) { - if (depth >= maxDepth) return - let entries - try { - entries = await readdir(d, { withFileTypes: true }) - } catch { - return - } - - const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot") - if (hasSandlot) { - const stateFile = Bun.file(join(d, ".sandlot", "state.json")) - if (await stateFile.exists()) { - found.push(d) - } - } - - const children = entries.filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith(".") && e.name !== "node_modules") - for (const entry of children) await walk(join(d, entry.name), depth + 1) - } - - await walk(root, 0) - await registerPaths(found) - return found -} - export interface GlobalSession extends Session { repo: string repoRoot: string } -/** Load all sessions across all registered projects. Prunes stale entries. */ +/** Discover all sessions across all repos by scanning ~/.sandlot/ */ export async function loadAll(): Promise { - const gs = await loadGlobal() + const sandlotDir = join(homedir(), ".sandlot") const all: GlobalSession[] = [] - const stale: string[] = [] - await Promise.all(gs.projects.map(async (project) => { - let st: State - try { - st = await load(project) - } catch { - // Corrupt state.json — skip but don't prune (self-heals on next session activity) - return - } - if (Object.keys(st.sessions).length === 0) { - try { await stat(join(project, ".sandlot")) } catch { stale.push(project) } - return - } - const repo = basename(project) - for (const s of Object.values(st.sessions)) { - all.push({ ...s, repo, repoRoot: project }) - } - })) + let repoDirs + try { + repoDirs = await readdir(sandlotDir, { withFileTypes: true }) + } catch { + return [] + } - // Prune projects whose state files no longer exist - if (stale.length > 0) { - const staleSet = new Set(stale) - await withGlobalLock(async () => { - const fresh = await loadGlobal() - fresh.projects = fresh.projects.filter(p => !staleSet.has(p)) - await saveGlobal(fresh) - }).catch(() => {}) + for (const entry of repoDirs) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue + const repoDir = join(sandlotDir, entry.name) + + // Find the main repo root from a worktree's .git pointer + let repoRoot: string | null = null + const branchEntries = await readdir(repoDir, { withFileTypes: true }).catch(() => []) + + for (const be of branchEntries) { + if (!be.isDirectory() || be.name.startsWith(".")) continue + const dotGit = await Bun.file(join(repoDir, be.name, ".git")).text().catch(() => "") + const m = dotGit.match(/^gitdir:\s*(.+)/m) + if (m) { + // gitdir: /path/to/repo/.git/worktrees/ + const mainGit = m[1].trim().replace(/\/worktrees\/[^/]+$/, "") + repoRoot = dirname(mainGit) + break + } + } + + if (repoRoot) { + try { + const st = await load(repoRoot) + for (const session of Object.values(st.sessions)) { + all.push({ ...session, repo: entry.name, repoRoot }) + } + } catch {} + } } return all