Extract shared helpers in list command and add file lock for global registry

Deduplicate status resolution, stale-review cleanup, and prompt
backfill between single-repo and all-repo list paths. Protect
the global registry file with a mkdir-based lock to prevent
concurrent read-modify-write races, and add a max-depth guard
to scanAndRegister.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-19 14:11:05 -07:00
parent 751b4773c9
commit f0b2a55c9c
2 changed files with 142 additions and 131 deletions

View File

@ -14,13 +14,11 @@ const styles = Object.fromEntries(
styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }])
)
interface DisplaySession {
branch: string
prompt?: string
key: string
}
function renderSessions(sessions: DisplaySession[], statuses: Record<string, string>) {
function renderSessions(
sessions: { branch: string; prompt?: string }[],
statuses: Record<string, string>,
keyFn: (s: { branch: string }) => string = s => s.branch,
) {
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
@ -29,7 +27,7 @@ function renderSessions(sessions: DisplaySession[], statuses: Record<string, str
for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.key] ?? "idle"
const status = statuses[keyFn(s)] ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
@ -41,6 +39,58 @@ function renderLegend() {
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`)
}
// ── Shared logic ─────────────────────────────────────────────────────
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
if (vmRunning) {
const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return { key, status: "review", staleReview: false }
staleReview = !active && !!s.in_review
if (active) return { key, status: "active", staleReview: false }
}
const dirty = await git.isDirty(s.worktree)
if (dirty) return { key, status: "dirty", staleReview }
const commits = await git.hasNewCommits(s.worktree)
return { key, status: commits ? "saved" : "idle", staleReview }
}
async function clearStaleReviews(staleReviews: { repoRoot: string; branch: string }[]) {
if (staleReviews.length === 0) return
const byRepo = Map.groupBy(staleReviews, r => r.repoRoot)
for (const [repoRoot, reviews] of byRepo) {
const fresh = await state.load(repoRoot)
for (const { branch } of reviews) {
if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false
}
await state.save(repoRoot, fresh).catch(() => {})
}
}
async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) {
if (!vmRunning) return
const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length === 0) return
try {
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null")
if (result.exitCode === 0 && result.stdout) {
const entries = result.stdout.split("\n").filter(Boolean).map(line => {
try { return JSON.parse(line) } catch { return null }
}).filter(Boolean)
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
}
}
} catch {}
}
// ── Commands ─────────────────────────────────────────────────────────
export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) {
@ -51,63 +101,31 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
const vmRunning = (await vm.status()) === "running"
// Discover prompts from Claude history for sessions that lack one
const needsPrompt = sessions.filter(s => !s.prompt)
if (needsPrompt.length > 0 && (await vm.status()) === "running") {
try {
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null")
if (result.exitCode === 0 && result.stdout) {
const entries = result.stdout.split("\n").filter(Boolean).map(line => {
try { return JSON.parse(line) } catch { return null }
}).filter(Boolean)
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
}
}
}
} catch {}
}
await backfillPrompts(sessions, vmRunning)
if (sessions.length === 0 && !opts.json) {
console.log("◆ No active sessions.")
if ((await vm.status()) !== "running") {
if (!vmRunning) {
console.log(`\n${red}VM is not running.${reset}`)
}
return
}
// Determine status for each session in parallel
const staleReviewBranches: string[] = []
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [s.branch, "review"]
if (!active && s.in_review) {
staleReviewBranches.push(s.branch)
s.in_review = false
}
if (active) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
return [s.branch, commits ? "saved" : "idle"]
})
const results = await Promise.all(
sessions.map(s => resolveStatus(s, s.branch, vmRunning))
)
const statuses = Object.fromEntries(statusEntries)
const statuses = Object.fromEntries(results.map(r => [r.key, r.status]))
// Clear stale in_review flags in a single load/save cycle
if (staleReviewBranches.length > 0) {
const fresh = await state.load(root)
for (const branch of staleReviewBranches) {
if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false
const staleReviews: { repoRoot: string; branch: string }[] = []
for (let i = 0; i < results.length; i++) {
if (results[i].staleReview) {
sessions[i].in_review = false
staleReviews.push({ repoRoot: root, branch: sessions[i].branch })
}
await state.save(root, fresh).catch(() => {})
}
await clearStaleReviews(staleReviews)
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
@ -115,11 +133,10 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
return
}
const displaySessions = sessions.map(s => ({ ...s, key: s.branch }))
renderSessions(displaySessions, statuses)
renderSessions(sessions, statuses)
renderLegend()
if ((await vm.status()) !== "running") {
if (!vmRunning) {
console.log(`\n${red}VM is not running.${reset}`)
}
}
@ -158,58 +175,29 @@ async function actionAll(opts: { json?: boolean }) {
return
}
// Determine status for each session in parallel, keyed by repo+branch
const vmRunning = (await vm.status()) === "running"
const staleReviews: { repoRoot: string; branch: string }[] = []
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
const key = `${s.repo}/${s.branch}`
if (!vmRunning) return [key, "idle"]
const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [key, "review"]
if (!active && s.in_review) {
staleReviews.push({ repoRoot: s.repoRoot, branch: s.branch })
}
if (active) return [key, "active"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [key, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
return [key, commits ? "saved" : "idle"]
})
await backfillPrompts(sessions, vmRunning)
const results = await Promise.all(
sessions.map(s => resolveStatus(s, `${s.repo}/${s.branch}`, vmRunning))
)
const statuses = Object.fromEntries(statusEntries)
const statuses = Object.fromEntries(results.map(r => [r.key, r.status]))
// Clear stale in_review flags grouped by repo
if (staleReviews.length > 0) {
const byRepo = new Map<string, string[]>()
for (const { repoRoot, branch } of staleReviews) {
const list = byRepo.get(repoRoot) ?? []
list.push(branch)
byRepo.set(repoRoot, list)
}
for (const [repoRoot, branches] of byRepo) {
try {
const fresh = await state.load(repoRoot)
for (const branch of branches) {
if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false
}
await state.save(repoRoot, fresh)
} catch {}
const staleReviews: { repoRoot: string; branch: string }[] = []
for (let i = 0; i < results.length; i++) {
if (results[i].staleReview) {
sessions[i].in_review = false
staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch })
}
}
await clearStaleReviews(staleReviews)
// Group by repo
const byRepo = new Map<string, typeof sessions>()
for (const s of sessions) {
const list = byRepo.get(s.repo) ?? []
list.push(s)
byRepo.set(s.repo, list)
}
const byRepo = Map.groupBy(sessions, s => s.repo)
for (const [repo, repoSessions] of byRepo) {
console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
const displaySessions = repoSessions.map(s => ({ ...s, key: `${s.repo}/${s.branch}` }))
renderSessions(displaySessions, statuses)
renderSessions(repoSessions, statuses, s => `${repo}/${s.branch}`)
}
renderLegend()

View File

@ -1,5 +1,5 @@
import { join, basename, resolve } from "path"
import { readdir, rename, mkdir } from "fs/promises"
import { readdir, rename, mkdir, rmdir } from "fs/promises"
import { homedir } from "os"
export interface Session {
@ -61,17 +61,35 @@ interface GlobalState {
}
const GLOBAL_DIR = join(homedir(), ".sandlot")
const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
function globalStatePath(): string {
return join(GLOBAL_DIR, "registry.json")
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
const lockPath = join(GLOBAL_DIR, "registry.lock")
await mkdir(GLOBAL_DIR, { recursive: true })
for (let i = 0; i < 20; i++) {
try {
await mkdir(lockPath)
break
} catch {
if (i === 19) throw new Error("Could not acquire registry lock")
await Bun.sleep(50)
}
}
try {
return await fn()
} finally {
await rmdir(lockPath).catch(() => {})
}
}
async function loadGlobal(): Promise<GlobalState> {
const file = Bun.file(globalStatePath())
const file = Bun.file(GLOBAL_STATE_PATH)
if (await file.exists()) {
try {
const data = await file.json()
if (data && Array.isArray(data.projects)) return data
if (data && Array.isArray(data.projects)) {
return { projects: data.projects.filter((p: unknown) => typeof p === "string") }
}
} catch {}
}
return { projects: [] }
@ -79,10 +97,9 @@ async function loadGlobal(): Promise<GlobalState> {
async function saveGlobal(gs: GlobalState): Promise<void> {
await mkdir(GLOBAL_DIR, { recursive: true })
const path = globalStatePath()
const tmpPath = path + ".tmp"
const tmpPath = GLOBAL_STATE_PATH + ".tmp"
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
await rename(tmpPath, path)
await rename(tmpPath, GLOBAL_STATE_PATH)
}
function normalizePath(dir: string): string {
@ -92,30 +109,35 @@ function normalizePath(dir: string): string {
/** Register a project directory in the global state. */
export async function registerProject(repoRoot: string): Promise<void> {
const normalized = normalizePath(repoRoot)
await withGlobalLock(async () => {
const gs = await loadGlobal()
if (!gs.projects.includes(normalized)) {
gs.projects.push(normalized)
await saveGlobal(gs)
}
})
}
/** Remove a project directory from the global state. */
export async function unregisterProject(dir: string): Promise<boolean> {
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): Promise<string[]> {
export async function scanAndRegister(dir: string, maxDepth = 5): Promise<string[]> {
const root = normalizePath(dir)
const found: string[] = []
async function walk(d: string) {
async function walk(d: string, depth: number) {
if (depth > maxDepth) return
let entries
try {
entries = await readdir(d, { withFileTypes: true })
@ -127,18 +149,18 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
if (hasSandlot) {
const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
if (await stateFile.exists()) {
found.push(normalizePath(d))
found.push(d)
}
}
const children = entries.filter(e => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
await Promise.all(children.map(entry => walk(join(d, entry.name))))
await Promise.all(children.map(entry => walk(join(d, entry.name), depth + 1)))
}
await walk(root)
await walk(root, 0)
// Bulk register all found projects in a single load/save cycle
if (found.length > 0) {
await withGlobalLock(async () => {
const gs = await loadGlobal()
let changed = false
for (const p of found) {
@ -148,6 +170,7 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
}
}
if (changed) await saveGlobal(gs)
})
}
return found