zen-gitsync 2.17.12 → 2.17.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +201 -9
  4. package/src/cli/ai/agent.js +16 -0
  5. package/src/ui/public/assets/AgentView-DLHlVWRg.js +5 -0
  6. package/src/ui/public/assets/AgentView-vvVhbIQ5.css +1 -0
  7. package/src/ui/public/assets/{AppVersionBadge-D5tJzJfa.css → AppVersionBadge-CJrDr7GV.css} +1 -1
  8. package/src/ui/public/assets/AppVersionBadge-JxpMuGwP.js +2 -0
  9. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
  10. package/src/ui/public/assets/BranchSelector-XIN87Mkw.js +1 -0
  11. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +1 -0
  12. package/src/ui/public/assets/CommitForm-CW53Er5m.js +6 -0
  13. package/src/ui/public/assets/CommonDialog-BiNTGpA-.js +1 -0
  14. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +1 -0
  15. package/src/ui/public/assets/EditorView-D0JoJhgt.js +1 -0
  16. package/src/ui/public/assets/EditorView-DZMLvQds.css +1 -0
  17. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +1 -0
  18. package/src/ui/public/assets/FileDiffViewer-Yas2nO2U.js +15 -0
  19. package/src/ui/public/assets/FlowExecutionViewer-D9Vzgh91.js +1 -0
  20. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
  21. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
  22. package/src/ui/public/assets/FlowOrchestrationWorkspace-DQ3fg5b9.js +21 -0
  23. package/src/ui/public/assets/IconButton-DysyaC1j.js +1 -0
  24. package/src/ui/public/assets/IconButton-tjkOjzkC.css +1 -0
  25. package/src/ui/public/assets/LogList-BlJplIi1.css +1 -0
  26. package/src/ui/public/assets/LogList-QDgok-UJ.js +7 -0
  27. package/src/ui/public/assets/MindmapView-BQTqx842.css +1 -0
  28. package/src/ui/public/assets/MindmapView-CeZsQNBK.js +2 -0
  29. package/src/ui/public/assets/MonitorView-D1tgmjjS.js +1 -0
  30. package/src/ui/public/assets/MonitorView-DetlFFXq.css +1 -0
  31. package/src/ui/public/assets/ProjectStartupButton-COntA4q1.js +1 -0
  32. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
  33. package/src/ui/public/assets/RemoteManagerDialog-3dihAOab.js +4 -0
  34. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +1 -0
  35. package/src/ui/public/assets/RemoteRepoCard-Bn9cKt1D.js +7 -0
  36. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +1 -0
  37. package/src/ui/public/assets/SourceMapView-C8UdzQXF.js +3 -0
  38. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +1 -0
  39. package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
  40. package/src/ui/public/assets/SvgIcon-CvtoU5yo.js +1 -0
  41. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +1 -0
  42. package/src/ui/public/assets/TemplateManager-saqC9kdQ.js +1 -0
  43. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
  44. package/src/ui/public/assets/UserInputNode-CvEfvX3i.js +1 -0
  45. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +1 -0
  46. package/src/ui/public/assets/WorkbenchView-B7WAu1Ay.js +56 -0
  47. package/src/ui/public/assets/_plugin-vue_export-helper-BdTxl34g.js +19 -0
  48. package/src/ui/public/assets/configStore-AQMSazCM.js +1 -0
  49. package/src/ui/public/assets/element-plus-CMSkP_w2.js +48 -0
  50. package/src/ui/public/assets/element-plus-Dc1iTOWq.css +1 -0
  51. package/src/ui/public/assets/{flow-mindmap-C_w8GJXm.js → flow-mindmap-CHxAWdLw.js} +16 -16
  52. package/src/ui/public/assets/gitStore-BgC8OY0k.js +6 -0
  53. package/src/ui/public/assets/index-Dpx9Bcqn.css +1 -0
  54. package/src/ui/public/assets/index-qAmzFO10.js +104 -0
  55. package/src/ui/public/assets/office-docx-CmBY5bpF.js +32 -0
  56. package/src/ui/public/assets/{office-excel-CDjN3Va1.js → office-excel-BEhsBUNX.js} +1 -1
  57. package/src/ui/public/assets/office-pptx-DWfi413x.js +74 -0
  58. package/src/ui/public/assets/{previewSandbox-B6Ud9g6a.css → previewSandbox-CgdwPcuW.css} +1 -1
  59. package/src/ui/public/assets/previewSandbox-CyuMMYaq.js +0 -0
  60. package/src/ui/public/assets/socket-io-BFqQXqAp.js +1 -0
  61. package/src/ui/public/assets/{useThemeObserver-DAWKXvWv.js → useThemeObserver-DhCU3SIo.js} +1 -1
  62. package/src/ui/public/assets/{vendor-DcK6TCKZ.js → vendor-CZnXb2r9.js} +46 -39
  63. package/src/ui/public/assets/vue-flow-7_n4vJqi.js +39 -0
  64. package/src/ui/public/index.html +24 -25
  65. package/src/ui/server/index.js +4 -0
  66. package/src/ui/server/routes/fs.js +3 -1
  67. package/src/ui/server/routes/git/clone.js +166 -0
  68. package/src/ui/server/routes/git/clone.test.js +253 -0
  69. package/src/ui/server/routes/gitOps.js +5 -0
  70. package/src/ui/server/routes/localRepos.js +60 -0
  71. package/src/ui/server/routes/localRepos.test.js +151 -0
  72. package/src/ui/server/routes/workbench/agentChat.js +16 -0
  73. package/src/ui/server/routes/workbench/envContext.js +16 -2
  74. package/src/ui/server/routes/workbench/envContext.test.js +14 -0
  75. package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +9 -0
  76. package/src/ui/server/utils/directoryGitState.js +49 -0
  77. package/src/ui/server/utils/directoryGitState.test.js +47 -0
  78. package/src/ui/server/utils/localRepoScan.js +314 -0
  79. package/src/ui/server/utils/localRepoScan.test.js +291 -0
  80. package/src/ui/public/assets/AgentView-BNH92bKu.js +0 -5
  81. package/src/ui/public/assets/AgentView-BV15UtEF.css +0 -1
  82. package/src/ui/public/assets/AppVersionBadge-DXWTExsY.js +0 -2
  83. package/src/ui/public/assets/BranchSelector-CJI-I8JY.js +0 -1
  84. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
  85. package/src/ui/public/assets/CommitForm-CTYhgpkj.js +0 -6
  86. package/src/ui/public/assets/CommitForm-Oij8R6P-.css +0 -1
  87. package/src/ui/public/assets/CommonDialog-C_RjF9Mp.css +0 -1
  88. package/src/ui/public/assets/CommonDialog-DNnN70HC.js +0 -1
  89. package/src/ui/public/assets/EditorView-BgA-_8WV.css +0 -1
  90. package/src/ui/public/assets/EditorView-C2AbXelw.js +0 -1
  91. package/src/ui/public/assets/FileDiffViewer-B2_ghtbv.css +0 -1
  92. package/src/ui/public/assets/FileDiffViewer-ZD705xXx.js +0 -14
  93. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
  94. package/src/ui/public/assets/FlowExecutionViewer-BP9w_OQb.js +0 -1
  95. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
  96. package/src/ui/public/assets/FlowOrchestrationWorkspace-DeSI8Mlj.js +0 -21
  97. package/src/ui/public/assets/IconButton-BxRsG5E1.js +0 -1
  98. package/src/ui/public/assets/IconButton-CTwyZrv2.css +0 -1
  99. package/src/ui/public/assets/LogList-1sIFUx4e.js +0 -7
  100. package/src/ui/public/assets/LogList-DlWwSZet.css +0 -1
  101. package/src/ui/public/assets/MindmapView-6_fbvhh3.js +0 -1
  102. package/src/ui/public/assets/MindmapView-CS2t30Ko.css +0 -1
  103. package/src/ui/public/assets/MonitorView-CURagJDe.js +0 -1
  104. package/src/ui/public/assets/MonitorView-CphDOlQu.css +0 -1
  105. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
  106. package/src/ui/public/assets/ProjectStartupButton-COyb4Fbk.js +0 -1
  107. package/src/ui/public/assets/RemoteManagerDialog-DoS9EWQr.js +0 -1
  108. package/src/ui/public/assets/RemoteManagerDialog-bpwaVAvX.css +0 -1
  109. package/src/ui/public/assets/RemoteRepoCard-BwrdI5UH.js +0 -1
  110. package/src/ui/public/assets/RemoteRepoCard-CDykPlPj.css +0 -1
  111. package/src/ui/public/assets/SourceMapView-DADzBGfb.js +0 -3
  112. package/src/ui/public/assets/SourceMapView-DjYKJZLj.css +0 -1
  113. package/src/ui/public/assets/SvgIcon-VCh_kGGV.js +0 -1
  114. package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +0 -1
  115. package/src/ui/public/assets/TemplateManager-C4go4RZu.js +0 -1
  116. package/src/ui/public/assets/UserInputNode-Cl5-bI42.js +0 -1
  117. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
  118. package/src/ui/public/assets/WorkbenchView-B8QOp8nM.css +0 -1
  119. package/src/ui/public/assets/WorkbenchView-DtCyyW3b.js +0 -6
  120. package/src/ui/public/assets/_plugin-vue_export-helper-Dcylt9fO.js +0 -19
  121. package/src/ui/public/assets/configStore-BlqdtNUA.js +0 -1
  122. package/src/ui/public/assets/element-plus-C1PloHQu.css +0 -1
  123. package/src/ui/public/assets/element-plus-IG-bwpR5.js +0 -28
  124. package/src/ui/public/assets/gitStore-BKkZm2m3.js +0 -4
  125. package/src/ui/public/assets/index-Bq7iCtof.css +0 -1
  126. package/src/ui/public/assets/index-DGHrUfrh.js +0 -28
  127. package/src/ui/public/assets/office-docx-CkLOvV7H.js +0 -31
  128. package/src/ui/public/assets/office-pptx-CVu3fXsL.js +0 -60
  129. package/src/ui/public/assets/previewSandbox-DYMhXJ91.js +0 -0
  130. package/src/ui/public/assets/socket-io-TBtw0msS.js +0 -1
  131. package/src/ui/public/assets/toolsStore-BlRHNqXl.js +0 -1
  132. package/src/ui/public/assets/vue-flow-B3gZmkCI.js +0 -39
