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.
13 KiB
w6 — Project detail: the Git panel
Design mock: docs/mockups/project-detail-git.html
Make "do I have commits I haven't pushed / which worktree am I in" ambient on the project detail page — visible without typing a git command. Read-mostly: the panel's job is to tell you what happened, not to replace the editor in handling it.
Design rule that drives everything (read first)
ahead/behind compare against @{u} — a locally cached remote ref. Without a
fetch it never changes. Right now this repo reports ↓0 while .git/FETCH_HEAD is
19 days old: the number is a lie, and a naive panel would render it as a confident
"in sync".
Therefore:
↑ aheadis always trustworthy (it only needs local refs) → never flagged.↓ behindis only as fresh as the last fetch → flaggedstaleonceFETCH_HEADmtime exceedsFETCH_STALE_MS(1 h).- Exactly one state may render green:
↑0 ↓0AND fetch within the threshold. Everything else is neutral or warning. Green here means "I checked, ignore this" — getting it wrong is lying to the user. - No upstream ⇒ no
↑↓at all, rendered as an explicitno upstreamchip. A new worktree branch has no upstream, soahead/behindareundefined— never let "no number" fall through to the green path. This is the single easiest bug to ship.
Contract
Types (src/types.ts)
New, and additive only — android/api-client/.../Projects.kt and
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift decode these shapes.
Never rename or remove an existing field; every new field is optional.
/** G1: upstream sync state for one repo/worktree. Every field degrades
* independently (no upstream / detached HEAD / empty repo / never fetched). */
export interface SyncState {
upstream?: string; // "origin/develop"; undefined ⇒ no upstream configured
ahead?: number; // commits on HEAD not on @{u}
behind?: number; // commits on @{u} not on HEAD — only as fresh as lastFetchMs
lastFetchMs?: number; // .git/FETCH_HEAD mtime; undefined ⇒ never fetched
detached?: boolean; // HEAD is detached ⇒ no branch, no ahead/behind
}
ProjectDetail gains:
sync?: SyncState; // git repos only; undefined for non-git dirs
dirtyCount?: number; // `git status --porcelain` line count (gated by projectDirtyCheck)
dirty?: boolean stays (mobile clients read it). dirtyCount is the additive refinement.
Log response (src/http/git-log.ts)
CommitLogEntry gains unpushed?: boolean; GitLogResult gains upstream?: string.
Marking happens server-side, not on the client. (The first draft of this plan
shipped the SHA set to the client and matched there — wrong split: %h is
abbreviated while rev-list yields full SHAs, and the two can disagree on width,
so the matching rule belongs next to the data that defines it.) The server matches
by prefix, bounded by GIT_LOG_MAX × UNPUSHED_MAX comparisons.
Why a second spawn and not "the first
aheadrows".git logis date-ordered; an unpushed merge can pull in commits with older dates that interleave below pushed ones. "First N are unpushed" is wrong in exactly the repo shape we use (merge-per-worktree), and it fails in the dangerous direction — labelling an unpushed commit as pushed. Usegit rev-list @{u}..HEAD --max-count=200.
The client draws the boundary only when upstream is present, exactly once, after
the last marked row; unpushed is honoured only when strictly true.
New route (src/server.ts + src/http/git-ops.ts)
POST /projects/git/fetch — { path } → { ok, lastFetchMs }.
- Same three-prong path check as the other git routes (absolute + dir + has
.git, SEC-H7). - Server-derived target, exactly like
push: never accept a remote or refspec from the client.git fetch --no-tags --quiet(current branch's remote, else the sole remote; ≥2 remotes without an upstream → 400). GIT_TERMINAL_PROMPT=0in the child env, plus an explicit timeout — a fetch that hits a credential prompt otherwise hangs the request forever. (Correction:pushalready had this viaNO_PROMPT_ENV; onlyfetchneeded wiring.)- Fetch only updates remote-tracking refs: no working tree, no index, no merge. It stays inside the "no destructive ops" line.
Out of scope (unchanged)
No reset, no branch checkout, no clean, no rebase, no force-push.
stage / commit / push stay exactly as they are in diff.ts.
Tasks
| ID | What | Owns |
|---|---|---|
| G1 | SyncState + dirtyCount; wire readSync into buildProjectDetail; add readUpstream / readLastFetch |
src/types.ts, src/http/projects.ts |
| G2 | POST /projects/git/fetch + fetch() in the git write engine |
src/http/git-ops.ts, src/server.ts |
| G3 | Sync band in the detail header (replaces the bare ●) + staleness rule |
public/projects.ts, public/style.css |
| G4 | Unpushed marking + origin/x boundary in the commit list |
src/http/git-log.ts, public/git-log.ts, public/style.css |
| G5 | Worktree panel: always "Worktrees", per-row branch/path/dirty/↑↓ | public/projects.ts, public/style.css |
| G6 | Cost controls: per-repo dedupe, .git mtime gate, pause while hidden |
src/http/projects.ts, public/projects.ts |
| G7 | Per-worktree dirty/↑↓ — lazy, only for an expanded row | src/http/worktrees.ts, public/projects.ts |
Order: G1 → G2 → (G3, G4) → G5 → G6 → G7. G3 needs G1's data and G2's button target. G7 is the most expensive and least essential — it ships last or not at all.
Status (2026-07-29) — G1–G7 all shipped
tsc + build clean; 46 new tests, all green (2128 → 2174), and npm test is
green end to end.
Correction to an earlier note in this file: the three residual
test/integration/server.test.ts failures were first written off as "environment,
not code" because of their [needs real PTY (sandbox-off)] labels. That was wrong
— PTY_AVAILABLE is true here, so those cases were running, not skipping, and
failing on timing. See "Test-suite flakiness" below.
Deviations worth knowing:
readBranchread<repo>/.git/HEADdirectly, so it returned nothing inside any linked worktree (there.gitis a file). Fixed byresolveGitDirs()— a pre-existing bug this feature would otherwise have inherited.- G6 dropped the
.git-mtime cache from the plan. A fingerprint over HEAD/index/reflog does not move whengit pushupdates a remote-tracking ref, so a cachedaheadwould keep claiming "9 to push" right after a successful push — the exact confident lie this feature exists to prevent. Replaced with two measures that cannot go stale: in-flight coalescing (N devices watching one repo cost one probe, entry dropped the instant it settles) and skip-render when the payload is byte-identical (which also stops the 5 s re-mount of the commit log, i.e. twogitspawns per tick). Plus pausing the timer while the document is hidden. - G7 needed an unplanned prerequisite:
ProjectSessionRefcarried nocwd, so sessions could not be attributed to a worktree. Added it, pluscountSessionsByWorktree— which does DEEPEST-match, because.claude/worktrees/<name>lives inside the main checkout and prefix matching would count every worktree session against the parent repo too. - G7 probes each non-current worktree once per opened project rather than on-expand: with the state cached client-side and skipped on unchanged ticks, the cost is the same and the information is there without a click.
- Test-suite flakiness (pre-existing, now fixed). Two independent causes:
- Fixtures outgrew vitest's 5 s default. The git ones spawn 6–12 sequential
gitprocesses; the real-server ones boot a server and a shell. Gave thosedescribes an explicit{ timeout: 30_000 }and the real-PTY waits a namedPTY_WAIT_MS. 11 failures → ~1. - The real-PTY E2E file cannot share the machine with the rest of the suite.
test/integration/server.test.tspassed alone (27/27, repeatedly) and flaked in the full run: it asserts on real prompt output within seconds, which is not achievable while ~8 workers saturate the box. Raising the numbers further only moved the flake around, sonpm testnow runs two passes —test:unit(everything else, parallel) thentest:e2e(that file alone).vitest runstill runs everything at once for anyone who wants it.
- Also bounded the
srv.close()in H1'sfinally. Unbounded, it swallowed the body's real error and reported a bare timeout instead — the diagnosis above only became possible after making the failure legible.
- Fixtures outgrew vitest's 5 s default. The git ones spawn 6–12 sequential
Cost control (G6 — the real risk)
Only two reads are free: branch (.git/HEAD) and last-fetch (FETCH_HEAD mtime).
Everything else spawns git, on the same machine that is running Claude Code and builds.
As shipped:
- In-flight coalescing by repo path — concurrent probes of one repo share a single promise, dropped as soon as it settles. N devices ⇒ one probe, and nothing is cached across time.
- Skip the render when the payload is unchanged — the 5 s tick used to rebuild
the whole subtree, re-mounting the commit log and re-running
/projects/log(two more spawns) to redraw identical rows. - Pause while hidden —
document.visibilityState; a backgrounded tab or a phone with the screen off stops probing entirely. - Per-worktree state (G7) is probed once per opened project, never per tick.
Rejected: the .git mtime gate from the original plan. A fingerprint over
HEAD / index / reflog does not change when push moves a remote-tracking ref, so
the cached ahead would still read "9 to push" immediately after pushing. Cheaper
than coalescing, and wrong in precisely the way this whole feature is built to
avoid.
TDD steps
Repo style: pure-unit in test/*.test.ts, real-server in test/integration/*.
G1 — test/projects.test.ts
- RED:
buildProjectDetailon a fixture repo with an upstream →sync.ahead === n,sync.upstream === 'origin/<b>'. - RED: repo with no upstream →
sync.upstream === undefinedandsync.ahead === undefined(guards the green-path bug). - RED: detached HEAD →
sync.detached === true, no branch, no ahead/behind. - RED: never fetched →
sync.lastFetchMs === undefined(not0, notDate.now()). - RED:
dirtyCountmatches porcelain line count;dirtystill boolean-true alongside. - RED: non-git dir →
sync === undefined, and nogitis spawned.
G2 — test/http/git-ops.test.ts + test/integration/projects-endpoint.test.ts
7. RED: fetch rejects a path outside the allowed roots (SEC-H7) → 400, no spawn.
8. RED: client-supplied remote / refspec in the body is ignored, not forwarded.
9. RED: ≥2 remotes and no upstream → 400 with a plain message (never raw stderr, SEC-M10).
10. RED: success → lastFetchMs advances.
G3 — test/projects-panel.test.ts
11. RED: ↑0 ↓0 + fresh fetch → the single green "in sync" chip.
12. RED: ↑0 ↓0 + lastFetchMs older than FETCH_STALE_MS → not green; ↓ carries stale.
13. RED: upstream === undefined → no upstream chip, no ↑↓, not green.
14. RED: detached === true → short SHA, no branch chip, fetch button disabled.
15. RED: dirtyCount === 3 → ● 3, not a bare dot.
G4 — test/git-log.test.ts
16. RED: entries whose SHA is in unpushed get the marker; the boundary renders exactly once, after the last of them.
17. RED: unpushed empty → no boundary, no markers.
18. RED: upstream === undefined → no boundary at all (nothing to draw it against).
19. RED: an unpushed commit that sorts below a pushed one (merge of an older branch) is still marked — the Set is authoritative, not the row index.
G5 — test/projects-panel.test.ts
20. RED: 1 worktree still renders the section (title always "Worktrees"), with its path and session count.
21. RED: a worktree with no upstream shows no upstream, never a green chip.
G6 — test/projects.test.ts
22. RED: two buildProjectDetail calls for the same repo with an unchanged .git mtime spawn git once.
Edge cases
- Empty repo (no commits): no
↑↓, no commit list, no boundary; not green. - Shallow clone:
rev-listcounts are still meaningful vs@{u}; don't special-case. @{u}set but the remote branch was deleted upstream →rev-listfails → all fieldsundefined→ rendersno upstream. Acceptable; do not invent a number.- Fetch in flight: disable the button, don't queue a second one.
- Fetch failing (offline / auth) → surface a short message, leave
lastFetchMsunchanged so thestaleflag stays on. Never silently mark it fresh.
Security
- New route reuses the existing gate: auth token check + the three-prong path check.
- No client-controlled remote/refspec, no shell,
--terminators,GIT_TERMINAL_PROMPT=0. - Errors are plain sentences, never raw git stderr (SEC-M10).
- Adds no new capability — anyone who can reach the port already has a shell.