zen-gitsync 2.13.22 → 2.13.24

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/gitCommit.js +2 -2
  3. package/src/ui/public/assets/{AppVersionBadge-CS59-kD5.js → AppVersionBadge-CaVphp8E.js} +2 -2
  4. package/src/ui/public/assets/{BranchSelector-DulCErgN.js → BranchSelector-B91Ylbas.js} +1 -1
  5. package/src/ui/public/assets/{CommandConsole-H1j4BsHx.js → CommandConsole-bK4iwZvI.js} +2 -2
  6. package/src/ui/public/assets/{CommitForm-DIdLEAZ_.js → CommitForm-zOCxmZ84.js} +1 -1
  7. package/src/ui/public/assets/{CustomCommandManager-Cm5yU0TM.js → CustomCommandManager-C1lRrp5z.js} +1 -1
  8. package/src/ui/public/assets/{EditorView-CTvDExIi.js → EditorView-DYo2oYtk.js} +0 -0
  9. package/src/ui/public/assets/{FileDiffViewer-C7Cosx8a.js → FileDiffViewer-Ce17WOZ-.js} +1 -1
  10. package/src/ui/public/assets/{FlowExecutionViewer-BeGTXBVV.js → FlowExecutionViewer-CRK6lIul.js} +1 -1
  11. package/src/ui/public/assets/{FlowOrchestrationWorkspace-CuqAZCaM.js → FlowOrchestrationWorkspace-BDw7ThWp.js} +1 -1
  12. package/src/ui/public/assets/{GitStatus-DBDjyLJD.js → GitStatus-DN4_m6sO.js} +1 -1
  13. package/src/ui/public/assets/{ImagePreview-C8eIs5yL.js → ImagePreview-D8klNa6F.js} +1 -1
  14. package/src/ui/public/assets/{LogList-YCJ_gVWr.js → LogList-BshRTCrE.js} +1 -1
  15. package/src/ui/public/assets/{ProjectStartupButton-gWSO3HCJ.js → ProjectStartupButton-C-dXxxMO.js} +1 -1
  16. package/src/ui/public/assets/{RemoteRepoCard-9iWOAx2g.js → RemoteRepoCard-DDZcP5-V.js} +1 -1
  17. package/src/ui/public/assets/{ResetToRemoteButton-BckRJ_lM.js → ResetToRemoteButton-TXJzvBxE.js} +1 -1
  18. package/src/ui/public/assets/{SourceMapView-DWBowIx1.js → SourceMapView-BUkB6h41.js} +1 -1
  19. package/src/ui/public/assets/{TemplateManager-DiKpGMhi.js → TemplateManager-DFVzDiu0.js} +1 -1
  20. package/src/ui/public/assets/{UserInputNode-CU11vBBn.js → UserInputNode-CXyV1xG4.js} +1 -1
  21. package/src/ui/public/assets/WorkbenchView-CEYhsa4O.js +12 -0
  22. package/src/ui/public/assets/WorkbenchView-DvML7yIv.css +1 -0
  23. package/src/ui/public/assets/{_plugin-vue_export-helper-vLPduyLG.js → _plugin-vue_export-helper-BfbhkIv4.js} +2 -2
  24. package/src/ui/public/assets/{index-BxxqloTb.js → index-BGmxuKoN.js} +3 -3
  25. package/src/ui/public/index.html +2 -2
  26. package/src/ui/server/index.js +3 -3
  27. package/src/ui/server/routes/branchStatus.js +4 -4
  28. package/src/ui/server/routes/config.js +8 -8
  29. package/src/ui/server/routes/exec.js +7 -1
  30. package/src/ui/server/routes/fs.js +2 -2
  31. package/src/ui/server/routes/git/diff.js +17 -16
  32. package/src/ui/server/routes/git/diffUtils.js +21 -2
  33. package/src/ui/server/routes/git/stash.js +38 -42
  34. package/src/ui/server/routes/git/tags.js +10 -10
  35. package/src/ui/server/routes/git.js +15 -26
  36. package/src/ui/server/routes/gitOps.js +91 -125
  37. package/src/ui/server/routes/status.js +3 -3
  38. package/src/ui/server/routes/workbench.js +160 -23
  39. package/src/utils/index.js +32 -29
  40. package/src/ui/public/assets/WorkbenchView-BsVMwobR.js +0 -12
  41. package/src/ui/public/assets/WorkbenchView-DslIMLgz.css +0 -1
