import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import type { Config, LiveSessionInfo } from '../src/types.js' import type { HistorySession } from '../src/http/history.js' // ── mock history.listSessions so the real ~/.claude/projects never pollutes ────── // (vitest hoists vi.mock; the factory may only reference vars prefixed "mock".) const mockListSessions = vi.fn(async (): Promise => []) vi.mock('../src/http/history.js', () => ({ listSessions: (...a: unknown[]) => mockListSessions(...a), })) const { parseGitHead, buildProjects, buildProjectDetail, buildWorktreeState, _clearProjectCache, } = await import('../src/http/projects.js') const execFileP = promisify(execFile) // ── helpers ───────────────────────────────────────────────────────────────────── function makeCfg(overrides: Partial): Config { return { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 1000, scrollbackBytes: 1024, maxPayloadBytes: 1024, wsPath: '/term', maxSessions: 50, maxMsgsPerSec: 2000, permTimeoutMs: 1000, reapIntervalMs: 1000, previewBytes: 1024, useTmux: false, allowedOrigins: [], projectRoots: [], projectScanDepth: 4, projectScanTtlMs: 0, // default OFF so each test re-scans deterministically projectDirtyCheck: false, ...overrides, } } function makeLive(over: { id: string; cwd: string | null } & Partial): LiveSessionInfo { return { id: over.id, createdAt: over.createdAt ?? 1000, clientCount: over.clientCount ?? 1, status: over.status ?? 'unknown', exited: over.exited ?? false, cwd: over.cwd, cols: over.cols ?? 80, rows: over.rows ?? 24, } } async function makeRepo(dir: string, branch: string): Promise { await fs.mkdir(path.join(dir, '.git'), { recursive: true }) await fs.writeFile(path.join(dir, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`) } async function gitAvailable(): Promise { try { await execFileP('git', ['--version']) return true } catch { return false } } let tmp: string beforeEach(async () => { _clearProjectCache() mockListSessions.mockReset() mockListSessions.mockResolvedValue([]) tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-')) }) afterEach(async () => { _clearProjectCache() await fs.rm(tmp, { recursive: true, force: true }) }) // ── parseGitHead (pure) ────────────────────────────────────────────────────────── describe('parseGitHead', { timeout: 30_000 }, () => { it('extracts a simple branch name', () => { expect(parseGitHead('ref: refs/heads/main\n')).toBe('main') }) it('extracts a branch name with slashes', () => { expect(parseGitHead('ref: refs/heads/feature/x\n')).toBe('feature/x') }) it('returns null for a detached HEAD (bare 40-hex sha)', () => { expect(parseGitHead('a'.repeat(40) + '\n')).toBeNull() }) it('returns null for empty / junk / non-heads refs', () => { expect(parseGitHead('')).toBeNull() expect(parseGitHead('garbage\n')).toBeNull() expect(parseGitHead('ref: refs/tags/v1\n')).toBeNull() }) }) // ── buildProjects: discovery (real temp dir) ───────────────────────────────────── describe('buildProjects — discovery', { timeout: 30_000 }, () => { it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1 await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2 await makeRepo(path.join(tmp, 'sub', 'deep', 'repoC'), 'x') // depth 3 -> excluded await makeRepo(path.join(tmp, 'node_modules', 'pkg'), 'y') // node_modules -> skipped const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const byName = new Map(out.map((p) => [p.name, p])) expect([...byName.keys()].sort()).toEqual(['repoA', 'repoB']) expect(byName.get('repoA')).toMatchObject({ isGit: true, branch: 'main', path: path.join(tmp, 'repoA'), sessions: [], }) expect(byName.get('repoB')!.branch).toBe('dev') expect(byName.has('repoC')).toBe(false) // too deep expect(byName.has('pkg')).toBe(false) // node_modules }) it('records a root that is itself a git repo and does not descend into it', async () => { await makeRepo(tmp, 'main') await makeRepo(path.join(tmp, 'nested'), 'dev') // inside a repo -> not descended const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.map((p) => p.path)).toEqual([tmp]) expect(out[0]!.branch).toBe('main') }) it('leaves branch undefined when .git/HEAD is missing/unreadable', async () => { await fs.mkdir(path.join(tmp, 'bare', '.git'), { recursive: true }) // .git dir, no HEAD const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const bare = out.find((p) => p.name === 'bare')! expect(bare.isGit).toBe(true) expect(bare.branch).toBeUndefined() }) it('does not follow symlinks', async () => { const other = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-link-')) try { await makeRepo(path.join(other, 'repoS'), 'main') try { await fs.symlink(other, path.join(tmp, 'linked'), 'dir') } catch { return // symlinks not permitted in this environment } const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.some((p) => p.name === 'repoS')).toBe(false) } finally { await fs.rm(other, { recursive: true, force: true }) } }) it('returns [] for an unreadable / missing root (never throws)', async () => { const cfg = makeCfg({ projectRoots: [path.join(tmp, 'does-not-exist')], projectScanDepth: 2, projectDirtyCheck: false, }) await expect(buildProjects(cfg, [])).resolves.toEqual([]) }) }) // ── buildProjects: live-session merge ──────────────────────────────────────────── describe('buildProjects — session merge', { timeout: 30_000 }, () => { it('merges sessions into the owning project by cwd === path or under path', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const live: LiveSessionInfo[] = [ makeLive({ id: 's-exact', cwd: repoPath, status: 'working', clientCount: 2, createdAt: 111 }), makeLive({ id: 's-under', cwd: path.join(repoPath, 'src', 'http'), status: 'idle' }), makeLive({ id: 's-other', cwd: path.join(tmp, 'elsewhere') }), makeLive({ id: 's-null', cwd: null }), ] const out = await buildProjects(cfg, live) const repoA = out.find((p) => p.name === 'repoA')! expect(repoA.sessions.map((s) => s.id).sort()).toEqual(['s-exact', 's-under']) expect(repoA.sessions.find((s) => s.id === 's-exact')).toEqual({ id: 's-exact', title: 'repoA', status: 'working', clientCount: 2, createdAt: 111, exited: false, cwd: repoPath, // w6/G7 — needed to attribute a session to its worktree }) expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http') }) it('attributes a session only to the DEEPEST project containing its cwd', async () => { // repoA is discovered by the scan; the nested repoA/sub repo enters via // history (scan does not descend into repos). A session inside sub must // NOT also light up the ancestor repoA. const repoPath = path.join(tmp, 'repoA') const subPath = path.join(repoPath, 'sub') await makeRepo(repoPath, 'main') await makeRepo(subPath, 'dev') mockListSessions.mockResolvedValue([ { id: 'h1', cwd: subPath, project: 'sub', mtimeMs: 1000, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const live = [makeLive({ id: 's-deep', cwd: path.join(subPath, 'src') })] const out = await buildProjects(cfg, live) const repoA = out.find((p) => p.path === repoPath)! const sub = out.find((p) => p.path === subPath)! expect(sub.sessions.map((s) => s.id)).toEqual(['s-deep']) expect(repoA.sessions).toEqual([]) }) it('re-merges live sessions FRESH on every call (discovery is cached)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false, projectScanTtlMs: 60_000, // keep discovery cached across calls }) const first = await buildProjects(cfg, []) expect(first.find((p) => p.name === 'repoA')!.sessions).toEqual([]) const second = await buildProjects(cfg, [makeLive({ id: 's1', cwd: repoPath })]) expect(second.find((p) => p.name === 'repoA')!.sessions.map((s) => s.id)).toEqual(['s1']) const third = await buildProjects(cfg, []) expect(third.find((p) => p.name === 'repoA')!.sessions).toEqual([]) }) }) // ── buildProjects: history merge + sorting ─────────────────────────────────────── describe('buildProjects — history merge & sorting', { timeout: 30_000 }, () => { it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') const histDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-hist-')) try { mockListSessions.mockResolvedValue([ { id: 'h1', cwd: repoPath, project: 'repoA', mtimeMs: 5000, preview: '' }, { id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 9000, preview: '' }, { id: 'h3', cwd: histDir, project: path.basename(histDir), mtimeMs: 3000, preview: '' }, { id: 'h4', cwd: '', project: 'unknown', mtimeMs: 1, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const repoA = out.find((p) => p.name === 'repoA')! expect(repoA.lastActiveMs).toBe(9000) // newest of h1/h2 const hist = out.find((p) => p.path === histDir)! expect(hist).toBeDefined() expect(hist.isGit).toBe(false) expect(hist.lastActiveMs).toBe(3000) expect(out.some((p) => p.path === '')).toBe(false) // empty cwd ignored expect(out[0]!.name).toBe('repoA') // 9000 > 3000 } finally { await fs.rm(histDir, { recursive: true, force: true }) } }) it('drops non-git parent folders that merely contain other projects', async () => { // Running a session in ~/Documents once puts it in history; it must not be // listed as a project when it only contains real repos (parent folder). const repoPath = path.join(tmp, 'repoA') await makeRepo(repoPath, 'main') mockListSessions.mockResolvedValue([ { id: 'h1', cwd: tmp, project: path.basename(tmp), mtimeMs: 2000, preview: '' }, { id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 1000, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.some((p) => p.path === tmp)).toBe(false) // parent folder dropped expect(out.some((p) => p.path === repoPath)).toBe(true) }) it('keeps a non-git history project that contains no other project', async () => { const standalone = path.join(tmp, 'notes') await fs.mkdir(standalone, { recursive: true }) mockListSessions.mockResolvedValue([ { id: 'h1', cwd: standalone, project: 'notes', mtimeMs: 2000, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.some((p) => p.path === standalone)).toBe(true) }) it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => { await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined await makeRepo(path.join(tmp, 'mmm'), 'main') // has history -> first mockListSessions.mockResolvedValue([ { id: 'h', cwd: path.join(tmp, 'mmm'), project: 'mmm', mtimeMs: 1000, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.map((p) => p.name)).toEqual(['mmm', 'aaa', 'zzz']) }) }) // ── buildProjects: in-flight promise deduplication (Finding 4) ───────────────────── describe('buildProjects — concurrent cache-miss deduplication', { timeout: 30_000 }, () => { it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') await makeRepo(path.join(tmp, 'repoB'), 'dev') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) // Both calls start concurrently while cache is cold. const [a, b] = await Promise.all([buildProjects(cfg, []), buildProjects(cfg, [])]) const aNames = a.map((p) => p.name).sort() const bNames = b.map((p) => p.name).sort() expect(aNames).toEqual(['repoA', 'repoB']) expect(bNames).toEqual(aNames) }) it('second concurrent call gets the same result as the first (in-flight reuse)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false, projectScanTtlMs: 60_000, }) // Start first call, immediately start second — second should share the in-flight promise. const first = buildProjects(cfg, []) const second = buildProjects(cfg, []) // cache still cold at this synchronous point const [r1, r2] = await Promise.all([first, second]) expect(r1.map((p) => p.name)).toContain('repoA') expect(r2.map((p) => p.name)).toEqual(r1.map((p) => p.name)) }) }) // ── buildProjects: dirty check (real git, when available) ───────────────────────── describe('buildProjects — dirty check', { timeout: 30_000 }, () => { it('computes dirty via git status only when projectDirtyCheck is true', async () => { if (!(await gitAvailable())) return // git not installed -> skip const repo = path.join(tmp, 'gitrepo') await fs.mkdir(repo, { recursive: true }) await execFileP('git', ['init', '-q'], { cwd: repo }) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: repo }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd: repo }) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true }) // clean repo -> dirty false let out = await buildProjects(cfg, []) const clean = out.find((p) => p.name === 'gitrepo')! expect(clean.dirty).toBe(false) expect(clean.branch).toBeTruthy() // untracked file -> dirty true await fs.writeFile(path.join(repo, 'a.txt'), 'hi') _clearProjectCache() out = await buildProjects(cfg, []) expect(out.find((p) => p.name === 'gitrepo')!.dirty).toBe(true) }) it('never spawns git (dirty undefined) when projectDirtyCheck is false', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined() }) }) // ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ────────────── describe('buildProjects — sync fields (W3 a)', { timeout: 30_000 }, () => { async function commit(cwd: string, file: string, msg: string): Promise { await fs.writeFile(path.join(cwd, file), `${file}\n`) await execFileP('git', ['add', '.'], { cwd }) await execFileP('git', ['commit', '-q', '-m', msg], { cwd }) } async function initRepo(cwd: string): Promise { await fs.mkdir(cwd, { recursive: true }) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd }) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd }) await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd }) } it('reports ahead/behind vs upstream and lastCommitMs when dirtyCheck is on', async () => { if (!(await gitAvailable())) return // Upstream repo with one commit; clone it so the clone's main tracks origin/main. const upstream = path.join(tmp, 'upstream') await initRepo(upstream) await commit(upstream, 'a.txt', 'first') const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-clone-')) const clone = path.join(cloneRoot, 'clone') await execFileP('git', ['clone', '-q', upstream, clone]) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone }) await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone }) // Diverge: 1 local commit ahead, then 1 upstream commit fetched → 1 behind. await commit(clone, 'local.txt', 'local ahead') await commit(upstream, 'b.txt', 'second upstream') await execFileP('git', ['fetch', '-q'], { cwd: clone }) const cfg = makeCfg({ projectRoots: [cloneRoot], projectScanDepth: 2, projectDirtyCheck: true }) const out = await buildProjects(cfg, []) const proj = out.find((p) => p.name === 'clone')! expect(proj.ahead).toBe(1) expect(proj.behind).toBe(1) expect(typeof proj.lastCommitMs).toBe('number') expect(proj.lastCommitMs!).toBeGreaterThan(0) await fs.rm(cloneRoot, { recursive: true, force: true }) }) it('leaves ahead/behind undefined for a repo with no upstream (no throw)', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'noupstream') await initRepo(repo) await commit(repo, 'a.txt', 'only') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true }) const out = await buildProjects(cfg, []) const proj = out.find((p) => p.name === 'noupstream')! expect(proj.ahead).toBeUndefined() expect(proj.behind).toBeUndefined() // lastCommitMs still resolves — HEAD has a commit even without an upstream. expect(typeof proj.lastCommitMs).toBe('number') }) it('skips sync entirely (all undefined) when projectDirtyCheck is false', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'skipsync') await initRepo(repo) await commit(repo, 'a.txt', 'only') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const proj = out.find((p) => p.name === 'skipsync')! expect(proj.ahead).toBeUndefined() expect(proj.behind).toBeUndefined() expect(proj.lastCommitMs).toBeUndefined() }) }) // ── buildProjectDetail ─────────────────────────────────────────────────────────── describe('buildProjectDetail', { timeout: 30_000 }, () => { it('returns null for a relative path', async () => { expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull() }) it('returns null for a non-existent path', async () => { expect(await buildProjectDetail(makeCfg({}), path.join(tmp, 'nope'), [])).toBeNull() }) it('returns null when the path is a file', async () => { const f = path.join(tmp, 'file.txt') await fs.writeFile(f, 'x') expect(await buildProjectDetail(makeCfg({}), f, [])).toBeNull() }) it('reports a non-git directory with no worktrees', async () => { const dir = path.join(tmp, 'plain') await fs.mkdir(dir) const d = await buildProjectDetail(makeCfg({}), dir, []) expect(d).not.toBeNull() expect(d!.isGit).toBe(false) expect(d!.worktrees).toEqual([]) expect(d!.name).toBe('plain') }) it('reads the branch for a git repo (worktrees best-effort)', async () => { const dir = path.join(tmp, 'repo') await makeRepo(dir, 'develop') const d = await buildProjectDetail(makeCfg({}), dir, []) expect(d!.isGit).toBe(true) expect(d!.branch).toBe('develop') expect(Array.isArray(d!.worktrees)).toBe(true) // real `git` fails on the fake .git → [] }) it('merges running sessions whose cwd is the project or under it', async () => { const dir = path.join(tmp, 'repo') await fs.mkdir(dir) const live = [ makeLive({ id: 'a', cwd: dir, status: 'working' }), makeLive({ id: 'b', cwd: path.join(dir, 'sub') }), makeLive({ id: 'c', cwd: path.join(tmp, 'other') }), ] const d = await buildProjectDetail(makeCfg({}), dir, live) expect(d!.sessions.map((s) => s.id).sort()).toEqual(['a', 'b']) }) it('includes CLAUDE.md content when present', async () => { const dir = path.join(tmp, 'withmd') await fs.mkdir(dir) await fs.writeFile(path.join(dir, 'CLAUDE.md'), '# Project\nbe excellent') const d = await buildProjectDetail(makeCfg({}), dir, []) expect(d!.hasClaudeMd).toBe(true) expect(d!.claudeMd).toContain('be excellent') }) it('reports no CLAUDE.md when absent', async () => { const dir = path.join(tmp, 'nomd') await fs.mkdir(dir) const d = await buildProjectDetail(makeCfg({}), dir, []) expect(d!.hasClaudeMd).toBe(false) expect(d!.claudeMd).toBeUndefined() }) it('lists worktrees for a real git repo (gated on git)', async () => { if (!(await gitAvailable())) return await fs.mkdir(path.join(tmp, 'realrepo')) // git canonicalises paths (/var → /private/var on macOS), so pass the // realpath — real project paths from discovery are already canonical. const dir = await fs.realpath(path.join(tmp, 'realrepo')) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir }) await execFileP('git', ['config', 'user.email', 't@t'], { cwd: dir }) await execFileP('git', ['config', 'user.name', 't'], { cwd: dir }) await fs.writeFile(path.join(dir, 'f.txt'), 'hi') await execFileP('git', ['add', '.'], { cwd: dir }) await execFileP('git', ['commit', '-q', '-m', 'init'], { cwd: dir }) const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), dir, []) expect(d!.isGit).toBe(true) expect(d!.branch).toBe('main') expect(d!.worktrees.length).toBeGreaterThanOrEqual(1) expect(d!.worktrees[0]).toMatchObject({ isMain: true, isCurrent: true, branch: 'main' }) expect(d!.dirty).toBe(false) }) }) // ── w6/G1 sync state on the detail view ──────────────────────────────────────── // The panel's whole point is to be trusted, so these tests pin the *absence* of // numbers as hard as their presence: "no upstream" must never look like "in sync". // Each case spawns 6–12 sequential `git` processes; vitest's 5 s default is not a // budget for that once the suite runs these files in parallel. describe('buildProjectDetail — sync state (w6 G1)', { timeout: 30_000 }, () => { async function commit(cwd: string, file: string, msg: string): Promise { await fs.writeFile(path.join(cwd, file), `${file}\n`) await execFileP('git', ['add', '.'], { cwd }) await execFileP('git', ['commit', '-q', '-m', msg], { cwd }) } async function initRepo(cwd: string): Promise { await fs.mkdir(cwd, { recursive: true }) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd }) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd }) await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd }) } it('reports upstream name, ahead and a fetch timestamp for a tracking clone', async () => { if (!(await gitAvailable())) return const upstream = path.join(tmp, 'up-detail') await initRepo(upstream) await commit(upstream, 'a.txt', 'first') const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-syncdetail-')) const clone = path.join(cloneRoot, 'clone') await execFileP('git', ['clone', '-q', upstream, clone]) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone }) await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone }) await commit(clone, 'local.txt', 'local ahead') await execFileP('git', ['fetch', '-q'], { cwd: clone }) const cfg = makeCfg({ projectDirtyCheck: true }) const d = await buildProjectDetail(cfg, clone, []) expect(d!.sync).toBeDefined() expect(d!.sync!.upstream).toBe('origin/main') expect(d!.sync!.ahead).toBe(1) expect(d!.sync!.behind).toBe(0) expect(d!.sync!.detached).not.toBe(true) expect(typeof d!.sync!.lastFetchMs).toBe('number') await fs.rm(cloneRoot, { recursive: true, force: true }) }) it('leaves upstream AND ahead/behind undefined when the branch tracks nothing', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g1-noupstream') await initRepo(repo) await commit(repo, 'a.txt', 'only') const d = await buildProjectDetail(makeCfg({}), repo, []) // All three together — a UI that sees ahead===undefined must be able to tell // "nothing to compare against" from "nothing to push". expect(d!.sync!.upstream).toBeUndefined() expect(d!.sync!.ahead).toBeUndefined() expect(d!.sync!.behind).toBeUndefined() }) it('never invents a fetch time for a repo that was never fetched', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g1-nofetch') await initRepo(repo) await commit(repo, 'a.txt', 'only') const d = await buildProjectDetail(makeCfg({}), repo, []) expect(d!.sync!.lastFetchMs).toBeUndefined() }) it('flags a detached HEAD and reports no branch or ahead/behind', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g1-detached') await initRepo(repo) await commit(repo, 'a.txt', 'first') await commit(repo, 'b.txt', 'second') const { stdout } = await execFileP('git', ['rev-parse', 'HEAD~1'], { cwd: repo }) await execFileP('git', ['checkout', '-q', stdout.trim()], { cwd: repo }) const d = await buildProjectDetail(makeCfg({}), repo, []) expect(d!.sync!.detached).toBe(true) expect(d!.branch).toBeUndefined() expect(d!.sync!.ahead).toBeUndefined() expect(d!.sync!.behind).toBeUndefined() // With no branch name the short sha is the only identity the panel can show. // Read out of the .git/HEAD text already loaded for the detached test — no // extra git call. expect(d!.sync!.head).toBe(stdout.trim().slice(0, 7)) }) it('does not report a head sha when HEAD is on a branch', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g1-attached-head') await initRepo(repo) await commit(repo, 'a.txt', 'only') const d = await buildProjectDetail(makeCfg({}), repo, []) expect(d!.sync!.detached).not.toBe(true) expect(d!.sync!.head).toBeUndefined() }) it('counts dirty files instead of only flagging them', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g1-dirty') await initRepo(repo) await commit(repo, 'a.txt', 'first') await fs.writeFile(path.join(repo, 'a.txt'), 'changed\n') await fs.writeFile(path.join(repo, 'new.txt'), 'new\n') const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), repo, []) expect(d!.dirtyCount).toBe(2) expect(d!.dirty).toBe(true) // legacy field the mobile clients decode — still there }) it('omits sync entirely for a non-git directory', async () => { const plain = path.join(tmp, 'g1-plain') await fs.mkdir(plain, { recursive: true }) const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), plain, []) expect(d!.isGit).toBe(false) expect(d!.sync).toBeUndefined() expect(d!.dirtyCount).toBeUndefined() }) }) // ── w6/G6 concurrent-probe coalescing ───────────────────────────────────────── // N devices watching one repo must cost ONE probe, not N. Deliberately in-flight // only: nothing is cached across time, so the numbers can never go stale. describe('buildProjectDetail — coalescing (w6 G6)', { timeout: 30_000 }, () => { it('serves concurrent probes of the same repo from one in-flight promise', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g6-coalesce') await fs.mkdir(repo, { recursive: true }) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo }) const cfg = makeCfg({ projectDirtyCheck: true }) const [a, b, c] = await Promise.all([ buildProjectDetail(cfg, repo, []), buildProjectDetail(cfg, repo, []), buildProjectDetail(cfg, repo, []), ]) expect(a).toBe(b) // same object identity ⇒ one probe served all three expect(b).toBe(c) }) it('re-probes on the next call — the entry is dropped once it settles', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g6-fresh') await fs.mkdir(repo, { recursive: true }) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo }) const cfg = makeCfg({ projectDirtyCheck: true }) const first = await buildProjectDetail(cfg, repo, []) expect(first!.dirtyCount).toBe(0) await fs.writeFile(path.join(repo, 'new.txt'), 'x\n') const second = await buildProjectDetail(cfg, repo, []) expect(second).not.toBe(first) // not a time-based cache expect(second!.dirtyCount).toBe(1) // and it sees the change immediately }) it('does not let two different repos share one probe', async () => { if (!(await gitAvailable())) return const one = path.join(tmp, 'g6-one') const two = path.join(tmp, 'g6-two') await fs.mkdir(one, { recursive: true }) await fs.mkdir(two, { recursive: true }) const cfg = makeCfg({}) const [a, b] = await Promise.all([ buildProjectDetail(cfg, one, []), buildProjectDetail(cfg, two, []), ]) expect(a!.path).toBe(one) expect(b!.path).toBe(two) }) }) // ── w6/G7 per-worktree state ────────────────────────────────────────────────── describe('buildWorktreeState (w6 G7)', { timeout: 30_000 }, () => { async function initRepo(cwd: string): Promise { await fs.mkdir(cwd, { recursive: true }) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd }) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd }) await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd }) } it('reads a LINKED worktree, where .git is a file rather than a directory', async () => { if (!(await gitAvailable())) return const repo = path.join(tmp, 'g7-main') await initRepo(repo) await fs.writeFile(path.join(repo, 'a.txt'), 'a\n') await execFileP('git', ['add', '.'], { cwd: repo }) await execFileP('git', ['commit', '-q', '-m', 'first'], { cwd: repo }) const wtPath = path.join(repo, '.claude', 'worktrees', 'feature') await execFileP('git', ['worktree', 'add', '-q', '-b', 'feature', wtPath], { cwd: repo }) // The pre-G1 reader assumed /.git was a directory and returned nothing here. const dotGit = await fs.stat(path.join(wtPath, '.git')) expect(dotGit.isDirectory()).toBe(false) const state = await buildWorktreeState(makeCfg({ projectDirtyCheck: true }), wtPath) expect(state!.path).toBe(wtPath) expect(state!.branch).toBe('feature') expect(state!.dirtyCount).toBe(0) // A fresh worktree branch tracks nothing — it must say so, not look settled. expect(state!.sync!.upstream).toBeUndefined() expect(state!.sync!.ahead).toBeUndefined() }) it('returns null for a directory that is not a git repo', async () => { const plain = path.join(tmp, 'g7-plain') await fs.mkdir(plain, { recursive: true }) expect(await buildWorktreeState(makeCfg({}), plain)).toBeNull() }) it('returns null for a relative path and for one that does not exist', async () => { expect(await buildWorktreeState(makeCfg({}), 'relative/path')).toBeNull() expect(await buildWorktreeState(makeCfg({}), path.join(tmp, 'g7-missing'))).toBeNull() }) })