Files
web-terminal/test/projects.test.ts
Yaojia Wang dba83559d3 fix(projects): close the last four git-panel design gaps
The low-severity leftovers from the panel audit, plus the tooling trap that made
reviewing this work harder than it needed to be.

.gitattributes (new) — `public/projects.ts diff`
  That file embeds literal NUL bytes on purpose: the namespace sentinels
  '\0active' / '\0other' are prefixed so they cannot collide with a real
  `First.Second` key. git therefore classified it as binary and printed
  "Binary files differ" for every diff, and `git diff --stat` reported
  "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively
  unreviewable. The `diff` attribute makes git diff it as text without setting
  `text`, so no line endings are normalised and the bytes on disk are untouched.
  (Same trap in the shell: grep prints only "Binary file ... matches", which
  reads as a no-match. The comment in the file says to use grep -a.)

Detached HEAD now shows its short sha
  The Head cell held the "detached HEAD" chip and nothing else: it said what was
  wrong and nothing about where you were. SyncState gains an optional `head`,
  populated from the .git/HEAD text readSyncState ALREADY reads for the detached
  test — so this costs no extra I/O and no extra git call. Guarded by a hex regex
  and rendered as textContent.

One geometry for the ghost button role
  The design defines two button roles. The outline one had drifted to three
  different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at
  12.5px/7px 15px/7px) and both used full-strength --text instead of the muted
  label. All four outline buttons are now declared once, together, at the
  design's geometry; the duplicate .proj-diff-toggle block further down the file
  is gone, since a second block at equal specificity is exactly how the role
  drifted in the first place.

Header branch chip joins the chip language
  .proj-branch was the only chip with no border and no mono face, and 1px tighter
  all round, so the detail header's branch did not match the branch chips in the
  worktree list right below it.

"Changes" heading over the View Diff toggle
  Every other block on the page is introduced by a section heading. Without one
  the toggle hung 4px off the bottom of the sync band and read as part of it.

Tests: 2167 unit (4 new — detached sha present/absent on both the server and the
panel) and 27 integration, twice.
2026-07-30 07:57:00 +02:00

806 lines
33 KiB
TypeScript
Raw Permalink 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()
// 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<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()
})
})