@@ -388,8 +388,70 @@ async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
388
388
  return lines.join('\n');
389
389
  }
390
390
 
391
+ // 剥离 LLM 输出的 思考块、```json``` 代码块围栏,以及首尾说明文字。
392
+ // 原贪婪正则 /({[\s\S]*})/ 会把 思考块(内含未配对花括号或字符串)一起吞进 JSON.parse,
393
+ // 在 DeepSeek 等会输出 推理链的模型上偶发 "Unterminated string in JSON"。
394
+ function stripThinkingBlocks(content) {
395
+ let s = String(content || '');
396
+ // 1) 显式 思考块(成对标签)
397
+ s = s.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
398
+ // 2) 某些 provider 流式结尾会留下裸 <think>...</think> 之外的残段(无结束标签时被截断)
399
+ s = s.replace(/<think(?:ing)?>[\s\S]*$/gi, '');
400
+ // 3) ```json ... ``` 代码块围栏(保留内部 JSON)
401
+ s = s.replace(/```json\s*/gi, '').replace(/```/g, '');
402
+ return s;
403
+ }
404
+
405
+ // 在剥离 思考块 的文本里定位第一个「平衡花括号对象」的起点/终点。
406
+ // 跳过字符串内的 {/}(含转义)以及 Jinja {{ }} 模板占位符;
407
+ // 同时只在「JSON 特征显著」的 { 处起算 —— 即该 { 之后不远处出现 " 字符串。
408
+ // 这能避免把 LLM 思考文本里的裸 { ... }(如 "see { views, stores }")误当 JSON 起点。
409
+ function extractFirstJsonObject(content) {
410
+ const s = String(content || '');
411
+ let depth = 0;
412
+ let inStr = false;
413
+ let escape = false;
414
+ let start = -1;
415
+ for (let i = 0; i < s.length; i++) {
416
+ const ch = s[i];
417
+ if (inStr) {
418
+ if (escape) { escape = false; continue; }
419
+ if (ch === '\\') { escape = true; continue; }
420
+ if (ch === '"') { inStr = false; }
421
+ continue;
422
+ }
423
+ if (ch === '"') { inStr = true; continue; }
424
+ // 跳过 Jinja {{ }} 占位符(提示词模板常用,勿当 JSON)
425
+ if (ch === '{' && s[i + 1] === '{') { i++; continue; }
426
+ if (ch === '}' && s[i - 1] === '}') { continue; }
427
+ if (ch === '{') {
428
+ if (depth === 0) {
429
+ // 启发式判断「这看起来像 JSON 起点」:
430
+ // 1. { 之前紧邻「真正的语义字符」(字母/数字/中文/下划线) → 不是 JSON 起点(散文里的花括号)
431
+ // 但接受 . , ; : 等句末标点(LLM 经常以「总结:{...}」直接开头 JSON)
432
+ // 2. { 之后跳过空白第一个字符必须是 " 或 }(空对象)
433
+ const before = i > 0 ? s[i - 1] : '';
434
+ if (/[A-Za-z0-9_一-龥]/.test(before)) continue;
435
+ let j = i + 1;
436
+ while (j < s.length && /\s/.test(s[j])) j++;
437
+ const firstNonWs = s[j];
438
+ if (firstNonWs !== '"' && firstNonWs !== '}') continue;
439
+ start = i;
440
+ }
441
+ depth++;
442
+ } else if (ch === '}') {
443
+ if (depth > 0) depth--;
444
+ if (depth === 0 && start !== -1) {
445
+ return s.slice(start, i + 1);
446
+ }
447
+ }
448
+ }
449
+ // 兜底:找不到平衡对象时返回原文(让上层 JSON.parse 报清晰错误)
450
+ return s;
451
+ }
452
+
391
453
  async function callLlmJson(model, prompt, opts = {}) {
392
- const { maxTokens = 1500, timeoutMs = 60000, images = [] } = opts;
454
+ const { timeoutMs = 60000, images = [] } = opts;
393
455
  const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
394
456
  const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
395
457
  const headers = { 'Content-Type': 'application/json' };
@@ -410,7 +472,6 @@ async function callLlmJson(model, prompt, opts = {}) {
410
472
  const body = JSON.stringify({
411
473
  model: model.model,
412
474
  messages: [{ role: 'user', content: userContent }],
413
- max_tokens: maxTokens,
414
475
  temperature: 0.4,
415
476
  response_format: { type: 'json_object' },
416
477
  stream: false,
@@ -422,18 +483,37 @@ async function callLlmJson(model, prompt, opts = {}) {
422
483
  const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
423
484
  const data = await resp.json().catch(() => ({}));
424
485
  if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
425
- const content = data?.choices?.[0]?.message?.content || '{}';
486
+ const content = data?.choices?.[0]?.message?.content || '';
487
+ if (!content.trim()) {
488
+ throw new Error('LLM 返回内容为空');
489
+ }
426
490
  try {
427
- const m = content.match(/```json\s*([\s\S]*?)```/) || content.match(/({[\s\S]*})/);
428
- return JSON.parse(m ? m[1] : content);
429
- } catch {
430
- return {};
491
+ const cleaned = stripThinkingBlocks(content);
492
+ return JSON.parse(extractFirstJsonObject(cleaned));
493
+ } catch (err) {
494
+ const snippet = content.length > 500 ? content.slice(0, 500) + '…' : content;
495
+ throw new Error(`LLM 返回非 JSON 或被截断:${err.message};原始内容片段:${snippet}`);
431
496
  }
432
497
  } finally {
433
498
  clearTimeout(timer);
434
499
  }
435
500
  }
436
501
 
502
+ async function callLlmJsonWithRetry(model, prompt, opts = {}, retries = 1) {
503
+ let lastErr;
504
+ for (let i = 0; i <= retries; i++) {
505
+ try {
506
+ return await callLlmJson(model, prompt, opts);
507
+ } catch (err) {
508
+ lastErr = err;
509
+ if (i < retries) {
510
+ await new Promise(r => setTimeout(r, 800 * (i + 1)));
511
+ }
512
+ }
513
+ }
514
+ throw lastErr;
515
+ }
516
+
437
517
  /**
438
518
  * 流式调用 OpenAI 兼容 LLM。每收到一个 chunk 调 onDelta 回调。
439
519
  * onDelta 接收 { thinking?: string, content?: string },二选一。
@@ -1118,13 +1198,21 @@ async function runTaskQueue(task, repoPath, branch, opts) {
1118
1198
  const priorOutputs = fromIndex > 0
1119
1199
  ? await collectPriorOutputsUpTo(task, fromIndex)
1120
1200
  : []
1201
+ // 连续模式(默认):任意 sub 终态非 done(cancelled / error)→ 整批停,后续 sub 保持 todo。
1202
+ // AI 拆出来的 sub 一般前后强依赖(前一步产出是后一步输入),出错就停下来让用户决策。
1203
+ // 关闭后回退旧行为:单个 sub 失败不影响后续 sub 继续跑。
1204
+ const sequential = task.sequential !== false
1121
1205
  for (let i = fromIndex; i < task.subtasks.length; i++) {
1122
1206
  const sub = task.subtasks[i]
1123
1207
  if (sub.status === 'done') continue;
1124
- await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
1208
+ const outcome = await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
1125
1209
  // 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
1126
1210
  // sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
1127
1211
  await persistTaskAfterRun(task)
1212
+ if (sequential && outcome !== 'done') {
1213
+ // cancelled / error → 后续 sub 全部保持 todo,不再继续
1214
+ break
1215
+ }
1128
1216
  }
1129
1217
  }
1130
1218
 
@@ -1325,7 +1413,12 @@ ${prompt}`
1325
1413
  job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
1326
1414
  job.status = 'cancelled';
1327
1415
  job.error = '用户已停止执行';
1328
- // sub 不改状态——cancelled job 维度,同 task 后续 sub 仍可继续执行
1416
+ // 同步把闭包里的 sub 也置 cancelled(cancel 接口的 syncSubToCancelled 改了磁盘 task
1417
+ // 引用,但 runSingleSubtask 形参里的 sub 是另一份内存引用)。否则 finally publish sub:update
1418
+ // 会用 status='running' 覆盖掉前端已渲染的 cancelled 状态,导致 UI 反复跳回 running。
1419
+ // 同时 sequential=true 时 runTaskQueue 会看到 outcome='cancelled',break 不再跑后续 sub。
1420
+ sub.status = 'cancelled';
1421
+ if (!sub.error) sub.error = '用户已停止执行';
1329
1422
  } else {
1330
1423
  job.exitCode = 0;
1331
1424
  job.status = 'done';
@@ -1359,6 +1452,36 @@ ${prompt}`
1359
1452
  console.warn('[workbench] flushJobsSaveNow failed (job id=' + job.id + ', status=' + job.status + '):', err && err.message || err)
1360
1453
  }
1361
1454
  }
1455
+ // 把 sub 的终态返回给 runTaskQueue,用于「连续模式」判断要不要 break 整批队列
1456
+ return job.status // 'done' | 'cancelled' | 'error'
1457
+ }
1458
+
1459
+ /**
1460
+ * 把被取消的 sub 同步置 'cancelled' 并落盘。
1461
+ * cancelJob 路径专用:前端 taskIsRunning/sub.is-running 都看 sub.status,不改就会出现
1462
+ * "主任务黄点 + sub running 动效 + 右侧执行完成"三处不一致。
1463
+ *
1464
+ * 简单任务的虚拟 subId 不在 tasks.json 里(task.subtasks 是 complex 才有),所以这里
1465
+ * 找不到 sub 时静默返回;简单任务的 running 状态由 job 数组单独维护(见 taskIsRunning)。
1466
+ *
1467
+ * @returns {{ taskId: string, sub: object } | null} 找到并更新时返回新 sub,否则 null
1468
+ */
1469
+ async function syncSubToCancelled(job) {
1470
+ if (!job || !job.taskId || !job.subId) return null
1471
+ const data = await readJson(TASKS_FILE, { tasks: [] })
1472
+ const task = (data.tasks || []).find(x => x.id === job.taskId)
1473
+ if (!task || !Array.isArray(task.subtasks)) return null
1474
+ const sub = task.subtasks.find(s => s && s.id === job.subId)
1475
+ if (!sub) return null
1476
+ if (sub.status === 'cancelled') return { taskId: task.id, sub } // 已置过,幂等返回
1477
+ sub.status = 'cancelled'
1478
+ sub.error = '用户已停止执行'
1479
+ sub.errorAt = nowIso()
1480
+ task.updatedAt = nowIso()
1481
+ await writeJson(TASKS_FILE, data)
1482
+ publish('sub:update', { taskId: task.id, sub })
1483
+ publish('task:update', task)
1484
+ return { taskId: task.id, sub }
1362
1485
  }
1363
1486
 
1364
1487
  /** 把 task.subtasks 写回 tasks.json,并广播 task:update。runTaskQueue 和"单 sub 执行"共用。 */
@@ -1488,7 +1611,7 @@ export function registerWorkbenchRoutes({ app, getCurrentProjectPath, getProject
1488
1611
  }
1489
1612
 
1490
1613
  const projectName = path.basename(projectPath);
1491
- const LLM_OPTS = { maxTokens: 16000, timeoutMs: 1200000 };
1614
+ const LLM_OPTS = { timeoutMs: 1200000 };
1492
1615
 
1493
1616
  // ── 第一阶段:基于可编辑指令 + 根目录概览,生成「可复用的提示词模板」 ──
1494
1617
  const overviewBlock = subProjects.map(sp =>
@@ -1522,7 +1645,7 @@ ${subProjects.map(sp => {
1522
1645
  "template": "可复用的提示词模板,长度不限,请充分覆盖 {{task.title}} / {{task.desc}} / {{sub.title}} / {{sub.desc}} / {{repo.path}} / {{branch}} 这 6 个变量的用法与上下文"
1523
1646
  }`;
1524
1647
 
1525
- const first = await callLlmJson(model, firstPrompt, LLM_OPTS);
1648
+ const first = await callLlmJsonWithRetry(model, firstPrompt, LLM_OPTS);
1526
1649
  const templateName = String(first.name || '').trim() || `${projectName}架构说明`;
1527
1650
  const template = String(first.template || '').trim();
1528
1651
 
@@ -1553,11 +1676,15 @@ ${sp.readme || '(无)'}
1553
1676
  {
1554
1677
  "summary": "该子项目的架构说明,长度不限,模型自行决定篇幅与详尽程度,能写多详细就多详细"
1555
1678
  }`;
1556
- const r = await callLlmJson(model, subPrompt, LLM_OPTS);
1679
+ const r = await callLlmJsonWithRetry(model, subPrompt, LLM_OPTS);
1557
1680
  return { name: sp.name, root: sp.root, summary: String(r.summary || '').trim() };
1558
1681
  }
1559
1682
 
1560
- const subSummaries = await Promise.all(subProjects.map(summarizeOneSub));
1683
+ // 串行执行,避免并发触发 provider 限流(429)导致整批失败
1684
+ const subSummaries = [];
1685
+ for (const sp of subProjects) {
1686
+ subSummaries.push(await summarizeOneSub(sp));
1687
+ }
1561
1688
 
1562
1689
  // ── 第三阶段:仅多子项目时合并(单子项目直接拿它的 summary) ──
1563
1690
  let finalSummary = '';
@@ -1578,7 +1705,7 @@ ${sp.readme || '(无)'}
1578
1705
  ## 子项目说明
1579
1706
  ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summary || '(空)'}`).join('\n')}`;
1580
1707
 
1581
- const merged = await callLlmJson(model, mergePrompt, LLM_OPTS);
1708
+ const merged = await callLlmJsonWithRetry(model, mergePrompt, LLM_OPTS);
1582
1709
  finalSummary = String(merged.summary || '').trim()
1583
1710
  || subSummaries.map(s => `### ${s.name}\n${s.summary}`).join('\n\n');
1584
1711
  finalName = String(merged.name || '').trim() || templateName;
@@ -1588,13 +1715,9 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1588
1715
  finalName = `${projectName}架构说明`;
1589
1716
 
1590
1717
  if (!finalSummary) {
1591
- // 兜底:仅返回模板
1592
- return res.json({
1593
- success: true,
1594
- name: finalName,
1595
- template,
1596
- result: '',
1597
- content: template
1718
+ return res.status(502).json({
1719
+ success: false,
1720
+ error: '模型返回为空,请稍后重试'
1598
1721
  });
1599
1722
  }
1600
1723
 
@@ -2276,11 +2399,14 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2276
2399
 
2277
2400
  app.post('/api/workbench/tasks', async (req, res) => {
2278
2401
  try {
2279
- const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride } = req.body || {};
2402
+ const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride, sequential: rawSequential } = req.body || {};
2280
2403
  // title 非必填:允许空字符串,UI 层会用"未命名任务"占位
2281
2404
  const safeTitle = typeof title === 'string' ? title.trim() : '';
2282
2405
  // type 归一化:仅接受 'simple' | 'complex',缺省/未知一律按 complex
2283
2406
  const taskType = rawType === 'simple' ? 'simple' : 'complex';
2407
+ // sequential 归一化:仅接受 boolean,缺省/非布尔一律 true(连续模式),
2408
+ // 旧任务没字段时也是 true,保留连续默认行为
2409
+ const sequential = rawSequential === false ? false : true;
2284
2410
  const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
2285
2411
  const data = await readJson(TASKS_FILE, { tasks: [] });
2286
2412
  const tasks = data.tasks || [];
@@ -2296,6 +2422,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2296
2422
  desc: desc || '',
2297
2423
  promptId: promptId || null,
2298
2424
  type: taskType,
2425
+ // 复杂任务可显式关闭连续模式;简单任务无意义,固定 true
2426
+ sequential: taskType === 'complex' ? sequential : true,
2299
2427
  simpleOverride: taskType === 'simple' ? safeOverride : '',
2300
2428
  subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
2301
2429
  id: s.id || genId(),
@@ -2329,6 +2457,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2329
2457
  desc: desc || '',
2330
2458
  promptId: promptId || null,
2331
2459
  type: taskType,
2460
+ // 新建任务:简单任务固定 true,复杂任务用传入值(默认 true)
2461
+ sequential: taskType === 'complex' ? sequential : true,
2332
2462
  simpleOverride: taskType === 'simple' ? safeOverride : '',
2333
2463
  projectPath: currentProjectPath || '',
2334
2464
  subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
@@ -2530,7 +2660,11 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2530
2660
  // - 找到正在运行的 job,调 child.kill() 终止 claude 进程
2531
2661
  // - Windows 下用 taskkill /T /F 杀进程树(claude 进程可能 fork 出子进程)
2532
2662
  // - 加入 cancelledJobs 集合,runTaskQueue 退出循环后会把 job 标为 'cancelled'
2533
- // - 只影响这一个 sub;同 task 后续 sub 仍按队列顺序继续执行
2663
+ // - 同步把 sub.status 改为 'cancelled' 并 publish sub:update,
2664
+ // 否则前端 sub 列表/任务头黄点会一直显示 running(sub.status 没动)。
2665
+ // 历史这里只改 job.status 导致 UI 误显示「执行中」。
2666
+ // - 只影响这一个 sub;同 task 后续 sub 在 sequential=false 时继续执行,
2667
+ // sequential=true 时由 runTaskQueue 自然 break(见 runSingleSubtask 的 cancelled 分支)
2534
2668
  app.post('/api/workbench/jobs/:id/cancel', (req, res) => {
2535
2669
  const job = jobs.get(req.params.id)
2536
2670
  if (!job) {
@@ -2545,6 +2679,9 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2545
2679
  job.error = '用户已停止执行'
2546
2680
  job.endedAt = nowIso()
2547
2681
  publish('job:update', { ...job }) // 用浅拷贝避免序列化 child 引用
2682
+ // 同步把 sub 也置 cancelled,否则 taskIsRunning(sub.status==='running') 一直 true
2683
+ // → 左侧任务头黄点 + 右侧 sub 列表 running 动效都不消失
2684
+ const subInfo = syncSubToCancelled(job)
2548
2685
  // 终态:fire-and-forget 同步落盘,cancel 是显式操作,要保证不丢
2549
2686
  flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
2550
2687
  const child = job.child
@@ -32,7 +32,7 @@ import stringWidth from 'string-width';
32
32
  import Table from 'cli-table3';
33
33
  import chalk from 'chalk';
34
34
  import boxen from "boxen";
35
- import {exec, execSync} from 'child_process'
35
+ import {execFile, execSync} from 'child_process'
36
36
  import os from 'os'
37
37
  import ora from "ora";
38
38
  import readline from 'readline'
@@ -266,14 +266,18 @@ function clearCommandHistory() {
266
266
 
267
267
  function execGitCommand(command, options = {}) {
268
268
  return new Promise((resolve, reject) => {
269
- let {encoding = 'utf-8', maxBuffer = 30 * 1024 * 1024, head = command, log = true} = options
269
+ let {encoding = 'utf-8', maxBuffer = 30 * 1024 * 1024, head = Array.isArray(command) ? command.join(' ') : command, log = true} = options
270
270
  let cwd = getCwd()
271
-
271
+
272
272
  // Record start time for command execution
273
273
  const startTime = Date.now();
274
274
 
275
275
  // setTimeout(() => {
276
- exec(command, {
276
+ // 用 execFile('git', argv) 跨平台执行:
277
+ // - 不走 shell,Windows cmd.exe / POSIX sh 都一致
278
+ // - argv 数组天然免疫 shell 注入,文件名/参数无需 shellQuote
279
+ // - 所有调用点约定只跑 git 子命令,见 src/utils/index.js 内的 execGitCommand 调用清单
280
+ execFile('git', command, {
277
281
  env: {
278
282
  ...process.env,
279
283
  // LANG: 'en_US.UTF-8', // Linux/macOS
@@ -282,7 +286,10 @@ function execGitCommand(command, options = {}) {
282
286
  },
283
287
  encoding,
284
288
  maxBuffer,
285
- cwd
289
+ cwd,
290
+ // Windows 下 git 是 .exe;POSIX 下直接 PATH 找。
291
+ // 不传 shell,杜绝 cmd.exe 单/双引号兼容问题与注入风险。
292
+ windowsHide: true
286
293
  }, (error, stdout, stderr) => {
287
294
  if (options.spinner) {
288
295
  options.spinner.stop();
@@ -590,7 +597,7 @@ async function printGitLog() {
590
597
  n = parseInt(logArg.split('=')[1], 10);
591
598
  }
592
599
  // 使用 ASCII 记录分隔符 %x1E 作为字段分隔符
593
- const logCommand = `git log -n ${n} --pretty=format:"%C(green)%h%C(reset) %x1E %C(cyan)%an%C(reset) %x1E %C(yellow)%ad%C(reset) %x1E %C(blue)%D%C(reset) %x1E %C(magenta)%s%C(reset)" --date=format:"%Y-%m-%d %H:%M" --graph --decorate --color`
600
+ const logCommand = ['log', '-n', n, '--pretty=format:%C(green)%h%C(reset) %x1E %C(cyan)%an%C(reset) %x1E %C(yellow)%ad%C(reset) %x1E %C(blue)%D%C(reset) %x1E %C(magenta)%s%C(reset)', '--date=format:%Y-%m-%d %H:%M', '--graph', '--decorate', '--color']
594
601
  try {
595
602
  const logOutput = await execGitCommand(logCommand, {
596
603
  head: `git log`
@@ -620,7 +627,7 @@ async function exec_push({exit, commitMessage}) {
620
627
  // 执行 git push
621
628
  const spinner = ora('正在推送代码...').start();
622
629
  try {
623
- const {stdout, stderr} = await execGitCommand('git push', {
630
+ const {stdout, stderr} = await execGitCommand(['push'], {
624
631
  spinner
625
632
  });
626
633
  await printCommitLog({commitMessage});
@@ -633,15 +640,15 @@ async function exec_push({exit, commitMessage}) {
633
640
  async function printCommitLog({commitMessage}) {
634
641
  try {
635
642
  // 获取项目名称(取git仓库根目录名)
636
- const projectRootResult = await execGitCommand('git rev-parse --show-toplevel', {log: false});
643
+ const projectRootResult = await execGitCommand(['rev-parse', '--show-toplevel'], {log: false});
637
644
  const projectName = chalk.blueBright(path.basename(projectRootResult.stdout.trim()));
638
645
 
639
646
  // 获取当前提交hash(取前7位)
640
- const commitHashResult = await execGitCommand('git rev-parse --short HEAD', {log: false});
647
+ const commitHashResult = await execGitCommand(['rev-parse', '--short', 'HEAD'], {log: false});
641
648
  const hashDisplay = chalk.yellow(commitHashResult.stdout.trim());
642
649
 
643
650
  // 获取分支信息
644
- const branchResult = await execGitCommand('git branch --show-current', {log: false});
651
+ const branchResult = await execGitCommand(['branch', '--show-current'], {log: false});
645
652
  const branchDisplay = chalk.magenta(branchResult.stdout.trim());
646
653
 
647
654
  // 构建信息内容
@@ -679,7 +686,7 @@ async function execPull() {
679
686
  try {
680
687
  // 检查是否需要拉取更新
681
688
  const spinner = ora('正在拉取代码...').start();
682
- await execGitCommand('git pull', {
689
+ await execGitCommand(['pull'], {
683
690
  spinner
684
691
  })
685
692
  } catch (e) {
@@ -697,12 +704,12 @@ async function judgeRemote() {
697
704
  try {
698
705
  // 检查是否有远程更新
699
706
  // 先获取远程最新状态
700
- await execGitCommand('git remote update', {
707
+ await execGitCommand(['remote', 'update'], {
701
708
  head: 'Fetching remote updates',
702
709
  log: false
703
710
  });
704
711
  // 检查是否需要 pull
705
- const res = await execGitCommand('git rev-list HEAD..@{u} --count', {
712
+ const res = await execGitCommand(['rev-list', 'HEAD..@{u}', '--count'], {
706
713
  head: 'Checking if behind remote',
707
714
  log: false
708
715
  });
@@ -754,7 +761,7 @@ async function judgeRemote() {
754
761
  async function execDiff() {
755
762
  const no_diff = process.argv.find(arg => arg.startsWith('--no-diff'))
756
763
  if (!no_diff) {
757
- await execGitCommand('git diff --color=always', {
764
+ await execGitCommand(['diff', '--color=always'], {
758
765
  head: `git diff`
759
766
  })
760
767
  }
@@ -768,14 +775,14 @@ async function execGitAddWithLockFilter() {
768
775
 
769
776
  if (lockedFiles.length === 0) {
770
777
  // 如果没有锁定文件,直接执行 git add .
771
- await execGitCommand('git add .');
778
+ await execGitCommand(['add', '.']);
772
779
  return;
773
780
  }
774
781
 
775
782
  // 获取Git工作目录根路径,确保路径匹配的准确性
776
783
  let gitRoot;
777
784
  try {
778
- const gitRootResult = await execGitCommand('git rev-parse --show-toplevel', {log: false});
785
+ const gitRootResult = await execGitCommand(['rev-parse', '--show-toplevel'], {log: false});
779
786
  gitRoot = path.normalize(gitRootResult.stdout.trim());
780
787
  } catch (error) {
781
788
  console.warn(chalk.yellow('⚠️ 无法获取Git根目录,使用当前工作目录'));
@@ -783,7 +790,7 @@ async function execGitAddWithLockFilter() {
783
790
  }
784
791
 
785
792
  // 获取所有修改的文件(包括未跟踪文件)
786
- const statusResult = await execGitCommand('git status --porcelain --untracked-files=all', {log: false});
793
+ const statusResult = await execGitCommand(['status', '--porcelain', '--untracked-files=all'], {log: false});
787
794
  const modifiedFiles = statusResult.stdout
788
795
  .split('\n')
789
796
  .filter(line => line.trim())
@@ -896,19 +903,15 @@ async function execGitAddWithLockFilter() {
896
903
  }
897
904
 
898
905
  // 逐个添加未锁定的文件
899
- // 注意:用单引号包文件名,单引号内不解释 $ ` " \ 等元字符,
900
- // 只需把单引号自身转义为 '\''
901
- // 避免文件名含特殊字符(如 " $ ` ;) 导致 shell 注入或命令错乱
902
- // null/undefined 显式返回 '' 而非 'null'/'undefined' (与 src/ui/server/utils/shellQuote.js shQuote 契约一致)
903
- const shellQuote = (s) => {
904
- if (s === null || s === undefined) return "''"
905
- return `'${String(s).replace(/'/g, `'\\''`)}'`
906
- }
906
+ // execFile('git', argv) 直接传 argv 数组,不经过 shell,
907
+ // 文件名含空格/特殊字符时不需要 shellQuote;同时跨 Windows cmd.exe /
908
+ // POSIX sh 都一致(此前单引号写法在 Windows cmd.exe 不被识别,导致
909
+ // "pathspec '...did not match any files" 报错,见 da01bcb 回归)
907
910
  let successCount = 0
908
911
  let failedFiles = []
909
912
  for (const file of filesToAdd) {
910
913
  try {
911
- await execGitCommand(`git add -- ${shellQuote(file)}`, {
914
+ await execGitCommand(['add', '--', file], {
912
915
  head: `git add ${file}`,
913
916
  log: false
914
917
  })
@@ -979,7 +982,7 @@ async function execAddAndCommit({statusOutput, commitMessage, exit}) {
979
982
  }
980
983
 
981
984
  // 提交前二次校验(包括未跟踪文件)
982
- const checkStatus = await execGitCommand('git status --porcelain --untracked-files=all', {log: false});
985
+ const checkStatus = await execGitCommand(['status', '--porcelain', '--untracked-files=all'], {log: false});
983
986
  if (!checkStatus.stdout.trim()) {
984
987
  console.log(chalk.yellow('⚠️ 没有检测到可提交的变更'));
985
988
  // exec_exit(exit)
@@ -988,7 +991,7 @@ async function execAddAndCommit({statusOutput, commitMessage, exit}) {
988
991
 
989
992
  // 执行 git commit
990
993
  if (statusOutput.includes('Untracked files:') || statusOutput.includes('Changes not staged for commit') || statusOutput.includes('Changes to be committed')) {
991
- await execGitCommand(`git commit -m "${commitMessage}"`)
994
+ await execGitCommand(['commit', '-m', commitMessage])
992
995
  }
993
996
 
994
997
  // 返回实际使用的提交信息
@@ -1053,7 +1056,7 @@ async function addResetScriptToPackageJson() {
1053
1056
  }
1054
1057
 
1055
1058
  // 获取当前分支名
1056
- const branchResult = await execGitCommand('git branch --show-current', {log: false});
1059
+ const branchResult = await execGitCommand(['branch', '--show-current'], {log: false});
1057
1060
  const branch = branchResult.stdout.trim();
1058
1061
 
1059
1062
  // 添加 g:reset 命令