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