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.
This commit is contained in:
Yaojia Wang
2026-07-29 17:12:00 +02:00
parent 553a00c32f
commit 8fe1f52e5d
16 changed files with 2005 additions and 30 deletions

View File

@@ -149,3 +149,65 @@ describe('mountGitLog', () => {
expect(host.querySelector('.proj-commit-row')).toBeNull()
})
})
/* ── w6/G4 unpushed rows + the upstream boundary ───────────────────────────── */
describe('renderGitLog — unpushed boundary (w6 G4)', () => {
function entry(hash: string, subject: string, unpushed?: boolean) {
return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) }
}
it('marks unpushed rows and draws the boundary exactly once, after the last one', () => {
const host = document.createElement('div')
renderGitLog(host, {
commits: [
entry('aaa1111', 'newest local', true),
entry('bbb2222', 'older local', true),
entry('ccc3333', 'already pushed'),
entry('ddd4444', 'also pushed'),
],
truncated: false,
upstream: 'origin/develop',
})
expect(host.querySelectorAll('.proj-commit-unpushed').length).toBe(2)
const boundaries = host.querySelectorAll('.proj-commit-boundary')
expect(boundaries.length).toBe(1)
expect(boundaries[0]!.textContent).toContain('origin/develop')
const rows = Array.from(host.querySelectorAll('.proj-commit-row, .proj-commit-boundary'))
expect(rows[2]!.classList.contains('proj-commit-boundary')).toBe(true)
})
it('draws no boundary when nothing is unpushed', () => {
const host = document.createElement('div')
renderGitLog(host, {
commits: [entry('aaa1111', 'pushed')],
truncated: false,
upstream: 'origin/main',
})
expect(host.querySelector('.proj-commit-boundary')).toBeNull()
expect(host.querySelector('.proj-commit-unpushed')).toBeNull()
})
it('draws no boundary without an upstream, even if rows claim to be unpushed', () => {
const host = document.createElement('div')
renderGitLog(host, { commits: [entry('aaa1111', 'local', true)], truncated: false })
expect(host.querySelector('.proj-commit-boundary')).toBeNull()
})
it('treats a non-true `unpushed` from the server as pushed', () => {
const norm = normalizeGitLog({
commits: [{ hash: 'aaa1111', at: 1, subject: 's', unpushed: 'yes' }],
truncated: false,
})
expect(norm!.commits[0]!.unpushed).toBeUndefined()
})
it('keeps a string upstream and drops a junk one', () => {
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 'origin/x' })!.upstream).toBe(
'origin/x',
)
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined()
})
})

View File

