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 +1 -1
- package/src/core/git-exec.js +77 -0
- package/src/core/remote-path.js +53 -0
- package/src/tools/cluster.js +31 -9
- package/src/tools/git.js +85 -30
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/cluster.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
|
19
|
-
if (top.error
|
|
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.
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
193
|
-
const diffOut = await
|
|
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 (
|
|
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
|
}
|