diff --git a/README.md b/README.md index 5a3ef61..e695c0b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Contains plugin sources, installable `.piplug` packages, and the generated marke | **pi.log-viewer** | Large log viewer with streaming pagination, live tail, search highlighting and multi-file tabs | Tioit-Wang | | **pi.bianqian** | Markdown desktop sticky notes: multi-note, live preview, task lists, highlighter and trash | ZY | | **io.github.muzimu217.session-import** | Universal session import + forge: bring sessions in from ZCode, WorkBuddy, Claude Code, Codex, OpenCode and Pi, then distill them into project conventions and reusable practices | muzimu217 | +| **io.github.liushunqiu.pi-idea-git** | IDEA-style Git tool window: staged/unstaged change lists, hunk-level stage & revert, commit, branch switcher, graph log and stashes | liushunqiu | ### Demos diff --git a/README.zh-CN.md b/README.zh-CN.md index 5122230..8879020 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,6 +39,7 @@ | **pi.log-viewer** | 大日志查看器:流式分页、实时跟随、搜索高亮、多文件页签 | Tioit-Wang | | **pi.bianqian** | Markdown 桌面便签:多便签、实时预览、任务列表、荧光笔与回收站 | ZY | | **io.github.muzimu217.session-import** | 一体化会话导入与熔炉:导入 ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi 的会话,再蒸馏成项目约定与可复用做法 | muzimu217 | +| **io.github.liushunqiu.pi-idea-git** | IDEA 风格 Git 工具窗口:暂存/未暂存分组、按代码块暂存与还原、提交、分支切换、图形化日志与储藏 | liushunqiu | ### 示例插件(学习参考) diff --git a/catalog.json b/catalog.json index 1ff631e..149759e 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-13T13:55:42Z", + "updatedAt": "2026-09-14T03:28:52Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -150,6 +150,61 @@ } ] }, + { + "id": "io.github.liushunqiu.pi-idea-git", + "name": "IDEA Git", + "description": "An IntelliJ-IDEA-style Git tool window for PI-Desktop: staged/unstaged change lists, hunk-level stage & revert, commit, branch switcher, graph log and stashes. IDEA Git \u98ce\u683c\u7684 Git \u5de5\u5177\u7a97\u53e3\uff1a\u6682\u5b58/\u672a\u6682\u5b58\u5206\u7ec4\u3001\u6309\u4ee3\u7801\u5757\u6682\u5b58\u4e0e\u8fd8\u539f\u3001\u63d0\u4ea4\u3001\u5206\u652f\u5207\u6362\u3001\u56fe\u5f62\u5316\u65e5\u5fd7\u4e0e\u50a8\u85cf\u3002", + "i18n": { + "en": { + "name": "IDEA Git", + "description": "An IntelliJ-IDEA-style Git tool window for PI-Desktop: staged/unstaged change lists, hunk-level stage & revert, commit, branch switcher, graph log and stashes.", + "safetyNotes": "Reads workspace files through the host fs APIs (Open File / Reveal only); discarding an untracked file deletes it (with a confirmation dialog) \u2014 every other write goes through Git commands. Commit-message generation calls the host model with your own model setup and quota (host rate limit 8/min); the plugin holds no API keys, makes no network requests, no telemetry. HTTPS credentials come from your own Git credential helper; keys that live only in ssh-agent do not work \u2014 use an HTTPS remote or a keychain-backed key." + }, + "zh-CN": { + "name": "IDEA Git", + "description": "PI-Desktop \u7684 IDEA \u98ce\u683c Git \u5de5\u5177\u7a97\u53e3\uff1a\u6682\u5b58/\u672a\u6682\u5b58\u5206\u7ec4\u3001\u6309\u4ee3\u7801\u5757\u6682\u5b58\u4e0e\u8fd8\u539f\u3001\u63d0\u4ea4\u3001\u5206\u652f\u5207\u6362\u3001\u56fe\u5f62\u5316\u65e5\u5fd7\u4e0e\u50a8\u85cf\u3002", + "safetyNotes": "\u53ea\u8bfb\u5de5\u4f5c\u533a\u6587\u4ef6\uff08Open File / Reveal \u8d70\u5bbf\u4e3b fs \u63a5\u53e3\uff09\uff1b\u4e22\u5f03\u672a\u8ddf\u8e2a\u6587\u4ef6\u65f6\u4f1a\u76f4\u63a5\u5220\u9664\u8be5\u6587\u4ef6\uff08\u64cd\u4f5c\u524d\u6709\u786e\u8ba4\u6846\uff09\uff0c\u5176\u4f59\u5199\u64cd\u4f5c\u4e00\u5f8b\u8d70 Git \u547d\u4ee4\u3002\u751f\u6210\u63d0\u4ea4\u4fe1\u606f\u8c03\u7528\u5bbf\u4e3b\u6a21\u578b\uff0c\u7528\u7684\u662f\u4f60\u81ea\u5df1\u7684\u6a21\u578b\u914d\u7f6e\u4e0e\u989d\u5ea6\uff08\u5bbf\u4e3b\u9650\u6d41 8 \u6b21/\u5206\uff09\uff0c\u63d2\u4ef6\u4e0d\u6301\u6709\u4efb\u4f55 API Key\uff0c\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\uff0c\u65e0\u9065\u6d4b\u3002HTTPS \u51ed\u636e\u8d70\u4f60\u81ea\u5df1\u7684 Git \u51ed\u636e\u52a9\u624b\uff1b\u53ea\u6d3b\u5728 ssh-agent \u91cc\u7684\u5bc6\u94a5\u7528\u4e0d\u4e86\uff0c\u8bf7\u7528 HTTPS \u8fdc\u7aef\u6216\u94a5\u5319\u4e32\u5bc6\u94a5\u3002" + } + }, + "author": "liushunqiu", + "categories": [ + "developer-tools", + "productivity" + ], + "verified": true, + "downloads": 0, + "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/io.github.liushunqiu.pi-idea-git", + "repository": "https://github.com/vastsa/pi-desktop-plugins", + "readmeMarkdown": "# IDEA Git \u2014\u2014 PI-Desktop \u7684 IntelliJ IDEA \u98ce\u683c Git \u5de5\u5177\u7a97\u53e3\n\n`io.github.liushunqiu.pi-idea-git` \u628a IntelliJ IDEA \u7684 Git \u754c\u9762\u642c\u8fdb PI-Desktop\uff1a\u4f60\u771f\u7684\u4f1a\u4e00\u76f4\u5f85\u5728\u91cc\u9762\u7684\u90a3\u4e24\u4e2a\n\u5de5\u5177\u7a97\u53e3\u3001\u6682\u5b58/\u672a\u6682\u5b58\u5206\u7ec4\u3001\u6309\u4ee3\u7801\u5757\uff08hunk\uff09\u6682\u5b58\u3001\u56fe\u5f62\u5316\u65e5\u5fd7\u4e0e Console\u3002\n\n\u8fd9\u91cc\u7684\u6bcf\u4e00\u4e2a Git \u52a8\u4f5c\u90fd\u662f**\u771f\u7684\u53bb\u8c03 `git`**\uff0c\u5bf9\u7740\u771f\u5b9e\u7684\u7d22\u5f15\u5e72\u6d3b\u3002\u6ca1\u6709\u4efb\u4f55\u529f\u80fd\u662f\u5728\u6e32\u67d3\u5c42\n\u5047\u88c5\u51fa\u6765\u7684\uff0c\u6240\u4ee5\u7ec8\u7aef\u91cc\u7684 `git status` \u6c38\u8fdc\u548c\u9762\u677f\u663e\u793a\u4e00\u81f4\u3002\n\n\u2014\u2014 \u4ee5\u4e0b\u5185\u5bb9\u6309\u300c\u80fd\u770b\u5230\u4ec0\u4e48 \u2192 \u600e\u4e48\u7528 \u2192 \u4e3a\u4ec0\u4e48\u8fd9\u6837\u5b9e\u73b0 \u2192 \u600e\u4e48\u6539\u300d\u6392\u5217\u3002\n\n| \u7ae0\u8282 | \u5185\u5bb9 |\n| --- | --- |\n| \u754c\u9762\u4e0e\u529f\u80fd | \u4e24\u4e2a\u89c6\u56fe\u3001Changes \u533a\u3001diff \u9762\u677f\u3001Log/Console \u5168\u90e8\u80fd\u529b\u8868 |\n| \u5feb\u6377\u952e | \u7ed1\u5b9a\u8868\uff0c\u4ee5\u53ca\u300c\u7126\u70b9\u5728\u54ea\u51b3\u5b9a\u8c01\u80fd\u6536\u5230\u6309\u952e\u300d |\n| \u4e0e IDEA \u7684\u6709\u610f\u5dee\u5f02 | \u54ea\u4e9b\u5730\u65b9\u523b\u610f\u4e0d\u7167\u6284 |\n| \u6743\u9650 | 6 \u9879\u6743\u9650\u5404\u81ea\u4e3a\u4ec0\u4e48\u9700\u8981 |\n| \u5de5\u7a0b\u7ed3\u6784 | \u6e90\u7801\u5e03\u5c40\u3001\u4e3a\u4ec0\u4e48\u5fc5\u987b\u6784\u5efa\u3001\u751f\u6210\u7269\u4e3a\u4ec0\u4e48\u4e0d\u80fd\u624b\u6539 |\n| \u5f15\u64ce\u8bbe\u8ba1 | \u89c6\u56fe\u5982\u4f55\u4e0e Git \u901a\u4fe1\u3001\u73af\u5883\u8865\u9f50\u3001\u72b6\u6001\u4e0e\u8865\u4e01\u7684\u5173\u952e\u53d6\u820d |\n| \u4e00\u4e2a\u9879\u76ee\u91cc\u7684\u591a\u4e2a\u4ed3\u5e93 | \u5b50\u6a21\u5757\u4e0e\u5d4c\u5957\u4ed3\u5e93\u7684\u8bc6\u522b\u3001Root \u9009\u62e9\u5668\u4e0e\u5176\u8fb9\u754c |\n| \u8fdc\u7aef\u540c\u6b65 | \u63a8\u9001\u88ab\u62d2\u3001\u5206\u53c9\u3001\u51b2\u7a81\u3001\u4ee5\u53ca\u672a\u5b8c\u6210\u5408\u5e76\u7684\u51fa\u8def |\n| \u8ba4\u8bc1 | \u4e3a\u4ec0\u4e48\u63d2\u4ef6\u4e0d\u9700\u8981\u51ed\u636e\uff0c\u4ee5\u53ca\u552f\u4e00\u505a\u4e0d\u5230\u7684\u4e8b |\n| \u751f\u6210\u63d0\u4ea4\u4fe1\u606f | \u7528\u5bbf\u4e3b\u6a21\u578b\u8d77\u8349\uff0c\u8bed\u8a00/\u683c\u5f0f/\u8303\u56f4\u5982\u4f55\u5904\u7406 |\n| \u914d\u8272 | \u54ea\u4e9b\u53d6\u81ea JetBrains \u6587\u6863\uff0c\u54ea\u4e9b\u662f\u523b\u610f\u7684\u504f\u79bb |\n| \u6d4b\u8bd5\u4e0e\u5de5\u5177 | harness / smoke / drive / \u51b3\u7b56\u7b14\u8bb0\u95e8\u7981 |\n| \u5f00\u53d1\u4e0e\u6253\u5305 | \u52a0\u8f7d\u672c\u5730\u63d2\u4ef6\u3001\u6784\u5efa\u3001\u6253\u5305\u542b\u6743\u9650\u6269\u5f20\u7684\u5751 |\n| \u5df2\u77e5\u7f3a\u53e3 | \u8bda\u5b9e\u6e05\u5355\uff1a\u54ea\u4e9b\u63a7\u4ef6\u65e0\u6548\u3001\u54ea\u4e9b\u80fd\u529b\u53d7\u9650 |\n| \u8bb0\u5fc6\u4e0e\u51b3\u7b56\u7b14\u8bb0 | `.memories/`\u3001`.agents/notes/` \u4e0e\u53cd\u5411\u7d22\u5f15 |\n\n## \u754c\u9762\u4e0e\u529f\u80fd\n\n\u5de5\u4f5c\u9762\u677f\u91cc\u6709\u4e24\u4e2a docked \u89c6\u56fe\uff0c\u5bf9\u5e94 IDEA \u7684\u4e24\u4e2a\u5de5\u5177\u7a97\u53e3\uff1a\n\n| \u9762\u677f | \u5bf9\u5e94 IDEA | \u5185\u5bb9 |\n| --- | --- | --- |\n| **\u63d0\u4ea4**\uff08`views/commit.html`\uff09 | Commit \u5de5\u5177\u7a97\u53e3\uff0c`Alt+0` | Changes \u533a\uff08Staged / Unstaged / Unversioned Files / Merge Conflicts\uff09\u3001\u9009\u4e2d\u6587\u4ef6\u7684 diff\u3001\u5e26\u5386\u53f2\u7684\u63d0\u4ea4\u4fe1\u606f\u6846\u3001Amend\u3001Sign-off\u3001Commit\u3001Commit and Push |\n| **Git**\uff08`views/git.html`\uff09 | Git \u5de5\u5177\u7a97\u53e3\uff0c`Alt+9` | **Log** \u9875\u7b7e\uff08\u5206\u652f\u9762\u677f\u3001\u5e26\u56fe\u5f62\u4e0e\u5f15\u7528\u5fbd\u6807\u7684\u63d0\u4ea4\u5217\u8868\u3001changed files\u3001commit details\uff09\u4e0e **Console** \u9875\u7b7e |\n\n> `Alt+0` / `Alt+9` \u662f IDEA \u81ea\u5df1\u7684\u7ed1\u5b9a\uff0c\u63d2\u4ef6**\u6ca1\u6709**\u6ce8\u518c\u70ed\u952e\uff08`manifest.json` \u91cc\u6ca1\u6709\n> `keybindings` \u6bb5\uff09\uff1b\u8fd9\u91cc\u53ea\u662f\u544a\u8bc9\u4f60\u5b83\u4eec\u5728 IDEA \u91cc\u5bf9\u5e94\u4ec0\u4e48\u3002\n\n\u547d\u4ee4\u9762\u677f\u91cc\u53e6\u6709\u4e24\u6761\u547d\u4ee4\uff1a`IDEA Git: Open as a separate window`\uff08\u628a\u4e24\u4e2a\u7a97\u53e3\u5e76\u5230\u4e00\u4e2a\u6807\u7b7e\u680f\u91cc\uff0c\n\u5bbd\u5c4f\u7528\uff0c\u52a0\u8f7d `renderer/index.html`\uff09\u4e0e `IDEA Git: Refresh repository`\u3002\u63d2\u4ef6\u5728\u542f\u52a8\u65f6\u6fc0\u6d3b\n\uff08`activationEvents: onStartup`\uff09\u3002\n\n### \u529f\u80fd\u5bf9\u7167\u8868\uff08\u7528 IDEA \u81ea\u5df1\u7684\u8bf4\u6cd5\uff09\n\n| \u529f\u80fd | \u4f4d\u7f6e | \u8bf4\u660e |\n| --- | --- | --- |\n| `Staged` / `Unstaged` / `Unversioned Files` / `Merge Conflicts` | \u63d0\u4ea4 \u2192 Changes \u533a | \u6309**\u76ee\u5f55\u6811**\u5206\u7ec4\uff0c\u6bcf\u4e2a\u6587\u4ef6\u843d\u5728\u5b83\u6240\u5c5e\u7684\u6587\u4ef6\u5939\u4e0b\u3002\u6587\u4ef6\u5939\u7684\u52fe\u9009\u662f\u7ea7\u8054\u7684\uff1a\u52fe\u4e0a\u4f1a\u628a\u5176\u4e0b\u6240\u6709\u6587\u4ef6\uff08\u542b\u66f4\u6df1\u5c42\u76ee\u5f55\uff09\u52a0\u5165\u7d22\u5f15\uff0c\u53d6\u6d88\u5219\u6574\u68f5\u5b50\u6811\u79fb\u51fa\u7d22\u5f15\u3002\u540c\u65f6\u6709\u5df2\u6682\u5b58\u4e0e\u672a\u6682\u5b58\u6539\u52a8\u7684\u6587\u4ef6\u4f1a**\u540c\u65f6\u51fa\u73b0\u5728\u4e24\u7ec4\u91cc**\u2014\u2014Git \u81ea\u5df1\u5c31\u662f\u8fd9\u4e48\u62a5\u7684 |\n| \u89c6\u56fe\u9009\u9879\uff08\u9f7f\u8f6e\uff09 | \u63d0\u4ea4 \u2192 Changes \u5934\u90e8 | `Group by Directory`\uff08\u76ee\u5f55\u6811\uff09\u4e0e\u5e73\u94fa\u6a21\u5f0f\u5207\u6362\uff08\u5173\u6389\u5b83\u5c31\u662f\u5e73\u94fa\uff09\u3001`Compact Middle Directories`\uff08`src/views/app` \u5408\u6210\u4e00\u884c\uff09\u3001`Collapse All`\u3001\u4ee5\u53ca\u300c\u5168\u90e8\u52a0\u5165\u7d22\u5f15 / \u5168\u90e8\u79fb\u51fa\u7d22\u5f15\u300d\u4e24\u4e2a\u52a8\u4f5c\u3002\u4e24\u4e2a\u89c6\u56fe\u9009\u9879\u90fd\u4f1a\u6301\u4e45\u5316 |\n| \u8fc7\u6ee4\u53d8\u66f4 | \u63d0\u4ea4 \u2192 Changes \u5934\u90e8 | \u628a\u6811\u6536\u7a84\u5230\u5339\u914d\u7684\u6587\u4ef6\u3001\u5e76\u4fdd\u7559\u5176\u7956\u5148\u76ee\u5f55\uff1b\u5206\u7ec4\u6807\u9898\u663e\u793a `matched/total`\u3002`Esc` \u6e05\u7a7a |\n| `Commit Message` + \u63d0\u4ea4\u4fe1\u606f\u5386\u53f2 | \u63d0\u4ea4 \u2192 \u5e95\u90e8 | \u65f6\u949f\u6309\u94ae\u56de\u653e\u4e4b\u524d\u5199\u8fc7\u7684\u4fe1\u606f\uff0c\u6700\u65b0\u5728\u524d\uff0c\u8de8\u91cd\u542f\u4fdd\u7559\uff08\u672c\u5730\u65e0\u5386\u53f2\u65f6\u7ed9\u63d0\u793a\uff0c\u83dc\u5355\u5e95\u90e8\u6709 `Clear All`\uff09 |\n| `Generate Commit Message` | \u63d0\u4ea4 \u2192 \u5e95\u90e8 | \u7528**\u5bbf\u4e3b\u81ea\u5df1\u7684\u6a21\u578b**\u8d77\u8349\u4fe1\u606f\u3002\u63cf\u8ff0\u5f53\u524d\u9009\u4e2d\u7684\u6587\u4ef6\uff1b\u6ca1\u9009\u6587\u4ef6\u65f6\u63cf\u8ff0\u6240\u6709\u5df2\u6682\u5b58\u5185\u5bb9\uff1b\u7bad\u5934\u83dc\u5355\u7528\u6765\u6311\u6a21\u578b |\n| `Amend`\u3001`Sign-off commit` | \u63d0\u4ea4 \u2192 \u5e95\u90e8 | `--amend`\u3001`--signoff` |\n| `Commit`\u3001`Commit and Push` | \u63d0\u4ea4 \u2192 \u5e95\u90e8 | \u62c6\u5206\u6309\u94ae\uff1b\u4e0b\u62c9\u91cc\u8fd8\u6709 Amend / Sign-off / \u56de\u6eda\uff08\u628a\u5df2\u6682\u5b58\u5185\u5bb9\u5168\u90e8\u79fb\u51fa\u7d22\u5f15\uff09 |\n| \u6bcf\u4e2a\u4ee3\u7801\u5757\u7684 `Include into commit` | \u63d0\u4ea4 \u2192 diff \u9762\u677f | **\u5c40\u90e8\u63d0\u4ea4**\u3002\u6bcf\u4e2a hunk \u5e26\u4e00\u4e2a\u52fe\u9009\u6846\uff1b\u5207\u6362\u5b83\u65f6\u7528\u91cd\u5efa\u51fa\u6765\u7684\u8865\u4e01\u8dd1 `git apply --cached`\uff08\u6216 `-R`\uff09\uff0c\u4e8e\u662f\u53ea\u6709\u88ab\u9009\u4e2d\u7684 hunk \u8fdb\u5165\u8fd9\u6b21\u63d0\u4ea4 |\n| `Stage Hunk` / `Discard Hunk` / `Unstage Hunk` | \u63d0\u4ea4 \u2192 diff \u9762\u677f | \u9f20\u6807\u60ac\u505c\u5230\u5de5\u4f5c\u533a diff \u7684 hunk \u5934\u65f6\u51fa\u73b0\uff1b\u5df2\u6682\u5b58 diff \u4e0a\u5219\u662f\u53cd\u5411\u7684 `Unstage Hunk` |\n| hunk \u5934\u4e0a\u7684\u9644\u6ce8 | \u63d0\u4ea4 \u2192 diff \u9762\u677f | \u7eaf\u7a7a\u767d hunk \u6807 `\u00b7 whitespace only`\uff1b\u5b50\u6a21\u5757\u6307\u9488\u53d8\u5316\u6807 `\u00b7 submodule abc1234 \u2192 def5678` |\n| `Show Diff`\u3001`Rollback`\u3001`Add`/`Remove from index`\u3001`Open File`\u3001`Reveal in File Manager`\u3001`Copy Hash` | \u63d0\u4ea4 \u2192 \u6587\u4ef6\u53f3\u952e\u83dc\u5355 | `Rollback` \u4f1a\u5148\u786e\u8ba4\uff1b\u5b83\u4f1a\u5220\u9664\u672a\u8ddf\u8e2a\u6587\u4ef6\u3001\u8fd8\u539f\u5df2\u8ddf\u8e2a\u6587\u4ef6 |\n | \u5206\u652f\u63a7\u4ef6\uff08`main \u21912 \u21931`\uff09 | \u4e24\u4e2a\u5de5\u5177\u680f | \u5206\u652f\u540d\u3001\u9886\u5148/\u843d\u540e\uff0c\u70b9\u5f00\u662f\u672c\u5730/\u8fdc\u7aef\u5206\u652f\u5217\u8868\uff0c\u70b9\u4e00\u884c\u5373 checkout\uff1b`New Branch`\u3001`Merge into Current\u2026`\u3001`Rebase Current onto\u2026`\u3001`Rename`\u3001`Set/Unset Upstream`\u3001`Delete\u2026`\u3001`Fetch`\u3001`Pull`\u3001`Push`\u3001`Force Push (with lease)`\u3001`Stash Changes` \u4e0e\u50a8\u85cf\u5217\u8868\uff08Apply/Pop/Drop\uff09\u3002\uff08\u5206\u652f\u7684**\u5b8c\u6574\u53f3\u952e\u52a8\u4f5c\u83dc\u5355\u5728 Git \u89c6\u56fe\u7684 `Branches` \u9762\u677f**\u91cc\uff0c\u5de5\u5177\u680f\u5f39\u5c42\u662f\u5e38\u7528\u5b50\u96c6\uff09 |\n| \u4ed3\u5e93\u63a7\u4ef6\uff08`mod \u00b7 submodule`\uff09 | \u4e24\u4e2a\u5de5\u5177\u680f | \u5217\u51fa\u5de5\u4f5c\u533a\u4ed3\u5e93\u4ee5\u53ca\u5d4c\u5957\u5728\u5b83\u91cc\u9762\u7684\u6bcf\u4e00\u4e2a\u4ed3\u5e93\u2014\u2014\u5b50\u6a21\u5757\u3001\u4ee5\u53ca\u6070\u597d\u4f4f\u5728\u8fd9\u91cc\u7684\u514b\u9686\u2014\u2014\u9009\u4e2d\u5b83\u5c31\u628a\u6574\u4e2a\u5de5\u5177\u7a97\u53e3\u5bf9\u51c6\u90a3\u4e2a\u4ed3\u5e93\u3002\u53ea\u6709\u4e00\u4e2a\u4ed3\u5e93\u65f6\uff08\u5355\u4ed3\u5e93\u9879\u76ee\uff09\u9690\u85cf |\n| `Log`\u3001`Console` | Git \u2192 \u9875\u7b7e | Console \u663e\u793a\u63d2\u4ef6\u8dd1\u8fc7\u7684\u547d\u4ee4\u4e0e\u8f93\u51fa\uff0c\u5931\u8d25\u6807\u7ea2\uff0c\u53ef `Clear All`\uff08\u89c1\u4e0b\u65b9\u300c\u5df2\u77e5\u7f3a\u53e3\u300d\uff1a\u63a2\u6d4b\u578b\u547d\u4ee4\u6210\u529f\u65f6\u4e0d\u8bb0\u5f55\uff09 |\n| \u63d0\u4ea4\u56fe | Git \u2192 Log | \u6cf3\u9053\u7531\u7236\u63d0\u4ea4\u4fe1\u606f\u7b97\u51fa\uff1b\u5206\u652f\u5c16\u7aef\u9ec4\u8272\u3001\u672c\u5730\u5206\u652f\u7eff\u8272\u3001\u8fdc\u7aef\u7d2b\u8272\u3001\u6807\u7b7e\u7070\u8272\u3002\u81ea\u5df1\u7684\u63d0\u4ea4 subject \u52a0\u7c97\uff0c\u5f53\u524d\u5206\u652f\u7684\u884c\u6709\u5e95\u8272 |\n | `Branches` \u9762\u677f | Git \u2192 Log \u2192 \u5de6\u4fa7 | `HEAD`\uff08\u70b9\u4e00\u70b9\u56de\u5230\u5f53\u524d\u63d0\u4ea4\uff09\uff0f`Local branches`\uff0f`Remote branches`\uff08`feature/*` \u8fd9\u7c7b\u6309 `/` \u6298\u6210\u53ef\u5c55\u5f00\u7684\u6587\u4ef6\u5939\uff1b\u70b9\u8fdc\u7aef\u5206\u652f\u4f1a\u81ea\u52a8\u5efa\u672c\u5730\u8ddf\u8e2a\u5206\u652f\uff0c\u4e0d\u518d\u662f detached HEAD\uff09\uff0f`Tags`\uff08\u70b9\u4e00\u70b9\u6309\u8be5\u6807\u7b7e\u8fc7\u6ee4\u65e5\u5fd7\uff09\uff0f`Remotes`\uff08Fetch / Fetch+Prune / \u590d\u5236\u5730\u5740\uff09\uff0f`Stashes`\uff08Show / Apply / Pop / Drop\uff09\u3002\u5206\u652f\u53f3\u952e\uff1a`Checkout`\u3001`New Branch from Here`\u3001`Merge into Current`\u3001`Rebase Current onto Selected`\u3001`Rename`\u3001`Set/Unset Upstream`\u3001`Delete`\uff08\u672a\u5b8c\u5168\u5408\u5e76\u8d70\u4e8c\u6b21\u786e\u8ba4\uff09\u3001`Copy Branch Name`\u3001`Push`\u3001`Fetch` |\n| `Changed Files`\u3001`Commit Details` | Git \u2192 Log \u2192 \u5217\u8868\u4e0b\u65b9 | Details \u663e\u793a\u54c8\u5e0c\u3001\u4f5c\u8005\u3001\u65e5\u671f\u3001subject \u4e0e\u5b8c\u6574\u6b63\u6587 |\n| `Graph Options` | Git \u2192 \u5de5\u5177\u680f | `By commit date` / `Topologically`\u3001`Show First Parent`\u3001`No Merges`\u3001`Show Graph`\u3001\u5404\u9762\u677f\u5f00\u5173 |\n | \u8fc7\u6ee4 | Git \u2192 \u5de5\u5177\u680f | \u6587\u672c\u641c\u7d22\uff08\u672c\u5730 substring\uff09\u3001\u5206\u652f\u8fc7\u6ee4\u3001\u7528\u6237\u8fc7\u6ee4\uff08\u5168\u90e8\uff0f\u53ea\u770b\u6211\u7684\uff09\u3001\u65e5\u671f\u8fc7\u6ee4\uff08\u5168\u90e8\uff0f24 \u5c0f\u65f6\uff0f\u4e00\u5468\uff0f\u4e00\u6708\uff09\u3001\u8def\u5f84\u8fc7\u6ee4\uff08\u670d\u52a1\u7aef `git log -- `\uff0c\u8f93\u5165\u9632\u6296 450ms\u3001\u56de\u8f66\u7acb\u5373\u5237\uff09 |\n | \u63d0\u4ea4\u52a8\u4f5c | Git \u2192 Log \u2192 \u53f3\u952e\u63d0\u4ea4 | `Show Diff`\u3001`Copy Revision Number`\u3001`Copy Message`\u3001`Compare with HEAD`\u3001`Checkout Revision`\u3001`New Branch from Here`\u3001`New Tag`\u3001`Cherry-Pick`\u3001`Revert`\u3001`Reset Current Branch to Here`\uff08soft/mixed/hard\uff09\u3001`Edit Commit Message`\uff08\u4ec5 HEAD\uff09 |\n| `Side-by-side viewer` / `Unified viewer` | diff \u9762\u677f\u5934\u90e8 | \u4e24\u8005\u6e32\u67d3\u540c\u4e00\u4efd\u89e3\u6790\u597d\u7684 hunk |\n| `Ignore Differences` \u2192 `None` / `Ignore whitespaces` | diff \u9762\u677f\u9f7f\u8f6e | \u7531 Git \u81ea\u5df1\u5b9e\u73b0\uff08`git diff -w`\uff09\uff0c\u4e0d\u662f\u628a\u884c\u85cf\u8d77\u6765\u3002\u5f00\u7740\u5b83\u65f6**\u7981\u7528 hunk \u6682\u5b58**\uff0c\u56e0\u4e3a\u5ffd\u7565\u7a7a\u767d\u7684 diff \u4e0d\u662f\u5408\u6cd5\u8865\u4e01 |\n| `Show Whitespaces` | diff \u9762\u677f\u773c\u775b\u56fe\u6807 | \u7a7a\u683c\u6e32\u67d3\u6210 `\u00b7`\uff0c\u5236\u8868\u7b26\u6e32\u67d3\u6210 `\u2192` |\n| `Show Line Numbers` | diff \u9762\u677f\u9f7f\u8f6e / commit diff \u6d6e\u5c42\u5934\u90e8 | \u884c\u53f7\u680f\u3002\u63d0\u4ea4\u89c6\u56fe\u9ed8\u8ba4**\u5173**\u3001\u4e14\u8fd9\u4e2a\u5f00\u5173\u4f1a\u88ab\u8bb0\u4f4f\uff1bcommit diff \u6d6e\u5c42\u9ed8\u8ba4**\u5f00**\uff0c\u4f46\u6bcf\u6b21\u6253\u5f00\u90fd\u56de\u5230\u9ed8\u8ba4\uff08\u6d6e\u5c42\u4e0d\u6301\u4e45\u5316\uff09 |\n| `Collapse Unchanged Fragments` | diff \u9762\u677f | \u957f\u7684\u4e0a\u4e0b\u6587\u4f1a\u6298\u53e0\u6210\u4e00\u884c\u53ef\u70b9\u51fb\u7684\u6761\uff08`\u22ee N \u884c\u672a\u4fee\u6539`\uff09\u3002\u53ea\u5728**\u7edf\u4e00\u89c6\u56fe**\u91cc\u6298\u53e0\uff0c\u5e76\u6392\u89c6\u56fe\u4e0d\u6298 |\n| \u9ad8\u4eae\u5dee\u5f02 | diff \u9762\u677f | **\u8bcd\u7ea7**\uff1a\u884c\u4e2d\u95f4\u53d8\u5316\u7684\u90e8\u5206\u88ab\u6807\u51fa\u6765\uff0c\u4e8e\u662f `1.0.0` \u2192 `1.1.0` \u53ea\u6807\u51fa\u90a3\u4e00\u4e2a\u4e0d\u540c\u7684\u5b57\u7b26 |\n| \u63d0\u4ea4\u533a\u72b6\u6001\u884c | \u63d0\u4ea4 \u2192 \u63d0\u4ea4\u6309\u94ae\u4e0a\u65b9 | \u4e09\u79cd\u72b6\u6001\uff1a\u8fdb\u884c\u4e2d\u7684\u64cd\u4f5c\uff08\u5e26 `Continue`/`Abort`\uff09\u3001\u51b2\u7a81\u63d0\u793a\u3001`Nothing is staged` \u8b66\u544a\uff0c\u5e38\u89c4\u65f6\u663e\u793a\u300c\u5c06\u63d0\u4ea4 N / M \u4e2a\u6587\u4ef6\u300d |\n| Console \u7ec6\u8282 | Git \u2192 Console | \u6bcf\u6761\u547d\u4ee4\u5e26 `[hh:mm:ss]` \u65f6\u95f4\u6233\u3001\u53f3\u4e0a\u89d2\u663e\u793a\u6761\u6570\u3001\u72ec\u7acb\u7684 Refresh\u3001\u7a7a\u6001\u63d0\u793a |\n| \u5de5\u5177\u680f\u53f3\u4fa7 | \u4e24\u4e2a\u5de5\u5177\u680f | \u5f53\u524d\u4ed3\u5e93/\u5de5\u4f5c\u533a\u76ee\u5f55\u540d |\n\n`Ctrl+F5` \u7684\u884c\u4e3a\u2014\u2014\u300c\u6bcf\u4e2a\u52a8\u4f5c\u4e4b\u540e\u90fd\u5237\u65b0\u300d\u2014\u2014\u662f\u5185\u5efa\u7684\uff1a\u4efb\u4f55\u5199\u5165\u52a8\u4f5c\u4e4b\u540e\u90fd\u4f1a\u5237\u65b0\u72b6\u6001\u3001diff \u4e0e\u5217\u8868\u3002\n\n## \u5feb\u6377\u952e\n\n\u5bbf\u4e3b\u5728\u4e3b\u7a97\u53e3\u6e32\u67d3\u5c42\u8dd1\u5e94\u7528\u81ea\u5df1\u7684\u5feb\u6377\u952e\uff0c\u800c\u63d2\u4ef6\u89c6\u56fe\u662f\u72ec\u7acb\u7684 `WebContentsView`\u2014\u2014\u6240\u4ee5**\u7126\u70b9\u5728\u89c6\u56fe\u91cc\u65f6\uff0c\u5bbf\u4e3b\u6536\u4e0d\u5230\u6309\u952e**\u3002\n\u7ed3\u8bba\uff1a**\u51e1\u662f\u6309\u94ae\u5e7f\u544a\u51fa\u6765\u7684\u952e\uff0c\u90fd\u5fc5\u987b\u5728\u9875\u5185\u81ea\u5df1\u7ed1\u5b9a**\uff0c\u4e0b\u9762\u8fd9\u4e9b\u5c31\u662f\uff1a\n\n| \u52a8\u4f5c | macOS | Windows / Linux |\n| --- | --- | --- |\n| \u5237\u65b0 | `\u2318R` | `Ctrl+R` |\n| \u5237\u65b0\uff08IDEA \u7684\u7ed1\u5b9a\uff09 | `\u2303F5` | `Ctrl+F5` |\n| \u52a0\u5165\u7d22\u5f15 | `\u2318\u2325A` | `Ctrl+Alt+A` |\n| \u56de\u6eda\uff08\u5148\u786e\u8ba4\uff09 | `\u2318\u2325Z` | `Ctrl+Alt+Z` |\n| Show Diff | `\u2318D` | `Ctrl+D` |\n| \u63d0\u4ea4\u4fe1\u606f\uff08\u63d0\u4ea4\u89c6\u56fe\uff09 | `\u2318K` | `Ctrl+K` |\n| \u641c\u7d22\u65e5\u5fd7\uff08Git \u89c6\u56fe\uff09 | `\u2318F` | `Ctrl+F` |\n| \u5173\u95ed\u5bf9\u8bdd\u6846 / \u83dc\u5355 / diff | `Esc` | `Esc` |\n| \u5728\u63d0\u4ea4\u5217\u8868\u4e2d\u79fb\u52a8 | `\u2191` `\u2193` | `\u2191` `\u2193` |\n\n\u63d0\u793a\u6587\u6848\u7531\u7ed1\u5b9a\u7684\u540c\u4e00\u4efd\u89c4\u683c\u751f\u6210\uff08`PIG.formatShortcut`\uff09\uff0c\u6240\u4ee5**\u4e0d\u53ef\u80fd\u51fa\u73b0\u300c\u5199\u4e86\u5374\u6309\u4e0d\u51fa\u300d\u7684\u63d0\u793a\u952e**\u2014\u2014\n\u8fd9\u662f\u4e2a\u503c\u5f97\u5728\u8bbe\u8ba1\u4e0a\u6392\u9664\u7684\u9519\u8bef\uff0c\u56e0\u4e3a\u5b83\u76f4\u5230\u6709\u4eba\u53bb\u6309\u624d\u4f1a\u66b4\u9732\u3002\n\n\u4ee3\u7801\u91cc\u8fd8\u7ed1\u4e86\uff1a\u63d0\u4ea4\u884c\u4e0a `Enter` / `Space` \u9009\u4e2d\u8be5\u63d0\u4ea4\uff1b\u5bf9\u8bdd\u6846\u91cc `Enter` \u786e\u8ba4\uff1b\u5f39\u51fa\u83dc\u5355\u6253\u5f00\u65f6\u81ea\u52a8\u805a\u7126\u7b2c\u4e00\u4e2a\u53ef\u9009\u9879\uff1b\n`\u2318\u21e7P` \u4f1a\u5f39\u4e00\u53e5\u63d0\u793a\uff08\u89c1\u4e0b\uff09\u3002\n\n### \u54ea\u4e2a\u952e\u5728\u54ea\u91cc\u6709\u6548\n\n\u5bbf\u4e3b\u7684\u52a0\u901f\u952e\u4e0e\u63d2\u4ef6\u7684\u7ed1\u5b9a\u4f4f\u5728\u4e0d\u540c\u5730\u65b9\uff0c\u6240\u4ee5\u300c\u540c\u4e00\u4e2a\u952e\u5e72\u4ec0\u4e48\u300d\u53d6\u51b3\u4e8e\u7126\u70b9\u5728\u54ea\u3002\u503c\u5f97\u7cbe\u786e\u77e5\u9053\uff1a\n\n| \u6309\u952e | \u5e94\u7528\u5185\uff08\u7126\u70b9\u4e0d\u5728\u89c6\u56fe\u91cc\uff09 | \u672c\u63d2\u4ef6\u89c6\u56fe\u5185 |\n| --- | --- | --- |\n| `\u2318K` / `Ctrl+K` | \u5e94\u7528\u7684**\u4f1a\u8bdd\u641c\u7d22** | **\u63d0\u4ea4\u89c6\u56fe**\uff1a\u805a\u7126\u63d0\u4ea4\u4fe1\u606f\u6846\u3002**Git \u89c6\u56fe**\uff1a\u65e0\uff08\u5e94\u7528\u7684\u641c\u7d22\u5728\u8fd9\u91cc\u591f\u4e0d\u5230\uff09 |\n| `\u2318\u21e7P` / `Ctrl+Shift+P` | \u5e94\u7528\u7684**\u547d\u4ee4\u9762\u677f** | \u6ca1\u7528\u2014\u2014\u6309\u952e\u5230\u4e0d\u4e86\u5e94\u7528\uff0c\u6240\u4ee5\u89c6\u56fe\u4f1a\u660e\u786e\u544a\u8bc9\u4f60\u8be5\u53bb\u54ea\uff08`Alt+Space`\uff09 |\n| `Alt+Space` | \u63d2\u4ef6\u542f\u52a8\u5668 | **\u4e00\u6837\u53ef\u7528\u2014\u2014\u5b83\u662f\u771f\u6b63\u7684\u5168\u5c40\u5feb\u6377\u952e** |\n| `\u2318R` / `Ctrl+R` | \u91cd\u65b0\u52a0\u8f7d | \u5237\u65b0\u4ed3\u5e93 |\n| `\u2318D`\u3001`\u2318\u2325A`\u3001`\u2318\u2325Z`\u3001`\u2318F`\u3001`Esc`\u3001`\u2191` `\u2193` | \u5e94\u7528\u81ea\u5df1\u7684\u542b\u4e49 | \u63d2\u4ef6\u7684\u542b\u4e49\uff0c\u89c1\u4e0a\u8868 |\n\n\u6240\u4ee5\uff1a**\u8981\u8dd1\u5e94\u7528\u7ea7\u547d\u4ee4\uff08\u5305\u62ec `IDEA Git: Open as a separate window`\uff09\uff0c\u6309 `Alt+Space`\uff0c\u6216\u8005\u70b9\u56de\u5e94\u7528\u91cc\u7528 `\u2318\u21e7P`\u3002**\n\u73b0\u5728\u5728\u89c6\u56fe\u91cc\u6309 `\u2318\u21e7P` \u4f1a\u5f39\u4e00\u53e5\u8bf4\u660e\uff0c\u800c\u4e0d\u662f\u6beb\u65e0\u53cd\u5e94\u3002\n\n## \u4e0e IDEA \u7684\u6709\u610f\u5dee\u5f02\n\n- **\u6ca1\u6709\u7f16\u8f91\u5668\u3002** IDEA \u628a diff \u5f00\u5728\u7f16\u8f91\u5668\u6807\u7b7e\u9875\u91cc\uff1b\u8fd9\u91cc\u63d0\u4ea4\u7684 diff \u5f00\u5728 Git \u5de5\u5177\u7a97\u53e3\u5185\u7684\u6d6e\u5c42\u91cc\u3002\n `Jump to Source` \u6362\u6210\u4e86 `Open File`\uff08\u4ea4\u7ed9\u7cfb\u7edf\u9ed8\u8ba4\u7a0b\u5e8f\u6253\u5f00\uff09\u4e0e `Reveal in File Manager`\u3002\n- **\u6ca1\u6709 changelist\u3002** IDEA \u7684 changelist \u662f IDE \u4fa7\u6982\u5ff5\uff0cGit \u91cc\u6ca1\u6709\u5bf9\u5e94\u7269\uff1bStaged/Unstaged \u7684\u5206\u7ec4\u53d6\u4ee3\u4e86\u5b83\u3002\n \u4e00\u4efd\u5de5\u4f5c\u526f\u672c\uff0c\u4e00\u4efd\u53d8\u66f4\u96c6\u3002\n- **\u6ca1\u6709 shelf\u3002** `Stash` \u8986\u76d6 Git \u539f\u751f\u7684\u90a3\u90e8\u5206\u573a\u666f\u3002\n- **\u5e76\u6392\u89c6\u56fe\u4e0d\u8054\u52a8\u6eda\u52a8\u3002** \u5de6\u53f3\u4e24\u680f\u4f5c\u4e3a\u4e00\u4e2a\u7f51\u683c\u4e00\u8d77\u6a2a\u5411\u6eda\u52a8\uff0c\u4e8e\u662f\u4e24\u680f\u6c38\u8fdc\u5728\u540c\u4e00\u4e2a\u504f\u79fb\u4e0a\u3002\n- **`Edit Commit Message` \u53ea\u5bf9\u5c16\u7aef\u63d0\u4ea4\u5f00\u653e**\uff0c\u56e0\u4e3a\u6539\u5199\u66f4\u65e9\u7684\u63d0\u4ea4\u9700\u8981 rebase\u3002\n- **`Ignore whitespaces` \u6a21\u5f0f\u4e0b\u7981\u7528 hunk \u6682\u5b58**\uff0c\u672a\u8ddf\u8e2a\u6587\u4ef6\u4e5f\u7981\u7528\u2014\u2014\u5ffd\u7565\u7a7a\u767d\u7684 diff\u3001\u4ee5\u53ca\u5bf9\u7740 `/dev/null` \u7684 diff\uff0c\n \u90fd\u4e0d\u662f Git \u4f1a\u63a5\u53d7\u7684\u8865\u4e01\u3002\u6574\u6587\u4ef6\u7684 `Add` \u4ecd\u7136\u53ef\u7528\u3002\n- **\u5220\u9664\u884c\u662f\u7ea2\u8272\uff0c\u4e0d\u662f JetBrains \u7684\u7070\u8272**\uff0c\u6697\u8272 diff \u914d\u8272\u662f\u8c03\u51fa\u6765\u7684\u800c\u975e\u7167\u6284\u7684\u3002\u89c1\u300c\u914d\u8272\u300d\u3002\n\n## \u6743\u9650\n\n| \u6743\u9650 | \u4e3a\u4ec0\u4e48 |\n| --- | --- |\n| `ui.panel` | \u72ec\u7acb\u7a97\u53e3\uff08`renderer/index.html`\uff09 |\n| `ui.view` | \u4e24\u4e2a docked \u89c6\u56fe |\n| `clipboard.write` | `Copy Revision Number`\u3001`Copy Hash` \u8fd9\u7c7b\u590d\u5236\u52a8\u4f5c |\n| `fs.read` | \u5bf9\u53d8\u66f4\u6587\u4ef6\u6267\u884c `Open File` \u4e0e `Reveal in File Manager` |\n| `models.list` | \u751f\u6210\u6309\u94ae\u4e0a\u7684\u6a21\u578b\u9009\u62e9\u83dc\u5355 |\n| `agent.complete` | \u7528\u5bbf\u4e3b\u6a21\u578b\u8d77\u8349\u63d0\u4ea4\u4fe1\u606f |\n\n`PluginCheck` \u4f1a\u628a `clipboard.write` \u4e0e `fs.read` \u62a5\u6210 unused\uff0c\u56e0\u4e3a\u5b83\u53ea\u626b `main.js`\uff1b\u8fd9\u4e24\u4e2a\u90fd\u662f\u4ece\u89c6\u56fe\u7ecf\u9762\u677f\u6865\u8c03\u7684\n\uff08`clipboard.writeText` \u5728 `src/common.js`\uff0c`fs.openDefault` / `fs.reveal` \u5728 `src/commit-view.js`\uff09\uff0c\u5c5e\u8bef\u62a5\u3002\n\n`fs` \u7684\u8bfb\u53d6\u8303\u56f4\u58f0\u660e\u4e3a `{\"root\":\"workspace\",\"scope\":[\"**\"]}`\u2014\u2014\u53ea\u8bfb\u5de5\u4f5c\u533a\u3002\n\n**\u552f\u4e00\u4e00\u5904\u63d2\u4ef6\u5199\u5de5\u4f5c\u533a\u7684\u5730\u65b9**\u662f\u4e22\u5f03\u672a\u8ddf\u8e2a\u6587\u4ef6\uff08\u6216\u8fd8\u539f\u672a\u8ddf\u8e2a\u6587\u4ef6\u7684 hunk\uff09\uff1a\u5f15\u64ce\u76f4\u63a5 `fs.rmSync` \u5220\u6587\u4ef6\uff0c\n\u89c6\u56fe\u4fa7\u4f1a\u5148\u786e\u8ba4\u3002\u9664\u6b64\u4e4b\u5916\u4e0d\u78b0\u5de5\u4f5c\u533a\u7684\u6587\u4ef6\u2014\u2014\u6240\u6709\u53d8\u66f4\u90fd\u7ecf\u8fc7 `git`\u3002\n\uff08\u63d2\u4ef6\u53e6\u6709\u4e00\u5904\u5199\u76d8\uff0c\u5199\u7684\u662f\u5b83**\u81ea\u5df1**\u7684\u6570\u636e\u76ee\u5f55\uff1a`prefs.json`\uff0c\u5b58\u63d0\u4ea4\u4fe1\u606f\u5386\u53f2\u4e0e\u89c6\u56fe\u9009\u9879\uff0c\u4e0d\u843d\u5728\u4ed3\u5e93\u91cc\u3002\uff09\n\n## \u5de5\u7a0b\u7ed3\u6784\n\n```\nmain.js Node \u8fdb\u7a0b\uff1aGit \u5f15\u64ce + \u901a\u9053\u8def\u7531\uff08\u7ea6 2600 \u884c\uff09\nsrc/theme.css IDEA \u8c03\u8272\u677f\u4e0e\u7ec4\u4ef6\u6837\u5f0f \u2500\u2510\nsrc/layout.css \u5e03\u5c40\u4e0e\u54cd\u5e94\u5f0f \u2500\u2524\nsrc/common.js \u6865\u3001i18n\u3001\u56fe\u6807\u3001\u5f39\u51fa\u83dc\u5355 \u2500\u2524 tools/build.mjs\nsrc/diff.js \u7edf\u4e00 diff \u89e3\u6790\u4e0e\u6e32\u67d3 \u2500\u2524 \u5185\u8054\u8fdb\nsrc/commit-view.js \u63d0\u4ea4\u5de5\u5177\u7a97\u53e3 \u2500\u2524\nsrc/git-view.js Log + Console\uff0c\u4ee5\u53ca\u542f\u52a8 \u2500\u2518\nviews/commit.html \u751f\u6210\u7269\u2014\u2014\u4e0d\u8981\u624b\u6539\nviews/git.html \u751f\u6210\u7269\u2014\u2014\u4e0d\u8981\u624b\u6539\nrenderer/index.html \u751f\u6210\u7269\u2014\u2014\u4e24\u4e2a\u7a97\u53e3\u5e76\u5230\u4e00\u4e2a\u6807\u7b7e\u680f\ntools/build.mjs \u5185\u8054\u5668\ntools/harness.mjs \u5f15\u64ce\u56de\u5f52\uff08\u771f\u5b9e\u4ed3\u5e93\uff0c136 \u6761\u65ad\u8a00\uff09\ntools/drive.mjs \u4e0d\u5f00 GUI \u76f4\u63a5\u9a71\u52a8\u5f15\u64ce\ntools/smoke.mjs \u7528\u6869 bridge \u6e32\u67d3\u89c6\u56fe\u5230 .smoke/\ntools/notes.mjs \u51b3\u7b56\u7b14\u8bb0\u95e8\u7981\uff08+ tools/agent-notes/ \u7684 vendored \u6821\u9a8c\u5668\uff09\nmanifest.json \u6e05\u5355\uff1a\u89c6\u56fe\u3001\u547d\u4ee4\u3001\u6743\u9650\u3001engines\n```\n\n### \u4e3a\u4ec0\u4e48\u5fc5\u987b\u6709\u6784\u5efa\u6b65\u9aa4\n\n\u5bbf\u4e3b\u7528 `loadURL(pathToFileURL(entry))` \u52a0\u8f7d\u89c6\u56fe\uff0c\u4e5f\u5c31\u662f `file://` URL\uff0c\u800c **Chromium \u62d2\u7edd\u4ece `file://` \u52a0\u8f7d ES module**\n\uff08\u4e0d\u900f\u660e\u6e90\uff09\u3002\u5b98\u65b9\u81ea\u5e26\u63d2\u4ef6\u662f\u9760\u300c\u6bcf\u4e2a\u89c6\u56fe\u4e00\u4e2a\u5de8\u5927\u7684\u81ea\u5305\u542b HTML\u300d\u7ed5\u8fc7\u53bb\u7684\uff1b`tools/build.mjs` \u4ece\u53ef\u8bfb\u7684\u6e90\u7801\u4ea7\u51fa\u540c\u6837\u7684\u5f62\u72b6\uff1a\n\n```bash\nnode tools/build.mjs # \u5199\u51fa views/*.html \u4e0e renderer/index.html\n```\n\n\u6539 `src/`\uff0c\u8dd1\u6784\u5efa\uff0c\u6b63\u5728\u8fd0\u884c\u7684\u5f00\u53d1\u63d2\u4ef6\u4f1a\u70ed\u91cd\u8f7d\u3002\u76f4\u63a5\u6539\u751f\u6210\u51fa\u6765\u7684 HTML \u662f\u767d\u8d39\u529b\u6c14\u2014\u2014\u4e0b\u6b21\u6784\u5efa\u5c31\u8986\u76d6\u4e86\u3002\n\n> \u751f\u6210\u5668\u56fa\u5b9a\u5199 `lang=\"en\"`\uff08`tools/build.mjs`\uff09\uff0c\u4e0e\u754c\u9762\u8bed\u8a00\u65e0\u5173\u3002\u53ea\u5f71\u54cd\u65e0\u969c\u788d\u8bed\u4e49\u4e0e\u5b57\u4f53\u56de\u9000\uff0c\u4e0d\u5f71\u54cd\u663e\u793a\u3002\n\n## \u5f15\u64ce\u8bbe\u8ba1\n\n### \u89c6\u56fe\u600e\u4e48\u8ddf Git \u8bf4\u8bdd\n\n`main.js` \u8dd1\u5728\u4e13\u5c5e Node \u8fdb\u7a0b\u91cc\uff08Electron `utilityProcess`\uff09\uff0c\u6240\u4ee5\u5b83\u6709\u771f\u6b63\u7684 Node API\u3001\u80fd\u8d77 `git` \u8fdb\u7a0b\u3002\n\u89c6\u56fe\u662f\u6c99\u7bb1\u9875\u9762\uff0c\u53ea\u62ff\u5230 `window.pluginBridge`\u3002\u5bbf\u4e3b\u81ea\u5df1\u4e0d\u5b9e\u73b0\u7684\u901a\u9053**\u5168\u90e8**\u8f6c\u53d1\u7ed9\u63d2\u4ef6\u5bfc\u51fa\u7684 `onPanelInvoke`\u2014\u2014\n\u8fd9\u6b63\u662f\u81ea\u5b9a\u4e49\u901a\u9053\u80fd\u5de5\u4f5c\u7684\u539f\u56e0\uff1a\n\n```\n\u89c6\u56fe --pluginBridge.invoke(\"git/\u2026\")--> \u5bbf\u4e3b --> onPanelInvoke --> git\n```\n\n`onPanelInvoke` \u91cc\u4e00\u5171 **36 \u4e2a `git/*` \u901a\u9053**\uff1b\u4e0d\u8ba4\u8bc6\u7684\u901a\u9053\u4f1a\u660e\u786e\u56de `Unsupported channel`\uff0c\u800c\u4e0d\u662f\u9759\u9ed8\u4ec0\u4e48\u90fd\u4e0d\u505a\u3002\n\n### \u4e24\u4e2a\u73af\u5883\u4e8b\u5b9e\u51b3\u5b9a\u4e86\u5f15\u64ce\u7684\u5f62\u72b6\n\n- \u63d2\u4ef6\u8fdb\u7a0b\u53ea\u88ab\u4ea4\u7ed9 `PATH`\u3001`LANG` \u548c\u4e34\u65f6\u76ee\u5f55\u53d8\u91cf\uff0c**\u6ca1\u6709 `HOME`**\uff08Windows \u4e0a\u662f `USERPROFILE`\uff09\uff0c\n \u800c Git \u9700\u8981\u5b83\u6765\u8bfb `~/.gitconfig`\uff08\u8eab\u4efd\u3001\u51ed\u636e\u52a9\u624b\uff09\u3002\u6240\u4ee5\u6bcf\u6b21\u8c03\u7528\u90fd\u81ea\u5df1\u8865\u56de\u53bb\u3002**\u8fd9\u662f\u300c\u96f6\u914d\u7f6e\u8ba4\u8bc1\u300d\u7684\u5b9e\u73b0\u57fa\u7840\u3002**\n- \u6ca1\u6709\u7ec8\u7aef\uff0c\u6240\u4ee5\u4ea4\u4e92\u5f0f\u63d0\u793a\u88ab\u5173\u6389\uff08`GIT_TERMINAL_PROMPT=0`\u3001`GIT_ASKPASS=echo`\u3001`GIT_EDITOR=true`\u2026\uff09\uff0c\n \u4e8e\u662f `push` / `pull` \u4f1a**\u5feb\u901f\u3001\u53ef\u89c1\u5730\u5931\u8d25**\uff0c\u800c\u4e0d\u662f\u5bf9\u7740\u4e00\u4e2a\u6ca1\u4eba\u80fd\u8f93\u5165\u7684\u5bc6\u7801\u6846\u6c38\u8fdc\u6302\u7740\u3002\n\n\u987a\u5e26\u8865\u4e0a\u7684\u8fd8\u6709 `GIT_OPTIONAL_LOCKS=0`\uff08\u8bfb\u64cd\u4f5c\u4e0d\u62ff\u7d22\u5f15\u9501\uff09\u3001`GIT_PAGER=cat`\u3001`GIT_CONFIG_NOSYSTEM` \u515c\u5e95\u3002\n`git` \u53ef\u6267\u884c\u6587\u4ef6\u5728 `PATH` \u4e0e\u4e00\u7ec4\u6807\u51c6\u5b89\u88c5\u8def\u5f84\u91cc\u627e\uff0c\u627e\u4e0d\u5230\u4f1a\u7ed9\u4e00\u6761\u660e\u786e\u7684\u9519\u8bef\uff0c\u800c\u4e0d\u662f\u8ba9\u6bcf\u4e2a\u52a8\u4f5c\u90fd\u83ab\u540d\u5931\u8d25\u3002\n\n### \u547d\u4ee4\u7684\u6267\u884c\u8fb9\u754c\n\n\u5355\u6761\u547d\u4ee4 **25 \u79d2**\u540e SIGKILL\u2014\u2014\u523b\u610f\u77ed\u4e8e\u5bbf\u4e3b\u7684 30 \u79d2\u9762\u677f\u8d85\u65f6\uff0c\u5426\u5219\u8d85\u65f6\u4f1a\u4ee5\u300c\u9762\u677f\u65e0\u54cd\u5e94\u300d\u7684\u5f62\u5f0f\u51fa\u73b0\uff0c\n\u800c\u770b\u4e0d\u5230 Git \u5230\u5e95\u5361\u5728\u54ea\u3002\u6bcf\u6761\u8f93\u51fa\u6d41\u4e0a\u9650 **8 MiB**\uff0c\u8d85\u51fa\u7684\u90e8\u5206\u662f\u5de8\u91cf\u65e5\u5fd7\uff0c\u4e0d\u662f\u4fe1\u606f\u3002\n\n### \u72b6\u6001\u662f\u600e\u4e48\u8bfb\u7684\n\n\u72b6\u6001\u7528 `git status --porcelain=v2 -z` \u8bfb\uff1a`-z` \u662f\u552f\u4e00\u80fd**\u539f\u6837**\u8fd4\u56de\u8def\u5f84\u7684\u5f62\u5f0f\uff0c\u6240\u4ee5\u7a7a\u683c\u4e0e\u975e ASCII \u6587\u4ef6\u540d\u80fd\u6d3b\u4e0b\u6765\uff1b\nv2 \u628a\u7d22\u5f15\u5217\u4e0e\u5de5\u4f5c\u533a\u5217\u5206\u5f00\uff0c\u800c\u8fd9\u4e24\u5217\u6b63\u597d\u5c31\u662f\u754c\u9762\u663e\u793a\u7684\u300c\u5df2\u6682\u5b58 / \u672a\u6682\u5b58\u300d\u3002\n\uff08\u53ea\u6709\u4e00\u5904\u4f8b\u5916\uff1a\u751f\u6210\u63d0\u4ea4\u4fe1\u606f\u65f6\u4e3a\u4e86\u907f\u514d\u91cd\u547d\u540d\u88ab\u91cd\u590d\u63cf\u8ff0\uff0c\u53e6\u8dd1\u4e86\u4e00\u6b21 v1 \u7684 `--porcelain`\u3002\uff09\n\n### diff \u4e0e\u8865\u4e01\n\ndiff \u7528 Git \u9ed8\u8ba4\u7684 `a/`-`b/` \u524d\u7f00\u4ea7\u51fa\uff0c\u56e0\u4e3a `git apply` \u4f1a\u5265\u6389\u4e00\u4e2a\u524d\u5bfc\u8def\u5f84\u5206\u91cf\u2014\u2014\n**\u7ed9 `git diff` \u52a0 `--no-prefix` \u4f1a\u8ba9 hunk \u8865\u4e01\u88ab\u62d2\u7edd**\uff0c\u8fd9\u662f\u672c\u4ed3\u5e93\u7684\u4e00\u6761\u786c\u6027\u7ea6\u5b9a\u3002\n\u56de\u4f20\u7ed9 Git \u7684\u8865\u4e01\u662f\u300c\u539f\u59cb\u5934\u90e8 + \u88ab\u9009\u4e2d\u7684 hunk\u300d\uff0c\u8fd9\u6b63\u662f `--recount` \u80fd\u63a5\u53d7\u90e8\u5206\u9009\u62e9\u7684\u539f\u56e0\u3002\n\n\u8865\u4e01\u8fd8\u5e26**\u6765\u6e90\u4ed3\u5e93\u6807\u8bc6**\uff1a`git/apply-patch` \u4f1a\u6821\u9a8c `root`\uff0c\u4e0d\u7b26\u5219\u56de `STALE_REPOSITORY`\u3002\n\u56e0\u4e3a `discard` \u4f1a\u5199\u5de5\u4f5c\u6811\uff0c\u4e00\u4e2a\u300c\u8bfb\u51fa\u6765\u3001\u8fc7\u4e00\u4f1a\u513f\u518d\u5199\u56de\u53bb\u300d\u7684\u901a\u9053\u5fc5\u987b\u80fd\u62d2\u7edd\u6765\u81ea\u5df2\u5207\u6362\u4ed3\u5e93\u7684\u65e7\u8865\u4e01\u3002\n\n### Console \u8bb0\u4ec0\u4e48\n\nConsole \u662f\u73af\u5f62\u7f13\u51b2\uff1a**\u6700\u591a 200 \u6761**\uff0c\u6bcf\u6761 stdout / stderr \u5404\u622a\u5230 **4000 \u5b57\u7b26**\u3002\n\u63a2\u6d4b\u578b\u547d\u4ee4\uff08`rev-parse`\u3001`ls-files`\u3001`for-each-ref`\u3001`config`\u3001`--version` \u7b49\uff09\u6210\u529f\u65f6**\u4e0d\u8bb0\u5f55**\u2014\u2014\n\u5b83\u4eec\u6bcf\u6b21\u5237\u65b0\u90fd\u8dd1\uff0c\u8bb0\u4e0b\u6765\u53ea\u4f1a\u628a\u6709\u7528\u7684\u8f93\u51fa\u51b2\u6389\u3002\n\n### \u5931\u8d25\u4fe1\u606f\u662f\u5206\u5c42\u7684\n\n\u77ed\u6d88\u606f\u53ea\u4fdd\u7559 6 \u884c\uff0c\u5e76**\u5254\u9664 `hint:` \u884c**\u2014\u2014\u90a3\u4e9b\u884c\u662f\u7ed9\u7ec8\u7aef\u8bfb\u8005\u7684\uff0c\u754c\u9762\u6539\u7528\u672c\u5730\u5316\u7684\u8865\u6551\u8bf4\u660e\uff1b\n\u4f46\u5982\u679c\u5254\u9664\u540e\u4f1a\u4e00\u4e2a\u5b57\u90fd\u4e0d\u5269\uff0c\u5c31\u9000\u56de\u4fdd\u7559 hint\uff08\u89c4\u5219\u662f\u300c\u8bf4\u70b9\u4ec0\u4e48\u300d\uff0c\u4e0d\u662f\u300c\u6765\u81ea hint \u7684\u5c31\u4e00\u5f8b\u4e0d\u8bf4\u300d\uff09\u3002\n\u5b8c\u6574 stdout+stderr \u8d70 `detail` \u5b57\u6bb5\uff0c\u7531 toast \u4e0a\u7684\u300c\u8be6\u60c5\u300d\u94fe\u63a5\u6253\u5f00\u5bf9\u8bdd\u6846\u67e5\u770b\uff08detail \u4e5f\u6709 4000 \u5b57\u7b26\u4e0a\u9650\uff0c\u8d85\u51fa\u52a0\u7701\u7565\u53f7\uff09\u3002\n\n\u5f15\u64ce\u7ed9\u6bcf\u4e00\u7c7b\u5931\u8d25\u6253**\u7a33\u5b9a\u7684\u5206\u7c7b\u7801**\uff0c\u7531\u89c6\u56fe\u672c\u5730\u5316\u6210\u63aa\u8f9e\uff1a`authHint`\uff08`ssh` / `credentials`\uff09\u3001\n`pushHint`\uff08`remote-ahead` / `remote-rejected`\uff09\u3001`pullHint`\uff08`diverged` / `conflicts`\uff09\u3002\n\uff08`needsReconcile` \u4e0d\u662f\u5206\u7c7b\u7801\uff0c\u5b83\u662f\u5f15\u64ce\u5185\u90e8\u7528\u6765\u5224\u65ad\u300c\u662f\u5426\u8be5\u8865\u4e00\u6b21 `--no-rebase`\u300d\u7684\u8c13\u8bcd\uff0c\u89c6\u56fe\u770b\u4e0d\u5230\u5b83\u3002\uff09\n\u65b0\u589e\u4efb\u4f55\u5931\u8d25\u5206\u652f\u65f6\uff0c\u522b\u628a Git \u7684\u539f\u6587\u4e22\u6389\uff0c\u4e5f\u522b\u628a\u6574\u6bb5\u585e\u8fdb toast\u3002\n\n### \u76f8\u5bf9\u8def\u5f84\u4e0e\u771f\u5b9e\u8def\u5f84\n\n\u8def\u5f84\u6bd4\u8f83\u4e00\u5f8b\u6309**\u771f\u5b9e\u8def\u5f84**\uff1aGit \u8fd4\u56de\u7684\u662f\u771f\u5b9e\u8def\u5f84\uff08macOS \u4e0a `/private/var/\u2026`\uff09\uff0c\u5bbf\u4e3b\u7ed9\u7684\u662f\u7528\u6237\u6253\u5f00\u65f6\u7684\u8def\u5f84\uff08`/var/\u2026`\uff09\u3002\n\u7528\u5b57\u7b26\u4e32\u524d\u7f00\u6bd4\u8f83\u4f1a\u628a\u540c\u4e00\u4e2a\u76ee\u5f55\u5224\u6210\u4e24\u4e2a\uff0c\u540e\u679c\u662f**\u9759\u9ed8\u964d\u7ea7**\uff08\u300c\u6253\u5f00\u6587\u4ef6 / \u5728\u8bbf\u8fbe\u4e2d\u663e\u793a\u300d\u88ab\u7981\u7528\uff09\uff0c\u800c\u4e0d\u662f\u62a5\u9519\u3002\n\u89c6\u56fe\u4fa7\u4e0d\u8981\u81ea\u5df1\u62fc\u7edd\u5bf9\u8def\u5f84\uff0c\u7528\u5f15\u64ce\u7b97\u597d\u7684 `repo.workspacePrefix`\uff08`null` \u6709\u8bed\u4e49\uff1a\u4ed3\u5e93\u5728\u5de5\u4f5c\u533a\u4e4b\u4e0a\uff0c\u4e0d\u80fd\u6298\u6210 `\".\"`\uff09\u3002\n\n## \u4e00\u4e2a\u9879\u76ee\u91cc\u7684\u591a\u4e2a\u4ed3\u5e93\n\n\u4e00\u4e2a\u9879\u76ee\u5e38\u5e38\u4e0d\u6b62\u4e00\u4e2a\u4ed3\u5e93\uff1a\u4e00\u4e2a\u5df2\u68c0\u51fa\u7684\u5b50\u6a21\u5757\uff0c\u6216\u8005\u4e00\u4e2a\u6070\u597d\u4f4f\u5728\u53e6\u4e00\u4e2a\u4ed3\u5e93\u91cc\u7684\u514b\u9686\u3002\u5b83\u4eec\u662f**\u72ec\u7acb\u7684\u4ed3\u5e93\uff0c\u4e0d\u662f\u6587\u4ef6\u5939**\u2014\u2014\n\u7236\u4ed3\u5e93\u53ea\u8bb0\u5f55\u5b50\u6a21\u5757*\u6307\u5411\u54ea\u4e2a\u63d0\u4ea4*\u2014\u2014\u6240\u4ee5\u7236\u4ed3\u5e93\u7684 `git status` \u6c38\u8fdc\u53ea\u7ed9\u4e00\u884c gitlink\uff0c\u770b\u4e0d\u5230\u5b50\u6a21\u5757\u91cc\u9762\u7684\u6587\u4ef6\u3002\nCommit \u89c6\u56fe\u56e0\u6b64\u505a\u6210 IDEA \u5f0f\u7684**\u805a\u5408\u663e\u793a**\uff1a\u5148\u6309\u6682\u5b58/\u672a\u6682\u5b58/\u51b2\u7a81/\u672a\u8ddf\u8e2a\u5206\u5927\u7c7b\uff0c\u5927\u7c7b\u4e0b\u6309**\u4ed3\u5e93**\u6392\u2014\u2014\n\u6bcf\u4e2a\u6709\u6539\u52a8\u7684\u4ed3\u5e93\uff08\u7236\u4ed3\u5e93\u53c2\u4e0e\u5b57\u6bcd\u6392\u5e8f\uff09\u5404\u5360\u4e00\u884c\"\u989c\u8272\u5757 + \u540d + N \u4e2a\u6587\u4ef6 + \u5206\u652f\u5fbd\u6807\"\uff0c\u6587\u4ef6\u518d\u5d4c\u5728\u884c\u4e0b\u5c55\u5f00\uff0c\n\u6682\u5b58\u3001diff\u3001hunk\u3001\u56de\u6eda\u90fd\u76f4\u63a5\u53ef\u7528\uff1b\n\u63d0\u4ea4\u6309\u94ae\u7528\u540c\u4e00\u63d0\u4ea4\u4fe1\u606f\u9010\u4ed3\u63d0\u4ea4\uff08\u5b50\u6a21\u5757\u5148\u3001\u7236\u4ed3\u5e93\u540e\uff09\uff0c\u7236\u4ed3\u5e93\u968f\u540e\u51fa\u73b0\u7684\u6307\u9488\u66f4\u65b0\u518d\u63d0\u4ea4\u4e00\u6b21\u5373\u6536\u655b\u3002\n`Commit and Push` \u628a\u521a\u624d\u63d0\u4ea4\u7684\u90a3 N \u4e2a\u4ed3\u9010\u4e2a\u63a8\u51fa\u53bb\uff0c\u4e0d\u518d\u53ea\u63a8\u5f53\u524d\u4ed3\u3002\n\u4ed3\u5e93\u884c\u53f3\u952e\"\u5207\u6362\u5230\u8be5\u4ed3\u5e93\"\u4f1a\u8df3\u5230 Root \u9009\u62e9\u5668\u7684\u90a3\u4e2a\u4ed3\u5e93\uff08Log/\u5206\u652f/\u50a8\u85cf\u4ecd\u6309\u4ed3\u5e93\u5207\u6362\u67e5\u770b\uff09\u3002\n\u63a8\u9001\u8d70 IDEA \u5f0f\u7684 **Push \u5bf9\u8bdd\u6846**\uff1a\u591a\u4ed3\u65f6\u5de5\u5177\u680f Push \u5217\u51fa\u6bcf\u4e2a\u4ed3\u5e93\u7684\u5206\u652f\u53bb\u5411\uff08`main \u2192 origin/main`\uff09\u3001`\u2191/\u2193` \u4e0e\u52fe\u9009\u6846\uff0c\n`Push All` \u9010\u4ed3\u63a8\u9001\uff0c\u5355\u4e2a\u5931\u8d25\u4e0d\u6321\u5176\u4ed6\u4ed3\uff0c\u884c\u5185\u663e\u793a\u6210\u529f/\u62d2\u56e0\uff1b\u5355\u4ed3\u65f6\u4fdd\u6301\u76f4\u63a5\u63a8\u9001\u3002Git \u89c6\u56fe\u5de5\u5177\u680f\u540c\u6837\u6709 Push \u5165\u53e3\u3002\n\u4e24\u4e2a\u5de5\u5177\u680f\u6700\u5de6\u8fb9\u7684\u4ed3\u5e93\u63a7\u4ef6\u4ecd\u7136\u662f\u5207\u6362\u5f53\u524d\u4ed3\u5e93\u7684\u4e1c\u897f\uff1a\u9009\u4e2d\u4e00\u4e2a\uff0c\u89c6\u56fe\u80cc\u540e\u7684\u6bcf\u6761\u547d\u4ee4\uff08\u72b6\u6001\u3001diff\u3001\u65e5\u5fd7\u3001\u5206\u652f\u3001\u50a8\u85cf\u3001\u63d0\u4ea4\u3001hunk \u6682\u5b58\uff09\n\u5c31\u90fd\u5728\u5b83\u91cc\u9762\u8dd1\u3002\n\n\u5de5\u4f5c\u533a\u672c\u8eab\u4e0d\u662f\u4ed3\u5e93\u3001\u4f46\u6587\u4ef6\u5939\u91cc\u5e76\u6392\u6446\u7740\u591a\u4e2a\u4ed3\u5e93\u65f6\uff08`repoA/`\u3001`repoB/`\uff09\uff0c\u540c\u6837\u53ef\u7528\uff1a\u4ed3\u5e93\u5217\u8868\u5217\u51fa\u5b83\u4eec\uff08`sibling repository`\n\u5fbd\u6807\uff09\uff0cCommit \u89c6\u56fe\u805a\u5408\u663e\u793a\u5176\u4ed6\u5e73\u7ea7\u4ed3\u7684\u6539\u52a8\uff0cPush \u5bf9\u8bdd\u6846\u4e00\u6b21\u63a8\u5b8c\u3002\u7a7a\u6587\u4ef6\u5939\uff08\u5185\u65e0\u4ed3\u5e93\uff09\u4ecd\u62a5\"\u4e0d\u662f Git \u4ed3\u5e93\"\u3002\n\n\u5217\u8868\u7531\u5de5\u4f5c\u533a\u4ed3\u5e93\u51fa\u53d1\u904d\u5386\u6784\u5efa\uff0c\u4e14**\u904d\u5386\u662f\u6709\u8fb9\u754c\u7684**\uff1a\u6700\u591a 4 \u5c42\u6df1\u3001\u6700\u591a 5000 \u4e2a\u76ee\u5f55\uff0c\u5e76\u4e14\u4e0d\u4e0b\u6f5c\u4f9d\u8d56/\u6784\u5efa/\u7f13\u5b58\u76ee\u5f55\n\uff08`node_modules`\u3001`vendor`\u3001`Pods`\u3001`.venv`\u2026 \u5171 17 \u4e2a\u540d\u5b57\uff0c\u542b `.git`\uff09\u3002\n\u4f46 `.gitmodules` \u91cc**\u58f0\u660e**\u7684\u8def\u5f84\u4e0d\u53d7\u8fd9\u4e9b\u8fb9\u754c\u9650\u5236\u2014\u2014\u90a3\u91cc\u7684\u6761\u76ee\u662f\u5173\u4e8e\u9879\u76ee\u7684\u65ad\u8a00\uff0c\u800c\u8fb9\u754c\u53ea\u662f\u5bf9\u9879\u76ee\u89c4\u6a21\u7684\u731c\u6d4b\u3002\n\u5df2\u58f0\u660e\u4f46\u672a\u68c0\u51fa\u7684\u5b50\u6a21\u5757\u4e0d\u5217\u51fa\uff1a\u6ca1\u6709\u5de5\u4f5c\u6811\uff0c\u5c31\u6ca1\u5f97\u663e\u793a\u3001\u4e5f\u6ca1\u5f97\u63d0\u4ea4\u3002\n\n\u4e24\u4e2a\u957f\u5f97\u5f88\u50cf\u7684\u4ed3\u5e93\u662f**\u6309\u7236\u4ed3\u5e93\u7684\u7d22\u5f15**\u533a\u5206\u7684\uff0c\u4e0d\u662f\u6309 `.gitmodules`\uff1a\u88ab\u7236\u4ed3\u5e93\u8bb0\u6210 `160000` gitlink \u7684\u8def\u5f84\u662f**\u5b50\u6a21\u5757**\n\uff08\u5728\u90a3\u91cc\u6682\u5b58\u7b49\u4e8e\u8bb0\u5f55\u4e00\u4e2a\u63d0\u4ea4\uff09\uff0c\u5176\u4ed6\u90fd\u662f**\u5d4c\u5957\u4ed3\u5e93**\uff08\u7236\u4ed3\u5e93\u4e0d\u5b58\u5b83\u7684\u4efb\u4f55\u6587\u4ef6\uff09\u3002\u754c\u9762\u4e0a\u5206\u522b\u6253 `submodule` / `nested repository` \u5fbd\u6807\u3002\n\n\u8fd9\u4e2a\u9009\u62e9\u5c5e\u4e8e**\u6253\u5f00\u7684\u90a3\u4e2a\u9879\u76ee**\uff0c\u4e0d\u5199\u8fdb prefs\uff1a\u5207\u9879\u76ee\u3001\u6216\u8005\u628a\u76ee\u5f55\u79fb\u8d70\uff0c\u5c31\u9000\u56de\u5de5\u4f5c\u533a\u81ea\u5df1\u7684\u4ed3\u5e93\u3002\n\n## \u8fdc\u7aef\u540c\u6b65\uff1a\u63a8\u9001\u88ab\u62d2\u4e0e\u5408\u5e76\u51b2\u7a81\n\n\u88ab\u62d2\u7edd\u7684\u63a8\u9001\u662f**\u5e38\u89c4\u60c5\u51b5\uff0c\u4e0d\u662f\u5f02\u5e38**\uff1a\u522b\u4eba\u5f80\u540c\u4e00\u4e2a\u5206\u652f\u63a8\u4e86\u4e1c\u897f\u3002Git \u4f1a\u5728 stderr \u4e0a\u8bf4\u660e\uff0c\u5e76\u5728 `hint:` \u884c\u91cc\u7ed9\u51fa\u8865\u6551\u529e\u6cd5\uff0c\n\u8fd9\u4e24\u8005\u90fd\u8bfb\u2014\u2014\u77ed\u6d88\u606f\u91cc\u662f Git \u81ea\u5df1\u90a3\u51e0\u884c\uff0c\u4e0a\u9762\u90a3\u53e5\u5206\u7c7b\u597d\u7684\u8865\u6551\u8bf4\u660e\u662f\u63d2\u4ef6\u7684\uff1a\n\n| \u5931\u8d25 | \u63d2\u4ef6\u600e\u4e48\u8bf4 |\n| --- | --- |\n| \u63a8\u9001\u88ab\u62d2\uff08`fetch first`\u3001`non-fast-forward`\u3001`stale info`\uff09 | \u8fdc\u7aef\u6709\u4f60\u6ca1\u6709\u7684\u63d0\u4ea4\u2014\u2014\u5148 Fetch\uff0c\u518d Pull\uff0c\u7136\u540e\u91cd\u65b0\u63a8\u9001\uff1b\u5982\u679c\u8be5\u4fdd\u7559\u7684\u662f\u672c\u5730\u5386\u53f2\uff0c\u7528 Force Push |\n| \u63a8\u9001\u88ab\u62d2\uff08`protected branch`\u3001\u94a9\u5b50\uff09 | \u670d\u52a1\u5668\u62d2\u7edd\u4e86\u5b83\uff1b\u5206\u652f\u5927\u6982\u662f\u53d7\u4fdd\u62a4\u7684 |\n| \u62c9\u53d6\u5361\u5728\u51b2\u7a81\u4e0a | \u5728 Changes \u533a\u91cc\u89e3\u51b3\uff0c\u7136\u540e\u63d0\u4ea4 |\n| \u5206\u53c9\u4e14 `pull.ff = only` | \u5148 merge \u6216 rebase\uff0c\u518d\u63a8\u9001 |\n| \u6ca1\u6709\u5b58\u4e0b\u6765\u7684\u51ed\u636e\uff08HTTPS\uff09 | \u5728\u7ec8\u7aef\u91cc\u8dd1\u4e00\u6b21 `git push` \u8ba9\u51ed\u636e\u52a9\u624b\u5b58\u4e0b\u6765\uff0c\u518d\u91cd\u8bd5 |\n| `Permission denied (publickey)` | \u6362\u6210 HTTPS \u8fdc\u7aef\uff0c\u6216\u8005\u8ba9\u5bc6\u94a5\u4e0d\u4f9d\u8d56 `ssh-agent`\u2014\u2014\u5bbf\u4e3b\u4e0d\u4f20 `SSH_AUTH_SOCK` |\n\n\u4e09\u6761\u5f15\u64ce\u51b3\u7b56\u6491\u8d77\u8fd9\u5957\u884c\u4e3a\uff1a\n\n- **\u4e24\u4e2a\u6d41\u90fd\u8bfb\u3002** `git push` \u628a\u62d2\u7edd\u5199\u5728 stderr\uff0c\u4f46 `git merge`\uff08\u4e5f\u5c31\u662f `git pull` \u7684\u540e\u534a\u6bb5\uff09\u628a `CONFLICT` \u5199\u5728 stdout\u3002\n \u53ea\u8bfb stderr \u4f1a\u628a\u4e00\u6b21\u51b2\u7a81\u7684 pull \u6e32\u67d3\u6210\u4e00\u6bb5\u300c\u770b\u8d77\u6765\u50cf\u6210\u529f\u7684 fetch \u65e5\u5fd7\u300d\u3002\n **\u5224\u636e\u662f\u300c\u5931\u8d25\u65f6\u54ea\u4e00\u884c\u80fd\u6307\u51fa\u4e0b\u4e00\u6b65\u300d\uff0c\u4e0d\u662f\u300c\u54ea\u4e2a\u6d41\u66f4\u50cf\u9519\u8bef\u6d41\u300d\u3002**\n- **`git pull` \u6309\u914d\u7f6e\u8dd1\uff0c\u53ea\u5728 Git \u81ea\u5df1\u56e0\u7f3a\u7b56\u7565\u800c\u62d2\u7edd\u65f6\u91cd\u8bd5\u4e00\u6b21\u3002** \u81ea\u4ece 2.27\uff0c\u5728\u5206\u53c9\u5206\u652f\u4e0a\u88f8\u8dd1 `git pull` \u662f\u786c\u5931\u8d25\n \uff08`fatal: Need to specify how to reconcile divergent branches`\uff09\u2014\u2014\u4e8e\u662f\u88ab\u62d2\u7684\u63a8\u9001\u8ba9\u4f60\u8dd1\u7684\u90a3\u6761\u547d\u4ee4\u81ea\u5df1\u8dd1\u4e0d\u8d77\u6765\u3002\n \u53ea\u6709\u8fd9\u4e00\u4e2a\u5931\u8d25\u4f1a\u88ab `--no-rebase`\uff08merge\uff09\u91cd\u8bd5\u4e00\u6b21\uff0c\u5176\u4f59\u7ed3\u679c\u90fd\u662f Git \u7684\u3002\n \u8fd9\u91cc\u4e0d\u505a\u7b56\u7565\u63a8\u5bfc\uff0c\u6240\u4ee5 `branch..rebase`\u3001`pull.rebase = merges|interactive`\u3001`pull.ff = only` \u5168\u90e8\u7167\u5e38\u751f\u6548\u2014\u2014\n \u5b83\u4eec\u662f\u88ab**\u7b2c\u4e00\u6b21\u5c1d\u8bd5**\u8bfb\u5230\u7684\uff0c\u800c\u4e0d\u662f\u88ab\u4e00\u4efd\u66f4\u5dee\u7684 Git \u4f18\u5148\u7ea7\u89c4\u5219\u526f\u672c\u8bfb\u5230\u7684\u3002\n- **Force push \u53ea\u80fd\u662f lease\uff0c\u4e0d\u80fd\u662f\u88f8 force\u3002** `--force-with-lease` \u662f\u63d2\u4ef6\u552f\u4e00\u80fd\u53d1\u51fa\u7684 force\uff1a\u5982\u679c\u8fdc\u7aef\u5df2\u7ecf\u4e0d\u5728\u8fd9\u4e2a\u7a97\u53e3\n \u4e0a\u6b21\u770b\u5230\u7684\u4f4d\u7f6e\uff0c\u5b83\u5c31\u4f1a\u88ab\u62d2\u7edd\uff0c\u4e8e\u662f\u540c\u4e8b\u7684\u63a8\u9001\u6c38\u8fdc\u4e0d\u4f1a\u88ab\u62b9\u6389\u3002\u8fdc\u7aef\u4e0e ref \u662f\u4f4d\u7f6e\u53c2\u6570\uff0c\u6240\u4ee5\u4ea4\u7ed9 Git \u4e4b\u524d\u4f1a\u5148\u6821\u9a8c\u2014\u2014\n `git push origin --force` \u6b63\u662f\u300c\u672a\u6821\u9a8c\u7684 `-` \u5f00\u5934\u53d6\u503c\u300d\u4f1a\u4ea7\u51fa\u7684\u4e1c\u897f\u3002\n\n**\u63a8\u9001\u63a8\u5230\u54ea\u91cc\uff0c\u7531\u5206\u652f\u8ffd\u8e2a\u5173\u7cfb\u51b3\u5b9a**\uff0c\u4e0d\u662f `origin` + \u672c\u5730\u5206\u652f\u540d\u3002\u8fd9\u4e24\u8005\u53ea\u5728\u5206\u652f\u8ffd\u8e2a\u540c\u4e00\u5904\u65f6\u624d\u4e00\u81f4\u2014\u2014\n\u4e00\u65e6\u5206\u652f\u8ffd\u8e2a\u522b\u5904\uff0c\u65e7\u884c\u4e3a\u5c31\u4f1a\u628a\u65b0\u5206\u652f\u63a8\u5230 `origin`\uff0c\u800c \u2191/\u2193 \u6807\u7b7e\u8fd8\u5728\u5bf9\u7740\u771f\u6b63\u7684 upstream \u8ba1\u6570\u3002\n\u5b8c\u5168\u6ca1\u6709 upstream \u65f6\u56de\u9000\u8303\u56f4\u5f88\u7a84\uff1a`origin`\uff0c\u6216\u8005\u53ea\u6709\u4e00\u4e2a\u8fdc\u7aef\u65f6\u5c31\u7528\u5b83\u3002\n\u6709\u591a\u4e2a\u8fdc\u7aef\u53c8\u6ca1 upstream \u65f6\u65e0\u4ece\u63a8\u65ad\uff0c\u4e8e\u662f\u62d2\u7edd\u63a8\u9001\u5e76\u5217\u51fa\u8fdc\u7aef\u5217\u8868\uff0c\u800c\u4e0d\u662f\u628a\u5206\u652f\u53d1\u5e03\u5230\u6392\u5e8f\u7b2c\u4e00\u4e2a\u7684\u540d\u5b57\u4e0a\u53bb\u3002\n\u9996\u6b21\u63a8\u9001\u53ef\u4ee5\u76f4\u63a5\u5efa\u7acb\u8ffd\u8e2a\uff08\u8ffd\u52a0 `--set-upstream`\uff09\u3002\n\u53e6\u5916\u4e24\u79cd\u4f1a\u660e\u786e\u62d2\u7edd\u7684\u60c5\u51b5\uff1a\u5206\u652f\u8ffd\u8e2a\u7684\u662f\u4e00\u4e2a**\u672c\u5730\u5206\u652f**\uff08\u540d\u5b57\u91cc\u6ca1\u6709 `/`\uff09\uff0c\u4ee5\u53ca**\u5c1a\u65e0\u63d0\u4ea4**\u7684\u5206\u652f\u3002\n\n### \u79bb\u5f00\u4e00\u4e2a\u672a\u5b8c\u6210\u7684\u5408\u5e76\n\n\u51b2\u7a81\u7684 `pull` \u4f1a\u628a\u4ed3\u5e93\u7559\u5728\u5408\u5e76\u4e2d\u95f4\uff0c\u4e8e\u662f\u63d0\u4ea4\u6309\u94ae\u4e0a\u65b9\u7684\u72b6\u6001\u884c\u4f1a\u5199\u51fa**\u8fdb\u884c\u4e2d\u7684\u64cd\u4f5c**\u5e76\u5e26\u4e0a\u5b83\u7684\u51fa\u53e3\uff1a\n\u56db\u4e2a\u64cd\u4f5c\u90fd\u6709 **Abort**\uff0c\u53ea\u6709\u5e8f\u5217\u7c7b\u7684\u4e09\u4e2a\uff08cherry-pick / revert / rebase\uff09\u6709 **Continue**\u3002\nmerge **\u4e0d\u8fdb\u8fd9\u4e2a\u5217\u8868**\uff1a\u5b83\u6ca1\u6709\u9700\u8981\u7ee7\u7eed\u7684\u534a\u6210\u54c1\u72b6\u6001\uff0c\u4e00\u6b21\u5408\u5e76\u662f\u9760**\u63d0\u4ea4**\u6765\u7ed3\u675f\u7684\u2014\u2014\u6240\u4ee5\u63d2\u4ef6\u4e0d\u7ed9\u5b83 Continue\n\uff08\u987a\u5e26\u8bf4\u660e\uff1a`git merge --continue` \u5728\u7ec8\u7aef\u91cc\u662f\u5b58\u5728\u7684\uff0c\u53ea\u662f\u8fd9\u91cc\u7528\u4e0d\u4e0a\uff0c\u56e0\u6b64\u4e0d\u63d0\u4f9b\uff09\u3002\nContinue \u7528 `core.editor=true` \u8dd1\uff0c\u56e0\u4e3a\u6ca1\u6709\u7ec8\u7aef\u53ef\u4ee5\u5199\u63d0\u4ea4\u4fe1\u606f\uff0c\u5426\u5219 Git \u53ea\u4f1a\u5931\u8d25\u2014\u2014\u7528\u7684\u662f\u8be5\u64cd\u4f5c\u5df2\u7ecf\u8bb0\u5f55\u597d\u7684\u90a3\u6761\u4fe1\u606f\u3002\n\n\u8fd9\u884c\u5728**\u51b2\u7a81\u88ab\u6682\u5b58\u4e4b\u540e**\u4ecd\u7136\u4fdd\u7559\u64cd\u4f5c\u540d\uff0c\u8fd9\u624d\u662f\u91cd\u70b9\uff1a\u89e3\u51b3\u51b2\u7a81\u4f1a\u628a `u` \u884c\u53d8\u6210\u666e\u901a\u7d22\u5f15\u884c\uff0c\n\u4e8e\u662f porcelain \u4e0d\u518d\u63d0\u5b83\u2014\u2014\u800c\u300c\u628a\u6240\u6709\u51b2\u7a81\u90fd\u6682\u5b58\u4e86\u300d\u6b63\u662f `--continue` \u5f00\u59cb\u80fd\u591f\u6210\u529f\u7684\u65f6\u523b\u3002\n\u628a\u68c0\u67e5\u9650\u5236\u5728\u300c\u6709\u51b2\u7a81\u65f6\u300d\u4f1a\u85cf\u8d77\u6765\u63d2\u4ef6\u81ea\u5df1\u521b\u9020\u7684\u90a3\u4e2a\u72b6\u6001\u7684\u552f\u4e00\u51fa\u53e3\uff0c\u6240\u4ee5\u63a2\u9488\u5728**\u6709\u51b2\u7a81 _\u6216_ \u521a\u624d\u53d1\u73b0\u8fc7\u64cd\u4f5c**\u65f6\u90fd\u8fd0\u884c\u3002\n\n\u4e0d\u8ba4\u8bc6\u7684\u64cd\u4f5c\u540d\u4e0d\u4f1a\u88ab\u8fd0\u884c\uff1a\u5f15\u64ce\u53ea\u63a5\u53d7\u5b83\u80fd\u62a5\u51fa\u7684\u90a3\u56db\u4e2a\u540d\u5b57\uff0c\u6240\u4ee5\u4e00\u4e2a\u7578\u5f62\u7684\u8f7d\u8377\u4e0d\u4f1a\u53d8\u6210\u4e00\u4e2a\u4efb\u610f\u7684 `git` \u52a8\u8bcd\u3002\n\n### \u914d\u7f6e\u914d\u65b9\n\n**GitHub\uff0cHTTPS** \u2014\u2014 `gh auth login` \u7136\u540e `gh auth setup-git` \u4f1a\u628a\u52a9\u624b\u5199\u8fdb `~/.gitconfig`\u3002\u4e0d\u9700\u8981\u522b\u7684\u3002\n\n**GitLab\uff08\u4efb\u610f\u4e3b\u673a\uff09\uff0cHTTPS** \u2014\u2014 \u8ba9 Git \u5b58\u4e00\u6b21\uff1a\n\n```bash\ngit config --global credential.helper osxkeychain # macOS\uff08Linux \u7528 libsecret\uff0c\n # Windows \u7528 manager\uff09\ngit push # \u8f93\u4e00\u6b21 token\uff0c\u6b64\u540e\u90fd\u4f1a\u88ab\u5b58\u4e0b\u6765\n```\n\n\u81ea\u5efa GitLab **\u4e0d\u9700\u8981\u4efb\u4f55\u63d2\u4ef6\u4fa7\u914d\u7f6e**\uff0c\u56e0\u4e3a\u7528\u7684\u5c31\u662f\u4f60 shell \u7528\u7684\u90a3\u4e2a\u51ed\u636e\u5e93\u3002\n\n**SSH** \u2014\u2014 \u65e0\u53e3\u4ee4\u7684\u5bc6\u94a5\u3001\u6216\u8005\u53e3\u4ee4\u5b58\u5728 macOS \u94a5\u5319\u4e32\u91cc\uff08`UseKeychain yes`\uff09\u7684\u5bc6\u94a5\uff0c\u53ef\u4ee5\u76f4\u63a5\u7528\u3002\n\u53ea\u6d3b\u5728 `ssh-agent` \u91cc\u7684\u5bc6\u94a5\u4e0d\u884c\uff0c\u56e0\u4e3a\u5bbf\u4e3b\u4e0d\u4f1a\u628a `SSH_AUTH_SOCK` \u4ea4\u7ed9\u63d2\u4ef6\u8fdb\u7a0b\uff1b\u8fd9\u7c7b\u60c5\u51b5\u8bf7\u7528 HTTPS \u8fdc\u7aef\uff0c\u6216\u628a\u5bc6\u94a5\u52a0\u8fdb\u94a5\u5319\u4e32\u3002\n\n> \u591a\u8d26\u53f7\u914d\u7f6e\uff1a\u63d2\u4ef6\u8bfb\u7684\u662f\u4f60\u7684\u51ed\u636e\u52a9\u624b\u4e3a\u8be5 URL \u89e3\u6790\u51fa\u7684\u90a3\u4e2a\u8d26\u53f7\uff0c\u6240\u4ee5\n> `credential..username` \u548c `includeIf \"gitdir:\u2026\"` \u7684\u89c4\u5219\u5728\u8fd9\u91cc\u548c\u7ec8\u7aef\u91cc\u4e00\u6837\u751f\u6548\u3002\n\n## \u8ba4\u8bc1\n\n**\u63d2\u4ef6\u4e0d\u6301\u6709\u4efb\u4f55\u51ed\u636e\uff0c\u4e5f\u4e0d\u9700\u8981\u3002** \u5b83\u8dd1\u7684\u662f\u7528\u6237\u81ea\u5df1\u7684 `git`\u3001\u5e26\u7684\u662f\u7528\u6237\u81ea\u5df1\u7684 `HOME`\uff0c\n\u4e8e\u662f\u5b83\u7ee7\u627f\u7684\u6b63\u662f\u7ec8\u7aef\u5df2\u7ecf\u5728\u7528\u7684\u90a3\u5957\u4e1c\u897f\uff1a\u540c\u4e00\u4e2a `~/.gitconfig`\u3001\u540c\u4e00\u4e2a\u51ed\u636e\u52a9\u624b\u3001\u540c\u4e00\u4efd `~/.ssh/config` \u4e0e\u5bc6\u94a5\u3002\nshell \u91cc\u4ec0\u4e48\u80fd\u8ba4\u8bc1\u4e00\u6b21 `git push`\uff0c\u8fd9\u91cc\u5c31\u80fd\uff0c\u5728 GitHub\u3001\u81ea\u5efa GitLab \u6216\u4efb\u4f55\u522b\u7684\u5730\u65b9\u2014\u2014**\u6ca1\u6709\u4efb\u4f55\u6309\u4e3b\u673a\u914d\u7f6e\u7684\u4e1c\u897f**\u3002\n\n\u8fd9\u79cd\u7ee7\u627f\u662f\u523b\u610f\u7684\u3002\u63d2\u4ef6\u4e0d\u8be5\u88ab\u6258\u4ed8 token\uff0c\u800c\u4e00\u4efd\u300c\u6309\u4e3b\u673a\u586b\u51ed\u636e\u300d\u7684\u8868\u5355\u4e5f\u53ea\u80fd\u8986\u76d6\u5b83\u8ba4\u8bc6\u7684\u90a3\u4e9b\u4e3b\u673a\u3002\n\n\u63d2\u4ef6\u552f\u4e00\u505a\u4e0d\u5230\u7684\u4e8b\u662f**\u63d0\u95ee**\u3002\u5de5\u5177\u7a97\u53e3\u80cc\u540e\u6ca1\u6709\u7ec8\u7aef\uff0c\u6240\u4ee5\u63d0\u793a\u88ab\u5173\u6389\uff0c\u547d\u4ee4\u4f1a\u7acb\u523b\u5931\u8d25\uff0c\u800c\u4e0d\u662f\u6c38\u8fdc\u6302\u5728\u4e00\u4e2a\u6ca1\u4eba\u80fd\u8f93\u5165\u7684\u5bc6\u7801\u4e0a\u3002\n\u53d1\u751f\u8fd9\u79cd\u60c5\u51b5\u65f6\uff0c\u539f\u59cb\u7684 Git \u9519\u8bef\u4f1a\u8fde\u540c\u300c\u8be5\u600e\u4e48\u529e\u300d\u4e00\u8d77\u663e\u793a\uff08\u89c1\u4e0a\u4e00\u8282\u7684\u8868\uff09\u3002\n\n## \u751f\u6210\u63d0\u4ea4\u4fe1\u606f\n\n\u63d0\u4ea4\u4fe1\u606f\u6846\u65c1\u8fb9\u7684\u95ea\u5149\u6309\u94ae\u4f1a\u66ff\u4f60\u8d77\u8349\u4fe1\u606f\u3002\u5b83\u901a\u8fc7 `pi.agent.complete` \u627e**\u5bbf\u4e3b**\u7684\u6a21\u578b\uff0c\n\u6240\u4ee5\u7528\u7684\u5c31\u662f\u4f60**\u5df2\u7ecf**\u914d\u597d\u7684 provider\u3001\u6a21\u578b\u4e0e\u989d\u5ea6\u2014\u2014\u63d2\u4ef6\u4e0d\u6301\u6709 API key\uff0c\u4e5f\u4e0d\u65b0\u589e\u4efb\u4f55\u8d26\u53f7\u3002\n\n\u5b83\u53d1\u51fa\u53bb\u7684\u662f\u4e09\u6837\u4e1c\u897f\uff1a\u53d8\u66f4\u672c\u8eab\u3001\u4ed3\u5e93\u8fd1\u671f\u63d0\u4ea4\u7684**subject \u4e0e\u6b63\u6587\u6837\u672c**\uff0c\u4ee5\u53ca\u8fd9\u4efd\u6837\u672c\u6d4b\u51fa\u6765\u7684\u4e1c\u897f\u2014\u2014\n\u63d0\u4ea4\u8bed\u8a00\u3001subject \u662f\u5426\u5e26 `type(scope):` \u524d\u7f00\u3001\u6709\u6ca1\u6709\u4eba\u5199\u6b63\u6587\u3002\u6837\u672c\u53d6**\u6700\u8fd1 40 \u6761**\u63d0\u4ea4\u3002\ndiff \u622a\u65ad\u5728 **12 000 \u5b57\u7b26**\uff1a\u8fd9\u662f\u63d0\u793a\u8bcd\uff0c\u4e0d\u662f\u5907\u4efd\u3002\n\n**\u8bed\u8a00\u3002** \u7531\u95ea\u5149\u6309\u94ae\u65c1\u7684\u7bad\u5934\u83dc\u5355\u51b3\u5b9a\uff0c\u56e0\u4e3a\u300c\u7167\u7740\u4ed3\u5e93\u5199\u300d\u4e0d\u662f\u4e00\u6761\u8bed\u8a00\u7b56\u7565\uff1a\n\u4e00\u4efd\u5168\u82f1\u6587\u7684\u5386\u53f2\u4f1a\u8ba9\u4e2d\u6587\u8bfb\u8005\u4e5f\u62ff\u5230\u82f1\u6587\u8349\u7a3f\u3002\u9009\u9879\u662f\n**\u8ddf\u968f\u4ed3\u5e93\u5386\u53f2**\uff08\u9ed8\u8ba4\uff1a\u7528\u5b9e\u6d4b\u8bed\u8a00\uff0c\u6ca1\u6709\u5386\u53f2\u65f6\u56de\u9000\u5230\u754c\u9762\u8bed\u8a00\uff09\u3001**\u7b80\u4f53\u4e2d\u6587**\u3001**English**\u3002\n\u65e0\u8bba\u54ea\u79cd\uff0c`type` \u4e0e `scope` \u90fd\u4fdd\u6301 ASCII\uff0c\u5c31\u50cf Conventional Commits \u7684\u5404\u79cd\u8bed\u8a00\u8bd1\u672c\u505a\u7684\u90a3\u6837\u2014\u2014\n`feat(\u767b\u5f55): \u652f\u6301\u77ed\u4fe1\u9a8c\u8bc1\u7801`\u3002\n\n\u63d0\u793a\u8bcd\u6309 Git \u81ea\u5df1\u7684\u60ef\u4f8b\uff08git-commit(1)\u3001Tim Pope\u3001Chris Beams\u3001Conventional Commits 1.0.0\uff09\u5199\u660e\u786c\u6027\u89c4\u5219\uff1a\nsubject \u540e\u4e00\u4e2a\u7a7a\u884c\u3001\u76ee\u6807 50 \u5b57\u7b26\u4e0a\u9650 72\uff08\u4e2d\u6587\u7ea6 25 \u5b57\uff0c\u56e0\u4e3a CJK \u5b57\u5f62\u5927\u81f4\u662f\u4e24\u500d\u5bbd\uff09\u3001\n\u7ed3\u5c3e\u4e0d\u52a0\u53e5\u53f7\u3001\u82f1\u6587 subject \u7528\u7948\u4f7f\u53e5\u3001\u6b63\u6587\u8bf4 **why** \u800c\u4e0d\u662f\u590d\u8ff0 diff\u3001\n`BREAKING CHANGE:` \u662f\u552f\u4e00\u5141\u8bb8\u5b83\u81ea\u884c\u8ffd\u52a0\u7684 trailer\u3002\n\n**\u5f62\u72b6\u662f\u88ab\u5f3a\u5236\u7684\uff0c\u4e0d\u662f\u88ab\u8bf7\u6c42\u7684\u3002** \u63d0\u793a\u8bcd\u4e0d\u80fd\u662f\u683c\u5f0f\u7684\u6700\u540e\u4e00\u9053\u9632\u7ebf\uff0c\u56e0\u4e3a\u8981\u547d\u7684\u90a3\u4e2a\u5931\u8d25\u5bf9\u5199\u63d0\u793a\u8bcd\u7684\u4eba\u662f\u9690\u5f62\u7684\uff1a\nGit \u628a**\u7b2c\u4e00\u4e2a\u7a7a\u884c\u4e4b\u524d\u7684\u6bcf\u4e00\u884c**\u90fd\u5f53\u6210\u6807\u9898\uff0c\u6240\u4ee5\u4e00\u4e2a\u628a\u6b63\u6587\u7d27\u8d34\u5728 subject \u4e0b\u9762\u7684\u56de\u590d**\u6839\u672c\u6ca1\u6709\u6b63\u6587**\u2014\u2014\n\u5b83\u662f\u4e00\u4e2a\u5de8\u5927\u7684 subject\uff0c\u800c\u8fd9\u6b63\u662f\u8bfb\u8005\u53e3\u4e2d\u7684\u300c\u63d0\u4ea4\u4fe1\u606f\u4e0d\u89c4\u8303\u300d\u3002\u6240\u4ee5\u56de\u590d\u5728\u8fdb\u5165\u4fe1\u606f\u6846\u4e4b\u524d\u4f1a\u8fc7\u4e00\u904d `formatCommitMessage`\uff1a\n\n- \u5148\u5265\u6389 ``` \u4ee3\u7801\u56f4\u680f\u548c `commit message:` / `\u63d0\u4ea4\u4fe1\u606f:` \u8fd9\u7c7b\u524d\u7f00\uff08\u6a21\u578b\u5f88\u7231\u52a0\uff09\uff1b\n- \u4fdd\u8bc1 subject \u662f\u5355\u884c\uff0c\u5e76\u4e14\u65e0\u8bba\u6a21\u578b\u600e\u4e48\u6392\uff0c\u540e\u9762\u90fd\u6070\u597d\u4e00\u4e2a\u7a7a\u884c\uff1b\n- \u8fc7\u957f\u7684 subject \u4f1a\u628a\u5c3e\u5df4**\u5728\u4ece\u53e5\u8fb9\u754c**\u632a\u8fdb\u6b63\u6587\uff0c\u800c\u4e0d\u662f\u622a\u65ad\uff0c\u4e8e\u662f\u6a21\u578b\u4ece diff \u91cc\u8bfb\u5230\u7684\u4fe1\u606f\u4e0d\u4f1a\u4e22\uff1b\n- \u628a\u6b63\u6587\u6298\u5230 Git \u7684 72 \u5217\uff0cCJK \u5b57\u5f62\u6309\u7ec8\u7aef\u90a3\u6837\u7b97\u4e24\u5217\uff1b\n- \u628a\u5206\u53f7\u4e32\u8d77\u6765\u7684\u4ece\u53e5\u62c6\u6210\u300c\u4e00\u4e2a\u60f3\u6cd5\u4e00\u6761\u300d\u7684\u9879\u76ee\u7b26\u53f7\uff1b\n- \u5f53 `,` `;` \u5939\u5728\u4e2d\u6587\u5b57\u7b26\u4e4b\u95f4\u65f6\u89c4\u8303\u6210 `\uff0c` `\uff1b`\u2014\u2014\u800c ASCII \u539f\u6837\u4fdd\u7559\uff0c\u6240\u4ee5 `feat(a,b): \u2026` \u4e0d\u4f1a\u88ab\u78b0\u3002\n\n\u5b83\u4e0d\u6539\u5199\u4efb\u4f55\u63aa\u8f9e\uff0c\u800c\u4e14\u662f**\u5e42\u7b49**\u7684\uff1a\u5f62\u72b6\u5df2\u7ecf\u6b63\u786e\u7684\u8349\u7a3f\u4f1a\u539f\u6837\u901a\u8fc7\u3002\n\n**\u8303\u56f4\u3002** Changes \u5217\u8868\u91cc\u6709\u9009\u4e2d\u7684\u6587\u4ef6\u65f6\uff0c\u5b83\u63cf\u8ff0*\u90a3\u4e2a\u6587\u4ef6*\uff1b\u6ca1\u9009\u4e2d\u65f6\u63cf\u8ff0*\u6240\u6709\u5df2\u6682\u5b58\u5185\u5bb9*\u3002\n\u7528\u4e86\u54ea\u4e00\u79cd\u4f1a\u5728\u63d0\u793a\u6761\u91cc\u8bf4\u660e\uff0c\u6240\u4ee5\u8303\u56f4\u6c38\u8fdc\u4e0d\u662f\u731c\u7684\u3002\u4e24\u8005\u90fd\u4e0d\u5b58\u5728\u65f6\u5b83\u4f1a\u76f4\u8bf4\uff0c\u800c\u4e0d\u662f\u7f16\u4e00\u6761\u4fe1\u606f\u51fa\u6765\u3002\n\n**\u8349\u7a3f\u5c31\u662f\u8349\u7a3f\u3002** \u5b83\u843d\u5728\u4fe1\u606f\u6846\u91cc\uff0c\u53ef\u4ee5\u7167\u5e38\u7f16\u8f91\u3002\u5982\u679c\u4f60\u5df2\u7ecf\u5199\u4e86\u5185\u5bb9\uff0c\u5b83\u4f1a\u5148\u95ee\u4e00\u53e5\u518d\u66ff\u6362\u3002\n\n\u6743\u9650\u662f `models.list`\uff08\u63d0\u4f9b\u6a21\u578b\u83dc\u5355\uff09\u4e0e `agent.complete`\uff08\u9ad8\u98ce\u9669\u2014\u2014\u4f1a\u82b1\u4f60\u7684\u6a21\u578b\u989d\u5ea6\uff09\u3002\n\u5bbf\u4e3b\u628a\u8fd9\u4ef6\u4e8b\u9650\u6d41\u5230**\u6bcf\u5206\u949f 8 \u6b21**\uff1b\u63d2\u4ef6\u628a\u8fd9\u4e2a\u9650\u5236\u62a5\u51fa\u6765\uff0c\u800c\u4e0d\u662f\u76f2\u76ee\u91cd\u8bd5\u3002\n\n## \u914d\u8272\n\n\u6d45\u8272\u914d\u8272\u5927\u90e8\u5206\u6284\u81ea JetBrains \u81ea\u5df1\u7684\u6587\u6863\uff1a\u65b0\u589e\u884c `#c6e4c1`\u3001\u4fee\u6539\u884c `#e9eff9`\u3001\u53d8\u5316\u7247\u6bb5\u84dd `#c6d7f0`\u3001\n\u9762\u677f\u5206\u9694\u7ebf\u3001\u7f16\u8f91\u5668\u8fb9\u680f\u7684\u53d8\u66f4\u6761\uff0c\u4ee5\u53ca\u6587\u4ef6\u72b6\u6001\u8272\uff08\u65b0\u589e `#0a7700`\u3001\u4fee\u6539 `#0032a0`\u3001\u672a\u7248\u672c\u63a7\u5236 `#993300` \u7b49\uff09\u3002\n\u4f59\u4e0b\u7684\u754c\u9762\u9970\u4ef6\u7167\u7740 IntelliJ Light \u4e0e Darcula \u7684\u6837\u5b50\u6765\uff0c\u90a3\u4e9b\u5730\u65b9 JetBrains \u6ca1\u6709\u516c\u5e03\u6570\u503c\u3002\n\n**\u4e00\u4e2a\u523b\u610f\u7684\u504f\u79bb\uff0c\u4e24\u5957\u4e3b\u9898\u90fd\u6709\uff1a** JetBrains \u628a\u5220\u9664\u884c\u8bb0\u6210\u4e00\u4e2a\u4e2d\u6027\u7070\uff08\u6d45\u8272 `#D7D6D6`\uff09\u3002\n\u7d27\u6328\u7740\u7eff\u8272\u7684\u65b0\u589e\uff0c\u90a3\u4e2a\u7070\u8bfb\u8d77\u6765\u50cf*\u892a\u8272*\u800c\u4e0d\u662f*\u88ab\u5220\u6389*\uff0c\u6240\u4ee5\u5220\u9664\u7528\u67d4\u548c\u7684\u7ea2\u8272\u2014\u2014\u6d45\u8272 `#f7d2d2`\uff0c\u6697\u8272 `#452a2e`\u3002\n\n**\u6697\u8272 diff \u914d\u8272\u662f\u8c03\u51fa\u6765\u7684\uff0c\u4e0d\u662f\u6284\u7684\u3002** \u6587\u6863\u91cc\u7684\u6697\u8272\u503c\u7ed9\u51fa\u4e00\u79cd\u504f\u68d5\u7684\u884c\u5e95\u8272\uff0c\u800c\u4e00\u5757\u94a2\u84dd\u8272\u7684\u7247\u6bb5\u8865\u4e01\u538b\u5728\u6697\u7ea2\u884c\u4e0a\u4f1a\u53d8\u6210\u4e00\u56e2\u6ce5\u3002\n\u6240\u4ee5\u6697\u8272\u5e26\u81ea\u5df1\u7684\u4e00\u5957\u503c\uff0c\u800c\u4e14\u53d8\u5316\u7247\u6bb5\u7684\u989c\u8272\u8ddf\u7740\u5b83\u6240\u5728\u7684\u884c\u8d70\uff1a\u5220\u9664\u884c\u4e0a\u66f4\u7ea2\uff0c\u65b0\u589e\u884c\u4e0a\u66f4\u7eff\u3002\n`--diff-fragment-deleted` / `--diff-fragment-inserted` \u662f\u4e24\u4e2a\u8986\u76d6\u70b9\uff08\u53ea\u5728\u6697\u8272\u4e3b\u9898\u91cc\u5b9a\u4e49\uff09\uff1b\u6d45\u8272\u4e3b\u9898\u4e0d\u8bbe\u5b83\u4eec\uff0c\u4fdd\u7559 JetBrains \u7684\u84dd\u3002\n\n\u4e3b\u9898\u5207\u6362\u8d70 `document.documentElement.dataset.theme`\uff0c\u7531 `app.getAppearance` \u521d\u59cb\u5316\u3001\u5e76\u8ba2\u9605 `appearance:changed` \u8ddf\u968f\u5bbf\u4e3b\u3002\n\u5168\u90e8\u90fd\u5728 `src/theme.css` \u9876\u90e8\u7684 CSS \u53d8\u91cf\u91cc\uff0c\u6240\u4ee5\u91cd\u65b0\u8c03\u4e00\u5904\u5c31\u662f\u6539\u4e00\u884c\u3002\n\n## \u6d4b\u8bd5\u4e0e\u5de5\u5177\n\n### \u5f15\u64ce\u56de\u5f52\n\n`tools/harness.mjs` \u5bf9\u7740**\u5f53\u573a\u9020\u51fa\u6765\u7684\u771f\u5b9e\u4ed3\u5e93**\u9a71\u52a8\u5f15\u64ce\u2014\u2014\u4e00\u4e2a\u88f8\u8fdc\u7aef\u3001\u4e00\u4e2a\u76f4\u63a5\u521d\u59cb\u5316\u7684\u5de5\u4f5c\u526f\u672c\u52a0\u4e00\u4e2a\u771f\u6b63\u7684 `git clone`\u3001\n\u4e00\u6b21\u771f\u5b9e\u7684\u53cc\u4eba\u5206\u53c9\u3001\u4e00\u4e2a\u5185\u542b\u5b50\u6a21\u5757\u7684\u5b50\u6a21\u5757\u2014\u2014\n\u5e76\u65ad\u8a00\u90a3\u4e9b\u300c\u62d2\u7edd\u300d\u6240\u4f9d\u8d56\u7684\u884c\u4e3a\uff1a\u5206\u7c7b\u3001\u6d88\u606f\u3001\u8be6\u60c5\u3001\u5148\u9648\u65e7\u518d fetch \u7684 ahead/behind \u6807\u7b7e\u3001\u51b2\u7a81\u7684\u5408\u5e76\u53ca\u5176\u51fa\u53e3\u3001\n\u4e00\u6b21\u63a8\u9001\u843d\u5230\u54ea\u91cc\uff08\u4ee5\u53ca\u88ab\u62d2\u7edd\u843d\u5230\u54ea\u91cc\uff09\u3001\u9648\u65e7\u7684 lease \u6c38\u8fdc\u4e0d\u4f1a\u8986\u76d6\u540c\u4e8b\u7684\u5de5\u4f5c\uff1b\n\u6700\u540e\u51e0\u6bb5\u8fd8\u5305\u62ec\uff1a\u5d4c\u5957\u5728\u53e6\u4e00\u4e2a\u4ed3\u5e93\u91cc\u7684\u4ed3\u5e93\u4f1a\u88ab\u5217\u51fa\u3001\u53ef\u4ee5\u88ab\u9009\u4e2d\u3001\u53ef\u4ee5\u5728\u91cc\u9762\u63d0\u4ea4\u3001\u626b\u63cf\u80fd\u770b\u591a\u8fdc\u3001\n`.gitmodules` \u7684\u58f0\u660e\u80fd\u63a8\u7ffb\u4ec0\u4e48\uff0c\u4ee5\u53ca\u4e00\u4efd\u6765\u81ea\u300c\u89c6\u56fe\u5df2\u7ecf\u79bb\u5f00\u7684\u4ed3\u5e93\u300d\u7684\u8865\u4e01\u4f1a\u88ab\u62d2\u7edd\u800c\u4e0d\u662f\u88ab\u5e94\u7528\u3002\n\n```bash\nnode tools/harness.mjs # \u5b9e\u6d4b\u8f93\u51fa\uff1a136/136 passed\uff0c\u5168\u90e8\u5bf9\u7740\u771f\u5b9e git\nnode tools/harness.mjs --keep # \u4fdd\u7559\u4e34\u65f6\u4ed3\u5e93\uff0c\u4fbf\u4e8e\u4e8b\u540e\u7ffb\u770b\n```\n\n`PLUGIN_DIR` \u53ef\u4ee5\u628a harness \u6307\u5411\u53e6\u4e00\u4efd\u68c0\u51fa\u3002\n\n### \u51b3\u7b56\u7b14\u8bb0\u95e8\u7981\n\n`.agents/notes/` \u4e0b\u7684\u7b14\u8bb0\u627f\u8f7d\u300c\u4ee3\u7801\u672c\u8eab\u5e26\u4e0d\u52a8\u7684\u51b3\u5b9a\u300d\u2014\u2014\u4e3a\u4ec0\u4e48\u662f\u8fd9\u4e2a\u5f62\u72b6\uff0c\u4ee5\u53ca\u5b83\u6362\u6389\u4e86\u4ec0\u4e48\u3002\n\u4e00\u7bc7\u6ca1\u4eba\u80fd\u627e\u5230\u7684\u7b14\u8bb0\u3001\u6216\u8005\u5934\u5757\u6f02\u79fb\u4e86\u7684\u7b14\u8bb0\uff0c\u662f\u4e0b\u4e00\u4e2a\u8bfb\u8005\uff08\u4eba\u6216 agent\uff09\u4e0d\u4f1a\u4fe1\u4efb\u7684\u7b14\u8bb0\uff0c\n\u6240\u4ee5\u5f62\u72b6\u662f**\u5f3a\u5236**\u7684\u800c\u4e0d\u662f\u7ea6\u5b9a\u4fd7\u6210\u7684\uff1a\n\n```bash\nnode tools/notes.mjs # \u4e24\u9879\u6821\u9a8c\uff1a\u76ee\u5f55/\u5206\u7c7b/\u76f8\u5bf9\u94fe\u63a5 + \u5934\u5757/Status/\u5fc5\u9700\u7ae0\u8282\n```\n\n\u4e24\u4e2a\u6821\u9a8c\u5668\u9010\u5b57\u8282 vendored \u5728 `tools/agent-notes/` \u4e0b\uff0c\u6240\u4ee5**\u65e0\u8bba\u673a\u5668\u4e0a\u88c5\u6ca1\u88c5\u90a3\u4e2a skill \u90fd\u80fd\u8dd1**\uff1b\n\u5b83\u4eec\u4e5f\u7531 `node tools/harness.mjs` \u4e00\u8d77\u65ad\u8a00\uff08136 \u6761\u91cc\u6709 2 \u6761\u6765\u81ea\u8fd9\u91cc\uff09\u3002\n\u672c\u673a Node 26 \u80fd\u76f4\u63a5\u6267\u884c `.ts`\uff0c\u4e0d\u9700\u8981 tsx \u6216\u4efb\u4f55 flag\u3002\n\n### \u4e0d\u5f00 GUI \u9a71\u52a8\u5f15\u64ce\n\n`tools/drive.mjs` \u8c03\u7528\u7684\u662f\u89c6\u56fe\u8c03\u7528**\u540c\u4e00\u6279** `onPanelInvoke` \u901a\u9053\uff0c\u53ea\u662f\u7ed9\u4e86\u4e2a\u6869 `pi` \u5168\u5c40\uff0c\n\u6240\u4ee5\u4e00\u6761\u6d41\u7a0b\u53ef\u4ee5\u4ece\u7ec8\u7aef\u6216\u6d4b\u8bd5\u811a\u672c\u91cc\u8dd1\u901a\uff1a\n\n```bash\nnode tools/drive.mjs /path/to/repo git/repo\nnode tools/drive.mjs /path/to/repo git/stage '{\"paths\":[\"src/app.js\"]}'\nnode tools/drive.mjs /path/to/repo git/commit '{\"message\":\"Fix the thing\"}'\n```\n\n\u7528**\u524a\u51cf\u8fc7\u7684\u73af\u5883**\u8dd1\u5b83\uff0c\u624d\u548c\u771f\u5b9e\u63d2\u4ef6\u8fdb\u7a0b\u62ff\u5230\u7684\u4e1c\u897f\u4e00\u81f4\u2014\u2014`PATH`\u3001`LANG` \u548c\u4e34\u65f6\u53d8\u91cf\uff0c\u4f46**\u6ca1\u6709 `HOME`**\uff1a\n\n```bash\nenv -i PATH=\"$PATH\" TMPDIR=\"${TMPDIR:-/tmp}\" node tools/drive.mjs . git/repo\n```\n\n\u8fd9\u4e48\u8dd1\u7b49\u4e8e\u590d\u73b0\u4e86\u771f\u5b9e\u63d2\u4ef6\u8fdb\u7a0b\u7684\u73af\u5883\uff0c\u6240\u4ee5\u63a8\u9001\u8def\u5f84\u4e5f\u80fd\u5728**\u4e0d\u52a0\u7ec8\u7aef**\u7684\u60c5\u51b5\u4e0b\u5bf9\u7740\u4efb\u610f\u8fdc\u7aef\u8bd5\u2014\u2014\n\uff08\u6ce8\u610f harness \u91cc\u5168\u90e8\u8fdc\u7aef\u90fd\u662f\u672c\u5730\u88f8\u4ed3\uff0c\u6ca1\u6709\u771f\u7684\u5f80 HTTPS \u8fdc\u7aef\u63a8\u8fc7\uff1b\u8981\u9a8c\u8bc1\u771f\u5b9e\u6258\u7ba1\uff0c\u8bf7\u81ea\u5df1\u6311\u4e00\u4e2a\u8fdc\u7aef\u8dd1\u4e0a\u9762\u8fd9\u6761\u547d\u4ee4\u3002\uff09\n\n\u6a21\u578b\u76f8\u5173\u7684\u4e24\u4e2a\u901a\u9053\u6709\u6869\uff1a`PIG_FAKE_MODELS`\uff08\u503c `denied` \u4f1a\u8fd4\u56de `PERMISSION_DENIED`\uff09\u3001\n`PIG_FAKE_COMPLETE`\uff08`RATE_LIMITED` / `error:\u2026`\uff09\u3001`PIG_SHOW_PROMPT=1` \u4f1a\u628a\u771f\u6b63\u53d1\u51fa\u53bb\u7684\u63d0\u793a\u8bcd\u6253\u51fa\u6765\u3002\n\n### \u6ca1\u6709\u5bbf\u4e3b\u4e5f\u80fd\u770b\u89c6\u56fe\n\n`tools/smoke.mjs` \u628a\u6784\u5efa\u597d\u7684\u89c6\u56fe\u914d\u4e0a\u6869 `window.pluginBridge` \u5199\u5230 `.smoke/`\uff0c\n\u4e8e\u662f\u90a3\u4e9b\u96be\u4ee5\u6309\u9700\u5236\u9020\u7684\u72b6\u6001\uff08\u4ed3\u5e93\u5361\u5728\u5408\u5e76\u4e2d\u3001\u4e00\u6b21\u88ab\u8fdc\u7aef\u62d2\u7edd\u7684\u63a8\u9001\u3001\u4e00\u4e2a\u5df2\u68c0\u51fa\u7684\u5b50\u6a21\u5757\u4f5c\u4e3a\u5f53\u524d\u4ed3\u5e93\uff09\u53ef\u4ee5\u6e32\u67d3\u51fa\u6765\u770b\uff1a\n\n```bash\nnode tools/build.mjs && node tools/smoke.mjs commit zh-CN\n```\n\n\u89c6\u56fe\u540d\u4e0e\u8bed\u8a00\u90fd\u662f\u4f4d\u7f6e\u53c2\u6570\uff08\u4ea7\u7269\u662f `.smoke/<\u89c6\u56fe>-<\u8bed\u8a00>.html`\uff09\u3002\u6869\u5728\u5de5\u5177\u91cc\uff0c\u4e0d\u5728\u63d2\u4ef6\u91cc\uff0c`.smoke/` \u5df2 gitignore\u3002\n\n## \u5f00\u53d1\u4e0e\u6253\u5305\n\n1. \u6269\u5c55\u9875\u53f3\u4e0a\u89d2 **`\u00b7\u00b7\u00b7` \u2192 \u300c\u52a0\u8f7d\u672c\u5730\u63d2\u4ef6\u300d** \u2192 \u9009\u8fd9\u4e2a\u76ee\u5f55\u3002\n2. \u6539 `src/`\uff0c\u8dd1 `node tools/build.mjs`\uff0c\u89c6\u56fe\u4f1a\u70ed\u91cd\u8f7d\u3002\n3. \u7ec4\u88c5\u5e72\u51c0\u7684\u53d1\u5e03\u76ee\u5f55\u518d\u6253 Pack\uff1a`node tools/build.mjs && node tools/make-publish-dir.mjs`\uff0c\u7136\u540e\u5bf9 `dist-publish/io.github.liushunqiu.pi-idea-git/` \u8dd1 `PluginCheck` / `PluginPack`\uff08\u4ea7\u7269\u5728\u8be5\u76ee\u5f55\u7684 `dist/*.piplug`\uff0c8 \u4e2a\u6587\u4ef6\u7ea6 0.9MB\uff09\u3002**\u4e0d\u8981\u76f4\u63a5\u5bf9\u4ed3\u5e93\u6839\u6253 Pack**\u2014\u2014\u5b83\u4f1a\u628a `.smoke/`\u3001`.memories/`\u3001`tools/`\u3001`src/` \u7b49\u5f00\u53d1\u6587\u4ef6\u4e00\u8d77\u6253\u8fdb\u53bb\uff08`PluginPack` \u4e0d\u8bfb `.gitignore`\uff0c\u4e14\u6ca1\u6709\u6392\u9664\u6587\u4ef6\u673a\u5236\uff0c\u5b9e\u6d4b 2.1MB / 46 \u6587\u4ef6\uff09\u3002\n \u4ece\u8001 ID `local.pi-idea-git` \u5347\u7ea7\u4e0a\u6765\u7684\u7528\u6237\uff0c\u63d0\u4ea4\u4fe1\u606f\u5386\u53f2\u4e0e\u89c6\u56fe\u9009\u9879\u4e0d\u4f1a\u8fc1\u79fb\uff08\u63d2\u4ef6\u6570\u636e\u76ee\u5f55\u6309 ID \u9694\u79bb\uff09\u3002\n\n\u8d28\u91cf\u5e95\u7ebf\uff08\u6539\u5b8c\u4ee3\u7801\u8fc7\u4e00\u904d\uff09\uff1a`node --check` \u6bcf\u4e2a `src/*.js`\u3001`PluginCheck` \u65e0 error\u3001`node tools/harness.mjs` \u5168\u7eff\u3002\n\n> **\u6269\u5927 `permissions` \u9700\u8981\u6bd4\u300cReload\u300d\u6309\u94ae\u66f4\u957f\u7684\u8def\u5f84\u3002**\n> \u4fdd\u5b58\u89e6\u53d1\u7684\u70ed\u91cd\u8f7d\u4f1a\u62d2\u7edd\u4e00\u4e2a\u300c\u8981\u7684\u6743\u9650\u591a\u4e8e\u8fd0\u884c\u5b9e\u4f8b\u5df2\u83b7\u6279\u7684\u300d\u6e05\u5355\u3002\u5361\u7247\u4e0a\u7684 **Reload** \u6309\u94ae\u4e5f\u4e0d\u591f\uff1a\n> \u5b83\u628a\u65b0\u6e05\u5355\u4e0e `registry.json` \u91cc\u8bb0\u5f55\u7684\u6743\u9650\u6c42\u4ea4\u96c6\uff0c**\u9759\u9ed8\u4e22\u6389\u65b0\u589e\u7684\u90a3\u4e9b\uff0c\u5374\u7167\u6837\u62a5\u544a\u6210\u529f**\u3002\n> \u53ea\u6709 **`\u00b7\u00b7\u00b7` \u2192 \u300c\u52a0\u8f7d\u672c\u5730\u63d2\u4ef6\u300d**\uff08\u91cd\u65b0\u9009\u4e00\u6b21\u76ee\u5f55\uff09\u4f1a\u91cd\u8bfb\u6e05\u5355\u5e76\u91cd\u65b0\u5f81\u6c42\u540c\u610f\u3002\n\n\u5bbf\u4e3b\u7248\u672c\u8981\u6c42\u5199\u5728\u540c\u4e00\u4efd\u6e05\u5355\u91cc\uff1a`engines.piDesktop >= 0.8.0`\u3002\n\n## \u5df2\u77e5\u7f3a\u53e3\n\n\u8bda\u5b9e\u6e05\u5355\u2014\u2014\u8fd9\u4e9b\u662f\u5f53\u524d\u5b9e\u73b0\u91cc\u786e\u5b9e\u5b58\u5728\u3001\u4e14\u7528\u6237\u53ef\u80fd\u649e\u4e0a\u7684\u9650\u5236\uff1a\n\n - **\u6587\u672c\u641c\u7d22\u662f\u672c\u5730\u8fc7\u6ee4**\uff1a\u5de5\u5177\u680f\u641c\u7d22\u6846\u53ea\u5728\u5df2\u53d6\u56de\u7684 200 \u6761\u5185\u505a substring \u5339\u914d\uff1b\u8981\u6309\u63d0\u4ea4\u4fe1\u606f\u5728\u670d\u52a1\u7aef\u67e5\uff0c\u7528\u5206\u652f\uff0f\u7528\u6237\uff0f\u65e5\u671f\uff0f\u8def\u5f84\u8fc7\u6ee4\uff08\u5b83\u4eec\u90fd\u8fdb `git log` \u53c2\u6570\uff09\u3002\n- **Console \u4e0d\u662f\u300c\u6bcf\u6761\u547d\u4ee4\u90fd\u8bb0\u300d**\uff1a\u63a2\u6d4b\u578b\u547d\u4ee4\u6210\u529f\u65f6\u4e0d\u8bb0\u5f55\uff08\u89c1\u300c\u5f15\u64ce\u8bbe\u8ba1\u300d\uff09\uff0c\u5931\u8d25\u65f6 short/detail \u90fd\u6709\u957f\u5ea6\u4e0a\u9650\u3002\n- **hunk \u7ea7\u64cd\u4f5c\u7684\u9002\u7528\u9762**\uff1a`Ignore whitespaces` \u6253\u5f00\u65f6\u7981\u7528\uff0c\u672a\u8ddf\u8e2a\u6587\u4ef6\u7981\u7528\uff08\u6574\u6587\u4ef6 `Add` \u4ecd\u53ef\u7528\uff09\u3002\n- **`Edit Commit Message` \u53ea\u5bf9 HEAD \u5f00\u653e**\u3002\n- **Commit \u89c6\u56fe\u6587\u4ef6\u83dc\u5355\u7684\u590d\u5236\u9879**\uff1a\u6709\u63d0\u4ea4\u65f6\u663e\u793a\u300cCopy Revision Number\u300d\u5e76\u590d\u5236 HEAD\uff08\u4e0e Log \u89c6\u56fe\u4e00\u81f4\uff09\uff0c\u5c1a\u65e0\u63d0\u4ea4\u65f6\u663e\u793a\u300c\u590d\u5236\u8def\u5f84/Copy Path\u300d\u5e76\u590d\u5236\u6587\u4ef6\u8def\u5f84\u3002\n- **\u4e24\u5904\u300c\u56de\u6eda\u300d\u540c\u540d\u4e0d\u540c\u4e49**\uff1a\u63d0\u4ea4\u4e0b\u62c9\u91cc\u7684\u300c\u56de\u6eda\u300d\u628a\u5df2\u6682\u5b58\u5185\u5bb9\u79fb\u51fa\u7d22\u5f15\uff08`git/unstage`\uff09\uff0c\u6587\u4ef6\u53f3\u952e\u91cc\u7684\u300c\u56de\u6eda\u300d\u4f1a**\u4e22\u5f03\u6539\u52a8**\uff08`git/discard`\uff0c\u672a\u8ddf\u8e2a\u6587\u4ef6\u76f4\u63a5\u5220\u9664\uff09\u3002\u540c\u540d\uff0c\u4f46\u5371\u9669\u7b49\u7ea7\u4e0d\u540c\u2014\u2014\u6309\u4e0b\u53bb\u4e4b\u524d\u5148\u770b\u5b83\u95ee\u7684\u662f\u4ec0\u4e48\u3002\n- **\u751f\u6210\u7684 HTML \u56fa\u5b9a `lang=\"en\"`**\uff0c\u4e0e\u754c\u9762\u8bed\u8a00\u65e0\u5173\u3002\n- **\u6267\u884c\u8fb9\u754c**\uff1a\u5355\u6761\u547d\u4ee4 25 \u79d2\u540e\u88ab\u6740\uff1bConsole \u53ea\u7559\u6700\u8fd1 200 \u6761\u3001\u65e5\u5fd7\u4e00\u6b21\u6700\u591a\u53d6 500 \u6761\u3001\u9519\u8bef\u8be6\u60c5\u4e0e Console \u7684\u6bcf\u6761\u8f93\u51fa\u90fd\u622a\u5230 4000 \u5b57\u7b26\u2014\u2014\u8fd9\u4e9b\u622a\u65ad\u662f\u9759\u9ed8\u7684\u3002\u552f\u4e00\u4f1a**\u62a5\u51fa\u6765**\u7684\u662f stdout \u8d85\u8fc7 8 MiB\uff1a\u90a3\u6761\u547d\u4ee4\u88ab\u6740\u6b7b\u5e76\u62a5 `Git output exceeded the size limit.`\uff08stderr \u8d85\u9650\u5219\u662f\u9759\u9ed8\u505c\u6b62\u6536\u96c6\uff09\u3002\n- **\u5bbf\u4e3b\u8d85\u65f6**\uff1a\u4efb\u4f55\u4e00\u6b21\u9762\u677f\u8c03\u7528\u8d85\u8fc7 30 \u79d2\uff0c\u90fd\u4f1a\u4ee5\u5bbf\u4e3b\u4fa7\u8d85\u65f6\u7684\u5f62\u5f0f\u5931\u8d25\uff1b\u5f15\u64ce\u7684 25 \u79d2\u4e0a\u9650\u5c31\u662f\u4e3a\u4e86\u8ba9\u5931\u8d25\u5148\u53d1\u751f\u5728 Git \u8fd9\u4e00\u4fa7\u3002\n- **\u6d4b\u8bd5\u4e0d\u8986\u76d6\u771f\u5b9e\u5bbf\u4e3b**\uff1aharness \u4e0e smoke \u7528\u7684\u662f\u6869\uff0c`PluginCheck` \u53ea\u770b `main.js`\uff08\u4e8e\u662f\u89c6\u56fe\u7ecf\u6865\u8c03\u7528\u7684\u6743\u9650\u88ab\u8bef\u62a5\u4e3a unused\uff09\u3002\n\n## \u8bb0\u5fc6\u4e0e\u51b3\u7b56\u7b14\u8bb0\n\n\u4ed3\u5e93\u91cc\u4e24\u7c7b\u7b14\u8bb0\u90fd\u662f\u7eaf Markdown\uff0c\u968f\u5305\u4e00\u8d77\u8d70\uff1a\n\n- `.memories/` \u2014\u2014 \u6392\u67e5\u8fc7\u7a0b\u4e0e\u8fd0\u884c\u65f6\u4e8b\u5b9e\uff08\u63d2\u4ef6\u8fd0\u884c\u65f6\u7ea6\u675f\u3001\u6e32\u67d3\u8fdb\u7a0b CPU \u8bca\u65ad\u6cd5\u3001\u5bbf\u4e3b API \u4e8b\u5b9e\u3001\n push/pull \u51b2\u7a81\u3001\u5d4c\u5957\u4ed3\u5e93\u3001\u7b14\u8bb0 skill \u6f02\u79fb\uff09\u3002\n- `.agents/notes/implemented/architecture/` \u2014\u2014 \u56db\u7bc7\u843d\u5730\u51b3\u7b56\u7b14\u8bb0\uff1a\u4e3a\u4ec0\u4e48\u7528 Git CLI \u800c\u4e0d\u662f\u89e3\u6790 `.git/`\u3001\n \u4e3a\u4ec0\u4e48\u505a\u6210\u4e24\u4e2a docked view\u3001\u4e3a\u4ec0\u4e48\u52a0\u6784\u5efa\u6b65\u9aa4\u3001\u53d8\u66f4\u5217\u8868\u4e3a\u4ec0\u4e48\u6539\u6210\u76ee\u5f55\u6811\uff1b\n \u63d0\u4ea4\u4fe1\u606f\u63d0\u793a\u8bcd\u4e3a\u4ec0\u4e48\u628a\u8bed\u8a00\u505a\u6210\u663e\u5f0f\u53c2\u6570\uff1b\u8fdc\u7aef\u51b2\u7a81\u4e3a\u4ec0\u4e48\u5fc5\u987b\u8bfb\u4e24\u4e2a\u6d41\u3001\u4e3a\u4ec0\u4e48\u53ea\u80fd\u6709 `--force-with-lease`\uff1b\n \u4e3a\u4ec0\u4e48\u4ed3\u5e93\u5217\u8868\u662f\u4f1a\u8bdd\u7ea7\u7684\u3001\u4ee5\u53ca `kind` \u4e3a\u4ec0\u4e48\u7531 gitlink \u5224\u5b9a\u3002\n\n\u6838\u5fc3\u4ee3\u7801\u5165\u53e3\u7559\u4e86\u4e00\u884c `// Note: <\u4e3a\u4ec0\u4e48\u8fd9\u6837\u505a\u3001\u653e\u5f03\u4e86\u4ec0\u4e48> \u2014 \u89c1 .agents/notes/\u2026`\uff0c\u523b\u610f\u4e0d\u9010\u884c\u6807\uff1b\n\u51b3\u5b9a\u88ab\u53d6\u4ee3\u65f6\uff0c\u8fd9\u4e9b\u6ce8\u91ca\u5c31\u662f\u8981\u540c\u6b65\u7684\u4ee3\u7801\u6e05\u5355\u3002\n", + "safetyNotes": "\u53ea\u8bfb\u5de5\u4f5c\u533a\u6587\u4ef6\uff08Open File / Reveal \u8d70\u5bbf\u4e3b fs \u63a5\u53e3\uff09\uff1b\u4e22\u5f03\u672a\u8ddf\u8e2a\u6587\u4ef6\u65f6\u4f1a\u76f4\u63a5\u5220\u9664\u8be5\u6587\u4ef6\uff08\u64cd\u4f5c\u524d\u6709\u786e\u8ba4\u6846\uff09\uff0c\u5176\u4f59\u5199\u64cd\u4f5c\u4e00\u5f8b\u8d70 Git \u547d\u4ee4\u3002\u751f\u6210\u63d0\u4ea4\u4fe1\u606f\u8c03\u7528\u5bbf\u4e3b\u6a21\u578b\uff0c\u7528\u7684\u662f\u4f60\u81ea\u5df1\u7684\u6a21\u578b\u914d\u7f6e\u4e0e\u989d\u5ea6\uff08\u5bbf\u4e3b\u9650\u6d41 8 \u6b21/\u5206\uff09\uff0c\u63d2\u4ef6\u4e0d\u6301\u6709\u4efb\u4f55 API Key\uff0c\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\uff0c\u65e0\u9065\u6d4b\u3002HTTPS \u51ed\u636e\u8d70\u4f60\u81ea\u5df1\u7684 Git \u51ed\u636e\u52a9\u624b\uff1b\u53ea\u6d3b\u5728 ssh-agent \u91cc\u7684\u5bc6\u94a5\u7528\u4e0d\u4e86\uff0c\u8bf7\u7528 HTTPS \u8fdc\u7aef\u6216\u94a5\u5319\u4e32\u5bc6\u94a5\u3002", + "versions": [ + { + "version": "0.3.0", + "publishedAt": "2026-09-14T03:27:28Z", + "changelog": "Release 0.3.0: Git operations parity (Branches panel with HEAD/Tags/Remotes/Stashes groups and slash-folder collapsing; branch merge/rebase/rename/upstream/delete; commit copy/compare; toolbar user/date/path server-side filters, Go to HEAD, Fetch+Pull shortcuts); git/log server-side author/since/until/search/paths filters plus git/tags, git/remotes, git/merge, git/rebase and more; remote checkout creates a tracking branch (IDEA behavior); commit-message drafting follows the checked/staged scope.", + "minPiDesktop": ">=0.8.0", + "shasum": "fbf784997dfa00c617437bc3d90d30b1d818d1af37cfb0927c4e22c2b37701f5", + "url": "packages/io.github.liushunqiu.pi-idea-git-0.3.0.piplug", + "sizeBytes": 1057159, + "permissions": [ + "ui.panel", + "ui.view", + "clipboard.write", + "fs.read", + "models.list", + "agent.complete" + ], + "fs": { + "read": { + "root": "workspace", + "scope": [ + "**" + ] + } + } + } + ] + }, { "id": "io.github.muzimu217.deps-audit", "name": "Deps Audit", diff --git a/packages/io.github.liushunqiu.pi-idea-git-0.3.0.piplug b/packages/io.github.liushunqiu.pi-idea-git-0.3.0.piplug new file mode 100644 index 0000000..72119cf Binary files /dev/null and b/packages/io.github.liushunqiu.pi-idea-git-0.3.0.piplug differ diff --git a/plugins/io.github.liushunqiu.pi-idea-git/CHANGELOG.md b/plugins/io.github.liushunqiu.pi-idea-git/CHANGELOG.md new file mode 100644 index 0000000..130231d --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/CHANGELOG.md @@ -0,0 +1,42 @@ + # Changelog + + ## 0.3.0 — 2026-09-14 + + - Git 操作补齐(对标 IDEA 日志/分支面板,见 + `.agents/notes/implemented/feature/2026-09-14-git-operations-parity.md`): + Branches 面板加 HEAD 行、Tags/Remotes/Stashes 组与 `/` 文件夹折叠; + 分支右键补合并/变基/改名/upstream/删除;提交右键补复制提交信息/与 + HEAD 比较;工具栏补用户/日期/路径服务端过滤、Go to HEAD 与 Fetch+Pull + 快捷按钮;提交视图分支弹层跟随。`git/log` 支持 author/since/until/ + search/paths;新增 `git/tags`、`git/remotes`、`git/merge`、`git/rebase`、 + `git/branch-delete`、`git/branch-rename`、`git/branch-upstream`、 + `git/tag-delete`、`git/tag-push`、`git/stash-show`、`git/compare`, + `git/fetch` 支持 `prune`。harness 198 条全绿(新增 34 条行为回归)。 + - 远端分支检出改为 IDEA 行为:`git/checkout` 加 `track` 意图位,建本地跟踪分支 + 并附着 HEAD(已存在则落到本地同名分支;裸 revision 仍 detach)。 + - 生成提交信息改按已勾选/已暂存范围取 diff,多仓聚合一次描述,与实际提交一致; + 日志提交者过滤支持指定人(名单按提交数排序加手输)。harness 208 条全绿。 + + +## 0.2.0 — 2026-09-12 + +首个对外发布版本。发布 ID 为 `io.github.liushunqiu.pi-idea-git` +(开发期曾用 `local.pi-idea-git`;插件数据目录按 ID 隔离, +改名后提交信息历史与视图选项从空开始)。 + +- 提交 / Git 双工具窗口:Staged / Unstaged / Unversioned Files / + Merge Conflicts 分组目录树,hunk 级暂存、取消暂存与丢弃, + 图形化 Log、分支面板、储藏与 Console。 +- 多仓库聚合:子模块与嵌套仓库统一显示、逐仓提交; + Push 对话框逐仓显示去向并支持 Push All。 +- 远端同步:推送被拒 / 分叉 / 冲突的分类补救说明; + force push 只允许 `--force-with-lease`。 +- 宿主模型生成提交信息:语言菜单(跟随仓库历史 / 简体中文 / English), + 回复落框前强制整形(空行、行宽、项目符号)。 +- 在 PI-Desktop 0.14.6 上验证;`engines` 声明 `>= 0.8.0`。 + +First public release under `io.github.liushunqiu.pi-idea-git` +(renamed from the dev id `local.pi-idea-git`; per-plugin prefs start fresh). +Commit/Git tool windows, multi-repo aggregation, classified push/pull +remedies with lease-only force push, and host-model commit-message drafting. +Verified on PI-Desktop 0.14.6. diff --git a/plugins/io.github.liushunqiu.pi-idea-git/LICENSE b/plugins/io.github.liushunqiu.pi-idea-git/LICENSE new file mode 100644 index 0000000..5ae4de0 --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 liushunqiu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/io.github.liushunqiu.pi-idea-git/README.md b/plugins/io.github.liushunqiu.pi-idea-git/README.md new file mode 100644 index 0000000..07cb7e4 --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/README.md @@ -0,0 +1,527 @@ +# IDEA Git —— PI-Desktop 的 IntelliJ IDEA 风格 Git 工具窗口 + +`io.github.liushunqiu.pi-idea-git` 把 IntelliJ IDEA 的 Git 界面搬进 PI-Desktop:你真的会一直待在里面的那两个 +工具窗口、暂存/未暂存分组、按代码块(hunk)暂存、图形化日志与 Console。 + +这里的每一个 Git 动作都是**真的去调 `git`**,对着真实的索引干活。没有任何功能是在渲染层 +假装出来的,所以终端里的 `git status` 永远和面板显示一致。 + +—— 以下内容按「能看到什么 → 怎么用 → 为什么这样实现 → 怎么改」排列。 + +| 章节 | 内容 | +| --- | --- | +| 界面与功能 | 两个视图、Changes 区、diff 面板、Log/Console 全部能力表 | +| 快捷键 | 绑定表,以及「焦点在哪决定谁能收到按键」 | +| 与 IDEA 的有意差异 | 哪些地方刻意不照抄 | +| 权限 | 6 项权限各自为什么需要 | +| 工程结构 | 源码布局、为什么必须构建、生成物为什么不能手改 | +| 引擎设计 | 视图如何与 Git 通信、环境补齐、状态与补丁的关键取舍 | +| 一个项目里的多个仓库 | 子模块与嵌套仓库的识别、Root 选择器与其边界 | +| 远端同步 | 推送被拒、分叉、冲突、以及未完成合并的出路 | +| 认证 | 为什么插件不需要凭据,以及唯一做不到的事 | +| 生成提交信息 | 用宿主模型起草,语言/格式/范围如何处理 | +| 配色 | 哪些取自 JetBrains 文档,哪些是刻意的偏离 | +| 测试与工具 | harness / smoke / drive / 决策笔记门禁 | +| 开发与打包 | 加载本地插件、构建、打包含权限扩张的坑 | +| 已知缺口 | 诚实清单:哪些控件无效、哪些能力受限 | +| 记忆与决策笔记 | `.memories/`、`.agents/notes/` 与反向索引 | + +## 界面与功能 + +工作面板里有两个 docked 视图,对应 IDEA 的两个工具窗口: + +| 面板 | 对应 IDEA | 内容 | +| --- | --- | --- | +| **提交**(`views/commit.html`) | Commit 工具窗口,`Alt+0` | Changes 区(Staged / Unstaged / Unversioned Files / Merge Conflicts)、选中文件的 diff、带历史的提交信息框、Amend、Sign-off、Commit、Commit and Push | +| **Git**(`views/git.html`) | Git 工具窗口,`Alt+9` | **Log** 页签(分支面板、带图形与引用徽标的提交列表、changed files、commit details)与 **Console** 页签 | + +> `Alt+0` / `Alt+9` 是 IDEA 自己的绑定,插件**没有**注册热键(`manifest.json` 里没有 +> `keybindings` 段);这里只是告诉你它们在 IDEA 里对应什么。 + +命令面板里另有两条命令:`IDEA Git: Open as a separate window`(把两个窗口并到一个标签栏里, +宽屏用,加载 `renderer/index.html`)与 `IDEA Git: Refresh repository`。插件在启动时激活 +(`activationEvents: onStartup`)。 + +### 功能对照表(用 IDEA 自己的说法) + +| 功能 | 位置 | 说明 | +| --- | --- | --- | +| `Staged` / `Unstaged` / `Unversioned Files` / `Merge Conflicts` | 提交 → Changes 区 | 按**目录树**分组,每个文件落在它所属的文件夹下。文件夹的勾选是级联的:勾上会把其下所有文件(含更深层目录)加入索引,取消则整棵子树移出索引。同时有已暂存与未暂存改动的文件会**同时出现在两组里**——Git 自己就是这么报的 | +| 视图选项(齿轮) | 提交 → Changes 头部 | `Group by Directory`(目录树)与平铺模式切换(关掉它就是平铺)、`Compact Middle Directories`(`src/views/app` 合成一行)、`Collapse All`、以及「全部加入索引 / 全部移出索引」两个动作。两个视图选项都会持久化 | +| 过滤变更 | 提交 → Changes 头部 | 把树收窄到匹配的文件、并保留其祖先目录;分组标题显示 `matched/total`。`Esc` 清空 | +| `Commit Message` + 提交信息历史 | 提交 → 底部 | 时钟按钮回放之前写过的信息,最新在前,跨重启保留(本地无历史时给提示,菜单底部有 `Clear All`) | +| `Generate Commit Message` | 提交 → 底部 | 用**宿主自己的模型**起草信息。描述当前选中的文件;没选文件时描述所有已暂存内容;箭头菜单用来挑模型 | +| `Amend`、`Sign-off commit` | 提交 → 底部 | `--amend`、`--signoff` | +| `Commit`、`Commit and Push` | 提交 → 底部 | 拆分按钮;下拉里还有 Amend / Sign-off / 回滚(把已暂存内容全部移出索引) | +| 每个代码块的 `Include into commit` | 提交 → diff 面板 | **局部提交**。每个 hunk 带一个勾选框;切换它时用重建出来的补丁跑 `git apply --cached`(或 `-R`),于是只有被选中的 hunk 进入这次提交 | +| `Stage Hunk` / `Discard Hunk` / `Unstage Hunk` | 提交 → diff 面板 | 鼠标悬停到工作区 diff 的 hunk 头时出现;已暂存 diff 上则是反向的 `Unstage Hunk` | +| hunk 头上的附注 | 提交 → diff 面板 | 纯空白 hunk 标 `· whitespace only`;子模块指针变化标 `· submodule abc1234 → def5678` | +| `Show Diff`、`Rollback`、`Add`/`Remove from index`、`Open File`、`Reveal in File Manager`、`Copy Hash` | 提交 → 文件右键菜单 | `Rollback` 会先确认;它会删除未跟踪文件、还原已跟踪文件 | + | 分支控件(`main ↑2 ↓1`) | 两个工具栏 | 分支名、领先/落后,点开是本地/远端分支列表,点一行即 checkout;`New Branch`、`Merge into Current…`、`Rebase Current onto…`、`Rename`、`Set/Unset Upstream`、`Delete…`、`Fetch`、`Pull`、`Push`、`Force Push (with lease)`、`Stash Changes` 与储藏列表(Apply/Pop/Drop)。(分支的**完整右键动作菜单在 Git 视图的 `Branches` 面板**里,工具栏弹层是常用子集) | +| 仓库控件(`mod · submodule`) | 两个工具栏 | 列出工作区仓库以及嵌套在它里面的每一个仓库——子模块、以及恰好住在这里的克隆——选中它就把整个工具窗口对准那个仓库。只有一个仓库时(单仓库项目)隐藏 | +| `Log`、`Console` | Git → 页签 | Console 显示插件跑过的命令与输出,失败标红,可 `Clear All`(见下方「已知缺口」:探测型命令成功时不记录) | +| 提交图 | Git → Log | 泳道由父提交信息算出;分支尖端黄色、本地分支绿色、远端紫色、标签灰色。自己的提交 subject 加粗,当前分支的行有底色 | + | `Branches` 面板 | Git → Log → 左侧 | `HEAD`(点一点回到当前提交)/`Local branches`/`Remote branches`(`feature/*` 这类按 `/` 折成可展开的文件夹;点远端分支会自动建本地跟踪分支,不再是 detached HEAD)/`Tags`(点一点按该标签过滤日志)/`Remotes`(Fetch / Fetch+Prune / 复制地址)/`Stashes`(Show / Apply / Pop / Drop)。分支右键:`Checkout`、`New Branch from Here`、`Merge into Current`、`Rebase Current onto Selected`、`Rename`、`Set/Unset Upstream`、`Delete`(未完全合并走二次确认)、`Copy Branch Name`、`Push`、`Fetch` | +| `Changed Files`、`Commit Details` | Git → Log → 列表下方 | Details 显示哈希、作者、日期、subject 与完整正文 | +| `Graph Options` | Git → 工具栏 | `By commit date` / `Topologically`、`Show First Parent`、`No Merges`、`Show Graph`、各面板开关 | + | 过滤 | Git → 工具栏 | 文本搜索(本地 substring)、分支过滤、用户过滤(全部/只看我的)、日期过滤(全部/24 小时/一周/一月)、路径过滤(服务端 `git log -- `,输入防抖 450ms、回车立即刷) | + | 提交动作 | Git → Log → 右键提交 | `Show Diff`、`Copy Revision Number`、`Copy Message`、`Compare with HEAD`、`Checkout Revision`、`New Branch from Here`、`New Tag`、`Cherry-Pick`、`Revert`、`Reset Current Branch to Here`(soft/mixed/hard)、`Edit Commit Message`(仅 HEAD) | +| `Side-by-side viewer` / `Unified viewer` | diff 面板头部 | 两者渲染同一份解析好的 hunk | +| `Ignore Differences` → `None` / `Ignore whitespaces` | diff 面板齿轮 | 由 Git 自己实现(`git diff -w`),不是把行藏起来。开着它时**禁用 hunk 暂存**,因为忽略空白的 diff 不是合法补丁 | +| `Show Whitespaces` | diff 面板眼睛图标 | 空格渲染成 `·`,制表符渲染成 `→` | +| `Show Line Numbers` | diff 面板齿轮 / commit diff 浮层头部 | 行号栏。提交视图默认**关**、且这个开关会被记住;commit diff 浮层默认**开**,但每次打开都回到默认(浮层不持久化) | +| `Collapse Unchanged Fragments` | diff 面板 | 长的上下文会折叠成一行可点击的条(`⋮ N 行未修改`)。只在**统一视图**里折叠,并排视图不折 | +| 高亮差异 | diff 面板 | **词级**:行中间变化的部分被标出来,于是 `1.0.0` → `1.1.0` 只标出那一个不同的字符 | +| 提交区状态行 | 提交 → 提交按钮上方 | 三种状态:进行中的操作(带 `Continue`/`Abort`)、冲突提示、`Nothing is staged` 警告,常规时显示「将提交 N / M 个文件」 | +| Console 细节 | Git → Console | 每条命令带 `[hh:mm:ss]` 时间戳、右上角显示条数、独立的 Refresh、空态提示 | +| 工具栏右侧 | 两个工具栏 | 当前仓库/工作区目录名 | + +`Ctrl+F5` 的行为——「每个动作之后都刷新」——是内建的:任何写入动作之后都会刷新状态、diff 与列表。 + +## 快捷键 + +宿主在主窗口渲染层跑应用自己的快捷键,而插件视图是独立的 `WebContentsView`——所以**焦点在视图里时,宿主收不到按键**。 +结论:**凡是按钮广告出来的键,都必须在页内自己绑定**,下面这些就是: + +| 动作 | macOS | Windows / Linux | +| --- | --- | --- | +| 刷新 | `⌘R` | `Ctrl+R` | +| 刷新(IDEA 的绑定) | `⌃F5` | `Ctrl+F5` | +| 加入索引 | `⌘⌥A` | `Ctrl+Alt+A` | +| 回滚(先确认) | `⌘⌥Z` | `Ctrl+Alt+Z` | +| Show Diff | `⌘D` | `Ctrl+D` | +| 提交信息(提交视图) | `⌘K` | `Ctrl+K` | +| 搜索日志(Git 视图) | `⌘F` | `Ctrl+F` | +| 关闭对话框 / 菜单 / diff | `Esc` | `Esc` | +| 在提交列表中移动 | `↑` `↓` | `↑` `↓` | + +提示文案由绑定的同一份规格生成(`PIG.formatShortcut`),所以**不可能出现「写了却按不出」的提示键**—— +这是个值得在设计上排除的错误,因为它直到有人去按才会暴露。 + +代码里还绑了:提交行上 `Enter` / `Space` 选中该提交;对话框里 `Enter` 确认;弹出菜单打开时自动聚焦第一个可选项; +`⌘⇧P` 会弹一句提示(见下)。 + +### 哪个键在哪里有效 + +宿主的加速键与插件的绑定住在不同地方,所以「同一个键干什么」取决于焦点在哪。值得精确知道: + +| 按键 | 应用内(焦点不在视图里) | 本插件视图内 | +| --- | --- | --- | +| `⌘K` / `Ctrl+K` | 应用的**会话搜索** | **提交视图**:聚焦提交信息框。**Git 视图**:无(应用的搜索在这里够不到) | +| `⌘⇧P` / `Ctrl+Shift+P` | 应用的**命令面板** | 没用——按键到不了应用,所以视图会明确告诉你该去哪(`Alt+Space`) | +| `Alt+Space` | 插件启动器 | **一样可用——它是真正的全局快捷键** | +| `⌘R` / `Ctrl+R` | 重新加载 | 刷新仓库 | +| `⌘D`、`⌘⌥A`、`⌘⌥Z`、`⌘F`、`Esc`、`↑` `↓` | 应用自己的含义 | 插件的含义,见上表 | + +所以:**要跑应用级命令(包括 `IDEA Git: Open as a separate window`),按 `Alt+Space`,或者点回应用里用 `⌘⇧P`。** +现在在视图里按 `⌘⇧P` 会弹一句说明,而不是毫无反应。 + +## 与 IDEA 的有意差异 + +- **没有编辑器。** IDEA 把 diff 开在编辑器标签页里;这里提交的 diff 开在 Git 工具窗口内的浮层里。 + `Jump to Source` 换成了 `Open File`(交给系统默认程序打开)与 `Reveal in File Manager`。 +- **没有 changelist。** IDEA 的 changelist 是 IDE 侧概念,Git 里没有对应物;Staged/Unstaged 的分组取代了它。 + 一份工作副本,一份变更集。 +- **没有 shelf。** `Stash` 覆盖 Git 原生的那部分场景。 +- **并排视图不联动滚动。** 左右两栏作为一个网格一起横向滚动,于是两栏永远在同一个偏移上。 +- **`Edit Commit Message` 只对尖端提交开放**,因为改写更早的提交需要 rebase。 +- **`Ignore whitespaces` 模式下禁用 hunk 暂存**,未跟踪文件也禁用——忽略空白的 diff、以及对着 `/dev/null` 的 diff, + 都不是 Git 会接受的补丁。整文件的 `Add` 仍然可用。 +- **删除行是红色,不是 JetBrains 的灰色**,暗色 diff 配色是调出来的而非照抄的。见「配色」。 + +## 权限 + +| 权限 | 为什么 | +| --- | --- | +| `ui.panel` | 独立窗口(`renderer/index.html`) | +| `ui.view` | 两个 docked 视图 | +| `clipboard.write` | `Copy Revision Number`、`Copy Hash` 这类复制动作 | +| `fs.read` | 对变更文件执行 `Open File` 与 `Reveal in File Manager` | +| `models.list` | 生成按钮上的模型选择菜单 | +| `agent.complete` | 用宿主模型起草提交信息 | + +`PluginCheck` 会把 `clipboard.write` 与 `fs.read` 报成 unused,因为它只扫 `main.js`;这两个都是从视图经面板桥调的 +(`clipboard.writeText` 在 `src/common.js`,`fs.openDefault` / `fs.reveal` 在 `src/commit-view.js`),属误报。 + +`fs` 的读取范围声明为 `{"root":"workspace","scope":["**"]}`——只读工作区。 + +**唯一一处插件写工作区的地方**是丢弃未跟踪文件(或还原未跟踪文件的 hunk):引擎直接 `fs.rmSync` 删文件, +视图侧会先确认。除此之外不碰工作区的文件——所有变更都经过 `git`。 +(插件另有一处写盘,写的是它**自己**的数据目录:`prefs.json`,存提交信息历史与视图选项,不落在仓库里。) + +## 工程结构 + +``` +main.js Node 进程:Git 引擎 + 通道路由(约 2600 行) +src/theme.css IDEA 调色板与组件样式 ─┐ +src/layout.css 布局与响应式 ─┤ +src/common.js 桥、i18n、图标、弹出菜单 ─┤ tools/build.mjs +src/diff.js 统一 diff 解析与渲染 ─┤ 内联进 +src/commit-view.js 提交工具窗口 ─┤ +src/git-view.js Log + Console,以及启动 ─┘ +views/commit.html 生成物——不要手改 +views/git.html 生成物——不要手改 +renderer/index.html 生成物——两个窗口并到一个标签栏 +tools/build.mjs 内联器 +tools/harness.mjs 引擎回归(真实仓库,136 条断言) +tools/drive.mjs 不开 GUI 直接驱动引擎 +tools/smoke.mjs 用桩 bridge 渲染视图到 .smoke/ +tools/notes.mjs 决策笔记门禁(+ tools/agent-notes/ 的 vendored 校验器) +manifest.json 清单:视图、命令、权限、engines +``` + +### 为什么必须有构建步骤 + +宿主用 `loadURL(pathToFileURL(entry))` 加载视图,也就是 `file://` URL,而 **Chromium 拒绝从 `file://` 加载 ES module** +(不透明源)。官方自带插件是靠「每个视图一个巨大的自包含 HTML」绕过去的;`tools/build.mjs` 从可读的源码产出同样的形状: + +```bash +node tools/build.mjs # 写出 views/*.html 与 renderer/index.html +``` + +改 `src/`,跑构建,正在运行的开发插件会热重载。直接改生成出来的 HTML 是白费力气——下次构建就覆盖了。 + +> 生成器固定写 `lang="en"`(`tools/build.mjs`),与界面语言无关。只影响无障碍语义与字体回退,不影响显示。 + +## 引擎设计 + +### 视图怎么跟 Git 说话 + +`main.js` 跑在专属 Node 进程里(Electron `utilityProcess`),所以它有真正的 Node API、能起 `git` 进程。 +视图是沙箱页面,只拿到 `window.pluginBridge`。宿主自己不实现的通道**全部**转发给插件导出的 `onPanelInvoke`—— +这正是自定义通道能工作的原因: + +``` +视图 --pluginBridge.invoke("git/…")--> 宿主 --> onPanelInvoke --> git +``` + +`onPanelInvoke` 里一共 **36 个 `git/*` 通道**;不认识的通道会明确回 `Unsupported channel`,而不是静默什么都不做。 + +### 两个环境事实决定了引擎的形状 + +- 插件进程只被交给 `PATH`、`LANG` 和临时目录变量,**没有 `HOME`**(Windows 上是 `USERPROFILE`), + 而 Git 需要它来读 `~/.gitconfig`(身份、凭据助手)。所以每次调用都自己补回去。**这是「零配置认证」的实现基础。** +- 没有终端,所以交互式提示被关掉(`GIT_TERMINAL_PROMPT=0`、`GIT_ASKPASS=echo`、`GIT_EDITOR=true`…), + 于是 `push` / `pull` 会**快速、可见地失败**,而不是对着一个没人能输入的密码框永远挂着。 + +顺带补上的还有 `GIT_OPTIONAL_LOCKS=0`(读操作不拿索引锁)、`GIT_PAGER=cat`、`GIT_CONFIG_NOSYSTEM` 兜底。 +`git` 可执行文件在 `PATH` 与一组标准安装路径里找,找不到会给一条明确的错误,而不是让每个动作都莫名失败。 + +### 命令的执行边界 + +单条命令 **25 秒**后 SIGKILL——刻意短于宿主的 30 秒面板超时,否则超时会以「面板无响应」的形式出现, +而看不到 Git 到底卡在哪。每条输出流上限 **8 MiB**,超出的部分是巨量日志,不是信息。 + +### 状态是怎么读的 + +状态用 `git status --porcelain=v2 -z` 读:`-z` 是唯一能**原样**返回路径的形式,所以空格与非 ASCII 文件名能活下来; +v2 把索引列与工作区列分开,而这两列正好就是界面显示的「已暂存 / 未暂存」。 +(只有一处例外:生成提交信息时为了避免重命名被重复描述,另跑了一次 v1 的 `--porcelain`。) + +### diff 与补丁 + +diff 用 Git 默认的 `a/`-`b/` 前缀产出,因为 `git apply` 会剥掉一个前导路径分量—— +**给 `git diff` 加 `--no-prefix` 会让 hunk 补丁被拒绝**,这是本仓库的一条硬性约定。 +回传给 Git 的补丁是「原始头部 + 被选中的 hunk」,这正是 `--recount` 能接受部分选择的原因。 + +补丁还带**来源仓库标识**:`git/apply-patch` 会校验 `root`,不符则回 `STALE_REPOSITORY`。 +因为 `discard` 会写工作树,一个「读出来、过一会儿再写回去」的通道必须能拒绝来自已切换仓库的旧补丁。 + +### Console 记什么 + +Console 是环形缓冲:**最多 200 条**,每条 stdout / stderr 各截到 **4000 字符**。 +探测型命令(`rev-parse`、`ls-files`、`for-each-ref`、`config`、`--version` 等)成功时**不记录**—— +它们每次刷新都跑,记下来只会把有用的输出冲掉。 + +### 失败信息是分层的 + +短消息只保留 6 行,并**剔除 `hint:` 行**——那些行是给终端读者的,界面改用本地化的补救说明; +但如果剔除后会一个字都不剩,就退回保留 hint(规则是「说点什么」,不是「来自 hint 的就一律不说」)。 +完整 stdout+stderr 走 `detail` 字段,由 toast 上的「详情」链接打开对话框查看(detail 也有 4000 字符上限,超出加省略号)。 + +引擎给每一类失败打**稳定的分类码**,由视图本地化成措辞:`authHint`(`ssh` / `credentials`)、 +`pushHint`(`remote-ahead` / `remote-rejected`)、`pullHint`(`diverged` / `conflicts`)。 +(`needsReconcile` 不是分类码,它是引擎内部用来判断「是否该补一次 `--no-rebase`」的谓词,视图看不到它。) +新增任何失败分支时,别把 Git 的原文丢掉,也别把整段塞进 toast。 + +### 相对路径与真实路径 + +路径比较一律按**真实路径**:Git 返回的是真实路径(macOS 上 `/private/var/…`),宿主给的是用户打开时的路径(`/var/…`)。 +用字符串前缀比较会把同一个目录判成两个,后果是**静默降级**(「打开文件 / 在访达中显示」被禁用),而不是报错。 +视图侧不要自己拼绝对路径,用引擎算好的 `repo.workspacePrefix`(`null` 有语义:仓库在工作区之上,不能折成 `"."`)。 + +## 一个项目里的多个仓库 + +一个项目常常不止一个仓库:一个已检出的子模块,或者一个恰好住在另一个仓库里的克隆。它们是**独立的仓库,不是文件夹**—— +父仓库只记录子模块*指向哪个提交*——所以父仓库的 `git status` 永远只给一行 gitlink,看不到子模块里面的文件。 +Commit 视图因此做成 IDEA 式的**聚合显示**:先按暂存/未暂存/冲突/未跟踪分大类,大类下按**仓库**排—— +每个有改动的仓库(父仓库参与字母排序)各占一行"颜色块 + 名 + N 个文件 + 分支徽标",文件再嵌在行下展开, +暂存、diff、hunk、回滚都直接可用; +提交按钮用同一提交信息逐仓提交(子模块先、父仓库后),父仓库随后出现的指针更新再提交一次即收敛。 +`Commit and Push` 把刚才提交的那 N 个仓逐个推出去,不再只推当前仓。 +仓库行右键"切换到该仓库"会跳到 Root 选择器的那个仓库(Log/分支/储藏仍按仓库切换查看)。 +推送走 IDEA 式的 **Push 对话框**:多仓时工具栏 Push 列出每个仓库的分支去向(`main → origin/main`)、`↑/↓` 与勾选框, +`Push All` 逐仓推送,单个失败不挡其他仓,行内显示成功/拒因;单仓时保持直接推送。Git 视图工具栏同样有 Push 入口。 +两个工具栏最左边的仓库控件仍然是切换当前仓库的东西:选中一个,视图背后的每条命令(状态、diff、日志、分支、储藏、提交、hunk 暂存) +就都在它里面跑。 + +工作区本身不是仓库、但文件夹里并排摆着多个仓库时(`repoA/`、`repoB/`),同样可用:仓库列表列出它们(`sibling repository` +徽标),Commit 视图聚合显示其他平级仓的改动,Push 对话框一次推完。空文件夹(内无仓库)仍报"不是 Git 仓库"。 + +列表由工作区仓库出发遍历构建,且**遍历是有边界的**:最多 4 层深、最多 5000 个目录,并且不下潜依赖/构建/缓存目录 +(`node_modules`、`vendor`、`Pods`、`.venv`… 共 17 个名字,含 `.git`)。 +但 `.gitmodules` 里**声明**的路径不受这些边界限制——那里的条目是关于项目的断言,而边界只是对项目规模的猜测。 +已声明但未检出的子模块不列出:没有工作树,就没得显示、也没得提交。 + +两个长得很像的仓库是**按父仓库的索引**区分的,不是按 `.gitmodules`:被父仓库记成 `160000` gitlink 的路径是**子模块** +(在那里暂存等于记录一个提交),其他都是**嵌套仓库**(父仓库不存它的任何文件)。界面上分别打 `submodule` / `nested repository` 徽标。 + +这个选择属于**打开的那个项目**,不写进 prefs:切项目、或者把目录移走,就退回工作区自己的仓库。 + +## 远端同步:推送被拒与合并冲突 + +被拒绝的推送是**常规情况,不是异常**:别人往同一个分支推了东西。Git 会在 stderr 上说明,并在 `hint:` 行里给出补救办法, +这两者都读——短消息里是 Git 自己那几行,上面那句分类好的补救说明是插件的: + +| 失败 | 插件怎么说 | +| --- | --- | +| 推送被拒(`fetch first`、`non-fast-forward`、`stale info`) | 远端有你没有的提交——先 Fetch,再 Pull,然后重新推送;如果该保留的是本地历史,用 Force Push | +| 推送被拒(`protected branch`、钩子) | 服务器拒绝了它;分支大概是受保护的 | +| 拉取卡在冲突上 | 在 Changes 区里解决,然后提交 | +| 分叉且 `pull.ff = only` | 先 merge 或 rebase,再推送 | +| 没有存下来的凭据(HTTPS) | 在终端里跑一次 `git push` 让凭据助手存下来,再重试 | +| `Permission denied (publickey)` | 换成 HTTPS 远端,或者让密钥不依赖 `ssh-agent`——宿主不传 `SSH_AUTH_SOCK` | + +三条引擎决策撑起这套行为: + +- **两个流都读。** `git push` 把拒绝写在 stderr,但 `git merge`(也就是 `git pull` 的后半段)把 `CONFLICT` 写在 stdout。 + 只读 stderr 会把一次冲突的 pull 渲染成一段「看起来像成功的 fetch 日志」。 + **判据是「失败时哪一行能指出下一步」,不是「哪个流更像错误流」。** +- **`git pull` 按配置跑,只在 Git 自己因缺策略而拒绝时重试一次。** 自从 2.27,在分叉分支上裸跑 `git pull` 是硬失败 + (`fatal: Need to specify how to reconcile divergent branches`)——于是被拒的推送让你跑的那条命令自己跑不起来。 + 只有这一个失败会被 `--no-rebase`(merge)重试一次,其余结果都是 Git 的。 + 这里不做策略推导,所以 `branch..rebase`、`pull.rebase = merges|interactive`、`pull.ff = only` 全部照常生效—— + 它们是被**第一次尝试**读到的,而不是被一份更差的 Git 优先级规则副本读到的。 +- **Force push 只能是 lease,不能是裸 force。** `--force-with-lease` 是插件唯一能发出的 force:如果远端已经不在这个窗口 + 上次看到的位置,它就会被拒绝,于是同事的推送永远不会被抹掉。远端与 ref 是位置参数,所以交给 Git 之前会先校验—— + `git push origin --force` 正是「未校验的 `-` 开头取值」会产出的东西。 + +**推送推到哪里,由分支追踪关系决定**,不是 `origin` + 本地分支名。这两者只在分支追踪同一处时才一致—— +一旦分支追踪别处,旧行为就会把新分支推到 `origin`,而 ↑/↓ 标签还在对着真正的 upstream 计数。 +完全没有 upstream 时回退范围很窄:`origin`,或者只有一个远端时就用它。 +有多个远端又没 upstream 时无从推断,于是拒绝推送并列出远端列表,而不是把分支发布到排序第一个的名字上去。 +首次推送可以直接建立追踪(追加 `--set-upstream`)。 +另外两种会明确拒绝的情况:分支追踪的是一个**本地分支**(名字里没有 `/`),以及**尚无提交**的分支。 + +### 离开一个未完成的合并 + +冲突的 `pull` 会把仓库留在合并中间,于是提交按钮上方的状态行会写出**进行中的操作**并带上它的出口: +四个操作都有 **Abort**,只有序列类的三个(cherry-pick / revert / rebase)有 **Continue**。 +merge **不进这个列表**:它没有需要继续的半成品状态,一次合并是靠**提交**来结束的——所以插件不给它 Continue +(顺带说明:`git merge --continue` 在终端里是存在的,只是这里用不上,因此不提供)。 +Continue 用 `core.editor=true` 跑,因为没有终端可以写提交信息,否则 Git 只会失败——用的是该操作已经记录好的那条信息。 + +这行在**冲突被暂存之后**仍然保留操作名,这才是重点:解决冲突会把 `u` 行变成普通索引行, +于是 porcelain 不再提它——而「把所有冲突都暂存了」正是 `--continue` 开始能够成功的时刻。 +把检查限制在「有冲突时」会藏起来插件自己创造的那个状态的唯一出口,所以探针在**有冲突 _或_ 刚才发现过操作**时都运行。 + +不认识的操作名不会被运行:引擎只接受它能报出的那四个名字,所以一个畸形的载荷不会变成一个任意的 `git` 动词。 + +### 配置配方 + +**GitHub,HTTPS** —— `gh auth login` 然后 `gh auth setup-git` 会把助手写进 `~/.gitconfig`。不需要别的。 + +**GitLab(任意主机),HTTPS** —— 让 Git 存一次: + +```bash +git config --global credential.helper osxkeychain # macOS(Linux 用 libsecret, + # Windows 用 manager) +git push # 输一次 token,此后都会被存下来 +``` + +自建 GitLab **不需要任何插件侧配置**,因为用的就是你 shell 用的那个凭据库。 + +**SSH** —— 无口令的密钥、或者口令存在 macOS 钥匙串里(`UseKeychain yes`)的密钥,可以直接用。 +只活在 `ssh-agent` 里的密钥不行,因为宿主不会把 `SSH_AUTH_SOCK` 交给插件进程;这类情况请用 HTTPS 远端,或把密钥加进钥匙串。 + +> 多账号配置:插件读的是你的凭据助手为该 URL 解析出的那个账号,所以 +> `credential..username` 和 `includeIf "gitdir:…"` 的规则在这里和终端里一样生效。 + +## 认证 + +**插件不持有任何凭据,也不需要。** 它跑的是用户自己的 `git`、带的是用户自己的 `HOME`, +于是它继承的正是终端已经在用的那套东西:同一个 `~/.gitconfig`、同一个凭据助手、同一份 `~/.ssh/config` 与密钥。 +shell 里什么能认证一次 `git push`,这里就能,在 GitHub、自建 GitLab 或任何别的地方——**没有任何按主机配置的东西**。 + +这种继承是刻意的。插件不该被托付 token,而一份「按主机填凭据」的表单也只能覆盖它认识的那些主机。 + +插件唯一做不到的事是**提问**。工具窗口背后没有终端,所以提示被关掉,命令会立刻失败,而不是永远挂在一个没人能输入的密码上。 +发生这种情况时,原始的 Git 错误会连同「该怎么办」一起显示(见上一节的表)。 + +## 生成提交信息 + +提交信息框旁边的闪光按钮会替你起草信息。它通过 `pi.agent.complete` 找**宿主**的模型, +所以用的就是你**已经**配好的 provider、模型与额度——插件不持有 API key,也不新增任何账号。 + +它发出去的是三样东西:变更本身、仓库近期提交的**subject 与正文样本**,以及这份样本测出来的东西—— +提交语言、subject 是否带 `type(scope):` 前缀、有没有人写正文。样本取**最近 40 条**提交。 +diff 截断在 **12 000 字符**:这是提示词,不是备份。 + +**语言。** 由闪光按钮旁的箭头菜单决定,因为「照着仓库写」不是一条语言策略: +一份全英文的历史会让中文读者也拿到英文草稿。选项是 +**跟随仓库历史**(默认:用实测语言,没有历史时回退到界面语言)、**简体中文**、**English**。 +无论哪种,`type` 与 `scope` 都保持 ASCII,就像 Conventional Commits 的各种语言译本做的那样—— +`feat(登录): 支持短信验证码`。 + +提示词按 Git 自己的惯例(git-commit(1)、Tim Pope、Chris Beams、Conventional Commits 1.0.0)写明硬性规则: +subject 后一个空行、目标 50 字符上限 72(中文约 25 字,因为 CJK 字形大致是两倍宽)、 +结尾不加句号、英文 subject 用祈使句、正文说 **why** 而不是复述 diff、 +`BREAKING CHANGE:` 是唯一允许它自行追加的 trailer。 + +**形状是被强制的,不是被请求的。** 提示词不能是格式的最后一道防线,因为要命的那个失败对写提示词的人是隐形的: +Git 把**第一个空行之前的每一行**都当成标题,所以一个把正文紧贴在 subject 下面的回复**根本没有正文**—— +它是一个巨大的 subject,而这正是读者口中的「提交信息不规范」。所以回复在进入信息框之前会过一遍 `formatCommitMessage`: + +- 先剥掉 ``` 代码围栏和 `commit message:` / `提交信息:` 这类前缀(模型很爱加); +- 保证 subject 是单行,并且无论模型怎么排,后面都恰好一个空行; +- 过长的 subject 会把尾巴**在从句边界**挪进正文,而不是截断,于是模型从 diff 里读到的信息不会丢; +- 把正文折到 Git 的 72 列,CJK 字形按终端那样算两列; +- 把分号串起来的从句拆成「一个想法一条」的项目符号; +- 当 `,` `;` 夹在中文字符之间时规范成 `,` `;`——而 ASCII 原样保留,所以 `feat(a,b): …` 不会被碰。 + +它不改写任何措辞,而且是**幂等**的:形状已经正确的草稿会原样通过。 + +**范围。** Changes 列表里有选中的文件时,它描述*那个文件*;没选中时描述*所有已暂存内容*。 +用了哪一种会在提示条里说明,所以范围永远不是猜的。两者都不存在时它会直说,而不是编一条信息出来。 + +**草稿就是草稿。** 它落在信息框里,可以照常编辑。如果你已经写了内容,它会先问一句再替换。 + +权限是 `models.list`(提供模型菜单)与 `agent.complete`(高风险——会花你的模型额度)。 +宿主把这件事限流到**每分钟 8 次**;插件把这个限制报出来,而不是盲目重试。 + +## 配色 + +浅色配色大部分抄自 JetBrains 自己的文档:新增行 `#c6e4c1`、修改行 `#e9eff9`、变化片段蓝 `#c6d7f0`、 +面板分隔线、编辑器边栏的变更条,以及文件状态色(新增 `#0a7700`、修改 `#0032a0`、未版本控制 `#993300` 等)。 +余下的界面饰件照着 IntelliJ Light 与 Darcula 的样子来,那些地方 JetBrains 没有公布数值。 + +**一个刻意的偏离,两套主题都有:** JetBrains 把删除行记成一个中性灰(浅色 `#D7D6D6`)。 +紧挨着绿色的新增,那个灰读起来像*褪色*而不是*被删掉*,所以删除用柔和的红色——浅色 `#f7d2d2`,暗色 `#452a2e`。 + +**暗色 diff 配色是调出来的,不是抄的。** 文档里的暗色值给出一种偏棕的行底色,而一块钢蓝色的片段补丁压在暗红行上会变成一团泥。 +所以暗色带自己的一套值,而且变化片段的颜色跟着它所在的行走:删除行上更红,新增行上更绿。 +`--diff-fragment-deleted` / `--diff-fragment-inserted` 是两个覆盖点(只在暗色主题里定义);浅色主题不设它们,保留 JetBrains 的蓝。 + +主题切换走 `document.documentElement.dataset.theme`,由 `app.getAppearance` 初始化、并订阅 `appearance:changed` 跟随宿主。 +全部都在 `src/theme.css` 顶部的 CSS 变量里,所以重新调一处就是改一行。 + +## 测试与工具 + +### 引擎回归 + +`tools/harness.mjs` 对着**当场造出来的真实仓库**驱动引擎——一个裸远端、一个直接初始化的工作副本加一个真正的 `git clone`、 +一次真实的双人分叉、一个内含子模块的子模块—— +并断言那些「拒绝」所依赖的行为:分类、消息、详情、先陈旧再 fetch 的 ahead/behind 标签、冲突的合并及其出口、 +一次推送落到哪里(以及被拒绝落到哪里)、陈旧的 lease 永远不会覆盖同事的工作; +最后几段还包括:嵌套在另一个仓库里的仓库会被列出、可以被选中、可以在里面提交、扫描能看多远、 +`.gitmodules` 的声明能推翻什么,以及一份来自「视图已经离开的仓库」的补丁会被拒绝而不是被应用。 + +```bash +node tools/harness.mjs # 实测输出:136/136 passed,全部对着真实 git +node tools/harness.mjs --keep # 保留临时仓库,便于事后翻看 +``` + +`PLUGIN_DIR` 可以把 harness 指向另一份检出。 + +### 决策笔记门禁 + +`.agents/notes/` 下的笔记承载「代码本身带不动的决定」——为什么是这个形状,以及它换掉了什么。 +一篇没人能找到的笔记、或者头块漂移了的笔记,是下一个读者(人或 agent)不会信任的笔记, +所以形状是**强制**的而不是约定俗成的: + +```bash +node tools/notes.mjs # 两项校验:目录/分类/相对链接 + 头块/Status/必需章节 +``` + +两个校验器逐字节 vendored 在 `tools/agent-notes/` 下,所以**无论机器上装没装那个 skill 都能跑**; +它们也由 `node tools/harness.mjs` 一起断言(136 条里有 2 条来自这里)。 +本机 Node 26 能直接执行 `.ts`,不需要 tsx 或任何 flag。 + +### 不开 GUI 驱动引擎 + +`tools/drive.mjs` 调用的是视图调用**同一批** `onPanelInvoke` 通道,只是给了个桩 `pi` 全局, +所以一条流程可以从终端或测试脚本里跑通: + +```bash +node tools/drive.mjs /path/to/repo git/repo +node tools/drive.mjs /path/to/repo git/stage '{"paths":["src/app.js"]}' +node tools/drive.mjs /path/to/repo git/commit '{"message":"Fix the thing"}' +``` + +用**削减过的环境**跑它,才和真实插件进程拿到的东西一致——`PATH`、`LANG` 和临时变量,但**没有 `HOME`**: + +```bash +env -i PATH="$PATH" TMPDIR="${TMPDIR:-/tmp}" node tools/drive.mjs . git/repo +``` + +这么跑等于复现了真实插件进程的环境,所以推送路径也能在**不加终端**的情况下对着任意远端试—— +(注意 harness 里全部远端都是本地裸仓,没有真的往 HTTPS 远端推过;要验证真实托管,请自己挑一个远端跑上面这条命令。) + +模型相关的两个通道有桩:`PIG_FAKE_MODELS`(值 `denied` 会返回 `PERMISSION_DENIED`)、 +`PIG_FAKE_COMPLETE`(`RATE_LIMITED` / `error:…`)、`PIG_SHOW_PROMPT=1` 会把真正发出去的提示词打出来。 + +### 没有宿主也能看视图 + +`tools/smoke.mjs` 把构建好的视图配上桩 `window.pluginBridge` 写到 `.smoke/`, +于是那些难以按需制造的状态(仓库卡在合并中、一次被远端拒绝的推送、一个已检出的子模块作为当前仓库)可以渲染出来看: + +```bash +node tools/build.mjs && node tools/smoke.mjs commit zh-CN +``` + +视图名与语言都是位置参数(产物是 `.smoke/<视图>-<语言>.html`)。桩在工具里,不在插件里,`.smoke/` 已 gitignore。 + +## 开发与打包 + +1. 扩展页右上角 **`···` → 「加载本地插件」** → 选这个目录。 +2. 改 `src/`,跑 `node tools/build.mjs`,视图会热重载。 +3. 组装干净的发布目录再打 Pack:`node tools/build.mjs && node tools/make-publish-dir.mjs`,然后对 `dist-publish/io.github.liushunqiu.pi-idea-git/` 跑 `PluginCheck` / `PluginPack`(产物在该目录的 `dist/*.piplug`,8 个文件约 0.9MB)。**不要直接对仓库根打 Pack**——它会把 `.smoke/`、`.memories/`、`tools/`、`src/` 等开发文件一起打进去(`PluginPack` 不读 `.gitignore`,且没有排除文件机制,实测 2.1MB / 46 文件)。 + 从老 ID `local.pi-idea-git` 升级上来的用户,提交信息历史与视图选项不会迁移(插件数据目录按 ID 隔离)。 + +质量底线(改完代码过一遍):`node --check` 每个 `src/*.js`、`PluginCheck` 无 error、`node tools/harness.mjs` 全绿。 + +> **扩大 `permissions` 需要比「Reload」按钮更长的路径。** +> 保存触发的热重载会拒绝一个「要的权限多于运行实例已获批的」清单。卡片上的 **Reload** 按钮也不够: +> 它把新清单与 `registry.json` 里记录的权限求交集,**静默丢掉新增的那些,却照样报告成功**。 +> 只有 **`···` → 「加载本地插件」**(重新选一次目录)会重读清单并重新征求同意。 + +宿主版本要求写在同一份清单里:`engines.piDesktop >= 0.8.0`。 + +## 已知缺口 + +诚实清单——这些是当前实现里确实存在、且用户可能撞上的限制: + + - **文本搜索是本地过滤**:工具栏搜索框只在已取回的 200 条内做 substring 匹配;要按提交信息在服务端查,用分支/用户/日期/路径过滤(它们都进 `git log` 参数)。 +- **Console 不是「每条命令都记」**:探测型命令成功时不记录(见「引擎设计」),失败时 short/detail 都有长度上限。 +- **hunk 级操作的适用面**:`Ignore whitespaces` 打开时禁用,未跟踪文件禁用(整文件 `Add` 仍可用)。 +- **`Edit Commit Message` 只对 HEAD 开放**。 +- **Commit 视图文件菜单的复制项**:有提交时显示「Copy Revision Number」并复制 HEAD(与 Log 视图一致),尚无提交时显示「复制路径/Copy Path」并复制文件路径。 +- **两处「回滚」同名不同义**:提交下拉里的「回滚」把已暂存内容移出索引(`git/unstage`),文件右键里的「回滚」会**丢弃改动**(`git/discard`,未跟踪文件直接删除)。同名,但危险等级不同——按下去之前先看它问的是什么。 +- **生成的 HTML 固定 `lang="en"`**,与界面语言无关。 +- **执行边界**:单条命令 25 秒后被杀;Console 只留最近 200 条、日志一次最多取 500 条、错误详情与 Console 的每条输出都截到 4000 字符——这些截断是静默的。唯一会**报出来**的是 stdout 超过 8 MiB:那条命令被杀死并报 `Git output exceeded the size limit.`(stderr 超限则是静默停止收集)。 +- **宿主超时**:任何一次面板调用超过 30 秒,都会以宿主侧超时的形式失败;引擎的 25 秒上限就是为了让失败先发生在 Git 这一侧。 +- **测试不覆盖真实宿主**:harness 与 smoke 用的是桩,`PluginCheck` 只看 `main.js`(于是视图经桥调用的权限被误报为 unused)。 + +## 记忆与决策笔记 + +仓库里两类笔记都是纯 Markdown,随包一起走: + +- `.memories/` —— 排查过程与运行时事实(插件运行时约束、渲染进程 CPU 诊断法、宿主 API 事实、 + push/pull 冲突、嵌套仓库、笔记 skill 漂移)。 +- `.agents/notes/implemented/architecture/` —— 四篇落地决策笔记:为什么用 Git CLI 而不是解析 `.git/`、 + 为什么做成两个 docked view、为什么加构建步骤、变更列表为什么改成目录树; + 提交信息提示词为什么把语言做成显式参数;远端冲突为什么必须读两个流、为什么只能有 `--force-with-lease`; + 为什么仓库列表是会话级的、以及 `kind` 为什么由 gitlink 判定。 + +核心代码入口留了一行 `// Note: <为什么这样做、放弃了什么> — 见 .agents/notes/…`,刻意不逐行标; +决定被取代时,这些注释就是要同步的代码清单。 diff --git a/plugins/io.github.liushunqiu.pi-idea-git/main.js b/plugins/io.github.liushunqiu.pi-idea-git/main.js new file mode 100644 index 0000000..303dad8 --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/main.js @@ -0,0 +1,3326 @@ +/** + * IDEA Git — PI-Desktop plugin entry. + * + * Architecture + * ------------ + * `main.js` runs in a dedicated Node process (Electron `utilityProcess`), so it + * has the real Node API. The work-panel view and the detached panel are + * sandboxed pages that only get `window.pluginBridge`. The host forwards every + * channel it does not implement itself to `onPanelInvoke` below, which makes + * this file the single place where Git actually executes. + * + * view/panel --pluginBridge.invoke("git/…")--> host --> onPanelInvoke --> git + * + * Why the Git CLI instead of reading `.git/` through the host file APIs: + * `.git/` sits on the host's credential refusal list, and porcelain output is + * the only stable, quoting-safe contract for status, diffs and staging. The + * process receives a reduced environment (PATH/LANG/TEMP but no HOME), so every + * invocation restores HOME explicitly — without it Git cannot read the user's + * global identity or credential helper. + * + * Nothing here writes to the repository except when the user asked for it: + * stage / unstage / discard / commit / branch / stash all map to one explicit + * + * A project can hold more than one repository: submodules, and repositories + * that merely live inside another one. `readRepo` is the single place that + * decides which of them every command runs against, so picking one in the + * repository list redirects the whole tool window without any other command + * knowing about it; `git/repos` is what the list is built from. + * Git command, and hunk operations pipe a patch that Git validates itself. + */ + +// Note: 为什么用 Git CLI 而不是解析 .git/(宿主硬拒 .git/,porcelain 是唯一对空格/非 ASCII 路径无歧义的契约)、为什么做成两个 docked view 而不是一个、变更列表为什么按分组各自建树 — 见 .agents/notes/implemented/architecture/2026-09-11-idea-git-tool-window.md +const { spawn } = require("node:child_process"); +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); + +/** Host aborts a panel call after 30s; stay under it so the UI gets a message. */ +const COMMAND_TIMEOUT_MS = 25_000; +/** Hard cap on one command's stream so a runaway log cannot exhaust memory. */ +const MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +/** Field/record separators for `git log` — control chars cannot appear in messages. */ +const FS_CHAR = "\u001f"; +const RS_CHAR = "\u001e"; + +/** + * Every command this plugin runs is kept in a small ring buffer. The host's + * Console tab is meant to show "the results of executing VCS-related + * commands", and because each work-panel view is its own page, the buffer has + * to live here rather than in a page's memory. + */ +const CONSOLE_LIMIT = 200; +let consoleEntries = []; +/** + * Commands whose output is pure plumbing and would only add noise — plus + * `config`, which this plugin only ever runs to *read* something (the user's + * identity, `.gitmodules`). A failed read is still recorded. + */ +const CONSOLE_QUIET = new Set([ + "rev-parse", + "ls-files", + "for-each-ref", + "config", + "--version", +]); + +function recordConsole(args, result, cwd) { + if (CONSOLE_QUIET.has(args[0]) && result.ok) return; + consoleEntries.push({ + ts: Date.now(), + cwd, + command: `git ${args.join(" ")}`, + ok: result.ok, + stdout: (result.stdout ?? "").slice(0, 4000), + stderr: (result.stderr ?? "").slice(0, 4000), + message: result.message, + code: result.code, + }); + if (consoleEntries.length > CONSOLE_LIMIT) { + consoleEntries = consoleEntries.slice(-CONSOLE_LIMIT); + } +} + +function consoleLog() { + return consoleEntries; +} + +function clearConsoleLog() { + consoleEntries = []; +} + +/** + * Small persisted state: the commit-message history IDEA keeps behind the + * clock button, and the view preferences (unified vs side-by-side, graph + * options). `plugin.getDataPath()` is the plugin's own directory, so this + * never touches the user's workspace. + */ +let prefsCache = null; +const MAX_MESSAGE_HISTORY = 30; + +async function prefsFile() { + const dir = await pi.plugin.getDataPath(); + return path.join(dir, "prefs.json"); +} + +async function readPrefs() { + if (prefsCache) return prefsCache; + try { + const parsed = JSON.parse(fs.readFileSync(await prefsFile(), "utf8")); + prefsCache = { + messages: Array.isArray(parsed?.messages) ? parsed.messages : [], + ui: parsed?.ui && typeof parsed.ui === "object" ? parsed.ui : {}, + }; + } catch { + prefsCache = { messages: [], ui: {} }; + } + return prefsCache; +} + +async function writePrefs(prefs) { + prefsCache = prefs; + try { + fs.writeFileSync(await prefsFile(), JSON.stringify(prefs, null, 2), "utf8"); + } catch { + // Persistence is a convenience; a failure must not break the view. + } + return prefs; +} + +/** Newest first, de-duplicated, so re-using a message promotes it. */ +function rememberMessage(prefs, message) { + const trimmed = String(message ?? "").trim(); + if (!trimmed) return prefs; + const messages = [trimmed, ...prefs.messages.filter((value) => value !== trimmed)]; + return { ...prefs, messages: messages.slice(0, MAX_MESSAGE_HISTORY) }; +} + +// --------------------------------------------------------------------------- +// Locating git +// --------------------------------------------------------------------------- + +let gitBinaryCache; + +/** + * Electron started from a desktop launcher inherits a minimal PATH, so a + * `git` that exists in the user's shell may be invisible here. Probe PATH + * through the filesystem and then fall back to the usual install locations. + */ +function resolveGitBinary() { + if (gitBinaryCache !== undefined) return gitBinaryCache; + const exe = process.platform === "win32" ? "git.exe" : "git"; + const dirs = [ + ...(process.env.PATH ?? "").split(path.delimiter).filter(Boolean), + "/usr/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/local/bin", + path.join(os.homedir(), ".local", "bin"), + "C:\\Program Files\\Git\\cmd", + "C:\\Program Files (x86)\\Git\\cmd", + path.join(os.homedir(), "AppData", "Local", "Programs", "Git", "cmd"), + ]; + for (const dir of dirs) { + const candidate = path.join(dir, exe); + try { + fs.accessSync(candidate, fs.constants.X_OK); + gitBinaryCache = candidate; + return candidate; + } catch { + // Not here; keep looking. + } + } + gitBinaryCache = null; + return null; +} + +/** + * The plugin process gets PATH/LANG/TEMP but never HOME, and Git needs HOME for + * `~/.gitconfig` (identity, aliases, credential helper). Prompts are disabled + * because there is no terminal to answer them. + */ +function gitEnv() { + const env = { ...process.env }; + if (!env.HOME) env.HOME = os.homedir(); + if (!env.USERPROFILE) env.USERPROFILE = os.homedir(); + env.GIT_TERMINAL_PROMPT = "0"; + env.GIT_ASKPASS = "echo"; + env.GIT_OPTIONAL_LOCKS = "0"; + env.GIT_PAGER = "cat"; + env.GIT_EDITOR = "true"; + env.GIT_CONFIG_NOSYSTEM = env.GIT_CONFIG_NOSYSTEM ?? ""; + return env; +} + +// --------------------------------------------------------------------------- +// Running git +// --------------------------------------------------------------------------- + +/** + * Run one Git command and always resolve — never reject — so the UI can render + * a real error message instead of an opaque bridge failure. + * + * @param {string[]} args + * @param {{cwd?: string, input?: string, timeoutMs?: number}} [options] + * `message` is the short form for a toast; on failure it is built from *both* + * streams (`gitError`) and `detail` carries what Git said, truncated to + * `ERROR_DETAIL_CHARS` (see `gitDetail`). + * @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: number|null, message?: string, detail?: string|null}>} + */ +function runGit(args, options = {}) { + const binary = resolveGitBinary(); + if (!binary) { + return Promise.resolve({ + ok: false, + stdout: "", + stderr: "", + code: null, + message: + "Git executable not found. Install Git, or make sure it is on PATH.", + }); + } + const cwd = options.cwd || workspacePath(); + if (!cwd) { + return Promise.resolve({ + ok: false, + stdout: "", + stderr: "", + code: null, + message: "No workspace is open.", + }); + } + return new Promise((resolve) => { + let child; + try { + child = spawn(binary, args, { + cwd, + env: gitEnv(), + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + resolve({ + ok: false, + stdout: "", + stderr: "", + code: null, + message: String(error?.message ?? error), + }); + return; + } + + const out = []; + const err = []; + let outBytes = 0; + let errBytes = 0; + let settled = false; + let truncated = false; + + const finish = (code, failure) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const stdout = Buffer.concat(out).toString("utf8"); + const stderr = Buffer.concat(err).toString("utf8"); + const result = { + ok: code === 0, + stdout, + stderr, + code, + message: failure ?? (code === 0 ? undefined : gitError(stdout, stderr, code)), + // `detail` is stdout+stderr for the dialog behind the toast, truncated to + // `ERROR_DETAIL_CHARS` with a trailing ellipsis (see `gitDetail`), so a + // runaway command cannot push megabytes through the bridge. + detail: code === 0 ? undefined : gitDetail(stdout, stderr), + truncated, + }; + // `quiet` is for a probe whose *failure* is an expected answer (asking + // whether a file is tracked, say): recording it would dress an ordinary + // path up as a failure in the Console. + if (!options.quiet) recordConsole(args, result, cwd); + resolve(result); + }; + + const effectiveTimeoutMs = options.timeoutMs ?? COMMAND_TIMEOUT_MS; + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Already gone. + } + finish(null, `git ${args[0] ?? ""} timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`); + }, effectiveTimeoutMs); + + child.stdout.on("data", (chunk) => { + outBytes += chunk.length; + if (outBytes > MAX_OUTPUT_BYTES) { + truncated = true; + try { + child.kill("SIGKILL"); + } catch { + // Already gone. + } + finish(null, "Git output exceeded the size limit."); + return; + } + out.push(chunk); + }); + child.stderr.on("data", (chunk) => { + errBytes += chunk.length; + if (errBytes > MAX_OUTPUT_BYTES) { + truncated = true; + return; + } + err.push(chunk); + }); + child.on("error", (error) => finish(null, String(error?.message ?? error))); + child.on("close", (code) => finish(code)); + + if (options.input !== undefined) { + child.stdin.on("error", () => { + // Git exited before reading (e.g. a rejected patch); the close handler + // reports the real reason. + }); + child.stdin.end(options.input, "utf8"); + } else { + child.stdin.end(); + } + }); +} + +function firstLine(text) { + const line = String(text ?? "").trim().split("\n").find((value) => value.trim()); + return line ? line.trim() : ""; +} + +/** + * How many lines of Git's own output the one-line message carries. The toast is + * small and disappears; `detail` is where the whole thing goes. + */ +const ERROR_LINES = 6; +/** Bounded so a runaway command cannot push megabytes through the bridge. */ +const ERROR_DETAIL_CHARS = 4000; + +// Note: 报错管线必须同时读 stdout 和 stderr——判据是"失败时哪一行能指出下一步",不是"哪个流更像错误流"(push 的拒绝在 stderr,merge 的 CONFLICT 在 stdout);hint 行从短消息里剔除、改用代码分类后的本地化补救,但完整输出仍进 detail 不丢 — 见 .agents/notes/implemented/architecture/2026-09-12-remote-sync-conflicts.md +/** + * Turn a failed command's output into something a person can act on. + * + * Both streams are read, because Git does not agree on one: `git push` reports + * the rejection on **stderr**, while `git merge` — and therefore the second half + * of `git pull` — reports `CONFLICT` on **stdout**. Reading only stderr, which + * is what this used to do, turned a conflicted pull into a message that read + * like a successful fetch log. + * + * `hint:` lines are dropped here because they are Git's generic advice, in + * English; the remedy shown instead is classified by + * `authHint`/`pushHint`/`pullHint` and worded by the views in the user's own + * language. They are still in `detail`, which is what the toast links to. When + * dropping them would leave nothing at all they are kept: the rule is "say + * something", not "say nothing that came from a hint". + */ +function gitError(stdout, stderr, code) { + const meaningful = []; + const hints = []; + const seen = new Set(); + for (const stream of [stderr, stdout]) { + for (const raw of String(stream ?? "").split("\n")) { + const line = raw.trimEnd(); + const key = line.trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + (key.startsWith("hint:") ? hints : meaningful).push(line); + } + } + const lines = meaningful.length ? meaningful : hints; + if (!lines.length) return `git exited with ${code}`; + return lines.slice(0, ERROR_LINES).join("\n"); +} + +/** The untrimmed output, for the details dialog behind an error toast. */ +function gitDetail(stdout, stderr) { + const text = [stderr, stdout] + .map((stream) => String(stream ?? "").trim()) + .filter(Boolean) + .join("\n"); + if (!text) return null; + return text.length > ERROR_DETAIL_CHARS ? `${text.slice(0, ERROR_DETAIL_CHARS)}\n…` : text; +} + +/** + * Classify an authentication failure so the views can explain it. + * + * The plugin deliberately holds no credentials: it runs the user's own `git` + * with the user's own `HOME`, so it inherits exactly the credential helpers, + * `~/.ssh/config` and keys that their terminal uses. That inheritance is the + * whole design — but it also means that when it is missing, the plugin has no + * way to ask. Prompts are disabled so a command fails fast rather than hanging + * with no terminal, which turns "no credential configured" into a dead end + * whose raw output names no remedy. + * + * Returns a stable code, never prose: the wording belongs to the views, which + * are the only part that knows the user's language. + */ +function authHint(stdout, stderr) { + const text = `${stderr ?? ""}\n${stdout ?? ""}`; + if (!text.trim()) return null; + if (/Permission denied \(publickey\)|Host key verification failed|Could not read from remote repository/i.test(text)) { + return "ssh"; + } + if ( + /Authentication failed|could not read Username|could not read Password|terminal prompts disabled|Invalid username or token|HTTP 401|401 Unauthorized/i.test(text) + ) { + return "credentials"; + } + return null; +} + +/** + * Classify a refused push so the views can name the way out. + * + * Git prints `! [rejected]` (or `[remote rejected]`) and then the reason in + * parentheses. Two of those reasons are one situation for the user — someone + * else moved the branch, and it has to be integrated before it can be pushed + * again — and the rest are the server saying no outright. Returns a stable + * code, never prose. + */ +function pushHint(stdout, stderr) { + const text = `${stderr ?? ""}\n${stdout ?? ""}`; + if (!/!\s*\[(?:remote )?rejected\]/i.test(text)) return null; + if (/\((?:fetch first|non-fast-forward|stale info)\)/i.test(text)) return "remote-ahead"; + return "remote-rejected"; +} +/** + * True when `git pull` refused only because no reconcile strategy is + * configured — the one failure this plugin answers itself, by retrying with + * `--no-rebase`. Every other refusal is the user's configuration talking, and + * is reported as it came. + */ +function needsReconcile(stdout, stderr) { + return /Need to specify how to reconcile divergent branches/i.test(`${stdout ?? ""}\n${stderr ?? ""}`); +} + +/** + * Classify a failed pull. + * + * `diverged` is what a user's own `pull.ff = only` says when it refuses a + * diverged branch. `conflicts` is the other outcome worth naming: Git reports it + * on stdout, and "there are conflicts to resolve" is the one thing the user must + * be told, since a pull that half-succeeded otherwise looks like a plain fetch. + */ +function pullHint(stdout, stderr) { + const text = `${stdout ?? ""}\n${stderr ?? ""}`; + if (/Need to specify how to reconcile|Not possible to fast-forward/i.test(text)) return "diverged"; + if (/CONFLICT \(|Automatic merge failed|fix conflicts and then commit/i.test(text)) return "conflicts"; + return null; +} + +// --------------------------------------------------------------------------- +// Commit-message drafting +// --------------------------------------------------------------------------- + +/** + * The message is written by the host's own model, through `pi.agent.complete`. + * The plugin holds no API key and picks no provider: the user's configured + * models are whatever `pi.models.list()` reports, which is exactly the set they + * already pay for. + */ + +/** Enough history for the model to copy the repository's conventions. */ +const STYLE_SAMPLE_COMMITS = 40; +/** The diff is a prompt, not a backup: keep it small enough to stay quick. */ +const MAX_PROMPT_PATCH_CHARS = 12_000; + +// Note: 语言是显式参数,而不是"匹配仓库历史"推断(英文历史会稳定压过用户的界面语言);格式类规则提示词也保证不了,必须由 formatCommitMessage 在模型输出后强制成立 — 见 .agents/notes/implemented/architecture/2026-09-11-commit-message-prompt.md +/** + * The instruction the model follows. + * + * "Match the repository" is the obvious rule and the one that used to be here, + * but it leaves two things to a guess: the language, which then follows training + * data back to English however Chinese the user is, and the shape of a good + * message, which the model has seen plenty of but not necessarily in this diff. + * So the language arrives as a parameter, and the facts `buildCommitContext` + * measured about the repository (language, subject prefix, body usage) arrive + * with the request. + * + * The prompt is deliberately long. A commit subject is short-lived in attention + * but permanent in history, and every clause below answers a failure that shows + * up in real drafts: a code fence, a "commit message:" prefix, a body that + * restates the diff, an English subject on a Chinese project, a subject that + * never ends, punctuation half full-width. + */ +function buildSystemPrompt(lang, style) { + // The history's language and the drafted message's language are two different + // things, and the model will follow whichever it is told about last. So every + // branch below states the language the message MUST be written in, then says + // what the history is for: evidence of tone and structure, never a licence to + // switch language. + // + // This note is keyed on the history being *empty*, not on its language being + // unmeasurable: a repository whose subjects carry no letters still has a + // history, and telling the model otherwise would contradict the samples it is + // shown in the same request. + const noHistory = style.count === 0; + const historyNote = noHistory + ? "This repository has no commit history to follow, so the rules above are the only guidance on how the message reads." + : null; + + const languageRule = lang === "zh" + ? [ + "Write the commit message in Simplified Chinese, including the subject and every line of the body.", + style.language === "zh" + ? "The repository's own history is Chinese too, so match its tone as well." + : style.language === "en" + ? "The repository's history is in English, but that is not the language of this message: use Chinese anyway, and use that history only as a model for structure and for how long a subject runs." + : historyNote ?? "The repository's history gives no clear signal about wording; follow the rules above.", + "Use half-width punctuation where characters are ASCII and full-width where they are Chinese: `feat(登录): 支持短信验证码`, not `feat(登录):支持短信验证码`.", + ] + : [ + "Write the commit message in English, including the subject and every line of the body.", + "Keep the subject in the imperative mood, start it with a capital letter, and do not end it with a full stop.", + // The same guard the Chinese branch carries, for the same reason: a + // Chinese history must not drag an explicitly-English draft into + // Chinese. + style.language === "zh" + ? "The repository's history is Chinese, but that is not the language of this message: use English anyway, and use that history only as a model for structure and for how long a subject runs." + : historyNote ?? "The repository's history gives no clear signal about wording; follow the rules above.", + ].filter(Boolean); + + const conventional = style.conventional + ? "This repository prefixes its subjects: `type(scope): description`. Keep that shape, keep the `type` and `scope` in ASCII exactly as the samples spell them, and pick the one type that dominates this change. If the samples use no scope, leave the parentheses out rather than inventing one." + : "Do not invent a `type:` prefix. Follow the subject shape this repository already uses."; + + // Keyed off the language the message is actually written in, not off the + // repository's measured history: for a Chinese draft the guidance on line + // length has to be the Chinese one even when the history was English or empty. + const bodyStyle = lang === "zh" + ? "In the body wrap English lines near 72 columns and keep Chinese lines short; write each bullet as one idea rather than one file." + : "In the body wrap English lines near 72 columns; write each bullet as one idea rather than one file."; + + return [ + "You write a single Git commit message.", + "Reply with that message and nothing else: no preamble, no explanation, no analysis, no markdown code fences, no `commit message:` label, no surrounding quotes.", + "First line is the subject; it states what the change does, never what it is.", + "Keep the subject short: aim for 50 characters and never exceed 72. A Chinese character is about twice as wide as a Latin one, so a Chinese subject stays under about 25 characters, and the subject is a single line — never continue it into the first sentence of the body.", + "The blank line after the subject is mandatory. Git treats every line up to the first blank line as the title, so a message without it has no body at all — only one enormous subject.", + "The body says why the change was made and what it makes different from here on: not a file list, not a walk through the diff, not the names of the functions you saw. Write one idea per line, and when the change has several parts give each part its own line.", + "When the change is thoroughly self-evident, the subject alone is the whole message.", + bodyStyle, + "After the body, leave another blank line and then footers, when they apply: `BREAKING CHANGE: ` for a change that is not backward compatible. Do not add any other trailer.", + "Never mention that you wrote the message, never address the user, and never ask a question.", + conventional, + // A worked example beats another rule, and it is the one thing that fixes + // the failure this prompt kept hitting: the model knew the words "blank + // line" and still returned subject-then-body with nothing between them. + // Showing the shape is unambiguous in a way the sentence was not. + "Reply in exactly this shape, with your own content:", + "", + ...(lang === "zh" + ? [ + style.conventional ? "feat(登录): 支持短信验证码登录" : "支持短信验证码登录", + "", + "- 验证码 60 秒内可重发,过期后提示重新获取", + "- 连续失败三次锁定十分钟,避免被暴力猜解", + "- 未登录用户仍可用密码登录,行为不变", + ] + : [ + style.conventional ? "feat(auth): add SMS code sign-in" : "Add SMS code sign-in", + "", + "- Let the code be resent after 60 seconds and say so once it expires", + "- Lock the account for ten minutes after three failed attempts", + "- Password sign-in is unchanged for accounts without a phone number", + ]), + "", + ...languageRule, + ].join("\n"); +} + +/** + * Which language the message must be written in. `auto` is not "guess": it is + * the language the repository's own subjects are written in, measured by + * `commitStyle`, and it falls back to the view's locale so a brand-new + * repository still lands in the language the user reads. + */ +function resolveCommitLang(preference, style, locale) { + if (preference === "zh") return "zh"; + if (preference === "en") return "en"; + if (style.language) return style.language; + return String(locale ?? "").toLowerCase().startsWith("zh") ? "zh" : "en"; +} + +/** Strip the shapes a model adds around a message even when told not to. */ +function tidyCommitMessage(raw) { + let text = String(raw ?? "").trim(); + const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n?```$/.exec(text); + if (fence) text = fence[1].trim(); + text = text.replace(/^(commit message|提交信息)\s*[::]\s*/i, ""); + return text.replace(/\s+$/, ""); +} + +// --------------------------------------------------------------------------- +// Shaping the message +// +// The model is asked for a shape; this section *enforces* it. Everything here +// answers a defect that shows up in real drafts and that a prompt alone did not +// prevent: +// +// - the body begins on the line straight after the subject, with no blank +// line between them. Git takes everything up to the first blank line as the +// title, so that draft is one enormous subject — the defect a reader +// reports as "this is not a proper commit message". +// - the body is one run-on sentence, or three ideas joined by `;`, where one +// idea per line belongs. +// - nothing is wrapped: a model does not measure columns, so a Chinese body +// arrives as a single 150-column line. +// +// None of it rewrites the model's words. It only puts those words where Git +// expects to find them. +// --------------------------------------------------------------------------- + +/** + * Display width, not `String.length`: every terminal and every web Git host + * renders a CJK glyph two columns wide, which is why the 50/72 rule from + * git-commit(1) means about 25 Chinese characters rather than 50. + */ +const WIDE_CHAR = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/; + +function textWidth(value) { + let width = 0; + for (const char of String(value ?? "")) width += WIDE_CHAR.test(char) ? 2 : 1; + return width; +} + +/** git-commit(1) asks for 50 columns and tools start truncating at 72. */ +const SUBJECT_TARGET_WIDTH = 50; +const SUBJECT_MAX_WIDTH = 72; +const BODY_WRAP_WIDTH = 72; + +/** + * Hard-wrap to `limit` columns. Latin breaks at the last space so words survive + * intact; CJK has no spaces and may break between any two characters, which is + * what a reader of Chinese expects anyway. + */ +function wrapText(value, limit) { + const out = []; + let line = ""; + const flush = () => { + if (line) out.push(line); + line = ""; + }; + for (const char of String(value ?? "")) { + if (char === "\n") { + flush(); + continue; + } + if (!line || textWidth(line) + textWidth(char) <= limit) { + line += char; + continue; + } + const space = line.lastIndexOf(" "); + // Only honour a space in the back half of the line: breaking at one far to + // the left would leave every wrapped line ragged. + if (space >= limit / 2) { + out.push(line.slice(0, space)); + line = `${line.slice(space + 1)}${char}`; + } else { + flush(); + line = char; + } + } + flush(); + return out; +} + +/** A title is never a list item and never ends in punctuation. */ +const TITLE_TRIM = /^[-*+\s]+|[\s。..,,;;::、]+$/g; + +/** + * Keep the subject a subject. Handed a change with three parts, a model tends to + * write all three into the first line, which is how a subject ends up past the + * 72-column ceiling and reads like a paragraph. The overflow moves into the body + * at a clause boundary rather than being truncated: nothing the model saw in the + * diff is discarded, it is only put where Git expects it. + */ +function splitSubject(value) { + const subject = String(value ?? "").replace(TITLE_TRIM, ""); + if (textWidth(subject) <= SUBJECT_MAX_WIDTH) return { subject, rest: [] }; + + const boundaries = []; + const pattern = /[,,;;::。!?!?]\s*/g; + let match; + while ((match = pattern.exec(subject))) boundaries.push(match.index + match[0].length); + const fitsTarget = boundaries + .filter((index) => textWidth(subject.slice(0, index)) <= SUBJECT_TARGET_WIDTH) + .pop(); + const fitsCeiling = boundaries.find((index) => textWidth(subject.slice(0, index)) <= SUBJECT_MAX_WIDTH); + const cut = fitsTarget ?? fitsCeiling; + // No boundary fits: the subject is one unbroken phrase, and cutting it would + // invent a title the model never wrote. Leave it alone. + if (!cut) return { subject, rest: [] }; + + return { + subject: subject.slice(0, cut).replace(TITLE_TRIM, ""), + rest: [subject.slice(cut).trim()], + }; +} + +/** + * Chinese punctuation inside Chinese text. Models drift to the ASCII `,` and + * `;` mid-sentence, which reads as a typo in a commit log. The guard is strictly + * Han-on-both-sides, so `feat(a,b): 支持` and anything else in ASCII — paths, + * identifiers, URLs — is left exactly as written. + */ +function normalizeCommitPunctuation(value) { + return String(value ?? "").replace( + /([\p{Script=Han}])([,;])(?=[\p{Script=Han}])/gu, + (match, before, mark) => `${before}${mark === "," ? "," : ";"}`, + ); +} + +/** A body line indented under the bullet above it — the shape wrapping produces. */ +const CONTINUATION_LINE = /^[ \t]/; + +/** + * Rejoin a line with the one it was wrapped from. Latin wrapping consumes the + * space it broke at, so it has to be put back; CJK wrapping breaks between two + * characters and must not gain one. Asking whether the two ends are ASCII is + * enough to tell those apart, and it is what makes this function idempotent. + */ +function joinWrapped(previous, next) { + const needsSpace = /[A-Za-z0-9,;:.)\]"'`]$/.test(previous) && /^[A-Za-z0-9(\["'`]/.test(next); + return needsSpace ? `${previous} ${next}` : `${previous}${next}`; +} + +/** One logical line of the body, rendered into `out` at Git's 72-column width. */ +function renderParagraph(out, text) { + const bullet = /^[-*+]\s+/.exec(text); + // A semicolon joins separate ideas, and the prompt asked for one idea per + // line — but only when the line is long enough for that to be the problem: + // splitting `修复拼写;无行为变化` into two bullets would be noise. + const clauses = bullet ? [text] : text.split(/[;;]/).map((part) => part.trim()).filter(Boolean); + if (clauses.length > 1 && textWidth(text) > SUBJECT_TARGET_WIDTH) { + for (const clause of clauses) { + // The marker spends two columns, so the text keeps the same right edge. + wrapText(clause, BODY_WRAP_WIDTH - 2).forEach((line, index) => { + out.push(index ? ` ${line}` : `- ${line}`); + }); + } + return; + } + if (bullet) { + const indent = " ".repeat(bullet[0].length); + wrapText(text.slice(bullet[0].length), BODY_WRAP_WIDTH - bullet[0].length).forEach((line, index) => { + out.push(index ? `${indent}${line}` : `${bullet[0]}${line}`); + }); + return; + } + out.push(...wrapText(text, BODY_WRAP_WIDTH)); +} + +/** + * The message the user actually receives — shaped here rather than trusted from + * the model. `subject` is one line; exactly one blank line separates it from the + * body, even when the model forgot it; the body is wrapped to Git's 72 columns, + * and semicolon-joined clauses become one bullet each. + * + * Idempotent on a well-formed draft, which matters because this runs on every + * reply and that reply may already be correct: a subject under the ceiling keeps + * its line, a body already broken into bullets is only re-wrapped (its + * continuation lines are re-joined first, then wrapped to the same result), and + * a paragraph with a single clause stays prose. + */ +function formatCommitMessage(raw) { + const text = normalizeCommitPunctuation(tidyCommitMessage(raw)).replace(/\r\n?/g, "\n"); + const lines = text.split("\n").map((line) => line.replace(/\s+$/, "")); + + let index = 0; + while (index < lines.length && !lines[index].trim()) index++; + if (index >= lines.length) return ""; + const { subject, rest } = splitSubject(lines[index].trim()); + index++; + + // Fold the body into logical lines. An indented line continues the line above + // it, which is how this function itself writes a wrapped bullet or sentence; a + // blank line is a paragraph break and nothing else is. + const logical = []; + let pending = null; + for (const line of lines.slice(index)) { + if (!line.trim()) { + if (pending) { + logical.push(pending); + pending = null; + } + if (logical.length && !logical[logical.length - 1].paragraphBreak) { + logical.push({ paragraphBreak: true }); + } + continue; + } + if (pending && CONTINUATION_LINE.test(line)) { + pending = { text: joinWrapped(pending.text, line.trim()) }; + continue; + } + if (pending) logical.push(pending); + pending = { text: line.trim() }; + } + if (pending) logical.push(pending); + while (logical.length && logical[logical.length - 1].paragraphBreak) logical.pop(); + + const body = []; + for (const item of [...rest.map((text_) => ({ text: text_ })), ...logical]) { + if (item.paragraphBreak) { + // Paragraph breaks the model wrote are preserved, never doubled. + if (body.length && body[body.length - 1] !== "") body.push(""); + continue; + } + renderParagraph(body, item.text); + } + while (body.length && body[body.length - 1] === "") body.pop(); + + return body.length ? `${subject}\n\n${body.join("\n")}` : subject; +} + +/** A subject prefix (`fix(parser)!: …`) — the pattern, not its type names. */ +const SUBJECT_PREFIX = /^[a-z][a-z0-9-]*(?:\([^)\n]{1,30}\))?!?: \S/; + +// Note: 风格事实由插件实测,而不是让模型猜(历史语言 / 是否用 type(scope): 前缀 / 是否写正文),并由 buildCommitContext 措辞进用户消息;language 测不出来时必须是 null,折成 "en" 会让界面语言回退变成死代码 — 见 .agents/notes/implemented/architecture/2026-09-11-commit-message-prompt.md +/** + * What the repository's recent history says about how to write here: which + * language it commits in, whether it prefixes its subjects, and whether anyone + * writes a body. Read from full messages rather than subjects, because the last + * two are properties of the whole message. + * + * `language` is `null` only when there is nothing to measure — an empty history, + * or subjects with no letters at all. It used to collapse that case to `"en"`, + * which made `resolveCommitLang`'s locale fallback dead code: a brand-new + * repository drafted in English no matter which language the window spoke. + * "Unknown" is a real answer and has to survive this far for the caller to act + * on it — but it must stay reserved for that case. A repository that *does* + * have history has a language, and folding a tie into `null` would hand a + * Chinese repository to the locale fallback just because its subjects also + * contain ASCII. + */ +function commitStyle(samples) { + // A subject with Chinese in it is a Chinese subject, full stop: the two + // buckets are mutually exclusive rather than both-incrementing. Counting + // `feat(登录): 支持短信验证码` in *both* buckets (it has CJK and a 3-letter + // ASCII run) made every subject in a Chinese Conventional-Commits repository + // cancel out, so the tie-break decided the language instead of the evidence. + const isChinese = (value) => /[\u3400-\u9fff]/.test(value); + const chinese = samples.filter((entry) => isChinese(entry.subject)).length; + const english = samples.filter( + (entry) => !isChinese(entry.subject) && /[A-Za-z]{3}/.test(entry.subject), + ).length; + return { + // `count` is what callers need to tell "no history" apart from "history I + // could not classify": `language` is null in both cases, but only the first + // one lets the prompt say the repository has no history. + count: samples.length, + language: chinese || english ? (chinese >= english ? "zh" : "en") : null, + conventional: samples.filter((entry) => SUBJECT_PREFIX.test(entry.subject)).length >= 2, + bodies: samples.some((entry) => entry.body), + }; +} + +/** + * Parse `git log --format=%s%x00%b%x1e` into subjects and bodies. The + * separators are control characters, which no commit message can contain, so + * this stays a split rather than a regex over user text. + */ +function parseStyleSamples(stdout) { + return String(stdout ?? "") + .split(RS_CHAR) + .map((record) => { + const [subject, body] = record.split(FS_CHAR); + return { subject: String(subject ?? "").trim(), body: String(body ?? "").trim() }; + }) + .filter((entry) => entry.subject); +} + +/** + * What the model gets to read: the change, plus what the repository's own + * history says about how a message is written here. The facts are computed by + * us and stated in prose; the samples stay as evidence of tone. + */ +async function buildCommitContext(repo, payload) { + const path = typeof payload?.path === "string" && payload.path.trim() ? payload.path.trim() : null; + const mode = payload?.mode === "index" ? "index" : "worktree"; +// Note: 多仓已暂存聚合进一个 prompt(stagedRoots)— 见 .agents/notes/implemented/bug-fix/2026-09-14-commit-message-checked-scope.md + // Multi-repo staged draft: the Commit view commits every repo with staged + // files using one message, so the draft must read all of them — not just the + // selected repo's index (which for a parent is often only gitlink bumps). + const stagedRoots = Array.isArray(payload?.stagedRoots) + ? [...new Set((payload.stagedRoots ?? []).filter((value) => typeof value === "string" && value))] + : null; + + // The caller passes repository-relative paths; only paths that stay inside + // the repository are accepted, the same rule the staging channels use. + if (path && !isSafePath(path)) { + return { ok: false, code: "BAD_PATH", message: "Unsafe path rejected." }; + } + + const log = await runGit( + ["log", `-${STYLE_SAMPLE_COMMITS}`, `--pretty=format:%s${FS_CHAR}%b${RS_CHAR}`], + { cwd: repo.root }, + ); + const samples = log.ok ? parseStyleSamples(log.stdout) : []; + const style = commitStyle(samples); + const lang = resolveCommitLang(payload?.lang, style, payload?.locale); + + let patch = ""; + let scope = ""; + let files = []; + + if (path) { + const diff = await readDiff(repo, path, mode, false); + if (!diff.ok) return { ok: false, code: "NO_DIFF", message: diff.message ?? "No changes to describe." }; + patch = diff.text; + scope = { kind: "file", path, mode }; + files = [path]; + } else if (stagedRoots && stagedRoots.length) { + const baseWs = repo.workspaceRoot ?? repo.workspace ?? repo.root; + const parts = []; + const aggregated = []; + let okRepos = 0; + for (const rawRoot of stagedRoots) { + if (!rawRoot || !isInside(baseWs, rawRoot)) continue; + const resolved = await resolveRepositoryRoot(rawRoot); + if (!resolved) continue; + const diff = await runGit( + // `--no-prefix` only trims a/ and b/ noise out of the prompt; this text is + // never fed back to `git apply`. + ["diff", "--cached", "--no-color", "--no-ext-diff", "--no-prefix", "-U3"], + { cwd: resolved }, + ); + if (!diff.ok) continue; + const text = diff.stdout ?? ""; + if (!text.trim()) continue; + okRepos += 1; + let rel = null; + try { + rel = workspaceRelativeRoot(baseWs, resolved); + } catch { + rel = null; + } + if (!rel || rel === ".") { + rel = samePath(resolved, repo.root) ? (repo.rel ?? ".") : path.basename(resolved); + } + parts.push(rel && rel !== "." ? `=== ${rel} ===\n${text}` : text); + const names = await runGit(["diff", "--cached", "--name-only", "--no-color"], { cwd: resolved }); + if (names.ok) { + for (const line of names.stdout.split("\n")) { + const name = line.trim(); + if (!name) continue; + aggregated.push(rel && rel !== "." ? `${rel}/${name}` : name); + } + } + } + if (!parts.length) { + return { + ok: false, + code: "EMPTY_DIFF", + message: "Nothing is staged. Stage the change first, or select a file in the Changes list.", + }; + } + patch = parts.join("\n"); + scope = { kind: "staged", repos: okRepos }; + files = [...new Set(aggregated)]; + } else { + const diff = await runGit( + // `--no-prefix` only trims a/ and b/ noise out of the prompt; this text is + // never fed back to `git apply`. + ["diff", "--cached", "--no-color", "--no-ext-diff", "--no-prefix", "-U3"], + { cwd: repo.root }, + ); + if (!diff.ok) return { ok: false, code: "NO_DIFF", message: diff.message ?? "No changes to describe." }; + patch = diff.stdout; + scope = { kind: "staged" }; + // `status --porcelain` folds a rename into one record and marks a + // conflicted file once, where `diff --name-only` repeats a path that is + // changed in both the index and the worktree. + const names = await runGit(["status", "--porcelain", "--untracked-files=no"], { cwd: repo.root }); + files = names.ok + ? [...new Set(names.stdout.split("\n").map((line) => line.slice(3).trim()).filter(Boolean))] + : []; + } + + if (!patch.trim()) { + return { + ok: false, + code: "EMPTY_DIFF", + message: path + ? `No changes to describe for ${path}.` + : "Nothing is staged. Stage the change first, or select a file in the Changes list.", + }; + } + + const truncated = patch.length > MAX_PROMPT_PATCH_CHARS; + // State the language the message must be written in, not merely what the + // history happens to be: when those disagreed (English history, Chinese + // draft) the old wording restated the history and the model followed *it*. + // Branch on `style.language` itself, never on the display string derived from + // it, so rewording a label cannot silently flip which clause is chosen. + const hasHistory = style.count > 0; + const historyLanguage = style.language === "zh" + ? "Chinese" + : style.language === "en" ? "English" : null; + const styleLines = [ + // "No history" is a claim about `samples`, so it is gated on `samples`: + // `language` is also null for an empty history, but a measured history can + // still have no lettered subjects, and calling that "no history" while the + // samples are printed below would contradict the same message. + hasHistory && historyLanguage + ? `Language of recent commit subjects: ${historyLanguage}.` + : hasHistory + ? "Language of recent commit subjects: not determinable from the samples below." + : "Recent commit subjects: none to measure — this repository has no commit history yet.", + lang === "zh" + ? `Write this message in Simplified Chinese${style.language === "en" + ? " even though the history is English; use that history only for structure and subject length." + : style.language === "zh" ? ", matching the history." : "."}` + : `Write this message in English${style.language === "zh" + ? ", even though the history is Chinese." + : style.language === "en" ? ", matching the history." : "."}`, + ]; + const body = [ + "Repository commit style", + "-----------------------", + ...styleLines, + `Subject prefix: ${style.conventional + ? "recent subjects look like `type(scope): description`; keep that shape." + : "recent subjects carry no `type:` prefix; do not add one."}`, + style.bodies + ? "Recent commits do write bodies; use one when the change needs it." + : "Recent commits are usually a single line; add a body only when the change genuinely needs one.", + "", + "Format the message as:", + " subject", + " ", + " body, when it helps", + "", + samples.length + ? `Recent commit messages from this repository:\n${samples.map((entry) => entry.subject).join("\n")}` + : "This repository has no commit history yet.", + "", + scope?.repos > 1 + ? `Change to describe (${scope.repos} repositories share this one message; sections are marked === === — describe them together and name the repo for a part that belongs to only one)` + : "Change to describe", + "------------------", + "```diff", + truncated ? patch.slice(0, MAX_PROMPT_PATCH_CHARS) : patch, + truncated ? "[diff truncated: describe what is visible here, and nothing you cannot see]" : "", + "```", + ].filter((line) => line !== "").join("\n"); + + // The prompt describes the scope in prose; the caller gets the structure above + // and phrases it in the user's language. + return { ok: true, content: body, lang, style, scope, files }; +} + +async function listModels() { + let models; + try { + models = await pi.models.list(); + } catch (error) { + return { + ok: false, + code: String(error?.code ?? "MODEL_LIST_FAILED"), + message: String(error?.message ?? error), + models: [], + }; + } + if (!Array.isArray(models) || !models.length) { + return { ok: false, code: "NO_MODEL", message: "No model is available.", models: [] }; + } + return { ok: true, models }; +} + +/** + * Draft a message for everything checked (staged) — the set Commit will commit, + * across repos when several have staged files — or, when nothing is staged, for + * the selected file. `text` carries the draft; `message` stays what it is + * everywhere else in this file — the error text. + */ +async function draftCommitMessage(repo, payload) { + const listing = await listModels(); + if (!listing.ok) return listing; + const models = listing.models; + + const wanted = String(payload?.modelKey ?? "").trim(); + const model = models.find((row) => row.key === wanted) ?? models[0]; + + const context = await buildCommitContext(repo, payload); + if (!context.ok) return context; + const system = buildSystemPrompt(context.lang, context.style); + + try { + const result = await pi.agent.complete({ + modelKey: model.key, + system, + messages: [{ role: "user", content: context.content }], + }); + // Shaped here, not trusted from the model: see `formatCommitMessage`. + const text = formatCommitMessage(result?.text); + if (!text) return { ok: false, code: "EMPTY_REPLY", message: "The model returned nothing." }; + return { + ok: true, + text, + modelKey: result?.modelKey ?? model.key, + lang: context.lang, + scope: context.scope, + files: context.files, + }; + } catch (error) { + // The host reports these as codes; keep them so the view can phrase them. + return { + ok: false, + code: String(error?.code ?? "FAILED"), + message: String(error?.message ?? error), + }; + } +} + +// --------------------------------------------------------------------------- +// Workspace and repository +// --------------------------------------------------------------------------- + +/** + * The active workspace, kept as a cache that is *always* refreshed before it is + * trusted. + * + * This used to latch on first read, which quietly pinned the plugin to whatever + * project was open when it first ran: switching projects left the tool window + * reading (and writing) the previous repository. The host announces switches on + * `workspace:changed`, and the one host call needed to read the current value is + * trivially cheap next to spawning `git`, so there is no reason to guess. + */ +let cachedWorkspace = null; + +function workspacePath() { + return cachedWorkspace; +} + +async function refreshWorkspace() { + try { + const workspace = await pi.workspace.get(); + const next = workspace?.path ?? null; + // A chosen repository belongs to the project it was chosen in. The plugin + // process outlives project switches, so the choice is dropped the moment + // the workspace changes — otherwise every command would keep running in + // the previous project's submodule. + if (next !== cachedWorkspace) selectedRoot = null; + cachedWorkspace = next; + } catch { + selectedRoot = null; + cachedWorkspace = null; + } + return cachedWorkspace; +} + +// --------------------------------------------------------------------------- +// Repositories +// --------------------------------------------------------------------------- + +// Note: 一个项目多个仓库做成「仓库列表 + Root 选择器」,而不是给 41 个通道加参数或每仓库一个视图;选择属于打开的那个项目(会话级、不写 prefs、工作区一变就由 refreshWorkspace 清除),因为下次打开项目该看到项目自己的仓库 — 见 .agents/notes/implemented/architecture/2026-09-12-nested-repositories.md +/** + * The repository the tool windows are pointed at when it is not the workspace's + * own repository: a checked-out submodule, or a repository nested anywhere + * inside it. `null` means "the workspace's own repository", which keeps the + * ordinary single-repository project free of any state. + * TODO: `selectedRoot` is module-global mutable state shared by overlapping + * `onPanelInvoke` calls; a `git/select-repo` racing a `readRepo` refresh can + * interleave the `refreshWorkspace` clear with the `isInside` check. Needs a + * generation counter or per-call snapshot, but that widens the change surface. + */ +let selectedRoot = null; + +/** + * How far the discovery walk goes below the workspace root, how many + * directories it is willing to look at, and the trees it will not enter. + * + * The walk runs on a refresh, in a project whose size is not known in advance, + * so it is bounded three ways. The skip list holds dependency, cache and build + * trees: they are large, and a Git repository inside one is a vendored or + * generated copy rather than something a person works in. A declared submodule + * is listed even when it sits outside all three bounds — see + * `discoverRepositories`. + */ +const REPO_SCAN_DEPTH = 4; +const REPO_SCAN_BUDGET = 5000; +const REPO_SCAN_SKIP = new Set([ + ".git", + "node_modules", + "bower_components", + "vendor", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".gradle", + ".dart_tool", + "Pods", + ".cache", + ".next", + ".nuxt", + ".terraform", +]); + +/** + * A path with symlinks resolved where the filesystem allows it. + * + * This is not pedantry: `git rev-parse --show-toplevel` answers with the real + * path, while the host hands out the workspace path as the user opened it, and + * on macOS the temporary directory alone differs by a `/private` prefix. A + * comparison of the two raw strings would conclude that a repository is not + * inside its own workspace. + */ +function realPath(value) { + try { + return fs.realpathSync(value); + } catch { + return path.resolve(value); + } +} + +function samePath(left, right) { + if (!left || !right) return false; + return left === right || realPath(left) === realPath(right); +} + +/** Is `child` the same directory as `parent`, or somewhere below it? */ +function isInside(parent, child) { + const base = realPath(parent); + const target = realPath(child); + return target === base || target.startsWith(base + path.sep); +} + +/** + * The absolute path of the repository rooted exactly at `directory`, or null. + * + * `rev-parse` alone is not enough: run in a subdirectory it happily answers + * with the enclosing repository, which would let a view point the tool windows + * at `src/` and call it a repository of its own. A path that is not a + * directory, or not there at all, is not a repository either. + */ +async function resolveRepositoryRoot(directory) { + if (typeof directory !== "string" || !directory) return null; + let stats; + try { + stats = fs.statSync(directory); + } catch { + return null; + } + if (!stats.isDirectory()) return null; + const top = await runGit(["rev-parse", "--show-toplevel"], { cwd: directory }); + if (!top.ok) return null; + const root = top.stdout.trim(); + return samePath(root, directory) ? root : null; +} + +/** The paths `.gitmodules` declares, read by Git's own config parser. */ +async function declaredSubmodulePaths(root) { + const paths = new Set(); + const file = path.join(root, ".gitmodules"); + // Asked only when there is something to ask. `config -f` on a missing file + // exits 1, which would show up in the Console as a failure on the ordinary + // path — and a project without submodules is the ordinary path. + if (!fs.existsSync(file)) return paths; + const result = await runGit( + ["config", "-f", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], + { cwd: root }, + ); + if (!result.ok) return paths; + for (const line of result.stdout.split("\n")) { + // ` `; the value is the path, and it may contain spaces. + const match = /^\S+\s+(.+)$/.exec(line.trim()); + if (match) paths.add(match[1].replace(/\\/g, "/")); + } + return paths; +} + +/** + * Walk `root` for repositories, alphabetically by path. + * + * A directory holding `.git` is a repository: a submodule worktree has a file + * there, an ordinary clone a directory. Finding one does not end the walk — + * descending through it is what makes a submodule's own submodules, or a + * repository inside a vendored one, reachable at all, since the list is always + * built from the workspace's repository. Symlinked directories are skipped + * (`Dirent.isDirectory()` is already false for them), which also means a link + * pointing back up the tree cannot be followed. + */ +function scanNestedRepositories(root) { + const found = []; + let scanned = 0; + + const walk = (directory, relative, depth) => { + if (depth > REPO_SCAN_DEPTH || scanned > REPO_SCAN_BUDGET) return; + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + // Unreadable, or it went away mid-walk. Its parent is still a usable + // answer, so this is not an error worth surfacing. + return; + } + for (const entry of entries) { + if (!entry.isDirectory() || REPO_SCAN_SKIP.has(entry.name)) continue; + const childRelative = relative ? `${relative}/${entry.name}` : entry.name; + const child = path.join(directory, entry.name); + if (fs.existsSync(path.join(child, ".git"))) { + found.push({ root: child, rel: childRelative }); + } + scanned += 1; + if (scanned > REPO_SCAN_BUDGET) return; + walk(child, childRelative, depth + 1); + } + }; + + walk(root, "", 1); + return found.sort((left, right) => left.rel.localeCompare(right.rel)); +} + +/** + * Which of these nested repositories the repository holding them records as a + * submodule. + * + * The index is the authority here, not `.gitmodules`: the parent records the + * exact commit of a submodule as a `160000` entry, and that entry is what + * staging and committing in the parent act on. A nested repository without such + * an entry is one that merely happens to live inside another repository — the + * parent stores its files instead, or ignores them. The question is asked once + * per holding repository, with every candidate as a single pathspec, so the + * cost does not grow with the number of nested repositories. + * + * @param {string} workspaceRoot + * @param {{root: string, rel: string}[]} nested + * @returns {Promise>} the `rel` of every submodule + */ +async function submodulePaths(workspaceRoot, nested) { + const rels = new Set(nested.map((entry) => entry.rel)); + /** The repository directly holding `rel`: the longest prefix, or the root. */ + const holderOf = (rel) => { + const parts = rel.split("/"); + for (let length = parts.length - 1; length > 0; length -= 1) { + const candidate = parts.slice(0, length).join("/"); + if (rels.has(candidate)) return candidate; + } + return ""; + }; + + /* + * Grouped by the repository that holds them: the question goes to *that* + * repository's index, and the pathspec has to be in its terms — `mod/dep` is + * `dep` to `mod`. The full path would match nothing there, which would + * quietly classify every submodule of a submodule as merely nested. + */ + const groups = new Map(); + for (const entry of nested) { + const holder = holderOf(entry.rel); + if (!groups.has(holder)) { + groups.set(holder, { + root: holder ? path.join(workspaceRoot, holder) : workspaceRoot, + specs: [], + }); + } + groups.get(holder).specs.push(holder ? entry.rel.slice(holder.length + 1) : entry.rel); + } + + const submodules = new Set(); + for (const [holder, group] of groups) { + const result = await runGit( + ["ls-files", "--stage", "-z", "--", ...group.specs], + { cwd: group.root }, + ); + if (!result.ok) continue; + for (const record of result.stdout.split("\0")) { + // ` \t` + if (!record.startsWith("160000 ")) continue; + const tab = record.indexOf("\t"); + if (tab < 0) continue; + // The record's path is the holder's, so the holder has to go back on + // before it can mean anything in the workspace-wide listing. + const found = record.slice(tab + 1).replace(/\\/g, "/"); + submodules.add(holder ? `${holder}/${found}` : found); + } + } + return submodules; +} + +// Note: 发现边界(深度 4 / 5000 目录 / 跳过依赖构建缓存树)只是对项目大小的猜测,.gitmodules 声明优先于猜测;kind 由持有者 index 里的 160000 gitlink 判定,而不是 .gitmodules — 见 .agents/notes/implemented/architecture/2026-09-12-nested-repositories.md +/** + * Every repository the tool windows can be pointed at: the workspace's own + * repository first, then everything nested inside it. + * + * A declared submodule is added even when the walk's bounds left it out — a + * submodule placed deeper than `REPO_SCAN_DEPTH`, or inside a tree the skip + * list excludes. An entry in `.gitmodules` is a statement about the project, + * while the walk's limits are a guess about its size. A declared path that is + * not checked out is left out: there is no working tree to show or commit in. + */ +async function discoverRepositories(workspaceRoot) { + const workspaceIsRepo = await resolveRepositoryRoot(workspaceRoot); + const nested = scanNestedRepositories(workspaceRoot); + const known = new Set(nested.map((entry) => entry.rel)); + for (const rel of await declaredSubmodulePaths(workspaceRoot)) { + if (known.has(rel)) continue; + const absolute = path.join(workspaceRoot, rel); + if (!isInside(workspaceRoot, absolute)) continue; + if (!fs.existsSync(path.join(absolute, ".git"))) continue; + nested.push({ root: absolute, rel }); + known.add(rel); + } + + const submodules = workspaceIsRepo ? await submodulePaths(workspaceRoot, nested) : new Set(); + const repositories = nested + .sort((left, right) => left.rel.localeCompare(right.rel)) + .map((entry) => ({ + root: entry.root, + name: path.basename(entry.root), + rel: entry.rel, + // Note: 平级模式无父仓 index 可问,kind 记 sibling 而不是 nested,视图据此显示“平级仓库” — 见 .agents/notes/implemented/architecture/2026-09-12-multi-repo-push.md + kind: !workspaceIsRepo ? "sibling" : (submodules.has(entry.rel) ? "submodule" : "nested"), + })); + if (!workspaceIsRepo) return repositories; + return [ + { root: workspaceRoot, name: path.basename(workspaceRoot), rel: ".", kind: "root" }, + ...repositories, + ]; +} + +// Note: 「哪个仓库」只由 readRepo 决定——41 处 runGit 里只有它的 --show-toplevel 探测用默认 cwd,重定向这一个函数等于重定向整张命令表;选择每次校验、失效静默回退(回退比报错更接近用户预期),路径比较一律按 realpath(macOS /private/var 与 /var 是同一目录) — 见 .agents/notes/implemented/architecture/2026-09-12-nested-repositories.md +/** + * Resolve the repository the commands run against: the one the user picked in + * the repository list, or the workspace's own repository. + * + * `git rev-parse` walks up parent directories, so a workspace nested inside a + * repository still resolves to that repository's root. A picked repository is + * honoured only while the workspace it was picked in is still open, it is still + * inside that workspace, and it is still a repository — a submodule can be + * deinitialised between two refreshes, and the tool windows have to fall back + * rather than fail. + */ +async function readRepo() { + await refreshWorkspace(); + if (!cachedWorkspace) { + return { ok: false, code: "NO_WORKSPACE", message: "No workspace is open." }; + } + const top = await runGit(["rev-parse", "--show-toplevel"]); + if (!top.ok) { + // Note: 平级多仓回退——工作区本身不是仓库但内含仓库时,以首个/已选平级仓为当前仓(siblingMode),而不是 NO_REPOSITORY;无命中仍返回 NO_REPOSITORY,单仓库行为不变 — 见 .agents/notes/implemented/architecture/2026-09-12-multi-repo-push.md + // Sibling mode: the workspace is a plain folder containing repositories + // rather than a repository itself. Fall back to the selected (if still a + // repo inside it) or the first nested repository, so the repo list, + // aggregated commit and push dialog all work there too. + let siblings = []; + try { + siblings = scanNestedRepositories(cachedWorkspace); + } catch { + siblings = []; + } + if (!siblings.length) { + return { + ok: false, + code: "NO_REPOSITORY", + message: `Not a Git repository: ${cachedWorkspace}`, + }; + } + let siblingRoot = null; + if (selectedRoot && isInside(cachedWorkspace, selectedRoot)) { + siblingRoot = await resolveRepositoryRoot(selectedRoot); + if (!siblingRoot) selectedRoot = null; + } else if (selectedRoot) { + selectedRoot = null; + } + if (!siblingRoot) { + const first = siblings.slice().sort((a, b) => String(a.rel).localeCompare(String(b.rel)))[0]; + siblingRoot = (await resolveRepositoryRoot(first.root)) ?? first.root; + selectedRoot = siblingRoot; + } + if (!siblingRoot) { + return { + ok: false, + code: "NO_REPOSITORY", + message: `Not a Git repository: ${cachedWorkspace}`, + }; + } + const siblingGitDir = await runGit(["rev-parse", "--absolute-git-dir"], { cwd: siblingRoot }); + // Note: macOS 上 Git 给 /private/var 而宿主给 /var,直接 path.relative 会算出 ../../..;统一走 realPath(workspaceRelativeRoot 内部已做) — 见 nested-repositories 记忆第 2 节 + const siblingRel = workspaceRelativeRoot(cachedWorkspace, siblingRoot); + return { + ok: true, + root: siblingRoot, + name: path.basename(siblingRoot), + rel: siblingRel ?? path.basename(siblingRoot), + workspacePrefix: siblingRel, + workspaceRoot: cachedWorkspace, + siblingMode: true, + gitDir: siblingGitDir.ok ? siblingGitDir.stdout.trim() : path.join(siblingRoot, ".git"), + workspace: cachedWorkspace, + }; + } + const workspaceRoot = top.stdout.trim(); + let root = workspaceRoot; + if (selectedRoot && !samePath(selectedRoot, workspaceRoot)) { + const resolved = isInside(workspaceRoot, selectedRoot) + ? await resolveRepositoryRoot(selectedRoot) + : null; + if (resolved) root = resolved; + else selectedRoot = null; + } + if (samePath(root, workspaceRoot)) selectedRoot = null; + + const gitDir = await runGit(["rev-parse", "--absolute-git-dir"], { cwd: root }); + const relative = path.relative(workspaceRoot, root); + return { + ok: true, + root, + name: path.basename(root), + // Where the repository sits inside the workspace's own repository; "." is + // the workspace's own. + rel: relative ? relative.split(path.sep).join("/") : ".", + // Where the repository sits inside the *workspace*, which is the form the + // host's `fs` channels resolve against, and null when it is not inside it + // at all (a workspace that is itself inside the repository, say). + // + // Computed here rather than in the views because it has to be compared as a + // real path: Git answers with `/private/var/…` where the host handed out + // `/var/…`, and a view holding only strings would conclude that the + // repository is outside the workspace and refuse to open any file in it. + workspacePrefix: workspaceRelativeRoot(cachedWorkspace, root), + // The repository the workspace *is*: what a picked repository must stay + // inside, and what the repository list is built from. + workspaceRoot, + gitDir: gitDir.ok ? gitDir.stdout.trim() : path.join(root, ".git"), + workspace: cachedWorkspace, + }; +} + +/** + * The path of `root` inside `workspace`, POSIX-separated, or null when it is + * not inside it. "." means the two are the same directory. + */ +function workspaceRelativeRoot(workspace, root) { + if (!workspace || !root) return null; + const base = realPath(workspace); + const target = realPath(root); + if (target === base) return "."; + if (!target.startsWith(base + path.sep)) return null; + return target.slice(base.length + 1).split(path.sep).join("/"); +} + + /** Every repository-scoped command runs from the repository root. */ + async function withRepo(handler) { + const repo = await readRepo(); + if (!repo.ok) return repo; + return handler(repo); + } + + // Note: 聚合视图用 repoRoot 直接作用子模块文件而不切换选中仓(父仓 status 永远只给一行 gitlink,见 .agents/notes/implemented/architecture/2026-09-12-submodule-aggregation.md) + /** + * Build the repo object for an explicit absolute path inside the current + * workspace's repository (the aggregated Commit view acting on a submodule's + * files without switching `selectedRoot`). Returns the base repo when no + * override was requested, so single-repo callers keep their behaviour. + */ + function targetRootFromPayload(payload) { + const candidate = payload?.repoRoot ?? payload?.root ?? null; + return typeof candidate === "string" && candidate ? candidate : null; + } + + async function readRepoFor(overrideRoot) { + const base = await readRepo(); + if (!base.ok) return base; + if (!overrideRoot || samePath(overrideRoot, base.root)) return base; + if (!isInside(base.workspaceRoot, overrideRoot)) { + return { ok: false, message: `Not a repository inside this workspace: ${overrideRoot}` }; + } + const resolved = await resolveRepositoryRoot(overrideRoot); + if (!resolved) { + return { ok: false, message: `Not a repository inside this workspace: ${overrideRoot}` }; + } + const gitDir = await runGit(["rev-parse", "--absolute-git-dir"], { cwd: resolved }); + const forRel = workspaceRelativeRoot(base.workspaceRoot, resolved) ?? path.relative(realPath(base.workspaceRoot), realPath(resolved)); + return { + ok: true, + root: resolved, + name: path.basename(resolved), + rel: forRel ? String(forRel).split(path.sep).join("/") : ".", + workspacePrefix: workspaceRelativeRoot(base.workspace, resolved), + workspace: base.workspace, + workspaceRoot: base.workspaceRoot, + siblingMode: base.siblingMode === true, + gitDir: gitDir.ok ? gitDir.stdout.trim() : path.join(resolved, ".git"), + }; + } + + /** + * Like `withRepo`, but honours an explicit `repoRoot` (or legacy `root`) in + * the payload so one aggregated view can stage/diff/commit a submodule's + * files while the selector still points at the parent. + */ + async function withRepoAt(payload, handler) { + const override = targetRootFromPayload(payload); + const repo = override ? await readRepoFor(override) : await readRepo(); + if (!repo.ok) return repo; + return handler(repo); + } + +/** + * The refs that say an operation is unfinished. None of them is visible in + * porcelain output. + */ +const SEQUENCER_REFS = [ + ["MERGE_HEAD", "merge"], + ["REBASE_HEAD", "rebase"], + ["CHERRY_PICK_HEAD", "cherry-pick"], + ["REVERT_HEAD", "revert"], +]; + +/** Which operation is in progress, or null. One command per candidate. */ +async function probeOperation(repoRoot) { + for (const [ref, name] of SEQUENCER_REFS) { + const result = await runGit(["rev-parse", "-q", "--verify", ref], { cwd: repoRoot }); + if (result.ok && result.stdout.trim()) return name; + } + return null; +} + +/** + * The last answer `readOperation` gave, so the probe can stop asking on an + * ordinary refresh without losing the answer exactly when it matters. + * TODO: single-slot cache keyed by one root; rapid switches between nested + * repositories (or overlapping `readStatus` calls) can clobber each other's + * answer. Needs a per-root map, but that changes eviction behaviour. + */ +let pendingOperation = { root: null, operation: null }; + +/** + * Which unfinished operation left these conflicts behind, if any. + * + * The gate is not "there are conflicts". Once every conflicted file has been + * staged, porcelain v2 simply stops reporting `u` lines — but the merge or + * rebase is still in progress, and that is precisely the moment Continue starts + * being able to succeed. Gating on `conflicted` therefore hid the only way out + * of the state the plugin itself can create. So the probe runs while there are + * conflicts *or* while it found an operation a moment ago, and stops only when + * Git agrees the operation is over. A refresh outside both cases pays nothing. + */ +async function readOperation(repoRoot, conflicted) { + if (pendingOperation.root !== repoRoot) pendingOperation = { root: repoRoot, operation: null }; + if (!conflicted.length && !pendingOperation.operation) return null; + pendingOperation.operation = await probeOperation(repoRoot); + return pendingOperation.operation; +} + +/** + * A ref name or remote that came from a caller, checked before it is handed to + * Git as a positional argument. + * + * `git push origin --force` is what a `-`-prefixed value produces, which is the + * one thing the lease-only rule is supposed to make impossible; the same shape + * would turn `git fetch` into something else entirely. The views no longer send + * these fields at all, so this is the engine's own boundary: the panel bridge + * forwards any channel to `onPanelInvoke`, and a boundary that trusts its input + * is not a boundary. + */ +function refArg(value) { + const text = String(value ?? "").trim(); + if (!text) return ""; + if (text.startsWith("-") || /\s/.test(text)) return null; + return text; +} + +/** + * A branch name before it becomes a positional argument. Beyond the option + * prefix and whitespace, `..` (range) and `@{` (reflog) would change which + * revision Git resolves, and control characters never belong in a ref; Git's + * own `check-ref-format` would reject more (trailing `/`/`.`, `.lock`), but + * those only fail the command and their errors still bubble up. + */ +function isBranchNameSafe(value) { + if (/[\s~^:?*\[\\]/.test(value) || value.startsWith("-")) return false; + if (value.includes("..") || value.includes("@{")) return false; + if (/[\x00-\x1f\x7f]/.test(value)) return false; + return true; +} + +// Note: 推送目标从 @{upstream} 解析,而不是硬编码 origin 或取 git remote 的第一个(字典序会决定分支发布到哪并顺手绑定 tracking);没有 upstream 时只接受 origin 或唯一远端,多个远端时报错而不替用户猜 — 见 .agents/notes/implemented/architecture/2026-09-12-remote-sync-conflicts.md +/** + * Where a push should go. + * + * The branch's own upstream is the only target that agrees with the ↑/↓ chip: + * the views used to send `origin` plus the local branch name, which pushed a new + * branch to `origin` while the chip kept counting against the real upstream. + * With no upstream there is nothing to agree with, so the fallback stays narrow + * — `origin`, or the single remote when there is only one — and refuses to guess + * when there are several. Choosing "the first remote" would be alphabetical + * order deciding where someone's branch gets published. + */ +async function resolvePushTarget(repoRoot) { + const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { cwd: repoRoot }); + const name = upstream.ok ? upstream.stdout.trim() : ""; + if (name) { + const slash = name.indexOf("/"); + // No slash means the branch tracks another *local* branch + // (`branch..remote = .`). There is nowhere to push to, and falling + // back to a remote would publish the branch somewhere it was never pointed. + if (slash < 1) { + return { ok: false, message: `This branch tracks the local branch "${name}", so there is no remote to push to.` }; + } + return { ok: true, remote: name.slice(0, slash), branch: name.slice(slash + 1) }; + } + + const head = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }); + // An unborn branch makes `rev-parse` print HEAD and exit non-zero: there is no + // commit to push, and saying so beats Git's "no upstream branch" for a command + // whose whole point was to set one up. + if (!head.ok || head.stdout.trim() === "HEAD") { + return { ok: false, message: "This branch has no commits yet, so there is nothing to push." }; + } + const branch = head.stdout.trim(); + + const remotes = await runGit(["remote"], { cwd: repoRoot }); + const names = remotes.ok ? remotes.stdout.split("\n").map((line) => line.trim()).filter(Boolean) : []; + if (names.includes("origin")) return { ok: true, remote: "origin", branch }; + if (names.length === 1) return { ok: true, remote: names[0], branch }; + if (!names.length) return { ok: false, message: "This repository has no remote to push to." }; + + return { + ok: false, + message: `"${branch}" has no upstream, and this repository has several remotes (${names.join(", ")}). Set one with \`git push --set-upstream ${branch}\`.`, + }; +} + +/** + * The sequencer commands this plugin is willing to continue or abort, and + * nothing else: the name comes back from `readOperation`, and a whitelist keeps + * a malformed payload from becoming an arbitrary `git` verb. + */ +const SEQUENCER = new Set(["merge", "rebase", "cherry-pick", "revert"]); + +/** + * Shape a network command's outcome for the views. + * + * `message` is what a toast can hold; `detail` is stdout+stderr for the dialog + * behind it, truncated to `ERROR_DETAIL_CHARS` (see `gitDetail`); the three + * hint codes are stable identifiers that the views word in the user's own language. + */ +function syncOutcome(channel, result) { + if (result.ok) return { ok: true, stdout: result.stdout }; + return { + ok: false, + stdout: result.stdout, + stderr: result.stderr, + message: result.message, + detail: gitDetail(result.stdout, result.stderr), + authHint: authHint(result.stdout, result.stderr), + pushHint: channel === "git/push" ? pushHint(result.stdout, result.stderr) : null, + pullHint: channel === "git/pull" ? pullHint(result.stdout, result.stderr) : null, + }; +} + +// --------------------------------------------------------------------------- +// Status parsing (porcelain v2) +// --------------------------------------------------------------------------- + +const INDEX_LABELS = { + M: "Modified", + A: "Added", + D: "Deleted", + R: "Renamed", + C: "Copied", + T: "Type changed", +}; + +const CONFLICT_LABELS = { + DD: "Both deleted", + AU: "Added by us", + UD: "Deleted by them", + UA: "Added by them", + DU: "Deleted by us", + AA: "Both added", + UU: "Both modified", +}; + +function labelFor(code, conflicted) { + if (conflicted) return CONFLICT_LABELS[code] ?? "Unmerged"; + return INDEX_LABELS[code] ?? code; +} + +/** + * `git status --porcelain=v2 -z` is the only status form that is unambiguous: + * `-z` suppresses the C-style path quoting, so spaces and non-ASCII paths come + * back verbatim, and v2 keeps the index and worktree columns apart — which is + * exactly the staged/unstaged split the UI shows. + * TODO: no pagination — a huge change list is returned whole over the bridge. + * Chunking needs a view-side protocol change, so behaviour stays as-is. + */ +async function readStatus(repo) { + const result = await runGit( + ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all"], + { cwd: repo.root }, + ); + if (!result.ok) return result; + + const branch = { + oid: null, + head: null, + upstream: null, + ahead: 0, + behind: 0, + detached: false, + unborn: false, + }; + const staged = []; + const unstaged = []; + const untracked = []; + const conflicted = []; + + const tokens = result.stdout.split("\0"); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index].replace(/^\n/, ""); + if (!token) continue; + + if (token.startsWith("# ")) { + const header = token.slice(2); + if (header.startsWith("branch.oid ")) { + const value = header.slice("branch.oid ".length).trim(); + branch.oid = value; + // "(initial)" means the branch exists but has no commit yet — an unborn + // branch, not a detached HEAD. Detachment is reported by branch.head. + branch.unborn = value === "(initial)"; + } else if (header.startsWith("branch.head ")) { + const value = header.slice("branch.head ".length).trim(); + branch.head = value === "(detached)" ? null : value; + if (value === "(detached)") branch.detached = true; + } else if (header.startsWith("branch.upstream ")) { + branch.upstream = header.slice("branch.upstream ".length).trim(); + } else if (header.startsWith("branch.ab ")) { + const match = /\+(\d+)\s+-(\d+)/.exec(header); + if (match) { + branch.ahead = Number(match[1]); + branch.behind = Number(match[2]); + } + } + continue; + } + + const kind = token[0]; + + if (kind === "1" || kind === "2") { + const parts = token.split(" "); + const xy = parts[1] ?? ".."; + const sub = parts[2] ?? "N..."; + const track = kind === "2" ? parts[8] : ""; + const offset = kind === "2" ? 9 : 8; + const filePath = parts.slice(offset).join(" "); + const originalPath = + kind === "2" ? (tokens[index + 1] ?? "").replace(/^\n/, "") : null; + if (kind === "2") index += 1; + + const x = xy[0]; + const y = xy[1]; + // `S`: a submodule, with C=new commits, M=modified content, + // U=untracked content. Staging a submodule from the parent repository + // records which commit it points at; it cannot touch anything inside, so + // a submodule with dirty content keeps a worktree-side change that no + // amount of `git add` here will clear. + const isSubmodule = sub.startsWith("S") || parts[3] === "160000"; + const subState = isSubmodule + ? { commits: sub[1] === "C", modified: sub[2] === "M", untracked: sub[3] === "U" } + : null; + const insideSubmodule = Boolean(subState && (subState.modified || subState.untracked)); + + if (x !== ".") { + staged.push({ + path: filePath, + originalPath, + status: x, + label: labelFor(x, false), + score: track ? ` (${Number(track.slice(1))}%)` : "", + submodule: isSubmodule, + sub: subState, + }); + } + if (y !== ".") { + unstaged.push({ + path: filePath, + originalPath, + status: y, + label: labelFor(y, false), + submodule: isSubmodule, + sub: subState, + // The remaining worktree change lives inside the submodule. + insideSubmodule, + }); + } + continue; + } + + if (kind === "u") { + const parts = token.split(" "); + const xy = parts[1] ?? "UU"; + const filePath = parts.slice(10).join(" "); + conflicted.push({ + path: filePath, + originalPath: null, + status: xy, + label: labelFor(xy, true), + }); + continue; + } + + if (kind === "?") { + untracked.push({ path: token.slice(2), originalPath: null, status: "?", label: "Unversioned" }); + continue; + } + // kind === "!" (ignored) needs no UI. + } + + // A conflicted merge/rebase/cherry-pick cannot be finished from porcelain + // output alone, and it is the state a conflicted `pull` leaves behind, so it + // has to reach the views: without it they cannot offer the way out. + const operation = await readOperation(repo.root, conflicted); + + return { + ok: true, + // `workspace` lets the views translate repository-relative paths into the + // workspace-relative form the host's fs channels are scoped to. + repo: { + root: repo.root, + name: repo.name, + // "." is the workspace's own repository; a nested one also shows where it + // sits inside that repository, which is how the views label it. + rel: repo.rel ?? ".", + // The form the host's `fs` channels resolve against, so a view never has + // to rebuild it from two absolute paths that may spell the same directory + // differently. + // Deliberately null when the repository is not inside the workspace: the + // view then falls back to its own rebasing, whereas a "." would send it + // looking for `mod/f.txt` inside a workspace that is a *subdirectory* of + // the repository. + workspacePrefix: repo.workspacePrefix ?? null, + workspace: repo.workspace ?? null, + }, + branch, + operation, + staged, + unstaged, + untracked, + conflicted, + }; +} + /** + * Direct submodules (and embedded nested repos) with their own inner status. + * + * `git status` in the parent only prints one gitlink line per submodule + * (`1 .M S.M.. 160000 … backend`), so a parent-only Commit view can never + * show the 15 files changed inside `backend` the way IDEA does. This runs + * `readStatus` for each dirty submodule/nested repo found in the parent + * status, so the Commit view can render them inline like IDEA's grouped + * changes. Only dirty ones are queried: a clean submodule has no inner + * files to show, and probing every declared submodule on each refresh + * would cost a process per submodule even when there is nothing to show. + */ +async function readSubmoduleStatuses(repo, parentStatus) { + // Note: 平级模式无父仓可聚合,返回除当前仓外所有脏平级仓(kind sibling),视图复用同一分组渲染 — 见 .agents/notes/implemented/architecture/2026-09-12-multi-repo-push.md + if (repo?.siblingMode) { + let siblings = []; + try { + siblings = scanNestedRepositories(repo.workspaceRoot ?? repo.root); + } catch { + siblings = []; + } + const submodules = []; + for (const entry of siblings) { + let subRoot = null; + try { + subRoot = await resolveRepositoryRoot(entry.root); + } catch { + subRoot = null; + } + if (!subRoot || samePath(subRoot, repo.root)) continue; + // scan 已给出工作区相对 rel(realpath 安全),不重算 path.relative。 + const rel = entry.rel; + if (!rel || rel === "." || rel.includes("..")) continue; + const subRepo = { + root: subRoot, + name: path.basename(subRoot), + rel, + workspacePrefix: workspaceRelativeRoot(repo.workspace, subRoot), + workspace: repo.workspace ?? null, + workspaceRoot: repo.workspaceRoot ?? repo.root, + }; + let subStatus = null; + try { + subStatus = await readStatus(subRepo); + } catch { + subStatus = null; + } + if (!subStatus?.ok) continue; + const total = (subStatus.staged?.length ?? 0) + + (subStatus.unstaged?.length ?? 0) + + (subStatus.untracked?.length ?? 0) + + (subStatus.conflicted?.length ?? 0); + if (!total) continue; + submodules.push({ rel, root: subRoot, name: subRepo.name, kind: "sibling", status: subStatus }); + } + submodules.sort((a, b) => String(a.rel).localeCompare(String(b.rel))); + return { ok: true, submodules }; + } + const status = parentStatus?.ok ? parentStatus : await readStatus(repo); + if (!status.ok) return status; + const candidates = new Map(); + for (const file of [...(status.staged ?? []), ...(status.unstaged ?? [])]) { + if (file?.submodule && file?.path) candidates.set(file.path, "submodule"); + } + // Embedded (non-submodule) repos show as a single untracked dir entry; + // Git never descends into them, so their inner changes are invisible too. + // Only entries that actually resolve to a repository root are expanded, + // which keeps the 106-untracked-files case from spawning 106 processes. + for (const file of status.untracked ?? []) { + const rel = String(file?.path ?? "").replace(/\/+$/, ""); + if (!rel || rel.includes("/")) continue; + if (candidates.has(rel) || candidates.has(`${rel}/`)) continue; + candidates.set(rel, "maybe-nested"); + } + const submodules = []; + for (const [rel, kind] of candidates) { + const cleanRel = String(rel).replace(/\/+$/, ""); + if (!cleanRel || cleanRel === "." || cleanRel.includes("..")) continue; + const absolute = path.join(repo.root, cleanRel); + if (!isInside(repo.workspaceRoot ?? repo.root, absolute)) continue; + let subRoot = null; + try { + subRoot = await resolveRepositoryRoot(absolute); + } catch { + subRoot = null; + } + if (!subRoot) continue; + if (kind === "maybe-nested" && samePath(subRoot, repo.root)) continue; + const relative = path.relative(repo.workspaceRoot ?? repo.root, subRoot); + const subRepo = { + root: subRoot, + name: path.basename(subRoot), + rel: relative ? relative.split(path.sep).join("/") : cleanRel, + workspacePrefix: workspaceRelativeRoot(repo.workspace, subRoot), + workspace: repo.workspace ?? null, + workspaceRoot: repo.workspaceRoot ?? repo.root, + }; + let subStatus = null; + try { + subStatus = await readStatus(subRepo); + } catch { + subStatus = null; + } + if (!subStatus?.ok) continue; + const total = (subStatus.staged?.length ?? 0) + + (subStatus.unstaged?.length ?? 0) + + (subStatus.untracked?.length ?? 0) + + (subStatus.conflicted?.length ?? 0); + // A nested dir that turned out to be an empty/clean repo adds no signal. + if (!total) continue; + submodules.push({ + rel: subRepo.rel, + root: subRoot, + name: subRepo.name, + kind: kind === "submodule" ? "submodule" : "nested", + status: subStatus, + }); + } + submodules.sort((a, b) => String(a.rel).localeCompare(String(b.rel))); + return { ok: true, submodules }; + } + +// --------------------------------------------------------------------------- +// Diffs +// --------------------------------------------------------------------------- + +/** + * Unstaged worktree diff, or the index diff when `mode === "index"`. + * `--no-ext-diff` keeps a user-configured external diff tool from hijacking the + * output, and `--no-color` keeps our own renderer authoritative. + * TODO: a very large diff is returned whole; truncating needs a view-side + * "show more" contract, so behaviour stays as-is. + */ +async function readDiff(repo, filePath, mode, ignoreWhitespace) { + if (!filePath) return { ok: false, message: "No file given." }; + // Same boundary as stage/discard: the path arrives over the bridge and is + // passed to Git as a pathspec, so reject absolute/`..`/root shapes here. + if (!isSafePath(filePath)) return { ok: false, message: "Unsafe path rejected." }; + const base = [ + "diff", + "--no-color", + "--no-ext-diff", + "-U3", + // IDEA's "Ignore whitespaces" view option, implemented by Git itself + // rather than by hiding lines in the renderer. + ignoreWhitespace ? "-w" : null, + mode === "index" ? "--cached" : null, + "--", + filePath, + ].filter((value) => value !== null); + + let result = await runGit(base, { cwd: repo.root }); + + // An untracked file has no index entry, so `git diff` prints nothing for it: + // an empty diff is not proof that there is nothing to show. + // + // The same empty output is what a clean tracked file produces, which is why + // being untracked has to be *asked*, not inferred — without the `ls-files` + // probe, selecting a file that has no changes renders the whole of it as an + // addition. This became reachable once the tool windows could be pointed at + // another repository, because a selection can outlive the change list it came + // from; it was wrong in the single-repository case too. + if ( + mode !== "index" && + result.ok && + !result.stdout.trim() && + fs.existsSync(path.resolve(repo.root, filePath)) + ) { + // Asked, not inferred, and quietly: "is it tracked" fails for exactly the + // files this branch exists for. + const tracked = await runGit(["ls-files", "--error-unmatch", "--", filePath], { + cwd: repo.root, + quiet: true, + }); + if (!tracked.ok) { + const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null"; + result = await runGit( + ["diff", "--no-color", "--no-ext-diff", "-U3", "--no-index", "--", nullDevice, filePath], + { cwd: repo.root }, + ); + } + } + + // `git diff --no-index` exits 1 when the files differ: that is success here. + const ok = result.ok || result.code === 1; + const text = result.stdout; + return { + ok, + filePath, + mode, + // Which repository the diff was read from, so a patch built from it can be + // refused if the repository has changed by the time it comes back. + root: repo.root, + text, + binary: /^Binary files |^GIT binary patch/m.test(text), + message: ok ? undefined : result.message, + }; +} + +/** + * Split a unified diff into one entry per file, each keeping its exact header + * lines plus its hunks. Handing the header back verbatim is what lets a + * hunk patch preserve modes, renames and the pre-image blob. + */ +function splitPatch(text) { + const files = []; + let current = null; + let hunk = null; + + for (const line of text.split("\n")) { + if (line.startsWith("diff --git ")) { + current = { header: [line], hunks: [] }; + files.push(current); + hunk = null; + continue; + } + if (!current) continue; + if (line.startsWith("@@")) { + hunk = { header: line, lines: [] }; + current.hunks.push(hunk); + continue; + } + if (hunk) hunk.lines.push(line); + else current.header.push(line); + } + return files; +} + +/** + * Stage, unstage or discard exactly the hunks the user picked. The patch is + * rebuilt from the original header + selected hunks; `--recount` lets Git + * accept it even though the selected subset no longer matches the line counts + * in the original hunk headers. + */ +async function applyPatch(repo, patch, action) { + const text = String(patch ?? ""); + if (!text.trim()) return { ok: false, message: "Empty patch." }; + const base = [ + "apply", + "--recount", + "--whitespace=nowarn", + // Diffs are produced with git's default `a/`-`b/` prefixes, so strip one + // leading component. Stating it keeps the two halves in step. + "-p1", + action === "stage" ? "--cached" : null, + action === "unstage" ? "--cached" : null, + action === "unstage" || action === "discard" ? "-R" : null, + ].filter((value) => value !== null); + + const result = await runGit([...base, "-"], { cwd: repo.root, input: text }); + return { + ok: result.ok, + message: result.ok ? undefined : result.message, + stderr: result.stderr, + }; +} + +// --------------------------------------------------------------------------- +// Log, branches, stashes +// --------------------------------------------------------------------------- + +async function readLog(repo, limit, options = {}) { + const count = Math.min(Math.max(Number(limit) || 150, 1), 500); + const format = ["%H", "%P", "%h", "%an", "%at", "%s", "%D"].join(FS_CHAR) + RS_CHAR; + const branch = String(options.branch ?? "").trim(); + const author = String(options.author ?? "").trim(); + const since = String(options.since ?? "").trim(); + const until = String(options.until ?? "").trim(); + const search = String(options.search ?? "").trim(); + const paths = Array.isArray(options.paths) ? options.paths.filter((v) => typeof v === "string" && v) : []; + const args = ["log", options.topo ? "--topo-order" : "--date-order", `--max-count=${count}`]; + // Naming a branch replaces `--all`; Git rejects the two together. + if (branch) { + if (!isBranchNameSafe(branch)) { + return { ok: false, message: "Invalid branch filter." }; + } + args.push(branch); + } else { + args.push("--all"); + } + if (options.firstParent) args.push("--first-parent"); + if (options.noMerges) args.push("--no-merges"); + // Note: 过滤条件全部拼成 `--key=value` 单参数进 Git,author 文本含空格也不拆词;since/until 只接受日期与相对口径,前导 `-` 拒绝以防 option 注入 — 见 .agents/notes/implemented/feature/2026-09-14-git-operations-parity.md + if (author) { + if (author.startsWith("-")) return { ok: false, message: "Invalid author filter." }; + args.push(`--author=${author}`); + } + for (const [flag, value] of [["--since", since], ["--until", until]]) { + if (!value) continue; + if (value.startsWith("-") || /[\x00-\x1f\x7f]/.test(value)) return { ok: false, message: "Invalid date filter." }; + args.push(`${flag}=${value}`); + } + if (search) { + if (search.startsWith("-")) return { ok: false, message: "Invalid search text." }; + args.push(`--grep=${search}`, "--regexp-ignore-case"); + } + args.push(`--pretty=format:${format}`); + if (paths.length) { + if (!paths.every(isSafePath)) return { ok: false, message: "Unsafe path rejected." }; + args.push("--", ...paths); + } + const result = await runGit(args, { cwd: repo.root }); + if (!result.ok) { + // A repository without commits is not an error worth shouting about. + return { ok: true, commits: [], empty: true, message: result.message }; + } + const commits = result.stdout + .split(RS_CHAR) + .map((record) => record.replace(/^\n/, "")) + .filter((record) => record.trim()) + .map((record) => { + const [hash, parents, short, commitAuthor, at, subject, refs] = record.split(FS_CHAR); + return { + hash, + parents: parents ? parents.split(" ").filter(Boolean) : [], + short, + author: commitAuthor, + timestamp: Number(at) * 1000, + subject, + refs: refs + ? refs + .split(", ") + .map((value) => value.trim()) + .filter(Boolean) + .map(cleanRef) + : [], + }; + }); + return { ok: true, commits, empty: commits.length === 0 }; +} + +/** + * Distinct recent committers for the Log's User filter — IDEA shows All / Me / + * a user list. Aggregated from recent history (capped) rather than the whole + * repository, so a large repository stays fast: the list is a picker, not a + * census, and anything beyond it is reachable by typing the name or email + * (which filters server-side through `--author`). + * Note: 名单 capped 而手输兜底、切仓丢具名的取舍 — 见 .agents/notes/implemented/feature/2026-09-14-git-log-user-filter.md + */ +async function readAuthors(repo, limit) { + const want = Math.min(Math.max(Number(limit) || 30, 1), 100); + const result = await runGit( + ["log", "--all", "--max-count=2000", "--pretty=format:%an%x1f%ae"], + { cwd: repo.root }, + ); + // A repository without commits (or an unreadable one) simply has no + // committers to pick from — the filter still offers All / Mine / typing. + if (!result.ok) return { ok: true, authors: [] }; + const counts = new Map(); + for (const line of result.stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const sep = trimmed.indexOf(FS_CHAR); + const name = (sep < 0 ? trimmed : trimmed.slice(0, sep)).trim(); + const email = (sep < 0 ? "" : trimmed.slice(sep + 1)).trim(); + if (!name) continue; + const entry = counts.get(name) ?? { name, email: "", count: 0 }; + if (!entry.email && email) entry.email = email; + entry.count += 1; + counts.set(name, entry); + } + const authors = [...counts.values()] + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + .slice(0, want); + return { ok: true, authors }; +} + +/** `%D` yields `HEAD -> main, origin/main, tag: v1`; turn that into typed chips. */ +function cleanRef(raw) { + let value = raw; + let head = false; + if (value.startsWith("HEAD -> ")) { + head = true; + value = value.slice("HEAD -> ".length); + } else if (value === "HEAD") { + return { name: "HEAD", kind: "head", head: true }; + } + let kind = "branch"; + if (value.startsWith("tag: ")) { + kind = "tag"; + value = value.slice("tag: ".length); + } else if (value.includes("/")) { + kind = "remote"; + } + return { name: value, kind, head }; +} + +async function readBranches(repo) { + const fields = [ + "%(refname)", + "%(refname:short)", + "%(objectname:short)", + "%(HEAD)", + "%(upstream:short)", + "%(committerdate:unix)", + "%(contents:subject)", + ].join(FS_CHAR); + const result = await runGit( + ["for-each-ref", `--format=${fields}`, "refs/heads", "refs/remotes"], + { cwd: repo.root }, + ); + if (!result.ok) return { ok: false, message: result.message }; + const branches = result.stdout + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const [ref, name, short, head, upstream, date, subject] = line.split(FS_CHAR); + return { + ref, + name, + short, + current: head === "*", + upstream: upstream || null, + timestamp: Number(date) * 1000, + subject: subject ?? "", + remote: ref.startsWith("refs/remotes/"), + }; + }) + // Drop the remote's symbolic HEAD (`refs/remotes/origin/HEAD`). The test + // runs on the full refname because `%(refname:short)` shortens that ref to + // plain `origin`, which matches no `/HEAD` suffix and used to surface as a + // phantom local branch named `origin`. + .filter((branch) => !branch.ref.endsWith("/HEAD")); + return { ok: true, branches: branches.map(({ ref, ...rest }) => rest) }; +} + +async function readStashes(repo) { + const result = await runGit(["stash", "list", "--pretty=format:%gd\u001f%gs\u001f%at"], { + cwd: repo.root, + }); + if (!result.ok) return { ok: true, stashes: [] }; + const stashes = result.stdout + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const [ref, subject, at] = line.split(FS_CHAR); + return { ref, subject, timestamp: Number(at) * 1000 }; + }); + return { ok: true, stashes }; +} +async function readTags(repo) { + const fields = ["%(refname:short)", "%(objectname:short)", "%(*objectname:short)", "%(creatordate:unix)", "%(contents:subject)"].join(FS_CHAR); + const result = await runGit(["for-each-ref", `--format=${fields}`, "refs/tags"], { cwd: repo.root }); + if (!result.ok) return { ok: false, message: result.message }; + const tags = result.stdout + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const [name, short, target, date, subject] = line.split(FS_CHAR); + return { name, short, target: target || short, timestamp: Number(date) * 1000 || 0, subject: subject ?? "" }; + }); + tags.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + return { ok: true, tags }; +} + +async function readRemotes(repo) { + const result = await runGit(["remote", "-v"], { cwd: repo.root }); + if (!result.ok) return { ok: false, message: result.message }; + const byName = new Map(); + for (const line of result.stdout.split("\n")) { + const match = line.trim().match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); + if (!match) continue; + const [, name, url, kind] = match; + if (!byName.has(name)) byName.set(name, { name, fetch: null, push: null }); + byName.get(name)[kind] = url; + } + return { ok: true, remotes: [...byName.values()] }; +} + +// --------------------------------------------------------------------------- +// Guarded helpers +// --------------------------------------------------------------------------- + +function requirePaths(payload) { + const paths = Array.isArray(payload?.paths) ? payload.paths : []; + return paths.filter((value) => typeof value === "string" && value.length > 0); +} + +/** + * A path is only ever passed to Git as a pathspec after `--`, so it cannot be + * mistaken for a revision or an option; this check rejects the two shapes that + * are still dangerous regardless: absolute paths and `..` traversal. + * It also rejects anything that normalises to the repository root itself + * (`"."`, `""`, `"./"`): `discard` deletes untracked paths with `rmSync`, and + * without this `"."` would resolve to `repo.root` and delete the whole tree. + */ +function isSafePath(value) { + if (typeof value !== "string" || !value) return false; + if (path.isAbsolute(value)) return false; + const normalised = value.replace(/\\/g, "/"); + if (normalised.split("/").includes("..")) return false; + // `normalize("./")` keeps the trailing slash (`"./"`), so strip it first: + // `"."`, `"./"`, `".//"` and `"a/.."` must all refuse as the repo root. + const stripped = normalised.replace(/\/+$/, ""); + if (!stripped || path.posix.normalize(stripped) === ".") return false; + return true; +} + +// --------------------------------------------------------------------------- +// Channel router +// --------------------------------------------------------------------------- + +/** + * Every `pluginBridge.invoke("git/…")` from the view or the panel lands here. + * Unknown methods fail loudly instead of returning undefined. + */ +async function onPanelInvoke(channel, payload = {}) { + switch (channel) { + // -- repository --------------------------------------------------------- + case "git/repo": { + const repo = await readRepo(); + if (!repo.ok) return repo; + const [status, stashes, user] = await Promise.all([ + readStatus(repo), + readStashes(repo), + runGit(["config", "--get", "user.name"], { cwd: repo.root }), + ]); + if (!status.ok) return status; + return { + ...status, + siblingMode: repo.siblingMode === true, + stashes: stashes.stashes ?? [], + available: true, + // Drives the Log's "My Commits" bolding. + user: user.ok ? user.stdout.trim() : null, + version: (await runGit(["--version"], { cwd: repo.root })).stdout.trim(), + }; + } + + // -- repository list ---------------------------------------------------- + /** + * Every repository the tool windows can be pointed at. Separate from + * `git/repo` because it is the expensive one: it walks the tree, while + * `git/repo` runs on every refresh and must stay a couple of Git calls. + */ + case "git/repos": { + const repo = await readRepo(); + if (!repo.ok) return repo; + const repositories = await discoverRepositories(repo.workspaceRoot); + return { + ok: true, + // Which repository the commands currently run against, as a `rel`. + active: repo.rel, + repos: repositories.map((entry) => ({ + ...entry, + active: samePath(entry.root, repo.root), + })), + }; + } + + /** + * Point every command at another repository. Accepted only for a directory + * that is a repository root inside the current workspace's repository: the + * bridge forwards whatever a view sends, so this validates its own input + * rather than trusting a path that arrived over it. + */ + case "git/select-repo": { + const repo = await readRepo(); + if (!repo.ok) return repo; + const requested = String(payload.root ?? ""); + const resolved = isInside(repo.workspaceRoot, requested) + ? await resolveRepositoryRoot(requested) + : null; + if (!resolved) { + return { + ok: false, + message: `Not a repository inside this workspace: ${requested}`, + }; + } + // Choosing the workspace's own repository is the absence of a choice. + selectedRoot = samePath(resolved, repo.workspaceRoot) ? null : resolved; + const next = await readRepo(); + if (!next.ok) return next; + return { ok: true, active: next.rel, root: next.root, name: next.name }; + } + + case "git/status": + return withRepoAt(payload, (repo) => readStatus(repo)); + + case "git/submodule-statuses": + return withRepo(async (repo) => readSubmoduleStatuses(repo)); + + case "git/diff": + return withRepoAt(payload, (repo) => + readDiff( + repo, + String(payload.path ?? ""), + payload.mode === "index" ? "index" : "worktree", + payload.ignoreWhitespace === true, + ), + ); + + case "git/log": + return withRepo((repo) => + readLog(repo, payload.limit, { + branch: payload.branch, + firstParent: payload.firstParent === true, + noMerges: payload.noMerges === true, + topo: payload.topo === true, + author: payload.author, + since: payload.since, + until: payload.until, + search: payload.search, + paths: payload.paths, + }), + ); + + case "git/authors": + return withRepo((repo) => readAuthors(repo, payload.limit)); + + case "git/commit-diff": + return withRepo(async (repo) => { + const hash = String(payload.hash ?? ""); + if (!/^[0-9a-f]{4,64}$/i.test(hash)) return { ok: false, message: "Invalid commit hash." }; + const result = await runGit( + ["show", "--no-color", "--no-ext-diff", "--format=", "-U3", hash], + { cwd: repo.root }, + ); + const meta = await runGit( + ["show", "--no-color", "--no-patch", `--format=%H${FS_CHAR}%h${FS_CHAR}%an${FS_CHAR}%ae${FS_CHAR}%at${FS_CHAR}%s${FS_CHAR}%b`, hash], + { cwd: repo.root }, + ); + const [full, short, author, email, at, subject, body] = meta.stdout.split(FS_CHAR); + return { + ok: result.ok, + text: result.stdout, + meta: { + hash: full, + short, + author, + email, + timestamp: Number(at) * 1000, + subject, + body: (body ?? "").trim(), + }, + message: result.ok ? undefined : result.message, + }; + }); + + case "git/branches": { + const branches = await withRepo((repo) => readBranches(repo)); + return branches; + } + + case "git/tags": + return withRepo((repo) => readTags(repo)); + + case "git/remotes": + return withRepo((repo) => readRemotes(repo)); + + case "git/stashes": + return withRepo((repo) => readStashes(repo)); + + // -- staging ------------------------------------------------------------ + case "git/stage": + case "git/unstage": { + const paths = requirePaths(payload); + if (!paths.length) return { ok: false, message: "No paths given." }; + if (!paths.every(isSafePath)) return { ok: false, message: "Unsafe path rejected." }; + return withRepoAt(payload, async (repo) => { + const staging = channel === "git/stage"; + const args = staging + ? ["add", "--", ...paths] + : ["restore", "--staged", "--", ...paths]; + let result = await runGit(args, { cwd: repo.root }); + // `git restore --staged` needs HEAD; in a fresh repository there is none. + if (!staging && !result.ok) { + result = await runGit(["rm", "--cached", "-r", "--", ...paths], { cwd: repo.root }); + } + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + case "git/discard": { + const paths = requirePaths(payload); + if (!paths.length) return { ok: false, message: "No paths given." }; + if (!paths.every(isSafePath)) return { ok: false, message: "Unsafe path rejected." }; + return withRepoAt(payload, async (repo) => { + const tracked = []; + const removed = []; + for (const filePath of paths) { + // An untracked file has no worktree version to restore, so discarding + // it means deleting the file. Everything else is a worktree restore. + const check = await runGit(["ls-files", "--error-unmatch", "--", filePath], { + cwd: repo.root, + }); + if (check.ok) tracked.push(filePath); + else removed.push(filePath); + } + const messages = []; + if (tracked.length) { + const result = await runGit(["restore", "--worktree", "--", ...tracked], { + cwd: repo.root, + }); + if (!result.ok) messages.push(result.message); + } + for (const filePath of removed) { + const resolved = path.resolve(repo.root, filePath); + // Defense in depth with `isSafePath`: never delete the repository root + // itself even if a `"."` pathspec slipped through. + if (samePath(resolved, repo.root)) { + messages.push(`Refused to discard repository root: ${filePath}`); + continue; + } + try { + fs.rmSync(resolved, { recursive: true, force: true }); + } catch (error) { + messages.push(String(error?.message ?? error)); + } + } + return { ok: messages.length === 0, message: messages.filter(Boolean).join("; ") || undefined }; + }); + } + + case "git/apply-patch": { + const action = String(payload.action ?? ""); + if (!["stage", "unstage", "discard"].includes(action)) { + return { ok: false, message: `Unknown patch action: ${action}` }; + } + // A patch carries the file paths and the content it was built from, and + // `discard` writes to the worktree. The repository list is one click away + // and a switch starts a reload the user can click through, so a patch can + // outlive the repository it came from — where its paths can name a + // different file entirely. Refuse rather than edit the wrong one. + const from = typeof payload.root === "string" && payload.root ? payload.root : null; + // Aggregated view: `repoRoot` names the repo to act in, `root` the repo + // the diff was read from. Both are the submodule root for submodule + // files; legacy callers send only `root` and act in the selected repo. + const targetOverride = typeof payload.repoRoot === "string" && payload.repoRoot ? payload.repoRoot : null; + const runner = targetOverride ? (handler) => withRepoAt({ repoRoot: targetOverride }, handler) : withRepo; + return runner((repo) => { + if (from && !samePath(from, repo.root)) { + return { + ok: false, + code: "STALE_REPOSITORY", + message: "This diff came from another repository; refresh and try again.", + }; + } + return applyPatch(repo, payload.patch, action); + }); + } + + // -- committing --------------------------------------------------------- + case "git/commit": { + const message = String(payload.message ?? "").trim(); + if (!message && !payload.amend) return { ok: false, message: "Commit message is empty." }; + return withRepoAt(payload, async (repo) => { + const args = ["commit"]; + if (payload.amend) args.push("--amend"); + if (message) args.push("-F", "-"); + else args.push("--no-edit"); + if (payload.signoff) args.push("--signoff"); + const result = await runGit(args, { cwd: repo.root, input: message }); + return { + ok: result.ok, + stdout: result.stdout, + message: result.ok ? undefined : result.message, + }; + }); + } + + case "git/last-message": + return withRepo(async (repo) => { + const result = await runGit(["log", "-1", "--pretty=format:%B"], { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? result.stdout.trim() : result.message }; + }); + + // -- branches and sync --------------------------------------------------- + case "git/checkout": + return withRepo(async (repo) => { + const name = String(payload.name ?? "").trim(); + const startPointRaw = String(payload.startPoint ?? "").trim(); + // `startPoint` becomes a positional argument too: the same option-injection + // shape (`-b`, `--upload-pack=…`) must not pass through unchecked. + const startPoint = startPointRaw ? refArg(startPointRaw) : ""; + if (!name) return { ok: false, message: "Branch name is required." }; + if (!isBranchNameSafe(name)) { + return { ok: false, message: "Branch name contains invalid characters." }; + } + if (startPoint === null) return { ok: false, message: "Invalid start point." }; + // `track: true` means the caller picked a remote-tracking branch out of + // a list it already classified (the Branches pane, the branch chip): + // check it out the way IDEA does — a local branch tracking it — instead + // of detaching at it. The remote-ness is re-verified here rather than + // trusted, because the panel bridge forwards any channel to + // `onPanelInvoke`. A raw revision still takes the `--detach` path below. + // Note: 跟踪检出取代了原来的 detach 语义(用户要的是 IDEA 行为)——见 .agents/notes/implemented/bug-fix/2026-09-12-checkout-remote-pathspec.md + if (payload.track === true && !payload.create) { + if (!startPoint || !startPoint.includes("/") || startPoint.endsWith("/HEAD")) { + return { ok: false, message: "Not a remote-tracking branch." }; + } + const probe = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/${startPoint}`], { cwd: repo.root }); + if (!probe.ok) return { ok: false, message: "Not a remote-tracking branch." }; + const local = startPoint.slice(startPoint.indexOf("/") + 1); + const first = await runGit(["switch", "--track", startPoint], { cwd: repo.root }); + if (first.ok) return { ok: true, message: undefined }; + if (/already exists/i.test(first.message ?? "")) { + // The local branch is already there — it is what the click meant. + // Its own error (a dirty tree names the file) is the actionable + // one here, not the expected "already exists". + const existing = await runGit(["switch", local], { cwd: repo.root }); + if (existing.ok) return { ok: true, message: undefined }; + const existingFallback = await runGit(["checkout", local], { cwd: repo.root }); + return { ok: existingFallback.ok, message: existingFallback.ok ? undefined : existing.message }; + } + const second = await runGit(["checkout", "--track", startPoint], { cwd: repo.root }); + // When both fail, keep the FIRST error (see below). + return { ok: second.ok, message: second.ok ? undefined : first.message }; + } + // A remote-tracking branch (or any raw revision) cannot be "switched + // to": `switch` demands `--detach` for those, and `checkout` with two + // positionals reads the second as a pathspec — `checkout origin/main + // origin/main` fails with `error: pathspec 'origin/main' did not match + // any file(s) known to git`. Build both commands in the same shape so + // the fallback cannot disagree with the attempt. + // Note: 兜底曾经是 checkout ,切远端分支必现 pathspec 误报且吞掉 switch 的真报错(含 --detach 提示)— 见 .agents/notes/implemented/bug-fix/2026-09-12-checkout-remote-pathspec.md + const attempt = payload.create + ? ["switch", "--create", name, ...(startPoint ? [startPoint] : [])] + : startPoint + ? ["switch", "--detach", startPoint] + : ["switch", name]; + const fallback = payload.create + ? ["checkout", "-b", name, ...(startPoint ? [startPoint] : [])] + : startPoint + ? ["checkout", "--detach", startPoint] + : ["checkout", name]; + let result = await runGit(attempt, { cwd: repo.root }); + if (!result.ok) { + // No `switch` (Git < 2.23), or `switch` refusing a real checkout. + // When both fail, keep the FIRST error: it names the actual cause + // (local changes in the way, the --detach hint), while the + // fallback's wording only adds confusion. + const second = await runGit(fallback, { cwd: repo.root }); + if (second.ok) result = second; + } + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + case "git/create-branch": + return withRepo(async (repo) => { + const name = String(payload.name ?? "").trim(); + if (!name) return { ok: false, message: "Branch name is required." }; + if (!isBranchNameSafe(name)) { + return { ok: false, message: "Branch name contains invalid characters." }; + } + const result = await runGit(["branch", "--", name], { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + + case "git/fetch": { + const remote = refArg(payload.remote); + const branch = refArg(payload.branch); + if (remote === null || branch === null) return { ok: false, message: "Invalid remote or branch name." }; + const target = [remote, branch].filter(Boolean); + const prune = payload.prune === true; + return withRepo(async (repo) => { + // Prompts are disabled, so this fails fast and visibly rather than + // hanging with no terminal to answer it. + const result = await runGit(["fetch", ...(prune ? ["--prune"] : []), ...target], { + cwd: repo.root, + timeoutMs: COMMAND_TIMEOUT_MS, + }); + return syncOutcome("git/fetch", result); + }); + } + + // Note: 分支的合并/变基/改名/删除/upstream 全部是独立通道:渲染层只传 ref 名、不拼 Git 参数,option 注入由 refArg/isBranchNameSafe 在引擎边界拦 — 见 .agents/notes/implemented/feature/2026-09-14-git-operations-parity.md + case "git/merge": { + const ref = refArg(payload.ref ?? payload.name); + if (ref === null) return { ok: false, message: "Invalid ref name." }; + if (!ref) return { ok: false, message: "Branch name is required." }; + return withRepo(async (repo) => { + const result = await runGit(["merge", "--no-edit", ref], { cwd: repo.root }); + return { + ok: result.ok, + stdout: result.stdout, + stderr: result.stderr, + message: result.ok ? undefined : result.message, + detail: result.ok ? undefined : gitDetail(result.stdout, result.stderr), + }; + }); + } + + case "git/rebase": { + const ref = refArg(payload.ref ?? payload.name); + if (ref === null) return { ok: false, message: "Invalid ref name." }; + if (!ref) return { ok: false, message: "Branch name is required." }; + return withRepo(async (repo) => { + const result = await runGit(["rebase", ref], { cwd: repo.root }); + return { + ok: result.ok, + stdout: result.stdout, + stderr: result.stderr, + message: result.ok ? undefined : result.message, + detail: result.ok ? undefined : gitDetail(result.stdout, result.stderr), + }; + }); + } + + case "git/branch-delete": { + const name = String(payload.name ?? "").trim(); + if (!name) return { ok: false, message: "Branch name is required." }; + if (!isBranchNameSafe(name)) return { ok: false, message: "Branch name contains invalid characters." }; + const remote = refArg(payload.remote); + if (remote === null) return { ok: false, message: "Invalid remote name." }; + return withRepo(async (repo) => { + if (remote) { + const short = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name; + const result = await runGit(["push", remote, "--delete", short], { cwd: repo.root, timeoutMs: COMMAND_TIMEOUT_MS }); + return syncOutcome("git/fetch", result); + } + const force = payload.force === true; + const result = await runGit(["branch", force ? "-D" : "-d", "--", name], { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + case "git/branch-rename": { + const oldName = String(payload.old ?? payload.name ?? "").trim(); + const newName = String(payload.new ?? payload.to ?? "").trim(); + if (!oldName || !newName) return { ok: false, message: "Branch name is required." }; + if (!isBranchNameSafe(oldName) || !isBranchNameSafe(newName)) { + return { ok: false, message: "Branch name contains invalid characters." }; + } + return withRepo(async (repo) => { + const result = await runGit(["branch", "-m", "--", oldName, newName], { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + case "git/branch-upstream": { + const name = String(payload.name ?? "").trim(); + const upstream = refArg(payload.upstream); + if (upstream === null) return { ok: false, message: "Invalid upstream name." }; + if (name && !isBranchNameSafe(name)) return { ok: false, message: "Branch name contains invalid characters." }; + return withRepo(async (repo) => { + const args = payload.unset === true + ? ["branch", "--unset-upstream", ...(name ? [name] : [])] + : upstream + ? ["branch", `--set-upstream-to=${upstream}`, ...(name ? [name] : [])] + : null; + if (!args) return { ok: false, message: "Upstream is required." }; + const result = await runGit(args, { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + case "git/tag-delete": { + const name = String(payload.name ?? "").trim(); + if (!name || /[\s~^:?*\[\\]/.test(name) || name.startsWith("-")) { + return { ok: false, message: "Invalid tag name." }; + } + return withRepo(async (repo) => { + const result = await runGit(["tag", "-d", "--", name], { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + case "git/tag-push": { + const name = String(payload.name ?? "").trim(); + if (!name || /[\s~^:?*\[\\]/.test(name) || name.startsWith("-")) { + return { ok: false, message: "Invalid tag name." }; + } + const remote = refArg(payload.remote); + if (remote === null) return { ok: false, message: "Invalid remote name." }; + return withRepo(async (repo) => { + let targetRemote = remote; + if (!targetRemote) { + const remotes = await runGit(["remote"], { cwd: repo.root }); + const names = remotes.ok ? remotes.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []; + if (names.includes("origin")) targetRemote = "origin"; + else if (names.length === 1) targetRemote = names[0]; + else if (!names.length) return { ok: false, message: "This repository has no remote to push to." }; + else return { ok: false, message: `Several remotes (${names.join(", ")}). Choose one to push the tag to.` }; + } + const result = await runGit(["push", targetRemote, "tag", "--", name], { cwd: repo.root, timeoutMs: COMMAND_TIMEOUT_MS }); + return syncOutcome("git/push", result); + }); + } + + case "git/stash-show": { + const ref = String(payload.ref ?? "").trim(); + if (ref && !/^stash@\{\d+\}$/.test(ref)) return { ok: false, message: "Invalid stash ref." }; + return withRepo(async (repo) => { + const result = await runGit(["stash", "show", "-p", ...(ref ? [ref] : [])], { cwd: repo.root }); + return { ok: result.ok, text: result.stdout, message: result.ok ? undefined : result.message }; + }); + } + + case "git/compare": { + const a = refArg(payload.a); + const b = refArg(payload.b); + if (a === null || b === null) return { ok: false, message: "Invalid revision." }; + if (!a || !b) return { ok: false, message: "Two revisions are required." }; + return withRepo(async (repo) => { + const result = await runGit(["diff", "--no-color", "--no-ext-diff", `${a}...${b}`], { cwd: repo.root }); + if (!result.ok || !result.stdout.trim()) { + const fallback = await runGit(["diff", "--no-color", "--no-ext-diff", a, b], { cwd: repo.root }); + return { ok: fallback.ok, text: fallback.stdout, message: fallback.ok ? undefined : fallback.message }; + } + return { ok: true, text: result.stdout }; + }); + } + + // Note: 推送只能走 --force-with-lease(裸 --force 会静默抹掉同事的提交);pull 先按用户配置原样跑、只在 Git 因没策略拒绝时补一次 --no-rebase——自己读 pull.rebase/pull.ff 拼参数是 Git 优先级规则的第二个、更差的副本 — 见 .agents/notes/implemented/architecture/2026-09-12-remote-sync-conflicts.md + case "git/push": { + const remote = refArg(payload.remote); + const branch = refArg(payload.branch); + if (remote === null || branch === null) return { ok: false, message: "Invalid remote or branch name." }; + // Note: 聚合推送不切换选中仓——repoRoot 显式覆盖让 Push 对话框与 Commit and Push 逐仓推送,缺省仍是当前仓 — 见 .agents/notes/implemented/architecture/2026-09-12-multi-repo-push.md + return withRepoAt(payload, async (repo) => { + const target = remote && branch ? { ok: true, remote, branch } : await resolvePushTarget(repo.root); + if (!target.ok) return target; + const args = ["push", target.remote, target.branch].filter(Boolean); + // The views send this whenever the branch has no upstream, so that the + // first push establishes tracking and the ahead/behind counts start + // working. It used to be accepted and then ignored. + if (payload.setUpstream === true) args.push("--set-upstream"); + // Never a bare `--force`: the lease keeps the overwrite conditional on + // the remote still being where it was when this plugin last saw it, so a + // push that landed in between is refused instead of erased. `refArg` + // above is what keeps a caller from smuggling `--force` in as the remote. + if (payload.forceWithLease === true) args.push("--force-with-lease"); + const result = await runGit(args, { cwd: repo.root, timeoutMs: COMMAND_TIMEOUT_MS }); + return syncOutcome("git/push", result); + }); + } + + case "git/push-statuses": { + const repo = await readRepo(); + if (!repo.ok) return repo; + const repositories = await discoverRepositories(repo.workspaceRoot); + const out = []; + for (const entry of repositories) { + let resolved = null; + try { + resolved = await resolveRepositoryRoot(entry.root); + } catch { + resolved = null; + } + if (!resolved) continue; + // discover 已给出工作区相对 rel(realpath 安全),不重算。 + const repoObj = { + root: resolved, + name: path.basename(resolved), + rel: entry.rel, + workspacePrefix: workspaceRelativeRoot(repo.workspace, resolved), + workspace: repo.workspace ?? null, + workspaceRoot: repo.workspaceRoot ?? resolved, + }; + let status = null; + try { + status = await readStatus(repoObj); + } catch { + status = null; + } + if (!status?.ok) continue; + const target = await resolvePushTarget(resolved); + out.push({ + rel: repoObj.rel, + root: resolved, + name: repoObj.name, + kind: entry.kind, + active: samePath(resolved, repo.root), + branch: status.branch, + pushTarget: target.ok ? { remote: target.remote, branch: target.branch } : null, + pushError: target.ok ? null : (target.message ?? null), + }); + } + out.sort((a, b) => String(a.rel).localeCompare(String(b.rel))); + return { ok: true, repos: out }; + } + + case "git/pull": { + const remote = refArg(payload.remote); + const branch = refArg(payload.branch); + if (remote === null || branch === null) return { ok: false, message: "Invalid remote or branch name." }; + const target = [remote, branch].filter(Boolean); + return withRepo(async (repo) => { + // Run it as the user configured it. `git pull` itself knows + // `branch..rebase`, `pull.rebase` (including `merges` and + // `interactive`), `pull.ff` and `pull.rebase=false`; re-deriving any of + // that here would be a second, worse copy of Git's own precedence rules. + const first = await runGit(["pull", ...target], { cwd: repo.root, timeoutMs: COMMAND_TIMEOUT_MS }); + if (first.ok || !needsReconcile(first.stdout, first.stderr)) return syncOutcome("git/pull", first); + // Since 2.27, `git pull` with no strategy configured is a hard fatal on a + // diverged branch — so the one command a refused push tells you to run + // could not run at all. Answer that one question, once, by merging: it is + // what `git pull` did before 2.27 and what IDEA's "Update Project" does + // by default, and it is the recoverable answer, because a merge that stops + // on conflicts lands in the Changes list where this plugin can finish or + // abort it. Every other failure is reported as it came. + const retry = await runGit(["pull", "--no-rebase", ...target], { cwd: repo.root, timeoutMs: COMMAND_TIMEOUT_MS }); + return syncOutcome("git/pull", retry); + }); + } + + /** + * Leave an unfinished merge/rebase/cherry-pick, or move it along. + * + * This exists because the plugin's own Pull can stop on conflicts: without + * it, the plugin could put a repository into a state it had no way to leave + * — resolving every conflict by hand, and even then needing a terminal to + * continue a rebase. + */ + case "git/sequencer": { + const operation = String(payload.operation ?? ""); + const action = String(payload.action ?? ""); + if (!SEQUENCER.has(operation)) return { ok: false, message: `Unknown operation: ${operation || "(none)"}` }; + if (action !== "abort" && action !== "continue") { + return { ok: false, message: `Unknown action: ${action || "(none)"}` }; + } + // A merge is finished by committing it, which the commit button already + // does; only the sequencer commands have a `--continue` of their own. + if (action === "continue" && operation === "merge") { + return { ok: false, message: "A merge is finished by committing it." }; + } + return withRepo(async (repo) => { + // `--continue` will open an editor for the commit message. There is no + // terminal here and stdin is closed, so Git could only fail; setting the + // editor to `true` accepts the message the operation already recorded, + // which is what pressing Continue means. + const prefix = action === "continue" ? ["-c", "core.editor=true"] : []; + const result = await runGit([...prefix, operation, `--${action}`], { cwd: repo.root }); + return { + ok: result.ok, + stdout: result.stdout, + message: result.ok ? undefined : result.message, + detail: gitDetail(result.stdout, result.stderr), + }; + }); + } + + case "git/stash": { + const action = String(payload.action ?? "push"); + return withRepo(async (repo) => { + let args; + if (action === "push") { + args = ["stash", "push", "--include-untracked", "--message", String(payload.message ?? "").trim() || "IDEA Git stash"]; + } else if (action === "pop" || action === "apply") { + const ref = String(payload.ref ?? "").trim(); + // Only `stash@{n}` may reach Git: anything else is either an option + // (`--help`) or a revision that names the wrong object. Empty means + // the top stash, which is what `git stash pop` does with no ref. + if (ref && !/^stash@\{\d+\}$/.test(ref)) return { ok: false, message: "Invalid stash ref." }; + args = ["stash", action, ...(ref ? [ref] : [])]; + } else if (action === "drop") { + const ref = String(payload.ref ?? "").trim(); + if (ref && !/^stash@\{\d+\}$/.test(ref)) return { ok: false, message: "Invalid stash ref." }; + args = ["stash", "drop", ...(ref ? [ref] : [])]; + } else { + return { ok: false, message: `Unknown stash action: ${action}` }; + } + const result = await runGit(args, { cwd: repo.root }); + return { ok: result.ok, stdout: result.stdout, message: result.ok ? undefined : result.message }; + }); + } + + // -- misc --------------------------------------------------------------- + // -- commit message drafting --------------------------------------------- + case "git/models": { + const listing = await listModels(); + if (!listing.ok) return listing; + return { ok: true, models: listing.models, preferred: (await readPrefs()).ui?.commitModelKey ?? "" }; + } + + case "git/commit-message": + return withRepoAt(payload, (repo) => draftCommitMessage(repo, payload)); + + // -- commit-level actions (Log) ------------------------------------------ + case "git/cherry-pick": + case "git/revert": { + const hash = String(payload.hash ?? ""); + if (!/^[0-9a-f]{4,64}$/i.test(hash)) return { ok: false, message: "Invalid commit hash." }; + return withRepo(async (repo) => { + const verb = channel === "git/cherry-pick" ? "cherry-pick" : "revert"; + const result = await runGit([verb, "--no-edit", hash], { cwd: repo.root }); + return { + ok: result.ok, + stdout: result.stdout, + stderr: result.stderr, + message: result.ok ? undefined : result.message, + }; + }); + } + + // TODO: `reset --hard`/`--keep` discards worktree content with no backup and + // no server-side confirmation; removing the modes needs a UI/UX decision + // (confirm dialog, reflog note), so behaviour stays as-is. + case "git/reset": { + const hash = String(payload.hash ?? ""); + if (!/^[0-9a-f]{4,64}$/i.test(hash)) return { ok: false, message: "Invalid commit hash." }; + const mode = ["soft", "mixed", "hard", "keep"].includes(payload.mode) ? payload.mode : "mixed"; + return withRepo(async (repo) => { + const result = await runGit(["reset", `--${mode}`, hash], { cwd: repo.root }); + return { ok: result.ok, stdout: result.stdout, stderr: result.stderr, message: result.ok ? undefined : result.message }; + }); + } + + case "git/tag": { + const name = String(payload.name ?? "").trim(); + const hash = String(payload.hash ?? "").trim(); + if (!name || /[\s~^:?*\[\\]/.test(name) || name.startsWith("-")) { + return { ok: false, message: "Invalid tag name." }; + } + if (hash && !/^[0-9a-f]{4,64}$/i.test(hash)) return { ok: false, message: "Invalid commit hash." }; + return withRepo(async (repo) => { + const result = await runGit(["tag", name, ...(hash ? [hash] : [])], { cwd: repo.root }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + /** `--name-status` gives the Changed Files pane its list, one line per file. */ + case "git/commit-files": { + const hash = String(payload.hash ?? ""); + if (!/^[0-9a-f]{4,64}$/i.test(hash)) return { ok: false, message: "Invalid commit hash." }; + return withRepo(async (repo) => { + const result = await runGit( + ["show", "--no-color", "--name-status", "--format=", "--no-renames", hash], + { cwd: repo.root }, + ); + const files = result.stdout + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const parts = line.split("\t"); + return { status: (parts[0] ?? "M").charAt(0), path: parts.slice(1).join("\t") }; + }) + .filter((entry) => entry.path); + return { ok: result.ok, files, message: result.ok ? undefined : result.message }; + }); + } + + /** Rewording is only safe for the tip, which is what the UI offers. */ + case "git/amend-message": { + const message = String(payload.message ?? "").trim(); + if (!message) return { ok: false, message: "Commit message is empty." }; + return withRepo(async (repo) => { + const result = await runGit(["commit", "--amend", "-F", "-"], { cwd: repo.root, input: message }); + return { ok: result.ok, message: result.ok ? undefined : result.message }; + }); + } + + // -- preferences -------------------------------------------------------- + case "git/prefs": + return { ok: true, prefs: await readPrefs() }; + + case "git/prefs-set": { + const current = await readPrefs(); + const uiPatch = payload?.ui && typeof payload.ui === "object" ? payload.ui : {}; + return { ok: true, prefs: await writePrefs({ messages: current.messages, ui: { ...current.ui, ...uiPatch } }) }; + } + + case "git/message-used": + return { ok: true, prefs: await writePrefs(rememberMessage(await readPrefs(), payload?.message)) }; + + // -- console ------------------------------------------------------------ + case "git/console": + return { ok: true, entries: consoleLog() }; + + case "git/console-clear": + clearConsoleLog(); + return { ok: true }; + + case "git/relative-time": + return { ok: true, now: Date.now() }; + + default: + return { ok: false, message: `Unsupported channel: ${channel}` }; + } +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +const OPEN_COMMAND = "pi-idea-git.open"; +const REFRESH_COMMAND = "pi-idea-git.refresh"; + +/** + * The workspace listener is rebuilt on every load and released on unload. + * + * This host's `pi.events.on` returns nothing (its only `return` is the early + * guard for a non-function handler), so the unsubscribe handle cannot be relied + * on — `pi.events.off(event, handler)` is the pairing that actually exists, and + * it needs the handler reference kept here. A host that does hand back a + * function is honoured too, since that removes the need for `off`. + */ +let workspaceListenerOff = null; +let workspaceListenerHandler = null; + +function detachWorkspaceListener() { + const off = workspaceListenerOff; + const handler = workspaceListenerHandler; + workspaceListenerOff = null; + workspaceListenerHandler = null; + try { + if (typeof off === "function") off(); + else if (handler) pi.events?.off?.("workspace:changed", handler); + } catch { + // The host already tore the listener down. + } +} + +/** + * Idempotent: a second `onLoad` without an intervening `onUnload` must not stack + * a duplicate handler — one is detached before the next is installed. + */ +function bindWorkspaceListener() { + detachWorkspaceListener(); + try { + const handler = () => { + refreshWorkspace().catch(() => {}); + }; + const off = pi.events?.on?.("workspace:changed", handler); + workspaceListenerHandler = handler; + workspaceListenerOff = typeof off === "function" ? off : null; + } catch { + // Older host without plugin-process events: readRepo still refreshes. + } +} + +async function onLoad() { + // The host re-broadcasts workspace switches to plugin processes; tracking them + // keeps `workspacePath()` honest for any path that does not go through + // `readRepo()` (which refreshes on its own). + bindWorkspaceListener(); + + + await pi.commands.register({ + id: OPEN_COMMAND, + title: "IDEA Git: Open as separate window", + keywords: ["git", "idea", "commit", "diff", "stage"], + run: async () => { + await pi.ui.openPanel({ title: "IDEA Git" }); + }, + }); + + await pi.commands.register({ + id: REFRESH_COMMAND, + title: "IDEA Git: Refresh repository", + keywords: ["git", "refresh"], + run: async () => { + await refreshWorkspace(); + const repo = await readRepo(); + // A nested repository is named by where it sits: `mod` alone would not + // say which one was picked when the workspace holds several. + const label = repo.ok && repo.rel && repo.rel !== "." ? repo.rel : repo.name; + await pi.ui.showToast( + repo.ok ? `Git repository: ${label}` : repo.message, + repo.ok ? "info" : "error", + ); + }, + }); +} + +async function onUnload() { + selectedRoot = null; + detachWorkspaceListener(); + + gitBinaryCache = undefined; + try { + await pi.commands.unregister(OPEN_COMMAND); + } catch { + // The host already tore the commands down. + } + try { + await pi.commands.unregister(REFRESH_COMMAND); + } catch { + // Same. + } +} + +module.exports = { onLoad, onUnload, onPanelInvoke, refreshWorkspace }; diff --git a/plugins/io.github.liushunqiu.pi-idea-git/manifest.json b/plugins/io.github.liushunqiu.pi-idea-git/manifest.json new file mode 100644 index 0000000..3178033 --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/manifest.json @@ -0,0 +1,106 @@ +{ + "schemaVersion": 1, + "id": "io.github.liushunqiu.pi-idea-git", + "name": "IDEA Git", + "version": "0.3.0", + "description": "An IntelliJ-IDEA-style Git tool window for PI-Desktop: staged/unstaged change lists, hunk-level stage & revert, commit, branch switcher, graph log and stashes. IDEA Git 风格的 Git 工具窗口:暂存/未暂存分组、按代码块暂存与还原、提交、分支切换、图形化日志与储藏。", + "author": "liushunqiu", + "homepage": "https://github.com/liushunqiu/pi-idea-git", + "repository": "https://github.com/liushunqiu/pi-idea-git", + "i18n": { + "en": { + "name": "IDEA Git", + "description": "An IntelliJ-IDEA-style Git tool window for PI-Desktop: staged/unstaged change lists, hunk-level stage & revert, commit, branch switcher, graph log and stashes.", + "safetyNotes": "Reads workspace files through the host fs APIs (Open File / Reveal only); discarding an untracked file deletes it (with a confirmation dialog) — every other write goes through Git commands. Commit-message generation calls the host model with your own model setup and quota (host rate limit 8/min); the plugin holds no API keys, makes no network requests, no telemetry. HTTPS credentials come from your own Git credential helper; keys that live only in ssh-agent do not work — use an HTTPS remote or a keychain-backed key." + }, + "zh-CN": { + "name": "IDEA Git", + "description": "PI-Desktop 的 IDEA 风格 Git 工具窗口:暂存/未暂存分组、按代码块暂存与还原、提交、分支切换、图形化日志与储藏。", + "safetyNotes": "只读工作区文件(Open File / Reveal 走宿主 fs 接口);丢弃未跟踪文件时会直接删除该文件(操作前有确认框),其余写操作一律走 Git 命令。生成提交信息调用宿主模型,用的是你自己的模型配置与额度(宿主限流 8 次/分),插件不持有任何 API Key,不发起网络请求,无遥测。HTTPS 凭据走你自己的 Git 凭据助手;只活在 ssh-agent 里的密钥用不了,请用 HTTPS 远端或钥匙串密钥。" + } + }, + "categories": [ + "developer-tools", + "productivity" + ], + "changelog": "Release 0.3.0: Git operations parity (Branches panel with HEAD/Tags/Remotes/Stashes groups and slash-folder collapsing; branch merge/rebase/rename/upstream/delete; commit copy/compare; toolbar user/date/path server-side filters, Go to HEAD, Fetch+Pull shortcuts); git/log server-side author/since/until/search/paths filters plus git/tags, git/remotes, git/merge, git/rebase and more; remote checkout creates a tracking branch (IDEA behavior); commit-message drafting follows the checked/staged scope.", + "safetyNotes": "只读工作区文件(Open File / Reveal 走宿主 fs 接口);丢弃未跟踪文件时会直接删除该文件(操作前有确认框),其余写操作一律走 Git 命令。生成提交信息调用宿主模型,用的是你自己的模型配置与额度(宿主限流 8 次/分),插件不持有任何 API Key,不发起网络请求,无遥测。HTTPS 凭据走你自己的 Git 凭据助手;只活在 ssh-agent 里的密钥用不了,请用 HTTPS 远端或钥匙串密钥。", + "main": "main.js", + "ui": { + "panel": "renderer/index.html", + "title": { + "en": "IDEA Git", + "zh-CN": "IDEA Git" + } + }, + "contributes": { + "views": [ + { + "id": "commit", + "title": { + "en": "Commit", + "zh-CN": "提交" + }, + "icon": "list-checks", + "entry": "views/commit.html", + "order": 33 + }, + { + "id": "git", + "title": { + "en": "Git", + "zh-CN": "Git" + }, + "icon": "branch", + "entry": "views/git.html", + "order": 35 + } + ], + "commands": [ + { + "id": "pi-idea-git.open", + "title": "IDEA Git: Open as a separate window", + "keywords": [ + "git", + "idea", + "commit", + "diff", + "stage" + ] + }, + { + "id": "pi-idea-git.refresh", + "title": "IDEA Git: Refresh repository", + "keywords": [ + "git", + "refresh", + "reload" + ] + } + ] + }, + "permissions": [ + "ui.panel", + "ui.view", + "clipboard.write", + "fs.read", + "models.list", + "agent.complete" + ], + "fs": { + "read": { + "root": "workspace", + "scope": [ + "**" + ] + } + }, + "engines": { + "piDesktop": ">=0.8.0" + }, + "activationEvents": [ + "onStartup", + "onCommand:pi-idea-git.open", + "onCommand:pi-idea-git.refresh" + ] +} diff --git a/plugins/io.github.liushunqiu.pi-idea-git/renderer/index.html b/plugins/io.github.liushunqiu.pi-idea-git/renderer/index.html new file mode 100644 index 0000000..36a22d5 --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/renderer/index.html @@ -0,0 +1,7752 @@ + + + + + + + + + IDEA Git + + + +
+ + + + diff --git a/plugins/io.github.liushunqiu.pi-idea-git/views/commit.html b/plugins/io.github.liushunqiu.pi-idea-git/views/commit.html new file mode 100644 index 0000000..fe43c0f --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/views/commit.html @@ -0,0 +1,7752 @@ + + + + + + + + + Commit + + + +
+ + + + diff --git a/plugins/io.github.liushunqiu.pi-idea-git/views/git.html b/plugins/io.github.liushunqiu.pi-idea-git/views/git.html new file mode 100644 index 0000000..88398d2 --- /dev/null +++ b/plugins/io.github.liushunqiu.pi-idea-git/views/git.html @@ -0,0 +1,7752 @@ + + + + + + + + + Git + + + +
+ + + + diff --git a/tests/panel-chrome.test.mjs b/tests/panel-chrome.test.mjs index 92921ad..95380e3 100644 --- a/tests/panel-chrome.test.mjs +++ b/tests/panel-chrome.test.mjs @@ -45,7 +45,7 @@ test("every panel plugin documents the host-owned 46px chrome contract", () => { ); } - assert.equal(panelPlugins.length, 18, "the official marketplace should cover all panel plugins"); + assert.equal(panelPlugins.length, 19, "the official marketplace should cover all panel plugins"); }); test("super domain keeps its v3 surface full-bleed and interactive", () => { diff --git a/tests/pi-idea-git.test.mjs b/tests/pi-idea-git.test.mjs new file mode 100644 index 0000000..19e7d38 --- /dev/null +++ b/tests/pi-idea-git.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); +const pluginRoot = join(root, "plugins/io.github.liushunqiu.pi-idea-git"); +const require = createRequire(import.meta.url); + +const manifest = JSON.parse( + readFileSync(join(pluginRoot, "manifest.json"), "utf8"), +); +const mainSource = readFileSync(join(pluginRoot, "main.js"), "utf8"); +const commitHtml = readFileSync( + join(pluginRoot, "views/commit.html"), + "utf8", +); +const gitHtml = readFileSync(join(pluginRoot, "views/git.html"), "utf8"); + +test("manifest declares the expected identity, permissions and contributions", () => { + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.id, "io.github.liushunqiu.pi-idea-git"); + assert.equal(manifest.version, "0.3.0"); + assert.match(manifest.engines.piDesktop, /^>=/); + assert.deepEqual(manifest.permissions, [ + "ui.panel", + "ui.view", + "clipboard.write", + "fs.read", + "models.list", + "agent.complete", + ]); + assert.equal(typeof manifest.i18n.en.name, "string"); + assert.equal(typeof manifest.i18n["zh-CN"].name, "string"); + assert.ok(manifest.i18n.en.safetyNotes.length > 0); + assert.ok(manifest.i18n["zh-CN"].safetyNotes.length > 0); + assert.equal(typeof manifest.ui.title.en, "string"); + assert.equal(typeof manifest.ui.title["zh-CN"], "string"); + assert.deepEqual( + manifest.contributes.views.map((v) => v.id), + ["commit", "git"], + ); + assert.deepEqual( + manifest.contributes.commands.map((c) => c.id), + ["pi-idea-git.open", "pi-idea-git.refresh"], + ); +}); + +test("main entry exposes the plugin lifecycle and panel invoke", () => { + const main = require(join(pluginRoot, "main.js")); + assert.equal(typeof main.onLoad, "function"); + assert.equal(typeof main.onUnload, "function"); + assert.equal(typeof main.onPanelInvoke, "function"); +}); + +test("git executes via spawn with argument arrays and no shell", () => { + assert.match(mainSource, /spawn\(binary, args,/); + assert.doesNotMatch(mainSource, /shell\s*:\s*true/); +}); + +test("work-panel views are self-contained pages", () => { + assert.ok(commitHtml.includes("") || commitHtml.includes("")); + assert.ok(gitHtml.includes("") || gitHtml.includes("")); + assert.ok(commitHtml.length > 100_000); + assert.ok(gitHtml.length > 100_000); +});