# 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 `<repo>/.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/<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:
  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/<b>'`.
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.
