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
@@ -10,37 +10,36 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-DGHrUfrh.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-qAmzFO10.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
15
- <link rel="modulepreload" crossorigin href="/assets/element-plus-IG-bwpR5.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/element-plus-CMSkP_w2.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/monaco-DkJuNcCr.js">
17
- <link rel="modulepreload" crossorigin href="/assets/vendor-DcK6TCKZ.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-Dcylt9fO.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-VCh_kGGV.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-BxRsG5E1.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-BlqdtNUA.js">
22
- <link rel="modulepreload" crossorigin href="/assets/socket-io-TBtw0msS.js">
23
- <link rel="modulepreload" crossorigin href="/assets/gitStore-BKkZm2m3.js">
24
- <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-DAWKXvWv.js">
25
- <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-C_w8GJXm.js">
26
- <link rel="modulepreload" crossorigin href="/assets/previewSandbox-DYMhXJ91.js">
27
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-ZD705xXx.js">
28
- <link rel="modulepreload" crossorigin href="/assets/toolsStore-BlRHNqXl.js">
29
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-DNnN70HC.js">
30
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-C4go4RZu.js">
31
- <link rel="modulepreload" crossorigin href="/assets/vue-flow-B3gZmkCI.js">
32
- <link rel="stylesheet" crossorigin href="/assets/element-plus-C1PloHQu.css">
17
+ <link rel="modulepreload" crossorigin href="/assets/vendor-CZnXb2r9.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-BdTxl34g.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/SvgIcon-CvtoU5yo.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/IconButton-DysyaC1j.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/configStore-AQMSazCM.js">
22
+ <link rel="modulepreload" crossorigin href="/assets/socket-io-BFqQXqAp.js">
23
+ <link rel="modulepreload" crossorigin href="/assets/gitStore-BgC8OY0k.js">
24
+ <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-DhCU3SIo.js">
25
+ <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-CHxAWdLw.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/previewSandbox-CyuMMYaq.js">
27
+ <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-Yas2nO2U.js">
28
+ <link rel="modulepreload" crossorigin href="/assets/CommonDialog-BiNTGpA-.js">
29
+ <link rel="modulepreload" crossorigin href="/assets/TemplateManager-saqC9kdQ.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/vue-flow-7_n4vJqi.js">
31
+ <link rel="stylesheet" crossorigin href="/assets/element-plus-Dc1iTOWq.css">
33
32
  <link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
34
33
  <link rel="stylesheet" crossorigin href="/assets/vendor-4ojsjDYc.css">
35
- <link rel="stylesheet" crossorigin href="/assets/SvgIcon-BQ6bR7l0.css">
36
- <link rel="stylesheet" crossorigin href="/assets/IconButton-CTwyZrv2.css">
34
+ <link rel="stylesheet" crossorigin href="/assets/SvgIcon-CDNsk4-Q.css">
35
+ <link rel="stylesheet" crossorigin href="/assets/IconButton-tjkOjzkC.css">
37
36
  <link rel="stylesheet" crossorigin href="/assets/flow-mindmap-DnzM9SlK.css">
38
- <link rel="stylesheet" crossorigin href="/assets/previewSandbox-B6Ud9g6a.css">
39
- <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-B2_ghtbv.css">
40
- <link rel="stylesheet" crossorigin href="/assets/CommonDialog-C_RjF9Mp.css">
41
- <link rel="stylesheet" crossorigin href="/assets/TemplateManager-C0rm8xfC.css">
37
+ <link rel="stylesheet" crossorigin href="/assets/previewSandbox-CgdwPcuW.css">
38
+ <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-B9EkuKdF.css">
39
+ <link rel="stylesheet" crossorigin href="/assets/CommonDialog-jP0E9mco.css">
40
+ <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DMuG4i3G.css">
42
41
  <link rel="stylesheet" crossorigin href="/assets/vue-flow-CavUTEzp.css">
43
- <link rel="stylesheet" crossorigin href="/assets/index-Bq7iCtof.css">
42
+ <link rel="stylesheet" crossorigin href="/assets/index-Dpx9Bcqn.css">
44
43
  </head>
45
44
  <body>
46
45
  <div id="app"></div>
@@ -42,6 +42,7 @@ import { registerFsRoutes } from './routes/fs.js';
42
42
  import { registerRecentDirectoriesSummaryRoutes } from './routes/recentDirectoriesAiSummary.js';