@@ -166,3 +166,97 @@ describe('getGitLog (real git repo)', () => {
await fs.rm(plain, { recursive: true, force: true })
})
})
// ── w6/G4 unpushed marking ─────────────────────────────────────────────────────
// The marks come from `rev-list`, never from row position — see markUnpushed.
describe('getGitLog — unpushed marking (w6 G4)', { timeout: 30_000 }, () => {
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
async function initRepo(cwd: string): Promise<void> {
await fs.mkdir(cwd, { recursive: true })
await git(cwd, ['init', '-q', '-b', 'main'])
await git(cwd, ['config', 'user.email', 't@t.local'])
await git(cwd, ['config', 'user.name', 'tester'])
await git(cwd, ['config', 'commit.gpgsign', 'false'])
}
async function commit(cwd: string, file: string, msg: string): Promise<void> {
await fs.writeFile(path.join(cwd, file), `${file}\n`, 'utf8')
await git(cwd, ['add', '.'])
await git(cwd, ['commit', '-q', '-m', msg])
}
it('marks nothing and names no upstream when the branch tracks nothing', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-noup-')))
await initRepo(repo)
await commit(repo, 'a.txt', 'one')
const res = await getGitLog(repo, { timeoutMs: 20_000 })
expect(res.commits.length).toBe(1)
expect(res.upstream).toBeUndefined()
expect(res.commits.every((c) => c.unpushed !== true)).toBe(true)
})
it('marks only the commits absent from the upstream and names it', async () => {
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-up-')))
const bare = path.join(root, 'bare.git')
await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare])
const repo = path.join(root, 'work')
await initRepo(repo)
await commit(repo, 'pushed.txt', 'pushed one')
await git(repo, ['remote', 'add', 'origin', bare])
await git(repo, ['push', '-q', '-u', 'origin', 'main'])
await commit(repo, 'local1.txt', 'local one')
await commit(repo, 'local2.txt', 'local two')
const res = await getGitLog(repo, { timeoutMs: 20_000 })
expect(res.upstream).toBe('origin/main')
const bySubject = new Map(res.commits.map((c) => [c.subject, c.unpushed === true]))
expect(bySubject.get('local two')).toBe(true)
expect(bySubject.get('local one')).toBe(true)
expect(bySubject.get('pushed one')).toBe(false)
})
it('marks an unpushed commit that sorts BELOW a pushed one (merged older branch)', async () => {
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-merge-')))
const bare = path.join(root, 'bare.git')
await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare])
const repo = path.join(root, 'work')
await initRepo(repo)
await commit(repo, 'base.txt', 'base')
await git(repo, ['remote', 'add', 'origin', bare])
await git(repo, ['push', '-q', '-u', 'origin', 'main'])
// A side branch whose commit is BACKDATED so date-ordered `git log` puts it
// below the newer pushed tip — the exact shape "first N rows" gets wrong.
await git(repo, ['checkout', '-q', '-b', 'side'])
const old = '2001-01-01T00:00:00'
await fs.writeFile(path.join(repo, 'old.txt'), 'old\n', 'utf8')
await git(repo, ['add', '.'])
await execFileAsync(
'git',
['commit', '-q', '-m', 'backdated side work', '--date', old],
{ cwd: repo, env: { ...process.env, GIT_COMMITTER_DATE: old } },
)
await git(repo, ['checkout', '-q', 'main'])
await git(repo, ['merge', '-q', '--no-ff', '-m', 'merge side', 'side'])
const res = await getGitLog(repo, { timeoutMs: 20_000 })
const side = res.commits.find((c) => c.subject === 'backdated side work')
expect(side).toBeDefined()
expect(side!.unpushed).toBe(true)
// And it really is out of order, so the test is testing what it claims.
const idxSide = res.commits.findIndex((c) => c.subject === 'backdated side work')
const idxBase = res.commits.findIndex((c) => c.subject === 'base')
expect(idxSide).toBeGreaterThan(idxBase)
expect(res.commits[idxBase]!.unpushed).not.toBe(true)
})
})

View File