@@ -0,0 +1,151 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // src/ui/server/routes/localRepos.js 单元测试。
16
+ //
17
+ // 两条路由都只是"转发",所以断言也集中在**它们自己的取舍**上,而不是扫描逻辑
18
+ // (那部分在 utils/localRepoScan.test.js):
19
+ // 1. GET 必须把快照原样摊平到顶层 —— 前端就靠 `data.scanning` 决定要不要轮询
20
+ // 2. POST 必须**不 await** 扫描:一 await 就会挂十几秒,前端拿不到 started
21
+ // 3. POST 那条 promise 是有意"发射后不管"的 —— 它 reject 时不能变成进程级
22
+ // unhandledRejection(Node 15+ 默认直接崩服务)
23
+ //
24
+ // 不真起 Express:沿用 remoteRepos.test.js 的 mock app + mock req/res 做法。
25
+ // 默认实现会真去遍历磁盘,所以两个注入口在所有用例里都换成假实现。
26
+ import { test } from 'node:test'
27
+ import assert from 'node:assert/strict'
28
+
29
+ import { registerLocalReposRoutes } from './localRepos.js'
30
+
31
+ /** 最小 express app mock:只实现 get + post 与 handler 调用 */
32
+ function makeApp() {
33
+ const handlers = new Map()
34
+ return {
35
+ get(path, handler) { handlers.set(`GET ${path}`, handler) },
36
+ post(path, handler) { handlers.set(`POST ${path}`, handler) },
37
+ invoke(method, path) {
38
+ const key = `${method} ${path}`
39
+ const handler = handlers.get(key)
40
+ assert.ok(handler, `no handler for ${key}`)
41
+ const res = makeRes()
42
+ return handler({ body: {}, query: {}, method, path }, res, () => {}).then(() => res)
43
+ },
44
+ }
45
+ }
46
+
47
+ function makeRes() {
48
+ return {
49
+ statusCode: 200,
50
+ body: undefined,
51
+ status(code) { this.statusCode = code; return this },
52
+ json(payload) { this.body = payload; return this },
53
+ }
54
+ }
55
+
56
+ const SNAPSHOT = {
57
+ scanning: false,
58
+ scannedAt: 1758000000000,
59
+ scannedDirs: 8613,
60
+ roots: ['C:/', 'D:/'],
61
+ repos: { 'D:/w/zen-gitsync': 'git@github.com:xz333221/zen-gitsync.git' },
62
+ error: null,
63
+ }
64
+
65
+ function setup({ snapshot, refresh } = {}) {
66
+ const app = makeApp()
67
+ const calls = { get: 0, refresh: 0 }
68
+ registerLocalReposRoutes({
69
+ app,
70
+ getLocalReposImpl: async () => {
71
+ calls.get += 1
72
+ return snapshot === undefined ? SNAPSHOT : snapshot
73
+ },
74
+ refreshReposImpl: () => {
75
+ calls.refresh += 1
76
+ return refresh === undefined ? Promise.resolve(SNAPSHOT) : refresh()
77
+ },
78
+ })
79
+ return { app, calls }
80
+ }
81
+
82
+ // ── GET /api/local-repos ────────────────────────────────────────────────
83
+
84
+ test('GET /api/local-repos: 快照摊平进响应顶层(前端靠 data.scanning 决定要不要轮询)', async () => {
85
+ const { app, calls } = setup()
86
+ const res = await app.invoke('GET', '/api/local-repos')
87
+
88
+ assert.equal(res.statusCode, 200)
89
+ assert.deepEqual(res.body, { success: true, ...SNAPSHOT })
90
+ // 关键字段必须在顶层,而不是嵌在 data / snapshot 里
91
+ assert.equal(res.body.scanning, false)
92
+ assert.equal(res.body.repos['D:/w/zen-gitsync'], 'git@github.com:xz333221/zen-gitsync.git')
93
+ assert.equal(calls.get, 1)
94
+ // GET 不该顺手触发重扫 —— 重扫是 POST 的职责
95
+ assert.equal(calls.refresh, 0)
96
+ })
97
+
98
+ test('GET /api/local-repos: 首次运行(还在扫)也回 200,只把 scanning 置真', async () => {
99
+ const { app } = setup({ snapshot: { ...SNAPSHOT, scanning: true, scannedAt: null, repos: {} } })
100
+ const res = await app.invoke('GET', '/api/local-repos')
101
+
102
+ assert.equal(res.statusCode, 200)
103
+ assert.equal(res.body.success, true)
104
+ assert.equal(res.body.scanning, true)
105
+ assert.deepEqual(res.body.repos, {})
106
+ })
107
+
108
+ // ── POST /api/local-repos/scan ──────────────────────────────────────────
109
+
110
+ test('POST /api/local-repos/scan: 不 await 扫描 —— 扫描没结束就先回 started', async () => {
111
+ let release
112
+ const pending = new Promise((resolve) => { release = resolve })
113
+ const { app, calls } = setup({ refresh: () => pending })
114
+
115
+ // 若路由 await 了扫描,这一行会一直挂着(扫描默认要十几秒)
116
+ const res = await app.invoke('POST', '/api/local-repos/scan')
117
+
118
+ assert.equal(res.statusCode, 200)
119
+ assert.deepEqual(res.body, { success: true, started: true })
120
+ assert.equal(calls.refresh, 1, '应该确实把重扫踢起来了')
121
+
122
+ release(SNAPSHOT)
123
+ await pending
124
+ })
125
+
126
+ test('POST /api/local-repos/scan: 重扫失败不会变成进程级 unhandledRejection', async () => {
127
+ const rejections = []
128
+ const onRejection = (reason) => { rejections.push(reason) }
129
+ process.on('unhandledRejection', onRejection)
130
+ try {
131
+ const { app } = setup({ refresh: () => Promise.reject(new Error('盘拔了')) })
132
+ const res = await app.invoke('POST', '/api/local-repos/scan')
133
+
134
+ assert.equal(res.body.started, true, '后台失败不该影响这次响应')
135
+
136
+ // 让被吞掉的那个 rejection 有机会冒出来
137
+ await new Promise(resolve => setTimeout(resolve, 50))
138
+ assert.deepEqual(rejections, [], 'rejection 必须被路由自己 catch 掉')
139
+ } finally {
140
+ process.off('unhandledRejection', onRejection)
141
+ }
142
+ })
143
+
144
+ test('POST /api/local-repos/scan: 重复点刷新都转成一次重扫(并发语义由 localRepoScan 保证)', async () => {
145
+ const { app, calls } = setup()
146
+ await app.invoke('POST', '/api/local-repos/scan')
147
+ await app.invoke('POST', '/api/local-repos/scan')
148
+
149
+ // 路由层不去重 —— 它对调用方是"转达意图",真正的单飞在 refreshLocalRepos 里
150
+ assert.equal(calls.refresh, 2)
151
+ })
@@ -108,6 +108,13 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
108
108
  报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
