diff --git a/src/commands/list.ts b/src/commands/list.ts index 71dd28a..d19a6b1 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,4 +1,4 @@ -import { basename, resolve } from "path" +import { basename } from "path" import { homedir } from "os" import { stat } from "fs/promises" import * as git from "../git.ts" @@ -8,13 +8,13 @@ import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" // ── Shared rendering ───────────────────────────────────────────────── -const styleDefs: [string, string, string][] = [ - ["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], - ["saved", green, "●"], ["review", magenta, "⦿"], -] -const styles = Object.fromEntries( - styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) -) +const styles: Record = { + idle: { icon: `${dim}◯${reset}`, color: dim }, + active: { icon: `${cyan}◎${reset}`, color: cyan }, + dirty: { icon: `${yellow}◐${reset}`, color: yellow }, + saved: { icon: `${green}●${reset}`, color: green }, + review: { icon: `${magenta}⦿${reset}`, color: magenta }, +} function renderSessions( sessions: state.GlobalSession[], @@ -69,7 +69,6 @@ async function clearStaleReviews( ) { const stale = sessions.filter((s, i) => s.in_review && results[i] !== "review") if (stale.length === 0) return - for (const s of stale) s.in_review = false const byRepo = Map.groupBy(stale, s => s.repoRoot) for (const [repoRoot, staleSessions] of byRepo) { const fresh = await state.load(repoRoot) @@ -169,7 +168,7 @@ async function actionAdd(dir: string) { } async function actionRemove(dir: string) { - const resolved = resolve(dir.replace(/^~(?=\/|$)/, homedir())) + const resolved = state.normalizePath(dir) let removed = false try { removed = await state.unregisterProject(dir) diff --git a/src/state.ts b/src/state.ts index 0cc6761..bd174a5 100644 --- a/src/state.ts +++ b/src/state.ts @@ -66,32 +66,34 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json") async function withGlobalLock(fn: () => Promise): Promise { const lockPath = join(GLOBAL_DIR, "registry.lock") await mkdir(GLOBAL_DIR, { recursive: true }) - let acquired = false for (let i = 0; i < 20; i++) { try { await mkdir(lockPath) - acquired = true - break } 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 {} - if (i === 19) throw new Error("Could not acquire registry lock") - await Bun.sleep(50) + } catch { + // Lock dir vanished between our mkdir and stat — retry immediately + continue + } + } + try { + return await fn() + } finally { + await rmdir(lockPath).catch(() => {}) } } - if (!acquired) throw new Error("Could not acquire registry lock") - try { - return await fn() - } finally { - await rmdir(lockPath).catch(() => {}) - } + throw new Error("Could not acquire registry lock") } async function loadGlobal(): Promise { @@ -99,9 +101,7 @@ async function loadGlobal(): Promise { if (await file.exists()) { try { const data = await file.json() - if (data && Array.isArray(data.projects)) { - return { projects: data.projects as string[] } - } + if (data && Array.isArray(data.projects)) return data } catch {} } return { projects: [] } @@ -113,7 +113,7 @@ async function saveGlobal(gs: GlobalState): Promise { await rename(tmpPath, GLOBAL_STATE_PATH) } -function normalizePath(dir: string): string { +export function normalizePath(dir: string): string { return resolve(dir.replace(/^~(?=\/|$)/, homedir())) } @@ -148,7 +148,7 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise maxDepth) return + if (depth >= maxDepth) return let entries try { entries = await readdir(d, { withFileTypes: true }) @@ -173,9 +173,10 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise 0) { await withGlobalLock(async () => { const gs = await loadGlobal() + const existing = new Set(gs.projects) let changed = false for (const p of found) { - if (!gs.projects.includes(p)) { + if (!existing.has(p)) { gs.projects.push(p) changed = true }