@thincoder/core 0.9.2 → 0.9.4
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/CHANGELOG.md +76 -0
- package/README.md +1 -0
- package/agent/completion.mjs +3 -1
- package/agent/family-tools.mjs +12 -9
- package/agent/helpers.mjs +11 -2
- package/agent/run-stages.mjs +27 -5
- package/agent/setup.mjs +6 -0
- package/agent/write-gate.mjs +5 -5
- package/agent-tools/advisor-async.mjs +4 -4
- package/agent-tools/advisor.mjs +1 -1
- package/agent-tools/async-discard.mjs +1 -1
- package/agent-tools/audit-block.mjs +106 -0
- package/agent-tools/batch-lifecycle.mjs +72 -16
- package/agent-tools/batch-skeleton.mjs +69 -7
- package/agent-tools/batch.mjs +19 -6
- package/agent-tools/context.mjs +174 -0
- package/agent-tools/goal.mjs +7 -0
- package/agent-tools/parent-channel.mjs +2 -2
- package/agent-tools/plan.mjs +6 -6
- package/agent-tools/read-history.mjs +122 -24
- package/agent-tools/settings.mjs +4 -2
- package/agent-tools/subagent-async.mjs +3 -3
- package/agent-tools/subagent-spawn.mjs +29 -101
- package/agent-tools/task.mjs +11 -0
- package/agent-tools.mjs +4 -1
- package/agent.mjs +10 -4
- package/config-presets.mjs +2 -2
- package/config.mjs +1 -1
- package/context.mjs +66 -121
- package/fts-text.mjs +41 -0
- package/memory/core.mjs +4 -18
- package/memory/schema.mjs +4 -11
- package/model-specs.mjs +22 -7
- package/package.json +5 -1
- package/prompts/common.md +2 -2
- package/prompts/discipline-engineering.md +17 -2
- package/prompts/persona-engineering.md +1 -1
- package/session-gc.mjs +11 -0
- package/session-index-build.mjs +298 -0
- package/session-index-cmd.mjs +61 -0
- package/session-index-pass.mjs +95 -0
- package/session-index-query.mjs +102 -0
- package/session-index.mjs +285 -0
- package/session-slots-manifest.mjs +19 -0
- package/token-window.mjs +188 -0
- package/tools/bash.mjs +4 -15
- package/tools/execute.mjs +5 -13
- package/tools/git-checkpoint.mjs +1 -1
- package/tools/git-ext.mjs +23 -20
- package/tools/git-run.mjs +141 -0
- package/tools/git.mjs +42 -36
- package/tools/index.mjs +3 -1
- package/tools/process-tree.mjs +20 -0
- package/tools/shared.mjs +8 -4
package/tools/git-ext.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 供 git.mjs 核心 action 复用——500 行硬限拆分)。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
|
|
5
5
|
*/
|
|
6
6
|
import { runGit, truncate } from "./shared.mjs"
|
|
7
|
-
import {
|
|
7
|
+
import { spawnGit, gitTimeoutNote } from "./git-run.mjs"
|
|
8
8
|
|
|
9
9
|
/** Keep only output lines matching a regex (git filter, case-insensitive). */
|
|
10
10
|
export function filterLines(output, filter) {
|
|
@@ -19,12 +19,15 @@ export function filterLines(output, filter) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/** Run git and report failure (stderr + exit code) instead of swallowing it.
|
|
22
|
-
* Used by write ops (commit/push/rm) where a silent "" would masquerade as success.
|
|
23
|
-
|
|
22
|
+
* Used by write ops (commit/push/rm) where a silent "" would masquerade as success.
|
|
23
|
+
* §6.14:体改**异步薄壳**(`spawnGit` 单点——加固 env + 两档超时 + 树杀);`{ok, out, err}` 形逐字保留
|
|
24
|
+
* + 超时分支(`err` = 单源超时注——可辨性不靠 stderr 文本猜)。 */
|
|
25
|
+
export async function runGitStrict(cwd, cmdArgs, config = []) {
|
|
24
26
|
try {
|
|
25
|
-
const out =
|
|
27
|
+
const out = (await spawnGit(cwd, [...config, ...cmdArgs])).trim().replace(/\r/g, "")
|
|
26
28
|
return { ok: true, out }
|
|
27
29
|
} catch (e) {
|
|
30
|
+
if (e.timedOut) return { ok: false, out: String(e.stdout || "").trim(), err: gitTimeoutNote(e.timeoutMs) }
|
|
28
31
|
return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
|
|
29
32
|
}
|
|
30
33
|
}
|
|
@@ -36,7 +39,7 @@ export function validateRef(ref, what = "git ref") {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** Normalize args.config into `-c key=value` pairs (git -c overrides, e.g. a proxy).
|
|
39
|
-
* Values are
|
|
42
|
+
* Values are spawn array args (no shell injection) — still reject newlines/empty. */
|
|
40
43
|
export function gitConfigArgs(config) {
|
|
41
44
|
if (config == null) return []
|
|
42
45
|
if (!Array.isArray(config)) throw new Error("config must be an array of \"key=value\" strings")
|
|
@@ -70,11 +73,11 @@ export async function executeExtAction(args, ctx) {
|
|
|
70
73
|
if (!args.remote) return "Error: clone requires remote (URL or local path)"
|
|
71
74
|
const cmdArgs = ["clone", args.remote]
|
|
72
75
|
if (args.path) cmdArgs.push(args.path)
|
|
73
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
|
|
76
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
|
|
74
77
|
return r.ok ? truncate(r.out || `Cloned ${args.remote}`) : truncate(`git clone failed: ${r.err || r.out}`)
|
|
75
78
|
}
|
|
76
79
|
case "init": {
|
|
77
|
-
const r = runGitStrict(ctx.cwd, ["init"])
|
|
80
|
+
const r = await runGitStrict(ctx.cwd, ["init"])
|
|
78
81
|
return r.ok ? truncate(r.out || "Initialized empty git repository") : truncate(`git init failed: ${r.err || r.out}`)
|
|
79
82
|
}
|
|
80
83
|
case "rebase": {
|
|
@@ -89,21 +92,21 @@ export async function executeExtAction(args, ctx) {
|
|
|
89
92
|
if (sub === "abort") cmdArgs.push("--abort")
|
|
90
93
|
else if (sub === "continue") cmdArgs.push("--continue")
|
|
91
94
|
else { if (!args.ref) return "Error: rebase requires ref (branch/commit to rebase onto)"; cmdArgs.push(validateRef(args.ref)) }
|
|
92
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
95
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
93
96
|
return r.ok ? truncate(snap + (r.out || `Rebase ${sub} complete`)) : truncate(snap + `git rebase failed: ${r.err || r.out} — use rebaseAction=abort to abort`)
|
|
94
97
|
}
|
|
95
98
|
case "remote": {
|
|
96
99
|
const sub = args.remoteAction ?? "list"
|
|
97
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
|
|
100
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
|
|
98
101
|
if (!args.remote) return `Error: remote ${sub} requires remote (name)`
|
|
99
102
|
validateRef(args.remote, "remote name")
|
|
100
103
|
if (sub === "add" || sub === "set-url") {
|
|
101
104
|
if (!args.remoteUrl) return `Error: remote ${sub} requires remoteUrl`
|
|
102
|
-
const r = runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
|
|
105
|
+
const r = await runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
|
|
103
106
|
return r.ok ? `Remote ${args.remote} ${sub === "add" ? "added" : "URL set"}` : truncate(`git remote ${sub} failed: ${r.err || r.out}`)
|
|
104
107
|
}
|
|
105
108
|
if (sub === "remove") {
|
|
106
|
-
const r = runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
|
|
109
|
+
const r = await runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
|
|
107
110
|
return r.ok ? `Remote ${args.remote} removed` : truncate(`git remote remove failed: ${r.err || r.out}`)
|
|
108
111
|
}
|
|
109
112
|
return "Error: remote requires remoteAction — use: list | add | remove | set-url"
|
|
@@ -113,7 +116,7 @@ export async function executeExtAction(args, ctx) {
|
|
|
113
116
|
// dryRun (-n) is a preview: no deletion, no snapshot.
|
|
114
117
|
const snap = args.dryRun ? "" : await snapshotBefore(ctx, "clean")
|
|
115
118
|
const cmdArgs = ["clean", args.dryRun ? "-n" : "-f", "-d"]
|
|
116
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
119
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
117
120
|
return r.ok ? truncate(snap + (r.out || (args.dryRun ? "Nothing to clean (dry run)" : "Clean complete"))) : truncate(snap + `git clean failed: ${r.err || r.out}`)
|
|
118
121
|
}
|
|
119
122
|
case "switch": {
|
|
@@ -122,28 +125,28 @@ export async function executeExtAction(args, ctx) {
|
|
|
122
125
|
const cmdArgs = ["switch"]
|
|
123
126
|
if (args.create) cmdArgs.push("-c")
|
|
124
127
|
cmdArgs.push(args.name)
|
|
125
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
128
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
126
129
|
return r.ok ? truncate(r.out || `Switched to branch ${args.name}`) : truncate(`git switch failed: ${r.err || r.out}`)
|
|
127
130
|
}
|
|
128
131
|
case "apply": {
|
|
129
132
|
// Apply a patch — non-destructive (fails cleanly on conflict, applies nothing).
|
|
130
133
|
if (!args.path) return "Error: apply requires path (patch file)"
|
|
131
|
-
const r = runGitStrict(ctx.cwd, ["apply", "--", args.path])
|
|
134
|
+
const r = await runGitStrict(ctx.cwd, ["apply", "--", args.path])
|
|
132
135
|
return r.ok ? truncate(r.out || `Applied ${args.path}`) : truncate(`git apply failed: ${r.err || r.out}`)
|
|
133
136
|
}
|
|
134
137
|
case "worktree": {
|
|
135
138
|
const sub = args.worktreeAction ?? "list"
|
|
136
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
|
|
139
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
|
|
137
140
|
if (sub === "add") {
|
|
138
141
|
if (!args.path) return "Error: worktree add requires path (new worktree directory)"
|
|
139
142
|
const cmdArgs = ["worktree", "add", args.path]
|
|
140
143
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
141
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
144
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
142
145
|
return r.ok ? truncate(r.out || `Worktree added at ${args.path}`) : truncate(`git worktree add failed: ${r.err || r.out}`)
|
|
143
146
|
}
|
|
144
147
|
if (sub === "remove") {
|
|
145
148
|
if (!args.path) return "Error: worktree remove requires path"
|
|
146
|
-
const r = runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
|
|
149
|
+
const r = await runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
|
|
147
150
|
return r.ok ? truncate(r.out || `Worktree removed: ${args.path}`) : truncate(`git worktree remove failed: ${r.err || r.out}`)
|
|
148
151
|
}
|
|
149
152
|
return "Error: worktree requires worktreeAction — use: list | add | remove"
|
|
@@ -154,17 +157,17 @@ export async function executeExtAction(args, ctx) {
|
|
|
154
157
|
const cmdArgs = ["archive", "--format=tar", "-o", args.path]
|
|
155
158
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
156
159
|
else cmdArgs.push("HEAD")
|
|
157
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
160
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
158
161
|
return r.ok ? truncate(r.out || `Archived ${args.ref ?? "HEAD"} to ${args.path}`) : truncate(`git archive failed: ${r.err || r.out}`)
|
|
159
162
|
}
|
|
160
163
|
case "blame": {
|
|
161
164
|
if (!args.path) return "Error: blame requires path (file)"
|
|
162
|
-
const out = runGit(ctx.cwd, ["blame", "--", args.path])
|
|
165
|
+
const out = await runGit(ctx.cwd, ["blame", "--", args.path])
|
|
163
166
|
return truncate(out || `(no blame output for ${args.path})`)
|
|
164
167
|
}
|
|
165
168
|
case "mv": {
|
|
166
169
|
if (!args.path || !args.dest) return "Error: mv requires path (source) and dest (destination)"
|
|
167
|
-
const r = runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
|
|
170
|
+
const r = await runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
|
|
168
171
|
return r.ok ? truncate(r.out || `Moved ${args.path} → ${args.dest}`) : truncate(`git mv failed: ${r.err || r.out}`)
|
|
169
172
|
}
|
|
170
173
|
default:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/git-run.mjs — git spawn 单点(TOOLS.md §6.14 · 批 GIT-NONINTERACTIVE · 台账 #207)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单点:git 的四交互族(编辑器 / 凭据 / GUI / pager)在**无 TTY** 下永久等待——
|
|
5
|
+
* `rebase --continue` 起编辑器即冻死整个会话(已发布 `0.12.64` 实报)。加固只许一处
|
|
6
|
+
* (逐调用点补 = 必漏):三个形适配器(`shared.mjs` `runGit` · `git.mjs` `runGitRaw` ·
|
|
7
|
+
* `git-ext.mjs` `runGitStrict`)体转**异步薄壳**委托本档,签名与返回形零变。
|
|
8
|
+
*
|
|
9
|
+
* 三件套:① `GIT_ENV` 加固集(**env 形**——实测 `GIT_EDITOR` env 优先于 `core.editor`,
|
|
10
|
+
* `-c` 形单独不足)② 两档超时(本地 120s / 网络 300s · 全量适用)③ 超时动作序
|
|
11
|
+
* (SIGTERM → 1.5s 树杀 → 1.5s kick——孙进程持管道时 `close` 永不触发)。
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process"
|
|
14
|
+
import { killProcessTree } from "./process-tree.mjs"
|
|
15
|
+
|
|
16
|
+
/** 加固集(逐字 = §6.14「加固集」):继承面在前,加固键一律置后覆盖——继承面不得反超。
|
|
17
|
+
* 编辑器族 4 键 · pager 族 2 键 · 凭据族 2 键(本批新增,bash 面亦无)· 终端族 1 键。 */
|
|
18
|
+
export const GIT_ENV = {
|
|
19
|
+
...process.env, // 继承面(PATH / HOME / 用户 proxy 等)
|
|
20
|
+
GIT_EDITOR: "true", // 编辑器族:提交信息编辑器(解析链最高优先键)
|
|
21
|
+
GIT_SEQUENCE_EDITOR: "true", // 编辑器族:rebase todo 列表编辑器
|
|
22
|
+
EDITOR: "true", // 编辑器族:兜底链 VISUAL / EDITOR
|
|
23
|
+
VISUAL: "true",
|
|
24
|
+
GIT_PAGER: "cat", // pager 族(bash 工具先例同值)
|
|
25
|
+
PAGER: "cat",
|
|
26
|
+
GIT_TERMINAL_PROMPT: "0", // 凭据族:禁终端提示
|
|
27
|
+
GIT_ASKPASS: "", // 凭据族:空串 ⇒ 不调 askpass 程序(同时封 core.askpass 升级路)
|
|
28
|
+
TERM: "dumb", // 终端族(bash 工具先例同值)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const GIT_TIMEOUT_MS = 120_000
|
|
32
|
+
export const GIT_NET_TIMEOUT_MS = 300_000
|
|
33
|
+
/** 网路面五动作(300s 档——合法耗时可远超本地;依据 = 需求档 §4.7 TTY-DRIVE N3 候选参照值)。 */
|
|
34
|
+
const GIT_NET_ACTIONS = new Set(["push", "fetch", "pull", "clone", "ls-remote"])
|
|
35
|
+
|
|
36
|
+
/** 测试态缝(模块级 · 缺省 null = 生产零行为变——先例 `manifest.mjs:40-41` · `session-gc.mjs:136`)。
|
|
37
|
+
* 用例 `finally` 复位;不经用户参数面(边界:不新增参数 / 用户选项)。 */
|
|
38
|
+
let gitTimeoutOverride = null
|
|
39
|
+
export function _setGitTimeoutForTest(ms) { gitTimeoutOverride = ms }
|
|
40
|
+
export function _resetGitTimeoutForTest() { gitTimeoutOverride = null }
|
|
41
|
+
|
|
42
|
+
/** 子命令取形:跳 `-c <k=v>` 对(适配器把 config 前置在 args 头)——首个其余 arg 即子命令。 */
|
|
43
|
+
function gitSubcommand(args) {
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
if (args[i] === "-c") { i++; continue }
|
|
46
|
+
return args[i]
|
|
47
|
+
}
|
|
48
|
+
return ""
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 超时文案单源(逐字 = §6.14「超时错误文案」):两帧嵌同一条——`runGit` / `runGitRaw` 抛错经
|
|
52
|
+
* `gitFailureMessage`;`runGitStrict` 的 `err` = 本条(调用方另加 `git <action> failed: ` 前缀)。 */
|
|
53
|
+
export function gitTimeoutNote(ms) {
|
|
54
|
+
return `timed out after ${ms / 1000}s (killed) — no interactive input is possible here (editor / credential / network); `
|
|
55
|
+
+ "process killed, tree best-effort — retry or use the bash tool; an interrupted write keeps git state (`git status`) "
|
|
56
|
+
+ "— git abort / continue, or checkpoint action=checkpoint checkpointAction=list"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** git spawn 单点。返回 Promise<string>(stdout 原文——trim / `\r` 归一 / 溢出分支等形由适配器保留)。
|
|
60
|
+
* 失败 / 超时 / 溢出 ⇒ reject;错误形 = `execFileSync` 同构(`.stdout` / `.stderr` / `.status` / `.message` /
|
|
61
|
+
* `.code`),超时另带 `.timedOut = true` / `.timeoutMs`(可辨性不靠 stderr 文本猜)。
|
|
62
|
+
* 缺省:timeout 按面取常量(网络五动作 300s / 其余 120s)· maxBuffer = Node `execFileSync` 缺省 1MB
|
|
63
|
+
* (`runGit` / `runGitRaw` 显式传 10MB——形保真清单 ②)。 */
|
|
64
|
+
export function spawnGit(cwd, args, { timeout, maxBuffer } = {}) {
|
|
65
|
+
const timeoutMs = timeout ?? gitTimeoutOverride ?? (GIT_NET_ACTIONS.has(gitSubcommand(args)) ? GIT_NET_TIMEOUT_MS : GIT_TIMEOUT_MS)
|
|
66
|
+
const cap = maxBuffer ?? 1024 * 1024
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn("git", args, {
|
|
69
|
+
cwd,
|
|
70
|
+
env: GIT_ENV,
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
detached: process.platform !== "win32", // POSIX:组首 ⇒ 树杀(-pid 组杀)可达孙进程(execute / bash 先例)
|
|
74
|
+
})
|
|
75
|
+
let stdout = "", stderr = "", mode = null, settled = false
|
|
76
|
+
let outBytes = 0, errBytes = 0 // maxBuffer 口径 = **byte**(`execFileSync` 同源)
|
|
77
|
+
let timer = null, killTimer = null, kickTimer = null
|
|
78
|
+
const finish = (fn) => {
|
|
79
|
+
if (settled) return
|
|
80
|
+
settled = true
|
|
81
|
+
clearTimeout(timer); clearTimeout(killTimer); clearTimeout(kickTimer)
|
|
82
|
+
fn()
|
|
83
|
+
}
|
|
84
|
+
const timeoutErr = () => Object.assign(new Error(gitTimeoutNote(timeoutMs)), {
|
|
85
|
+
code: "ETIMEDOUT", timedOut: true, timeoutMs, stdout, stderr, status: null,
|
|
86
|
+
})
|
|
87
|
+
const overflowErr = () => Object.assign(new Error(`git output exceeded maxBuffer (${cap} bytes)`), {
|
|
88
|
+
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", stdout, stderr, status: null,
|
|
89
|
+
})
|
|
90
|
+
const exitErr = (code, signal) => Object.assign(new Error(`Command failed: git ${args.join(" ")}${stderr ? `\n${stderr}` : ""}`), {
|
|
91
|
+
cmd: `git ${args.join(" ")}`, status: code, signal, killed: Boolean(signal), stdout, stderr,
|
|
92
|
+
})
|
|
93
|
+
const settleWith = (code, signal) => {
|
|
94
|
+
// 超时收尾:树杀必达(`close` 早退 ⇒ 1.5s 定时器已被 finish 清掉——**不持管道**的孙进程
|
|
95
|
+
// 会因此漏杀;树杀在头进程死后于 POSIX 仍可达全组)
|
|
96
|
+
if (mode === "timeout") { treeKill(); return reject(timeoutErr()) }
|
|
97
|
+
if (mode === "overflow") return reject(overflowErr())
|
|
98
|
+
if (code === 0 && !signal) return resolve(stdout)
|
|
99
|
+
return reject(exitErr(code, signal))
|
|
100
|
+
}
|
|
101
|
+
const treeKill = () => { try { killProcessTree(child) } catch { /* 尽力而为 */ } }
|
|
102
|
+
// 超时动作序(§6.14):① 直接子 → ② 逾 1.5s 树杀(`killProcessTree`,尽力而为)→ ③ 逾 1.5s 未 `close`
|
|
103
|
+
// 亦 settle(kick——孙进程持管道时 `close` 永不触发,`bash.mjs:200-217` 同款)。上界 = timeout + 3s。
|
|
104
|
+
// 平台分岔(§6.14 平台注的后果):win32 上 SIGTERM 实为硬终止且**不连带子进程**——父一死,
|
|
105
|
+
// `taskkill /PID <父> /T` 就够不到孙进程(实测:helper 孤儿存活、管道不放 ⇒ 只能等 kick)⇒
|
|
106
|
+
// win32 直接走树杀(须在树仍可寻址时执行);POSIX 保留宽限序(组杀在头进程死后仍可达全组)。
|
|
107
|
+
const armKill = () => {
|
|
108
|
+
if (process.platform === "win32") treeKill()
|
|
109
|
+
else { try { child.kill("SIGTERM") } catch { /* 已退 */ } }
|
|
110
|
+
killTimer = setTimeout(treeKill, 1500)
|
|
111
|
+
kickTimer = setTimeout(() => finish(() => settleWith(null, "SIGTERM")), 3000)
|
|
112
|
+
}
|
|
113
|
+
timer = setTimeout(() => { if (!mode) { mode = "timeout"; armKill() } }, timeoutMs)
|
|
114
|
+
// 解码 = **流级 UTF-8**(`StringDecoder` 正确拼接跨 chunk 的多字节序列——逐 chunk `toString()`
|
|
115
|
+
// 会把半截序列解成 U+FFFD;批前三档经 `execFileSync({encoding:"utf8"})` 整体解码)
|
|
116
|
+
child.stdout.setEncoding("utf8")
|
|
117
|
+
child.stderr.setEncoding("utf8")
|
|
118
|
+
child.stdout.on("data", (d) => {
|
|
119
|
+
if (mode === "overflow") return
|
|
120
|
+
stdout += d
|
|
121
|
+
outBytes += Buffer.byteLength(d)
|
|
122
|
+
// 截断按**字符**(判据只认 code + 部分输出——多字节前缀可略超 cap 字节,语义不损)
|
|
123
|
+
if (outBytes > cap) { mode = "overflow"; stdout = stdout.slice(0, cap); armKill() }
|
|
124
|
+
})
|
|
125
|
+
child.stderr.on("data", (d) => {
|
|
126
|
+
if (errBytes > cap) return // 两管各自独立计(`spawnSync` 的 maxBuffer 同口径:per-stream)
|
|
127
|
+
stderr += d
|
|
128
|
+
errBytes += Buffer.byteLength(d)
|
|
129
|
+
})
|
|
130
|
+
child.on("error", (e) => {
|
|
131
|
+
// 杀失败等信号面错误不得遮蔽已定模式(timeout / overflow 由 kick 收尾)
|
|
132
|
+
if (mode) return
|
|
133
|
+
finish(() => reject(Object.assign(e, { stdout, stderr, status: null })))
|
|
134
|
+
})
|
|
135
|
+
child.on("exit", (code, signal) => {
|
|
136
|
+
// 直接子已退但孙进程持管道 ⇒ `close` 不到:逾 1.5s 亦按已收集输出 settle(kick)
|
|
137
|
+
if (!settled && !kickTimer) kickTimer = setTimeout(() => finish(() => settleWith(code, signal)), 1500)
|
|
138
|
+
})
|
|
139
|
+
child.on("close", (code, signal) => finish(() => settleWith(code, signal)))
|
|
140
|
+
})
|
|
141
|
+
}
|
package/tools/git.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
runGit,
|
|
5
5
|
gitFailureMessage
|
|
6
6
|
} from "./shared.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { spawnGit } from "./git-run.mjs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { filterLines, runGitStrict, validateRef, gitConfigArgs, snapshotBefore, executeExtAction } from "./git-ext.mjs";
|
|
10
10
|
import { executeCheckpointAction } from "./git-checkpoint.mjs";
|
|
@@ -15,10 +15,11 @@ import { discoverRepos, MANIFEST_REL } from "../manifest.mjs";
|
|
|
15
15
|
* strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
|
|
16
16
|
* unstaged-only first line as staged. status uses this so the staged/unstaged column survives.
|
|
17
17
|
* #55 fail-closed:失败不再吞成 ""(曾把非仓 / 任意失败渲染成 `(clean — no changes)`)——
|
|
18
|
-
* 溢出保留部分输出;其余 ⇒ throw(消息契约 = `shared.mjs` `gitFailureMessage
|
|
19
|
-
|
|
18
|
+
* 溢出保留部分输出;其余 ⇒ throw(消息契约 = `shared.mjs` `gitFailureMessage`)。§6.14:体改**异步薄壳**
|
|
19
|
+
* (`spawnGit` 单点——加固 env + 两档超时 + 树杀;签名与产出形零变)。 */
|
|
20
|
+
async function runGitRaw(cwd, cmdArgs, config = []) {
|
|
20
21
|
try {
|
|
21
|
-
return
|
|
22
|
+
return (await spawnGit(cwd, [...config, ...cmdArgs], { maxBuffer: 10 * 1024 * 1024 })).replace(/\r/g, "").replace(/\n$/, "")
|
|
22
23
|
} catch (e) {
|
|
23
24
|
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) return String(e.stdout).replace(/\r/g, "")
|
|
24
25
|
throw new Error(gitFailureMessage(e, cmdArgs, cwd))
|
|
@@ -150,13 +151,16 @@ const gitActionCore = {
|
|
|
150
151
|
if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
151
152
|
const flags = args.staged ? ["--staged"] : []
|
|
152
153
|
const paths = args.path ? [args.path] : []
|
|
153
|
-
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
154
|
-
|
|
154
|
+
const out = await runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
155
|
+
// #71(hygiene-sweep 批):传了 path 且输出空 ⇒ 注记(pathspec 未命中也是 exit 0——
|
|
156
|
+
// 原样「(no changes)」为假洁净;不传 path 逐字零变)
|
|
157
|
+
const empty = args.path ? `(no changes — pathspec '${args.path}' matched nothing in ${ref})` : "(no changes)"
|
|
158
|
+
return truncate(filterLines(out || empty, args.filter))
|
|
155
159
|
}
|
|
156
160
|
case "status": {
|
|
157
161
|
// Preserve per-line leading whitespace — porcelain " M"/"M " staged/unstaged markers are
|
|
158
162
|
// significant (runGit trims the whole output's leading space, corrupting an unstaged-first-line).
|
|
159
|
-
const porcelain = runGitRaw(ctx.cwd, ["status", "--porcelain"])
|
|
163
|
+
const porcelain = await runGitRaw(ctx.cwd, ["status", "--porcelain"])
|
|
160
164
|
if (!porcelain) return "(clean — no changes)"
|
|
161
165
|
|
|
162
166
|
const staged = []
|
|
@@ -196,19 +200,21 @@ const gitActionCore = {
|
|
|
196
200
|
? ["log", "-" + n, "--oneline"]
|
|
197
201
|
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
198
202
|
if (args.path) cmdArgs.push("--", args.path)
|
|
199
|
-
const out = runGit(ctx.cwd, cmdArgs)
|
|
200
|
-
|
|
203
|
+
const out = await runGit(ctx.cwd, cmdArgs)
|
|
204
|
+
// #71:同上(log 腿——传了 path 且输出空 ⇒ 注记;不传 path 逐字零变)
|
|
205
|
+
const empty = args.path ? `(no commits — pathspec '${args.path}' matched nothing)` : "(no commits)"
|
|
206
|
+
return truncate(filterLines(out || empty, args.filter))
|
|
201
207
|
}
|
|
202
208
|
case "show": {
|
|
203
209
|
const ref = args.ref ?? "HEAD"
|
|
204
210
|
if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
205
|
-
const out = runGit(ctx.cwd, ["show", "--stat", ref])
|
|
211
|
+
const out = await runGit(ctx.cwd, ["show", "--stat", ref])
|
|
206
212
|
return truncate(out || "(no such commit)")
|
|
207
213
|
}
|
|
208
214
|
case "rm": {
|
|
209
215
|
if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
|
|
210
216
|
const paths = args.path.split(/\s+/).filter(Boolean)
|
|
211
|
-
const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
|
|
217
|
+
const r = await runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
|
|
212
218
|
return r.ok ? truncate(r.out || `Untracked ${paths.join(" ")} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
|
|
213
219
|
}
|
|
214
220
|
case "commit": {
|
|
@@ -222,12 +228,12 @@ const gitActionCore = {
|
|
|
222
228
|
if (args.path !== undefined && args.path !== null) {
|
|
223
229
|
const trimmed = args.path.trim()
|
|
224
230
|
if (!trimmed) return "Error: commit path is empty/whitespace — give at least one file path (space-separated)"
|
|
225
|
-
commit = runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
|
|
231
|
+
commit = await runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
|
|
226
232
|
} else {
|
|
227
|
-
const add = runGitStrict(ctx.cwd, ["add", "-A"])
|
|
233
|
+
const add = await runGitStrict(ctx.cwd, ["add", "-A"])
|
|
228
234
|
if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
|
|
229
235
|
if (add.out) parts.push(add.out)
|
|
230
|
-
commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
236
|
+
commit = await runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
231
237
|
}
|
|
232
238
|
if (commit.ok) {
|
|
233
239
|
if (commit.out) parts.push(commit.out)
|
|
@@ -249,7 +255,7 @@ const gitActionCore = {
|
|
|
249
255
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
250
256
|
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
251
257
|
if (args.tags) cmdArgs.push("--tags")
|
|
252
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
258
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
253
259
|
return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
|
|
254
260
|
}
|
|
255
261
|
case "ls-remote": {
|
|
@@ -258,7 +264,7 @@ const gitActionCore = {
|
|
|
258
264
|
const cmdArgs = ["ls-remote"]
|
|
259
265
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
260
266
|
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
261
|
-
const out = runGit(ctx.cwd, cmdArgs, cfgArgs)
|
|
267
|
+
const out = await runGit(ctx.cwd, cmdArgs, cfgArgs)
|
|
262
268
|
if (!out) return "(no refs / remote unreachable)"
|
|
263
269
|
return truncate(filterLines(out, args.filter))
|
|
264
270
|
}
|
|
@@ -267,51 +273,51 @@ const gitActionCore = {
|
|
|
267
273
|
// 多路径:空格分隔(ref 先例 L175——2026-09-05 发版痛点——git add 单路径被迫 N 次调用)
|
|
268
274
|
const paths = args.path ? args.path.split(/\s+/).filter(Boolean) : null
|
|
269
275
|
const cmdArgs = paths?.length ? ["add", "--", ...paths] : ["add", "-A"]
|
|
270
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
276
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
271
277
|
return r.ok ? truncate(r.out || `Staged ${paths?.join(" ") || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
|
|
272
278
|
}
|
|
273
279
|
case "tag": {
|
|
274
280
|
const sub = args.tagAction
|
|
275
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
|
|
281
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
|
|
276
282
|
if (sub === "create") {
|
|
277
283
|
if (!args.name) return "Error: tag create requires name"
|
|
278
284
|
validateRef(args.name, "tag")
|
|
279
285
|
const cmdArgs = ["tag", args.name]
|
|
280
286
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
281
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
287
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
282
288
|
return r.ok ? `Tag ${args.name} created` : truncate(`git tag failed: ${r.err || r.out}`)
|
|
283
289
|
}
|
|
284
290
|
if (sub === "delete") {
|
|
285
291
|
if (!args.name) return "Error: tag delete requires name"
|
|
286
292
|
validateRef(args.name, "tag")
|
|
287
293
|
const snap = await snapshotBefore(ctx, `tag delete ${args.name}`)
|
|
288
|
-
const r = runGitStrict(ctx.cwd, ["tag", "-d", args.name])
|
|
294
|
+
const r = await runGitStrict(ctx.cwd, ["tag", "-d", args.name])
|
|
289
295
|
return r.ok ? truncate(snap + `Tag ${args.name} deleted`) : truncate(`git tag -d failed: ${r.err || r.out}`)
|
|
290
296
|
}
|
|
291
297
|
return "Error: tag requires tagAction — use: list | create | delete"
|
|
292
298
|
}
|
|
293
299
|
case "branch": {
|
|
294
300
|
const sub = args.branchAction
|
|
295
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
|
|
301
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
|
|
296
302
|
if (sub === "create") {
|
|
297
303
|
if (!args.name) return "Error: branch create requires name"
|
|
298
304
|
validateRef(args.name, "branch")
|
|
299
305
|
const cmdArgs = ["branch", args.name]
|
|
300
306
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
301
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
307
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
302
308
|
return r.ok ? `Branch ${args.name} created` : truncate(`git branch failed: ${r.err || r.out}`)
|
|
303
309
|
}
|
|
304
310
|
if (sub === "switch") {
|
|
305
311
|
if (!args.name) return "Error: branch switch requires name"
|
|
306
312
|
validateRef(args.name, "branch")
|
|
307
|
-
const r = runGitStrict(ctx.cwd, ["checkout", args.name])
|
|
313
|
+
const r = await runGitStrict(ctx.cwd, ["checkout", args.name])
|
|
308
314
|
return r.ok ? `Switched to branch ${args.name}` : truncate(`git checkout ${args.name} failed: ${r.err || r.out}`)
|
|
309
315
|
}
|
|
310
316
|
if (sub === "delete") {
|
|
311
317
|
if (!args.name) return "Error: branch delete requires name"
|
|
312
318
|
validateRef(args.name, "branch")
|
|
313
319
|
const snap = await snapshotBefore(ctx, `branch delete ${args.name}`)
|
|
314
|
-
const r = runGitStrict(ctx.cwd, ["branch", "-d", args.name])
|
|
320
|
+
const r = await runGitStrict(ctx.cwd, ["branch", "-d", args.name])
|
|
315
321
|
return r.ok ? truncate(snap + `Branch ${args.name} deleted`) : truncate(`git branch -d failed: ${r.err || r.out}`)
|
|
316
322
|
}
|
|
317
323
|
return "Error: branch requires branchAction — use: list | create | delete | switch"
|
|
@@ -320,12 +326,12 @@ const gitActionCore = {
|
|
|
320
326
|
if (args.path) {
|
|
321
327
|
// Restore file from index (discards working-tree changes to it) — destructive: snapshot first.
|
|
322
328
|
const snap = await snapshotBefore(ctx, `checkout -- ${args.path}`)
|
|
323
|
-
const r = runGitStrict(ctx.cwd, ["checkout", "--", args.path])
|
|
329
|
+
const r = await runGitStrict(ctx.cwd, ["checkout", "--", args.path])
|
|
324
330
|
return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git checkout -- ${args.path} failed: ${r.err || r.out}`)
|
|
325
331
|
}
|
|
326
332
|
if (args.ref) {
|
|
327
333
|
validateRef(args.ref, "ref")
|
|
328
|
-
const r = runGitStrict(ctx.cwd, ["checkout", args.ref])
|
|
334
|
+
const r = await runGitStrict(ctx.cwd, ["checkout", args.ref])
|
|
329
335
|
return r.ok ? truncate(r.out || `Checked out ${args.ref}`) : truncate(`git checkout ${args.ref} failed: ${r.err || r.out}`)
|
|
330
336
|
}
|
|
331
337
|
return "Error: checkout requires ref (branch/commit) or path (file to restore)"
|
|
@@ -336,21 +342,21 @@ const gitActionCore = {
|
|
|
336
342
|
const cmdArgs = ["restore"]
|
|
337
343
|
if (args.staged) cmdArgs.push("--staged")
|
|
338
344
|
cmdArgs.push("--", args.path)
|
|
339
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
345
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
340
346
|
return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git restore failed: ${r.err || r.out}`)
|
|
341
347
|
}
|
|
342
348
|
case "stash": {
|
|
343
349
|
const sub = args.stashAction
|
|
344
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
|
|
350
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
|
|
345
351
|
if (sub === "push") {
|
|
346
352
|
const cmdArgs = ["stash", "push"]
|
|
347
353
|
if (args.message) cmdArgs.push("-m", args.message)
|
|
348
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
354
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
349
355
|
return r.ok ? truncate(r.out || "Stashed") : truncate(`git stash push failed: ${r.err || r.out}`)
|
|
350
356
|
}
|
|
351
357
|
if (sub === "pop") {
|
|
352
358
|
const snap = await snapshotBefore(ctx, "stash pop")
|
|
353
|
-
const r = runGitStrict(ctx.cwd, ["stash", "pop"])
|
|
359
|
+
const r = await runGitStrict(ctx.cwd, ["stash", "pop"])
|
|
354
360
|
return r.ok ? truncate(snap + (r.out || "Popped")) : truncate(`git stash pop failed: ${r.err || r.out}`)
|
|
355
361
|
}
|
|
356
362
|
return "Error: stash requires stashAction — use: push | pop | list"
|
|
@@ -359,14 +365,14 @@ const gitActionCore = {
|
|
|
359
365
|
const cmdArgs = ["fetch"]
|
|
360
366
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
361
367
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
362
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
368
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
363
369
|
return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
|
|
364
370
|
}
|
|
365
371
|
case "pull": {
|
|
366
372
|
const cmdArgs = ["pull"]
|
|
367
373
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
368
374
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
369
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
375
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
370
376
|
return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
|
|
371
377
|
}
|
|
372
378
|
case "reset": {
|
|
@@ -376,24 +382,24 @@ const gitActionCore = {
|
|
|
376
382
|
if (mode === "hard") snap = await snapshotBefore(ctx, "reset --hard") // destructive: drops working-tree changes
|
|
377
383
|
const cmdArgs = ["reset", `--${mode}`]
|
|
378
384
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
379
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
385
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
380
386
|
return r.ok ? truncate(snap + (r.out || `Reset (${mode}) complete`)) : truncate(`git reset failed: ${r.err || r.out}`)
|
|
381
387
|
}
|
|
382
388
|
case "revert": {
|
|
383
389
|
const ref = validateRef(args.ref ?? "HEAD")
|
|
384
|
-
const r = runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
|
|
390
|
+
const r = await runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
|
|
385
391
|
return r.ok ? truncate(r.out || `Reverted ${ref}`) : truncate(`git revert failed: ${r.err || r.out}`)
|
|
386
392
|
}
|
|
387
393
|
case "merge": {
|
|
388
394
|
if (!args.ref) return "Error: merge requires ref (branch/commit to merge)"
|
|
389
395
|
validateRef(args.ref, "ref")
|
|
390
|
-
const r = runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
|
|
396
|
+
const r = await runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
|
|
391
397
|
return r.ok ? truncate(r.out || `Merged ${args.ref}`) : truncate(`git merge failed: ${r.err || r.out} — resolve conflicts, then commit`)
|
|
392
398
|
}
|
|
393
399
|
case "cherry-pick": {
|
|
394
400
|
if (!args.ref) return "Error: cherry-pick requires ref (commit)"
|
|
395
401
|
validateRef(args.ref, "ref")
|
|
396
|
-
const r = runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
|
|
402
|
+
const r = await runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
|
|
397
403
|
return r.ok ? truncate(r.out || `Cherry-picked ${args.ref}`) : truncate(`git cherry-pick failed: ${r.err || r.out}`)
|
|
398
404
|
}
|
|
399
405
|
// F7 扩展 action + checkpoint:实现拆在 git-ext.mjs / git-checkpoint.mjs(500 行硬限)
|
package/tools/index.mjs
CHANGED
|
@@ -45,8 +45,10 @@ export {
|
|
|
45
45
|
// faces (memory / code+doc search / repo outline / settings / peer instances)
|
|
46
46
|
// whose factories need the shell's memory handle at run time.
|
|
47
47
|
//
|
|
48
|
-
// Host-only tools (VS Code `
|
|
48
|
+
// Host-only tools (VS Code `ide` / `focus` — TOOLS #179 ④, IDE capabilities)
|
|
49
49
|
// are NOT part of the core registry: the VS Code shell adds them itself.
|
|
50
|
+
// D-CC26(context-tool 批 2026-09-21):宿主 IDE 快照工具自 `context` **改名 `ide`**(`tools/ide.mjs`)
|
|
51
|
+
// ——让出 `context` 名给核新工具(`agent-tools/context.mjs`;同名撞车 ⇒ provider 逐字 400)。
|
|
50
52
|
//
|
|
51
53
|
// `read_image` registration follows VS Code (#70): it is registered only when the
|
|
52
54
|
// model accepts image input (`specForModel(model).multimodal`) — the conservative
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/process-tree.mjs — 平台感知树杀单源(自 `execute.mjs` 抽出——TOOLS.md §6.14 落位表行 2)。
|
|
3
|
+
* 抽出理由 = **断环**:`shared → git-run → execute → shared` 会 TDZ(`execute.mjs:29` 在模块求值期
|
|
4
|
+
* 调 `DESC`(`const`))⇒ 树杀须住两不依赖档。行为逐字同(win32 `taskkill /T /F` · POSIX 组杀 + 直杀兜底)。
|
|
5
|
+
* `execute.mjs` 保持再导出面(`test/tool-seams.test.mjs:26` 消费)。
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process"
|
|
8
|
+
|
|
9
|
+
/** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
|
|
10
|
+
* Timeout/abort must reach grandchildren: a script that spawned children keeps the
|
|
11
|
+
* pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
|
|
12
|
+
* while the orphan keeps running (2026-09-05 advisor 🟡#4). */
|
|
13
|
+
export function killProcessTree(child) {
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
16
|
+
} else {
|
|
17
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
18
|
+
try { child.kill("SIGKILL") } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/tools/shared.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync
|
|
|
8
8
|
import { dirname, join, resolve } from "node:path"
|
|
9
9
|
import { fileURLToPath } from "node:url"
|
|
10
10
|
import { loadToolDoc, applyPromptInjections } from "../prompt-files.mjs"
|
|
11
|
+
import { spawnGit, gitTimeoutNote, GIT_TIMEOUT_MS } from "./git-run.mjs"
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
14
|
// 工具描述加载面——**核内单一解析面**(D-C13 / 契约 8):解析根 = `prompt-files.mjs`
|
|
@@ -438,8 +439,10 @@ export function htmlToText(html) {
|
|
|
438
439
|
|
|
439
440
|
/** git 失败消息构造单点(#55 fail-closed——`runGit` / `runGitRaw` 两读取面同款):形态 =
|
|
440
441
|
* `git <args…> failed: <stderr 首行> (cwd: <绝对 cwd>)`;**非仓**失败(退出码 128 ∧ stderr 首行含
|
|
441
|
-
* `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。
|
|
442
|
+
* `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。
|
|
443
|
+
* **超时**(`e.timedOut`——§6.14):帧 + 单源超时注(`gitTimeoutNote`,含树杀尽义与恢复指引)。 */
|
|
442
444
|
export function gitFailureMessage(e, cmdArgs, cwd) {
|
|
445
|
+
if (e?.timedOut) return `git ${cmdArgs.join(" ")} failed: ${gitTimeoutNote(e.timeoutMs ?? GIT_TIMEOUT_MS)} (cwd: ${resolve(cwd ?? ".")})`
|
|
443
446
|
const stderr = String(e?.stderr ?? "").trim()
|
|
444
447
|
const first = stderr.split("\n")[0].trim() || String(e?.message || e?.code || "unknown error").split("\n")[0]
|
|
445
448
|
const notRepo = e?.status === 128 && first.includes("not a git repository")
|
|
@@ -449,10 +452,11 @@ export function gitFailureMessage(e, cmdArgs, cwd) {
|
|
|
449
452
|
|
|
450
453
|
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
454
|
* config: optional array of `-c key=value` overrides (e.g. ["http.proxy=http://10.2.2.112:3128"]) —
|
|
452
|
-
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy.
|
|
453
|
-
|
|
455
|
+
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy.
|
|
456
|
+
* §6.14:体改**异步薄壳**委托 `spawnGit` 单点(加固 env + 两档超时 + 树杀);trim / `\r` / 溢出 / 抛出四形逐字保留。 */
|
|
457
|
+
export async function runGit(cwd, cmdArgs, config = []) {
|
|
454
458
|
try {
|
|
455
|
-
return
|
|
459
|
+
return (await spawnGit(cwd, [...config, ...cmdArgs], { maxBuffer: 10 * 1024 * 1024 })).trim().replace(/\r/g, "")
|
|
456
460
|
} catch (e) {
|
|
457
461
|
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
458
462
|
// (callers show "(truncated)"-style tails). Every OTHER failure (non-git repo,
|