/** * src/http/projects.ts (v0.6 Project Manager — P2) — discover host projects. * * Modelled on history.ts: fully async, best-effort, never throws to the caller. * Steps (see docs/FEATURE_PROJECT_MANAGER.md §4.3): * 1. breadth-first scan cfg.projectRoots for git repos (depth-capped, skipping * node_modules / dotdirs / symlinks — bounds the DoS/scan surface). * 2. per repo: branch from .git/HEAD (cheap, no spawn); optional dirty via * `git status --porcelain` (execFile, no shell, timeout + concurrency cap). * 3. merge recently-used cwds from ~/.claude/projects (history.ts). * 4. merge live sessions by cwd → ProjectInfo.sessions[] (FRESH every call). * * Steps 1–3 (expensive disk work) are cached at module scope with a TTL; the * step-4 session merge runs fresh on every buildProjects call (sessions churn). */ import fs from 'node:fs/promises' import path from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import type { Dirent } from 'node:fs' import type { Config, LiveSessionInfo, ProjectInfo, ProjectSessionRef } from '../types.js' import { listSessions } from './history.js' const execFileAsync = promisify(execFile) // ── constants ───────────────────────────────────────────────────────────────── const GIT_STATUS_TIMEOUT_MS = 2000 // hard kill on a slow `git status` const GIT_CONCURRENCY = 8 // cap simultaneous git/fs work (fork-bomb guard) const GIT_STATUS_MAX_BUFFER = 1024 * 1024 // truncate huge porcelain output const MAX_PROJECTS = 200 // sane cap on the returned list const SKIP_DIR_NAMES = new Set(['node_modules']) // ── parse (pure, unit-tested) ─────────────────────────────────────────────────── /** * Parse a `.git/HEAD` file. `ref: refs/heads/` → '' (slashes * kept); a bare 40-hex sha (detached HEAD) or any junk → null. */ export function parseGitHead(headText: string): string | null { const match = /^ref:\s+refs\/heads\/(.+)$/.exec(headText.trim()) if (match === null) return null const branch = match[1]?.trim() return branch !== undefined && branch !== '' ? branch : null } // ── small async utilities ─────────────────────────────────────────────────────── /** Run `fn` over `items` with at most `limit` in flight; preserves order. */ async function mapWithConcurrency( items: readonly T[], limit: number, fn: (item: T) => Promise, ): Promise { const results: R[] = new Array(items.length) let cursor = 0 async function worker(): Promise { for (;;) { const idx = cursor cursor += 1 if (idx >= items.length) return const item = items[idx] if (item === undefined) continue results[idx] = await fn(item) } } const poolSize = Math.min(Math.max(limit, 1), items.length) await Promise.all(Array.from({ length: poolSize }, () => worker())) return results } /** Skip dotfiles/dotdirs (incl. .git) and known-heavy dirs when descending. */ function shouldSkipDir(name: string): boolean { return name.startsWith('.') || SKIP_DIR_NAMES.has(name) } // ── per-repo metadata (best-effort) ───────────────────────────────────────────── /** Read the current branch from `/.git/HEAD`; undefined if unreadable. */ async function readBranch(repoPath: string): Promise { try { const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8') return parseGitHead(head) ?? undefined } catch { return undefined } } /** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */ async function readDirty(repoPath: string): Promise { try { const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER, }) return stdout.trim().length > 0 } catch { return undefined } } /** True iff `/.git` exists (file or directory). */ async function hasGitEntry(dir: string): Promise { try { await fs.stat(path.join(dir, '.git')) return true } catch { return false } } interface MakeProjectArgs { readonly path: string readonly isGit: boolean readonly branch?: string readonly dirty?: boolean readonly lastActiveMs?: number } function makeProject(args: MakeProjectArgs): ProjectInfo { return { name: path.basename(args.path), path: args.path, isGit: args.isGit, branch: args.branch, dirty: args.dirty, lastActiveMs: args.lastActiveMs, sessions: [], } } // ── step 1: breadth-first repo scan ───────────────────────────────────────────── /** * BFS each root to `maxDepth`. A directory containing a `.git` entry is recorded * as a repo and NOT descended into. Unreadable dirs are skipped (best-effort); * node_modules / dotdirs / symlinks are never followed. */ async function scanRepos(roots: readonly string[], maxDepth: number): Promise { const found: string[] = [] const seen = new Set() const queue: Array<{ dir: string; depth: number }> = roots.map((r) => ({ dir: path.resolve(r), depth: 0, })) while (queue.length > 0) { const next = queue.shift() if (next === undefined) break const { dir, depth } = next if (seen.has(dir)) continue seen.add(dir) let entries: Dirent[] try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { continue // unreadable dir → skip } if (entries.some((e) => e.name === '.git')) { found.push(dir) continue // don't descend into a repo } if (depth >= maxDepth) continue for (const entry of entries) { if (entry.isSymbolicLink()) continue // never follow symlinks if (!entry.isDirectory()) continue if (shouldSkipDir(entry.name)) continue queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }) } } return found } // ── step 3: merge recently-used cwds from history ─────────────────────────────── /** Newest mtime per distinct (non-empty) cwd seen in ~/.claude/projects. */ async function lastActiveByCwd(): Promise> { let sessions try { sessions = await listSessions() } catch { return new Map() } const byCwd = new Map() for (const s of sessions) { if (s.cwd === '') continue const prev = byCwd.get(s.cwd) if (prev === undefined || s.mtimeMs > prev) byCwd.set(s.cwd, s.mtimeMs) } return byCwd } async function mergeHistory(repos: ProjectInfo[]): Promise { const byCwd = await lastActiveByCwd() const knownPaths = new Set(repos.map((p) => p.path)) // Stamp discovered repos that also appear in history. const stamped = repos.map((p) => { const ms = byCwd.get(p.path) return ms === undefined ? p : { ...p, lastActiveMs: ms } }) // Add history cwds not already in discovered repos — processed concurrently. const historyEntries = [...byCwd.entries()].filter(([cwd]) => !knownPaths.has(cwd)) const extras = await mapWithConcurrency( historyEntries, GIT_CONCURRENCY, async ([cwd, ms]) => { const isGit = await hasGitEntry(cwd) const branch = isGit ? await readBranch(cwd) : undefined return makeProject({ path: cwd, isGit, branch, lastActiveMs: ms }) }, ) return [...stamped, ...extras] } // ── steps 1–3: cached disk discovery ───────────────────────────────────────────── interface DiscoverCacheEntry { readonly key: string readonly expiresAt: number readonly projects: ProjectInfo[] } let discoverCache: DiscoverCacheEntry | null = null /** Shared in-flight promise so concurrent cache-miss callers join one discovery run. */ let inflightDiscovery: { key: string; promise: Promise } | null = null function cacheKey(cfg: Config): string { return JSON.stringify({ roots: cfg.projectRoots, depth: cfg.projectScanDepth, dirty: cfg.projectDirtyCheck, }) } function dedupByPath(projects: readonly ProjectInfo[]): ProjectInfo[] { const seen = new Set() const out: ProjectInfo[] = [] for (const p of projects) { if (seen.has(p.path)) continue seen.add(p.path) out.push(p) } return out } async function runDiscovery(cfg: Config): Promise { const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth) const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => { const branch = await readBranch(repoPath) const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined return makeProject({ path: repoPath, isGit: true, branch, dirty }) }) const merged = await mergeHistory(repos) return dedupByPath(merged) } /** * Cached steps 1–3, keyed by (roots+depth+dirtyCheck) with cfg.projectScanTtlMs. * Concurrent cache-miss callers share a single in-flight promise (cache-stampede guard). */ async function discoverProjects(cfg: Config): Promise { const now = Date.now() const key = cacheKey(cfg) if (discoverCache !== null && discoverCache.key === key && discoverCache.expiresAt > now) { return discoverCache.projects } // Deduplicate concurrent cache-miss calls onto one discovery run. if (inflightDiscovery !== null && inflightDiscovery.key === key) { return inflightDiscovery.promise } const promise = runDiscovery(cfg) .then((projects) => { discoverCache = { key, expiresAt: Date.now() + cfg.projectScanTtlMs, projects } inflightDiscovery = null return projects }) .catch((e: unknown) => { inflightDiscovery = null throw e }) inflightDiscovery = { key, promise } return promise } /** Test-only: drop the discovery cache and any in-flight run so each test sees a fresh scan. */ export function _clearProjectCache(): void { discoverCache = null inflightDiscovery = null } // ── step 4: fresh live-session merge + sort ────────────────────────────────────── function lastSegment(cwd: string | null): string | undefined { if (cwd === null || cwd === '') return undefined return cwd.split(path.sep).filter(Boolean).pop() } /** A session belongs to a project when its spawn cwd is the path or under it. */ function belongsTo(cwd: string | null, projectPath: string): boolean { if (cwd === null) return false if (cwd === projectPath) return true return cwd.startsWith(projectPath + path.sep) } function toSessionRef(s: LiveSessionInfo): ProjectSessionRef { return { id: s.id, title: lastSegment(s.cwd), status: s.status, clientCount: s.clientCount, createdAt: s.createdAt, exited: s.exited, } } function matchSessions(projectPath: string, live: readonly LiveSessionInfo[]): ProjectSessionRef[] { return live.filter((s) => belongsTo(s.cwd, projectPath)).map(toSessionRef) } function sortProjects(projects: readonly ProjectInfo[]): ProjectInfo[] { return [...projects].sort((a, b) => { if (a.lastActiveMs !== b.lastActiveMs) { if (a.lastActiveMs === undefined) return 1 // undefined sorts last if (b.lastActiveMs === undefined) return -1 return b.lastActiveMs - a.lastActiveMs // most-recent first } return a.name.localeCompare(b.name) }) } /** * Discover the host's projects and attach the currently-running sessions. * `liveSessions` is INJECTED (e.g. `manager.list()`) so this stays pure-testable. * Disk discovery is cached; the session merge always runs fresh. */ export async function buildProjects( cfg: Config, liveSessions: readonly LiveSessionInfo[], ): Promise { const base = await discoverProjects(cfg) const withSessions = base.map((p) => ({ ...p, sessions: matchSessions(p.path, liveSessions), })) return sortProjects(withSessions).slice(0, MAX_PROJECTS) }