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 vm from "../vm.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 }) {
if (opts.add) return actionAdd(opts.add)
@ -76,29 +115,9 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
return
}
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 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}`)
const displaySessions = sessions.map(s => ({ ...s, key: s.branch }))
renderSessions(displaySessions, statuses)
renderLegend()
if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`)
@ -122,7 +141,7 @@ async function actionRemove(dir: string) {
if (removed) {
console.log(`${red}-${reset} ${dir}`)
} 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
}
// 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 staleReviews: { repoRoot: string; branch: string }[] = []
const statusEntries = await Promise.all(
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)
if (active && s.in_review) return [s.branch, "review"]
if (active) return [s.branch, "active"]
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 [s.branch, "dirty"]
if (dirty) return [key, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
return [s.branch, commits ? "saved" : "idle"]
return [key, commits ? "saved" : "idle"]
})
)
const statuses = Object.fromEntries(statusEntries)
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 }])
)
// 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 {}
}
}
// Group by repo
const byRepo = new Map<string, typeof sessions>()
@ -171,25 +206,13 @@ async function actionAll(opts: { json?: boolean }) {
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) {
console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
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}`)
}
const displaySessions = repoSessions.map(s => ({ ...s, key: `${s.repo}/${s.branch}` }))
renderSessions(displaySessions, statuses)
}
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`)
renderLegend()
if (!vmRunning) {
console.log(`\n${red}VM is not running.${reset}`)

View File

@ -1,5 +1,5 @@
import { join, basename, dirname, resolve } from "path"
import { readdir, rename } from "fs/promises"
import { join, basename, resolve } from "path"
import { readdir, rename, mkdir } from "fs/promises"
import { homedir } from "os"
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(tmpPath, JSON.stringify(state, null, 2) + "\n")
await rename(tmpPath, path)
await registerProject(repoRoot).catch(() => {})
}
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)
state.sessions[session.branch] = session
await save(repoRoot, state)
await registerProject(repoRoot).catch(() => {})
}
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)
}
// ── Global state: ~/.sandlot/state.json ──────────────────────────────
// ── Global state: ~/.sandlot/registry.json ─────────────────────────
interface GlobalState {
projects: string[] // repo root paths
}
const GLOBAL_DIR = join(homedir(), ".sandlot")
function globalStatePath(): string {
return join(homedir(), ".sandlot", "state.json")
return join(GLOBAL_DIR, "registry.json")
}
async function loadGlobal(): Promise<GlobalState> {
const file = Bun.file(globalStatePath())
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: [] }
}
async function saveGlobal(gs: GlobalState): Promise<void> {
await mkdir(GLOBAL_DIR, { recursive: true })
const path = globalStatePath()
const tmpPath = path + ".tmp"
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
await rename(tmpPath, path)
}
function normalizePath(dir: string): string {
return resolve(dir.replace(/^~/, homedir()))
}
/** Register a project directory in the global state. */
export async function registerProject(repoRoot: string): Promise<void> {
const normalized = normalizePath(repoRoot)
const gs = await loadGlobal()
if (!gs.projects.includes(repoRoot)) {
gs.projects.push(repoRoot)
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 = resolve(dir.replace(/^~/, homedir()))
const target = normalizePath(dir)
const gs = await loadGlobal()
const idx = gs.projects.indexOf(target)
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. */
export async function scanAndRegister(dir: string): Promise<string[]> {
const root = resolve(dir.replace(/^~/, homedir()))
const registered: string[] = []
const root = normalizePath(dir)
const found: string[] = []
async function walk(d: string) {
let entries
@ -114,26 +125,37 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot")
if (hasSandlot) {
// Verify it has a state.json
const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
if (await stateFile.exists()) {
await registerProject(d)
registered.push(d)
found.push(normalizePath(d))
}
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue
await walk(join(d, entry.name))
}
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 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 {
repo: string
repoRoot: string
}
/** Load all sessions across all registered projects. */
@ -146,7 +168,7 @@ export async function loadAll(): Promise<GlobalSession[]> {
const st = await load(project)
const repo = basename(project)
for (const session of Object.values(st.sessions)) {
all.push({ ...session, repo })
all.push({ ...session, repo, repoRoot: project })
}
} catch {}
}