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:
@@ -24,6 +24,35 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🌿 [x] w6 项目详情 Git 面板 — G1–G7 全部完成(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 6–12 个 `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 6–12 个 `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`,这些用例根本没跳过,是真的在跑并且卡在时间预算上。
|
||||
- **遗留 / 待办**: 无(G1–G7 全部完成)。设计稿里画的每样东西都落地了。
|
||||
- **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/` 仍被忽略。
|
||||
|
||||
Reference in New Issue
Block a user