Files
web-terminal/test/projects.test.ts
Yaojia Wang 8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
    "I checked, ignore this"; getting it wrong is lying to the user.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: the remote is derived
    server-side and no remote or refspec is ever read from the body, so a
    client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00

791 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HistorySession[]> => [])
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>): 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>): 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<void> {
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<boolean> {
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<void> {
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<void> {
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 612 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<void> {
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<void> {
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()
})
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<void> {
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 <repo>/.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()
})
})