sandlot/src/state.ts
Chris Wanstrath 5893e07530 Replace filesystem-scanning session discovery with an explicit project registry
The old loadAll() walked ~/.sandlot/ and reverse-engineered repo roots
from .git worktree pointers, which was fragile and slow. A simple
registry (~/.sandlot/state.json) tracks known projects explicitly,
with commands to add, remove, and list across all of them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00

156 lines
4.3 KiB
TypeScript

import { join, basename, dirname, resolve } from "path"
import { readdir, rename } from "fs/promises"
import { homedir } from "os"
export interface Session {
branch: string
worktree: string
created_at: string
prompt?: string
in_review?: boolean
}
export interface State {
sessions: Record<string, Session>
}
function statePath(repoRoot: string): string {
return join(repoRoot, ".sandlot", "state.json")
}
export async function load(repoRoot: string): Promise<State> {
const path = statePath(repoRoot)
const file = Bun.file(path)
if (await file.exists()) {
return await file.json()
}
return { sessions: {} }
}
export async function save(repoRoot: string, state: State): Promise<void> {
const path = statePath(repoRoot)
const tmpPath = path + ".tmp"
const dir = join(repoRoot, ".sandlot")
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> {
const state = await load(repoRoot)
return state.sessions[branch]
}
export async function setSession(repoRoot: string, session: Session): Promise<void> {
const state = await load(repoRoot)
state.sessions[session.branch] = session
await save(repoRoot, state)
}
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
const state = await load(repoRoot)
delete state.sessions[branch]
await save(repoRoot, state)
}
// ── Global state: ~/.sandlot/state.json ──────────────────────────────
interface GlobalState {
projects: string[] // repo root paths
}
function globalStatePath(): string {
return join(homedir(), ".sandlot", "state.json")
}
async function loadGlobal(): Promise<GlobalState> {
const file = Bun.file(globalStatePath())
if (await file.exists()) {
return await file.json()
}
return { projects: [] }
}
async function saveGlobal(gs: GlobalState): Promise<void> {
const path = globalStatePath()
const tmpPath = path + ".tmp"
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
await rename(tmpPath, path)
}
/** Register a project directory in the global state. */
export async function registerProject(repoRoot: string): Promise<void> {
const gs = await loadGlobal()
if (!gs.projects.includes(repoRoot)) {
gs.projects.push(repoRoot)
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 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[]> {
const root = resolve(dir.replace(/^~/, homedir()))
const registered: string[] = []
async function walk(d: string) {
let entries
try {
entries = await readdir(d, { withFileTypes: true })
} catch {
return
}
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)
}
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue
await walk(join(d, entry.name))
}
}
await walk(root)
return registered
}
export interface GlobalSession extends Session {
repo: string
}
/** Load all sessions across all registered projects. */
export async function loadAll(): Promise<GlobalSession[]> {
const gs = await loadGlobal()
const all: GlobalSession[] = []
for (const project of gs.projects) {
try {
const st = await load(project)
const repo = basename(project)
for (const session of Object.values(st.sessions)) {
all.push({ ...session, repo })
}
} catch {}
}
return all
}