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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user