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:
parent
5893e07530
commit
751b4773c9
|
|
@ -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}`)
|
||||
|
|
|
|||
62
src/state.ts
62
src/state.ts
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user