Merge w6-project-git-panel: ambient git state on the project detail page
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Adds the sync band (upstream, ↑ to push, ↓ to pull, staleness), the unpushed boundary in the commit list, a reworked worktree panel with per-row state and session counts, and a read-only POST /projects/git/fetch so the numbers can be refreshed without a pull. The governing rule throughout: only one state may look reassuring — even with upstream and a fetch inside the hour. behind is derived from a locally cached ref, so a stale zero is a guess and is marked as one; no upstream renders as 'no upstream', never as synced. Also fixes the test suite's long-standing flakiness (5-11 shifting failures per run) so the feature could actually be gated on it.
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/` 仍被忽略。
|
||||
|
||||
439
docs/mockups/project-detail-git.html
Normal file
439
docs/mockups/project-detail-git.html
Normal file
@@ -0,0 +1,439 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project detail — Git 面板设计稿</title>
|
||||
<style>
|
||||
/* ── tokens: 沿用 project-manager-final.html 的 amber 主题,不另起炉灶 ── */
|
||||
:root{
|
||||
--bg:#100f0d; --panel:#181613; --card:#1b1916; --sunk:#0c0b09;
|
||||
--border:rgba(255,255,255,.07); --border-strong:rgba(255,255,255,.14);
|
||||
--text:#ece9e3; --dim:#a8a299; --faint:#6f6a61;
|
||||
--accent:#e3a64a; --accent-2:#c9892f; --soft:rgba(227,166,74,.14); --line:rgba(227,166,74,.36);
|
||||
--ink:#1a1305;
|
||||
/* 语义色 —— 与 accent 分开,全部取自项目既有调色板 */
|
||||
--dirty:#e0975a; /* 未提交 */
|
||||
--warn:#cf6b4f; /* 可能在说谎 / 无 upstream(取自 clay 主题) */
|
||||
--ok:#7d9b76; /* 已同步 */
|
||||
--glow:rgba(227,166,74,.10);
|
||||
--radius:9px; --radius-lg:14px;
|
||||
--ui:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
/* 文档外壳(随浏览器主题走);内部 mock 画布恒为暗色 —— 它是一张产品截图 */
|
||||
--doc-bg:#f6f4f0; --doc-text:#221f1a; --doc-dim:#6b655c; --doc-rule:rgba(0,0,0,.10);
|
||||
--doc-card:#fffefb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{ --doc-bg:#0b0a09; --doc-text:#e8e5df; --doc-dim:#98928a;
|
||||
--doc-rule:rgba(255,255,255,.10); --doc-card:#141310; }
|
||||
}
|
||||
:root[data-theme="dark"]{ --doc-bg:#0b0a09; --doc-text:#e8e5df; --doc-dim:#98928a;
|
||||
--doc-rule:rgba(255,255,255,.10); --doc-card:#141310; }
|
||||
:root[data-theme="light"]{ --doc-bg:#f6f4f0; --doc-text:#221f1a; --doc-dim:#6b655c;
|
||||
--doc-rule:rgba(0,0,0,.10); --doc-card:#fffefb; }
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--doc-bg);color:var(--doc-text);font-family:var(--ui);
|
||||
-webkit-font-smoothing:antialiased;line-height:1.55}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:56px 24px 96px;display:flex;flex-direction:column;gap:52px}
|
||||
|
||||
/* ── 文档头 ── */
|
||||
.doc-head{display:flex;flex-direction:column;gap:10px}
|
||||
.eyebrow{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--doc-dim);
|
||||
font-family:var(--mono)}
|
||||
h1{font-size:30px;line-height:1.2;margin:0;font-weight:640;text-wrap:balance;letter-spacing:-.01em}
|
||||
.lede{margin:0;max-width:64ch;color:var(--doc-dim);font-size:15px}
|
||||
.facts{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
|
||||
.fact{font-family:var(--mono);font-size:12px;color:var(--doc-dim);
|
||||
border:1px solid var(--doc-rule);border-radius:999px;padding:4px 11px;
|
||||
font-variant-numeric:tabular-nums}
|
||||
.fact b{color:var(--doc-text);font-weight:600}
|
||||
|
||||
/* ── 每个提案块 ── */
|
||||
.block{display:flex;flex-direction:column;gap:14px}
|
||||
.block-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;
|
||||
border-bottom:1px solid var(--doc-rule);padding-bottom:10px}
|
||||
.ord{font-family:var(--mono);font-size:12px;color:var(--accent-2);letter-spacing:.08em;
|
||||
border:1px solid var(--line);border-radius:5px;padding:2px 7px;flex:none}
|
||||
h2{font-size:19px;margin:0;font-weight:620;letter-spacing:-.005em}
|
||||
.tag{font-size:11px;font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;
|
||||
border-radius:999px;padding:3px 9px;flex:none}
|
||||
.tag.new{color:var(--accent);background:var(--soft);border:1px solid var(--line)}
|
||||
.tag.wire{color:var(--ok);background:rgba(125,155,118,.13);border:1px solid rgba(125,155,118,.34)}
|
||||
.note{margin:0;color:var(--doc-dim);font-size:14.5px;max-width:70ch}
|
||||
.note code{font-family:var(--mono);font-size:12.5px;background:var(--doc-rule);
|
||||
padding:1px 5px;border-radius:4px;color:var(--doc-text)}
|
||||
|
||||
/* ── mock 画布:恒暗,模拟真实 app ── */
|
||||
.mock{background:radial-gradient(760px 300px at 78% -30%, var(--glow), transparent 62%),var(--bg);
|
||||
border:1px solid var(--border-strong);border-radius:var(--radius-lg);
|
||||
padding:22px;color:var(--text);font-size:14px;overflow-x:auto;
|
||||
box-shadow:0 14px 40px rgba(0,0,0,.34)}
|
||||
.mock *{font-variant-numeric:tabular-nums}
|
||||
.label{font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--faint);
|
||||
margin-bottom:9px;display:flex;align-items:center;gap:10px}
|
||||
.label .count{color:var(--dim);letter-spacing:.04em}
|
||||
|
||||
/* ── ① 同步状态条 ── */
|
||||
.proj-line{display:flex;align-items:center;gap:11px;flex-wrap:wrap;margin-bottom:3px}
|
||||
.proj-name{font-size:21px;font-weight:650;letter-spacing:-.01em}
|
||||
.chip{font-family:var(--mono);font-size:11.5px;border-radius:6px;padding:3px 9px;
|
||||
display:inline-flex;align-items:center;gap:6px;flex:none;white-space:nowrap}
|
||||
.chip.branch{background:var(--soft);color:var(--accent);border:1px solid var(--line)}
|
||||
.chip.dirty{background:rgba(224,151,90,.13);color:var(--dirty);border:1px solid rgba(224,151,90,.32)}
|
||||
.chip.ok{background:rgba(125,155,118,.12);color:var(--ok);border:1px solid rgba(125,155,118,.30)}
|
||||
.chip.warn{background:rgba(207,107,79,.14);color:var(--warn);border:1px solid rgba(207,107,79,.36)}
|
||||
.chip.mute{background:var(--sunk);color:var(--faint);border:1px solid var(--border)}
|
||||
.path{font-family:var(--mono);font-size:12px;color:var(--faint);margin-bottom:16px}
|
||||
|
||||
.sync{display:flex;align-items:stretch;gap:0;background:var(--sunk);
|
||||
border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
||||
.sync-cell{padding:12px 18px;display:flex;flex-direction:column;gap:3px;
|
||||
border-right:1px solid var(--border);min-width:0}
|
||||
.sync-cell:last-child{border-right:0;margin-left:auto;justify-content:center}
|
||||
.sync-k{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint)}
|
||||
.sync-v{font-family:var(--mono);font-size:15px;display:flex;align-items:center;gap:7px}
|
||||
.sync-v .big{font-size:19px;font-weight:600;letter-spacing:-.02em}
|
||||
.up{color:var(--accent)} .down{color:var(--dim)} .zero{color:var(--faint)}
|
||||
.sub{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
||||
.sub.bad{color:var(--warn)}
|
||||
.btn{all:unset;cursor:pointer;font-family:var(--ui);font-size:12.5px;font-weight:560;
|
||||
padding:7px 15px;border-radius:7px;background:var(--accent);color:var(--ink);
|
||||
text-align:center;white-space:nowrap}
|
||||
.btn:focus-visible{outline:2px solid var(--text);outline-offset:2px}
|
||||
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--border-strong)}
|
||||
|
||||
/* ── ② commits + push 分界 ── */
|
||||
.commits{display:flex;flex-direction:column}
|
||||
.c-row{display:grid;grid-template-columns:22px 74px 52px 1fr;align-items:baseline;
|
||||
gap:10px;padding:4px 8px;border-radius:5px;position:relative}
|
||||
.c-row.unpushed{background:linear-gradient(90deg,var(--soft),transparent 55%)}
|
||||
.c-row.unpushed::before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;
|
||||
background:var(--accent);border-radius:2px}
|
||||
.c-mark{font-family:var(--mono);font-size:12px;color:var(--accent);text-align:center}
|
||||
.c-sha{font-family:var(--mono);font-size:12.5px;color:var(--accent-2)}
|
||||
.c-age{font-family:var(--mono);font-size:11.5px;color:var(--faint);text-align:right}
|
||||
.c-msg{font-size:13.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.c-row:not(.unpushed) .c-msg{color:var(--dim)}
|
||||
.boundary{display:flex;align-items:center;gap:12px;margin:9px 0 7px;padding-left:8px}
|
||||
.boundary .rule{flex:1;height:1px;background:var(--border-strong)}
|
||||
.boundary .who{font-family:var(--mono);font-size:11px;color:var(--dim);letter-spacing:.04em;
|
||||
background:var(--sunk);border:1px solid var(--border);border-radius:999px;padding:3px 11px}
|
||||
.more{font-family:var(--mono);font-size:11.5px;color:var(--faint);padding:9px 8px 0}
|
||||
|
||||
/* ── ③ worktrees ── */
|
||||
.wt{display:flex;flex-direction:column;gap:8px}
|
||||
.wt-row{display:grid;grid-template-columns:1fr auto;gap:14px;align-items:center;
|
||||
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:12px 15px}
|
||||
.wt-row.current{border-color:var(--line);background:linear-gradient(90deg,var(--soft),var(--card) 46%)}
|
||||
.wt-main{display:flex;flex-direction:column;gap:5px;min-width:0}
|
||||
.wt-top{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
||||
.wt-branch{font-family:var(--mono);font-size:13.5px;font-weight:600;color:var(--text)}
|
||||
.wt-path{font-family:var(--mono);font-size:11.5px;color:var(--faint);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wt-right{display:flex;align-items:center;gap:8px;flex:none}
|
||||
.sess{font-size:11.5px;color:var(--dim);font-family:var(--mono);display:flex;align-items:center;gap:5px}
|
||||
.sess .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:none}
|
||||
.sess .dot.idle{background:var(--faint)}
|
||||
|
||||
/* ── ④ 状态变体 ── */
|
||||
.states{display:grid;grid-template-columns:repeat(auto-fit,minmax(232px,1fr));gap:11px}
|
||||
.state{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:13px 15px;display:flex;flex-direction:column;gap:8px}
|
||||
.state-k{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint)}
|
||||
.state-d{font-size:12px;color:var(--dim);line-height:1.45}
|
||||
|
||||
/* ── ⑤ 成本表 ── */
|
||||
.tbl-wrap{overflow-x:auto;border:1px solid var(--doc-rule);border-radius:var(--radius)}
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;background:var(--doc-card);min-width:640px}
|
||||
th,td{text-align:left;padding:11px 15px;border-bottom:1px solid var(--doc-rule);vertical-align:top}
|
||||
th{font-size:11px;letter-spacing:.13em;text-transform:uppercase;color:var(--doc-dim);font-weight:600}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td code{font-family:var(--mono);font-size:12px;color:var(--doc-text)}
|
||||
td.status{white-space:nowrap;font-family:var(--mono);font-size:12px}
|
||||
.yes{color:var(--ok)} .no{color:var(--warn)}
|
||||
|
||||
.foot{border-top:1px solid var(--doc-rule);padding-top:20px;color:var(--doc-dim);font-size:13.5px;
|
||||
max-width:70ch}
|
||||
.foot strong{color:var(--doc-text)}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){ *{animation:none!important;transition:none!important} }
|
||||
@media (max-width:720px){
|
||||
.wrap{padding:36px 16px 64px;gap:40px}
|
||||
.sync{flex-direction:column}
|
||||
.sync-cell{border-right:0;border-bottom:1px solid var(--border)}
|
||||
.sync-cell:last-child{margin-left:0}
|
||||
.c-row{grid-template-columns:18px 66px 1fr;row-gap:0}
|
||||
.c-age{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="doc-head">
|
||||
<div class="eyebrow">web-terminal · 设计稿</div>
|
||||
<h1>项目详情页 · Git 面板</h1>
|
||||
<p class="lede">把「有没有 commit 没推」「现在在哪个 worktree」变成不用问就能看见的信息。
|
||||
下面每一块都用 <code style="font-family:var(--mono);font-size:13px">web-terminal</code> 此刻的真实状态渲染 —— 包括那个正在说谎的 ↓0。</p>
|
||||
<div class="facts">
|
||||
<span class="fact">分支 <b>develop</b></span>
|
||||
<span class="fact">未推送 <b>9</b></span>
|
||||
<span class="fact">未提交 <b>3</b></span>
|
||||
<span class="fact">上次 fetch <b>19 天前</b></span>
|
||||
<span class="fact">worktree <b>2</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ───────────────── ① 同步状态条 ───────────────── -->
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<span class="ord">01</span>
|
||||
<h2>同步状态条</h2>
|
||||
<span class="tag wire">接线即可</span>
|
||||
</div>
|
||||
<p class="note">替换现在标题行里那个孤零零的圆点。四个格子回答四个问题:我在哪个分支、有多少要推、有多少要拉、<b>这两个数字还能不能信</b>。</p>
|
||||
|
||||
<div class="mock">
|
||||
<div class="proj-line">
|
||||
<span class="proj-name">web-terminal</span>
|
||||
<span class="chip branch">develop</span>
|
||||
<span class="chip dirty">● 3 未提交</span>
|
||||
</div>
|
||||
<div class="path">/Users/yiukai/Documents/git/web-terminal</div>
|
||||
|
||||
<div class="sync">
|
||||
<div class="sync-cell">
|
||||
<span class="sync-k">Upstream</span>
|
||||
<span class="sync-v"><span style="color:var(--dim)">origin/develop</span></span>
|
||||
</div>
|
||||
<div class="sync-cell">
|
||||
<span class="sync-k">待推送</span>
|
||||
<span class="sync-v up"><span class="big">↑ 9</span></span>
|
||||
<span class="sub">9 个 commit 只在本地</span>
|
||||
</div>
|
||||
<div class="sync-cell">
|
||||
<span class="sync-k">待拉取</span>
|
||||
<span class="sync-v zero"><span class="big">↓ 0</span>
|
||||
<span class="chip warn">存疑</span></span>
|
||||
<span class="sub bad">上次 fetch 在 19 天前 — 这个 0 不可信</span>
|
||||
</div>
|
||||
<div class="sync-cell">
|
||||
<button class="btn" type="button">Fetch</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">「存疑」标记是这块最重要的设计。<code>ahead/behind</code> 比的是本地缓存的
|
||||
<code>@{u}</code>,不 fetch 就永远不会变 —— 没有这个标记,面板会<b>自信地告诉你已经同步</b>,而这正是它此刻在做的事。
|
||||
规则:<code>FETCH_HEAD</code> 的 mtime 超过阈值(建议 1 小时)就给 ↓ 挂上存疑,并把 Fetch 按钮点亮。
|
||||
Fetch 是只读的,不 pull、不 merge —— 符合写侧一贯的克制。</p>
|
||||
</section>
|
||||
|
||||
<!-- ───────────────── ② commits + push 分界线 ───────────────── -->
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<span class="ord">02</span>
|
||||
<h2>提交列表 · 推送分界线</h2>
|
||||
<span class="tag new">新增</span>
|
||||
</div>
|
||||
<p class="note">你要的不是「有 9 个没推」这个数字,而是<b>哪 9 个</b>。复用现有的 commit 列表,给未推送的加左侧竖线和 ↑,在边界上画出 upstream 的位置 —— 一眼看完,零点击。</p>
|
||||
|
||||
<div class="mock">
|
||||
<div class="label">Recent commits <span class="count">↑ 9 未推送</span></div>
|
||||
<div class="commits">
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">553a00c</span><span class="c-age">8m</span><span class="c-msg">docs(progress): log the three leftover fixes + live verification</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">d92caed</span><span class="c-age">22m</span><span class="c-msg">Merge fix-tunnel-leftovers: gitignore-swallowed source, host identifiers in logs</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">2a602d5</span><span class="c-age">31m</span><span class="c-msg">fix(tunnel): close the three leftovers from the renewal-deadlock fix</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">befe677</span><span class="c-age">1h</span><span class="c-msg">Merge fix-cert-renew-deadlock: break the expired-leaf renewal deadlock</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">7064a39</span><span class="c-age">1h</span><span class="c-msg">chore: drop accidentally committed node_modules symlinks</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">0970c62</span><span class="c-age">1h</span><span class="c-msg">docs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">5509c81</span><span class="c-age">2h</span><span class="c-msg">fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">f3f4d8b</span><span class="c-age">2h</span><span class="c-msg">fix(tunnel): break the expired-leaf renewal deadlock</span></div>
|
||||
<div class="c-row unpushed"><span class="c-mark">↑</span><span class="c-sha">b1bc50c</span><span class="c-age">3h</span><span class="c-msg">fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK</span></div>
|
||||
|
||||
<div class="boundary"><span class="who">origin/develop</span><span class="rule"></span></div>
|
||||
|
||||
<div class="c-row"><span class="c-mark"></span><span class="c-sha">1dbed54</span><span class="c-age">6d</span><span class="c-msg">docs(progress): log the zero-touch enrollment + control-panel session</span></div>
|
||||
<div class="c-row"><span class="c-mark"></span><span class="c-sha">675de77</span><span class="c-age">9d</span><span class="c-msg">feat(control-panel): web admin UI for the zero-touch tunnel</span></div>
|
||||
<div class="c-row"><span class="c-mark"></span><span class="c-sha">7c1d433</span><span class="c-age">10d</span><span class="c-msg">Merge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)</span></div>
|
||||
<div class="c-row"><span class="c-mark"></span><span class="c-sha">0b35dc0</span><span class="c-age">10d</span><span class="c-msg">feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)</span></div>
|
||||
</div>
|
||||
<div class="more">显示最近 20 条</div>
|
||||
</div>
|
||||
|
||||
<p class="note">分界线不是装饰 —— 它是 <code>origin/develop</code> 在时间轴上的真实位置。分支没有 upstream 时不画线,改在顶部显示「无 upstream」(见 ④)。</p>
|
||||
</section>
|
||||
|
||||
<!-- ───────────────── ③ worktrees ───────────────── -->
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<span class="ord">03</span>
|
||||
<h2>Worktree 面板</h2>
|
||||
<span class="tag new">重做</span>
|
||||
</div>
|
||||
<p class="note">现在这块叫 BRANCH、只有一行。按「每个 session 一个 worktree」的规矩,它很快会变成 4–5 行,而且每一行要能独立回答:哪个分支、在哪、干净不干净、推没推、里面跑着谁。<b>这是编辑器给不了的视角</b> —— Cursor 一次只看得见一个 worktree。</p>
|
||||
|
||||
<div class="mock">
|
||||
<div class="label">Worktrees <span class="count">2</span></div>
|
||||
<div class="wt">
|
||||
<div class="wt-row current">
|
||||
<div class="wt-main">
|
||||
<div class="wt-top">
|
||||
<span class="wt-branch">develop</span>
|
||||
<span class="chip mute">当前</span>
|
||||
<span class="chip dirty">● 3</span>
|
||||
<span class="chip branch">↑ 9</span>
|
||||
</div>
|
||||
<div class="wt-path">/Users/yiukai/Documents/git/web-terminal</div>
|
||||
</div>
|
||||
<div class="wt-right">
|
||||
<span class="sess"><span class="dot"></span>2 sessions</span>
|
||||
<button class="btn ghost" type="button">打开</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wt-row">
|
||||
<div class="wt-main">
|
||||
<div class="wt-top">
|
||||
<span class="wt-branch">worktree-project-detail-git-mockup</span>
|
||||
<span class="chip ok">干净</span>
|
||||
<span class="chip warn">无 upstream</span>
|
||||
</div>
|
||||
<div class="wt-path">.claude/worktrees/project-detail-git-mockup</div>
|
||||
</div>
|
||||
<div class="wt-right">
|
||||
<span class="sess"><span class="dot"></span>1 session</span>
|
||||
<button class="btn ghost" type="button">打开</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">「无 upstream」必须显式标出。新建的 worktree 分支天然没有 upstream,<code>ahead/behind</code> 全是
|
||||
<code>undefined</code> —— 绝不能因为「没有数字」就渲染成绿色的「已同步」。这是这套 UI 最容易犯的错。</p>
|
||||
</section>
|
||||
|
||||
<!-- ───────────────── ④ 状态变体 ───────────────── -->
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<span class="ord">04</span>
|
||||
<h2>状态变体</h2>
|
||||
<span class="tag new">规格</span>
|
||||
</div>
|
||||
<p class="note">同步条的全部形态。做设计稿的意义一半在这里 —— 边界情况不定死,实现时就会各写各的。</p>
|
||||
|
||||
<div class="mock">
|
||||
<div class="states">
|
||||
<div class="state">
|
||||
<span class="state-k">已同步 · 刚 fetch</span>
|
||||
<div><span class="chip ok">✓ 已同步</span></div>
|
||||
<span class="state-d">↑0 ↓0 且 fetch 在阈值内。唯一可以显示绿色的情况。</span>
|
||||
</div>
|
||||
<div class="state">
|
||||
<span class="state-k">待推送</span>
|
||||
<div><span class="chip branch">↑ 9</span></div>
|
||||
<span class="state-d">主状态。数字用 accent,提交列表同步高亮。</span>
|
||||
</div>
|
||||
<div class="state">
|
||||
<span class="state-k">待拉取</span>
|
||||
<div><span class="chip mute">↓ 3</span></div>
|
||||
<span class="state-d">用中性色而非警告色 —— 落后不是错误,只是信息。</span>
|
||||
</div>
|
||||
<div class="state">
|
||||
<span class="state-k">数据陈旧</span>
|
||||
<div><span class="chip warn">存疑</span></div>
|
||||
<span class="state-d">FETCH_HEAD 超过 1 小时。挂在 ↓ 上,不挂在 ↑ 上 —— ↑ 不依赖 fetch,永远准。</span>
|
||||
</div>
|
||||
<div class="state">
|
||||
<span class="state-k">无 upstream</span>
|
||||
<div><span class="chip warn">无 upstream</span></div>
|
||||
<span class="state-d">新 worktree 分支的默认状态。不显示 ↑↓,不显示绿色。</span>
|
||||
</div>
|
||||
<div class="state">
|
||||
<span class="state-k">HEAD 游离</span>
|
||||
<div><span class="chip warn">detached HEAD</span></div>
|
||||
<span class="state-d">不显示分支名和 ↑↓,只显示短 sha。push 按钮禁用。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note"><b>只有一个状态配用绿色:↑0 ↓0 且刚 fetch 过。</b>其余一律中性或警示 —— 绿色在这里的含义是「我确认过了,你不用管」,给错了就是骗人。</p>
|
||||
</section>
|
||||
|
||||
<!-- ───────────────── ⑤ 数据来源与成本 ───────────────── -->
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<span class="ord">05</span>
|
||||
<h2>数据来源与成本</h2>
|
||||
</div>
|
||||
<p class="note">编号 = 建议实现顺序。01 和 02 共用同一份数据,应该一个 PR 一起做。</p>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>元素</th><th>数据来源</th><th>后端现状</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>分支名</td>
|
||||
<td><code>.git/HEAD</code>(不 spawn 进程)</td>
|
||||
<td class="status yes">✓ 已有</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>↑ ahead / ↓ behind</td>
|
||||
<td><code>git rev-list --count --left-right @{u}...HEAD</code></td>
|
||||
<td class="status no">助手已有,<code>ProjectDetail</code> 没接</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>upstream 名字</td>
|
||||
<td><code>git rev-parse --abbrev-ref @{u}</code></td>
|
||||
<td class="status no">缺</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未推送的 sha 集合</td>
|
||||
<td><code>git log @{u}..HEAD --format=%H</code></td>
|
||||
<td class="status no">缺</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>上次 fetch 时间</td>
|
||||
<td><code>.git/FETCH_HEAD</code> 的 mtime(不 spawn)</td>
|
||||
<td class="status no">缺</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fetch 按钮</td>
|
||||
<td><code>git fetch --no-tags</code>,只读</td>
|
||||
<td class="status no">缺(需新路由)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未提交数量</td>
|
||||
<td><code>git status --porcelain</code> 的行数</td>
|
||||
<td class="status no">只有布尔 <code>dirty</code>,没有计数</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>worktree 列表</td>
|
||||
<td><code>git worktree list --porcelain</code></td>
|
||||
<td class="status yes">✓ 已有</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>每个 worktree 的 dirty / ↑↓</td>
|
||||
<td>同上,逐 worktree 跑一次</td>
|
||||
<td class="status no">缺 — 成本最高的一项</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="note"><b>成本控制。</b>只有分支名和 fetch 时间是读文件、免费的;其余每项都要 spawn 一次 <code>git</code>,而这台机器同时在跑 Claude Code 和构建。三条便宜的对策:按仓库去重(N 个 tab 同一个 repo 只查一次)、盯
|
||||
<code>.git/HEAD</code> / <code>.git/index</code> / <code>.git/refs</code> 的 mtime 变了才查、页面隐藏时暂停。逐 worktree 的 ↑↓ 建议懒加载 —— 展开那一行才算。</p>
|
||||
</section>
|
||||
|
||||
<p class="foot"><strong>范围外。</strong>这一页不加 reset、不加切分支 checkout、不加 clean、不加 force-push。
|
||||
stage / commit / push 已经在 <code style="font-family:var(--mono);font-size:12.5px">diff.ts</code> 里,维持现状。
|
||||
这块面板的职责是<strong>告诉你发生了什么</strong>,不是替代编辑器去处理它。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
235
docs/plans/w6-project-git-panel.md
Normal file
235
docs/plans/w6-project-git-panel.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# w6 — Project detail: the Git panel
|
||||
|
||||
Design mock: [`docs/mockups/project-detail-git.html`](../mockups/project-detail-git.html)
|
||||
|
||||
Make "do I have commits I haven't pushed / which worktree am I in" **ambient** on the
|
||||
project detail page — visible without typing a git command. Read-mostly: the panel's
|
||||
job is to *tell you what happened*, not to replace the editor in handling it.
|
||||
|
||||
## Design rule that drives everything (read first)
|
||||
|
||||
`ahead`/`behind` compare against `@{u}` — a **locally cached** remote ref. Without a
|
||||
fetch it never changes. Right now this repo reports `↓0` while `.git/FETCH_HEAD` is
|
||||
19 days old: the number is a lie, and a naive panel would render it as a confident
|
||||
"in sync".
|
||||
|
||||
Therefore:
|
||||
|
||||
- **`↑ ahead` is always trustworthy** (it only needs local refs) → never flagged.
|
||||
- **`↓ behind` is only as fresh as the last fetch** → flagged `stale` once
|
||||
`FETCH_HEAD` mtime exceeds `FETCH_STALE_MS` (1 h).
|
||||
- **Exactly one state may render green: `↑0 ↓0` AND fetch within the threshold.**
|
||||
Everything else is neutral or warning. Green here means "I checked, ignore this" —
|
||||
getting it wrong is lying to the user.
|
||||
- **No upstream ⇒ no `↑↓` at all**, rendered as an explicit `no upstream` chip.
|
||||
A new worktree branch has no upstream, so `ahead`/`behind` are `undefined` — never
|
||||
let "no number" fall through to the green path. This is the single easiest bug to ship.
|
||||
|
||||
## Contract
|
||||
|
||||
### Types (`src/types.ts`)
|
||||
|
||||
New, and **additive only** — `android/api-client/.../Projects.kt` and
|
||||
`ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift` decode these shapes.
|
||||
Never rename or remove an existing field; every new field is optional.
|
||||
|
||||
```ts
|
||||
/** G1: upstream sync state for one repo/worktree. Every field degrades
|
||||
* independently (no upstream / detached HEAD / empty repo / never fetched). */
|
||||
export interface SyncState {
|
||||
upstream?: string; // "origin/develop"; undefined ⇒ no upstream configured
|
||||
ahead?: number; // commits on HEAD not on @{u}
|
||||
behind?: number; // commits on @{u} not on HEAD — only as fresh as lastFetchMs
|
||||
lastFetchMs?: number; // .git/FETCH_HEAD mtime; undefined ⇒ never fetched
|
||||
detached?: boolean; // HEAD is detached ⇒ no branch, no ahead/behind
|
||||
}
|
||||
```
|
||||
|
||||
`ProjectDetail` gains:
|
||||
|
||||
```ts
|
||||
sync?: SyncState; // git repos only; undefined for non-git dirs
|
||||
dirtyCount?: number; // `git status --porcelain` line count (gated by projectDirtyCheck)
|
||||
```
|
||||
|
||||
`dirty?: boolean` **stays** (mobile clients read it). `dirtyCount` is the additive refinement.
|
||||
|
||||
### Log response (`src/http/git-log.ts`)
|
||||
|
||||
`CommitLogEntry` gains `unpushed?: boolean`; `GitLogResult` gains `upstream?: string`.
|
||||
|
||||
**Marking happens server-side**, not on the client. (The first draft of this plan
|
||||
shipped the SHA set to the client and matched there — wrong split: `%h` is
|
||||
abbreviated while `rev-list` yields full SHAs, and the two can disagree on width,
|
||||
so the matching rule belongs next to the data that defines it.) The server matches
|
||||
by prefix, bounded by `GIT_LOG_MAX × UNPUSHED_MAX` comparisons.
|
||||
|
||||
> **Why a second spawn and not "the first `ahead` rows".** `git log` is date-ordered;
|
||||
> an unpushed merge can pull in commits with older dates that interleave below pushed
|
||||
> ones. "First N are unpushed" is wrong in exactly the repo shape we use (merge-per-worktree),
|
||||
> and it fails in the dangerous direction — labelling an unpushed commit as pushed.
|
||||
> Use `git rev-list @{u}..HEAD --max-count=200`.
|
||||
|
||||
The client draws the boundary only when `upstream` is present, exactly once, after
|
||||
the last marked row; `unpushed` is honoured only when strictly `true`.
|
||||
|
||||
### New route (`src/server.ts` + `src/http/git-ops.ts`)
|
||||
|
||||
`POST /projects/git/fetch` — `{ path }` → `{ ok, lastFetchMs }`.
|
||||
|
||||
- Same three-prong path check as the other git routes (absolute + dir + has `.git`, SEC-H7).
|
||||
- **Server-derived target**, exactly like `push`: never accept a remote or refspec
|
||||
from the client. `git fetch --no-tags --quiet` (current branch's remote, else the
|
||||
sole remote; ≥2 remotes without an upstream → 400).
|
||||
- `GIT_TERMINAL_PROMPT=0` in the child env, plus an explicit timeout — a fetch that
|
||||
hits a credential prompt otherwise hangs the request forever. (**Correction:**
|
||||
`push` already had this via `NO_PROMPT_ENV`; only `fetch` needed wiring.)
|
||||
- Fetch only updates remote-tracking refs: no working tree, no index, no merge.
|
||||
It stays inside the "no destructive ops" line.
|
||||
|
||||
### Out of scope (unchanged)
|
||||
|
||||
No `reset`, no branch `checkout`, no `clean`, no rebase, no force-push.
|
||||
`stage` / `commit` / `push` stay exactly as they are in `diff.ts`.
|
||||
|
||||
## Tasks
|
||||
|
||||
| ID | What | Owns |
|
||||
|----|------|------|
|
||||
| **G1** | `SyncState` + `dirtyCount`; wire `readSync` into `buildProjectDetail`; add `readUpstream` / `readLastFetch` | `src/types.ts`, `src/http/projects.ts` |
|
||||
| **G2** | `POST /projects/git/fetch` + `fetch()` in the git write engine | `src/http/git-ops.ts`, `src/server.ts` |
|
||||
| **G3** | Sync band in the detail header (replaces the bare `●`) + staleness rule | `public/projects.ts`, `public/style.css` |
|
||||
| **G4** | Unpushed marking + `origin/x` boundary in the commit list | `src/http/git-log.ts`, `public/git-log.ts`, `public/style.css` |
|
||||
| **G5** | Worktree panel: always "Worktrees", per-row branch/path/dirty/↑↓ | `public/projects.ts`, `public/style.css` |
|
||||
| **G6** | Cost controls: per-repo dedupe, `.git` mtime gate, pause while hidden | `src/http/projects.ts`, `public/projects.ts` |
|
||||
| **G7** | Per-worktree dirty/↑↓ — **lazy**, only for an expanded row | `src/http/worktrees.ts`, `public/projects.ts` |
|
||||
|
||||
Order: **G1 → G2 → (G3, G4) → G5 → G6 → G7.** G3 needs G1's data and G2's button target.
|
||||
G7 is the most expensive and least essential — it ships last or not at all.
|
||||
|
||||
### Status (2026-07-29) — G1–G7 all shipped
|
||||
|
||||
tsc + build clean; **46 new tests, all green** (2128 → 2174), and `npm test` is
|
||||
green end to end.
|
||||
|
||||
**Correction to an earlier note in this file:** the three residual
|
||||
`test/integration/server.test.ts` failures were first written off as "environment,
|
||||
not code" because of their `[needs real PTY (sandbox-off)]` labels. That was wrong
|
||||
— `PTY_AVAILABLE` is **true** here, so those cases were running, not skipping, and
|
||||
failing on timing. See "Test-suite flakiness" below.
|
||||
|
||||
Deviations worth knowing:
|
||||
|
||||
- `readBranch` read `<repo>/.git/HEAD` directly, so it returned nothing inside any
|
||||
linked worktree (there `.git` is a file). Fixed by `resolveGitDirs()` — a
|
||||
pre-existing bug this feature would otherwise have inherited.
|
||||
- **G6 dropped the `.git`-mtime cache from the plan.** A fingerprint over
|
||||
HEAD/index/reflog does *not* move when `git push` updates a remote-tracking ref,
|
||||
so a cached `ahead` would keep claiming "9 to push" right after a successful
|
||||
push — the exact confident lie this feature exists to prevent. Replaced with two
|
||||
measures that cannot go stale: **in-flight coalescing** (N devices watching one
|
||||
repo cost one probe, entry dropped the instant it settles) and **skip-render when
|
||||
the payload is byte-identical** (which also stops the 5 s re-mount of the commit
|
||||
log, i.e. two `git` spawns per tick). Plus pausing the timer while the document
|
||||
is hidden.
|
||||
- **G7 needed an unplanned prerequisite:** `ProjectSessionRef` carried no `cwd`, so
|
||||
sessions could not be attributed to a worktree. Added it, plus
|
||||
`countSessionsByWorktree` — which does DEEPEST-match, because
|
||||
`.claude/worktrees/<name>` lives *inside* the main checkout and prefix matching
|
||||
would count every worktree session against the parent repo too.
|
||||
- G7 probes each non-current worktree once per opened project rather than
|
||||
on-expand: with the state cached client-side and skipped on unchanged ticks, the
|
||||
cost is the same and the information is there without a click.
|
||||
- **Test-suite flakiness (pre-existing, now fixed).** Two independent causes:
|
||||
1. *Fixtures outgrew vitest's 5 s default.* The git ones spawn 6–12 sequential
|
||||
`git` processes; the real-server ones boot a server and a shell. Gave those
|
||||
`describe`s an explicit `{ timeout: 30_000 }` and the real-PTY waits a named
|
||||
`PTY_WAIT_MS`. 11 failures → ~1.
|
||||
2. *The real-PTY E2E file cannot share the machine with the rest of the suite.*
|
||||
`test/integration/server.test.ts` passed alone (27/27, repeatedly) and flaked
|
||||
in the full run: it asserts on real prompt output within seconds, which is not
|
||||
achievable while ~8 workers saturate the box. Raising the numbers further only
|
||||
moved the flake around, so `npm test` now runs **two passes** — `test:unit`
|
||||
(everything else, parallel) then `test:e2e` (that file alone). `vitest run`
|
||||
still runs everything at once for anyone who wants it.
|
||||
- Also bounded the `srv.close()` in H1's `finally`. Unbounded, it swallowed the
|
||||
body's real error and reported a bare timeout instead — the diagnosis above
|
||||
only became possible after making the failure legible.
|
||||
|
||||
## Cost control (G6 — the real risk)
|
||||
|
||||
Only two reads are free: branch (`.git/HEAD`) and last-fetch (`FETCH_HEAD` mtime).
|
||||
Everything else spawns `git`, on the same machine that is running Claude Code and builds.
|
||||
|
||||
**As shipped:**
|
||||
|
||||
1. **In-flight coalescing by repo path** — concurrent probes of one repo share a
|
||||
single promise, dropped as soon as it settles. N devices ⇒ one probe, and
|
||||
nothing is cached across time.
|
||||
2. **Skip the render when the payload is unchanged** — the 5 s tick used to rebuild
|
||||
the whole subtree, re-mounting the commit log and re-running `/projects/log`
|
||||
(two more spawns) to redraw identical rows.
|
||||
3. **Pause while hidden** — `document.visibilityState`; a backgrounded tab or a
|
||||
phone with the screen off stops probing entirely.
|
||||
4. Per-worktree state (G7) is probed once per opened project, never per tick.
|
||||
|
||||
**Rejected: the `.git` mtime gate from the original plan.** A fingerprint over
|
||||
HEAD / index / reflog does not change when `push` moves a remote-tracking ref, so
|
||||
the cached `ahead` would still read "9 to push" immediately after pushing. Cheaper
|
||||
than coalescing, and wrong in precisely the way this whole feature is built to
|
||||
avoid.
|
||||
|
||||
## TDD steps
|
||||
|
||||
Repo style: pure-unit in `test/*.test.ts`, real-server in `test/integration/*`.
|
||||
|
||||
**G1** — `test/projects.test.ts`
|
||||
1. RED: `buildProjectDetail` on a fixture repo with an upstream → `sync.ahead === n`, `sync.upstream === 'origin/<b>'`.
|
||||
2. RED: repo with **no upstream** → `sync.upstream === undefined` **and** `sync.ahead === undefined` (guards the green-path bug).
|
||||
3. RED: detached HEAD → `sync.detached === true`, no branch, no ahead/behind.
|
||||
4. RED: never fetched → `sync.lastFetchMs === undefined` (not `0`, not `Date.now()`).
|
||||
5. RED: `dirtyCount` matches porcelain line count; `dirty` still boolean-true alongside.
|
||||
6. RED: non-git dir → `sync === undefined`, and no `git` is spawned.
|
||||
|
||||
**G2** — `test/http/git-ops.test.ts` + `test/integration/projects-endpoint.test.ts`
|
||||
7. RED: fetch rejects a path outside the allowed roots (SEC-H7) → 400, no spawn.
|
||||
8. RED: client-supplied `remote` / `refspec` in the body is **ignored**, not forwarded.
|
||||
9. RED: ≥2 remotes and no upstream → 400 with a plain message (never raw stderr, SEC-M10).
|
||||
10. RED: success → `lastFetchMs` advances.
|
||||
|
||||
**G3** — `test/projects-panel.test.ts`
|
||||
11. RED: `↑0 ↓0` + fresh fetch → the single green "in sync" chip.
|
||||
12. RED: `↑0 ↓0` + `lastFetchMs` older than `FETCH_STALE_MS` → **not** green; `↓` carries `stale`.
|
||||
13. RED: `upstream === undefined` → `no upstream` chip, **no** `↑↓`, **not** green.
|
||||
14. RED: `detached === true` → short SHA, no branch chip, fetch button disabled.
|
||||
15. RED: `dirtyCount === 3` → `● 3`, not a bare dot.
|
||||
|
||||
**G4** — `test/git-log.test.ts`
|
||||
16. RED: entries whose SHA is in `unpushed` get the marker; the boundary renders exactly once, after the last of them.
|
||||
17. RED: `unpushed` empty → no boundary, no markers.
|
||||
18. RED: `upstream === undefined` → no boundary at all (nothing to draw it against).
|
||||
19. RED: an unpushed commit that sorts *below* a pushed one (merge of an older branch) is still marked — the Set is authoritative, not the row index.
|
||||
|
||||
**G5** — `test/projects-panel.test.ts`
|
||||
20. RED: 1 worktree still renders the section (title always "Worktrees"), with its path and session count.
|
||||
21. RED: a worktree with no upstream shows `no upstream`, never a green chip.
|
||||
|
||||
**G6** — `test/projects.test.ts`
|
||||
22. RED: two `buildProjectDetail` calls for the same repo with an unchanged `.git` mtime spawn `git` once.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Empty repo (no commits): no `↑↓`, no commit list, no boundary; not green.
|
||||
- Shallow clone: `rev-list` counts are still meaningful vs `@{u}`; don't special-case.
|
||||
- `@{u}` set but the remote branch was deleted upstream → `rev-list` fails → all
|
||||
fields `undefined` → renders `no upstream`. Acceptable; do not invent a number.
|
||||
- Fetch in flight: disable the button, don't queue a second one.
|
||||
- Fetch failing (offline / auth) → surface a short message, leave `lastFetchMs` unchanged
|
||||
so the `stale` flag stays on. Never silently mark it fresh.
|
||||
|
||||
## Security
|
||||
|
||||
- New route reuses the existing gate: auth token check + the three-prong path check.
|
||||
- No client-controlled remote/refspec, no shell, `--` terminators, `GIT_TERMINAL_PROMPT=0`.
|
||||
- Errors are plain sentences, never raw git stderr (SEC-M10).
|
||||
- Adds **no new capability** — anyone who can reach the port already has a shell.
|
||||
@@ -15,7 +15,9 @@
|
||||
"build:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap",
|
||||
"dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json",
|
||||
"test": "vitest run",
|
||||
"test": "npm run test:unit && npm run test:e2e",
|
||||
"test:unit": "vitest run --exclude test/integration/server.test.ts",
|
||||
"test:e2e": "vitest run test/integration/server.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"setup-hooks": "node scripts/setup-hooks.mjs"
|
||||
},
|
||||
|
||||
@@ -34,7 +34,11 @@ function normalizeCommit(raw: unknown): CommitLogEntry | null {
|
||||
const o = raw as Record<string, unknown>
|
||||
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.`))
|
||||
|
||||
Binary file not shown.
148
public/style.css
148
public/style.css
@@ -1559,6 +1559,154 @@ body {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── w6/G3: the project detail sync band ──────────────────────────────────────
|
||||
Semantic colours here are deliberately NOT the accent: --ok reads "verified,
|
||||
ignore this" and is the one state that may look reassuring, so it must never
|
||||
be reachable from a stale or unknowable comparison (see makeSyncBand). */
|
||||
.proj-syncband {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 0 4px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-sunk, rgba(0, 0, 0, 0.22));
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.proj-sync-upstream {
|
||||
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-dim, #a8a299);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.proj-sync-chip {
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.proj-sync-ahead {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.proj-sync-behind {
|
||||
color: var(--fg-dim, #a8a299);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* The one reassuring state. Reachable only with an upstream AND a fresh fetch. */
|
||||
.proj-sync-ok {
|
||||
color: #7d9b76;
|
||||
background: rgba(125, 155, 118, 0.13);
|
||||
border-color: rgba(125, 155, 118, 0.3);
|
||||
}
|
||||
|
||||
/* "This number may be lying" — never applied to ahead, which needs no fetch. */
|
||||
.proj-sync-stale,
|
||||
.proj-sync-noupstream,
|
||||
.proj-sync-detached {
|
||||
color: #cf6b4f;
|
||||
background: rgba(207, 107, 79, 0.14);
|
||||
border-color: rgba(207, 107, 79, 0.36);
|
||||
}
|
||||
|
||||
.proj-sync-dirty {
|
||||
color: var(--amber);
|
||||
background: rgba(224, 151, 90, 0.13);
|
||||
}
|
||||
|
||||
.proj-sync-fetch {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.14));
|
||||
background: transparent;
|
||||
color: var(--fg, #ece9e3);
|
||||
}
|
||||
|
||||
.proj-sync-fetch:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.proj-sync-fetch:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── w6/G4: unpushed commits + the upstream boundary ───────────────────────── */
|
||||
.proj-commit-unpushed {
|
||||
position: relative;
|
||||
background: linear-gradient(90deg, var(--accent-soft), transparent 55%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.proj-commit-unpushed::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.proj-commit-mark {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
flex: none;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Where the upstream actually sits in this history — never drawn without one. */
|
||||
.proj-commit-boundary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 7px 0 5px;
|
||||
}
|
||||
|
||||
.proj-commit-boundary-label {
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-dim, #a8a299);
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.proj-commit-boundary-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-strong, rgba(255, 255, 255, 0.14));
|
||||
}
|
||||
|
||||
/* w6/G7: running sessions attributed to one worktree row. */
|
||||
.proj-wt-sessions {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */
|
||||
.tg-cost-warn {
|
||||
color: var(--red);
|
||||
|
||||
@@ -102,8 +102,67 @@ export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promi
|
||||
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
|
||||
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
|
||||
)
|
||||
return parseGitLog(stdout, n)
|
||||
const parsed = parseGitLog(stdout, n)
|
||||
return await markUnpushed(repoPath, parsed, opts.timeoutMs)
|
||||
} catch {
|
||||
return { commits: [], truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Cap on how many unpushed commits we enumerate — a runaway branch must not
|
||||
* turn one log read into an unbounded buffer. */
|
||||
const UNPUSHED_MAX = 200
|
||||
|
||||
/**
|
||||
* w6/G4 — tag the commits that exist only locally, and name the upstream so the
|
||||
* client can draw the boundary between pushed and unpushed history.
|
||||
*
|
||||
* Marking is done HERE, from the authoritative `rev-list` set, and never inferred
|
||||
* on the client from row order: `git log` is date-ordered, so merging an older
|
||||
* branch interleaves unpushed commits below pushed ones and "the first N rows are
|
||||
* unpushed" silently mislabels them as pushed — the same class of confident lie
|
||||
* the sync band exists to prevent.
|
||||
*
|
||||
* No upstream / detached / empty repo → the input is returned untouched (no
|
||||
* `upstream`, no marks), so the client draws no boundary. Never throws.
|
||||
*/
|
||||
async function markUnpushed(
|
||||
repoPath: string,
|
||||
result: GitLogResult,
|
||||
timeoutMs: number,
|
||||
): Promise<GitLogResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -359,3 +359,85 @@ export async function push(repoPath: string, opts: PushOptions): Promise<GitOpRe
|
||||
return failFromError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── fetch (w6/G2) ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refresh the remote-tracking refs so `behind` stops lying. This is the ONLY
|
||||
* network read the panel performs, and it is deliberately the weakest possible
|
||||
* one: `git fetch` updates `refs/remotes/*` and nothing else — no working tree,
|
||||
* no index, no branch, no merge. It is NOT a pull.
|
||||
*
|
||||
* Same discipline as push (SEC): the remote is ALWAYS derived server-side — the
|
||||
* current branch's upstream, else the sole remote — and no remote or refspec is
|
||||
* ever read from the caller, so a client cannot point it at an arbitrary URL.
|
||||
* Zero remotes → 400; ≥2 remotes with no upstream → 409 (ambiguous).
|
||||
*
|
||||
* Returns {ok, remote, lastFetchMs}. Never throws.
|
||||
*/
|
||||
export async function fetch(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
|
||||
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<number | undefined> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<repo>/.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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
|
||||
/** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */
|
||||
async function readDirty(repoPath: string): Promise<boolean | undefined> {
|
||||
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 `<repo>/.git` is a *file*
|
||||
* containing `gitdir: <path>`, 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 `<common>/worktrees/<name>` → up two levels.
|
||||
*
|
||||
* Returns null when `<repo>/.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: `<commonDir>/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<number | undefined> {
|
||||
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<string | undefined> {
|
||||
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<SyncState> {
|
||||
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` → "<behind>\t<ahead>"
|
||||
@@ -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<string | undefined> {
|
||||
* 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<WorktreeState | null> {
|
||||
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<string, Promise<ProjectDetail | null>>()
|
||||
|
||||
export function buildProjectDetail(
|
||||
cfg: Config,
|
||||
projectPath: string,
|
||||
liveSessions: readonly LiveSessionInfo[],
|
||||
): Promise<ProjectDetail | null> {
|
||||
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,
|
||||
|
||||
@@ -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<void> } {
|
||||
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<void> } {
|
||||
// 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<void> } {
|
||||
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<string, unknown>
|
||||
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 = {
|
||||
|
||||
39
src/types.ts
39
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) ──────────────────── */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd })
|
||||
return stdout
|
||||
}
|
||||
|
||||
async function initRepo(cwd: string): Promise<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,7 +50,7 @@ async function makeRepo(): Promise<string> {
|
||||
|
||||
// ── validateBranchName ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateBranchName', () => {
|
||||
describe('validateBranchName', { timeout: 30_000 }, () => {
|
||||
it('accepts ordinary branch names', () => {
|
||||
for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) {
|
||||
expect(validateBranchName(ok)).toBe(true)
|
||||
@@ -93,7 +93,7 @@ describe('validateBranchName', () => {
|
||||
|
||||
// ── sanitizeBranchForDir ────────────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeBranchForDir', () => {
|
||||
describe('sanitizeBranchForDir', { timeout: 30_000 }, () => {
|
||||
it('replaces slashes with dashes', () => {
|
||||
expect(sanitizeBranchForDir('feature/login')).toBe('feature-login')
|
||||
expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c')
|
||||
@@ -116,7 +116,7 @@ describe('sanitizeBranchForDir', () => {
|
||||
|
||||
// ── computeWorktreeDir ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeWorktreeDir', () => {
|
||||
describe('computeWorktreeDir', { timeout: 30_000 }, () => {
|
||||
it('places the worktree under an explicit root', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-'))
|
||||
const dir = await computeWorktreeDir('/repo', 'feature', root)
|
||||
@@ -145,7 +145,7 @@ describe('computeWorktreeDir', () => {
|
||||
|
||||
// ── createWorktree (real temp repos) ────────────────────────────────────────────
|
||||
|
||||
describe('createWorktree', () => {
|
||||
describe('createWorktree', { timeout: 30_000 }, () => {
|
||||
it('creates a new worktree + branch and returns its path', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
|
||||
@@ -59,7 +59,7 @@ async function worktreeList(repo: string): Promise<string> {
|
||||
|
||||
// ── removeWorktree ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeWorktree', () => {
|
||||
describe('removeWorktree', { timeout: 30_000 }, () => {
|
||||
it('returns 404 for a non-git directory', async () => {
|
||||
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-plain-'))
|
||||
const res = await removeWorktree(plain, plain, { timeoutMs: TIMEOUT_MS })
|
||||
@@ -156,7 +156,7 @@ describe('removeWorktree', () => {
|
||||
|
||||
// ── pruneWorktrees ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pruneWorktrees', () => {
|
||||
describe('pruneWorktrees', { timeout: 30_000 }, () => {
|
||||
it('returns 404 for a non-git directory', async () => {
|
||||
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-pruneplain-'))
|
||||
const res = await pruneWorktrees(plain, { timeoutMs: TIMEOUT_MS })
|
||||
|
||||
@@ -118,7 +118,7 @@ afterEach(async () => {
|
||||
|
||||
const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const
|
||||
|
||||
describe('git-write routes — shared guards (CSRF / kill-switch)', () => {
|
||||
describe('git-write routes — shared guards (CSRF / kill-switch)', { timeout: 30_000 }, () => {
|
||||
for (const route of ROUTES) {
|
||||
it(`${route}: rejects a foreign Origin with 403`, async () => {
|
||||
const { port } = await spawnServer()
|
||||
@@ -142,7 +142,7 @@ describe('git-write routes — shared guards (CSRF / kill-switch)', () => {
|
||||
|
||||
// ── POST /projects/git/stage ──────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/git/stage', () => {
|
||||
describe('POST /projects/git/stage', { timeout: 30_000 }, () => {
|
||||
it('returns 400 when files are missing', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin })
|
||||
@@ -173,7 +173,7 @@ describe('POST /projects/git/stage', () => {
|
||||
|
||||
// ── POST /projects/git/commit ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/git/commit', () => {
|
||||
describe('POST /projects/git/commit', { timeout: 30_000 }, () => {
|
||||
itGit('returns 400 for an empty message', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const { port, origin } = await spawnServer()
|
||||
@@ -210,7 +210,7 @@ describe('POST /projects/git/commit', () => {
|
||||
|
||||
// ── POST /projects/git/push ───────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/git/push', () => {
|
||||
describe('POST /projects/git/push', { timeout: 30_000 }, () => {
|
||||
itGit('returns a safe 400 when the repo has no remote', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const { port, origin } = await spawnServer()
|
||||
|
||||
@@ -53,6 +53,17 @@ const PTY_AVAILABLE = (() => {
|
||||
})()
|
||||
const itPty = PTY_AVAILABLE ? it : it.skip
|
||||
|
||||
/**
|
||||
* How long a real-PTY case waits for something that MUST arrive (handshake,
|
||||
* attached frame, shell output). These are the bounds that actually catch a hang;
|
||||
* the per-test ceiling above them is only a backstop. They were 3–5 s, which is
|
||||
* fine for this file alone but not while ~8 workers hammer the machine — a real
|
||||
* shell simply takes longer to boot and echo then, and the case failed with
|
||||
* "timeout waiting for matching message" rather than any real defect.
|
||||
* The deliberate "must be rejected" waits in ①② keep their short bounds.
|
||||
*/
|
||||
const PTY_WAIT_MS = 20_000
|
||||
|
||||
/** H1 tmux tests need real PTY + the tmux binary. */
|
||||
const TMUX_OK =
|
||||
PTY_AVAILABLE &&
|
||||
@@ -232,7 +243,12 @@ function waitForMessage(
|
||||
|
||||
// ── Test suite ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('startServer — integration', () => {
|
||||
// These cases boot a real server, spawn a real shell and wait for prompt output —
|
||||
// and H1 restarts the server and waits for tmux to hand the session back. None of
|
||||
// that fits vitest's 5 s default, so under a full parallel run they were failing as
|
||||
// `Test timed out in 5000ms`. The `[needs sandbox-off]` labels are about whether
|
||||
// PTY_AVAILABLE lets them RUN, which is a separate axis from how long they need.
|
||||
describe('startServer — integration', { timeout: 60_000 }, () => {
|
||||
// NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix
|
||||
// (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
|
||||
// it registered, so repeated startServer()/close() cycles don't accumulate.
|
||||
@@ -255,7 +271,9 @@ describe('startServer — integration', () => {
|
||||
await serverHandle.close()
|
||||
// Extra delay to let the OS release the port before the next test.
|
||||
await new Promise<void>((r) => setTimeout(r, 50))
|
||||
})
|
||||
// Shutting a server down after a real-PTY case can exceed vitest's 10 s hook
|
||||
// default under a loaded full-suite run; the close itself is what we wait on.
|
||||
}, 30_000)
|
||||
|
||||
// ── ① Bad Origin → 401 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -477,7 +495,7 @@ describe('startServer — integration', () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
await waitForOpen(ws, PTY_WAIT_MS)
|
||||
|
||||
// Send first frame: attach with sessionId=null (new session).
|
||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
@@ -528,7 +546,7 @@ describe('startServer — integration', () => {
|
||||
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws1, 3_000)
|
||||
await waitForOpen(ws1, PTY_WAIT_MS)
|
||||
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
|
||||
// Wait for 'attached' to capture the sessionId.
|
||||
@@ -566,7 +584,7 @@ describe('startServer — integration', () => {
|
||||
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws2, 3_000)
|
||||
await waitForOpen(ws2, PTY_WAIT_MS)
|
||||
ws2.send(JSON.stringify({ type: 'attach', sessionId }))
|
||||
|
||||
// ── Step D: Collect replay output and assert marker is present ───────────
|
||||
@@ -626,7 +644,7 @@ describe('startServer — integration', () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
await waitForOpen(ws, PTY_WAIT_MS)
|
||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = (await waitForMessage(
|
||||
ws,
|
||||
@@ -670,7 +688,7 @@ describe('startServer — integration', () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
await waitForOpen(ws, PTY_WAIT_MS)
|
||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = (await waitForMessage(
|
||||
ws,
|
||||
@@ -719,7 +737,7 @@ describe('startServer — integration', () => {
|
||||
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws1, 3_000)
|
||||
await waitForOpen(ws1, PTY_WAIT_MS)
|
||||
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = (await waitForMessage(
|
||||
ws1,
|
||||
@@ -756,7 +774,7 @@ describe('startServer — integration', () => {
|
||||
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws2, 3_000)
|
||||
await waitForOpen(ws2, PTY_WAIT_MS)
|
||||
const joinPending = waitForMessage(
|
||||
ws2,
|
||||
(m) =>
|
||||
@@ -806,9 +824,9 @@ describe('startServer — integration', () => {
|
||||
const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
||||
headers: { Origin: `http://127.0.0.1:${tport}` },
|
||||
})
|
||||
await waitForOpen(ws1, 3_000)
|
||||
await waitForOpen(ws1, PTY_WAIT_MS)
|
||||
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record<string, unknown>
|
||||
const att = (await waitForMessage(ws1, isAttached, PTY_WAIT_MS)) as Record<string, unknown>
|
||||
sid = att['sessionId'] as string
|
||||
await delay(500)
|
||||
ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' }))
|
||||
@@ -825,7 +843,7 @@ describe('startServer — integration', () => {
|
||||
const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
||||
headers: { Origin: `http://127.0.0.1:${tport}` },
|
||||
})
|
||||
await waitForOpen(ws2, 3_000)
|
||||
await waitForOpen(ws2, PTY_WAIT_MS)
|
||||
const out: string[] = []
|
||||
ws2.on('message', (raw) => {
|
||||
try {
|
||||
@@ -844,7 +862,11 @@ describe('startServer — integration', () => {
|
||||
ws2.close()
|
||||
await waitForClose(ws2, 3_000).catch(() => undefined)
|
||||
} finally {
|
||||
await srv.close()
|
||||
// Bound the shutdown. An unbounded close() here swallows whatever really
|
||||
// went wrong in the body: the inner waits throw, control jumps to this
|
||||
// finally, close() blocks, and the case reports "timed out" instead of
|
||||
// the actual error. The test must fail with its own diagnosis.
|
||||
await Promise.race([srv.close(), delay(5_000)])
|
||||
if (sid !== '') {
|
||||
try {
|
||||
execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' })
|
||||
@@ -854,7 +876,11 @@ describe('startServer — integration', () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
// The inner waits are already individually bounded (3+5+3+3+3 s) and the fixed
|
||||
// delays add ~2.6 s, so the worst case is ~19.6 s — the old 20 s ceiling had
|
||||
// no headroom at all and tripped whenever the full suite ran in parallel.
|
||||
// The real guards against a hang are those inner bounds, not this number.
|
||||
60_000,
|
||||
)
|
||||
|
||||
// ── Signal-handler leak fix (CQ H1) ────────────────────────────────────────
|
||||
@@ -982,7 +1008,7 @@ describe('startServer — integration', () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${rlPort}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
await waitForOpen(ws, PTY_WAIT_MS)
|
||||
|
||||
// Flood 50 invalid frames in one tick — far over the 5/s cap.
|
||||
for (let i = 0; i < 50; i++) ws.send('not-json')
|
||||
|
||||
@@ -117,7 +117,7 @@ afterEach(async () => {
|
||||
|
||||
// ── POST /projects/worktree ──────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/worktree (B3)', () => {
|
||||
describe('POST /projects/worktree (B3)', { timeout: 30_000 }, () => {
|
||||
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
@@ -210,7 +210,7 @@ async function createWorktreeViaRoute(
|
||||
return body.path as string
|
||||
}
|
||||
|
||||
describe('DELETE /projects/worktree (W4)', () => {
|
||||
describe('DELETE /projects/worktree (W4)', { timeout: 30_000 }, () => {
|
||||
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
@@ -308,7 +308,7 @@ describe('DELETE /projects/worktree (W4)', () => {
|
||||
|
||||
// ── POST /projects/worktree/prune (W4) ────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/worktree/prune (W4)', () => {
|
||||
describe('POST /projects/worktree/prune (W4)', { timeout: 30_000 }, () => {
|
||||
it('rejects a foreign Origin with 403', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
|
||||
@@ -362,7 +362,7 @@ describe('POST /projects/worktree/prune (W4)', () => {
|
||||
|
||||
// ── GET /projects/diff ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /projects/diff (B1)', () => {
|
||||
describe('GET /projects/diff (B1)', { timeout: 30_000 }, () => {
|
||||
it('returns 400 when the path query parameter is missing', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/diff`)
|
||||
|
||||
@@ -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<import('../src/types.js').WorktreeInfo> = {}) => ({
|
||||
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/<name>` 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <repo>/.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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
// `npm test` runs two passes (see package.json): the unit/integration bulk in
|
||||
// parallel, then test/integration/server.test.ts on its own. That file boots a
|
||||
// real server and a real shell and asserts on prompt output within seconds —
|
||||
// budgets it cannot meet while ~8 workers saturate the machine. It passed
|
||||
// alone and flaked in the full run, which is a scheduling problem, not a
|
||||
// timeout value to keep raising. `vitest run` still runs everything at once.
|
||||
exclude: ['node_modules/**', 'dist/**'],
|
||||
environment: 'node',
|
||||
// jsdom 29's localStorage methods are undefined under this config; a shared
|
||||
// in-memory Web Storage polyfill fixes the frontend (@vitest-environment
|
||||
|
||||
Reference in New Issue
Block a user