From 3f8a3839f12e0a79f285a1c0480b12d0951fe9f8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 19 Mar 2026 14:32:31 -0700 Subject: [PATCH] Unify list and list --all into a single code path The separate actionAll function duplicated most of the listing logic. Merging it simplifies status resolution by removing the key/repoRoot indirection, fixes stale-review detection inline, and batches scanAndRegister writes into a single lock acquisition. Also bumps the stale lock timeout to 5 minutes and fixes normalizePath matching bare ~. Co-Authored-By: Claude Opus 4.6 --- src/commands/list.ts | 131 +++++++++++++++++++------------------------ src/state.ts | 19 +++++-- 2 files changed, 74 insertions(+), 76 deletions(-) diff --git a/src/commands/list.ts b/src/commands/list.ts index 1ed9a26..d0bb12f 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,3 +1,4 @@ +import { basename } from "path" import { homedir } from "os" import * as git from "../git.ts" import * as vm from "../vm.ts" @@ -17,7 +18,7 @@ const styles = Object.fromEntries( function renderSessions( sessions: { branch: string; prompt?: string }[], statuses: Record, - keyFn: (s: { branch: string }) => string = s => s.branch, + keyFn: (s: { branch: string }) => string, ) { const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) const cols = process.stdout.columns || 80 @@ -43,20 +44,17 @@ function renderLegend() { async function resolveStatus( s: { branch: string; worktree: string; in_review?: boolean }, - key: string, vmRunning: boolean, -): Promise<{ key: string; status: string; staleReview: boolean }> { - let staleReview = false +): Promise { if (vmRunning) { const active = await vm.isClaudeActive(s.worktree, s.branch) - if (active && s.in_review) return { key, status: "review", staleReview: false } - if (active) return { key, status: "active", staleReview: false } + if (active && s.in_review) return "review" + if (active) return "active" } - staleReview = !!s.in_review const dirty = await git.isDirty(s.worktree) - if (dirty) return { key, status: "dirty", staleReview } + if (dirty) return "dirty" const commits = await git.hasNewCommits(s.worktree) - return { key, status: commits ? "saved" : "idle", staleReview } + return commits ? "saved" : "idle" } async function clearStaleReviews(staleReviews: { repoRoot: string; branch: string }[]) { @@ -71,19 +69,20 @@ async function clearStaleReviews(staleReviews: { repoRoot: string; branch: strin } } -async function resolveAllStatuses( +async function resolveAllStatuses( sessions: T[], vmRunning: boolean, keyFn: (s: T) => string, - repoRootFn: (s: T) => string, ): Promise> { - const results = await Promise.all(sessions.map(s => resolveStatus(s, keyFn(s), vmRunning))) - const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) + const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning))) + const statuses = Object.fromEntries(sessions.map((s, i) => [keyFn(s), results[i]])) + + // Clear stale reviews: in_review is set but Claude isn't actually active const staleReviews: { repoRoot: string; branch: string }[] = [] - for (let i = 0; i < results.length; i++) { - if (results[i].staleReview) { + for (let i = 0; i < sessions.length; i++) { + if (sessions[i].in_review && results[i] !== "review") { sessions[i].in_review = false - staleReviews.push({ repoRoot: repoRootFn(sessions[i]), branch: sessions[i].branch }) + staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch }) } } await clearStaleReviews(staleReviews) @@ -101,10 +100,15 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[] try { return JSON.parse(line) } catch { return null } }).filter(Boolean) + // Later entries overwrite earlier ones so the most recent prompt wins + const byProject = new Map() + for (const e of entries) { + if (e.project && e.display) byProject.set(e.project, e.display) + } + for (const s of needsPrompt) { - const cPath = vm.containerPath(s.worktree) - const match = entries.find((e: any) => e.project === cPath) - if (match?.display) s.prompt = match.display + const display = byProject.get(vm.containerPath(s.worktree)) + if (display) s.prompt = display } } } catch {} @@ -112,40 +116,59 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[] // ── Commands ───────────────────────────────────────────────────────── +interface ListSession extends state.Session { + repoRoot: string + repo?: string +} + 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) - if (opts.all) return actionAll(opts) - const root = await git.repoRoot() - const st = await state.load(root) - const sessions = Object.values(st.sessions) - const vmRunning = (await vm.status()) === "running" - - await backfillPrompts(sessions, vmRunning) + // Load sessions with repoRoot attached + let sessions: ListSession[] + if (opts.all) { + sessions = await state.loadAll() + } else { + const root = await git.repoRoot() + const st = await state.load(root) + sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root })) + } if (sessions.length === 0 && !opts.json) { - console.log("◆ No active sessions.") - if (!vmRunning) { - console.log(`\n${red}VM is not running.${reset}`) + console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.") + if (!opts.all) { + const vmRunning = (await vm.status()) === "running" + if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`) } return } - const statuses = await resolveAllStatuses(sessions, vmRunning, s => s.branch, () => root) + const vmRunning = (await vm.status()) === "running" + await backfillPrompts(sessions, vmRunning) + + // Key by repoRoot/branch to avoid collisions across repos with the same basename + const keyFn = (s: ListSession) => `${s.repoRoot}/${s.branch}` + const statuses = await resolveAllStatuses(sessions, vmRunning, keyFn) if (opts.json) { - const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) + const withStatus = sessions.map(s => ({ ...s, status: statuses[keyFn(s)] })) console.log(JSON.stringify(withStatus, null, 2)) return } - renderSessions(sessions, statuses) - renderLegend() - - if (!vmRunning) { - console.log(`\n${red}VM is not running.${reset}`) + if (opts.all) { + const byRepo = Map.groupBy(sessions as state.GlobalSession[], s => s.repoRoot) + for (const [repoRoot, repoSessions] of byRepo) { + console.log(`\n${dim}── ${reset}${basename(repoRoot)}${dim} ──${reset}`) + renderSessions(repoSessions, statuses, keyFn) + } + } else { + renderSessions(sessions, statuses, keyFn) } + + renderLegend() + if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`) } async function actionAdd(dir: string) { @@ -168,39 +191,3 @@ async function actionRemove(dir: string) { die(`Project not found in registry: ${dir}`) } } - -async function actionAll(opts: { json?: boolean }) { - const sessions = await state.loadAll() - - if (sessions.length === 0 && !opts.json) { - console.log("◆ No active sessions across any project.") - return - } - - const vmRunning = (await vm.status()) === "running" - - await backfillPrompts(sessions, vmRunning) - - const statuses = await resolveAllStatuses( - sessions, vmRunning, s => `${s.repo}/${s.branch}`, s => s.repoRoot, - ) - - if (opts.json) { - const withStatus = sessions.map(s => ({ ...s, status: statuses[`${s.repo}/${s.branch}`] })) - console.log(JSON.stringify(withStatus, null, 2)) - return - } - - const byRepo = Map.groupBy(sessions, s => s.repo) - - for (const [repo, repoSessions] of byRepo) { - console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`) - renderSessions(repoSessions, statuses, s => `${repo}/${s.branch}`) - } - - renderLegend() - - if (!vmRunning) { - console.log(`\n${red}VM is not running.${reset}`) - } -} diff --git a/src/state.ts b/src/state.ts index faf5fb1..15f051b 100644 --- a/src/state.ts +++ b/src/state.ts @@ -74,7 +74,7 @@ async function withGlobalLock(fn: () => Promise): Promise { // If the lock is older than 30 seconds, assume it's stale (crashed process) try { const info = await stat(lockPath) - if (Date.now() - info.mtimeMs > 30_000) { + if (Date.now() - info.mtimeMs > 300_000) { await rmdir(lockPath).catch(() => {}) continue } @@ -111,7 +111,7 @@ async function saveGlobal(gs: GlobalState): Promise { } function normalizePath(dir: string): string { - return resolve(dir.replace(/^~/, homedir())) + return resolve(dir.replace(/^~(?=\/|$)/, homedir())) } /** Register a project directory in the global state. */ @@ -167,8 +167,19 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise 0) { + await withGlobalLock(async () => { + const gs = await loadGlobal() + let changed = false + for (const p of found) { + const normalized = normalizePath(p) + if (!gs.projects.includes(normalized)) { + gs.projects.push(normalized) + changed = true + } + } + if (changed) await saveGlobal(gs) + }) } return found