43
43
  import { registerNpmRoutes } from './routes/npm.js';
44
44
  import { registerFileOpenRoutes } from './routes/fileOpen.js';
45
+ import { registerLocalReposRoutes } from './routes/localRepos.js';
45
46
  import { registerRemoteReposRoutes } from './routes/remoteRepos.js';
46
47
  import { registerWorkbenchRoutes } from './routes/workbench.js';
47
48
  import { registerGitOpsRoutes } from './routes/gitOps.js';
@@ -416,6 +417,9 @@ async function startUIServer(noOpen = false, savePort = false) {
416
417
  // 远程托管平台仓库列表(GitHub / Gitee 两个 Tab 的数据源)
417
418
  registerRemoteReposRoutes({ app });
418
419
 
420
+ // 本机 Git 仓库清单 —— 仓库卡片上「已克隆」徽标的判据(全盘扫描 + 落盘缓存)
421
+ registerLocalReposRoutes({ app });
422
+
419
423
  registerWorkbenchRoutes({
420
424
  app,
421
425
  getCurrentProjectPath: () => currentProjectPath,
@@ -616,7 +616,9 @@ export function registerFsRoutes({
616
616
 
617
617
  res.json({ success: true });
618
618
  } catch (error) {
619
- res.status(400).json({ success: false, error: `无法打开: ${'$'}{error.message}` });
619
+ // 注意别把 ${error.message} 写成字符串字面量 —— 之前这里多包了一层引号,
620
+ // 用户看到的就是"无法打开: ${error.message}"这种原文
621
+ res.status(400).json({ success: false, error: `无法在 "${directoryPath}" 中启动 g ui: ${error.message}` });
620
622
  }
621
623
  } catch (error) {
622
624
  res.status(500).json({ success: false, error: error.message });
@@ -0,0 +1,166 @@
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
+ // 把远程仓库克隆到用户指定的文件夹(远程仓库列表卡片上的「克隆到文件夹」)。
16
+ //
17
+ // 与同目录其它 git 路由的关键差别:clone 的 cwd **不是当前工作目录**,而是用户
18
+ // 刚在文件选择器里选的那个父目录。所以这里不能复用 execGitCommand —— 它固定用
19
+ // getCwd(),而且会把命令塞进"当前项目"的命令历史里,那条记录里的 cwd 是错的,
20
+ // 事后回看会误导。这里自己 spawn,也不碰命令历史。
21
+ //
22
+ // 用哪个协议(SSH / HTTPS)由调用方决定:前端传什么就 clone 什么,服务端只做
23
+ // 地址合法性校验。推导逻辑只留一份在前端(utils/remoteUrl.ts),免得两边各写
24
+ // 一套规则、哪天规则变了只改了一边。
25
+
26
+ import path from 'path'
27
+ import fs from 'fs/promises'
28
+ import { execFile } from 'child_process'
29
+ import { promisify } from 'util'
30
+
31
+ import logger from '../../utils/logger.js'
32
+ import { HttpError } from '../../utils/asyncRoute.js'
33
+ import { assertGitRemoteUrl } from '../../utils/gitArgs.js'
34
+ import { rememberRepo } from '../../utils/localRepoScan.js'
35
+ import { augmentEnvPath } from '../../../../utils/shellPath.js'
36
+
37
+ const execFileAsync = promisify(execFile)
38
+
39
+ /** 大仓库 clone 可能好几分钟;与 agentMarketplace 的 CLONE_TIMEOUT_MS 取齐 */
40
+ const CLONE_TIMEOUT_MS = 180_000
41
+ const MAX_BUFFER = 10 * 1024 * 1024
42
+
43
+ /**
44
+ * 从远程地址里取出仓库目录名(clone 出来的子目录就用它)。
45
+ * git@github.com:owner/repo.git → repo
46
+ * https://gitee.com/owner/repo.git → repo
47
+ * https://github.com/owner/repo/ → repo
48
+ * 取不出来返回空串,调用方兜底成 repository。
49
+ */
50
+ export function repoNameFromUrl(url) {
51
+ const s = String(url || '').trim().replace(/\/+$/, '')
52
+ if (!s) return ''
53
+ const tail = s.split(/[/:]/).filter(Boolean).pop() || ''
54
+ return tail.replace(/\.git$/i, '')
55
+ }
56
+
57
+ /**
58
+ * 校验父目录:必须是**已存在的目录**的绝对路径。
59
+ *
60
+ * 这里刻意不用 assertGitPath —— 那个函数要求"仓库内的相对路径",而用户在文件
61
+ * 选择器里选出来的必然是绝对路径。反过来也不能拿它当安全边界:clone 的落点
62
+ * 本来就允许在工作区之外(用户装项目的盘符通常不是当前项目所在盘)。
63
+ */
64
+ async function assertCloneParentDir(value) {
65
+ const s = String(value ?? '').trim()
66
+ if (!s) throw new HttpError(400, '缺少目标文件夹')
67
+ if (/[\x00-\x1f\x7f]/.test(s)) throw new HttpError(400, '目标文件夹含有非法字符')
68
+ if (!path.isAbsolute(s)) throw new HttpError(400, '目标文件夹必须是绝对路径')
69
+
70
+ let stat
71
+ try {
72
+ stat = await fs.stat(s)
73
+ } catch {
74
+ throw new HttpError(400, `目标文件夹不存在:${s}`)
75
+ }
76
+ if (!stat.isDirectory()) throw new HttpError(400, `目标不是文件夹:${s}`)
77
+ return path.resolve(s)
78
+ }
79
+
80
+ async function pathExists(target) {
81
+ try {
82
+ await fs.stat(target)
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** 取 stderr / message 里最后一行非空文本 —— git 把结论放在最后一行(fatal: ...) */
90
+ function lastErrorLine(text) {
91
+ const lines = String(text || '')
92
+ .split('\n')
93
+ .map((line) => line.trim())
94
+ .filter(Boolean)
95
+ return lines[lines.length - 1] || ''
96
+ }
97
+
98
+ /** 把子进程错误压成一句能直接给用户看的话 */
99
+ export function describeCloneError(error, timeoutMs = CLONE_TIMEOUT_MS) {
100
+ if (error?.killed || error?.signal) {
101
+ return `克隆超时(超过 ${Math.round(timeoutMs / 1000)} 秒),仓库可能过大或网络不通`
102
+ }
103
+ return lastErrorLine(error?.stderr) || lastErrorLine(error?.message) || '克隆失败'
104
+ }
105
+
106
+ /** 真正跑一次 git clone。抽出来是为了让路由能被单测注入替身(不联网)。 */
107
+ async function runClone({ url, parentDir, target }) {
108
+ const env = await augmentEnvPath({
109
+ ...process.env,
110
+ GIT_CONFIG_PARAMETERS: "'core.quotepath=false'",
111
+ // 子进程没有 TTY,交互式提示只会把 clone 挂到超时。让它在需要凭据时
112
+ // 立刻失败并把原因写在 stderr 里(与 directoryFetch.js 同一取舍)。
113
+ GIT_TERMINAL_PROMPT: '0'
114
+ })
115
+ if (!env.GIT_SSH_COMMAND && !env.GIT_SSH) env.GIT_SSH_COMMAND = 'ssh -oBatchMode=yes'
116
+
117
+ try {
118
+ await execFileAsync('git', ['clone', url, target], {
119
+ cwd: parentDir,
120
+ timeout: CLONE_TIMEOUT_MS,
121
+ maxBuffer: MAX_BUFFER,
122
+ windowsHide: true,
123
+ env
124
+ })
125
+ } catch (error) {
126
+ logger.error('克隆仓库失败:', error?.stderr || error?.message || error)
127
+ throw new HttpError(500, describeCloneError(error))
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 注册克隆路由。
133
+ *
134
+ * @param {object} deps
135
+ * @param {import('express').Express} deps.app
136
+ * @param {typeof runClone} [deps.runCloneImpl] 单测注入点:默认实现会真跑 git clone
137
+ */
138
+ export function registerGitCloneRoutes({ app, runCloneImpl = runClone }) {
139
+ app.post('/api/clone', async (req, res) => {
140
+ try {
141
+ const url = assertGitRemoteUrl(req.body?.url)
142
+ const parentDir = await assertCloneParentDir(req.body?.parentDir)
143
+ const target = path.join(parentDir, repoNameFromUrl(url) || 'repository')
144
+
145
+ // 目标已存在就先拦下:git 自己也会拒绝,但它那句
146
+ // "destination path already exists and is not an empty directory"
147
+ // 既没说是哪个目录,也没给下一步怎么办
148
+ if (await pathExists(target)) {
149
+ return res.json({ success: false, error: `目标文件夹已存在:${target}` })
150
+ }
151
+
152
+ await runCloneImpl({ url, parentDir, target })
153
+ // 就地登记到本机仓库清单:远程仓库列表的「已克隆」徽标要立刻亮起来,
154
+ // 不能等下一次全盘重扫(十几秒)。登记用的是服务端刚算出的**真实落点**,
155
+ // 不依赖前端回传 —— 前端只知道"选了哪个父目录",仓库名是服务端推的。
156
+ await rememberRepo(target, url)
157
+ res.json({ success: true, path: target })
158
+ } catch (error) {
159
+ logger.error('克隆仓库失败:', error?.message || error)
160
+ res.status(error?.statusCode || 200).json({
161
+ success: false,
162
+ error: error?.message || '克隆仓库失败'
163
+ })
164
+ }
165
+ })
166
+ }
@@ -0,0 +1,253 @@
1
+ // clone.js 单元测试
2
+ // 模式参照 remotes.test.js:node:test + 假 app 捕获 handler + 注入 runCloneImpl 替身。
3
+ // 关键:所有用例都不联网、不真跑 git —— runCloneImpl 一律换成计数用的替身,
4
+ // 唯一的"真实"依赖是本地文件系统(校验父目录要用 fs.stat)。
5
+ //
6
+ // 克隆成功后路由会调 rememberRepo() 把落点登记进本机仓库清单(「已克隆」徽标用),
7
+ // 那一步会**写盘** —— 必须先把缓存路径指到临时文件,否则测试会往用户真实的
8
+ // ~/.zen-gitsync/local-repos.json 里塞一堆 os.tmpdir() 下的假记录。
9
+ import { test, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+
15
+ import { registerGitCloneRoutes, repoNameFromUrl, describeCloneError } from './clone.js';
16
+ import { resetLocalRepoScan } from '../../utils/localRepoScan.js';
17
+ import { HttpError } from '../../utils/asyncRoute.js';
18
+
19
+ // 缓存路径必须在**任何用例跑起来之前**指到临时文件。写在 import 之间没用
20
+ // (ESM 的 import 全部提升),好在 cacheFilePath() 是每次调用才读 env,
21
+ // 所以放在这里同样生效 —— 真正要防的是"用例跑的时候还指着家目录"。
22
+ const CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'zen-clone-cache-'));
23
+ process.env.ZEN_LOCAL_REPOS_CACHE = path.join(CACHE_DIR, 'local-repos.json');
24
+
25
+ after(() => {
26
+ fs.rmSync(CACHE_DIR, { recursive: true, force: true });
27
+ });
28
+
29
+ // ── 测试工具 ─────────────────────────────────────────────────────────────
30
+
31
+ function makeApp() {
32
+ const handlers = {};
33
+ const app = {
34
+ get(p, ...hs) { handlers[`GET ${p}`] = hs.at(-1); },
35
+ post(p, ...hs) { handlers[`POST ${p}`] = hs.at(-1); }
36
+ };
37
+ return { app, handlers };
38
+ }
39
+
40
+ async function invoke(handler, body = {}) {
41
+ let statusCode = 200;
42
+ let payload;
43
+ const req = { body, method: 'POST', path: '/api/clone' };
44
+ const res = {
45
+ status(code) { statusCode = code; return this; },
46
+ json(data) { payload = data; return this; }
47
+ };
48
+ await handler(req, res, () => {});
49
+ return { statusCode, payload };
50
+ }
51
+
52
+ /** 注册路由并返回 handler;runCloneImpl 默认是个永远不该被调到的替身 */
53
+ function register(runCloneImpl = async () => { throw new Error('不该真的跑 git clone'); }) {
54
+ const { app, handlers } = makeApp();
55
+ registerGitCloneRoutes({ app, runCloneImpl });
56
+ return handlers['POST /api/clone'];
57
+ }
58
+
59
+ /** 建一个临时父目录,返回路径(用例结束自己删) */
60
+ function tmpDir() {
61
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'zen-clone-test-'));
62
+ }
63
+
64
+ function cleanup(dir) {
65
+ fs.rmSync(dir, { recursive: true, force: true });
66
+ }
67
+
68
+ // ── repoNameFromUrl ─────────────────────────────────────────────────────
69
+
70
+ test('repoNameFromUrl:从各种地址形态里取出仓库名', () => {
71
+ assert.equal(repoNameFromUrl('git@github.com:xz333221/zen-gitsync.git'), 'zen-gitsync');
72
+ assert.equal(repoNameFromUrl('https://gitee.com/xz_web/xiangyu-sites'), 'xiangyu-sites');
73
+ // 尾斜杠 + .git 同时出现
74
+ assert.equal(repoNameFromUrl('https://github.com/a/b.git/'), 'b');
75
+ // ssh:// 形式(带端口)也走同一条路径
76
+ assert.equal(repoNameFromUrl('ssh://git@github.com:22/a/b.git'), 'b');
77
+ // 取不出来 → 空串,由调用方兜底成 repository
78
+ assert.equal(repoNameFromUrl(''), '');
79
+ assert.equal(repoNameFromUrl(' '), '');
80
+ // 只有纯路径分隔符才真的取不出东西;根地址(没有仓库段)会退化成 host,
81
+ // 不是空串 —— 钉住这个行为,免得以后误以为它一定是"干净"的仓库名
82
+ assert.equal(repoNameFromUrl('/'), '');
83
+ assert.equal(repoNameFromUrl('https://github.com/'), 'github.com');
84
+ });
85
+
86
+ // ── describeCloneError ──────────────────────────────────────────────────
87
+
88
+ test('describeCloneError:超时说人话,其余透出 git 的最后一行', () => {
89
+ assert.match(describeCloneError({ killed: true }), /超时/);
90
+ assert.match(describeCloneError({ signal: 'SIGTERM' }), /超时/);
91
+ assert.equal(
92
+ describeCloneError({ stderr: 'Cloning into...\nwarning: 一些警告\nfatal: repository not found\n' }),
93
+ 'fatal: repository not found'
94
+ );
95
+ assert.equal(describeCloneError({ message: 'spawn git ENOENT' }), 'spawn git ENOENT');
96
+ assert.equal(describeCloneError({}), '克隆失败');
97
+ });
98
+
99
+ // ── 正常路径 ────────────────────────────────────────────────────────────
100
+
101
+ test('克隆成功:在父目录下建出以仓库名命名的子目录', async () => {
102
+ const parent = tmpDir();
103
+ const seen = [];
104
+ const handler = register(async (args) => { seen.push(args); });
105
+
106
+ const { payload } = await invoke(handler, {
107
+ url: 'git@github.com:xz333221/zen-gitsync.git',
108
+ parentDir: parent
109
+ });
110
+
111
+ assert.equal(payload.success, true);
112
+ assert.equal(payload.path, path.join(path.resolve(parent), 'zen-gitsync'));
113
+ assert.equal(seen.length, 1);
114
+ assert.deepEqual(seen[0], {
115
+ url: 'git@github.com:xz333221/zen-gitsync.git',
116
+ parentDir: path.resolve(parent),
117
+ target: path.join(path.resolve(parent), 'zen-gitsync')
118
+ });
119
+
120
+ cleanup(parent);
121
+ });
122
+
123
+ test('地址取不出仓库名时,兜底成 repository 而不是拼出一个父目录本身', async () => {
124
+ const parent = tmpDir();
125
+ const seen = [];
126
+ const handler = register(async (args) => { seen.push(args); });
127
+
128
+ // '/' 这种地址 assertGitRemoteUrl 放行(本地路径形态),但取不出仓库名
129
+ const { payload } = await invoke(handler, { url: '/', parentDir: parent });
130
+
131
+ assert.equal(payload.success, true);
132
+ assert.equal(seen[0].target, path.join(path.resolve(parent), 'repository'));
133
+
134
+ cleanup(parent);
135
+ });
136
+
137
+ // ── 拒绝的入参 ──────────────────────────────────────────────────────────
138
+
139
+ test('地址非法:空 / ext:: / 把"远程名 + 地址"一起粘进来 —— 一律拒绝且不跑 git', async () => {
140
+ const parent = tmpDir();
141
+ let called = 0;
142
+ const handler = register(async () => { called += 1; });
143
+
144
+ for (const url of ['', ' ', 'ext::sh -c whoami', 'origin git@gitee.com:x/y.git']) {
145
+ const { payload } = await invoke(handler, { url, parentDir: parent });
146
+ assert.equal(payload.success, false, `url=${JSON.stringify(url)}`);
147
+ assert.ok(payload.error, `url=${JSON.stringify(url)} 应带错误说明`);
148
+ }
149
+ assert.equal(called, 0, '入参不合法时一次都不该执行 git clone');
150
+
151
+ cleanup(parent);
152
+ });
153
+
154
+ test('父目录:不存在 / 不是目录 / 相对路径 —— 一律拒绝且不跑 git', async () => {
155
+ const parent = tmpDir();
156
+ const filePath = path.join(parent, 'a-file.txt');
157
+ fs.writeFileSync(filePath, 'x');
158
+ let called = 0;
159
+ const handler = register(async () => { called += 1; });
160
+
161
+ const cases = [
162
+ [path.join(parent, 'not-exist'), /不存在/],
163
+ [filePath, /不是文件夹/],
164
+ ['relative/dir', /绝对路径/],
165
+ ['', /缺少目标文件夹/]
166
+ ];
167
+ for (const [parentDir, pattern] of cases) {
168
+ const { payload } = await invoke(handler, {
169
+ url: 'git@github.com:a/b.git',
170
+ parentDir
171
+ });
172
+ assert.equal(payload.success, false, `parentDir=${parentDir}`);
173
+ assert.match(payload.error, pattern);
174
+ }
175
+ assert.equal(called, 0);
176
+
177
+ cleanup(parent);
178
+ });
179
+
180
+ test('目标子目录已存在:先拦下,把路径说清楚,不去撞 git 那句英文报错', async () => {
181
+ const parent = tmpDir();
182
+ fs.mkdirSync(path.join(parent, 'zen-gitsync'));
183
+ let called = 0;
184
+ const handler = register(async () => { called += 1; });
185
+
186
+ const { payload } = await invoke(handler, {
187
+ url: 'git@github.com:xz333221/zen-gitsync.git',
188
+ parentDir: parent
189
+ });
190
+
191
+ assert.equal(payload.success, false);
192
+ assert.match(payload.error, /已存在/);
193
+ assert.match(payload.error, /zen-gitsync/);
194
+ assert.equal(called, 0);
195
+
196
+ cleanup(parent);
197
+ });
198
+
199
+ // ── 失败透传 ────────────────────────────────────────────────────────────
200
+
201
+ test('git 失败:状态码与错误原文透给前端', async () => {
202
+ const parent = tmpDir();
203
+ const handler = register(async () => {
204
+ throw new HttpError(500, 'fatal: could not read from remote repository');
205
+ });
206
+
207
+ const { statusCode, payload } = await invoke(handler, {
208
+ url: 'git@github.com:a/b.git',
209
+ parentDir: parent
210
+ });
211
+
212
+ assert.equal(statusCode, 500);
213
+ assert.equal(payload.success, false);
214
+ assert.match(payload.error, /could not read from remote repository/);
215
+
216
+ cleanup(parent);
217
+ });
218
+
219
+ // ── 克隆成功后登记本机仓库清单 ──────────────────────────────────────────
220
+
221
+ test('克隆成功:落点被登记进本机仓库清单,徽标不必等下一次全盘重扫', async () => {
222
+ const parent = tmpDir();
223
+ const cachePath = process.env.ZEN_LOCAL_REPOS_CACHE;
224
+ const prevRepo = path.join(parent, 'already-known');
225
+
226
+ // 造一份"上次全盘扫的结果":登记只该往里加一条,不能把整份盖掉
227
+ fs.writeFileSync(
228
+ cachePath,
229
+ JSON.stringify({
230
+ scannedAt: 1,
231
+ scannedDirs: 2,
232
+ roots: ['X:/'],
233
+ repos: { [prevRepo]: 'https://gitee.com/xz_web/xiangqi.git' }
234
+ }),
235
+ 'utf8'
236
+ );
237
+ resetLocalRepoScan();
238
+
239
+ const handler = register(async () => {});
240
+ const { payload } = await invoke(handler, {
241
+ url: 'git@github.com:xz333221/zen-gitsync.git',
242
+ parentDir: parent
243
+ });
244
+ assert.equal(payload.success, true);
245
+
246
+ const raw = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
247
+ assert.equal(raw.repos[payload.path], 'git@github.com:xz333221/zen-gitsync.git');
248
+ assert.equal(raw.repos[prevRepo], 'https://gitee.com/xz_web/xiangqi.git', '旧记录必须还在');
249
+ // 登记 ≠ 重扫:时间戳保持原样,否则前端会以为"刚扫过"
250
+ assert.equal(raw.scannedAt, 1);
251
+
252
+ cleanup(parent);
253
+ });
@@ -25,6 +25,7 @@ import { createDiffHelpers } from './git/diffUtils.js';
25
25
  import { registerGitStashRoutes } from './git/stash.js';
26
26
  import { registerGitTagRoutes } from './git/tags.js';
27
27
  import { registerGitRemoteRoutes, __testables as remoteTestables } from './git/remotes.js';
28
+ import { registerGitCloneRoutes } from './git/clone.js';
28
29
  import {
29
30
  assertGitRef,
30
31
  assertGitHash,
@@ -1016,6 +1017,10 @@ export function registerGitOpsRoutes({
1016
1017
  setRecentPushStatus
1017
1018
  });
1018
1019
 
1020
+ // 克隆到指定文件夹:远程仓库列表卡片上的「克隆到文件夹」。
1021
+ // 注意它不接 execGitCommand —— clone 的 cwd 是用户选的那个目录,不是当前工作目录。
1022
+ registerGitCloneRoutes({ app });
1023
+
1019
1024
  // 重置暂存区 (git reset HEAD)
1020
1025
  app.post('/api/reset-head', async (req, res) => {
1021
1026
  try {
@@ -0,0 +1,60 @@
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
+ // 本机 Git 仓库清单(远程仓库列表的「已克隆」徽标用)。
16
+ //
17
+ // GET /api/local-repos 取快照 —— 不阻塞:首次运行没有缓存时立刻回
18
+ // { scanning: true, repos: {} },调用方过几秒再问
19
+ // POST /api/local-repos/scan 强制重扫("我刚才在别处克隆了个仓库")
20
+ //
21
+ // 为什么 GET 不 await 扫描:全盘扫一次十几秒(见 utils/localRepoScan.js 的文件头),
22
+ // 把它挂在 HTTP 请求上会让仓库列表首屏跟着卡十几秒。宁可先给一份可能过期的,
23
+ // 也不要让用户对着转圈等。
24
+ //
25
+ // 安全口径:**不接受任何客户端传入的路径**。扫描根固定是"本机存在的盘符",
26
+ // 跳过名单是模块里的常量 —— 没有任何参数能把它变成"遍历你指定的目录"。
27
+ // 所以它不需要像 /api/recent_directories/git-state 那样做白名单校验。
28
+ import { asyncRoute } from '../utils/asyncRoute.js'
29
+ import logger from '../utils/logger.js'
30
+ import { getLocalRepos, refreshLocalRepos } from '../utils/localRepoScan.js'
31
+
32
+ /**
33
+ * 注册本机仓库清单路由。
34
+ *
35
+ * *Impl 参数是给单测留的注入口(与 registerRemoteReposRoutes 同一套做法):
36
+ * 默认实现会真去遍历磁盘,单测换成假实现就不必扫一遍真实文件系统。
37
+ */
38
+ export function registerLocalReposRoutes({
39
+ app,
40
+ getLocalReposImpl = getLocalRepos,
41
+ refreshReposImpl = refreshLocalRepos,
42
+ }) {
43
+ app.get('/api/local-repos', asyncRoute(async (req, res) => {
44
+ const snapshot = await getLocalReposImpl()
45
+ res.json({ success: true, ...snapshot })
46
+ }))
47
+
48
+ app.post('/api/local-repos/scan', asyncRoute(async (req, res) => {
49
+ // 刻意不 await 扫描本身(要十几秒):这里只负责"开始",结果由前端轮询 GET 拿。
50
+ // refreshLocalRepos 会把状态同步置成 scanning,随后回来的 GET 就能看到。
51
+ //
52
+ // 必须显式 .catch:这是"发射后不管"的 promise,一旦 reject 就是**进程级**
53
+ // unhandled rejection(Node 15+ 默认直接崩服务)。默认实现内部 try/catch 过、
54
+ // 不会 reject,但注入口的存在意味着将来可能换实现,别把崩溃留成隐患。
55
+ void Promise.resolve(refreshReposImpl()).catch((error) => {
56
+ logger.warn('[local-repos] 手动重扫失败:', error?.message || error)
57
+ })
58
+ res.json({ success: true, started: true })
59
+ }))
60
+ }