From 8fe1f52e5d154d41958031510c51191e292f53c5 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 29 Jul 2026 17:12:00 +0200 Subject: [PATCH] feat(projects): show real git state on the project detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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/ 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. --- docs/PROGRESS_LOG.md | 29 ++ docs/mockups/project-detail-git.html | 439 +++++++++++++++++++++++++++ docs/plans/w6-project-git-panel.md | 235 ++++++++++++++ public/git-log.ts | 40 ++- public/projects.ts | Bin 50969 -> 64206 bytes public/style.css | 148 +++++++++ src/http/git-log.ts | 61 +++- src/http/git-ops.ts | 82 +++++ src/http/projects.ts | 198 +++++++++++- src/server.ts | 72 ++++- src/types.ts | 39 ++- test/git-log.test.ts | 62 ++++ test/http/git-log.test.ts | 94 ++++++ test/http/git-ops.test.ts | 103 ++++++- test/projects-panel.test.ts | 189 ++++++++++++ test/projects.test.ts | 244 ++++++++++++++- 16 files changed, 2005 insertions(+), 30 deletions(-) create mode 100644 docs/mockups/project-detail-git.html create mode 100644 docs/plans/w6-project-git-panel.md diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 3b06828..1f036d1 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -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` 直接读 `/.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/` 就在主 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/` 仍被忽略。 diff --git a/docs/mockups/project-detail-git.html b/docs/mockups/project-detail-git.html new file mode 100644 index 0000000..64718c6 --- /dev/null +++ b/docs/mockups/project-detail-git.html @@ -0,0 +1,439 @@ + + + + + +Project detail — Git 面板设计稿 + + + +
+ +
+
web-terminal · 设计稿
+

项目详情页 · Git 面板

+

把「有没有 commit 没推」「现在在哪个 worktree」变成不用问就能看见的信息。 + 下面每一块都用 web-terminal 此刻的真实状态渲染 —— 包括那个正在说谎的 ↓0。

+
+ 分支 develop + 未推送 9 + 未提交 3 + 上次 fetch 19 天前 + worktree 2 +
+
+ + +
+
+ 01 +

同步状态条

+ 接线即可 +
+

替换现在标题行里那个孤零零的圆点。四个格子回答四个问题:我在哪个分支、有多少要推、有多少要拉、这两个数字还能不能信

+ +
+
+ web-terminal + develop + ● 3 未提交 +
+
/Users/yiukai/Documents/git/web-terminal
+ +
+
+ Upstream + origin/develop +
+
+ 待推送 + ↑ 9 + 9 个 commit 只在本地 +
+
+ 待拉取 + ↓ 0 + 存疑 + 上次 fetch 在 19 天前 — 这个 0 不可信 +
+
+ +
+
+
+ +

「存疑」标记是这块最重要的设计。ahead/behind 比的是本地缓存的 + @{u},不 fetch 就永远不会变 —— 没有这个标记,面板会自信地告诉你已经同步,而这正是它此刻在做的事。 + 规则:FETCH_HEAD 的 mtime 超过阈值(建议 1 小时)就给 ↓ 挂上存疑,并把 Fetch 按钮点亮。 + Fetch 是只读的,不 pull、不 merge —— 符合写侧一贯的克制。

+
+ + +
+
+ 02 +

提交列表 · 推送分界线

+ 新增 +
+

你要的不是「有 9 个没推」这个数字,而是哪 9 个。复用现有的 commit 列表,给未推送的加左侧竖线和 ↑,在边界上画出 upstream 的位置 —— 一眼看完,零点击。

+ +
+
Recent commits ↑ 9 未推送
+
+
553a00c8mdocs(progress): log the three leftover fixes + live verification
+
d92caed22mMerge fix-tunnel-leftovers: gitignore-swallowed source, host identifiers in logs
+
2a602d531mfix(tunnel): close the three leftovers from the renewal-deadlock fix
+
befe6771hMerge fix-cert-renew-deadlock: break the expired-leaf renewal deadlock
+
7064a391hchore: drop accidentally committed node_modules symlinks
+
0970c621hdocs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof
+
5509c812hfix(tunnel): recover an expired leaf over plain HTTPS, not mTLS
+
f3f4d8b2hfix(tunnel): break the expired-leaf renewal deadlock
+
b1bc50c3hfix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK
+ +
origin/develop
+ +
1dbed546ddocs(progress): log the zero-touch enrollment + control-panel session
+
675de779dfeat(control-panel): web admin UI for the zero-touch tunnel
+
7c1d43310dMerge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)
+
0b35dc010dfeat(android): wire zero-touch device enrollment + fix renew/cache (B-track)
+
+
显示最近 20 条
+
+ +

分界线不是装饰 —— 它是 origin/develop 在时间轴上的真实位置。分支没有 upstream 时不画线,改在顶部显示「无 upstream」(见 ④)。

+
+ + +
+
+ 03 +

Worktree 面板

+ 重做 +
+

现在这块叫 BRANCH、只有一行。按「每个 session 一个 worktree」的规矩,它很快会变成 4–5 行,而且每一行要能独立回答:哪个分支、在哪、干净不干净、推没推、里面跑着谁。这是编辑器给不了的视角 —— Cursor 一次只看得见一个 worktree。

+ +
+
Worktrees 2
+
+
+
+
+ develop + 当前 + ● 3 + ↑ 9 +
+
/Users/yiukai/Documents/git/web-terminal
+
+
+ 2 sessions + +
+
+ +
+
+
+ worktree-project-detail-git-mockup + 干净 + 无 upstream +
+
.claude/worktrees/project-detail-git-mockup
+
+
+ 1 session + +
+
+
+
+ +

「无 upstream」必须显式标出。新建的 worktree 分支天然没有 upstream,ahead/behind 全是 + undefined —— 绝不能因为「没有数字」就渲染成绿色的「已同步」。这是这套 UI 最容易犯的错。

+
+ + +
+
+ 04 +

状态变体

+ 规格 +
+

同步条的全部形态。做设计稿的意义一半在这里 —— 边界情况不定死,实现时就会各写各的。

+ +
+
+
+ 已同步 · 刚 fetch +
✓ 已同步
+ ↑0 ↓0 且 fetch 在阈值内。唯一可以显示绿色的情况。 +
+
+ 待推送 +
↑ 9
+ 主状态。数字用 accent,提交列表同步高亮。 +
+
+ 待拉取 +
↓ 3
+ 用中性色而非警告色 —— 落后不是错误,只是信息。 +
+
+ 数据陈旧 +
存疑
+ FETCH_HEAD 超过 1 小时。挂在 ↓ 上,不挂在 ↑ 上 —— ↑ 不依赖 fetch,永远准。 +
+
+ 无 upstream +
无 upstream
+ 新 worktree 分支的默认状态。不显示 ↑↓,不显示绿色。 +
+
+ HEAD 游离 +
detached HEAD
+ 不显示分支名和 ↑↓,只显示短 sha。push 按钮禁用。 +
+
+
+ +

只有一个状态配用绿色:↑0 ↓0 且刚 fetch 过。其余一律中性或警示 —— 绿色在这里的含义是「我确认过了,你不用管」,给错了就是骗人。

+
+ + +
+
+ 05 +

数据来源与成本

+
+

编号 = 建议实现顺序。01 和 02 共用同一份数据,应该一个 PR 一起做。

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
元素数据来源后端现状
分支名.git/HEAD(不 spawn 进程)✓ 已有
↑ ahead / ↓ behindgit rev-list --count --left-right @{u}...HEAD助手已有,ProjectDetail 没接
upstream 名字git rev-parse --abbrev-ref @{u}
未推送的 sha 集合git log @{u}..HEAD --format=%H
上次 fetch 时间.git/FETCH_HEAD 的 mtime(不 spawn)
Fetch 按钮git fetch --no-tags,只读缺(需新路由)
未提交数量git status --porcelain 的行数只有布尔 dirty,没有计数
worktree 列表git worktree list --porcelain✓ 已有
每个 worktree 的 dirty / ↑↓同上,逐 worktree 跑一次缺 — 成本最高的一项
+
+ +

