# w6 — Project detail: the Git panel Design mock: [`docs/mockups/project-detail-git.html`](../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: - **`↑ ahead` is always trustworthy** (it only needs local refs) → never flagged. - **`↓ behind` is only as fresh as the last fetch** → flagged `stale` once `FETCH_HEAD` mtime exceeds `FETCH_STALE_MS` (1 h). - **Exactly one state may render green: `↑0 ↓0` AND 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 explicit `no upstream` chip. A new worktree branch has no upstream, so `ahead`/`behind` are `undefined` — 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. ```ts /** 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: ```ts 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 `ahead` rows".** `git log` is 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. > Use `git 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=0` in the child env, plus an explicit timeout — a fetch that hits a credential prompt otherwise hangs the request forever. (**Correction:** `push` already had this via `NO_PROMPT_ENV`; only `fetch` needed 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: - `readBranch` read `/.git/HEAD` directly, so it returned nothing inside any linked worktree (there `.git` is a file). Fixed by `resolveGitDirs()` — 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 when `git push` updates a remote-tracking ref, so a cached `ahead` would 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. two `git` spawns per tick). Plus pausing the timer while the document is hidden. - **G7 needed an unplanned prerequisite:** `ProjectSessionRef` carried no `cwd`, so sessions could not be attributed to a worktree. Added it, plus `countSessionsByWorktree` — which does DEEPEST-match, because `.claude/worktrees/` 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: 1. *Fixtures outgrew vitest's 5 s default.* The git ones spawn 6–12 sequential `git` processes; the real-server ones boot a server and a shell. Gave those `describe`s an explicit `{ timeout: 30_000 }` and the real-PTY waits a named `PTY_WAIT_MS`. 11 failures → ~1. 2. *The real-PTY E2E file cannot share the machine with the rest of the suite.* `test/integration/server.test.ts` passed 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, so `npm test` now runs **two passes** — `test:unit` (everything else, parallel) then `test:e2e` (that file alone). `vitest run` still runs everything at once for anyone who wants it. - Also bounded the `srv.close()` in H1's `finally`. 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. ## 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:** 1. **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. 2. **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. 3. **Pause while hidden** — `document.visibilityState`; a backgrounded tab or a phone with the screen off stops probing entirely. 4. 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` 1. RED: `buildProjectDetail` on a fixture repo with an upstream → `sync.ahead === n`, `sync.upstream === 'origin/'`. 2. RED: repo with **no upstream** → `sync.upstream === undefined` **and** `sync.ahead === undefined` (guards the green-path bug). 3. RED: detached HEAD → `sync.detached === true`, no branch, no ahead/behind. 4. RED: never fetched → `sync.lastFetchMs === undefined` (not `0`, not `Date.now()`). 5. RED: `dirtyCount` matches porcelain line count; `dirty` still boolean-true alongside. 6. RED: non-git dir → `sync === undefined`, and no `git` is 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-list` counts are still meaningful vs `@{u}`; don't special-case. - `@{u}` set but the remote branch was deleted upstream → `rev-list` fails → all fields `undefined` → renders `no 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 `lastFetchMs` unchanged so the `stale` flag 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.