dsh-mesh-node 0.0.7 → 0.0.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-node",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Mesh server for dsh-mesh: expose a machine to peer DSH nodes over MCP/HTTPS, with delegation and optional desktop-control tools.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,77 @@
1
+ // @generated 共享的「git 可执行文件解析 + 执行」,供 src/tools/git.js 与 src/tools/cluster.js 共用
2
+ // [why] 2026-11-10:mesh server 由计划任务/启动脚本拉起,PATH 里可能没有 git(peer-node 实测:git 在
3
+ // C:\Users\<user>\tools\git\cmd,只有 DSH 的 start-dsh0.cmd 把它写进 PATH)。git.js 已有
4
+ // 「DSH_MESH_GIT → PATH → 常见安装目录」回退;cluster.js 的 gitInfo() 却仍直接 execFileSync('git'),
5
+ // 于是 mesh_overview 的本机 repo 摘要在干净 PATH 下失效且只报空。抽成共用模块,两处行为一致。
6
+ import { join } from 'node:path'
7
+ import { run } from './util.js'
8
+
9
+ /** 找不到 git 时给用户的可诊断提示。 */
10
+ const SPAWN_HINT =
11
+ 'the mesh server process cannot find git on its PATH. Add the git directory to PATH in the mesh server start script, or set DSH_MESH_GIT to the git executable, then restart mesh (no DSH restart needed).'
12
+
13
+ /**
14
+ * git 可执行文件候选,按优先级:DSH_MESH_GIT 覆盖 → PATH 里的 git → 平台常见安装位置。
15
+ * @returns {string[]} 去重后的候选列表
16
+ */
17
+ export function gitCandidates() {
18
+ const env = process.env
19
+ const list = []
20
+ if (env.DSH_MESH_GIT) list.push(env.DSH_MESH_GIT)
21
+ list.push('git')
22
+ if (process.platform === 'win32') {
23
+ for (const root of [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)']].filter(Boolean)) {
24
+ list.push(join(root, 'Git', 'cmd', 'git.exe'))
25
+ }
26
+ if (env.LOCALAPPDATA) list.push(join(env.LOCALAPPDATA, 'Programs', 'Git', 'cmd', 'git.exe'))
27
+ if (env.USERPROFILE) list.push(join(env.USERPROFILE, 'tools', 'git', 'cmd', 'git.exe'))
28
+ } else {
29
+ list.push('/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git')
30
+ }
31
+ return [...new Set(list)]
32
+ }
33
+
34
+ /** 解析一次并缓存(`git --version` 成功才算命中)。 */
35
+ let gitProbe = null
36
+ /**
37
+ * @returns {Promise<{bin: string, tried: string[]} | {error: {code: string, message: string}, tried: string[]}>}
38
+ */
39
+ export function gitBinary() {
40
+ if (!gitProbe) {
41
+ gitProbe = (async () => {
42
+ const tried = []
43
+ for (const candidate of gitCandidates()) {
44
+ tried.push(candidate)
45
+ const out = await run(candidate, ['--version'], { timeout: 5000 })
46
+ if (!out.error && /\bgit version\b/i.test(out.stdout)) return { bin: candidate, tried }
47
+ }
48
+ return { error: { code: 'ENOENT', message: `git not found; probed: ${tried.join(', ')}` }, tried }
49
+ })()
50
+ }
51
+ return gitProbe
52
+ }
53
+
54
+ /**
55
+ * 用解析到的 git 跑命令;解析失败时返回与 run() 同形的 error 结果,调用方不必各自处理。
56
+ * @param {string[]} args git 参数
57
+ * @param {{timeout?: number, cwd?: string, signal?: AbortSignal, maxBuffer?: number}} [options] 透传给 run()
58
+ * @returns {Promise<{error: unknown, stdout: string, stderr: string, code: number, killed: boolean, timedOut: boolean}>}
59
+ */
60
+ export async function runGit(args, options) {
61
+ const found = await gitBinary()
62
+ if (found.error) return { error: found.error, stdout: '', stderr: '', code: 0, killed: false, timedOut: false }
63
+ return run(found.bin, args, options)
64
+ }
65
+
66
+ /**
67
+ * 诊断信息:spawn 失败时附加到工具错误里,避免把「找不到 git」误报成「不是仓库」。
68
+ * @returns {{hint: string, gitCandidates: string[], pathEnv: string, nodePid: number}}
69
+ */
70
+ export function gitSpawnDiagnostics() {
71
+ return {
72
+ hint: SPAWN_HINT,
73
+ gitCandidates: gitCandidates(),
74
+ pathEnv: String(process.env.PATH || process.env.Path || ''),
75
+ nodePid: process.pid
76
+ }
77
+ }
@@ -0,0 +1,53 @@
1
+ // @generated 平台无关的「对端路径」拼接 / 取父目录(src 与 scripts 共用)
2
+ // [why] 2026-11-10:peer-inspect.mjs 用本机 `node:path.dirname` 推导对端用户目录。调用方可能是 Linux
3
+ // (手机 proot 里的 node),对端路径却是 Windows(`C:\Users\<user>\.dsh-mesh`)——
4
+ // posix dirname 把整串当成一个文件名,返回 `.`,于是拼出相对路径 `.dsh0/storages/workspace.json`,
5
+ // 被对端按 mesh server 的 cwd 解析成 `...\Code\dsh-mesh\.dsh0\storages\workspace.json` → E_NOT_FOUND。
6
+ // 对端路径的分隔符由**对端平台**决定,不能交给本机 path 模块;这里只用字符串处理,`/` 和 `\` 都支持。
7
+ // 2026-11-10 batch2(B5):从 scripts/lib/remote-path.mjs 提升到 src/core,供 src/tools/cluster.js 等共用;
8
+ // scripts/lib/remote-path.mjs 只做 re-export,保持既有 import 不变。
9
+
10
+ /** 探测一串路径使用的分隔符:出现反斜杠按 Windows 处理,否则按 POSIX。 */
11
+ function sepOf(p) {
12
+ return String(p ?? '').includes('\\') ? '\\' : '/'
13
+ }
14
+
15
+ /**
16
+ * 平台无关的取父目录。
17
+ * `C:\Users\<user>\.dsh-mesh` → `C:\Users\<user>`
18
+ * `/root/.dsh-mesh` → `/root`;`C:\` → `C:\`;`/` → `/`
19
+ * 没有分隔符的单段(相对名)原样返回——调用方只应对绝对路径使用它。
20
+ * @param {string} p 路径(对端风格,`/` 或 `\` 均可)
21
+ * @returns {string} 父目录
22
+ */
23
+ export function parentDir(p) {
24
+ const s = String(p ?? '')
25
+ if (!s) return ''
26
+ const trimmed = s.replace(/[\\/]+$/, '')
27
+ if (!trimmed) return s[0]
28
+ const m = /^(.*)[\\/][^\\/]*$/.exec(trimmed)
29
+ if (!m) {
30
+ // 单段:盘符(C:)要补回分隔符,相对名原样返回
31
+ return /^[A-Za-z]:$/.test(trimmed) ? trimmed + sepOf(s) : trimmed
32
+ }
33
+ if (/^[A-Za-z]:$/.test(m[1])) return m[1] + sepOf(s)
34
+ return m[1] || sepOf(s)
35
+ }
36
+
37
+ /**
38
+ * 平台无关拼接:分隔符跟随 base(base 含反斜杠就用 `\`),避免在本机用错分隔符拼出相对路径。
39
+ * `joinRemote('C:\\Users\\Administrator', '.dsh0', 'storages')` → `C:\Users\<user>\.dsh0\storages`
40
+ * @param {string} base 基准目录
41
+ * @param {...string} parts 追加的路径段
42
+ * @returns {string} 拼接结果
43
+ */
44
+ export function joinRemote(base, ...parts) {
45
+ const sep = sepOf(base)
46
+ let out = String(base ?? '').replace(/[\\/]+$/, '')
47
+ for (const part of parts) {
48
+ const p = String(part ?? '').replace(/^[\\/]+|[\\/]+$/g, '')
49
+ if (!p) continue
50
+ out = out ? `${out}${sep}${p}` : p
51
+ }
52
+ return out || sep
53
+ }
@@ -1,25 +1,42 @@
1
1
  import { z } from 'zod'
