Fix status collisions in multi-repo list and clear stale review flags

Keying statuses by branch alone caused collisions when multiple repos
shared branch names. Key by repo/branch instead and auto-clear
in_review when Claude is no longer active. Also extract shared
rendering helpers, batch scanAndRegister writes, normalize paths
in the global registry, and move registerProject to setSession.
This commit is contained in:
Chris Wanstrath 2026-03-19 14:01:02 -07:00
parent 5893e07530
commit 751b4773c9
2 changed files with 118 additions and 73 deletions

View File

@ -2,7 +2,46 @@ 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"
import * as state from "../state.ts" import * as state from "../state.ts"
import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" 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 }])
)
interface DisplaySession {
branch: string
prompt?: string
key: string
}
function renderSessions(sessions: DisplaySession[], statuses: Record<string, string>) {
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.key] ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
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}`)
}
// ── Commands ─────────────────────────────────────────────────────────
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)
@ -76,29 +115,9 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
return return
} }
const styleDefs: [string, string, string][] = [ const displaySessions = sessions.map(s => ({ ...s, key: s.branch }))
["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], renderSessions(displaySessions, statuses)
["saved", green, "●"], ["review", magenta, "⊛"], renderLegend()
]
const styles = Object.fromEntries(
styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }])
)
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.branch] ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`)
if ((await vm.status()) !== "running") { if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`) console.log(`\n${red}VM is not running.${reset}`)
@ -122,7 +141,7 @@ async function actionRemove(dir: string) {
if (removed) { if (removed) {
console.log(`${red}-${reset} ${dir}`) console.log(`${red}-${reset} ${dir}`)
} else { } else {
console.log(`Project not found in registry: ${dir}`) die(`Project not found in registry: ${dir}`)
} }
} }
@ -139,29 +158,45 @@ async function actionAll(opts: { json?: boolean }) {
return return
} }
// Determine status for each session in parallel // Determine status for each session in parallel, keyed by repo+branch
const vmRunning = (await vm.status()) === "running" const vmRunning = (await vm.status()) === "running"
const staleReviews: { repoRoot: string; branch: string }[] = []
const statusEntries = await Promise.all( const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => { sessions.map(async (s): Promise<[string, string]> => {
if (!vmRunning) return [s.branch, "idle"] const key = `${s.repo}/${s.branch}`
if (!vmRunning) return [key, "idle"]
const active = await vm.isClaudeActive(s.worktree, s.branch) const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [s.branch, "review"] if (active && s.in_review) return [key, "review"]
if (active) return [s.branch, "active"] 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) const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"] if (dirty) return [key, "dirty"]
const commits = await git.hasNewCommits(s.worktree) const commits = await git.hasNewCommits(s.worktree)
return [s.branch, commits ? "saved" : "idle"] return [key, commits ? "saved" : "idle"]
}) })
) )
const statuses = Object.fromEntries(statusEntries) const statuses = Object.fromEntries(statusEntries)
const styleDefs: [string, string, string][] = [ // Clear stale in_review flags grouped by repo
["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], if (staleReviews.length > 0) {
["saved", green, "●"], ["review", magenta, "⊛"], const byRepo = new Map<string, string[]>()
] for (const { repoRoot, branch } of staleReviews) {
const styles = Object.fromEntries( const list = byRepo.get(repoRoot) ?? []
styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) 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 {}
}
}
// Group by repo // Group by repo
const byRepo = new Map<string, typeof sessions>() const byRepo = new Map<string, typeof sessions>()
@ -171,25 +206,13 @@ async function actionAll(opts: { json?: boolean }) {
byRepo.set(s.repo, list) byRepo.set(s.repo, list)
} }
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
for (const [repo, repoSessions] of byRepo) { for (const [repo, repoSessions] of byRepo) {
console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`) console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) const displaySessions = repoSessions.map(s => ({ ...s, key: `${s.repo}/${s.branch}` }))
renderSessions(displaySessions, statuses)
for (const s of repoSessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.branch] ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
} }
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) renderLegend()
if (!vmRunning) { if (!vmRunning) {
console.log(`\n${red}VM is not running.${reset}`) console.log(`\n${red}VM is not running.${reset}`)

View File

@ -1,5 +1,5 @@
import { join, basename, dirname, resolve } from "path" import { join, basename, resolve } from "path"
import { readdir, rename } from "fs/promises" import { readdir, rename, mkdir } from "fs/promises"
import { homedir } from "os" import { homedir } from "os"
export interface Session { export interface Session {
@ -34,7 +34,6 @@ export async function save(repoRoot: string, state: State): Promise<void> {
await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists
await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n") await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n")
await rename(tmpPath, path) await rename(tmpPath, path)
await registerProject(repoRoot).catch(() => {})
} }
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> { export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
@ -46,6 +45,7 @@ export async function setSession(repoRoot: string, session: Session): Promise<vo
const state = await load(repoRoot) const state = await load(repoRoot)
state.sessions[session.branch] = session state.sessions[session.branch] = session
await save(repoRoot, state) await save(repoRoot, state)
await registerProject(repoRoot).catch(() => {})
} }
export async function removeSession(repoRoot: string, branch: string): Promise<void> { export async function removeSession(repoRoot: string, branch: string): Promise<void> {
@ -54,43 +54,54 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
await save(repoRoot, state) await save(repoRoot, state)
} }
// ── Global state: ~/.sandlot/state.json ────────────────────────────── // ── Global state: ~/.sandlot/registry.json ─────────────────────────
interface GlobalState { interface GlobalState {
projects: string[] // repo root paths projects: string[] // repo root paths
} }
const GLOBAL_DIR = join(homedir(), ".sandlot")
function globalStatePath(): string { function globalStatePath(): string {
return join(homedir(), ".sandlot", "state.json") return join(GLOBAL_DIR, "registry.json")
} }
async function loadGlobal(): Promise<GlobalState> { async function loadGlobal(): Promise<GlobalState> {
const file = Bun.file(globalStatePath()) const file = Bun.file(globalStatePath())
if (await file.exists()) { if (await file.exists()) {
return await file.json() try {
const data = await file.json()
if (data && Array.isArray(data.projects)) return data
} catch {}
} }
return { projects: [] } return { projects: [] }
} }
async function saveGlobal(gs: GlobalState): Promise<void> { async function saveGlobal(gs: GlobalState): Promise<void> {
await mkdir(GLOBAL_DIR, { recursive: true })
const path = globalStatePath() const path = globalStatePath()
const tmpPath = path + ".tmp" const tmpPath = path + ".tmp"
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
await rename(tmpPath, path) await rename(tmpPath, path)
} }
function normalizePath(dir: string): string {
return resolve(dir.replace(/^~/, homedir()))
}
/** Register a project directory in the global state. */ /** Register a project directory in the global state. */
export async function registerProject(repoRoot: string): Promise<void> { export async function registerProject(repoRoot: string): Promise<void> {
const normalized = normalizePath(repoRoot)
const gs = await loadGlobal() const gs = await loadGlobal()
if (!gs.projects.includes(repoRoot)) { if (!gs.projects.includes(normalized)) {
gs.projects.push(repoRoot) gs.projects.push(normalized)
await saveGlobal(gs) await saveGlobal(gs)
} }
} }
/** Remove a project directory from the global state. */ /** Remove a project directory from the global state. */
export async function unregisterProject(dir: string): Promise<boolean> { export async function unregisterProject(dir: string): Promise<boolean> {
const target = resolve(dir.replace(/^~/, homedir())) const target = normalizePath(dir)
const gs = await loadGlobal() const gs = await loadGlobal()
const idx = gs.projects.indexOf(target) const idx = gs.projects.indexOf(target)
if (idx === -1) return false if (idx === -1) return false
@ -101,8 +112,8 @@ export async function unregisterProject(dir: string): Promise<boolean> {
/** Recursively scan a directory for .sandlot dirs and register their parent projects. */ /** 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): Promise<string[]> {
const root = resolve(dir.replace(/^~/, homedir())) const root = normalizePath(dir)
const registered: string[] = [] const found: string[] = []
async function walk(d: string) { async function walk(d: string) {
let entries let entries
@ -114,26 +125,37 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot") const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot")
if (hasSandlot) { if (hasSandlot) {
// Verify it has a state.json
const stateFile = Bun.file(join(d, ".sandlot", "state.json")) const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
if (await stateFile.exists()) { if (await stateFile.exists()) {
await registerProject(d) found.push(normalizePath(d))
registered.push(d)
} }
} }
for (const entry of entries) { const children = entries.filter(e => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue await Promise.all(children.map(entry => walk(join(d, entry.name))))
await walk(join(d, entry.name))
}
} }
await walk(root) await walk(root)
return registered
// Bulk register all found projects in a single load/save cycle
if (found.length > 0) {
const gs = await loadGlobal()
let changed = false
for (const p of found) {
if (!gs.projects.includes(p)) {
gs.projects.push(p)
changed = true
}
}
if (changed) await saveGlobal(gs)
}
return found
} }
export interface GlobalSession extends Session { export interface GlobalSession extends Session {
repo: string repo: string
repoRoot: string
} }
/** Load all sessions across all registered projects. */ /** Load all sessions across all registered projects. */
@ -146,7 +168,7 @@ export async function loadAll(): Promise<GlobalSession[]> {
const st = await load(project) const st = await load(project)
const repo = basename(project) const repo = basename(project)
for (const session of Object.values(st.sessions)) { for (const session of Object.values(st.sessions)) {
all.push({ ...session, repo }) all.push({ ...session, repo, repoRoot: project })
} }
} catch {} } catch {}
} }