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 <noreply@anthropic.com>
This commit is contained in:
parent
e2a76a6ad9
commit
3f8a3839f1
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { basename } from "path"
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
import * as git from "../git.ts"
|
import * as git from "../git.ts"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
|
|
@ -17,7 +18,7 @@ const styles = Object.fromEntries(
|
||||||
function renderSessions(
|
function renderSessions(
|
||||||
sessions: { branch: string; prompt?: string }[],
|
sessions: { branch: string; prompt?: string }[],
|
||||||
statuses: Record<string, string>,
|
statuses: Record<string, string>,
|
||||||
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 branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
|
||||||
const cols = process.stdout.columns || 80
|
const cols = process.stdout.columns || 80
|
||||||
|
|
@ -43,20 +44,17 @@ function renderLegend() {
|
||||||
|
|
||||||
async function resolveStatus(
|
async function resolveStatus(
|
||||||
s: { branch: string; worktree: string; in_review?: boolean },
|
s: { branch: string; worktree: string; in_review?: boolean },
|
||||||
key: string,
|
|
||||||
vmRunning: boolean,
|
vmRunning: boolean,
|
||||||
): Promise<{ key: string; status: string; staleReview: boolean }> {
|
): Promise<string> {
|
||||||
let staleReview = false
|
|
||||||
if (vmRunning) {
|
if (vmRunning) {
|
||||||
const active = await vm.isClaudeActive(s.worktree, s.branch)
|
const active = await vm.isClaudeActive(s.worktree, s.branch)
|
||||||
if (active && s.in_review) return { key, status: "review", staleReview: false }
|
if (active && s.in_review) return "review"
|
||||||
if (active) return { key, status: "active", staleReview: false }
|
if (active) return "active"
|
||||||
}
|
}
|
||||||
staleReview = !!s.in_review
|
|
||||||
const dirty = await git.isDirty(s.worktree)
|
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)
|
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 }[]) {
|
async function clearStaleReviews(staleReviews: { repoRoot: string; branch: string }[]) {
|
||||||
|
|
@ -71,19 +69,20 @@ async function clearStaleReviews(staleReviews: { repoRoot: string; branch: strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveAllStatuses<T extends { branch: string; worktree: string; in_review?: boolean }>(
|
async function resolveAllStatuses<T extends { branch: string; worktree: string; in_review?: boolean; repoRoot: string }>(
|
||||||
sessions: T[],
|
sessions: T[],
|
||||||
vmRunning: boolean,
|
vmRunning: boolean,
|
||||||
keyFn: (s: T) => string,
|
keyFn: (s: T) => string,
|
||||||
repoRootFn: (s: T) => string,
|
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
const results = await Promise.all(sessions.map(s => resolveStatus(s, keyFn(s), vmRunning)))
|
const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning)))
|
||||||
const statuses = Object.fromEntries(results.map(r => [r.key, r.status]))
|
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 }[] = []
|
const staleReviews: { repoRoot: string; branch: string }[] = []
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < sessions.length; i++) {
|
||||||
if (results[i].staleReview) {
|
if (sessions[i].in_review && results[i] !== "review") {
|
||||||
sessions[i].in_review = false
|
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)
|
await clearStaleReviews(staleReviews)
|
||||||
|
|
@ -101,10 +100,15 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[]
|
||||||
try { return JSON.parse(line) } catch { return null }
|
try { return JSON.parse(line) } catch { return null }
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
|
|
||||||
|
// Later entries overwrite earlier ones so the most recent prompt wins
|
||||||
|
const byProject = new Map<string, string>()
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.project && e.display) byProject.set(e.project, e.display)
|
||||||
|
}
|
||||||
|
|
||||||
for (const s of needsPrompt) {
|
for (const s of needsPrompt) {
|
||||||
const cPath = vm.containerPath(s.worktree)
|
const display = byProject.get(vm.containerPath(s.worktree))
|
||||||
const match = entries.find((e: any) => e.project === cPath)
|
if (display) s.prompt = display
|
||||||
if (match?.display) s.prompt = match.display
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -112,40 +116,59 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[]
|
||||||
|
|
||||||
// ── Commands ─────────────────────────────────────────────────────────
|
// ── Commands ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ListSession extends state.Session {
|
||||||
|
repoRoot: string
|
||||||
|
repo?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) {
|
export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) {
|
||||||
if (opts.add) return actionAdd(opts.add)
|
if (opts.add) return actionAdd(opts.add)
|
||||||
if (opts.remove) return actionRemove(opts.remove)
|
if (opts.remove) return actionRemove(opts.remove)
|
||||||
if (opts.all) return actionAll(opts)
|
|
||||||
|
|
||||||
const root = await git.repoRoot()
|
// Load sessions with repoRoot attached
|
||||||
const st = await state.load(root)
|
let sessions: ListSession[]
|
||||||
const sessions = Object.values(st.sessions)
|
if (opts.all) {
|
||||||
const vmRunning = (await vm.status()) === "running"
|
sessions = await state.loadAll()
|
||||||
|
} else {
|
||||||
await backfillPrompts(sessions, vmRunning)
|
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) {
|
if (sessions.length === 0 && !opts.json) {
|
||||||
console.log("◆ No active sessions.")
|
console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.")
|
||||||
if (!vmRunning) {
|
if (!opts.all) {
|
||||||
console.log(`\n${red}VM is not running.${reset}`)
|
const vmRunning = (await vm.status()) === "running"
|
||||||
|
if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`)
|
||||||
}
|
}
|
||||||
return
|
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) {
|
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))
|
console.log(JSON.stringify(withStatus, null, 2))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSessions(sessions, statuses)
|
if (opts.all) {
|
||||||
renderLegend()
|
const byRepo = Map.groupBy(sessions as state.GlobalSession[], s => s.repoRoot)
|
||||||
|
for (const [repoRoot, repoSessions] of byRepo) {
|
||||||
if (!vmRunning) {
|
console.log(`\n${dim}── ${reset}${basename(repoRoot)}${dim} ──${reset}`)
|
||||||
console.log(`\n${red}VM is not running.${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) {
|
async function actionAdd(dir: string) {
|
||||||
|
|
@ -168,39 +191,3 @@ async function actionRemove(dir: string) {
|
||||||
die(`Project not found in registry: ${dir}`)
|
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
19
src/state.ts
19
src/state.ts
|
|
@ -74,7 +74,7 @@ async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
// If the lock is older than 30 seconds, assume it's stale (crashed process)
|
// If the lock is older than 30 seconds, assume it's stale (crashed process)
|
||||||
try {
|
try {
|
||||||
const info = await stat(lockPath)
|
const info = await stat(lockPath)
|
||||||
if (Date.now() - info.mtimeMs > 30_000) {
|
if (Date.now() - info.mtimeMs > 300_000) {
|
||||||
await rmdir(lockPath).catch(() => {})
|
await rmdir(lockPath).catch(() => {})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +111,7 @@ async function saveGlobal(gs: GlobalState): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePath(dir: string): string {
|
function normalizePath(dir: string): string {
|
||||||
return resolve(dir.replace(/^~/, homedir()))
|
return resolve(dir.replace(/^~(?=\/|$)/, homedir()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register a project directory in the global state. */
|
/** Register a project directory in the global state. */
|
||||||
|
|
@ -167,8 +167,19 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise<string
|
||||||
|
|
||||||
await walk(root, 0)
|
await walk(root, 0)
|
||||||
|
|
||||||
for (const p of found) {
|
if (found.length > 0) {
|
||||||
await registerProject(p)
|
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
|
return found
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user