2
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
- import { execFileSync } from 'node:child_process'
4
3
  import { join } from 'node:path'
5
4
  import { loadPeers } from '../core/pairing.js'
6
5
  import { clusterReport, probePeer } from '../core/cluster.js'
7
6
  import { callPeerTool } from '../core/peer-client.js'
7
+ import { gitSpawnDiagnostics, runGit } from '../core/git-exec.js'
8
+ import { joinRemote } from '../core/remote-path.js'
8
9
  import { fail, ok } from '../core/result.js'
9
10
 
10
11
  const TERMINAL = new Set(['done', 'failed', 'cancelled'])
11
12
 
12
- function gitInfo(repo) {
13
+ /**
14
+ * 本机仓库摘要。走共享的 git 解析(DSH_MESH_GIT → PATH → 常见安装目录),
15
+ * 否则计划任务式干净 PATH 下这里会把「找不到 git」报成空摘要。
16
+ * @param {string} repo 仓库目录
17
+ * @returns {Promise<object>} { path, head, branch, dirty, ahead, behind } 或 { path, error, ...诊断 }
18
+ */
19
+ async function gitInfo(repo) {
13
20
  if (!repo) return { path: '', error: 'no repo path' }
14
- const run = (args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim()
21
+ const run = async (args) => {
22
+ const out = await runGit(['-C', repo, ...args], { timeout: 5000 })
23
+ if (out.error) {
24
+ const code = typeof out.error.code === 'string' ? out.error.code : `exit-${out.code}`
25
+ const error = new Error(out.error.message || out.stderr.trim() || `git ${args[0]} failed`)
26
+ error.spawnCode = typeof out.error.code === 'string' ? out.error.code : null
27
+ error.code = code
28
+ throw error
29
+ }
30
+ return out.stdout.trim()
31
+ }
15
32
  try {
16
- const head = run(['rev-parse', '--short', 'HEAD'])
17
- const branch = run(['rev-parse', '--abbrev-ref', 'HEAD'])
18
- const dirty = run(['status', '--porcelain']) !== ''
33
+ const head = await run(['rev-parse', '--short', 'HEAD'])
34
+ const branch = await run(['rev-parse', '--abbrev-ref', 'HEAD'])
35
+ const dirty = (await run(['status', '--porcelain'])) !== ''
19
36
  let ahead = null
20
37
  let behind = null
21
38
  try {
22
- const counts = run(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}']).split(/\s+/).map(Number)
39
+ const counts = (await run(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'])).split(/\s+/).map(Number)
23
40
  ahead = counts[0] || 0
24
41
  behind = counts[1] || 0
25
42
  } catch {
@@ -27,6 +44,9 @@ function gitInfo(repo) {
27
44
  }
28
45
  return { path: repo, head, branch, dirty, ahead, behind }
29
46
  } catch (error) {
47
+ if (error?.spawnCode) {
48
+ return { path: repo, error: `E_GIT_SPAWN: ${error.message}`, ...gitSpawnDiagnostics() }
49
+ }
30
50
  return { path: repo, error: String(error?.message || error) }
31
51
  }
32
52
  }
@@ -140,7 +160,8 @@ export function buildCluster(ctx) {
140
160
  const selfOut = await callPeerTool(peer, { tool: 'mesh_self', args: {}, timeoutMs: Math.min(timeoutMs, 8000) })
141
161
  const selfText = selfOut.result?.content?.[0]?.text || '{}'
142
162
  const self = JSON.parse(selfText)
143
- const repoPath = self.mesh?.repo || join(self.acl?.fsRoots?.[0]?.path || '', 'dsh-mesh')
163
+ // 对端仓库路径由**对端**决定:用平台无关拼接(Linux 节点拼 Windows 对端路径不能走本机 path.join)
164
+ const repoPath = self.mesh?.repo || joinRemote(self.acl?.fsRoots?.[0]?.path || '', 'dsh-mesh')
144
165
  const statusOut = await callPeerTool(peer, { tool: 'git_status', args: { path: repoPath }, timeoutMs: Math.min(timeoutMs, 8000) })
145
166
  const statusText = statusOut.result?.content?.[0]?.text || '{}'
146
167
  const status = JSON.parse(statusText)
@@ -157,12 +178,13 @@ export function buildCluster(ctx) {
157
178
  peerRepos.push({ peer: peer.name, error: String(error?.message || error) })
158
179
  }
159
180
  }
181
+ const localRepo = await gitInfo(process.cwd())
160
182
  return ok({
161
183
  node: report.node,
162
184
  summary: report.summary,
163
185
  verdict: report.verdict,
164
186
  issues: report.issues,
165
- repos: { local: gitInfo(process.cwd()), peers: peerRepos },
187
+ repos: { local: localRepo, peers: peerRepos },
166
188
  instances: (report.instances || []).map((instance) => ({
167
189
  id: instance.id,
168
190
  label: instance.label,
package/src/tools/git.js CHANGED
@@ -2,8 +2,9 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs'
2
2
  import { basename, join, resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { isInsideRoots } from '../core/config.js'
5
- import { clip, run } from '../core/util.js'
6
- import { denied, invalidArg, notFound, ok } from '../core/result.js'
5
+ import { gitBinary, gitSpawnDiagnostics, runGit } from '../core/git-exec.js'
6
+ import { clip } from '../core/util.js'
7
+ import { denied, fail, invalidArg, notFound, ok } from '../core/result.js'
7
8
 
8
9
  function guard(path, config) {
9
10
  const abs = resolve(path)
@@ -14,10 +15,53 @@ function guard(path, config) {
14
15
  return { abs }
15
16
  }
16
17
 
18
+ // git 可执行文件解析/执行与 spawn 诊断都在 src/core/git-exec.js(cluster.js 共用同一套回退)。
19
+ // [why] 2026-11-10:peer-node 的 mesh server 由没设 PATH 的启动脚本拉起,spawn git 直接 ENOENT,
20
+ // 旧代码把 top.error 一律吞成「不是仓库」,把真因藏了。现在 spawn 失败如实报 E_GIT_SPAWN。
21
+ function gitFailureResult(failure, extra = {}) {
22
+ return fail(failure.code, failure.message, { ...failure.extra, ...extra })
23
+ }
24
+
17
25
  async function repoRootOf(abs, { timeout, signal } = {}) {
18
- const top = await run('git', ['-C', abs, 'rev-parse', '--show-toplevel'], { timeout, signal })
19
- if (top.error || !top.stdout.trim()) return { error: top }
26
+ const top = await runGit(['-C', abs, 'rev-parse', '--show-toplevel'], { timeout, signal })
27
+ if (top.error) {
28
+ const spawnCode = top.error.code
29
+ // spawn 失败时 error.code 是字符串(ENOENT/EACCES…);进程正常退出(不是仓库)时是数字 exit code
30
+ if (typeof spawnCode === 'string') {
31
+ const probed = (await gitBinary()).tried || []
32
+ return {
33
+ failure: {
34
+ code: 'E_GIT_SPAWN',
35
+ message: `cannot spawn git (${spawnCode}): ${top.error.message || spawnCode}`,
36
+ extra: { spawnCode, ...gitSpawnDiagnostics(), probed }
37
+ }
38
+ }
39
+ }
40
+ if (top.killed || top.timedOut) {
41
+ return {
42
+ failure: {
43
+ code: 'E_GIT_TIMEOUT',
44
+ message: `git rev-parse timed out after ${timeout ?? '?'}ms: git -C ${abs} rev-parse --show-toplevel`,
45
+ extra: { timeoutMs: timeout ?? null }
46
+ }
47
+ }
48
+ }
49
+ const stderr = String(top.stderr || '').trim()
50
+ if (top.code === 128 && /not a git repository/i.test(stderr)) {
51
+ return { failure: { code: 'E_INVALID_ARG', message: `not a git repository: ${abs}`, extra: { exitCode: top.code, stderr: stderr.slice(0, 500) } } }
52
+ }
53
+ return {
54
+ failure: {
55
+ code: 'E_GIT_EXIT',
56
+ message: `git rev-parse failed (exit ${top.code}): ${stderr.slice(0, 400) || 'no stderr'}`,
57
+ extra: { exitCode: top.code, stderr: stderr.slice(0, 500) }
58
+ }
59
+ }
60
+ }
20
61
  const root = top.stdout.trim()
62
+ if (!root) {
63
+ return { failure: { code: 'E_GIT_EXIT', message: `git rev-parse returned no path for ${abs} (exit ${top.code})`, extra: { exitCode: top.code } } }
64
+ }
21
65
  try {
22
66
  return { root: realpathSync(root) }
23
67
  } catch {
@@ -44,18 +88,18 @@ function countDirty(entries) {
44
88
 
45
89
  async function repoSummary(abs, { timeout, signal, entries = 200 } = {}) {
46
90
  const found = await repoRootOf(abs, { timeout, signal })
47
- if (found.error) return null
91
+ if (found.failure) return { failure: found.failure }
48
92
  const root = found.root
49
93
  const [branch, head, upstream, status] = await Promise.all([
50
- run('git', ['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout, signal }),
51
- run('git', ['-C', root, 'rev-parse', 'HEAD'], { timeout, signal }),
52
- run('git', ['-C', root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { timeout, signal }),
53
- run('git', ['-C', root, 'status', '--porcelain=v1', '-b', '--untracked-files=normal'], { timeout, signal })
94
+ runGit(['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout, signal }),
95
+ runGit(['-C', root, 'rev-parse', 'HEAD'], { timeout, signal }),
96
+ runGit(['-C', root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { timeout, signal }),
97
+ runGit(['-C', root, 'status', '--porcelain=v1', '-b', '--untracked-files=normal'], { timeout, signal })
54
98
  ])
55
99
  let ahead = null
56
100
  let behind = null
57
101
  if (!upstream.error) {
58
- const counts = await run('git', ['-C', root, 'rev-list', '--left-right', '--count', '@{u}...HEAD'], {
102
+ const counts = await runGit(['-C', root, 'rev-list', '--left-right', '--count', '@{u}...HEAD'], {
59
103
  timeout,
60
104
  signal
61
105
  })
@@ -69,17 +113,19 @@ async function repoSummary(abs, { timeout, signal, entries = 200 } = {}) {
69
113
  const branchLine = lines.find((l) => l.startsWith('## ')) || ''
70
114
  const statusEntries = lines.filter((l) => !l.startsWith('## '))
71
115
  return {
72
- root,
73
- name: basename(root),
74
- branch: branch.error ? null : branch.stdout.trim(),
75
- head: head.error ? null : head.stdout.trim(),
76
- upstream: upstream.error ? null : upstream.stdout.trim(),
77
- ahead,
78
- behind,
79
- statusBranchLine: branchLine,
80
- dirty: countDirty(statusEntries),
81
- entries: statusEntries.slice(0, entries),
82
- truncatedEntries: Math.max(0, statusEntries.length - entries)
116
+ summary: {
117
+ root,
118
+ name: basename(root),
119
+ branch: branch.error ? null : branch.stdout.trim(),
120
+ head: head.error ? null : head.stdout.trim(),
121
+ upstream: upstream.error ? null : upstream.stdout.trim(),
122
+ ahead,
123
+ behind,
124
+ statusBranchLine: branchLine,
125
+ dirty: countDirty(statusEntries),
126
+ entries: statusEntries.slice(0, entries),
127
+ truncatedEntries: Math.max(0, statusEntries.length - entries)
128
+ }
83
129
  }
84
130
  }
85
131
 
@@ -124,8 +170,8 @@ export function buildGit(ctx) {
124
170
  const g = guard(path, config)
125
171
  if (g.error) return g.error
126
172
  const timeout = config.limits?.gitTimeoutMs ?? 20000
127
- const summary = await repoSummary(g.abs, { timeout, signal })
128
- if (!summary) return invalidArg(`not a git repository: ${g.abs}`)
173
+ const { summary, failure } = await repoSummary(g.abs, { timeout, signal })
174
+ if (failure) return gitFailureResult(failure, { path: g.abs })
129
175
  return ok(summary)
130
176
  }
131
177
  },
@@ -146,13 +192,13 @@ export function buildGit(ctx) {
146
192
  if (g.error) return g.error
147
193
  const timeout = config.limits?.gitTimeoutMs ?? 20000
148
194
  const found = await repoRootOf(g.abs, { timeout, signal })
149
- if (found.error) return invalidArg(`not a git repository: ${g.abs}`)
195
+ if (found.failure) return gitFailureResult(found.failure, { path: g.abs })
150
196
  const n = Math.min(limit ?? 20, 200)
151
197
  const args = ['-C', found.root, 'log', `-n${n}`]
152
198
  if (format === 'detail') args.push('--date=iso-strict', '--pretty=format:%H%x09%h%x09%an%x09%aI%x09%s')
153
199
  else args.push('--date=short', '--pretty=format:%h%x09%ad%x09%s')
154
200
  if (ref) args.push(ref, '--')
155
- const out = await run('git', args, { timeout, signal })
201
+ const out = await runGit(args, { timeout, signal })
156
202
  if (out.error) return invalidArg(`git log failed: ${out.stderr.trim().slice(0, 500)}`)
157
203
  const commits = out.stdout
158
204
  .split(/\r?\n/)
@@ -184,13 +230,13 @@ export function buildGit(ctx) {
184
230
  if (g.error) return g.error
185
231
  const timeout = config.limits?.gitTimeoutMs ?? 20000
186
232
  const found = await repoRootOf(g.abs, { timeout, signal })
187
- if (found.error) return invalidArg(`not a git repository: ${g.abs}`)
233
+ if (found.failure) return gitFailureResult(found.failure, { path: g.abs })
188
234
  const base = ['-C', found.root, 'diff']
189
235
  if (staged) base.push('--staged')
190
236
  if (ref) base.push(ref)
191
237
  const pathspec = file ? ['--', file] : []
192
- const statOut = await run('git', [...base, '--stat', ...pathspec], { timeout, signal })
193
- const diffOut = await run('git', [...base, ...pathspec], { timeout, signal })
238
+ const statOut = await runGit([...base, '--stat', ...pathspec], { timeout, signal })
239
+ const diffOut = await runGit([...base, ...pathspec], { timeout, signal })
194
240
  if (diffOut.error && diffOut.code !== 1) {
195
241
  return invalidArg(`git diff failed: ${diffOut.stderr.trim().slice(0, 500)}`)
196
242
  }
@@ -233,13 +279,17 @@ export function buildGit(ctx) {
233
279
  targets.push(...findRepos(abs, { depth: depth ?? 2, maxRepos: cap - targets.length }))
234
280
  }
235
281
  const repos = []
282
+ const errors = []
236
283
  let index = 0
237
284
  const worker = async () => {
238
285
  while (index < targets.length) {
239
286
  const dir = targets[index]
240
287
  index += 1
241
- const summary = await repoSummary(dir, { timeout, signal, entries: 20 })
242
- if (!summary) continue
288
+ const { summary, failure } = await repoSummary(dir, { timeout, signal, entries: 20 })
289
+ if (failure) {
290
+ errors.push({ path: dir, code: failure.code, message: failure.message, ...failure.extra })
291
+ continue
292
+ }
243
293
  const dirtyCount =
244
294
  summary.dirty.staged + summary.dirty.modified + summary.dirty.untracked + summary.dirty.unmerged
245
295
  if (dirtyOnly && dirtyCount === 0 && !(summary.ahead > 0)) continue
@@ -248,6 +298,10 @@ export function buildGit(ctx) {
248
298
  }
249
299
  await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker))
250
300
  repos.sort((a, b) => a.root.localeCompare(b.root))
301
+ // 全部候选都失败时把真因抛出来(2026-11-10 的坑:repo_scan 只回 scanned 1/repos 0,看不出是 git spawn 失败)
302
+ if (targets.length > 0 && repos.length === 0 && errors.length === targets.length) {
303
+ return gitFailureResult(errors[0], { scanned: targets.length, errors })
304
+ }
251
305
  const dirty = repos.filter(
252
306
  (r) => r.dirty.staged + r.dirty.modified + r.dirty.untracked + r.dirty.unmerged > 0 || r.ahead > 0
253
307
  )
@@ -258,6 +312,7 @@ export function buildGit(ctx) {
258
312
  repos: repos.length,
259
313
  dirtyRepos: dirty.length,
260
314
  dirtyOnly: Boolean(dirtyOnly),
315
+ ...(errors.length ? { errors } : {}),
261
316
  items: repos
262
317
  })
263
318
  }