@@ -25,6 +25,7 @@ import {
stageFiles,
commit,
push,
fetch as gitFetch,
} from '../../src/http/git-ops.js'
const execFileP = promisify(execFile)
@@ -148,7 +149,7 @@ describe('validateRepoFiles', () => {
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
describe('stageFiles', () => {
describe('stageFiles', { timeout: 30_000 }, () => {
it('stages the given files (git add) and unstages them (restore --staged)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
@@ -183,7 +184,7 @@ describe('stageFiles', () => {
// ── commit (real temp repos) ────────────────────────────────────────────────────
describe('commit', () => {
describe('commit', { timeout: 30_000 }, () => {
it('commits staged changes and returns a short SHA', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
@@ -256,7 +257,7 @@ describe('commit', () => {
// ── push (real bare-repo remote, no network) ────────────────────────────────────
describe('push', () => {
describe('push', { timeout: 30_000 }, () => {
it('first push sets upstream (-u) and the bare remote receives the ref', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
@@ -320,3 +321,99 @@ describe('push', () => {
expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 })
})
})
// ── w6/G2 fetch — read-only refresh of remote-tracking refs ────────────────────
// Fetch exists so the panel can stop lying about `behind`. It touches no working
// tree, no index and no branch: the only thing it may change is refs/remotes.
// Same reason as the G1 block: clone + push + fetch is well past a 5 s default.
describe('fetch (w6 G2)', { timeout: 30_000 }, () => {
it('refuses a non-git directory without spawning git', async () => {
if (!gitAvailable) return
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
const res = await gitFetch(plain, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(false)
expect(res.ok === false && res.status).toBe(404)
})
it('reports a plain message — never raw git stderr — when no remote exists', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(false)
expect(res.ok === false && res.status).toBe(400)
expect(res.ok === false && res.error).toBe('No remote configured.')
})
it('fetches from the sole remote and leaves HEAD and the working tree untouched', async () => {
if (!gitAvailable) return
const bare = await makeBareRemote()
const repo = await makeRepo()
const git = async (args: string[]): Promise<void> => {
await execFileP('git', args, { cwd: repo })
}
await git(['remote', 'add', 'origin', bare])
await git(['push', '-q', '-u', 'origin', 'main'])
const headBefore = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
const headAfter = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(headAfter).toBe(headBefore)
const status = (await execFileP('git', ['status', '--porcelain'], { cwd: repo })).stdout
expect(status.trim()).toBe('')
})
it('moves the remote-tracking ref so `behind` becomes true after fetching', async () => {
if (!gitAvailable) return
const bare = await makeBareRemote()
const repo = await makeRepo()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo })
// A second clone pushes one commit; `repo` cannot know until it fetches.
const other = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-other-')))
const clone = path.join(other, 'c')
await execFileP('git', ['clone', '-q', bare, 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 fs.writeFile(path.join(clone, 'remote.txt'), 'r\n', 'utf8')
await execFileP('git', ['add', '.'], { cwd: clone })
await execFileP('git', ['commit', '-q', '-m', 'from elsewhere'], { cwd: clone })
await execFileP('git', ['push', '-q'], { cwd: clone })
const countBefore = (
await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo })
).stdout.trim()
expect(countBefore).toBe('0') // the stale lie the panel would have shown
const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
const countAfter = (
await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo })
).stdout.trim()
expect(countAfter).toBe('1')
})
it('ignores any remote or refspec the caller tries to smuggle in', async () => {
if (!gitAvailable) return
const bare = await makeBareRemote()
const repo = await makeRepo()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo })
// The options type carries only timeoutMs; a caller passing extra keys must
// not be able to steer the command at a remote of their choosing.
const smuggled = {
timeoutMs: TIMEOUT_MS,
remote: 'file:///tmp/evil',
refspec: '+refs/heads/*:refs/heads/*',
} as unknown as { timeoutMs: number }
const res = await gitFetch(repo, smuggled)
expect(res.ok).toBe(true)
expect(res.ok === true && res.remote).toBe('origin')
})
})

View File

@@ -34,6 +34,9 @@ const {
normalizeProject,
makeProjectCard,
makeSyncChip,
makeSyncBand,
makeWorktreeRow,
countSessionsByWorktree,
renderProjectDetail,
groupProjects,
displayLabel,
@@ -705,3 +708,189 @@ describe('displayLabel', () => {
expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment')
})
})
/* ── w6/G3 makeSyncBand — the header's ambient git state ────────────────────── */
// The band's contract is mostly about what it must REFUSE to say: a stale or
// unknowable comparison must never be dressed up as "in sync".
describe('makeSyncBand (w6 G3)', () => {
const NOW = 1_700_000_000_000
const FRESH = NOW - 60_000 // a minute ago
const STALE = NOW - 26 * 60 * 60 * 1000 // 26 hours ago
function band(
sync: import('../src/types.js').SyncState | undefined,
opts: { dirtyCount?: number } = {},
): HTMLElement | null {
return makeSyncBand(sync, { nowMs: NOW, ...opts })
}
it('renders nothing for a non-git project', () => {
expect(band(undefined)).toBeNull()
})
it('shows the single green in-sync state only when even with a fresh fetch', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-ok')).not.toBeNull()
expect(b.querySelector('.proj-sync-stale')).toBeNull()
})
it('refuses to call a stale comparison in sync', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: STALE })!
expect(b.querySelector('.proj-sync-ok')).toBeNull()
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
})
it('treats a never-fetched repo as stale, not as in sync', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0 })!
expect(b.querySelector('.proj-sync-ok')).toBeNull()
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
})
it('never flags ahead as stale — it does not depend on a fetch', () => {
const b = band({ upstream: 'origin/main', ahead: 9, behind: 0, lastFetchMs: STALE })!
const ahead = b.querySelector('.proj-sync-ahead')!
expect(ahead.textContent).toContain('9')
expect(ahead.classList.contains('proj-sync-stale')).toBe(false)
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-stale')).toBe(true)
})
it('says "no upstream" instead of inventing counts, and is not green', () => {
const b = band({ ahead: undefined, behind: undefined, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-noupstream')).not.toBeNull()
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
expect(b.querySelector('.proj-sync-behind')).toBeNull()
expect(b.querySelector('.proj-sync-ok')).toBeNull()
})
it('flags a detached HEAD, drops the counts and disables fetching', () => {
const b = band({ detached: true })!
expect(b.querySelector('.proj-sync-detached')).not.toBeNull()
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
expect(b.querySelector('.proj-sync-ok')).toBeNull()
const btn = b.querySelector('.proj-sync-fetch') as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('names the upstream so the arrows are not floating numbers', () => {
const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })!
expect(b.textContent).toContain('origin/develop')
})
it('counts dirty files instead of showing a bare dot', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
dirtyCount: 3,
})!
expect(b.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
})
it('omits the dirty chip entirely for a clean tree', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
dirtyCount: 0,
})!
expect(b.querySelector('.proj-sync-dirty')).toBeNull()
})
})
/* ── w6/G5 worktree panel ───────────────────────────────────────────────────── */
describe('makeWorktreeRow — per-worktree state (w6 G5)', () => {
const wt = (over: Partial<import('../src/types.js').WorktreeInfo> = {}) => ({
path: '/p/repo',
isMain: true,
isCurrent: true,
branch: 'develop',
...over,
})
it('shows nothing extra for a worktree whose state was not probed', () => {
const row = makeWorktreeRow(wt({ isCurrent: false, isMain: false }))
expect(row.querySelector('.proj-sync-chip')).toBeNull()
})
it('says "no upstream" for a fresh worktree branch instead of looking settled', () => {
const row = makeWorktreeRow(wt(), undefined, { sync: { ahead: undefined } })
expect(row.querySelector('.proj-sync-noupstream')).not.toBeNull()
expect(row.querySelector('.proj-sync-ahead')).toBeNull()
})
it('shows the unpushed count for a tracking worktree', () => {
const row = makeWorktreeRow(wt(), undefined, {
sync: { upstream: 'origin/develop', ahead: 9, behind: 0 },
dirtyCount: 3,
})
expect(row.querySelector('.proj-sync-ahead')!.textContent).toContain('9')
expect(row.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
})
it('omits the dirty chip on a clean worktree', () => {
const row = makeWorktreeRow(wt(), undefined, {
sync: { upstream: 'origin/develop', ahead: 0, behind: 0 },
dirtyCount: 0,
})
expect(row.querySelector('.proj-sync-dirty')).toBeNull()
})
})
/* ── w6/G7 session attribution across worktrees ─────────────────────────────── */
// `.claude/worktrees/<name>` lives INSIDE the main checkout, so this is exactly
// the case naive prefix matching double-counts.
describe('countSessionsByWorktree (w6 G7)', () => {
const MAIN = '/Users/x/repo'
const WT = '/Users/x/repo/.claude/worktrees/feature'
const wts = [
{ path: MAIN, isMain: true, isCurrent: true, branch: 'develop' },
{ path: WT, isMain: false, isCurrent: false, branch: 'worktree-feature' },
]
function sess(id: string, cwd: string | undefined, exited = false) {
return { id, status: 'idle' as const, clientCount: 1, createdAt: 0, exited, ...(cwd !== undefined ? { cwd } : {}) }
}
it('attributes a nested worktree session to the worktree, not to the parent', () => {
const counts = countSessionsByWorktree([sess('a', `${WT}/src`)], wts)
expect(counts.get(WT)).toBe(1)
expect(counts.get(MAIN)).toBe(0)
})
it('counts a session in the main checkout against the main worktree', () => {
const counts = countSessionsByWorktree([sess('a', `${MAIN}/src`)], wts)
expect(counts.get(MAIN)).toBe(1)
expect(counts.get(WT)).toBe(0)
})
it('ignores exited sessions and sessions with no cwd', () => {
const counts = countSessionsByWorktree(
[sess('a', `${MAIN}/src`, true), sess('b', undefined)],
wts,
)
expect(counts.get(MAIN)).toBe(0)
})
it('does not treat a sibling with a shared prefix as inside', () => {
const counts = countSessionsByWorktree([sess('a', '/Users/x/repo-old/src')], wts)
expect(counts.get(MAIN)).toBe(0)
})
it('gives every worktree an entry so a row can render 0 without a lookup miss', () => {
const counts = countSessionsByWorktree([], wts)
expect(counts.get(MAIN)).toBe(0)
expect(counts.get(WT)).toBe(0)
})
})
describe('makeWorktreeRow — session chip (w6 G7)', () => {
const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' }
it('shows the running-session count', () => {
const row = makeWorktreeRow(wt, undefined, { sessionCount: 2 })
expect(row.querySelector('.proj-wt-sessions')!.textContent).toContain('2')
})
it('omits the chip when nothing runs there', () => {
const row = makeWorktreeRow(wt, undefined, { sessionCount: 0 })
expect(row.querySelector('.proj-wt-sessions')).toBeNull()
})
})

View File

@@ -14,9 +14,13 @@ vi.mock('../src/http/history.js', () => ({
listSessions: (...a: unknown[]) => mockListSessions(...a),
}))
const { parseGitHead, buildProjects, buildProjectDetail, _clearProjectCache } = await import(
'../src/http/projects.js'
)
const {
parseGitHead,
buildProjects,
buildProjectDetail,
buildWorktreeState,
_clearProjectCache,
} = await import('../src/http/projects.js')
const execFileP = promisify(execFile)
@@ -90,7 +94,7 @@ afterEach(async () => {
// ── parseGitHead (pure) ──────────────────────────────────────────────────────────
describe('parseGitHead', () => {
describe('parseGitHead', { timeout: 30_000 }, () => {
it('extracts a simple branch name', () => {
expect(parseGitHead('ref: refs/heads/main\n')).toBe('main')
})
@@ -112,7 +116,7 @@ describe('parseGitHead', () => {
// ── buildProjects: discovery (real temp dir) ─────────────────────────────────────
describe('buildProjects — discovery', () => {
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
@@ -184,7 +188,7 @@ describe('buildProjects — discovery', () => {
// ── buildProjects: live-session merge ────────────────────────────────────────────
describe('buildProjects — 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')
@@ -208,6 +212,7 @@ describe('buildProjects — session merge', () => {
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')
})
@@ -257,7 +262,7 @@ describe('buildProjects — session merge', () => {
// ── buildProjects: history merge + sorting ───────────────────────────────────────
describe('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')
@@ -335,7 +340,7 @@ describe('buildProjects — history merge & sorting', () => {
// ── buildProjects: in-flight promise deduplication (Finding 4) ─────────────────────
describe('buildProjects — concurrent cache-miss deduplication', () => {
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')
@@ -370,7 +375,7 @@ describe('buildProjects — concurrent cache-miss deduplication', () => {
// ── buildProjects: dirty check (real git, when available) ─────────────────────────
describe('buildProjects — dirty check', () => {
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')
@@ -404,7 +409,7 @@ describe('buildProjects — dirty check', () => {
// ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ──────────────
describe('buildProjects — sync fields (W3 a)', () => {
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 })
@@ -482,7 +487,7 @@ describe('buildProjects — sync fields (W3 a)', () => {
// ── buildProjectDetail ───────────────────────────────────────────────────────────
describe('buildProjectDetail', () => {
describe('buildProjectDetail', { timeout: 30_000 }, () => {
it('returns null for a relative path', async () => {
expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull()
})
@@ -566,3 +571,220 @@ describe('buildProjectDetail', () => {
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()
})
})