109
109
  直接告诉用户未检测到该 CLI,可在「远程仓库」页一键安装/登录,或需要时重启服务让新装的 CLI 生效;
110
110
  若只是想回答本项目的问题,退回 \`git remote -v\`
111
+ - 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
112
+ · GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
113
+ · 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
114
+ 走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
115
+ · 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
116
+ · 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
117
+ 并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
111
118
 
112
119
  # 权限(用户已明确授权,无需反复征求同意)
113
120
  - 工作目录内:读写文件、执行命令等所有操作直接执行
@@ -180,6 +187,15 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
180
187
  repo or directory names. Tell the user the CLI was not found and point them to the "Remote
181
188
  repositories" page (one-click install / login), or restart the server so a freshly installed CLI
182
189
  becomes visible; if you only need to answer for this project, fall back to \`git remote -v\`
190
+ - Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
191
+ and https has been observed to pop a credential window that stalls the task):
192
+ · GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
193
+ · If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
194
+ form first — https pops up Git Credential Manager asking for a username/password and blocks the task
195
+ · Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
196
+ · Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
197
+ \`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
198
+ once the key is set up; if both fail, stop and ask the user — do not keep retrying
183
199
 
184
200
  # Permissions (explicitly granted by the user)
185
201
  - Inside the working directory: read/write files and run commands directly
@@ -19,9 +19,12 @@
19
19
  // 一概不在里面。于是用户问"我的项目有哪些",Agent 只能就着当前目录猜 ——
20
20
  // 而数据其实全在本机,只是没人告诉它路径。
21
21
  //
22
- // 这里做两件事,缺一不可:
22
+ // 这里做三件事,缺一不可:
23
23
  // 1. 给**摘要** —— 项目清单 + 各列任务计数(顺着看板口径,一眼看到进度);
24
- // 2. 给**真相源文件路径** —— 要细节自己去读。
24
+ // 2. 给**真相源文件路径** —— 要细节自己去读;
25
+ // 3. 给**用户偏好** —— 克隆仓库优先 SSH。这一条不是"运行环境"的事实,而是工作台任务的
26
+ // Agent 是外部 CLI(claude / opencode),系统提示词不归我们写 —— 这里是唯一能把偏好
27
+ // 递进去的地方。少这一句,任务里克隆仓库就会退回 https,弹凭据窗口把任务停住。
25
28
  // 刻意不把 tasks.json 全文塞进 prompt:几十条任务、每条还挂着执行输出,
26
29
  // 既烧 token,又会把用户真正要办的那句话淹掉。
27
30
  //
@@ -122,6 +125,17 @@ export function buildEnvContextBlock({
122
125
  TASK_COLUMNS.map(k => `${COLUMN_LABELS[k]} ${total[k] || 0}`).join(' / '),
123
126
  );
124
127
  lines.push('');
128
+ // 用户偏好(见文件头第 3 条)。放在"读文件"之前、"看板概览"之后 ——
129
+ // 它是**行为**约定,与上面的事实清单分开;压尾的那句仍是"去读文件",
130
+ // 那是整块上下文里最需要被记住的动作。
131
+ lines.push(
132
+ '用户偏好:克隆仓库 / 添加远端时**优先使用 SSH**(https 地址先换算成 ' +
133
+ 'git@github.com:owner/repo.git,Gitee 同理 git@gitee.com:owner/repo.git —— ' +
134
+ '走 https 会弹凭据窗口,把任务停在半路等用户输账号密码)。' +
135
+ '只有 SSH 不可用(Permission denied (publickey) / Host key verification failed)' +
136
+ '才退回 https,并说明这次走的是 https。',
137
+ );
138
+ lines.push('');
125
139
  lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
126
140
  if (tasksFile) lines.push(`- ${tasksFile} —— 全部任务全文(标题/描述/子任务状态/报错/projectPath)`);
127
141
  if (jobsFile) lines.push(`- ${jobsFile} —— 历次执行记录(状态与输出)`);
@@ -176,3 +176,17 @@ test('项目行/合计的段数必须与 TASK_COLUMNS 一致,且每列都有
176
176
  // 两个任务都是待处理 → 第一段是 2,其余列是 0(顺序由 TASK_COLUMNS 决定,不是写死的下标)
177
177
  assert.equal(rowCounts[TASK_COLUMNS.indexOf('todo')], '2');
178
178
  });
179
+
180
+ // ── 用户偏好:克隆仓库优先 SSH ────────────────────────────────────────
181
+ //
182
+ // 这条只对**工作台任务**起作用(执行器是外部 CLI claude / opencode,系统提示词不归我们写,
183
+ // envContext 是唯一能把偏好递进去的地方)。断言钉住两个平台的地址都写全 —— 用户实际踩的
184
+ // 那个坑就是 Gitee 走 https 弹了凭据窗口;只留 GitHub 一条等于只覆盖一半场景。
185
+ test('注入块带上「克隆优先 SSH」这条用户偏好', () => {
186
+ const block = buildEnvContextBlock({ projects: [entry('D:\\ws\\a')], tasks: [], jobs: [], ...PATHS });
187
+ assert.match(block, /优先使用 SSH/);
188
+ assert.match(block, /git@github\.com:owner\/repo\.git/);
189
+ assert.match(block, /git@gitee\.com:owner\/repo\.git/);
190
+ // 退回条件也得在:没写清"什么时候可以退回",模型会在 SSH 失败后来回重试
191
+ assert.match(block, /Permission denied \(publickey\)/);
192
+ });
@@ -122,6 +122,15 @@ const FACTS = [
122
122
  en: [/Never ask the user for a token/],
123
123
  commands: [],
124
124
  },
125
+ {
126
+ // 用户明确提过的偏好:让 AI 克隆仓库时优先 SSH。走 https 会弹 Git Credential Manager,
127
+ // 把任务停在半路等人输账号密码(实测)。四个入口都得守同一条,否则从某个入口克隆
128
+ // 又会退回去走 https —— 这类"少写一条不报错"的失效方式正是本文件存在的理由。
129
+ what: '克隆 / 加远端优先 SSH(https 先换算,无密钥才退回)',
130
+ zh: [/优先用 SSH/, /git@github\.com:owner\/repo\.git/, /Permission denied \(publickey\)/],
131
+ en: [/prefer SSH/, /git@github\.com:owner\/repo\.git/, /Permission denied \(publickey\)/],
132
+ commands: ['git@github.com:owner/repo.git', 'git@gitee.com:owner/repo.git', 'git remote set-url origin'],
133
+ },
125
134
  ];
126
135
 
127
136
  test('四条提示词覆盖同样的事实,命令串逐字一致(跨语言对齐)', () => {
@@ -288,6 +288,55 @@ export async function probeDirectoryGitStates(paths, options = {}) {
288
288
  return results;
289
289
  }
290
290
 
291
+ /**
292
+ * 探测单个目录的 origin 远程地址。
293
+ *
294
+ * 用途:远程仓库列表要回答"这个仓库本地已经克隆过了吗" —— 判据只能是本地
295
+ * 某个目录的 origin 指向谁。口径与 probeDirectoryGitState 一致:一次 spawn、
296
+ * 超时即放弃、**不联网**(`git remote get-url` 只读 .git/config,不发请求)。
297
+ *
298
+ * 非仓库、目录不存在、没有名为 origin 的 remote 都是失败(退出码非 0)——
299
+ * 统一收敛成 null:对"是不是克隆来的"这个问题,这些情况的答案都是"不知道/不是",
300
+ * 调用方不需要区分。
301
+ */
302
+ export async function probeDirectoryOrigin(dirPath, { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
303
+ try {
304
+ const { stdout } = await execFileAsync(
305
+ 'git',
306
+ ['-C', dirPath, 'remote', 'get-url', 'origin'],
307
+ { timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true }
308
+ );
309
+ const url = String(stdout || '').trim();
310
+ return url || null;
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * 批量探测 origin 地址。返回 `{ [调用方传入的原始路径]: url|null }`
318
+ * —— 键用原始字符串,前端可以直接 `origins[item.path]` 取。
319
+ *
320
+ * 为什么不做缓存(同一个文件里 git 状态就有 15s TTL):remote 是用户随时在改的
321
+ * 东西(刚加了 remote、刚把地址换成 SSH),缓存只会让「已克隆」这个标识慢半拍地
322
+ * 骗人;而调用方只有仓库列表挂载时那一次请求,重复扫的代价可以忽略。
323
+ */
324
+ export async function probeDirectoryOrigins(paths, options = {}) {
325
+ const { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, concurrency = DEFAULT_CONCURRENCY } = options;
326
+ const list = Array.isArray(paths) ? paths.filter(p => typeof p === 'string' && p.trim()) : [];
327
+ // 同一个目录可能以不同写法出现在配置里,先按原始串去重再探(normalizeDirKey
328
+ // 会把 `D:/x` 与 `d:\x\` 合并,但这里要保持"键用原始串"的约定)
329
+ const unique = [...new Set(list)];
330
+ const results = {};
331
+
332
+ const probed = await runWithConcurrency(unique, concurrency, async (dir) => ({
333
+ dir,
334
+ url: await probeDirectoryOrigin(dir, { timeoutMs }),
335
+ }));
336
+ for (const { dir, url } of probed) results[dir] = url;
337
+ return results;
338
+ }
339
+
291
340
  /** 仅供测试:清空缓存 */
292
341
  export function clearGitStateCache() {
293
342
  cache.clear();
@@ -26,6 +26,8 @@ import {
26
26
  normalizeDirKey,
27
27
  probeDirectoryGitState,
28
28
  probeDirectoryGitStates,
29
+ probeDirectoryOrigin,
30
+ probeDirectoryOrigins,
29
31
  clearGitStateCache,
30
32
  } from './directoryGitState.js';
31
33
 
@@ -262,6 +264,51 @@ test('probeDirectoryGitState: 真实远端 → 读出上游分支与领先/落
262
264
  }
263
265
  });
264
266
 
267
+ // ── origin 远程地址探测(远程仓库列表的「已克隆」徽标) ──────────────────
268
+ test('probeDirectoryOrigin: 有 origin 读到地址,没有 / 非仓库 / 目录不存在都是 null', async () => {
269
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-origin-'));
270
+ const withOrigin = path.join(root, 'with-origin');
271
+ const noRemote = path.join(root, 'no-remote');
272
+ const plain = path.join(root, 'plain');
273
+ try {
274
+ await fs.mkdir(withOrigin);
275
+ await fs.mkdir(noRemote);
276
+ await fs.mkdir(plain);
277
+ await git(['init', '-q'], withOrigin);
278
+ await git(['init', '-q'], noRemote);
279
+ await git(['remote', 'add', 'origin', 'git@gitee.com:xz_web/xiangqi.git'], withOrigin);
280
+
281
+ assert.equal(await probeDirectoryOrigin(withOrigin), 'git@gitee.com:xz_web/xiangqi.git');
282
+ // 仓库但没配 origin / 根本不是仓库 / 目录不存在 —— 对"是不是克隆来的"
283
+ // 这三个答案都是"不知道",统一收敛成 null
284
+ assert.equal(await probeDirectoryOrigin(noRemote), null);
285
+ assert.equal(await probeDirectoryOrigin(plain), null);
286
+ assert.equal(await probeDirectoryOrigin(path.join(root, 'ghost')), null);
287
+ } finally {
288
+ await fs.rm(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test('probeDirectoryOrigins: 键是调用方原始路径,空值与非字符串入参被过滤', async () => {
293
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-origins-'));
294
+ const repoDir = path.join(root, 'repo');
295
+ const plainDir = path.join(root, 'plain');
296
+ try {
297
+ await fs.mkdir(repoDir);
298
+ await fs.mkdir(plainDir);
299
+ await git(['init', '-q'], repoDir);
300
+ await git(['remote', 'add', 'origin', 'https://gitee.com/xz_web/xiangqi.git'], repoDir);
301
+
302
+ const results = await probeDirectoryOrigins([repoDir, plainDir, '', null, repoDir]);
303
+ // 重复路径只探一次但保留在结果里(键去重),空串 / null 不进结果
304
+ assert.deepEqual(Object.keys(results).sort(), [repoDir, plainDir].sort());
305
+ assert.equal(results[repoDir], 'https://gitee.com/xz_web/xiangqi.git');
306
+ assert.equal(results[plainDir], null);
307
+ } finally {
308
+ await fs.rm(root, { recursive: true, force: true });
309
+ }
310
+ });
311
+
265
312
  test('probeDirectoryGitStates: 结果按调用方原始路径字符串为键,重复路径只探一次', async () => { clearGitStateCache();
266
313
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-batch-'));
267
314
  try {