diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90ebe68..a6897c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,9 +39,11 @@ jobs: target="myworktree_${VERSION}_darwin_${GOARCH}" dist_dir="dist/${target}" mkdir -p "${dist_dir}" - ldflags="-s -w -X myworktree/internal/version.Version=${VERSION} -X myworktree/internal/version.Commit=${COMMIT} -X myworktree/internal/version.BuildDate=${build_date}" - GOOS="${GOOS}" GOARCH="${GOARCH}" go build -trimpath -ldflags "${ldflags}" -o "${dist_dir}/myworktree" ./cmd/myworktree - GOOS="${GOOS}" GOARCH="${GOARCH}" go build -trimpath -ldflags "${ldflags}" -o "${dist_dir}/mw" ./cmd/mw + # Only strip debug symbols (-s), keep symbol table (-w removed) + # to preserve macOS code signing compatibility + ldflags="-s -X myworktree/internal/version.Version=${VERSION} -X myworktree/internal/version.Commit=${COMMIT} -X myworktree/internal/version.BuildDate=${build_date}" + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -trimpath -ldflags "${ldflags}" -o "${dist_dir}/myworktree" ./cmd/myworktree + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -trimpath -ldflags "${ldflags}" -o "${dist_dir}/mw" ./cmd/mw cp README.md LICENSE CHANGELOG.md "${dist_dir}/" tar -C dist -czf "dist/${target}.tar.gz" "${target}" @@ -52,9 +54,83 @@ jobs: path: dist/*.tar.gz if-no-files-found: error + codesign-notarize: + runs-on: macos-latest + needs: build-darwin + if: ${{ vars.APPLE_ENABLE_CODESIGN == 'true' }} + strategy: + fail-fast: false + matrix: + goarch: + - amd64 + - arm64 + steps: + - name: Download archive + uses: actions/download-artifact@v4 + with: + name: release-darwin-${{ matrix.goarch }} + path: dist + + - name: Extract and codesign + env: + APPLE_DEVELOPER_ID_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERT_BASE64 }} + APPLE_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + set -euo pipefail + archive=$(ls dist/*.tar.gz) + basename=$(basename "${archive}" .tar.gz) + mkdir -p dist/extracted + tar xzf "${archive}" -C dist/extracted + + # Import Apple Developer signing certificate + KEYCHAIN="build.keychain" + security create-keychain -p "" "${KEYCHAIN}" + security default-keychain -s "${KEYCHAIN}" + security unlock-keychain -p "" "${KEYCHAIN}" + security set-keychain-settings -lut 21600 "${KEYCHAIN}" + echo "${APPLE_DEVELOPER_ID_CERT_BASE64}" | base64 -d > cert.p12 + security import cert.p12 -k "${KEYCHAIN}" -P "${APPLE_DEVELOPER_ID_CERT_PASSWORD}" -T /usr/bin/codesign -T /usr/bin/pkgbuild + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "${KEYCHAIN}" + + for binary in myworktree mw; do + # Sign with hardened runtime (required for notarization) + codesign --force --options runtime --timestamp --sign "${APPLE_TEAM_ID}" \ + "dist/extracted/${basename}/${binary}" + done + + # Notarize each binary + for binary in myworktree mw; do + zip_path="dist/${binary}.zip" + ditto -c -k --keepParent "dist/extracted/${basename}/${binary}" "${zip_path}" + xcrun notarytool submit "${zip_path}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_APP_SPECIFIC_PASSWORD}" \ + --wait + xcrun stapler staple "dist/extracted/${basename}/${binary}" + done + + # Re-pack the signed archive + tar czf "${archive}" -C dist/extracted "${basename}" + + # Clean up keychain + security delete-keychain "${KEYCHAIN}" + + - name: Upload signed archive + uses: actions/upload-artifact@v4 + with: + name: release-darwin-${{ matrix.goarch }} + path: dist/*.tar.gz + if-no-files-found: error + overwrite: true + publish: runs-on: ubuntu-latest - needs: build-darwin + needs: [build-darwin, codesign-notarize] + if: ${{ !failure() }} steps: - name: Download archives uses: actions/download-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3401300..b8a9817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v0.3.0 + +Release focused on remote collaboration, build robustness, and Apple Silicon reliability. + +Highlights: +- Added **Portal Dashboard** — a shared entry port with auto-discovery of running instances across repos, global auth token (HttpOnly Cookie, CSRF protection), and Tailscale serve readiness. +- Improved **remote access** — instances and portal now bind to `0.0.0.0` by default, auth token auto-generates on first run, and the login flow supports CSRF-protected forms for non-loopback clients. +- Integrated **LLM-powered branch naming** — configurable protocol (OpenAI / Anthropic), with reasoning split support and a manual override option. +- Upgraded terminal shell with **xterm.js v6.0.0** and fixed Chinese IME shift-symbol fullwidth issues. +- Enhanced the **Changes panel** with separate Staged / Unstaged accordion sections, untracked file tracking, and per-file diff stats. +- Streamlined instance lifecycle — instances can be deleted directly on stop (no archive step), with per-worktree tab reordering via optimistic locking. + +Build hardening for Apple Silicon: +- Release builds now set `CGO_ENABLED=0` to guarantee pure-Go cross-compilation from Linux to Darwin. +- Removed `-w` linker flag to preserve macOS code-signing compatibility. +- Added optional macOS codesign + notarization job (enabled via repository variables/secrets) to resolve Gatekeeper blocking on Apple Silicon Macs. +- Users who still encounter "no response" on Apple Silicon can run `xattr -d com.apple.quarantine ./mw` to clear the download quarantine attribute. + +Documentation and validation: +- Expanded API, architecture, and PRD docs to cover the Portal Dashboard, remote access flow, CSRF protection, and auto-auth generation. +- Release packaging continues to publish Darwin `amd64` / `arm64` archives plus SHA256 checksums via the tag-triggered GitHub Actions workflow, with an optional codesign job. + ## v0.2.0 Feature release focused on workspace visibility, terminal continuity, and day-to-day usability improvements. diff --git a/README.md b/README.md index 3ac46aa..145cce9 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ Start from `v0.2.0` or newer for public release binaries. The earlier `v0.1.0` G Each release archive contains `mw`, `myworktree`, `README.md`, `LICENSE`, and `CHANGELOG.md`. If there is no prerelease/release asset yet, or you need a platform we do not publish, follow the source build steps below. +**Apple Silicon troubleshooting:** macOS may quarantine downloaded binaries and silently prevent execution (Gatekeeper). If the binary does not respond or shows "cannot be opened": +```bash +xattr -d com.apple.quarantine ./mw ./myworktree +``` +Or open **System Settings → Privacy & Security** and click "Allow Anyway" for the blocked binaries. + ### Build & install ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 8ca99ae..ffe12e4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -77,6 +77,12 @@ mw --version 每个发布压缩包内都包含 `mw`、`myworktree`、`README.md`、`LICENSE` 和 `CHANGELOG.md`。 如果当前还没有预发布/正式发布压缩包,或者你的平台暂无对应产物,就直接使用下面的源码编译步骤。 +**Apple Silicon 排障提示:** macOS 会对从网络下载的二进制文件施加隔离属性(Gatekeeper),可能导致二进制无响应或提示"无法验证开发者"。可运行: +```bash +xattr -d com.apple.quarantine ./mw ./myworktree +``` +或在 **系统设置 → 隐私与安全性** 中为被阻止的二进制文件点击"仍要打开"。 + ### Build & install ```bash diff --git a/docs/API.md b/docs/API.md index d54f927..cc13388 100644 --- a/docs/API.md +++ b/docs/API.md @@ -186,6 +186,62 @@ Partial failure example (staged succeeded, unstaged failed): ``` - Returns HTTP 400 if `id` is missing or unknown. +### Get all worktrees divergence +`GET /api/worktrees/diverged` + +Returns divergence information for all worktrees: whether each worktree branch is behind the main branch or develop branch. + +- Called on page load and every 60 seconds. +- For each worktree whose branch is the main branch itself, returns an empty object `{}` (no divergence check needed). +- For each worktree whose branch is develop, checks only main. +- For all other worktrees, checks both main and develop (if develop exists locally). +- Uses the local vs remote effective head that is more ahead (`git rev-list --left-right --count`). +- If the worktree's current branch cannot be determined (e.g., detached HEAD), the worktree entry contains only an `error` field. + +Response: +```json +{ + "items": { + "wt_abc123": { + "mainBranch": {"diverged": true, "ahead": 3}, + "develop": {"diverged": false} + }, + "wt_def456": { + "mainBranch": {"diverged": true, "ahead": 1}, + "develop": {"diverged": true, "ahead": 2} + }, + "wt_detached": { + "mainBranch": {"error": "cannot determine branch: git HEAD is detached or malformed"} + }, + "__main__": {} + } +} +``` + +- `diverged`: `true` means the upstream branch has commits not yet contained in the worktree branch HEAD. +- `ahead`: number of commits the upstream effective head is ahead of the worktree HEAD. Only present when `diverged` is `true`. +- `error`: optional string describing why the check failed (e.g., git command timeout). When present, `diverged` is `false` and `ahead` is absent. +- `mainBranch` / `develop`: each key may be absent if the check is not applicable (e.g., develop does not exist locally). + +### Get single worktree divergence +`GET /api/worktree/diverged?id=` + +Returns divergence information for a single worktree. Same response structure as above, but only contains the requested worktree entry. + +- Called immediately when the user selects a worktree in the sidebar to refresh divergence labels. +- `id` can be a managed worktree ID, or `"__main__"` for the main repo. + +Response: +```json +{ + "items": { + "wt_abc123": { + "mainBranch": {"diverged": true, "ahead": 3} + } + } +} +``` + ## 3) Branches ### List (default + top 10) `GET /api/branches` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 93e169d..603239a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,6 +22,7 @@ It does **not** analyze project code or prevent concurrent write conflicts insid - `internal/llm/` — LLM API client(OpenAI / Anthropic / OpenAI Compatible),可选,LLM Settings 通过 Web UI 对话框配置 - `internal/config/` — global auth configuration (read/write `auth.json`) - `internal/portal/` — Portal dashboard (port claiming, instance registry, CSRF state management, HTTP endpoints; **reverse proxy `/s//` planned but not yet implemented** — current dashboard links point to instance ports directly; tailscale serve automation code is defined but **currently unused** due to tailscale CLI bug) +- `internal/gitx/` — git CLI wrappers (branch listing, default branch detection, branch divergence detection) - `internal/ui/` — embedded static UI. ## 3. Data & persistence @@ -67,6 +68,7 @@ The sidebar shows a pinned **Main Workspace** item at the top (purple accent), f - **Instance routing**: Use `worktree_id: "__main__"` (constant: `instance.MainWorktreeID`) in `POST /api/instances` to start an instance in the main repo root. The instance's `worktree_id` will be `"__main__"` and `worktree_name` will be the directory basename. - **Auto-select**: On first load, the UI auto-selects the first worktree; if no worktrees exist, it selects the main repo. - **Refresh**: All branch info (main repo + worktrees) updates via the existing 2-second polling. +- **Divergence labels**: Each non-main worktree item in the sidebar displays compact red labels (e.g. `m↑3`, `d↑1`) next to its branch name, indicating how many commits the upstream branch (main or develop) is ahead. Labels are refreshed every 60 seconds and immediately when the user selects a different worktree. This helps users verify whether their worktree base is up-to-date before starting new work. See `docs/plans/git-commit-history-graph/DESIGN.md` for details. - **Git Changes panel**: Below the worktree list, a read-only panel shows changed files for the currently selected worktree, split into two mutually exclusive accordion sections: **Staged** (changes in the index via `git diff --cached --numstat`) and **Unstaged** (working tree changes via `git diff --numstat`). The panel auto-refreshes every 10 seconds and on worktree selection change. The main repo's changes also refresh when its branch changes. Both git commands run concurrently on the server with a 2-second timeout each. The accordion defaults to showing Unstaged; clicking either header expands that section and collapses the other. Empty sections still show their header with a "No staged changes" / "No unstaged changes" message. ## 4. Instance lifecycle & reconnect semantics diff --git a/docs/PRD.md b/docs/PRD.md index 309c551..c228582 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -72,6 +72,13 @@ - 双层认证架构(Portal 层 Cookie + CSRF,实例层 loopback 绕过) - 浏览器关闭保护:前端在 `beforeunload` 事件时,无论是否存在运行中实例,均触发浏览器原生确认对话框,防止误操作关闭页面。 - **Main workspace 分支查询**:`GET /api/main` 返回 `{name, branch}`。branch 字段实时查询(`git rev-parse --abbrev-ref HEAD`),在 detached HEAD 场景(如 CI 浅克隆)下返回空字符串而非错误。 +- **规划新增:分支落后检测**: + - 侧栏每个 worktree 分支名旁显示红色标签(如 `m↑3` / `d↑1`),标识当前分支是否落后于主分支或集成分支 develop。 + - 如果当前 worktree 就是主分支自身,则不显示标记。 + - 判断逻辑:计算当前 HEAD 到上游 effective head(本地和远端中更领先的一方)的 ahead 数量,结果 `> 0` 即落后。 + - 远端发现优先 `origin`,其次取其他 remote 中领先最多的;无远端则仅用本地判断。 + - 标签常驻显示,60 秒定时刷新;切换 worktree 时立即刷新。 + - 详情见 `docs/plans/git-commit-history-graph/DESIGN.md`。 ## 8. 验收标准(MVP) - 可创建/列出/删除 worktree(dirty 删除被拒绝)。 diff --git a/docs/plans/git-commit-history-graph/DESIGN.md b/docs/plans/git-commit-history-graph/DESIGN.md new file mode 100644 index 0000000..a97ead1 --- /dev/null +++ b/docs/plans/git-commit-history-graph/DESIGN.md @@ -0,0 +1,117 @@ +# 分支落后检测 — 需求文档 + +> 版本:1.0 +> 日期:2026-05-26 + +--- + +## 1. 背景 + +myworktree 是一个基于 git worktree 的多工作区管理工具。用户在日常开发中会在同一个仓库下创建多个 worktree,每个 worktree 对应一个独立的功能分支。随着时间推移,主分支(main/master)或集成分支(develop)可能已经包含新的提交,而用户的 worktree 分支可能基于旧版本创建,导致分支落后。 + +在创建新 worktree 时,如果 base 不是最新的主分支或集成分支,后续合并可能产生不必要的冲突。用户需要一种直观的方式来随时了解每个 worktree 分支是否落后于上游。 + +--- + +## 2. 需求 + +1. 在 Web 前端左侧 worktree 列表中,每个 worktree 分支名旁边用**红色文字标签**简洁标识是否落后于主分支(main/master)或集成分支(develop) +2. 标签格式如 `m↑3`(主分支领先 3 个 commit)、`d↑1`(develop 领选 1 个 commit) +3. 如果当前分支就是主分支自身,不显示任何标记 +4. 如果当前分支就是 develop 自身,仍检查是否落后于主分支 +5. 本地和远端取**领先更多的那个**作为 effective head 进行比较 +6. 远端优先使用 `origin`,没有 `origin` 则取其他 remote 中领先最多的,没有远端则仅用本地 +7. 标记**常驻显示**,60 秒定时刷新;当用户切换 worktree 时立即刷新 + +--- + +## 3. 需求分析 + +### 3.1 核心判定逻辑 + +只用一个 git 命令即可完成判定: + +``` +git rev-list --count <当前分支HEAD>..<上游effective_head> +``` + +- 结果 `> 0` → 上游领先,显示标记 +- 结果 `= 0` → 无落后,不显示标记 + +相比于使用 `git merge-base --is-ancestor` 额外判断,这种方式一步完成,更简洁。 + +### 3.2 分支关系 + +``` +检查方 被检查方 +───────────────────── +普通分支 → 主分支 (必须) +普通分支 → develop (如果 develop 存在且当前分支不是 develop) +develop → 主分支 (必须) +主分支 → 无 (不显示标记) +``` + +### 3.3 effective head 计算 + +对每个上游分支(main 或 develop): + +1. `git rev-parse ` → 本地 head +2. 远端发现: + - 优先 `git rev-parse origin/` + - 不存在则遍历 `git remote` 列表,取 ahead 最多的 remote tracking branch + - 没有任何远端匹配 → 仅使用本地 head +3. `git rev-list --left-right --count ...` → 取 `ahead` 值 +4. 本地和远端中,取 `ahead` 更多的一方作为 effective head + +### 3.4 刷新策略 + +| 触发条件 | 接口 | 说明 | +|---------|------|------| +| 页面初始加载 | 批量接口 | 随 refresh() 一起调用 | +| 定时刷新 (60s) | 批量接口 | 低频轮询,避免频繁 git 操作 | +| 用户切换 worktree | 单条接口 | 立即获取最新状态 | + +--- + +## 4. 基础规划 + +### 4.1 后端 API + +**批量接口**:`GET /api/worktrees/diverged` + +遍历所有 worktree,对每个分支执行分支关系判定,返回聚合结果。 + +**单条接口**:`GET /api/worktree/diverged?id=` + +根据指定 worktree 执行判定,用于切换 worktree 时的即时刷新。 + +**新增后端模块**:`internal/gitx/diverged.go` + +封装三个核心工具函数: +- 计算分支的 ahead 数量 +- 计算分支的 effective head(本地与远端中更领先的一方) +- 发现远端 tracking branch(优先 origin,其次其他 remote) + +### 4.2 前端 UI + +**渲染位置**:在 `renderSidebar()` 中,每个 worktree 行的分支名字符串后,追加红色标签。 + +**HTML 结构**:一个内联的 `` 标签组,每个标签带 `title` 属性提供 tooltip 说明文字。 + +**CSS 样式**:10px 字号,红色 (`--danger-text`),与分支名字号形成对比但不过分突出。 + +**状态管理**:在全局 `state` 中新增 `diverged` 字段存储每个 worktree 的检测结果,由刷新函数异步更新,`renderSidebar()` 渲染时读取。 + +**交互**:切换 worktree 的 `selectWorktree()` 函数中追加单条接口调用。 + +### 4.3 涉及文件 + +| 文件 | 操作 | 内容 | +|------|------|------| +| `internal/gitx/diverged.go` | 新增 | 核心 git 查询函数 | +| `internal/app/app.go` | 修改 | 注册 2 个新路由 + handler | +| `internal/ui/static/index.html` | 修改 | CSS 样式 + JS 刷新逻辑 + 渲染 | + +--- + +*本文档确认后进入实现阶段。* diff --git a/docs/plans/git-commit-history-graph/PLAN.md b/docs/plans/git-commit-history-graph/PLAN.md new file mode 100644 index 0000000..cd09dcf --- /dev/null +++ b/docs/plans/git-commit-history-graph/PLAN.md @@ -0,0 +1,105 @@ +# 分支落后检测 — 实施计划 + +> 基于需求文档:`DESIGN.md` +> 日期:2026-05-26 + +--- + +## 1. 计划概述 + +本计划描述如何实现"分支落后检测"功能:在侧栏每个 worktree 分支名旁显示红色标签(如 `m↑3` / `d↑1`),标识当前分支是否落后于主分支或 develop 分支持。 + +### 1.1 设计决策回顾 + +| # | 决策点 | 结论 | +|---|--------|------| +| 1 | 当前是主分支 | 不显示标记 | +| 2 | 当前是 develop | 仍检查 main 是否领先 | +| 3 | 当前是普通分支 | 检查 main 和 develop 是否领先 | +| 4 | effective head | 取本地和远端中 ahead 更多的 | +| 5 | 远端发现 | 优先 origin → 其次最领先 remote → 无则略过 | +| 6 | develop 不存在 | 跳过,不渲染该区块 | +| 7 | 标记格式 | `m↑3` / `d↑1`,红色,常驻,10px | +| 8 | 刷新策略 | 60s 定时 + 切换 worktree 立即刷新 | +| 9 | 批量 vs 单条 | 初始加载用批量接口,切换用单条接口 | + +--- + +## 2. 文档优先原则 + +在代码实现之前,必须先更新 `docs/` 下的主文档: + +| 优先级 | 文档 | 更新内容 | +|--------|------|---------| +| 1 | `docs/PRD.md` | §7 新增"分支落后检测"功能描述 | +| 2 | `docs/ARCHITECTURE.md` | §2 新增 `internal/gitx/diverged.go` 组件;§3.3 侧栏新增标签说明 | +| 3 | `docs/API.md` | §2 Worktrees 节新增两个 API 端点的文档 | + +--- + +## 3. 实施阶段 + +### Phase A:后端核心逻辑 + +**目标**:新增 `internal/gitx/diverged.go`,封装 git 查询函数。 + +涉及 git 命令: +- `git rev-list --count ..` — 核心判定(结果 > 0 即落后) +- `git rev-list --left-right --count ...` — 计算 ahead/behind +- `git remote` — 发现可用 remote 列表 +- `git show-ref --verify refs/heads/develop` — 检测 develop 是否存在 + +对外暴露的函数: +- 计算指定分支相对上游的 ahead 数量 +- 计算分支 effective head(本地与远端取更领先者) +- 发现远端 tracking branch(优先 origin,其次其他 remote) + +### Phase B:后端 API + +**目标**:在 `internal/app/app.go` 中注册两个新路由并实现 handler。 + +- `GET /api/worktrees/diverged` — 批量接口,遍历所有 worktree 返回聚合结果 +- `GET /api/worktree/diverged?id=` — 单条接口,用于切换 worktree 时即时刷新 + +复用现有 `handleWorktreeStatus` 的路径解析逻辑。 + +### Phase C:前端渲染 + +**目标**:修改 `internal/ui/static/index.html`。 + +需新增: +- CSS 样式:`.wt-diverge-badges` / `.wt-diverge-badge`(红色标签,10px) +- `state.diverged` 全局状态字段 +- `renderSidebar()` 中追加标签渲染逻辑 +- `fetchDiverge()` 批量刷新函数(调用批量接口) +- `fetchDivergeSingle(id)` 单条刷新函数(调用单条接口) + +### Phase D:刷新调度 + +**目标**:将刷新逻辑接入现有事件循环。 + +- `refresh()` 初始加载中追加批量接口调用 +- `setInterval(fetchDiverge, 60000)` 60s 定时刷新 +- `selectWorktree()` 中追加单条接口即时刷新 + +--- + +## 4. 文件变更清单 + +| 文件 | 操作 | 内容 | +|------|------|------| +| `docs/PRD.md` | 修改 | §7 新增功能描述 | +| `docs/ARCHITECTURE.md` | 修改 | 新增组件 + 侧栏说明 | +| `docs/API.md` | 修改 | 新增两个 API 文档 | +| `internal/gitx/diverged.go` | **新增** | git 查询工具函数 | +| `internal/app/app.go` | 修改 | 注册 2 路由 + 2 handler | +| `internal/ui/static/index.html` | 修改 | CSS + JS 渲染 + 刷新调度 | + +--- + +## 5. 不包含的内容 + +- 不实现 commit 历史图形树(原始需求已调整) +- 不实现 mouse hover tooltip +- 不实现闪烁动画 +- 不需要前端缓存或离线支持 diff --git a/docs/plans/git-commit-history-graph/TASK.md b/docs/plans/git-commit-history-graph/TASK.md new file mode 100644 index 0000000..6b5ce9a --- /dev/null +++ b/docs/plans/git-commit-history-graph/TASK.md @@ -0,0 +1,87 @@ +# 分支落后检测 — 任务清单 + +> 基于需求文档:`DESIGN.md` 和实施计划:`PLAN.md` +> 日期:2026-05-26 + +--- + +## 文档更新任务 + +### T-01 更新 PRD.md +**文件**:`docs/PRD.md` +**内容**:在 §7 当前实现状态中,新增"分支落后检测"功能描述条目 +**依赖**:无 +**验证**:确认 PRD.md 中新增条目描述了此功能及其文档引用 + +### T-02 更新 ARCHITECTURE.md +**文件**:`docs/ARCHITECTURE.md` +**内容**: +- §2 High-level components 中新增 `internal/gitx/diverged.go` 组件说明 +- §3.3 Main workspace 侧栏中新增分支落后标签的渲染说明 +**依赖**:无 +**验证**:确认 ARCHITECTURE.md 中组件列表和侧栏说明已更新 + +### T-03 更新 API.md +**文件**:`docs/API.md` +**内容**:在 §2 Worktrees 节末尾,新增以下两个端点的完整 API 文档: +- `GET /api/worktrees/diverged` — 批量接口 +- `GET /api/worktree/diverged?id=` — 单条接口 +**依赖**:无 +**验证**:确认 API.md 中已包含上述两个端点的请求/响应格式说明 + +--- + +## 代码实现任务 + +### T-04 新增 gitx/diverged.go +**文件**:`internal/gitx/diverged.go`(新增) +**内容**:封装以下 git 查询工具函数: +- 计算当前分支相对上游的 ahead 数量(核心判定:`> 0` 即落后) +- 计算分支 effective head(本地与远端取 ahead 更多的一方) +- 发现远端 tracking branch(优先 `origin`,其次最领先的 remote,无则略过) +- 检测 develop 分支是否存在 +**依赖**:无,可独立实现 +**验证**:可通过 `go test` 或集成测试验证 git 命令输出解析正确 + +### T-05 注册 API 路由 +**文件**:`internal/app/app.go` +**内容**: +- 在 `registerAPIs()` 中注册两个新路由 +- 实现 `handleWorktreesDiverged`(批量 handler) +- 实现 `handleWorktreeDiverged`(单条 handler) +- 复用 `handleWorktreeStatus` 中已有的 worktree 路径解析逻辑 +**依赖**:T-04(需要调用 diverged.go 中的函数) +**验证**:启动服务后用 curl 测试两个端点返回正确 JSON + +### T-06 前端渲染逻辑 +**文件**:`internal/ui/static/index.html` +**内容**: +- 新增 CSS 样式:`.wt-diverge-badges` 容器和 `.wt-diverge-badge` 红色标签 +- `state` 中新增 `diverged` 字段 +- `renderSidebar()` 中读取 `state.diverged`,为每个 worktree 追加标签 +- 标签带 `title` 属性提供 tooltip 说明文字 +**依赖**:T-05(需要 API 已就绪才能联调) +**验证**:页面加载后,落后分支旁出现红色标签 `m↑N` / `d↑N` + +### T-07 刷新调度 +**文件**:`internal/ui/static/index.html` +**内容**: +- 新增 `fetchDiverge()` 函数,调用 `GET /api/worktrees/diverged` +- 新增 `fetchDivergeSingle(id)` 函数,调用 `GET /api/worktree/diverged?id=xxx` +- `refresh()` 初始加载中追加 `fetchDiverge()` 调用 +- `setInterval(fetchDiverge, 60000)` 60s 定时刷新 +- `selectWorktree()` 中追加 `fetchDivergeSingle(id)` 即时刷新 +**依赖**:T-06(渲染逻辑已就绪) +**验证**:切换 worktree 后标签立即更新;等待 60s 后标签自动更新 + +--- + +## 执行顺序 + +``` +T-01 ─┬─ T-04 ── T-05 ── T-06 ── T-07 +T-02 ─┤ +T-03 ─┘ +``` + +文档更新任务(T-01 到 T-03)之间无依赖,可并行执行,且须在代码实现任务之前完成。代码实现任务(T-04 到 T-07)须严格按序执行。 diff --git a/internal/app/app.go b/internal/app/app.go index d2831e2..7b1859b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -543,6 +543,8 @@ func (s *Server) registerAPIs(mux *http.ServeMux) { mux.HandleFunc("/api/branches", s.handleBranches) mux.HandleFunc("/api/worktrees/open-terminal", s.handleWorktreeOpenTerminal) mux.HandleFunc("/api/worktrees/open-finder", s.handleWorktreeOpenFinder) + mux.HandleFunc("/api/worktrees/diverged", s.handleWorktreesDiverged) + mux.HandleFunc("/api/worktree/diverged", s.handleWorktreeDiverged) mux.HandleFunc("/api/mcp/tools", s.handleMCPTools) mux.HandleFunc("/api/mcp/call", s.handleMCPCall) mux.HandleFunc("/api/main", s.handleMain) @@ -946,9 +948,22 @@ func (s *Server) handleWorktreeStatus(w http.ResponseWriter, r *http.Request) { untrackedCh <- diffResult{changes: changes, total: map[string]int{"additions": totalAdds, "deletions": 0}} }() - staged := <-stagedCh - unstaged := <-unstagedCh - untracked := <-untrackedCh + var staged, unstaged, untracked diffResult + select { + case staged = <-stagedCh: + case <-r.Context().Done(): + return + } + select { + case unstaged = <-unstagedCh: + case <-r.Context().Done(): + return + } + select { + case untracked = <-untrackedCh: + case <-r.Context().Done(): + return + } unstaged.changes = append(unstaged.changes, untracked.changes...) unstaged.total["additions"] += untracked.total["additions"] @@ -980,6 +995,128 @@ func (s *Server) handleWorktreeStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func (s *Server) handleWorktreesDiverged(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + result := make(map[string]gitx.DivergedResult) + + worktrees, err := s.worktreeMgr.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + + mainBranch := gitx.DefaultBranch(s.root) + + var wg sync.WaitGroup + var mu sync.Mutex + + for _, wt := range worktrees { + wg.Add(1) + go func(wt store.ManagedWorktree) { + defer wg.Done() + branch, err := gitx.CurrentBranch(wt.Path) + var items gitx.DivergedResult + if err != nil { + items = gitx.DivergedResult{ + gitx.MainBranchKey: gitx.DivergedStatus{Error: fmt.Sprintf("cannot determine branch: %v", err)}, + } + } else { + items = gitx.CheckDiverged(wt.Path, branch, mainBranch) + } + mu.Lock() + if len(items) > 0 { + result[wt.ID] = items + } + mu.Unlock() + }(wt) + } + + wg.Add(1) + go func() { + defer wg.Done() + rootBranch, err := gitx.CurrentBranch(s.root) + var items gitx.DivergedResult + if err != nil { + items = gitx.DivergedResult{ + gitx.MainBranchKey: gitx.DivergedStatus{Error: fmt.Sprintf("cannot determine branch: %v", err)}, + } + } else { + items = gitx.CheckDiverged(s.root, rootBranch, mainBranch) + } + mu.Lock() + if len(items) > 0 { + result[instance.MainWorktreeID] = items + } + mu.Unlock() + }() + + wg.Wait() + + writeJSON(w, http.StatusOK, map[string]any{"items": result}) +} + +func (s *Server) handleWorktreeDiverged(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + writeErr(w, http.StatusBadRequest, errors.New("id is required")) + return + } + + var worktreePath string + if id == instance.MainWorktreeID { + worktreePath = s.root + } else { + worktrees, err := s.worktreeMgr.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + found := false + for _, wt := range worktrees { + if wt.ID == id { + worktreePath = wt.Path + found = true + break + } + } + if !found { + writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown worktree id: %s", id)) + return + } + } + + mainBranch := gitx.DefaultBranch(s.root) + + branch, err := gitx.CurrentBranch(worktreePath) + if err != nil { + result := map[string]gitx.DivergedResult{ + id: { + gitx.MainBranchKey: gitx.DivergedStatus{Error: fmt.Sprintf("cannot determine branch: %v", err)}, + }, + } + writeJSON(w, http.StatusOK, map[string]any{"items": result}) + return + } + + items := gitx.CheckDiverged(worktreePath, branch, mainBranch) + + result := make(map[string]gitx.DivergedResult) + if len(items) > 0 { + result[id] = items + } + + writeJSON(w, http.StatusOK, map[string]any{"items": result}) +} + func (s *Server) handleInstances(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/internal/gitx/diverged.go b/internal/gitx/diverged.go new file mode 100644 index 0000000..83f78e0 --- /dev/null +++ b/internal/gitx/diverged.go @@ -0,0 +1,205 @@ +package gitx + +import ( + "fmt" + "log" + "strconv" + "strings" + "time" +) + +const MainBranchKey = "mainBranch" + +type DivergedStatus struct { + Diverged bool `json:"diverged"` + Ahead int `json:"ahead,omitempty"` + Error string `json:"error,omitempty"` +} + +type DivergedResult map[string]DivergedStatus + +func CheckDiverged(gitRoot, worktreeBranch, mainBranch string) DivergedResult { + result := make(DivergedResult) + + isMain := worktreeBranch == mainBranch + isDevelop := worktreeBranch == "develop" + + if isMain { + return result + } + + if isDevelop { + count, err := aheadCount(gitRoot, worktreeBranch, mainBranch) + if err != nil { + result[MainBranchKey] = DivergedStatus{Error: fmt.Sprintf("failed to check main: %v", err)} + } else if count > 0 { + result[MainBranchKey] = DivergedStatus{Diverged: true, Ahead: count} + } else { + result[MainBranchKey] = DivergedStatus{Diverged: false} + } + return result + } + + if count, err := aheadCount(gitRoot, worktreeBranch, mainBranch); err != nil { + result[MainBranchKey] = DivergedStatus{Error: fmt.Sprintf("failed to check main: %v", err)} + } else if count > 0 { + result[MainBranchKey] = DivergedStatus{Diverged: true, Ahead: count} + } else { + result[MainBranchKey] = DivergedStatus{Diverged: false} + } + + if branchExists(gitRoot, "develop") || remoteHead(gitRoot, "origin", "develop") != "" { + if count, err := aheadCount(gitRoot, worktreeBranch, "develop"); err != nil { + result["develop"] = DivergedStatus{Error: fmt.Sprintf("failed to check develop: %v", err)} + } else if count > 0 { + result["develop"] = DivergedStatus{Diverged: true, Ahead: count} + } else { + result["develop"] = DivergedStatus{Diverged: false} + } + } + + return result +} + +func aheadCount(gitRoot, branch, upstreamBranch string) (int, error) { + effective, effErr := effectiveHead(gitRoot, upstreamBranch) + if effErr != nil { + return 0, effErr + } + if effective == "" { + return 0, nil + } + + localHead := branchHead(gitRoot, branch) + if localHead == "" { + return 0, nil + } + + cmd := GitCommand(2*time.Second, gitRoot, "rev-list", "--count", localHead+".."+effective) + out, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("git rev-list --count failed: %w", err) + } + + count, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, fmt.Errorf("parse rev-list count: %w", err) + } + + return count, nil +} + +func effectiveHead(gitRoot, branch string) (string, error) { + localHead := branchHead(gitRoot, branch) + + if localHead != "" { + if remoteHeadVal := remoteHead(gitRoot, "origin", branch); remoteHeadVal != "" { + localAhead, remoteAhead, lrErr := leftRightCount(gitRoot, localHead, remoteHeadVal) + if lrErr != nil { + return localHead, fmt.Errorf("comparing origin/%s: %w", branch, lrErr) + } + if remoteAhead > localAhead { + return remoteHeadVal, nil + } + return localHead, nil + } + + var maxRemoteAhead int + var maxRemoteHead string + for _, remote := range listRemotes(gitRoot) { + if remote == "origin" { + continue + } + if remoteHeadVal := remoteHead(gitRoot, remote, branch); remoteHeadVal != "" { + localAhead, remoteAhead, lrErr := leftRightCount(gitRoot, localHead, remoteHeadVal) + if lrErr != nil { + log.Printf("diverged: effectiveHead(%q): leftRightCount(%s): %v", branch, remote, lrErr) + continue + } + if remoteAhead > localAhead && remoteAhead >= maxRemoteAhead { + maxRemoteAhead = remoteAhead + maxRemoteHead = remoteHeadVal + } + } + } + if maxRemoteHead != "" { + return maxRemoteHead, nil + } + + return localHead, nil + } + + if remoteHeadVal := remoteHead(gitRoot, "origin", branch); remoteHeadVal != "" { + return remoteHeadVal, nil + } + for _, remote := range listRemotes(gitRoot) { + if remote == "origin" { + continue + } + if remoteHeadVal := remoteHead(gitRoot, remote, branch); remoteHeadVal != "" { + return remoteHeadVal, nil + } + } + return "", nil +} + +func branchHead(gitRoot, branch string) string { + if branch == "" { + return "" + } + cmd := GitCommand(2*time.Second, gitRoot, "rev-parse", branch) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func remoteHead(gitRoot, remote, branch string) string { + ref := remote + "/" + branch + cmd := GitCommand(2*time.Second, gitRoot, "rev-parse", ref) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func leftRightCount(gitRoot, left, right string) (leftAhead, rightAhead int, err error) { + if left == "" || right == "" { + return 0, 0, nil + } + cmd := GitCommand(2*time.Second, gitRoot, "rev-list", "--left-right", "--count", left+"..."+right) + out, err := cmd.Output() + if err != nil { + return 0, 0, fmt.Errorf("git rev-list --left-right: %w", err) + } + parts := strings.Fields(string(out)) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("unexpected rev-list --left-right output: %q", strings.TrimSpace(string(out))) + } + l, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("parse left count %q: %w", parts[0], err) + } + r, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("parse right count %q: %w", parts[1], err) + } + return l, r, nil +} + +func listRemotes(gitRoot string) []string { + cmd := GitCommand(2*time.Second, gitRoot, "remote") + out, err := cmd.Output() + if err != nil { + return nil + } + var remotes []string + for _, r := range strings.Fields(string(out)) { + if r != "" { + remotes = append(remotes, r) + } + } + return remotes +} diff --git a/internal/ui/static/index.html b/internal/ui/static/index.html index 485b9b3..8fb0db2 100644 --- a/internal/ui/static/index.html +++ b/internal/ui/static/index.html @@ -417,6 +417,29 @@ gap: 4px; } + .wt-diverge-badges { + display: inline-flex; + gap: 3px; + align-items: center; + margin-left: 2px; + } + + .wt-diverge-badge { + font-size: 10px; + color: var(--danger-text); + font-weight: 600; + line-height: 1; + white-space: nowrap; + } + + .wt-diverge-badge-err { + font-size: 10px; + color: #d4a017; + font-weight: 600; + line-height: 1; + white-space: nowrap; + } + .wt-actions { margin-left: auto; display: flex; @@ -1365,7 +1388,8 @@ activeInst: null, mainRepo: null, renamingInst: null, // instance id currently being renamed - renamingInstValue: "" // preserved value across re-renders + renamingInstValue: "", // preserved value across re-renders + diverged: {} // worktree_id -> {branch: {diverged, ahead}} }; let loadingActions = new Set(); // Stores keys like "term:" or "finder:" @@ -1379,6 +1403,7 @@ // Terminal & Transport let terminalSessions = {}; let pollTimer = null; + let divergeTimer = null; let refreshInFlight = null; let stateVersion = 0; @@ -1478,6 +1503,8 @@ state.branches = branches; reconcileTerminalSessions(); + fetchDiverge(); + // Fetch main repo info independently — failure is non-blocking. api("/api/main").then(main => { const prevBranch = state.mainRepo ? state.mainRepo.branch : null; @@ -1520,6 +1547,30 @@ await refreshInFlight; } + async function fetchDiverge() { + try { + const data = await api("/api/worktrees/diverged"); + if (!state.diverged) state.diverged = {}; + state.diverged = data.items || {}; + renderSidebar(); + } catch (e) { + console.error("fetch diverge failed:", e); + } + } + + async function fetchDivergeSingle(id) { + try { + const data = await api(`/api/worktree/diverged?id=${encodeURIComponent(id)}`); + if (!state.diverged) state.diverged = {}; + if (data.items) { + Object.assign(state.diverged, data.items); + } + renderSidebar(); + } catch (e) { + console.error("fetch diverge single failed:", e); + } + } + function init() { if (sessionStorage.getItem(SERVER_UPGRADED_FLAG) === "1") { sessionStorage.removeItem(SERVER_UPGRADED_FLAG); @@ -1535,6 +1586,9 @@ // Polling for status updates (2s) if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(refresh, 2000); + // Polling for diverge status (60s) + if (divergeTimer) clearInterval(divergeTimer); + divergeTimer = setInterval(fetchDiverge, 60000); // Status bar click -> resource monitor document.getElementById("status-bar").addEventListener("click", openModalMonitor); @@ -1781,6 +1835,23 @@ renderModals(); // Update options inside modals } + function divergeBadges(id) { + const data = state.diverged && state.diverged[id]; + if (!data) return ''; + let badges = ''; + const branchKeys = Object.keys(data).sort(); + for (const branch of branchKeys) { + const info = data[branch]; + if (info.error) { + badges += `err\u2191`; + } else if (info.diverged && info.ahead > 0) { + const label = branch === 'mainBranch' ? 'm' : (branch === 'develop' ? 'd' : branch.substring(0, 1)); + badges += `${label}\u2191${info.ahead}`; + } + } + return badges ? `${badges}` : ''; + } + function renderSidebar() { const list = document.getElementById("wt-list"); let html = ""; @@ -1820,7 +1891,7 @@
  • ${state.mainRepo.name}
    - 🏠 ${branchLabel} + 🏠 ${branchLabel}${divergeBadges(MAIN_WT_ID)}
    ${shortcuts(MAIN_WT_ID)}
    @@ -1839,7 +1910,7 @@
  • ${w.name}
    - 🌱 ${w.branch} + 🌱 ${w.branch}${divergeBadges(w.id)}
    ${shortcuts(w.id)} ${isActive ? `` : ''} @@ -2222,6 +2293,8 @@ } else { refresh(); // Will trigger auto-select instance in renderTabs } + // Refresh diverge status for the selected worktree immediately. + fetchDivergeSingle(id); // Reset and start git changes polling for the new worktree. if (gitChangesTimer) clearInterval(gitChangesTimer); refreshGitChanges();