成本控制。只有分支名和 fetch 时间是读文件、免费的;其余每项都要 spawn 一次 git,而这台机器同时在跑 Claude Code 和构建。三条便宜的对策:按仓库去重(N 个 tab 同一个 repo 只查一次)、盯 + .git/HEAD / .git/index / .git/refs 的 mtime 变了才查、页面隐藏时暂停。逐 worktree 的 ↑↓ 建议懒加载 —— 展开那一行才算。

+
+ +

范围外。这一页不加 reset、不加切分支 checkout、不加 clean、不加 force-push。 + stage / commit / push 已经在 diff.ts 里,维持现状。 + 这块面板的职责是告诉你发生了什么,不是替代编辑器去处理它。

+ +
+ + diff --git a/docs/plans/w6-project-git-panel.md b/docs/plans/w6-project-git-panel.md new file mode 100644 index 0000000..733d5c4 --- /dev/null +++ b/docs/plans/w6-project-git-panel.md @@ -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) — 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. diff --git a/public/git-log.ts b/public/git-log.ts index e5c0f6a..54bc11a 100644 --- a/public/git-log.ts +++ b/public/git-log.ts @@ -34,7 +34,11 @@ function normalizeCommit(raw: unknown): CommitLogEntry | null { const o = raw as Record if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null - return { hash: o['hash'], at: o['at'], subject: o['subject'] } + const entry: CommitLogEntry = { hash: o['hash'], at: o['at'], subject: o['subject'] } + // w6/G4: only an explicit `true` marks a commit unpushed — anything else (absent, + // truthy junk, an older server) must fall back to "pushed", because the failure + // that matters is calling an unpushed commit pushed, not the reverse. + return o['unpushed'] === true ? { ...entry, unpushed: true } : entry } /** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */ @@ -45,7 +49,12 @@ export function normalizeGitLog(raw: unknown): GitLogResult | null { const commits = o['commits'] .map(normalizeCommit) .filter((c): c is CommitLogEntry => c !== null) - return { commits, truncated: o['truncated'] === true } + const upstream = typeof o['upstream'] === 'string' && o['upstream'] !== '' ? o['upstream'] : undefined + return { + commits, + truncated: o['truncated'] === true, + ...(upstream !== undefined ? { upstream } : {}), + } } /* ── fetch ───────────────────────────────────────────────────────────────────── */ @@ -73,9 +82,17 @@ function relTime(ms: number): string { return `${Math.floor(s / 86400)}d` } -/** One commit row: short hash · relative time · subject (all inert text). */ +/** One commit row: short hash · relative time · subject (all inert text). + * w6/G4: an unpushed commit gets a rail + ↑ so the eye reads the group, not the + * individual rows. */ function renderCommitRow(c: CommitLogEntry): HTMLElement { const row = el('div', 'proj-commit-row') + if (c.unpushed === true) { + row.classList.add('proj-commit-unpushed') + const mark = el('span', 'proj-commit-mark', '↑') + mark.title = 'Not pushed yet' + row.append(mark) + } row.append(el('span', 'proj-commit-hash', c.hash)) row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`)) row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent @@ -90,7 +107,22 @@ export function renderGitLog(container: HTMLElement, log: GitLogResult): void { return } const list = el('div', 'proj-commitlog-list') - for (const c of log.commits) list.append(renderCommitRow(c)) + // w6/G4: draw the upstream's position ONCE, right after the last unpushed row. + // Only with a named upstream — with nothing to compare against there is no + // boundary to claim, and an invented one would be a lie about what is pushed. + const lastUnpushed = log.upstream === undefined + ? -1 + : log.commits.reduce((acc, c, i) => (c.unpushed === true ? i : acc), -1) + + log.commits.forEach((c, i) => { + list.append(renderCommitRow(c)) + if (i === lastUnpushed) { + const boundary = el('div', 'proj-commit-boundary') + boundary.append(el('span', 'proj-commit-boundary-label', log.upstream as string)) + boundary.append(el('span', 'proj-commit-boundary-rule')) + list.append(boundary) + } + }) container.append(list) if (log.truncated) { container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`)) diff --git a/public/projects.ts b/public/projects.ts index 5cc21d8a55f2a41b86b056c77fb3669fa88715c4..8dafee2e1098aa85e16c81a320bee7d70b7105df 100644 GIT binary patch delta 12026 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 { + if (result.commits.length === 0) return result + + let upstream: string + try { + const { stdout } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER }, + ) + upstream = stdout.trim() + if (upstream === '') return result + } catch { + return result // no upstream configured / detached — nothing to compare against + } + + let fullHashes: string[] + try { + const { stdout } = await execFileAsync( + 'git', + ['rev-list', `--max-count=${UNPUSHED_MAX}`, '@{u}..HEAD'], + { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER }, + ) + fullHashes = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '') + } catch { + return result + } + + // `hash` is abbreviated (%h) while rev-list yields full SHAs, and the two can + // disagree on width, so match by prefix rather than equality. Bounded by + // GIT_LOG_MAX × UNPUSHED_MAX string compares — trivial, and always correct. + const commits = result.commits.map((c) => + fullHashes.some((full) => full.startsWith(c.hash)) ? { ...c, unpushed: true } : c, + ) + return { ...result, commits, upstream } +} diff --git a/src/http/git-ops.ts b/src/http/git-ops.ts index a9a0c9d..8cf4219 100644 --- a/src/http/git-ops.ts +++ b/src/http/git-ops.ts @@ -359,3 +359,85 @@ export async function push(repoPath: string, opts: PushOptions): Promise { + if (!(await isGitDir(repoPath))) { + return { ok: false, status: 404, error: 'Not a git repository.' } + } + + const upstream = await upstreamRemote(repoPath, opts.timeoutMs) + let args: string[] + let targetRemote: string + + if (upstream !== null) { + // Upstream set → bare fetch; git resolves the current branch's remote itself. + args = ['fetch', '--no-tags', '--quiet'] + targetRemote = upstream + } else { + const remotes = await listRemotes(repoPath, opts.timeoutMs) + if (remotes.length === 0) { + return { ok: false, status: 400, error: 'No remote configured.' } + } + if (remotes.length > 1) { + return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' } + } + const sole = remotes[0] as string + // A remote named like a flag would otherwise be parsed as one. + if (sole.startsWith('-')) { + return { ok: false, status: 400, error: 'Cannot fetch from this remote.' } + } + args = ['fetch', '--no-tags', '--quiet', sole] + targetRemote = sole + } + + try { + await execFileAsync('git', args, { + cwd: repoPath, + timeout: opts.timeoutMs, + maxBuffer: GIT_MAX_BUFFER, + env: NO_PROMPT_ENV, + }) + } catch (err: unknown) { + // Offline / auth failure: leave lastFetchMs alone so the UI keeps showing + // "stale" rather than silently marking the data as freshly verified. + return failFromError(err) + } + + return { ok: true, remote: targetRemote, lastFetchMs: await fetchHeadMtime(repoPath) } +} + +/** FETCH_HEAD mtime after a successful fetch; undefined if it cannot be read. + * Resolves `.git` first so a linked worktree reads the shared common dir. */ +async function fetchHeadMtime(repoPath: string): Promise { + try { + const dotGit = path.join(repoPath, '.git') + const stat = await fs.stat(dotGit) + let commonDir = dotGit + if (!stat.isDirectory()) { + const match = /^gitdir:\s*(.+)$/m.exec(await fs.readFile(dotGit, 'utf8')) + const raw = match?.[1]?.trim() + if (raw === undefined || raw === '') return undefined + const gitDir = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw) + const parent = path.dirname(gitDir) + commonDir = path.basename(parent) === 'worktrees' ? path.dirname(parent) : gitDir + } + return (await fs.stat(path.join(commonDir, 'FETCH_HEAD'))).mtimeMs + } catch { + return undefined + } +} diff --git a/src/http/projects.ts b/src/http/projects.ts index a7d48a6..875948b 100644 --- a/src/http/projects.ts +++ b/src/http/projects.ts @@ -25,6 +25,8 @@ import type { ProjectInfo, ProjectSessionRef, ProjectDetail, + SyncState, + WorktreeState, } from '../types.js' import { listSessions } from './history.js' import { listWorktrees } from './worktrees.js' @@ -84,10 +86,14 @@ function shouldSkipDir(name: string): boolean { // ── per-repo metadata (best-effort) ───────────────────────────────────────────── -/** Read the current branch from `/.git/HEAD`; undefined if unreadable. */ +/** Read the current branch from the repo's HEAD; undefined if unreadable or + * detached. Resolves `.git` first (w6/G1) so a linked worktree — where `.git` is + * a file, not a directory — reports its own branch instead of nothing. */ async function readBranch(repoPath: string): Promise { + const dirs = await resolveGitDirs(repoPath) + if (dirs === null) return undefined try { - const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8') + const head = await fs.readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8') return parseGitHead(head) ?? undefined } catch { return undefined @@ -96,18 +102,117 @@ async function readBranch(repoPath: string): Promise { /** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */ async function readDirty(repoPath: string): Promise { + return (await readDirtyCount(repoPath)).dirty +} + +/** w6/G1: one `git status --porcelain` → both the boolean and the line count, so + * the header can show `● 3` instead of a bare dot without a second spawn. Both + * fields degrade together (undefined on error/timeout/non-repo). */ +async function readDirtyCount( + repoPath: string, +): Promise<{ dirty?: boolean; dirtyCount?: number }> { try { const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER, }) - return stdout.trim().length > 0 + const body = stdout.replace(/\n+$/, '') + const count = body.length === 0 ? 0 : body.split('\n').length + return { dirty: count > 0, dirtyCount: count } + } catch { + return {} + } +} + +/** + * w6/G1: resolve a repo's git directories without spawning git. + * + * - `gitDir` — where HEAD lives. For a linked worktree `/.git` is a *file* + * containing `gitdir: `, not a directory (the pre-existing readBranch + * assumed a directory, so it silently failed inside every worktree). + * - `commonDir` — where FETCH_HEAD lives, shared by all worktrees of a repo. For a + * linked worktree that is `/worktrees/` → up two levels. + * + * Returns null when `/.git` is missing or unreadable. + */ +async function resolveGitDirs( + repoPath: string, +): Promise<{ gitDir: string; commonDir: string } | null> { + const dotGit = path.join(repoPath, '.git') + let stat + try { + stat = await fs.stat(dotGit) + } catch { + return null + } + if (stat.isDirectory()) return { gitDir: dotGit, commonDir: dotGit } + + try { + const text = await fs.readFile(dotGit, 'utf8') + const match = /^gitdir:\s*(.+)$/m.exec(text) + const raw = match?.[1]?.trim() + if (raw === undefined || raw === '') return null + const gitDir = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw) + const parent = path.dirname(gitDir) + const commonDir = path.basename(parent) === 'worktrees' ? path.dirname(parent) : gitDir + return { gitDir, commonDir } + } catch { + return null + } +} + +/** w6/G1: `/FETCH_HEAD` mtime, or undefined if the repo was never + * fetched. A file stat, no spawn. Undefined must stay undefined — inventing a + * timestamp here would make a stale `behind` look freshly verified. */ +async function readLastFetchMs(commonDir: string): Promise { + try { + const stat = await fs.stat(path.join(commonDir, 'FETCH_HEAD')) + return stat.mtimeMs } catch { return undefined } } +/** w6/G1: the upstream's short name (`origin/develop`) for the current branch; + * undefined when the branch tracks nothing or HEAD is detached. */ +async function readUpstream(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER }, + ) + const name = stdout.trim() + return name === '' ? undefined : name + } catch { + return undefined + } +} + +/** w6/G1: the full sync state for the detail header — upstream + ahead/behind + + * last fetch + detached. Reuses readSync for the counts (same two git calls the + * project list already makes) and adds two cheap reads on top. Never throws. */ +async function readSyncState(repoPath: string): Promise { + const dirs = await resolveGitDirs(repoPath) + const [counts, upstream, lastFetchMs, headText] = await Promise.all([ + readSync(repoPath), + readUpstream(repoPath), + dirs === null ? Promise.resolve(undefined) : readLastFetchMs(dirs.commonDir), + dirs === null + ? Promise.resolve(null) + : fs.readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8').catch(() => null), + ]) + + const state: SyncState = {} + if (upstream !== undefined) state.upstream = upstream + if (counts.ahead !== undefined) state.ahead = counts.ahead + if (counts.behind !== undefined) state.behind = counts.behind + if (lastFetchMs !== undefined) state.lastFetchMs = lastFetchMs + if (headText !== null && parseGitHead(headText) === null) state.detached = true + return state +} + /** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time. * Two read-only git calls (no shell), each bounded by timeout + maxBuffer: * 1. `git rev-list --count --left-right @{u}...HEAD` → "\t" @@ -391,6 +496,9 @@ function toSessionRef(s: LiveSessionInfo): ProjectSessionRef { clientCount: s.clientCount, createdAt: s.createdAt, exited: s.exited, + // w6/G7: without cwd the UI cannot say WHICH worktree a session runs in — + // and with one worktree per session that is the question being asked. + ...(s.cwd !== null ? { cwd: s.cwd } : {}), } } @@ -470,7 +578,78 @@ async function readClaudeMd(projectPath: string): Promise { * cached: the detail view is opened on demand and wants fresh worktree/session * state. Reads only (git branch/status/worktree-list); never throws. */ -export async function buildProjectDetail( +/** + * w6/G7 — the git state of ONE worktree, probed on demand. + * + * A linked worktree is just a directory with a `.git` FILE, so the same readers + * the project detail uses work verbatim once resolveGitDirs handles that shape. + * Kept as its own small call (rather than reusing buildProjectDetail) because a + * row only needs branch/sync/dirty — listing worktrees and reading CLAUDE.md for + * every row would spend spawns on data the row never shows. + * + * Returns null for a missing path or a non-git directory. Never throws. + */ +export async function buildWorktreeState( + cfg: Config, + worktreePath: string, +): Promise { + if (typeof worktreePath !== 'string' || !path.isAbsolute(worktreePath)) return null + try { + const stat = await fs.stat(worktreePath) + if (!stat.isDirectory()) return null + } catch { + return null + } + if (!(await hasGitEntry(worktreePath))) return null + + const [branch, dirtyState, sync] = await Promise.all([ + readBranch(worktreePath), + cfg.projectDirtyCheck + ? readDirtyCount(worktreePath) + : Promise.resolve<{ dirty?: boolean; dirtyCount?: number }>({}), + readSyncState(worktreePath), + ]) + + return { + path: worktreePath, + ...(branch !== undefined ? { branch } : {}), + ...(dirtyState.dirtyCount !== undefined ? { dirtyCount: dirtyState.dirtyCount } : {}), + sync, + } +} + +/** + * w6/G6 — coalesce concurrent probes of the SAME repo into one. + * + * A detail view refreshes every 5 s and spawns several `git` processes per pass, + * on the machine that is also running Claude Code and builds. With the app open + * on a laptop, a phone and a tablet, that cost multiplied by the number of + * devices for no added information. + * + * This deliberately caches NOTHING across time: the entry is dropped the moment + * it settles, so a later call always re-probes. A time-based cache would be the + * cheaper fix and the wrong one — this panel's entire value is that its numbers + * are true right now, and a stale `ahead` after a push is exactly the kind of + * confident lie the design rules out. + */ +const inFlightDetails = new Map>() + +export function buildProjectDetail( + cfg: Config, + projectPath: string, + liveSessions: readonly LiveSessionInfo[], +): Promise { + const existing = inFlightDetails.get(projectPath) + if (existing !== undefined) return existing + + const pending = buildProjectDetailUncoalesced(cfg, projectPath, liveSessions).finally(() => { + inFlightDetails.delete(projectPath) + }) + inFlightDetails.set(projectPath, pending) + return pending +} + +async function buildProjectDetailUncoalesced( cfg: Config, projectPath: string, liveSessions: readonly LiveSessionInfo[], @@ -486,9 +665,12 @@ export async function buildProjectDetail( if (!stat.isDirectory()) return null const isGit = await hasGitEntry(projectPath) - const [branch, dirty, worktrees, claudeMd] = await Promise.all([ + const [branch, dirtyState, sync, worktrees, claudeMd] = await Promise.all([ isGit ? readBranch(projectPath) : Promise.resolve(undefined), - isGit && cfg.projectDirtyCheck ? readDirty(projectPath) : Promise.resolve(undefined), + isGit && cfg.projectDirtyCheck + ? readDirtyCount(projectPath) + : Promise.resolve<{ dirty?: boolean; dirtyCount?: number }>({}), + isGit ? readSyncState(projectPath) : Promise.resolve(undefined), isGit ? listWorktrees(projectPath) : Promise.resolve([]), readClaudeMd(projectPath), ]) @@ -498,7 +680,9 @@ export async function buildProjectDetail( path: projectPath, isGit, branch, - dirty, + dirty: dirtyState.dirty, + dirtyCount: dirtyState.dirtyCount, + sync, worktrees, sessions: matchSessions(projectPath, liveSessions), hasClaudeMd: claudeMd !== undefined, diff --git a/src/server.ts b/src/server.ts index a5c7192..7a934c2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,7 +45,7 @@ import { import { parseHookEvent } from './http/hook.js' import { deriveApprovalPreview } from './http/approval-preview.js' import { listSessions } from './http/history.js' -import { buildProjects, buildProjectDetail } from './http/projects.js' +import { buildProjects, buildProjectDetail, buildWorktreeState } from './http/projects.js' import { openInEditor, openFileInEditor } from './http/editor.js' import { getDiff, isPlausibleRev } from './http/diff.js' import { getGitLog } from './http/git-log.js' @@ -54,7 +54,12 @@ import { getPrStatus } from './http/gh.js' import { parseStatusLine } from './http/statusline.js' import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js' import { groupSessionsByRepo } from './http/session-groups.js' -import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js' +import { + stageFiles, + commit as gitCommit, + push as gitPush, + fetch as gitFetch, +} from './http/git-ops.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' import { loadSubscriptionStore } from './push/subscription-store.js' @@ -248,6 +253,9 @@ export function startServer(cfg: Config): { close(): Promise } { const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2 const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push + // w6/G2 fetch gets its OWN bucket: it is the panel's refresh button, so a burst + // of fetches must never eat the budget a real push needs. + const gitFetchLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token // W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook @@ -527,6 +535,32 @@ export function startServer(cfg: Config): { close(): Promise } { // Project detail (v0.6) — branch/worktrees + running sessions for one repo. // Read-only (git branch/status/worktree-list); same threat model as /projects. // ?path must be an absolute existing directory (validated in buildProjectDetail). + // w6/G7 — one worktree's git state, fetched lazily per row. Read-only, and + // deliberately narrower than /projects/detail: a row shows branch/sync/dirty, + // so listing worktrees and reading CLAUDE.md for each of N rows would spend + // spawns on data nothing renders. + app.get('/projects/worktree/state', async (req, res) => { + const target = req.query['path'] + if (typeof target !== 'string' || target === '') { + res.status(400).json({ error: 'path query parameter is required' }) + return + } + try { + const state = await buildWorktreeState(cfg, target) + if (state === null) { + res.status(404).json({ error: 'worktree not found' }) + return + } + res.json(state) + } catch (err) { + console.error( + '[server] /projects/worktree/state failed:', + err instanceof Error ? err.message : String(err), + ) + res.status(500).json({ error: 'failed to read worktree state' }) + } + }) + app.get('/projects/detail', async (req, res) => { const target = req.query['path'] if (typeof target !== 'string' || target === '') { @@ -1248,6 +1282,40 @@ export function startServer(cfg: Config): { close(): Promise } { res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' }) }) + // w6/G2 — refresh remote-tracking refs so the panel's `behind` stops being a + // stale guess. Read-only against the working tree (refs/remotes only, never a + // pull); the remote is derived server-side inside gitFetch, never from the body. + // Shares the gitOpsEnabled kill-switch because it is the one route that talks to + // the network with the host's git credentials. + app.post('/projects/git/fetch', express.json({ limit: '4kb' }), async (req, res) => { + if (!requireAllowedOrigin(req, res)) return + if (!cfg.gitOpsEnabled) { + res.status(403).json({ error: 'Git operations are disabled.' }) + return + } + if (!gitFetchLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).json({ error: 'Too many requests.' }) + return + } + const body = (req.body ?? {}) as Record + const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined + if (repoPath === undefined) { + res.status(400).json({ error: 'path is required' }) + return + } + if (!(await isValidGitDir(repoPath))) { + res.status(404).json({ error: 'project not found' }) + return + } + console.error(`[server] git fetch: path=${sanitizeForLog(repoPath)}`) + const result = await gitFetch(repoPath, { timeoutMs: cfg.gitPushTimeoutMs }) + if (result.ok) { + res.status(200).json({ ok: true, remote: result.remote, lastFetchMs: result.lastFetchMs }) + return + } + res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' }) + }) + // ── GET /config/ui (review #4) — client-readable UI config (read-only) ──── app.get('/config/ui', (_req, res) => { const uiConfig: UiConfig = { diff --git a/src/types.ts b/src/types.ts index 34e1546..7ee8b12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -350,6 +350,7 @@ export interface ProjectSessionRef { clientCount: number; // mirror devices currently attached createdAt: number; exited: boolean; + cwd?: string; // w6/G7: where it runs — lets the UI attribute it to a worktree } /** A discovered project (git repo or recently-used cwd) for the Projects panel. @@ -379,14 +380,43 @@ export interface WorktreeInfo { prunable?: boolean; // git considers it prunable (gone working tree) } +/** w6/G1: upstream sync state for one repo or worktree (impl: src/http/projects.ts + * readSyncState). Every field degrades independently — no upstream, detached HEAD, + * empty repo and never-fetched are all normal, and each leaves its field undefined. + * + * `ahead` is always trustworthy (local refs only). `behind` is only as fresh as + * `lastFetchMs`, because `@{u}` is a locally cached remote ref that a fetch is the + * only thing that moves — the UI MUST NOT render a stale `behind: 0` as "in sync". + * Likewise `upstream === undefined` means "nothing to compare against", which is + * NOT the same as "nothing to push" and must never render as synced. */ +export interface SyncState { + upstream?: string; // e.g. 'origin/develop'; undefined ⇒ branch tracks nothing + ahead?: number; // commits on HEAD not on @{u} + behind?: number; // commits on @{u} not on HEAD — trust only with a fresh lastFetchMs + lastFetchMs?: number; // FETCH_HEAD mtime; undefined ⇒ never fetched + detached?: boolean; // HEAD is not on a branch ⇒ no branch, no ahead/behind +} + +/** w6/G7: the git state of ONE worktree, fetched lazily per row. + * impl: src/http/projects.ts buildWorktreeState — GET /projects/worktree/state. */ +export interface WorktreeState { + path: string; + branch?: string; + sync?: SyncState; + dirtyCount?: number; +} + /** Detailed view of one project: branch/worktrees + its running sessions. - * impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions). */ + * impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions). + * NOTE: fields here are decoded by the Android/iOS clients — additive only. */ export interface ProjectDetail { name: string; path: string; isGit: boolean; branch?: string; dirty?: boolean; + dirtyCount?: number; // w6/G1: porcelain line count (same gate as `dirty`) + sync?: SyncState; // w6/G1: git repos only; undefined for a non-git dir worktrees: WorktreeInfo[]; // empty for a non-git dir sessions: ProjectSessionRef[]; // running sessions under this path (fresh) hasClaudeMd: boolean; // a CLAUDE.md exists at the project root @@ -662,7 +692,8 @@ export interface GitOpResult { count?: number; // stage: number of files affected commit?: string; // commit: short SHA of the new commit branch?: string; // push: branch that was pushed - remote?: string; // push: remote it was pushed to + remote?: string; // push/fetch: remote it talked to + lastFetchMs?: number; // fetch: FETCH_HEAD mtime after the fetch (w6/G2) } /* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */ @@ -727,12 +758,16 @@ export interface CommitLogEntry { hash: string; at: number; subject: string; + unpushed?: boolean; // w6/G4: reachable from HEAD but not from @{u} } /** GET /projects/log result. `truncated` = more commits exist beyond the cap. */ export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean; + /** w6/G4: upstream short name, used to label the pushed/unpushed boundary. + * Undefined ⇒ nothing to compare against, so no boundary may be drawn. */ + upstream?: string; } /* ─────────────────────── frontend (§5/§6.3) ──────────────────── */ diff --git a/test/git-log.test.ts b/test/git-log.test.ts index d1e147a..6a97092 100644 --- a/test/git-log.test.ts +++ b/test/git-log.test.ts @@ -149,3 +149,65 @@ describe('mountGitLog', () => { expect(host.querySelector('.proj-commit-row')).toBeNull() }) }) + +/* ── w6/G4 unpushed rows + the upstream boundary ───────────────────────────── */ + +describe('renderGitLog — unpushed boundary (w6 G4)', () => { + function entry(hash: string, subject: string, unpushed?: boolean) { + return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) } + } + + it('marks unpushed rows and draws the boundary exactly once, after the last one', () => { + const host = document.createElement('div') + renderGitLog(host, { + commits: [ + entry('aaa1111', 'newest local', true), + entry('bbb2222', 'older local', true), + entry('ccc3333', 'already pushed'), + entry('ddd4444', 'also pushed'), + ], + truncated: false, + upstream: 'origin/develop', + }) + + expect(host.querySelectorAll('.proj-commit-unpushed').length).toBe(2) + const boundaries = host.querySelectorAll('.proj-commit-boundary') + expect(boundaries.length).toBe(1) + expect(boundaries[0]!.textContent).toContain('origin/develop') + + const rows = Array.from(host.querySelectorAll('.proj-commit-row, .proj-commit-boundary')) + expect(rows[2]!.classList.contains('proj-commit-boundary')).toBe(true) + }) + + it('draws no boundary when nothing is unpushed', () => { + const host = document.createElement('div') + renderGitLog(host, { + commits: [entry('aaa1111', 'pushed')], + truncated: false, + upstream: 'origin/main', + }) + expect(host.querySelector('.proj-commit-boundary')).toBeNull() + expect(host.querySelector('.proj-commit-unpushed')).toBeNull() + }) + + it('draws no boundary without an upstream, even if rows claim to be unpushed', () => { + const host = document.createElement('div') + renderGitLog(host, { commits: [entry('aaa1111', 'local', true)], truncated: false }) + expect(host.querySelector('.proj-commit-boundary')).toBeNull() + }) + + it('treats a non-true `unpushed` from the server as pushed', () => { + const norm = normalizeGitLog({ + commits: [{ hash: 'aaa1111', at: 1, subject: 's', unpushed: 'yes' }], + truncated: false, + }) + expect(norm!.commits[0]!.unpushed).toBeUndefined() + }) + + it('keeps a string upstream and drops a junk one', () => { + expect(normalizeGitLog({ commits: [], truncated: false, upstream: 'origin/x' })!.upstream).toBe( + 'origin/x', + ) + expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined() + }) +}) diff --git a/test/http/git-log.test.ts b/test/http/git-log.test.ts index 91ff872..fe03395 100644 --- a/test/http/git-log.test.ts +++ b/test/http/git-log.test.ts @@ -166,3 +166,97 @@ describe('getGitLog (real git repo)', () => { await fs.rm(plain, { recursive: true, force: true }) }) }) + +// ── w6/G4 unpushed marking ───────────────────────────────────────────────────── +// The marks come from `rev-list`, never from row position — see markUnpushed. + +describe('getGitLog — unpushed marking (w6 G4)', { timeout: 30_000 }, () => { + async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout + } + + async function initRepo(cwd: string): Promise { + await fs.mkdir(cwd, { recursive: true }) + await git(cwd, ['init', '-q', '-b', 'main']) + await git(cwd, ['config', 'user.email', 't@t.local']) + await git(cwd, ['config', 'user.name', 'tester']) + await git(cwd, ['config', 'commit.gpgsign', 'false']) + } + + async function commit(cwd: string, file: string, msg: string): Promise { + await fs.writeFile(path.join(cwd, file), `${file}\n`, 'utf8') + await git(cwd, ['add', '.']) + await git(cwd, ['commit', '-q', '-m', msg]) + } + + it('marks nothing and names no upstream when the branch tracks nothing', async () => { + const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-noup-'))) + await initRepo(repo) + await commit(repo, 'a.txt', 'one') + + const res = await getGitLog(repo, { timeoutMs: 20_000 }) + expect(res.commits.length).toBe(1) + expect(res.upstream).toBeUndefined() + expect(res.commits.every((c) => c.unpushed !== true)).toBe(true) + }) + + it('marks only the commits absent from the upstream and names it', async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-up-'))) + const bare = path.join(root, 'bare.git') + await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare]) + + const repo = path.join(root, 'work') + await initRepo(repo) + await commit(repo, 'pushed.txt', 'pushed one') + await git(repo, ['remote', 'add', 'origin', bare]) + await git(repo, ['push', '-q', '-u', 'origin', 'main']) + + await commit(repo, 'local1.txt', 'local one') + await commit(repo, 'local2.txt', 'local two') + + const res = await getGitLog(repo, { timeoutMs: 20_000 }) + expect(res.upstream).toBe('origin/main') + const bySubject = new Map(res.commits.map((c) => [c.subject, c.unpushed === true])) + expect(bySubject.get('local two')).toBe(true) + expect(bySubject.get('local one')).toBe(true) + expect(bySubject.get('pushed one')).toBe(false) + }) + + it('marks an unpushed commit that sorts BELOW a pushed one (merged older branch)', async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-merge-'))) + const bare = path.join(root, 'bare.git') + await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare]) + + const repo = path.join(root, 'work') + await initRepo(repo) + await commit(repo, 'base.txt', 'base') + await git(repo, ['remote', 'add', 'origin', bare]) + await git(repo, ['push', '-q', '-u', 'origin', 'main']) + + // A side branch whose commit is BACKDATED so date-ordered `git log` puts it + // below the newer pushed tip — the exact shape "first N rows" gets wrong. + await git(repo, ['checkout', '-q', '-b', 'side']) + const old = '2001-01-01T00:00:00' + await fs.writeFile(path.join(repo, 'old.txt'), 'old\n', 'utf8') + await git(repo, ['add', '.']) + await execFileAsync( + 'git', + ['commit', '-q', '-m', 'backdated side work', '--date', old], + { cwd: repo, env: { ...process.env, GIT_COMMITTER_DATE: old } }, + ) + await git(repo, ['checkout', '-q', 'main']) + await git(repo, ['merge', '-q', '--no-ff', '-m', 'merge side', 'side']) + + const res = await getGitLog(repo, { timeoutMs: 20_000 }) + const side = res.commits.find((c) => c.subject === 'backdated side work') + expect(side).toBeDefined() + expect(side!.unpushed).toBe(true) + + // And it really is out of order, so the test is testing what it claims. + const idxSide = res.commits.findIndex((c) => c.subject === 'backdated side work') + const idxBase = res.commits.findIndex((c) => c.subject === 'base') + expect(idxSide).toBeGreaterThan(idxBase) + expect(res.commits[idxBase]!.unpushed).not.toBe(true) + }) +}) diff --git a/test/http/git-ops.test.ts b/test/http/git-ops.test.ts index 0368a62..5033fe7 100644 --- a/test/http/git-ops.test.ts +++ b/test/http/git-ops.test.ts @@ -25,6 +25,7 @@ import { stageFiles, commit, push, + fetch as gitFetch, } from '../../src/http/git-ops.js' const execFileP = promisify(execFile) @@ -148,7 +149,7 @@ describe('validateRepoFiles', () => { // ── stageFiles (real temp repos) ──────────────────────────────────────────────── -describe('stageFiles', () => { +describe('stageFiles', { timeout: 30_000 }, () => { it('stages the given files (git add) and unstages them (restore --staged)', async () => { if (!gitAvailable) return const repo = await makeRepo() @@ -183,7 +184,7 @@ describe('stageFiles', () => { // ── commit (real temp repos) ──────────────────────────────────────────────────── -describe('commit', () => { +describe('commit', { timeout: 30_000 }, () => { it('commits staged changes and returns a short SHA', async () => { if (!gitAvailable) return const repo = await makeRepo() @@ -256,7 +257,7 @@ describe('commit', () => { // ── push (real bare-repo remote, no network) ──────────────────────────────────── -describe('push', () => { +describe('push', { timeout: 30_000 }, () => { it('first push sets upstream (-u) and the bare remote receives the ref', async () => { if (!gitAvailable) return const repo = await makeRepo() @@ -320,3 +321,99 @@ describe('push', () => { expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 }) }) }) + +// ── w6/G2 fetch — read-only refresh of remote-tracking refs ──────────────────── +// Fetch exists so the panel can stop lying about `behind`. It touches no working +// tree, no index and no branch: the only thing it may change is refs/remotes. + +// Same reason as the G1 block: clone + push + fetch is well past a 5 s default. +describe('fetch (w6 G2)', { timeout: 30_000 }, () => { + it('refuses a non-git directory without spawning git', async () => { + if (!gitAvailable) return + const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-'))) + const res = await gitFetch(plain, { timeoutMs: TIMEOUT_MS }) + expect(res.ok).toBe(false) + expect(res.ok === false && res.status).toBe(404) + }) + + it('reports a plain message — never raw git stderr — when no remote exists', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS }) + expect(res.ok).toBe(false) + expect(res.ok === false && res.status).toBe(400) + expect(res.ok === false && res.error).toBe('No remote configured.') + }) + + it('fetches from the sole remote and leaves HEAD and the working tree untouched', async () => { + if (!gitAvailable) return + const bare = await makeBareRemote() + const repo = await makeRepo() + const git = async (args: string[]): Promise => { + await execFileP('git', args, { cwd: repo }) + } + await git(['remote', 'add', 'origin', bare]) + await git(['push', '-q', '-u', 'origin', 'main']) + + const headBefore = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() + const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS }) + expect(res.ok).toBe(true) + + const headAfter = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() + expect(headAfter).toBe(headBefore) + const status = (await execFileP('git', ['status', '--porcelain'], { cwd: repo })).stdout + expect(status.trim()).toBe('') + }) + + it('moves the remote-tracking ref so `behind` becomes true after fetching', async () => { + if (!gitAvailable) return + const bare = await makeBareRemote() + const repo = await makeRepo() + await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) + await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo }) + + // A second clone pushes one commit; `repo` cannot know until it fetches. + const other = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-other-'))) + const clone = path.join(other, 'c') + await execFileP('git', ['clone', '-q', bare, clone]) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone }) + await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone }) + await fs.writeFile(path.join(clone, 'remote.txt'), 'r\n', 'utf8') + await execFileP('git', ['add', '.'], { cwd: clone }) + await execFileP('git', ['commit', '-q', '-m', 'from elsewhere'], { cwd: clone }) + await execFileP('git', ['push', '-q'], { cwd: clone }) + + const countBefore = ( + await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo }) + ).stdout.trim() + expect(countBefore).toBe('0') // the stale lie the panel would have shown + + const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS }) + expect(res.ok).toBe(true) + + const countAfter = ( + await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo }) + ).stdout.trim() + expect(countAfter).toBe('1') + }) + + it('ignores any remote or refspec the caller tries to smuggle in', async () => { + if (!gitAvailable) return + const bare = await makeBareRemote() + const repo = await makeRepo() + await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) + await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo }) + + // The options type carries only timeoutMs; a caller passing extra keys must + // not be able to steer the command at a remote of their choosing. + const smuggled = { + timeoutMs: TIMEOUT_MS, + remote: 'file:///tmp/evil', + refspec: '+refs/heads/*:refs/heads/*', + } as unknown as { timeoutMs: number } + const res = await gitFetch(repo, smuggled) + expect(res.ok).toBe(true) + expect(res.ok === true && res.remote).toBe('origin') + }) +}) diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index 6329062..2484035 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -34,6 +34,9 @@ const { normalizeProject, makeProjectCard, makeSyncChip, + makeSyncBand, + makeWorktreeRow, + countSessionsByWorktree, renderProjectDetail, groupProjects, displayLabel, @@ -705,3 +708,189 @@ describe('displayLabel', () => { expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment') }) }) + +/* ── w6/G3 makeSyncBand — the header's ambient git state ────────────────────── */ +// The band's contract is mostly about what it must REFUSE to say: a stale or +// unknowable comparison must never be dressed up as "in sync". + +describe('makeSyncBand (w6 G3)', () => { + const NOW = 1_700_000_000_000 + const FRESH = NOW - 60_000 // a minute ago + const STALE = NOW - 26 * 60 * 60 * 1000 // 26 hours ago + + function band( + sync: import('../src/types.js').SyncState | undefined, + opts: { dirtyCount?: number } = {}, + ): HTMLElement | null { + return makeSyncBand(sync, { nowMs: NOW, ...opts }) + } + + it('renders nothing for a non-git project', () => { + expect(band(undefined)).toBeNull() + }) + + it('shows the single green in-sync state only when even with a fresh fetch', () => { + const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })! + expect(b.querySelector('.proj-sync-ok')).not.toBeNull() + expect(b.querySelector('.proj-sync-stale')).toBeNull() + }) + + it('refuses to call a stale comparison in sync', () => { + const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: STALE })! + expect(b.querySelector('.proj-sync-ok')).toBeNull() + expect(b.querySelector('.proj-sync-stale')).not.toBeNull() + }) + + it('treats a never-fetched repo as stale, not as in sync', () => { + const b = band({ upstream: 'origin/main', ahead: 0, behind: 0 })! + expect(b.querySelector('.proj-sync-ok')).toBeNull() + expect(b.querySelector('.proj-sync-stale')).not.toBeNull() + }) + + it('never flags ahead as stale — it does not depend on a fetch', () => { + const b = band({ upstream: 'origin/main', ahead: 9, behind: 0, lastFetchMs: STALE })! + const ahead = b.querySelector('.proj-sync-ahead')! + expect(ahead.textContent).toContain('9') + expect(ahead.classList.contains('proj-sync-stale')).toBe(false) + expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-stale')).toBe(true) + }) + + it('says "no upstream" instead of inventing counts, and is not green', () => { + const b = band({ ahead: undefined, behind: undefined, lastFetchMs: FRESH })! + expect(b.querySelector('.proj-sync-noupstream')).not.toBeNull() + expect(b.querySelector('.proj-sync-ahead')).toBeNull() + expect(b.querySelector('.proj-sync-behind')).toBeNull() + expect(b.querySelector('.proj-sync-ok')).toBeNull() + }) + + it('flags a detached HEAD, drops the counts and disables fetching', () => { + const b = band({ detached: true })! + expect(b.querySelector('.proj-sync-detached')).not.toBeNull() + expect(b.querySelector('.proj-sync-ahead')).toBeNull() + expect(b.querySelector('.proj-sync-ok')).toBeNull() + const btn = b.querySelector('.proj-sync-fetch') as HTMLButtonElement + expect(btn.disabled).toBe(true) + }) + + it('names the upstream so the arrows are not floating numbers', () => { + const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })! + expect(b.textContent).toContain('origin/develop') + }) + + it('counts dirty files instead of showing a bare dot', () => { + const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, { + dirtyCount: 3, + })! + expect(b.querySelector('.proj-sync-dirty')!.textContent).toContain('3') + }) + + it('omits the dirty chip entirely for a clean tree', () => { + const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, { + dirtyCount: 0, + })! + expect(b.querySelector('.proj-sync-dirty')).toBeNull() + }) +}) + +/* ── w6/G5 worktree panel ───────────────────────────────────────────────────── */ + +describe('makeWorktreeRow — per-worktree state (w6 G5)', () => { + const wt = (over: Partial = {}) => ({ + path: '/p/repo', + isMain: true, + isCurrent: true, + branch: 'develop', + ...over, + }) + + it('shows nothing extra for a worktree whose state was not probed', () => { + const row = makeWorktreeRow(wt({ isCurrent: false, isMain: false })) + expect(row.querySelector('.proj-sync-chip')).toBeNull() + }) + + it('says "no upstream" for a fresh worktree branch instead of looking settled', () => { + const row = makeWorktreeRow(wt(), undefined, { sync: { ahead: undefined } }) + expect(row.querySelector('.proj-sync-noupstream')).not.toBeNull() + expect(row.querySelector('.proj-sync-ahead')).toBeNull() + }) + + it('shows the unpushed count for a tracking worktree', () => { + const row = makeWorktreeRow(wt(), undefined, { + sync: { upstream: 'origin/develop', ahead: 9, behind: 0 }, + dirtyCount: 3, + }) + expect(row.querySelector('.proj-sync-ahead')!.textContent).toContain('9') + expect(row.querySelector('.proj-sync-dirty')!.textContent).toContain('3') + }) + + it('omits the dirty chip on a clean worktree', () => { + const row = makeWorktreeRow(wt(), undefined, { + sync: { upstream: 'origin/develop', ahead: 0, behind: 0 }, + dirtyCount: 0, + }) + expect(row.querySelector('.proj-sync-dirty')).toBeNull() + }) +}) + +/* ── w6/G7 session attribution across worktrees ─────────────────────────────── */ +// `.claude/worktrees/` lives INSIDE the main checkout, so this is exactly +// the case naive prefix matching double-counts. + +describe('countSessionsByWorktree (w6 G7)', () => { + const MAIN = '/Users/x/repo' + const WT = '/Users/x/repo/.claude/worktrees/feature' + + const wts = [ + { path: MAIN, isMain: true, isCurrent: true, branch: 'develop' }, + { path: WT, isMain: false, isCurrent: false, branch: 'worktree-feature' }, + ] + + function sess(id: string, cwd: string | undefined, exited = false) { + return { id, status: 'idle' as const, clientCount: 1, createdAt: 0, exited, ...(cwd !== undefined ? { cwd } : {}) } + } + + it('attributes a nested worktree session to the worktree, not to the parent', () => { + const counts = countSessionsByWorktree([sess('a', `${WT}/src`)], wts) + expect(counts.get(WT)).toBe(1) + expect(counts.get(MAIN)).toBe(0) + }) + + it('counts a session in the main checkout against the main worktree', () => { + const counts = countSessionsByWorktree([sess('a', `${MAIN}/src`)], wts) + expect(counts.get(MAIN)).toBe(1) + expect(counts.get(WT)).toBe(0) + }) + + it('ignores exited sessions and sessions with no cwd', () => { + const counts = countSessionsByWorktree( + [sess('a', `${MAIN}/src`, true), sess('b', undefined)], + wts, + ) + expect(counts.get(MAIN)).toBe(0) + }) + + it('does not treat a sibling with a shared prefix as inside', () => { + const counts = countSessionsByWorktree([sess('a', '/Users/x/repo-old/src')], wts) + expect(counts.get(MAIN)).toBe(0) + }) + + it('gives every worktree an entry so a row can render 0 without a lookup miss', () => { + const counts = countSessionsByWorktree([], wts) + expect(counts.get(MAIN)).toBe(0) + expect(counts.get(WT)).toBe(0) + }) +}) + +describe('makeWorktreeRow — session chip (w6 G7)', () => { + const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' } + + it('shows the running-session count', () => { + const row = makeWorktreeRow(wt, undefined, { sessionCount: 2 }) + expect(row.querySelector('.proj-wt-sessions')!.textContent).toContain('2') + }) + + it('omits the chip when nothing runs there', () => { + const row = makeWorktreeRow(wt, undefined, { sessionCount: 0 }) + expect(row.querySelector('.proj-wt-sessions')).toBeNull() + }) +}) diff --git a/test/projects.test.ts b/test/projects.test.ts index f8d0dc2..75f5687 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -14,9 +14,13 @@ vi.mock('../src/http/history.js', () => ({ listSessions: (...a: unknown[]) => mockListSessions(...a), })) -const { parseGitHead, buildProjects, buildProjectDetail, _clearProjectCache } = await import( - '../src/http/projects.js' -) +const { + parseGitHead, + buildProjects, + buildProjectDetail, + buildWorktreeState, + _clearProjectCache, +} = await import('../src/http/projects.js') const execFileP = promisify(execFile) @@ -90,7 +94,7 @@ afterEach(async () => { // ── parseGitHead (pure) ────────────────────────────────────────────────────────── -describe('parseGitHead', () => { +describe('parseGitHead', { timeout: 30_000 }, () => { it('extracts a simple branch name', () => { expect(parseGitHead('ref: refs/heads/main\n')).toBe('main') }) @@ -112,7 +116,7 @@ describe('parseGitHead', () => { // ── buildProjects: discovery (real temp dir) ───────────────────────────────────── -describe('buildProjects — discovery', () => { +describe('buildProjects — discovery', { timeout: 30_000 }, () => { it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1 await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2 @@ -184,7 +188,7 @@ describe('buildProjects — discovery', () => { // ── buildProjects: live-session merge ──────────────────────────────────────────── -describe('buildProjects — session merge', () => { +describe('buildProjects — session merge', { timeout: 30_000 }, () => { it('merges sessions into the owning project by cwd === path or under path', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') @@ -208,6 +212,7 @@ describe('buildProjects — session merge', () => { clientCount: 2, createdAt: 111, exited: false, + cwd: repoPath, // w6/G7 — needed to attribute a session to its worktree }) expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http') }) @@ -257,7 +262,7 @@ describe('buildProjects — session merge', () => { // ── buildProjects: history merge + sorting ─────────────────────────────────────── -describe('buildProjects — history merge & sorting', () => { +describe('buildProjects — history merge & sorting', { timeout: 30_000 }, () => { it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') @@ -335,7 +340,7 @@ describe('buildProjects — history merge & sorting', () => { // ── buildProjects: in-flight promise deduplication (Finding 4) ───────────────────── -describe('buildProjects — concurrent cache-miss deduplication', () => { +describe('buildProjects — concurrent cache-miss deduplication', { timeout: 30_000 }, () => { it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') await makeRepo(path.join(tmp, 'repoB'), 'dev') @@ -370,7 +375,7 @@ describe('buildProjects — concurrent cache-miss deduplication', () => { // ── buildProjects: dirty check (real git, when available) ───────────────────────── -describe('buildProjects — dirty check', () => { +describe('buildProjects — dirty check', { timeout: 30_000 }, () => { it('computes dirty via git status only when projectDirtyCheck is true', async () => { if (!(await gitAvailable())) return // git not installed -> skip const repo = path.join(tmp, 'gitrepo') @@ -404,7 +409,7 @@ describe('buildProjects — dirty check', () => { // ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ────────────── -describe('buildProjects — sync fields (W3 a)', () => { +describe('buildProjects — sync fields (W3 a)', { timeout: 30_000 }, () => { async function commit(cwd: string, file: string, msg: string): Promise { await fs.writeFile(path.join(cwd, file), `${file}\n`) await execFileP('git', ['add', '.'], { cwd }) @@ -482,7 +487,7 @@ describe('buildProjects — sync fields (W3 a)', () => { // ── buildProjectDetail ─────────────────────────────────────────────────────────── -describe('buildProjectDetail', () => { +describe('buildProjectDetail', { timeout: 30_000 }, () => { it('returns null for a relative path', async () => { expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull() }) @@ -566,3 +571,220 @@ describe('buildProjectDetail', () => { expect(d!.dirty).toBe(false) }) }) + +// ── w6/G1 sync state on the detail view ──────────────────────────────────────── +// The panel's whole point is to be trusted, so these tests pin the *absence* of +// numbers as hard as their presence: "no upstream" must never look like "in sync". + +// Each case spawns 6–12 sequential `git` processes; vitest's 5 s default is not a +// budget for that once the suite runs these files in parallel. +describe('buildProjectDetail — sync state (w6 G1)', { timeout: 30_000 }, () => { + async function commit(cwd: string, file: string, msg: string): Promise { + await fs.writeFile(path.join(cwd, file), `${file}\n`) + await execFileP('git', ['add', '.'], { cwd }) + await execFileP('git', ['commit', '-q', '-m', msg], { cwd }) + } + + async function initRepo(cwd: string): Promise { + await fs.mkdir(cwd, { recursive: true }) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd }) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd }) + await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd }) + } + + it('reports upstream name, ahead and a fetch timestamp for a tracking clone', async () => { + if (!(await gitAvailable())) return + + const upstream = path.join(tmp, 'up-detail') + await initRepo(upstream) + await commit(upstream, 'a.txt', 'first') + + const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-syncdetail-')) + const clone = path.join(cloneRoot, 'clone') + await execFileP('git', ['clone', '-q', upstream, clone]) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone }) + await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone }) + + await commit(clone, 'local.txt', 'local ahead') + await execFileP('git', ['fetch', '-q'], { cwd: clone }) + + const cfg = makeCfg({ projectDirtyCheck: true }) + const d = await buildProjectDetail(cfg, clone, []) + + expect(d!.sync).toBeDefined() + expect(d!.sync!.upstream).toBe('origin/main') + expect(d!.sync!.ahead).toBe(1) + expect(d!.sync!.behind).toBe(0) + expect(d!.sync!.detached).not.toBe(true) + expect(typeof d!.sync!.lastFetchMs).toBe('number') + + await fs.rm(cloneRoot, { recursive: true, force: true }) + }) + + it('leaves upstream AND ahead/behind undefined when the branch tracks nothing', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g1-noupstream') + await initRepo(repo) + await commit(repo, 'a.txt', 'only') + + const d = await buildProjectDetail(makeCfg({}), repo, []) + + // All three together — a UI that sees ahead===undefined must be able to tell + // "nothing to compare against" from "nothing to push". + expect(d!.sync!.upstream).toBeUndefined() + expect(d!.sync!.ahead).toBeUndefined() + expect(d!.sync!.behind).toBeUndefined() + }) + + it('never invents a fetch time for a repo that was never fetched', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g1-nofetch') + await initRepo(repo) + await commit(repo, 'a.txt', 'only') + + const d = await buildProjectDetail(makeCfg({}), repo, []) + expect(d!.sync!.lastFetchMs).toBeUndefined() + }) + + it('flags a detached HEAD and reports no branch or ahead/behind', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g1-detached') + await initRepo(repo) + await commit(repo, 'a.txt', 'first') + await commit(repo, 'b.txt', 'second') + const { stdout } = await execFileP('git', ['rev-parse', 'HEAD~1'], { cwd: repo }) + await execFileP('git', ['checkout', '-q', stdout.trim()], { cwd: repo }) + + const d = await buildProjectDetail(makeCfg({}), repo, []) + expect(d!.sync!.detached).toBe(true) + expect(d!.branch).toBeUndefined() + expect(d!.sync!.ahead).toBeUndefined() + expect(d!.sync!.behind).toBeUndefined() + }) + + it('counts dirty files instead of only flagging them', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g1-dirty') + await initRepo(repo) + await commit(repo, 'a.txt', 'first') + await fs.writeFile(path.join(repo, 'a.txt'), 'changed\n') + await fs.writeFile(path.join(repo, 'new.txt'), 'new\n') + + const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), repo, []) + expect(d!.dirtyCount).toBe(2) + expect(d!.dirty).toBe(true) // legacy field the mobile clients decode — still there + }) + + it('omits sync entirely for a non-git directory', async () => { + const plain = path.join(tmp, 'g1-plain') + await fs.mkdir(plain, { recursive: true }) + + const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), plain, []) + expect(d!.isGit).toBe(false) + expect(d!.sync).toBeUndefined() + expect(d!.dirtyCount).toBeUndefined() + }) +}) + +// ── w6/G6 concurrent-probe coalescing ───────────────────────────────────────── +// N devices watching one repo must cost ONE probe, not N. Deliberately in-flight +// only: nothing is cached across time, so the numbers can never go stale. + +describe('buildProjectDetail — coalescing (w6 G6)', { timeout: 30_000 }, () => { + it('serves concurrent probes of the same repo from one in-flight promise', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g6-coalesce') + await fs.mkdir(repo, { recursive: true }) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo }) + + const cfg = makeCfg({ projectDirtyCheck: true }) + const [a, b, c] = await Promise.all([ + buildProjectDetail(cfg, repo, []), + buildProjectDetail(cfg, repo, []), + buildProjectDetail(cfg, repo, []), + ]) + expect(a).toBe(b) // same object identity ⇒ one probe served all three + expect(b).toBe(c) + }) + + it('re-probes on the next call — the entry is dropped once it settles', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g6-fresh') + await fs.mkdir(repo, { recursive: true }) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo }) + + const cfg = makeCfg({ projectDirtyCheck: true }) + const first = await buildProjectDetail(cfg, repo, []) + expect(first!.dirtyCount).toBe(0) + + await fs.writeFile(path.join(repo, 'new.txt'), 'x\n') + const second = await buildProjectDetail(cfg, repo, []) + expect(second).not.toBe(first) // not a time-based cache + expect(second!.dirtyCount).toBe(1) // and it sees the change immediately + }) + + it('does not let two different repos share one probe', async () => { + if (!(await gitAvailable())) return + const one = path.join(tmp, 'g6-one') + const two = path.join(tmp, 'g6-two') + await fs.mkdir(one, { recursive: true }) + await fs.mkdir(two, { recursive: true }) + + const cfg = makeCfg({}) + const [a, b] = await Promise.all([ + buildProjectDetail(cfg, one, []), + buildProjectDetail(cfg, two, []), + ]) + expect(a!.path).toBe(one) + expect(b!.path).toBe(two) + }) +}) + +// ── w6/G7 per-worktree state ────────────────────────────────────────────────── + +describe('buildWorktreeState (w6 G7)', { timeout: 30_000 }, () => { + async function initRepo(cwd: string): Promise { + await fs.mkdir(cwd, { recursive: true }) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd }) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd }) + await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd }) + } + + it('reads a LINKED worktree, where .git is a file rather than a directory', async () => { + if (!(await gitAvailable())) return + const repo = path.join(tmp, 'g7-main') + await initRepo(repo) + await fs.writeFile(path.join(repo, 'a.txt'), 'a\n') + await execFileP('git', ['add', '.'], { cwd: repo }) + await execFileP('git', ['commit', '-q', '-m', 'first'], { cwd: repo }) + + const wtPath = path.join(repo, '.claude', 'worktrees', 'feature') + await execFileP('git', ['worktree', 'add', '-q', '-b', 'feature', wtPath], { cwd: repo }) + + // The pre-G1 reader assumed /.git was a directory and returned nothing here. + const dotGit = await fs.stat(path.join(wtPath, '.git')) + expect(dotGit.isDirectory()).toBe(false) + + const state = await buildWorktreeState(makeCfg({ projectDirtyCheck: true }), wtPath) + expect(state!.path).toBe(wtPath) + expect(state!.branch).toBe('feature') + expect(state!.dirtyCount).toBe(0) + // A fresh worktree branch tracks nothing — it must say so, not look settled. + expect(state!.sync!.upstream).toBeUndefined() + expect(state!.sync!.ahead).toBeUndefined() + }) + + it('returns null for a directory that is not a git repo', async () => { + const plain = path.join(tmp, 'g7-plain') + await fs.mkdir(plain, { recursive: true }) + expect(await buildWorktreeState(makeCfg({}), plain)).toBeNull() + }) + + it('returns null for a relative path and for one that does not exist', async () => { + expect(await buildWorktreeState(makeCfg({}), 'relative/path')).toBeNull() + expect(await buildWorktreeState(makeCfg({}), path.join(tmp, 'g7-missing'))).toBeNull() + }) +})