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

@@ -24,6 +24,35 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🌿 [x] w6 项目详情 Git 面板 — G1G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`)
- **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`
- **贯穿全篇的一条硬规矩**: `ahead/behind` 比的是**本地缓存**的 `@{u}`,不 fetch 永远不动。**本仓库当时就是活教材** —— `↑9` 是真的,而 `↓0` 假的:`FETCH_HEAD` 停在 7-10,已 19 天。因此:`ahead` 永远可信(只用本地 ref,绝不标记陈旧);`behind` 超过 `FETCH_STALE_MS`(1h)就打 `stale`;**只有 `↑0 ↓0` 且刚 fetch 过这一个状态允许显示绿色**;`upstream === undefined`(新 worktree 分支的常态)必须显式说 `no upstream`,绝不能因为"没数字"掉进绿色分支。
- **G1 后端同步状态** (`src/types.ts`, `src/http/projects.ts`): 新增 `SyncState`(upstream/ahead/behind/lastFetchMs/detached)+ `ProjectDetail.sync``dirtyCount`。**全部为可选新增字段** —— Android/iOS 客户端解码同一批 JSON,不改名不删字段。`readSync` 助手本已存在(列表页在用),`buildProjectDetail` 从来没接;新增 `readUpstream``readLastFetchMs`(读 `FETCH_HEAD` mtime,不 spawn)、`readDirtyCount`(一次 porcelain 同时出布尔和计数)。
- **顺手修掉一个既有 bug**: `readBranch` 直接读 `<repo>/.git/HEAD`,而**链接 worktree 的 `.git` 是文件不是目录**,所以在任何 worktree 里都读不到分支。新增 `resolveGitDirs()` 同时解析 per-worktree `gitDir`(放 HEAD)和共享 `commonDir`(放 FETCH_HEAD)。
- **G2 fetch 路由** (`src/http/git-ops.ts`, `src/server.ts`): `POST /projects/git/fetch` + `fetch()` 引擎。纪律照抄 push:**remote 一律服务端推导**(当前分支 upstream,否则唯一 remote;0 个 → 400,≥2 个无 upstream → 409),**绝不接受 body 里的 remote/refspec**,错误信息永远是白话不是 raw stderr(SEC-M10)。只动 `refs/remotes/*`,不碰工作树/索引/分支,不是 pull。**自己的限流桶**,不与 push 抢额度。失败时**不更新** `lastFetchMs`,让 UI 继续显示 stale 而不是假装刷新过。
- **计划里的一条写错了**: 原计划说"顺手给 push 加 `GIT_TERMINAL_PROMPT=0`" —— push 早就有 `NO_PROMPT_ENV` 了,无需改。
- **G3 同步状态条** (`public/projects.ts` `makeSyncBand`, `public/style.css`): 取代光秃秃的 `●`。upstream 名 + ↑n + ↓n + stale 标记 + dirty 计数 + Fetch 按钮(detached 时禁用)。`nowMs` 可注入以便测试。in-flight 期间 `fetching` 标志挡住第二次点击。
- **G4 推送分界线** (`src/http/git-log.ts`, `public/git-log.ts`): `CommitLogEntry.unpushed` + `GitLogResult.upstream`,**服务端**从 `git rev-list @{u}..HEAD` 打标。**刻意不用"前 N 行就是未推送"这个便宜做法** —— `git log` 按日期排序,合入一个较老的分支会把未推送的 commit 插到已推送的**下面**,那样会把未推送的说成已推送(和 ↓0 撒谎是同一类错误)。短 hash(`%h`)与 rev-list 的全 SHA 用**前缀匹配**,避免缩写长度不一致。前端只在有 upstream 时画一次分界线。
- **G5 worktree 面板** (`public/projects.ts`): 标题从 `worktrees.length > 1 ? 'Worktrees' : 'Branch'` 改成恒为 `Worktrees (n)`;当前 worktree 那行复用 band 的芯片词汇显示 `↑n` / `● n` / `no upstream`。**其余行保持无状态** —— 不知道就什么都不显示,不猜。
- **验证**: `tsc --noEmit` 干净、`npm run build` 干净。新增 **33 个测试全过**(2128 → 2161)。分模块:`projects.test.ts` 43、`git-ops.test.ts` 29、`projects-panel.test.ts` 90、`git-log.test.ts`(前端)17 +(后端)17。
- **全量 `npm test` 有 9 个失败,但全部是既有测试,没有一个是 w6 的**。在 develop HEAD 上跑基线同样失败(5 个)。根因已定位:**vitest 默认 `testTimeout` 是 5s**,而这些 fixture 要串行 spawn 612 个 `git`(init/config/commit/clone/push),全量并行时超预算 → `Test timed out in 5000ms`。我给**自己新增的** describe 块显式加了 `{ timeout: 30_000 }`;**既有测试没动**(改共享测试配置超出本次范围)。
- **G6 成本控制** (`src/http/projects.ts`, `public/projects.ts`): **否决了计划里的 `.git` mtime 缓存**。用 HEAD/index/reflog 做指纹时,`git push` 更新 remote-tracking ref **不会**改动其中任何一个 —— 缓存的 `ahead` 会在推送成功后继续宣称"还有 9 个要推",正是这个功能存在的意义所要避免的那种自信的谎话。改用三条**不可能变陈旧**的措施:
1. **并发合流**:同一仓库的并发探测共用一个 promise,settle 即删。N 个设备看同一个项目 = 一次探测,且**不跨时间缓存任何东西**。
2. **数据未变则跳过重渲染**:原来每 5s 重建整棵子树,顺带**重新挂载 commit log**(又是两次 `git` spawn)去重画一模一样的行。
3. **页面隐藏时暂停**:后台标签页 / 手机熄屏时完全停止探测。
- **G7 逐 worktree 状态** (`src/http/projects.ts`, `src/server.ts`, `public/projects.ts`): 新增 `buildWorktreeState` + `GET /projects/worktree/state`(比 `/projects/detail` 窄:一行只要 branch/sync/dirty,给 N 行都跑一遍列 worktree + 读 CLAUDE.md 是在为不渲染的数据花 spawn)。
- **前置缺口已补**:`ProjectSessionRef` 加了 `cwd`。配套的 `countSessionsByWorktree` 用**最深匹配** —— `.claude/worktrees/<name>` 就在主 checkout **里面**,前缀匹配会把每个 worktree 的 session 同时算到父仓库头上。
- 偏离计划:改成**每次打开项目探一次**而不是"展开某行才探"。状态在客户端缓存、且被"未变则跳过"挡住,成本一样,但信息不用点就有。
- **验证**: `tsc --noEmit` 干净、`npm run build` 干净。新增 **46 个测试全过**(2128 → 2174)。
- **顺手修掉了既有测试的抖动,`npm test` 现在端到端全绿**(单元趟 78 文件 / 2147,e2e 趟 27,连续多次)。两个独立原因:
1. **fixture 撑破了 vitest 的 5s 默认值**。git 类的要串行 spawn 612 个 `git`,真实服务器类的要起 server + shell。给相关 `describe` 加显式 `{ timeout: 30_000 }`,真实 PTY 的等待抽成具名的 `PTY_WAIT_MS`。11 个失败 → 约 1 个。
2. **真实 PTY 的 E2E 文件不能和整套共用机器**`test/integration/server.test.ts` 单独跑 27/27 反复稳定通过,一进全量并行就抖 —— 它要在几秒内断言真实提示符输出,而 8 个 worker 正把机器榨干,这个预算根本不成立。继续调大数字只是让抖动换个地方出现,所以 `npm test` 改成**两趟**:`test:unit`(其余全部,并行)然后 `test:e2e`(该文件独占)。`vitest run` 仍然一次跑全部。
- 另外给 H1 的 `finally` 里那个 `await srv.close()` 加了上限。它原本无上限,会把函数体里的真实错误吞掉、只报一个光秃秃的超时 —— 上面这个诊断是先把失败变得可读之后才做得出来的。
- **更正**:上一条日志把这 3 个失败写成"沙箱环境限制,与代码无关",**那是错的**。`PTY_AVAILABLE` 在本机是 `true`,这些用例根本没跳过,是真的在跑并且卡在时间预算上。
- **遗留 / 待办**: 无(G1G7 全部完成)。设计稿里画的每样东西都落地了。
- **commit**: (未提交,停在 worktree `project-detail-git-mockup` / 分支 `worktree-project-detail-git-mockup`)
### 🧹 [x] 补完上一条留下的三个遗留(2026-07-29,紧接死锁修复之后)
- **① `.gitignore` 把源码吞了**: `agent/src/dist/buildBinary.ts` 是打包配置(源码),却被通配的 `dist/` 规则排除,**从未提交**——全新 clone 既过不了 `agent/src/index.ts` 的类型检查,也导入不了已提交的 `agent/test/buildBinary.test.ts`。修:**先解除目录排除**(`!agent/src/dist/` + `!agent/src/dist/**`)再提交文件 —— git **不会进入被排除的目录**,所以只否定文件名是无效的(实测确认)。反向验证 `agent/dist/``dist/``public/build/``desktop/dist-app/` 仍被忽略。

