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.
791 lines
33 KiB
TypeScript
791 lines
33 KiB
TypeScript
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 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<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()
|
||
})
|
||
})
|