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

@@ -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()
})
})