View File

@@ -0,0 +1,439 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project detail — Git 面板设计稿</title>
<style>
/* ── tokens: 沿用 project-manager-final.html 的 amber 主题,不另起炉灶 ── */
:root{
--bg:#100f0d; --panel:#181613; --card:#1b1916; --sunk:#0c0b09;
--border:rgba(255,255,255,.07); --border-strong:rgba(255,255,255,.14);
--text:#ece9e3; --dim:#a8a299; --faint:#6f6a61;
--accent:#e3a64a; --accent-2:#c9892f; --soft:rgba(227,166,74,.14); --line:rgba(227,166,74,.36);
--ink:#1a1305;
/* 语义色 —— 与 accent 分开,全部取自项目既有调色板 */
--dirty:#e0975a; /* 未提交 */
--warn:#cf6b4f; /* 可能在说谎 / 无 upstream(取自 clay 主题) */
--ok:#7d9b76; /* 已同步 */
--glow:rgba(227,166,74,.10);
--radius:9px; --radius-lg:14px;
--ui:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
/* 文档外壳(随浏览器主题走);内部 mock 画布恒为暗色 —— 它是一张产品截图 */
--doc-bg:#f6f4f0; --doc-text:#221f1a; --doc-dim:#6b655c; --doc-rule:rgba(0,0,0,.10);
--doc-card:#fffefb;
}
@media (prefers-color-scheme: dark){
:root{ --doc-bg:#0b0a09; --doc-text:#e8e5df; --doc-dim:#98928a;
--doc-rule:rgba(255,255,255,.10); --doc-card:#141310; }
}
:root[data-theme="dark"]{ --doc-bg:#0b0a09; --doc-text:#e8e5df; --doc-dim:#98928a;
--doc-rule:rgba(255,255,255,.10); --doc-card:#141310; }
:root[data-theme="light"]{ --doc-bg:#f6f4f0; --doc-text:#221f1a; --doc-dim:#6b655c;
--doc-rule:rgba(0,0,0,.10); --doc-card:#fffefb; }
*{box-sizing:border-box}
body{margin:0;background:var(--doc-bg);color:var(--doc-text);font-family:var(--ui);
-webkit-font-smoothing:antialiased;line-height:1.55}
.wrap{max-width:1000px;margin:0 auto;padding:56px 24px 96px;display:flex;flex-direction:column;gap:52px}
/* ── 文档头 ── */
.doc-head{display:flex;flex-direction:column;gap:10px}
.eyebrow{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--doc-dim);
font-family:var(--mono)}
h1{font-size:30px;line-height:1.2;margin:0;font-weight:640;text-wrap:balance;letter-spacing:-.01em}
.lede{margin:0;max-width:64ch;color:var(--doc-dim);font-size:15px}
.facts{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
.fact{font-family:var(--mono);font-size:12px;color:var(--doc-dim);
border:1px solid var(--doc-rule);border-radius:999px;padding:4px 11px;
font-variant-numeric:tabular-nums}
.fact b{color:var(--doc-text);font-weight:600}
/* ── 每个提案块 ── */
.block{display:flex;flex-direction:column;gap:14px}
.block-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;
border-bottom:1px solid var(--doc-rule);padding-bottom:10px}
.ord{font-family:var(--mono);font-size:12px;color:var(--accent-2);letter-spacing:.08em;
border:1px solid var(--line);border-radius:5px;padding:2px 7px;flex:none}
h2{font-size:19px;margin:0;font-weight:620;letter-spacing:-.005em}
.tag{font-size:11px;font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;
border-radius:999px;padding:3px 9px;flex:none}
.tag.new{color:var(--accent);background:var(--soft);border:1px solid var(--line)}
.tag.wire{color:var(--ok);background:rgba(125,155,118,.13);border:1px solid rgba(125,155,118,.34)}
.note{margin:0;color:var(--doc-dim);font-size:14.5px;max-width:70ch}
.note code{font-family:var(--mono);font-size:12.5px;background:var(--doc-rule);
padding:1px 5px;border-radius:4px;color:var(--doc-text)}
/* ── mock 画布:恒暗,模拟真实 app ── */
.mock{background:radial-gradient(760px 300px at 78% -30%, var(--glow), transparent 62%),var(--bg);
border:1px solid var(--border-strong);border-radius:var(--radius-lg);
padding:22px;color:var(--text);font-size:14px;overflow-x:auto;
box-shadow:0 14px 40px rgba(0,0,0,.34)}
.mock *{font-variant-numeric:tabular-nums}
.label{font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--faint);
margin-bottom:9px;display:flex;align-items:center;gap:10px}
.label .count{color:var(--dim);letter-spacing:.04em}
/* ── ① 同步状态条 ── */
.proj-line{display:flex;align-items:center;gap:11px;flex-wrap:wrap;margin-bottom:3px}
.proj-name{font-size:21px;font-weight:650;letter-spacing:-.01em}
.chip{font-family:var(--mono);font-size:11.5px;border-radius:6px;padding:3px 9px;
display:inline-flex;align-items:center;gap:6px;flex:none;white-space:nowrap}
.chip.branch{background:var(--soft);color:var(--accent);border:1px solid var(--line)}
.chip.dirty{background:rgba(224,151,90,.13);color:var(--dirty);border:1px solid rgba(224,151,90,.32)}
.chip.ok{background:rgba(125,155,118,.12);color:var(--ok);border:1px solid rgba(125,155,118,.30)}
.chip.warn{background:rgba(207,107,79,.14);color:var(--warn);border:1px solid rgba(207,107,79,.36)}
.chip.mute{background:var(--sunk);color:var(--faint);border:1px solid var(--border)}
.path{font-family:var(--mono);font-size:12px;color:var(--faint);margin-bottom:16px}
.sync{display:flex;align-items:stretch;gap:0;background:var(--sunk);
border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.sync-cell{padding:12px 18px;display:flex;flex-direction:column;gap:3px;
border-right:1px solid var(--border);min-width:0}
.sync-cell:last-child{border-right:0;margin-left:auto;justify-content:center}
.sync-k{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint)}
.sync-v{font-family:var(--mono);font-size:15px;display:flex;align-items:center;gap:7px}
.sync-v .big{font-size:19px;font-weight:600;letter-spacing:-.02em}
.up{color:var(--accent)} .down{color:var(--dim)} .zero{color:var(--faint)}
.sub{font-family:var(--mono);font-size:11px;color:var(--faint)}
.sub.bad{color:var(--warn)}
.btn{all:unset;cursor:pointer;font-family:var(--ui);font-size:12.5px;font-weight:560;
padding:7px 15px;border-radius:7px;background:var(--accent);color:var(--ink);
text-align:center;white-space:nowrap}
.btn:focus-visible{outline:2px solid var(--text);outline-offset:2px}
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--border-strong)}
/* ── ② commits + push 分界 ── */
.commits{display:flex;flex-direction:column}
.c-row{display:grid;grid-template-columns:22px 74px 52px 1fr;align-items:baseline;
gap:10px;padding:4px 8px;border-radius:5px;position:relative}
.c-row.unpushed{background:linear-gradient(90deg,var(--soft),transparent 55%)}
.c-row.unpushed::before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;
background:var(--accent);border-radius:2px}
.c-mark{font-family:var(--mono);font-size:12px;color:var(--accent);text-align:center}
.c-sha{font-family:var(--mono);font-size:12.5px;color:var(--accent-2)}
.c-age{font-family:var(--mono);font-size:11.5px;color:var(--faint);text-align:right}
.c-msg{font-size:13.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.c-row:not(.unpushed) .c-msg{color:var(--dim)}
.boundary{display:flex;align-items:center;gap:12px;margin:9px 0 7px;padding-left:8px}
.boundary .rule{flex:1;height:1px;background:var(--border-strong)}
.boundary .who{font-family:var(--mono);font-size:11px;color:var(--dim);letter-spacing:.04em;
background:var(--sunk);border:1px solid var(--border);border-radius:999px;padding:3px 11px}
.more{font-family:var(--mono);font-size:11.5px;color:var(--faint);padding:9px 8px 0}
/* ── ③ worktrees ── */
.wt{display:flex;flex-direction:column;gap:8px}
.wt-row{display:grid;grid-template-columns:1fr auto;gap:14px;align-items:center;
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:12px 15px}
.wt-row.current{border-color:var(--line);background:linear-gradient(90deg,var(--soft),var(--card) 46%)}
.wt-main{display:flex;flex-direction:column;gap:5px;min-width:0}
.wt-top{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.wt-branch{font-family:var(--mono);font-size:13.5px;font-weight:600;color:var(--text)}
.wt-path{font-family:var(--mono);font-size:11.5px;color:var(--faint);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wt-right{display:flex;align-items:center;gap:8px;flex:none}
.sess{font-size:11.5px;color:var(--dim);font-family:var(--mono);display:flex;align-items:center;gap:5px}
.sess .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:none}
.sess .dot.idle{background:var(--faint)}
/* ── ④ 状态变体 ── */
.states{display:grid;grid-template-columns:repeat(auto-fit,minmax(232px,1fr));gap:11px}
.state{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:13px 15px;display:flex;flex-direction:column;gap:8px}
.state-k{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint)}
.state-d{font-size:12px;color:var(--dim);line-height:1.45}
/* ── ⑤ 成本表 ── */
.tbl-wrap{overflow-x:auto;border:1px solid var(--doc-rule);border-radius:var(--radius)}
table{border-collapse:collapse;width:100%;font-size:13.5px;background:var(--doc-card);min-width:640px}
th,td{text-align:left;padding:11px 15px;border-bottom:1px solid var(--doc-rule);vertical-align:top}
th{font-size:11px;letter-spacing:.13em;text-transform:uppercase;color:var(--doc-dim);font-weight:600}
tr:last-child td{border-bottom:0}
td code{font-family:var(--mono);font-size:12px;color:var(--doc-text)}
td.status{white-space:nowrap;font-family:var(--mono);font-size:12px}
.yes{color:var(--ok)} .no{color:var(--warn)}
.foot{border-top:1px solid var(--doc-rule);padding-top:20px;color:var(--doc-dim);font-size:13.5px;
max-width:70ch}
.foot strong{color:var(--doc-text)}
@media (prefers-reduced-motion: reduce){ *{animation:none!important;transition:none!important} }
@media (max-width:720px){
.wrap{padding:36px 16px 64px;gap:40px}
.sync{flex-direction:column}
.sync-cell{border-right:0;border-bottom:1px solid var(--border)}
.sync-cell:last-child{margin-left:0}
.c-row{grid-template-columns:18px 66px 1fr;row-gap:0}
.c-age{display:none}
}
</style>
</head>
<body>
<div class="wrap">
<header class="doc-head">
<div class="eyebrow">web-terminal · 设计稿</div>
<h1>项目详情页 · Git 面板</h1>
<p class="lede">把「有没有 commit 没推」「现在在哪个 worktree」变成不用问就能看见的信息。
下面每一块都用 <code style="font-family:var(--mono);font-size:13px">web-terminal</code> 此刻的真实状态渲染 —— 包括那个正在说谎的 ↓0。</p>
<div class="facts">
<span class="fact">分支 <b>develop</b></span>
<span class="fact">未推送 <b>9</b></span>
<span class="fact">未提交 <b>3</b></span>
<span class="fact">上次 fetch <b>19 天前</b></span>
<span class="fact">worktree <b>2</b></span>
</div>
</header>
<!-- ───────────────── ① 同步状态条 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">01</span>
<h2>同步状态条</h2>
<span class="tag wire">接线即可</span>
</div>
<p class="note">替换现在标题行里那个孤零零的圆点。四个格子回答四个问题:我在哪个分支、有多少要推、有多少要拉、<b>这两个数字还能不能信</b></p>
<div class="mock">
<div class="proj-line">
<span class="proj-name">web-terminal</span>
<span class="chip branch">develop</span>
<span class="chip dirty">● 3 未提交</span>
</div>
<div class="path">/Users/yiukai/Documents/git/web-terminal</div>
<div class="sync">
<div class="sync-cell">
<span class="sync-k">Upstream</span>
<span class="sync-v"><span style="color:var(--dim)">origin/develop</span></span>
</div>
<div class="sync-cell">
<span class="sync-k">待推送</span>
<span class="sync-v up"><span class="big">↑ 9</span></span>
<span class="sub">9 个 commit 只在本地</span>
</div>
<div class="sync-cell">
<span class="sync-k">待拉取</span>
<span class="sync-v zero"><span class="big">↓ 0</span>
<span class="chip warn">存疑</span></span>
<span class="sub bad">上次 fetch 在 19 天前 — 这个 0 不可信</span>
</div>
<div class="sync-cell">
<button class="btn" type="button">Fetch</button>
</div>
</div>
</div>
<p class="note">「存疑」标记是这块最重要的设计。<code>ahead/behind</code> 比的是本地缓存的
<code>@{u}</code>,不 fetch 就永远不会变 —— 没有这个标记,面板会<b>自信地告诉你已经同步</b>,而这正是它此刻在做的事。
规则:<code>FETCH_HEAD</code> 的 mtime 超过阈值(建议 1 小时)就给 ↓ 挂上存疑,并把 Fetch 按钮点亮。
Fetch 是只读的,不 pull、不 merge —— 符合写侧一贯的克制。</p>
</section>
<!-- ───────────────── ② commits + push 分界线 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">02</span>
<h2>提交列表 · 推送分界线</h2>
<span class="tag new">新增</span>
</div>
<p class="note">你要的不是「有 9 个没推」这个数字,而是<b>哪 9 个</b>。复用现有的 commit 列表,给未推送的加左侧竖线和 ↑,在边界上画出 upstream 的位置 —— 一眼看完,零点击。</p>
<div class="mock">
<div class="label">Recent commits <span class="count">↑ 9 未推送</span></div>
<div class="commits">
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">553a00c</span><span class="c-age">8m</span><span class="c-msg">docs(progress): log the three leftover fixes + live verification</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">d92caed</span><span class="c-age">22m</span><span class="c-msg">Merge fix-tunnel-leftovers: gitignore-swallowed source, host identifiers in logs</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">2a602d5</span><span class="c-age">31m</span><span class="c-msg">fix(tunnel): close the three leftovers from the renewal-deadlock fix</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">befe677</span><span class="c-age">1h</span><span class="c-msg">Merge fix-cert-renew-deadlock: break the expired-leaf renewal deadlock</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">7064a39</span><span class="c-age">1h</span><span class="c-msg">chore: drop accidentally committed node_modules symlinks</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">0970c62</span><span class="c-age">1h</span><span class="c-msg">docs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">5509c81</span><span class="c-age">2h</span><span class="c-msg">fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">f3f4d8b</span><span class="c-age">2h</span><span class="c-msg">fix(tunnel): break the expired-leaf renewal deadlock</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">b1bc50c</span><span class="c-age">3h</span><span class="c-msg">fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK</span></div>
<div class="boundary"><span class="who">origin/develop</span><span class="rule"></span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">1dbed54</span><span class="c-age">6d</span><span class="c-msg">docs(progress): log the zero-touch enrollment + control-panel session</span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">675de77</span><span class="c-age">9d</span><span class="c-msg">feat(control-panel): web admin UI for the zero-touch tunnel</span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">7c1d433</span><span class="c-age">10d</span><span class="c-msg">Merge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)</span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">0b35dc0</span><span class="c-age">10d</span><span class="c-msg">feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)</span></div>
</div>
<div class="more">显示最近 20 条</div>
</div>
<p class="note">分界线不是装饰 —— 它是 <code>origin/develop</code> 在时间轴上的真实位置。分支没有 upstream 时不画线,改在顶部显示「无 upstream」(见 ④)。</p>
</section>
<!-- ───────────────── ③ worktrees ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">03</span>
<h2>Worktree 面板</h2>
<span class="tag new">重做</span>
</div>
<p class="note">现在这块叫 BRANCH、只有一行。按「每个 session 一个 worktree」的规矩,它很快会变成 45 行,而且每一行要能独立回答:哪个分支、在哪、干净不干净、推没推、里面跑着谁。<b>这是编辑器给不了的视角</b> —— Cursor 一次只看得见一个 worktree。</p>
<div class="mock">
<div class="label">Worktrees <span class="count">2</span></div>
<div class="wt">
<div class="wt-row current">
<div class="wt-main">
<div class="wt-top">
<span class="wt-branch">develop</span>
<span class="chip mute">当前</span>
<span class="chip dirty">● 3</span>
<span class="chip branch">↑ 9</span>
</div>
<div class="wt-path">/Users/yiukai/Documents/git/web-terminal</div>
</div>
<div class="wt-right">
<span class="sess"><span class="dot"></span>2 sessions</span>
<button class="btn ghost" type="button">打开</button>
</div>
</div>
<div class="wt-row">
<div class="wt-main">
<div class="wt-top">
<span class="wt-branch">worktree-project-detail-git-mockup</span>
<span class="chip ok">干净</span>
<span class="chip warn">无 upstream</span>
</div>
<div class="wt-path">.claude/worktrees/project-detail-git-mockup</div>
</div>
<div class="wt-right">
<span class="sess"><span class="dot"></span>1 session</span>
<button class="btn ghost" type="button">打开</button>
</div>
</div>
</div>
</div>
<p class="note">「无 upstream」必须显式标出。新建的 worktree 分支天然没有 upstream,<code>ahead/behind</code> 全是
<code>undefined</code> —— 绝不能因为「没有数字」就渲染成绿色的「已同步」。这是这套 UI 最容易犯的错。</p>
</section>
<!-- ───────────────── ④ 状态变体 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">04</span>
<h2>状态变体</h2>
<span class="tag new">规格</span>
</div>
<p class="note">同步条的全部形态。做设计稿的意义一半在这里 —— 边界情况不定死,实现时就会各写各的。</p>
<div class="mock">
<div class="states">
<div class="state">
<span class="state-k">已同步 · 刚 fetch</span>
<div><span class="chip ok">✓ 已同步</span></div>
<span class="state-d">↑0 ↓0 且 fetch 在阈值内。唯一可以显示绿色的情况。</span>
</div>
<div class="state">
<span class="state-k">待推送</span>
<div><span class="chip branch">↑ 9</span></div>
<span class="state-d">主状态。数字用 accent,提交列表同步高亮。</span>
</div>
<div class="state">
<span class="state-k">待拉取</span>
<div><span class="chip mute">↓ 3</span></div>
<span class="state-d">用中性色而非警告色 —— 落后不是错误,只是信息。</span>
</div>
<div class="state">
<span class="state-k">数据陈旧</span>
<div><span class="chip warn">存疑</span></div>
<span class="state-d">FETCH_HEAD 超过 1 小时。挂在 ↓ 上,不挂在 ↑ 上 —— ↑ 不依赖 fetch,永远准。</span>
</div>
<div class="state">
<span class="state-k">无 upstream</span>
<div><span class="chip warn">无 upstream</span></div>
<span class="state-d">新 worktree 分支的默认状态。不显示 ↑↓,不显示绿色。</span>
</div>
<div class="state">
<span class="state-k">HEAD 游离</span>
<div><span class="chip warn">detached HEAD</span></div>
<span class="state-d">不显示分支名和 ↑↓,只显示短 sha。push 按钮禁用。</span>
</div>
</div>
</div>
<p class="note"><b>只有一个状态配用绿色:↑0 ↓0 且刚 fetch 过。</b>其余一律中性或警示 —— 绿色在这里的含义是「我确认过了,你不用管」,给错了就是骗人。</p>
</section>
<!-- ───────────────── ⑤ 数据来源与成本 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">05</span>
<h2>数据来源与成本</h2>
</div>
<p class="note">编号 = 建议实现顺序。01 和 02 共用同一份数据,应该一个 PR 一起做。</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>元素</th><th>数据来源</th><th>后端现状</th></tr>
</thead>
<tbody>
<tr>
<td>分支名</td>
<td><code>.git/HEAD</code>(不 spawn 进程)</td>
<td class="status yes">✓ 已有</td>
</tr>
<tr>
<td>↑ ahead / ↓ behind</td>
<td><code>git rev-list --count --left-right @{u}...HEAD</code></td>
<td class="status no">助手已有,<code>ProjectDetail</code> 没接</td>
</tr>
<tr>
<td>upstream 名字</td>
<td><code>git rev-parse --abbrev-ref @{u}</code></td>
<td class="status no"></td>
</tr>
<tr>
<td>未推送的 sha 集合</td>
<td><code>git log @{u}..HEAD --format=%H</code></td>
<td class="status no"></td>
</tr>
<tr>
<td>上次 fetch 时间</td>
<td><code>.git/FETCH_HEAD</code> 的 mtime(不 spawn)</td>
<td class="status no"></td>
</tr>
<tr>
<td>Fetch 按钮</td>
<td><code>git fetch --no-tags</code>,只读</td>
<td class="status no">缺(需新路由)</td>
</tr>
<tr>
<td>未提交数量</td>
<td><code>git status --porcelain</code> 的行数</td>
<td class="status no">只有布尔 <code>dirty</code>,没有计数</td>
</tr>
<tr>
<td>worktree 列表</td>
<td><code>git worktree list --porcelain</code></td>
<td class="status yes">✓ 已有</td>
</tr>
<tr>
<td>每个 worktree 的 dirty / ↑↓</td>
<td>同上,逐 worktree 跑一次</td>
<td class="status no">缺 — 成本最高的一项</td>
</tr>
</tbody>
</table>
</div>
<p class="note"><b>成本控制。</b>只有分支名和 fetch 时间是读文件、免费的;其余每项都要 spawn 一次 <code>git</code>,而这台机器同时在跑 Claude Code 和构建。三条便宜的对策:按仓库去重(N 个 tab 同一个 repo 只查一次)、盯
<code>.git/HEAD</code> / <code>.git/index</code> / <code>.git/refs</code> 的 mtime 变了才查、页面隐藏时暂停。逐 worktree 的 ↑↓ 建议懒加载 —— 展开那一行才算。</p>
</section>
<p class="foot"><strong>范围外。</strong>这一页不加 reset、不加切分支 checkout、不加 clean、不加 force-push。
stage / commit / push 已经在 <code style="font-family:var(--mono);font-size:12.5px">diff.ts</code> 里,维持现状。
这块面板的职责是<strong>告诉你发生了什么</strong>,不是替代编辑器去处理它。</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,235 @@
# 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) — G1G7 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 612 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.