diff --git a/README.md b/README.md
index 4d8d32c..20eaf54 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques
| [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) |
| [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) |
| [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) |
+| [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) |
Preview: Token Usage Board
@@ -82,6 +83,15 @@ The preview uses synthetic data. The app interface is currently in Chinese. See
+
+Preview: Git Commit Tree
+
+
+
+The preview uses synthetic data. The app interface is currently in Chinese. See its [README](plugins/microbiosis/git-tree/README.md) for repository discovery, layout algorithm attribution, and compatibility notes.
+
+
+
## Getting started
### Requirements
diff --git a/README.zh-CN.md b/README.zh-CN.md
index aed133e..e18ec61 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -64,6 +64,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求
| [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) |
| [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) |
| [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) |
+| [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) |
预览:Token 用量看板
@@ -83,6 +84,15 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求
+
+预览:Git 提交树
+
+
+
+预览使用合成数据,应用界面目前为中文。仓库发现规则、泳道算法来源与兼容性说明见作品 [README](plugins/microbiosis/git-tree/README.zh-CN.md)。
+
+
+
## 开始使用
### 使用要求
diff --git a/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json b/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json
new file mode 100644
index 0000000..e891b95
--- /dev/null
+++ b/plugins/microbiosis/git-tree/.minimax-plugin/plugin.json
@@ -0,0 +1,18 @@
+{
+ "schemaVersion": 1,
+ "name": "git-tree",
+ "displayName": "Git 提交树",
+ "version": "1.1.0",
+ "description": "查看本机 git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计。",
+ "author": "Microbiosis",
+ "icon": "icon.png",
+ "category": "Other",
+ "exampleQueries": [
+ "看一下这个仓库的提交树",
+ "显示最近的 git 提交历史",
+ "查看提交详情和文件改动"
+ ],
+ "apps": [],
+ "mcpServers": [],
+ "skills": []
+}
diff --git a/plugins/microbiosis/git-tree/LICENSE b/plugins/microbiosis/git-tree/LICENSE
new file mode 100644
index 0000000..a41c15c
--- /dev/null
+++ b/plugins/microbiosis/git-tree/LICENSE
@@ -0,0 +1,197 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensed or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of tracking or otherwise improving the Work,
+ but excludes communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work or
+ a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for describing the origin of the Work and
+ reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Copyright 2026 Microbiosis
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ The git-graph lane-assignment algorithm and pixel layout in
+ miniapp/node/git-graph.mjs is a JavaScript port of:
+ packages/ui/src/git-graph/layoutAlgorithm.ts
+ packages/ui/src/git-graph/layout.ts
+ from the upstream repository https://github.com/zai-org/ZCode (Apache-2.0).
+ Original authors: Z.ai / zai-org contributors.
diff --git a/plugins/microbiosis/git-tree/README.md b/plugins/microbiosis/git-tree/README.md
new file mode 100644
index 0000000..813555f
--- /dev/null
+++ b/plugins/microbiosis/git-tree/README.md
@@ -0,0 +1,93 @@
+# Git Commit Tree
+
+[简体中文](README.zh-CN.md) | English
+
+Inspect a local Git repository's commit history from MiniMax Code: swim-lane commit graph, branches and tags, commit detail and per-file change stats, with optional auto-refresh and persisted filter preferences.
+
+Author: [Microbiosis](https://github.com/Microbiosis) · Version: `1.1.0`
+
+
+
+## Install & use
+
+Copy this directory into the active MiniMax Code data directory under `plugins/`. `` defaults to `~/.minimax`, so the default install path is:
+
+```text
+/plugins/git-tree/
+```
+
+Keep the hidden `.minimax-plugin/` directory. Restart MiniMax Code with Mini App support, confirm the plugin is recognized, then open "Git 提交树" — either from the plugin list or by asking the assistant.
+
+The page loads the first 200 commits of the active repo. 200 is a fixed page size on the client, not a setting in `repos.json`; narrow the result set with the ref dropdown (branch or tag), the search box (commit subject), and the author box, or use "Auto refresh" to keep the view in sync with new pushes.
+
+## What it shows
+
+| Area | Source | Detail |
+|---|---|---|
+| Stats grid | `git rev-list --all --count`, `for-each-ref`, `tag --list`, `status --porcelain` | Total commits, branches, tags, working-tree changes |
+| Worktree chips | `git status --porcelain` | Current branch + the first 6 changed paths |
+| Commit graph | `git log --topo-order` + custom lane algorithm (ported from `zai-org/ZCode`) | Single SVG canvas: paths, dots, selection ring, hover highlight |
+| Commit rows | `git log` + `git tag --contains` | 4-column grid (subject / date / author / hash) |
+| Inline detail (toggle) | server-side `/api/commit` | Subject, branches/tags chips, file changes table, body — appears below the list and can be collapsed |
+| Right detail (always on) | server-side `/api/commit` | Same payload as inline; selection and inline are independent |
+
+## Data sources & caching
+
+- **Repos**: `repos.json` ships with the plugin empty by design. Each install must declare its own local paths — either by listing them in the `repos` array or by setting `scanRoots` to one or more directories whose direct children contain `.git`. The Node entry walks up from the plugin root and process cwd, then one directory level of every `scanRoots` entry, and validates each candidate by checking for a `.git` directory. **Portability fallback**: when neither `repos.json` nor the walk-up produced any scan base, the registry also looks under `~/Code`, `~/Projects`, `~/repos`, `~/workspace`, `~/src`, `~/source`, `~/dev`, `~/work`, `~/Documents`, `~/git` (case-insensitive on Windows/macOS). On Windows it additionally walks every mounted drive root (`A:\` … `Z:\`) because developers routinely keep projects on a non-OS drive. Windows system hives (`Program Files`, `Windows`, `Users`, `ProgramData`, `$Recycle.Bin`, …) and macOS resource dirs (`Library`, `Applications`, `System`) are filtered out so widening to drive roots cannot recurse into `%ProgramFiles%`. When a first-level entry under any scan base looks like a dev parent (`github`, `code`, `projects`, `workspace`, `src`, `dev`, `work`, `git`, `repos`, …) but does not itself contain `.git`, the scanner recurses one level into it — this surfaces layouts like `D:\Github\\` where projects live two levels under the drive root. The fallback only kicks in when there is no other discovery source, so users with an explicit `repos.json` are unaffected.
+- **Stats**: 7 parallel `git` commands (`log -n 1`, `rev-list --all --count`, two `for-each-ref`, `tag --list`, `status`, `log -n 1 --format=%D`), cached for **30 s** per repo.
+- **Graph**: `git log [ --topo-order` with optional `--grep` and `--author` filters, capped at **400** rows per call. Layout result is server-validated; the client renders it as a single SVG.
+- **Commit detail**: `git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`, cached **60 s** per `(repo, sha)`. The client mirrors this cache for 60 s as well, so re-clicking a row is free.
+- **Tags for graph**: `git log --all --simplify-by-decoration --format=%H %D`, cached 60 s per repo.
+- **Compression**: API responses ≥ 256 bytes are gzipped when the client advertises `Accept-Encoding: gzip`.
+- **Timeouts**: quick reads (for-each-ref, tag --list, contains checks) 10 s; medium reads (log -n 1, status, git show) 15 s; heavy reads (log --topo-order --all, rev-list --all --count) 30 s. Client `api()` caps at 35 s by default and 60 s for graph requests, using `AbortController`.
+
+## Preferences
+
+Filter state and theme are written to `/prefs.json` and restored on the next open:
+
+| Key | Type | Default | Notes |
+|---|---|---|---|
+| `theme` | `auto` \| `light` \| `dark` | `auto` | `auto` follows `prefers-color-scheme` |
+| `repo` | absolute path | first repo | Falls back to the registry default if the saved path is no longer present |
+| `ref` | string | `all` | `all`, branch name, or tag name |
+| `q` | string | `""` | substring search on commit subject |
+| `author` | string | `""` | matches author name or email |
+| `interval` | `0` \| `5` \| `10` \| `30` \| `60` | `0` | Auto-refresh interval in seconds |
+
+The `/api/prefs` endpoint is GET for read, POST for merge-write (never wipes sibling fields). Writes are debounced 400 ms on the client side.
+
+## Auto refresh
+
+Set "Auto refresh" to 5 / 10 / 30 / 60 seconds. The page re-fetches overview and graph in place, keeping the user's selected commit, scroll position, and inline-detail state. A countdown chip next to the refresh button shows the time until the next tick; manual refresh is still available at any time.
+
+## Source & verification
+
+The Client is at `miniapp/client/index.html` — single HTML file, single SVG canvas, inline script. The Node entry is at `miniapp/node/server.mjs`. The lane-assignment algorithm is at `miniapp/node/git-graph.mjs`.
+
+No build step.
+
+## Data & access
+
+**What the plugin reads.** Every read is a local `git` invocation against a repository you declared in `repos.json` or one the scanner found on this machine. Git reads `.git` internals and working-tree metadata (for example `git status`) inside those repositories only.
+
+**How repositories are discovered.** Discovery enumerates *directory names* and checks each candidate for a `.git` entry; it never reads the contents of files it is not already pointed at. The scan surface is wider than `repos.json`:
+
+- Your home development directories (`~/Code`, `~/Projects`, `~/repos`, `~/workspace`, `~/src`, `~/source`, `~/dev`, `~/work`, `~/Documents`, `~/git`), plus the plugin root and process cwd walk-up.
+- On Windows, the root of every mounted drive letter (`A:\` … `Z:\`), which includes mapped network drives. System directories are filtered out.
+- On macOS and Linux, the parent of your home directory (`/Users`, `/home`), which lists the *names* of other local user accounts' home directories.
+
+This only runs when neither `repos.json` nor the walk-up produced a scan base.
+
+**What the plugin writes.** The only write is `/prefs.json`, which stores just the filter and theme keys listed under Preferences.
+
+**Network.** Nothing is uploaded, and the plugin makes no outbound network requests.
+
+## Tested environment
+
+MiniMax Code desktop `3.0.73.166`, Windows 10.0.26200 (x64). macOS and Linux not verified.
+
+## License
+
+[Apache-2.0](LICENSE).
+
+The git-graph layout algorithm in `miniapp/node/git-graph.mjs` is a JavaScript port of [`zai-org/ZCode`](https://github.com/zai-org/ZCode)'s `packages/ui/src/git-graph/layoutAlgorithm.ts` and `packages/ui/src/git-graph/layout.ts`, which are licensed under Apache-2.0 by Z.ai / zai-org contributors.
diff --git a/plugins/microbiosis/git-tree/README.zh-CN.md b/plugins/microbiosis/git-tree/README.zh-CN.md
new file mode 100644
index 0000000..7fe4060
--- /dev/null
+++ b/plugins/microbiosis/git-tree/README.zh-CN.md
@@ -0,0 +1,93 @@
+# Git 提交树
+
+[English](README.md) | 简体中文
+
+在 MiniMax Code 里查看本机 Git 仓库的提交历史:泳道提交图、分支与标签、提交详情和文件改动统计,支持自动刷新与筛选偏好持久化。
+
+作者:[Microbiosis](https://github.com/Microbiosis) · 版本:`1.1.0`
+
+
+
+## 安装与使用
+
+将本目录完整复制到 MiniMax Code 当前数据目录下。`` 默认为用户主目录下的 `.minimax`,即 `~/.minimax`,默认安装路径:
+
+```text
+/plugins/git-tree/
+```
+
+保留 `.minimax-plugin/` 隐藏目录。重新启动支持 Mini App 的 MiniMax Code,确认插件已被识别并启用,然后打开「Git 提交树」,或在对话中请求打开它。
+
+页面默认加载当前仓库最近 200 条提交。200 是客户端固定分页大小,不是 `repos.json` 里的配置项;可用引用下拉(分支或标签)、搜索框(提交信息)和作者框收窄结果,或用「自动刷新」下拉保持视图与新推送同步。
+
+## 页面分区
+
+| 区域 | 数据来源 | 说明 |
+|---|---|---|
+| 统计卡片 | `git rev-list --all --count`、`for-each-ref`、`tag --list`、`status --porcelain` | 提交总数、分支数、标签数、工作区改动数 |
+| 工作区条 | `git status --porcelain` | 当前分支 + 前 6 个改动路径 chip |
+| 提交图 | `git log --topo-order` + 自定义泳道算法(移植自 `zai-org/ZCode`) | 单 SVG canvas:path、dot、选中环、悬停高亮 |
+| 提交行 | `git log` + `git tag --contains` | 4 列网格(subject / date / author / hash) |
+| 行内详情(可折叠) | 服务端 `/api/commit` | 标题、分支/标签 chip、文件改动表、body — 出现在列表下方,可折叠 |
+| 右侧详情(常驻) | 服务端 `/api/commit` | 同上行内;选中态与行内互相独立 |
+
+## 数据源与缓存
+
+- **仓库列表**:`repos.json` 与插件同发,默认空(刻意保留)。每次安装需自行声明本地路径:在 `repos` 数组里列出,或把 `scanRoots` 设为包含 `.git` 的一层目录。Node 入口从插件根和工作目录向上找 `.git`,再扫一次 `scanRoots` 的一层目录验证。**可移植性回退**:如果 `repos.json` 与向上找都没产出任何 scanBase,自动再扫 `~/Code`、`~/Projects`、`~/repos`、`~/workspace`、`~/src`、`~/source`、`~/dev`、`~/work`、`~/Documents`、`~/git`(Windows/macOS 下大小写不敏感)。Windows 上额外扫描每个挂载的盘符根(`A:\` 到 `Z:\`),因为开发者常把项目放在非系统盘的 `D:\` / `E:\` 上。Windows 系统目录(`Program Files`、`Windows`、`Users`、`ProgramData`、`$Recycle.Bin` 等)和 macOS 资源目录(`Library`、`Applications`、`System`)会被过滤掉,所以扫描盘符根不会进入 `%ProgramFiles%`。如果 scanBase 下的第一层目录名字命中开发父目录模式(`github`、`code`、`projects`、`workspace`、`src`、`dev`、`work`、`git`、`repos` 等)但自身没有 `.git`,扫描器会再下一层 — 这样 `D:\Github\\` 这种把项目放在盘符根下两层的布局也能被发现。回退只在没有其他发现源时才启用,有 `repos.json` 的用户完全不受影响。
+- **统计**:7 个并行 `git` 命令(`log -n 1`、`rev-list --all --count`、两次 `for-each-ref`、`tag --list`、`status`、`log -n 1 --format=%D`),按仓库缓存 **30 秒**。
+- **提交图**:`git log ][ --topo-order` + 可选 `--grep` / `--author`,每次请求上限 **400** 行。布局结果由服务端校验,客户端用单 SVG 渲染。
+- **提交详情**:`git show -s` + `git show --numstat` + `branch --contains` + `tag --contains`,按 `(repo, sha)` 缓存 **60 秒**。客户端镜像同一缓存,重复点同一条提交零成本。
+- **图的标签**:`git log --all --simplify-by-decoration --format=%H %D`,按仓库缓存 60 秒。
+- **响应压缩**:API 响应 ≥ 256 字节且客户端带 `Accept-Encoding: gzip` 时自动 gzip。
+- **超时分级**:快速读(`for-each-ref`、`tag --list`、`--contains`)10 秒;中速读(`log -n 1`、`status`、`git show`)15 秒;重量级读(`log --topo-order --all`、`rev-list --all --count`)30 秒。客户端 `api()` 默认用 `AbortController` 兜底 35 秒,提交图请求放宽到 60 秒。
+
+## 偏好持久化
+
+筛选条件和主题写入 `/prefs.json`,下次打开自动恢复:
+
+| 键 | 类型 | 默认 | 说明 |
+|---|---|---|---|
+| `theme` | `auto` \| `light` \| `dark` | `auto` | `auto` 跟随系统 `prefers-color-scheme` |
+| `repo` | 绝对路径 | 第一个仓库 | 若保存的路径已不在仓库列表,回退到默认 |
+| `ref` | 字符串 | `all` | `all`、分支名或标签名 |
+| `q` | 字符串 | `""` | 提交信息子串搜索 |
+| `author` | 字符串 | `""` | 匹配作者名或邮箱 |
+| `interval` | `0` \| `5` \| `10` \| `30` \| `60` | `0` | 自动刷新间隔(秒) |
+
+`/api/prefs` 用 GET 读、POST 合并写(不会清空其他字段)。客户端写 400 ms 防抖。
+
+## 自动刷新
+
+「自动刷新」设为 5 / 10 / 30 / 60 秒后,页面原地重新拉取 overview 和 graph,选中行、滚动位置、行内详情全部保留。刷新按钮旁的倒计时 chip 显示距离下次自动刷新的秒数;手动刷新按钮任意时刻可用。
+
+## 源码与验证
+
+页面位于 `miniapp/client/index.html`(单文件、单 SVG canvas、行内脚本),Node 入口位于 `miniapp/node/server.mjs`,泳道算法位于 `miniapp/node/git-graph.mjs`。
+
+无需构建。
+
+## 数据与访问
+
+**读取范围**。所有读取都是对本机仓库的本地 `git` 命令调用,仓库来自你在 `repos.json` 中声明的路径或扫描器在本机发现的目录。`git` 只读取这些仓库内部的 `.git` 数据和工作区元数据(例如 `git status`)。
+
+**仓库发现方式**。发现逻辑只枚举*目录名*并检查候选目录里是否存在 `.git` 条目,不会读取无关文件的内容。扫描面比 `repos.json` 更宽:
+
+- 你的主目录下的开发目录(`~/Code`、`~/Projects`、`~/repos`、`~/workspace`、`~/src`、`~/source`、`~/dev`、`~/work`、`~/Documents`、`~/git`),以及从插件根和工作目录向上查找。
+- Windows 上每个已挂载盘符的根目录(`A:\` 到 `Z:\`),其中包含映射的网络驱动器;系统目录会被过滤掉。
+- macOS 与 Linux 上主目录的父目录(`/Users`、`/home`),这会列出本机其他用户账户的*目录名*。
+
+只有在 `repos.json` 与向上查找都没有产出任何扫描基址时,才会执行上述扫描。
+
+**写入范围**。唯一的写入是 `/prefs.json`,只保存「偏好持久化」一节列出的筛选与主题字段。
+
+**网络**。不上传任何数据,插件不发起任何对外网络请求。
+
+## 测试环境
+
+MiniMax Code 桌面端 `3.0.73.166`,Windows 10.0.26200(x64)。macOS 与 Linux 未验证。
+
+## 许可证
+
+[Apache-2.0](LICENSE)。
+
+`miniapp/node/git-graph.mjs` 中的泳道布局算法是 [`zai-org/ZCode`](https://github.com/zai-org/ZCode) 的 `packages/ui/src/git-graph/layoutAlgorithm.ts` 与 `packages/ui/src/git-graph/layout.ts` 的 JavaScript 移植,上游以 Apache-2.0 许可,原作者 Z.ai / zai-org 贡献者。
diff --git a/plugins/microbiosis/git-tree/docs/preview.jpg b/plugins/microbiosis/git-tree/docs/preview.jpg
new file mode 100644
index 0000000..868c1b1
Binary files /dev/null and b/plugins/microbiosis/git-tree/docs/preview.jpg differ
diff --git a/plugins/microbiosis/git-tree/icon.png b/plugins/microbiosis/git-tree/icon.png
new file mode 100644
index 0000000..299a26c
Binary files /dev/null and b/plugins/microbiosis/git-tree/icon.png differ
diff --git a/plugins/microbiosis/git-tree/miniapp/client/index.html b/plugins/microbiosis/git-tree/miniapp/client/index.html
new file mode 100644
index 0000000..5d21060
--- /dev/null
+++ b/plugins/microbiosis/git-tree/miniapp/client/index.html
@@ -0,0 +1,1836 @@
+
+
+
+
+
+ Git 提交树
+
+
+
+
+
+
+
+
diff --git a/plugins/microbiosis/git-tree/miniapp/miniapp.json b/plugins/microbiosis/git-tree/miniapp/miniapp.json
new file mode 100644
index 0000000..cae762b
--- /dev/null
+++ b/plugins/microbiosis/git-tree/miniapp/miniapp.json
@@ -0,0 +1,20 @@
+{
+ "schemaVersion": 1,
+ "artifacts": {
+ "client": [
+ "./miniapp/client"
+ ],
+ "node": [
+ "./miniapp/node"
+ ]
+ },
+ "runtime": {
+ "kind": "process",
+ "entry": "./miniapp/node/server.mjs",
+ "lifecycle": "on-demand"
+ },
+ "surface": {
+ "path": "/git-tree"
+ },
+ "mcpEndpoints": []
+}
diff --git a/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs b/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs
new file mode 100644
index 0000000..48eef2d
--- /dev/null
+++ b/plugins/microbiosis/git-tree/miniapp/node/git-graph.mjs
@@ -0,0 +1,432 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// This file is a JavaScript port of:
+// packages/ui/src/git-graph/layoutAlgorithm.ts
+// packages/ui/src/git-graph/layout.ts
+// from the upstream repository https://github.com/zai-org/ZCode (Apache-2.0).
+// Original authors: Z.ai / zai-org contributors.
+// Modifications: TypeScript → ES module JavaScript; default rowHeight /
+// laneGap / lanePadding / topPadding / bottomPadding reduced so the layout
+// fits a single-screen commit table on a Mini App surface (upstream uses
+// 42 / 18 / 16 / 20 / 18). Curve constants in `buildEdgePath` and every
+// algorithm detail (vertex state machine, branch colour reuse, merge-path
+// detection, lockedFirst semantics) are kept in lock-step with upstream.
+
+/**
+ * @typedef {object} GraphCommit
+ * @property {string} hash
+ * @property {string[]} parents
+ *
+ * @typedef {object} GraphPoint
+ * @property {number} laneIndex
+ * @property {number} rowIndex
+ *
+ * @typedef {object} BranchLineSeed
+ * @property {GraphPoint} from
+ * @property {GraphPoint} to
+ * @property {number} laneIndex
+ * @property {string} sourceHash
+ * @property {string} targetHash
+ * @property {boolean} lockedFirst
+ *
+ * @typedef {object} LayoutOptions
+ * @property {number} [rowHeight]
+ * @property {number} [laneGap]
+ * @property {number} [lanePadding]
+ * @property {number} [topPadding]
+ * @property {number} [bottomPadding]
+ */
+
+const MISSING_PARENT_ID = -1;
+const DEFAULT_ROW_HEIGHT = 26;
+const DEFAULT_LANE_GAP = 14;
+const DEFAULT_LANE_PADDING = 12;
+const DEFAULT_TOP_PADDING = 0;
+const DEFAULT_BOTTOM_PADDING = 0;
+
+class LayoutBranch {
+ /** @type {Property} */ colourIndex;
+ /** @type {BranchLineSeed[]} */ lines = [];
+ endRowIndex = 0;
+ constructor(colourIndex) {
+ this.colourIndex = colourIndex;
+ }
+ addLine(from, to, sourceHash, targetHash, lockedFirst) {
+ this.lines.push({ from, to, laneIndex: /** @type {number} */ (this.colourIndex), sourceHash, targetHash, lockedFirst });
+ }
+}
+
+class LayoutVertex {
+ /** @type {number} */ id;
+ /** @type {string} */ hash;
+ /** @type {LayoutVertex[]} */ parents = [];
+ nextParentIndex = 0;
+ laneIndex = /** @type {number | null} */ (null);
+ /** @type {LayoutBranch | null} */ branch = null;
+ nextLaneIndex = 0;
+ /** @type {Array<{ target: LayoutVertex, branch: LayoutBranch } | undefined>} */ connections = [];
+ constructor(id, hash) {
+ this.id = id;
+ this.hash = hash;
+ }
+ addParent(vertex) { this.parents.push(vertex); }
+ getNextParent() {
+ return this.nextParentIndex < this.parents.length ? /** @type {LayoutVertex} */ (this.parents[this.nextParentIndex]) : null;
+ }
+ registerParentProcessed() { this.nextParentIndex += 1; }
+ isMerge() { return this.parents.length > 1; }
+ isNotOnBranch() { return this.branch === null || this.laneIndex === null; }
+ addToBranch(branch, laneIndex) {
+ if (this.branch === null) {
+ this.branch = branch;
+ this.laneIndex = laneIndex;
+ }
+ }
+ getBranch() { return this.branch; }
+ getLaneIndex() { return this.laneIndex ?? 0; }
+ getPoint() { return { laneIndex: this.getLaneIndex(), rowIndex: this.id }; }
+ getNextPoint() { return { laneIndex: this.nextLaneIndex, rowIndex: this.id }; }
+ getPointConnectingTo(target, branch) {
+ const idx = this.connections.findIndex((c) => c && c.target === target && c.branch === branch);
+ return idx >= 0 ? { laneIndex: idx, rowIndex: this.id } : null;
+ }
+ reservePoint(laneIndex, target, branch) {
+ if (laneIndex === this.nextLaneIndex) {
+ this.connections[laneIndex] = { target, branch };
+ this.nextLaneIndex = laneIndex + 1;
+ }
+ }
+ getWidthLaneIndex() { return this.nextLaneIndex; }
+}
+
+function createVertices(commits) {
+ const missingParent = new LayoutVertex(MISSING_PARENT_ID, '__zcode_missing_parent__');
+ const vertices = commits.map((c, i) => new LayoutVertex(i, c.hash));
+ const vertexByHash = new Map(vertices.map((v) => [v.hash, v]));
+ commits.forEach((commit, index) => {
+ const vertex = vertices[index];
+ for (const parentHash of commit.parents) {
+ const parent = vertexByHash.get(parentHash) ?? missingParent;
+ vertex.addParent(parent);
+ }
+ });
+ return { missingParent, vertices, vertexByHash };
+}
+
+function getAvailableColour(startAt, availableColours) {
+ const reusable = availableColours.findIndex((endAt) => startAt > endAt);
+ if (reusable >= 0) return reusable;
+ availableColours.push(0);
+ return availableColours.length - 1;
+}
+
+function determineMergePath(startAt, vertices, vertex, parentVertex) {
+ const parentBranch = /** @type {LayoutBranch} */ (parentVertex.getBranch());
+ let lastPoint = vertex.getPoint();
+ let foundConnectionToParent = false;
+ for (let rowIndex = startAt + 1; rowIndex < vertices.length; rowIndex += 1) {
+ const currentVertex = vertices[rowIndex];
+ const existingPoint = currentVertex.getPointConnectingTo(parentVertex, parentBranch);
+ const currentPoint = existingPoint ?? currentVertex.getNextPoint();
+ foundConnectionToParent = existingPoint !== null;
+ parentBranch.addLine(
+ lastPoint,
+ currentPoint,
+ vertex.hash,
+ parentVertex.hash,
+ !foundConnectionToParent && currentVertex !== parentVertex
+ ? lastPoint.laneIndex < currentPoint.laneIndex
+ : true,
+ );
+ currentVertex.reservePoint(currentPoint.laneIndex, parentVertex, parentBranch);
+ lastPoint = currentPoint;
+ if (foundConnectionToParent) {
+ vertex.registerParentProcessed();
+ break;
+ }
+ }
+}
+
+function determineNormalPath(params) {
+ const { startAt, vertices, branches, availableColours, missingParent } = params;
+ let rowIndex = startAt;
+ let vertex = vertices[rowIndex];
+ let parentVertex = vertex.getNextParent();
+ let lastPoint = vertex.isNotOnBranch() ? vertex.getNextPoint() : vertex.getPoint();
+ const branch = new LayoutBranch(getAvailableColour(startAt, availableColours));
+ vertex.addToBranch(branch, lastPoint.laneIndex);
+ vertex.reservePoint(lastPoint.laneIndex, vertex, branch);
+ for (rowIndex = startAt + 1; rowIndex < vertices.length; rowIndex += 1) {
+ if (parentVertex === null || parentVertex === missingParent) break;
+ const currentVertex = vertices[rowIndex];
+ const currentPoint =
+ parentVertex === currentVertex && !parentVertex.isNotOnBranch()
+ ? currentVertex.getPoint()
+ : currentVertex.getNextPoint();
+ branch.addLine(
+ lastPoint,
+ currentPoint,
+ vertex.hash,
+ parentVertex.hash,
+ lastPoint.laneIndex < currentPoint.laneIndex,
+ );
+ currentVertex.reservePoint(currentPoint.laneIndex, parentVertex, branch);
+ lastPoint = currentPoint;
+ if (parentVertex === currentVertex) {
+ vertex.registerParentProcessed();
+ const parentWasAlreadyOnBranch = !parentVertex.isNotOnBranch();
+ parentVertex.addToBranch(branch, currentPoint.laneIndex);
+ vertex = parentVertex;
+ parentVertex = vertex.getNextParent();
+ if (parentVertex === missingParent) {
+ vertex.registerParentProcessed();
+ break;
+ }
+ if (parentVertex === null || parentWasAlreadyOnBranch) break;
+ }
+ }
+ branch.endRowIndex = rowIndex;
+ branches.push(branch);
+ availableColours[branch.colourIndex] = rowIndex;
+}
+
+function determinePath(params) {
+ const vertex = params.vertices[params.startAt];
+ const parentVertex = vertex.getNextParent();
+ if (parentVertex === params.missingParent) {
+ vertex.registerParentProcessed();
+ return;
+ }
+ if (
+ parentVertex !== null &&
+ vertex.isMerge() &&
+ !vertex.isNotOnBranch() &&
+ !parentVertex.isNotOnBranch()
+ ) {
+ determineMergePath(params.startAt, params.vertices, vertex, parentVertex);
+ return;
+ }
+ determineNormalPath(params);
+}
+
+function createGitGraphLayoutModel(commits) {
+ const { missingParent, vertices, vertexByHash } = createVertices(commits);
+ const branches = [];
+ const availableColours = [];
+ let index = 0;
+ while (index < vertices.length) {
+ const vertex = vertices[index];
+ if (vertex.getNextParent() !== null || vertex.isNotOnBranch()) {
+ determinePath({ startAt: index, vertices, branches, availableColours, missingParent });
+ } else {
+ index += 1;
+ }
+ }
+ return {
+ vertices,
+ vertexByHash,
+ branchLines: branches.flatMap((branch) => branch.lines),
+ };
+}
+
+function buildEdgePath({ fromX, fromY, toX, toY, lockedFirst }) {
+ if (fromX === toX) {
+ return `M ${fromX} ${fromY} L ${toX} ${toY}`;
+ }
+ const curveOffset = Math.max(14, Math.abs(toY - fromY) * 0.38);
+ if (lockedFirst === false) {
+ return `M ${fromX} ${fromY} C ${fromX} ${toY - curveOffset}, ${toX} ${toY - curveOffset}, ${toX} ${toY}`;
+ }
+ return `M ${fromX} ${fromY} C ${fromX} ${fromY + curveOffset}, ${toX} ${fromY + curveOffset}, ${toX} ${toY}`;
+}
+
+function pointToPixels(point, opts) {
+ return {
+ x: opts.lanePadding + point.laneIndex * opts.laneGap,
+ y: opts.topPadding + point.rowIndex * opts.rowHeight,
+ };
+}
+
+/**
+ * @typedef {object} DetailedRow
+ * @property {string} hash
+ * @property {number} rowIndex
+ * @property {number} laneIndex
+ * @property {number} x
+ * @property {number} y
+ *
+ * @typedef {object} DetailedEdge
+ * @property {string} id
+ * @property {string} fromHash
+ * @property {string} toHash
+ * @property {number} fromLaneIndex
+ * @property {number} toLaneIndex
+ * @property {string} path
+ * @property {boolean} truncated
+ *
+ * @typedef {object} DetailedPath
+ * @property {string} id
+ * @property {number} laneIndex
+ * @property {string} path
+ * @property {string[]} relatedHashes
+ *
+ * @typedef {object} DetailedLaneSegment
+ * @property {string} id
+ * @property {string} hash
+ * @property {number} laneIndex
+ * @property {string} path
+ *
+ * @typedef {object} DetailedGraphLayout
+ * @property {DetailedRow[]} rows
+ * @property {DetailedEdge[]} edges
+ * @property {DetailedPath[]} paths
+ * @property {DetailedLaneSegment[]} laneSegments
+ * @property {number} laneCount
+ * @property {number} width
+ * @property {number} height
+ * @property {number} rowHeight
+ * @property {number} laneGap
+ */
+
+/**
+ * ZCode-aligned wrapper around the algorithm. Returns the rich structure the
+ * client needs to render the graph as a single SVG canvas: rows with x/y
+ * pixel coordinates, full SVG path strings per edge, the complete set of
+ * branch paths for hover highlighting, and vertical-only lane segments.
+ *
+ * Equivalent to upstream `layoutGitGraph`; renamed so the existing
+ * `createGitGraphLayoutModel` export below keeps its original name.
+ * @param {GraphCommit[]} commits
+ * @param {LayoutOptions} [options]
+ * @returns {DetailedGraphLayout}
+ */
+function layoutGitGraphDetailed(commits, options = {}) {
+ const rowHeight = options.rowHeight ?? DEFAULT_ROW_HEIGHT;
+ const laneGap = options.laneGap ?? DEFAULT_LANE_GAP;
+ const lanePadding = options.lanePadding ?? DEFAULT_LANE_PADDING;
+ const topPadding = options.topPadding ?? DEFAULT_TOP_PADDING;
+ const bottomPadding = options.bottomPadding ?? DEFAULT_BOTTOM_PADDING;
+
+ const { vertices, vertexByHash, branchLines } = createGitGraphLayoutModel(commits);
+
+ const rows = vertices.map((vertex, rowIndex) => {
+ const laneIndex = vertex.getLaneIndex();
+ return {
+ hash: commits[rowIndex].hash,
+ rowIndex,
+ laneIndex,
+ x: lanePadding + laneIndex * laneGap,
+ y: topPadding + rowIndex * rowHeight,
+ };
+ });
+
+ const rowByHash = new Map(rows.map((row) => [row.hash, row]));
+ const pixelOptions = { lanePadding, laneGap, topPadding, rowHeight };
+
+ const maxRowLaneIndex = rows.reduce((max, r) => Math.max(max, r.laneIndex), 0);
+ const maxWidthLaneIndex = vertices.reduce(
+ (max, vertex) => Math.max(max, vertex.getWidthLaneIndex() - 1),
+ 0,
+ );
+ const maxLineLaneIndex = branchLines.reduce(
+ (max, line) => Math.max(max, line.from.laneIndex, line.to.laneIndex),
+ 0,
+ );
+ const laneCount = Math.max(1, maxRowLaneIndex + 1, maxWidthLaneIndex + 1, maxLineLaneIndex + 1);
+ const width = lanePadding * 2 + (laneCount - 1) * laneGap;
+ const height = topPadding + Math.max(0, commits.length - 1) * rowHeight + bottomPadding;
+
+ /** @type {DetailedEdge[]} */
+ const edges = [];
+ for (let rowIndex = 0; rowIndex < commits.length; rowIndex += 1) {
+ const commit = commits[rowIndex];
+ const fromVertex = vertexByHash.get(commit.hash);
+ if (!fromVertex) continue;
+ const fromRow = rows[rowIndex];
+ for (let parentIndex = 0; parentIndex < commit.parents.length; parentIndex += 1) {
+ const parentHash = commit.parents[parentIndex];
+ const parentVertex = vertexByHash.get(parentHash);
+ const fromLaneIndex = fromVertex.getLaneIndex();
+ const toLaneIndex = parentVertex ? parentVertex.getLaneIndex() : fromLaneIndex + parentIndex;
+ const toRow = parentHash ? rowByHash.get(parentHash) : null;
+ const fromX = lanePadding + fromLaneIndex * laneGap;
+ const toX = lanePadding + toLaneIndex * laneGap;
+ edges.push({
+ id: `${commit.hash}:${parentHash}:${parentIndex}`,
+ fromHash: commit.hash,
+ toHash: parentHash,
+ fromLaneIndex,
+ toLaneIndex,
+ path: buildEdgePath({
+ fromX,
+ fromY: fromRow.y,
+ toX,
+ toY: toRow ? toRow.y : fromRow.y,
+ lockedFirst: fromLaneIndex < toLaneIndex,
+ }),
+ truncated: !toRow,
+ });
+ }
+ }
+
+ /** @type {DetailedPath[]} */
+ const paths = branchLines.map((line, index) => {
+ const from = pointToPixels(line.from, pixelOptions);
+ const to = pointToPixels(line.to, pixelOptions);
+ return {
+ id: `${line.sourceHash}:${line.targetHash}:${line.from.rowIndex}:${line.to.rowIndex}:${index}:path`,
+ laneIndex: line.laneIndex,
+ path: buildEdgePath({
+ fromX: from.x,
+ fromY: from.y,
+ toX: to.x,
+ toY: to.y,
+ lockedFirst: line.lockedFirst,
+ }),
+ relatedHashes:
+ line.sourceHash === line.targetHash
+ ? [line.sourceHash]
+ : [line.sourceHash, line.targetHash],
+ };
+ });
+
+ /** @type {DetailedLaneSegment[]} */
+ const laneSegments = branchLines
+ .filter((line) => line.from.laneIndex === line.to.laneIndex)
+ .map((line, index) => {
+ const from = pointToPixels(line.from, pixelOptions);
+ const to = pointToPixels(line.to, pixelOptions);
+ return {
+ id: `${line.sourceHash}:${line.targetHash}:${line.from.rowIndex}:${line.to.rowIndex}:${index}:segment`,
+ hash: line.targetHash,
+ laneIndex: line.to.laneIndex,
+ path: buildEdgePath({
+ fromX: from.x,
+ fromY: from.y,
+ toX: to.x,
+ toY: to.y,
+ lockedFirst: line.lockedFirst,
+ }),
+ };
+ });
+
+ return {
+ rows,
+ edges,
+ paths,
+ laneSegments,
+ laneCount,
+ width,
+ height,
+ rowHeight,
+ laneGap,
+ };
+}
+
+export {
+ createGitGraphLayoutModel,
+ layoutGitGraphDetailed,
+ DEFAULT_ROW_HEIGHT,
+ DEFAULT_LANE_GAP,
+ DEFAULT_LANE_PADDING,
+};
\ No newline at end of file
diff --git a/plugins/microbiosis/git-tree/miniapp/node/repos.json b/plugins/microbiosis/git-tree/miniapp/node/repos.json
new file mode 100644
index 0000000..acaf8fb
--- /dev/null
+++ b/plugins/microbiosis/git-tree/miniapp/node/repos.json
@@ -0,0 +1,5 @@
+{
+ "_comment": "List of Git repositories the page shows by default. Empty by design — every install should declare its own local paths. Set `scanRoots` to one or more directories whose direct children will be auto-discovered (each must contain a `.git` directory).",
+ "repos": [],
+ "scanRoots": []
+}
diff --git a/plugins/microbiosis/git-tree/miniapp/node/server.mjs b/plugins/microbiosis/git-tree/miniapp/node/server.mjs
new file mode 100644
index 0000000..c8adbf2
--- /dev/null
+++ b/plugins/microbiosis/git-tree/miniapp/node/server.mjs
@@ -0,0 +1,1208 @@
+// @ts-check
+
+import { execFile } from 'node:child_process';
+import { readFile, readdir, stat, writeFile, mkdir } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { homedir } from 'node:os';
+import { dirname, isAbsolute, join, resolve } from 'node:path';
+import { createGzip } from 'node:zlib';
+import { pipeline } from 'node:stream/promises';
+import {
+ layoutGitGraphDetailed,
+ DEFAULT_LANE_GAP,
+ DEFAULT_LANE_PADDING,
+ DEFAULT_ROW_HEIGHT,
+} from './git-graph.mjs';
+
+/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */
+/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */
+
+const SURFACE_PATH = '/git-tree';
+const API_ROOT = '/api/git-tree';
+const ROW_HEIGHT = DEFAULT_ROW_HEIGHT;
+const LANE_SPACING = DEFAULT_LANE_GAP;
+const GRAPH_PADDING = DEFAULT_LANE_PADDING;
+const DEFAULT_LIMIT = 200;
+const MAX_LIMIT = 400;
+// Per-command git timeout budgets. Tight, quick reads should fail fast so a
+// hung background process does not stall the whole overview response.
+const GIT_TIMEOUT_QUICK_MS = 10_000; // for-each-ref, tag --list, contains checks
+const GIT_TIMEOUT_MEDIUM_MS = 15_000; // log -n 1, status --porcelain, git show (single commit)
+const GIT_TIMEOUT_HEAVY_MS = 30_000; // log --topo-order --all, rev-list --all --count
+const MAX_BUFFER = 64 * 1024 * 1024;
+const DISCOVERY_TTL_MS = 60_000;
+const OVERVIEW_TTL_MS = 30_000;
+const COMMIT_TTL_MS = 60_000;
+const GZIP_MIN_BYTES = 256;
+const REF_PATTERN = /^(?!-)[A-Za-z0-9._/+^-]{1,200}$/;
+const SHA_PATTERN = /^[0-9a-fA-F]{4,64}$/;
+
+/**
+ * @typedef {object} GitResult
+ * @property {boolean} ok
+ * @property {string} stdout
+ * @property {string} stderr
+ * @property {number} code
+ */
+
+/**
+ * @param {string} cwd
+ * @param {string[]} args
+ * @param {number} [timeoutMs]
+ * @returns {Promise}
+ */
+function runGit(cwd, args, timeoutMs = GIT_TIMEOUT_HEAVY_MS) {
+ return new Promise((resolvePromise) => {
+ execFile(
+ 'git',
+ args,
+ { cwd, windowsHide: true, maxBuffer: MAX_BUFFER, timeout: timeoutMs, encoding: 'utf8' },
+ (error, stdout, stderr) => {
+ const code = typeof error?.code === 'number' ? error.code : error ? 1 : 0;
+ resolvePromise({
+ ok: !error,
+ stdout: typeof stdout === 'string' ? stdout : '',
+ stderr: typeof stderr === 'string' ? stderr : '',
+ code,
+ });
+ },
+ );
+ });
+}
+
+/** @param {string} target */
+async function pathExists(target) {
+ try {
+ await stat(target);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Walk up from `start` until a directory containing `.git` is found.
+ * @param {string} start
+ * @returns {Promise}
+ */
+async function findRepoRoot(start) {
+ let current = resolve(start);
+ for (let depth = 0; depth < 5; depth += 1) {
+ if (await pathExists(join(current, '.git'))) return current;
+ const parent = dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+ return null;
+}
+
+/**
+ * @typedef {object} RepoEntry
+ * @property {string} path
+ * @property {string} label
+ * @property {boolean} isDefault
+ */
+
+/**
+ * @typedef {object} Registry
+ * @property {string | null} defaultRepo
+ * @property {RepoEntry[]} repos
+ */
+
+/**
+ * @typedef {object} RepoConfig
+ * @property {string[]} repos
+ * @property {string[]} scanRoots
+ */
+
+/**
+ * Parse JSON text that may carry a UTF-8 BOM. Windows editors (notepad.exe in
+ * particular) write one by default, and `JSON.parse` rejects a leading
+ * `\uFEFF` — which used to make an explicitly configured `repos.json` fail
+ * silently and fall back to discovery with nothing surfaced to the user.
+ * @param {string} text
+ * @returns {unknown}
+ */
+function parseJsonText(text) {
+ return JSON.parse(text.replace(/^\uFEFF/, ''));
+}
+
+/**
+ * Load the authored repository allowlist shipped beside the Node entry. The
+ * published Host install directory sits outside the workspace, so this file is
+ * the only discovery source that survives publication.
+ * @param {string} pluginRoot
+ * @returns {Promise}
+ */
+async function loadRepoConfig(pluginRoot) {
+ /** @type {RepoConfig} */
+ const empty = { repos: [], scanRoots: [] };
+ try {
+ const raw = await readFile(join(pluginRoot, 'miniapp/node/repos.json'), 'utf8');
+ const parsed = parseJsonText(raw);
+ const pick = (/** @type {unknown} */ value) =>
+ Array.isArray(value) ? value.filter((entry) => typeof entry === 'string' && isAbsolute(resolve(entry))) : [];
+ return { repos: pick(parsed?.repos), scanRoots: pick(parsed?.scanRoots) };
+ } catch {
+ return empty;
+ }
+}
+
+/**
+ * Subdirectories we never want to walk into even when they appear at a scan
+ * base, because they are either inaccessible, contain OS state, or are simply
+ * not where a developer keeps code. Most hidden dirs (`.Trash`, `.cache`, …)
+ * are caught by the `startsWith('.')` rule in the filter; this set covers
+ * unhidden-but-systemic names.
+ */
+const SKIP_DIR_NAMES = new Set([
+ // macOS system / resource / metadata directories
+ 'Library', 'Applications', 'System',
+ // Windows system hives — even when read access works, recursing here is
+ // never what a developer wants.
+ 'Program Files', 'Program Files (x86)', 'ProgramData', 'Windows', 'Users',
+ '$Recycle.Bin', 'System Volume Information', '$WinREAgent', 'Recovery',
+ 'PerfLogs', 'Boot', 'Config.Msi', 'OneDriveTemp',
+]);
+
+/** @param {string} name */
+function isSkippableDirectoryName(name) {
+ if (!name) return true;
+ // Hidden dirs: .Trash, .cache, .npm, .next, .pnpm-store, .git, ...
+ if (name.startsWith('.')) return true;
+ // macOS resource forks / metadata and AppleDouble double-dot prefixes
+ if (name.startsWith('_')) return true;
+ return SKIP_DIR_NAMES.has(name);
+}
+
+/**
+ * Subdirectory names under a drive root that suggest "projects live here".
+ * When such a directory exists one level under a scan base but does not
+ * itself contain `.git`, we recurse one level into it to catch layouts
+ * like `D:\Github\\` where the actual repositories are nested
+ * two levels deep under the drive root.
+ *
+ * Match is case-insensitive. The list covers English conventions
+ * (`github`, `code`, `projects`, …) and CJK variants seen on shared
+ * developer machines (`代码`, `项目`, …). Heuristic-only names that
+ * could collide with unrelated folders (e.g. `play`, `stuff`) are
+ * intentionally omitted to keep the recursion cheap and predictable.
+ */
+const DEV_PARENT_HINTS = new Set([
+ 'github', 'code', 'codes', 'projects', 'project',
+ 'workspace', 'workspaces',
+ 'src', 'source', 'sources',
+ 'dev', 'devel', 'development',
+ 'work', 'works',
+ 'git', 'gits', 'repos', 'repos', 'repositories', 'repository',
+ 'gitlab', 'bitbucket', 'coding', 'playground', 'sandbox',
+ 'opensources', 'oss', 'personal', 'private', 'public',
+ 'practices', 'experiments',
+ // CJK variants occasionally seen on shared dev machines
+ '代码', '项目', '工程', '源码',
+]);
+
+/** @param {string} name */
+function isLikelyDevParentName(name) {
+ if (!name) return false;
+ return DEV_PARENT_HINTS.has(name.toLowerCase());
+}
+
+/**
+ * Portability fallback for `repos.json` empty installs: derive a small, bounded
+ * set of plausible dev directories. On macOS/Linux we stay under or one level
+ * above `$HOME`; on Windows we also walk every mounted drive root because
+ * developers routinely keep projects on a non-OS drive (`D:\GitHub\...`,
+ * `E:\Work\...`) that is never under `$HOME`.
+ *
+ * The candidate set is only added when nothing else surfaced a scan base, so
+ * users with an explicit `repos.json` are unaffected. Per-entry filtering
+ * below drops Windows system hives (`Program Files`, `Users`, `Windows`, …),
+ * so widening to drive roots cannot accidentally recurse into `%ProgramFiles%`.
+ *
+ * Names with case duplicates (Code/code, Repos/repos) are intentional because
+ * Windows and macOS default to case-insensitive filesystems and developers
+ * frequently mix capitalizations. The set itself is small (≈ 16 + drives)
+ * and the per-base scan is one-level deep with a 60-entry cap.
+ * @returns {string[]}
+ */
+function discoverDefaultScanBases() {
+ const home = homedir();
+ if (!home) return [];
+ const bases = [];
+ for (const name of [
+ 'Code', 'code',
+ 'Projects', 'projects',
+ 'repos', 'Repos',
+ 'workspace', 'Workspace',
+ 'src', 'SRC', 'source', 'Source',
+ 'dev', 'Dev', 'work', 'Work',
+ 'Documents', 'git', 'Git',
+ ]) {
+ bases.push(join(home, name));
+ }
+ if (process.platform === 'win32') {
+ // Each Windows drive root is one readdir() away from common
+ // "all my projects live here" parents (D:\GitHub\\...,
+ // D:\Projects\..., D:\Work\...). Per-entry filter below keeps
+ // Windows system hives out of the scan.
+ for (let i = 65; i <= 90; i += 1) {
+ bases.push(`${String.fromCharCode(i)}:\\`);
+ }
+ } else {
+ // On macOS/Linux the home parent (e.g. /home, /Users) is a
+ // sibling-user scan that costs one extra readdir and frequently
+ // catches other developer accounts on the same host.
+ bases.push(resolve(home, '..'));
+ }
+ return bases;
+}
+
+/**
+ * Build a bounded repository registry from an explicit allowlist plus bounded
+ * discovery: walk-ups from the plugin root and the process cwd, then one
+ * directory level of every scan root, each validated by a `.git` entry.
+ * @param {string} pluginRoot
+ * @returns {Promise}
+ */
+async function buildRegistry(pluginRoot) {
+ /** @type {RepoEntry[]} */
+ const repos = [];
+ /** @type {Set} */
+ const seen = new Set();
+
+ const add = (/** @type {string} */ candidate) => {
+ const normalized = resolve(candidate);
+ const key = normalized.toLowerCase();
+ if (seen.has(key)) return;
+ seen.add(key);
+ repos.push({ path: normalized, label: normalized.split(/[\\/]/).filter(Boolean).pop() ?? normalized, isDefault: false });
+ };
+
+ const config = await loadRepoConfig(pluginRoot);
+ const walkRoots = [await findRepoRoot(pluginRoot), await findRepoRoot(process.cwd())];
+
+ /** @type {string[]} */
+ const preferred = [];
+ for (const candidate of [...config.repos, ...walkRoots]) {
+ // Normalize on the way in: `repos[].path` entries are `resolve()`d below,
+ // and `defaultRepo` is handed to the client verbatim. Keeping the raw
+ // `D:/foo` (forward slashes) here would make the repo dropdown fail to
+ // match its own option list and render as "nothing selected".
+ if (candidate && (await pathExists(join(candidate, '.git')))) preferred.push(resolve(candidate));
+ }
+ for (const candidate of preferred) add(candidate);
+
+ /** @type {Set} */
+ const scanBases = new Set();
+ for (const base of config.scanRoots) scanBases.add(resolve(base));
+ for (const root of walkRoots) if (root) scanBases.add(dirname(root));
+
+ // Portability fallback: when walk-up produced nothing (plugin installed
+ // outside any git tree) AND the user has not authored `repos.json`, derive
+ // a small set of `$HOME` dev directories plus, on Windows, every mounted
+ // drive root. This lets a fresh install on a brand-new host surface
+ // projects without any manual configuration.
+ //
+ // The `config.repos.length === 0` half of the gate matters: without it a
+ // user who listed their repos explicitly would still get a whole-disk
+ // scan, which is far more file access than they asked for.
+ //
+ // On Windows the per-entry filter below drops system hives (`Program
+ // Files`, `Users`, `Windows`, …) so widening to drive roots cannot
+ // accidentally recurse into %ProgramFiles% or %SystemRoot%.
+ if (scanBases.size === 0 && config.repos.length === 0) {
+ for (const base of discoverDefaultScanBases()) {
+ scanBases.add(resolve(base));
+ }
+ }
+
+ for (const scanBase of scanBases) {
+ try {
+ const entries = await readdir(scanBase, { withFileTypes: true });
+ const directories = entries.filter(
+ (entry) => entry.isDirectory() && !isSkippableDirectoryName(entry.name),
+ );
+ // Direct `.git` check is uncapped: every non-skipped entry is probed
+ // with one stat. The earlier `slice(0, 60)` was a silent correctness
+ // bug — on a Windows drive root with 100+ entries (D:\ here has ~110)
+ // anything past index 60 was dropped before the `.git` check, so
+ // legitimate repos like `D:\synthetic-git-repo` could never appear.
+ // Stat-per-entry is cheap enough on a local SSD that the cap was
+ // never justified in the first place.
+ const directChecks = directories.map(async (entry) => {
+ const candidate = join(scanBase, entry.name);
+ if (await pathExists(join(candidate, '.git'))) add(candidate);
+ return entry;
+ });
+ const directEntries = (await Promise.all(directChecks));
+ // Dev-parent recursion stays capped. Only entries that match
+ // `DEV_PARENT_HINTS` recurse one level, and we cap at 60 to bound
+ // the worst-case second-level readdir fan-out. Sorting dev-parent
+ // entries first keeps the most likely productive subtrees inside
+ // the cap even when the parent scan base has hundreds of entries.
+ const devParentEntries = directEntries
+ .filter((entry) => isLikelyDevParentName(entry.name))
+ .slice(0, 60);
+ for (const entry of devParentEntries) {
+ const candidate = join(scanBase, entry.name);
+ try {
+ const subEntries = await readdir(candidate, { withFileTypes: true });
+ for (const sub of subEntries) {
+ if (!sub.isDirectory() || isSkippableDirectoryName(sub.name)) continue;
+ const subCandidate = join(candidate, sub.name);
+ if (await pathExists(join(subCandidate, '.git'))) add(subCandidate);
+ }
+ } catch {
+ /* subdir unreadable: skip */
+ }
+ }
+ } catch {
+ /* scan base unreadable: keep whatever was already found */
+ }
+ }
+
+ if (preferred.length > 0 && repos.length > 0) repos[0].isDefault = true;
+ // Fallback path: when no walk-up produced a preferred repo, the registry
+ // still has entries but no `defaultRepo`. Mark the first entry as the
+ // default so the client dropdown opens with something selected instead
+ // of forcing the user to pick manually. The client also has its own
+ // `state.repos[0]` fallback, so this is a defense-in-depth nicety.
+ if (repos.length > 0 && !preferred.length) repos[0].isDefault = true;
+ return { defaultRepo: preferred[0] ?? repos[0]?.path ?? null, repos };
+}
+
+/** @param {string} value */
+function normalizeRepo(value) {
+ if (typeof value !== 'string' || value.length === 0) return null;
+ if (value.includes('\0')) return null;
+ const normalized = resolve(value);
+ if (!isAbsolute(normalized)) return null;
+ return normalized;
+}
+
+/**
+ * Resolve a requested repository against the discovered registry.
+ * @param {Registry} registry
+ * @param {string | null} requested
+ * @returns {string | null}
+ */
+function resolveRepo(registry, requested) {
+ const target = requested ? normalizeRepo(requested) : registry.defaultRepo;
+ if (!target) return null;
+ const key = target.toLowerCase();
+ const match = registry.repos.find((repo) => repo.path.toLowerCase() === key);
+ return match ? match.path : null;
+}
+
+/**
+ * @typedef {object} CommitRow
+ * @property {string} sha
+ * @property {string} short
+ * @property {string[]} parents
+ * @property {string} authorName
+ * @property {string} authorEmail
+ * @property {string} date
+ * @property {string} subject
+ * @property {number} lane
+ * @property {boolean} merge
+ * @property {string[]} tags
+ */
+
+/**
+ * @typedef {object} GraphEdge
+ * @property {number} from
+ * @property {number} to
+ * @property {number} fromLane
+ * @property {number} toLane
+ */
+
+/**
+ * Compute the swim-lane layout, full SVG path strings, and per-row pixel
+ * coordinates the client renders as a single canvas SVG. Also mutates each
+ * commit with its assigned lane index and merge flag.
+ * @param {CommitRow[]} commits
+ */
+function buildGraph(commits) {
+ const graphCommits = commits.map((c) => ({ hash: c.sha, parents: c.parents }));
+ const layout = layoutGitGraphDetailed(graphCommits, {
+ rowHeight: ROW_HEIGHT,
+ laneGap: LANE_SPACING,
+ lanePadding: GRAPH_PADDING,
+ });
+
+ for (const row of layout.rows) {
+ const commit = commits[row.rowIndex];
+ if (commit) {
+ commit.lane = row.laneIndex;
+ commit.merge = commit.parents.length > 1;
+ }
+ }
+
+ return layout;
+}
+
+/** @param {string} stdout */
+function splitLines(stdout) {
+ return stdout
+ .split('\n')
+ .map((line) => line.replace(/\r$/, ''))
+ .filter((line) => line.length > 0);
+}
+
+/** @param {string} stdout */
+function splitFields(stdout, separator) {
+ return splitLines(stdout).map((line) => line.split(separator));
+}
+
+/**
+ * @param {string} repo
+ * @returns {Promise]