sillyspec 3.25.9 → 3.26.0
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/.claude/skills/sillyspec-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/.sillyspec-platform-cleaned +1 -0
- package/.sillyspec-platform.json +7 -0
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +170 -161
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +210 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +326 -36
- package/src/init.js +7 -14
- package/src/local-detect.js +120 -110
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +85 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +8 -5
- package/src/progress/stage-machine.js +67 -65
- package/src/progress/step-store.js +72 -67
- package/src/progress.js +448 -236
- package/src/quicklog.js +59 -12
- package/src/run/command.js +78 -39
- package/src/run/complete-handlers.js +1028 -889
- package/src/run/complete.js +132 -212
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +302 -19
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +722 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +394 -0
- package/src/sillyhub-mcp/config.js +62 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +118 -10
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +10 -7
- package/src/stages/execute.js +64 -22
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +25 -9
- package/src/stages/scan.js +53 -8
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +14 -9
- package/src/sync.js +433 -16
- package/src/task-review.js +59 -1
- package/src/verify-postcheck.js +938 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +218 -93
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- package/src/stages/propose.js +0 -181
package/src/version.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 读取 package.json 版本号。
|
|
9
|
+
* 独立轻量模块:只依赖 fs/path/url,不拖入 init.js 的 @inquirer/prompts 等重型交互库。
|
|
10
|
+
* 供 index.js 顶部静态 import——--version 等高频路径不再为 getVersion 付 init.js 加载税。
|
|
11
|
+
*/
|
|
12
|
+
export function getVersion() {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
15
|
+
return pkg.version;
|
|
16
|
+
} catch {
|
|
17
|
+
return '?.?.?';
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/worktree-apply.js
CHANGED
|
@@ -12,29 +12,17 @@
|
|
|
12
12
|
* 8. 成功后自动 cleanup
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
16
|
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
17
17
|
import { join, resolve } from 'path';
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
|
-
import { createHash } from 'crypto';
|
|
20
19
|
import { WorktreeManager } from './worktree.js';
|
|
21
20
|
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
22
21
|
import { parseAllowedPaths } from './stages/plan-postcheck.js';
|
|
22
|
+
import { git, gitQuiet } from './git-helper.js';
|
|
23
23
|
|
|
24
24
|
const CHANGES_REL = '.sillyspec/changes';
|
|
25
25
|
|
|
26
|
-
function git(cwd, args) {
|
|
27
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function gitQuiet(cwd, args) {
|
|
31
|
-
try {
|
|
32
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
26
|
/**
|
|
39
27
|
* 过滤掉 worktree 基础设施文件(非交付物),让 apply 只关心真正的变更产出:
|
|
40
28
|
* - meta.json:worktree 元数据,baseline commit 中被跟踪、working-tree 被 CLI 改写
|
|
@@ -42,11 +30,95 @@ function gitQuiet(cwd, args) {
|
|
|
42
30
|
* 的锚点,保证 baseline overlay 文件不被误判为变更)。若不排除,modified-tracked 的
|
|
43
31
|
* meta.json 会落入 changedFiles,触发「不在 design.md 清单」误判,导致每个 execute 的
|
|
44
32
|
* assess 恒 BLOCKED。
|
|
45
|
-
* - .sillyspec
|
|
33
|
+
* - .sillyspec/changes/:变更文档(worktree 专属,apply 回主仓污染进度库)。
|
|
34
|
+
* - .sillyspec/.runtime/:运行时产物(进度库/锁/review 产物,非源码)。
|
|
35
|
+
* - .sillyspec/quicklog/:quicklog 条目(worktree 进度,非交付物)。
|
|
36
|
+
* 保留 .sillyspec/docs/(dogfood 模块规范文档 = 交付物,apply 回主仓)。
|
|
46
37
|
* 对 modified-tracked(git diff)与 untracked(ls-files --others)一视同仁。
|
|
47
38
|
*/
|
|
48
39
|
export function filterDeliverableFiles(files) {
|
|
49
|
-
return files.filter(f =>
|
|
40
|
+
return files.filter(f =>
|
|
41
|
+
!f.startsWith('.sillyspec/changes/') &&
|
|
42
|
+
!f.startsWith('.sillyspec/.runtime/') &&
|
|
43
|
+
!f.startsWith('.sillyspec/quicklog/') &&
|
|
44
|
+
f !== 'meta.json'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* dirty 拦截触发时生成「逐文件 rescue 指令」——旁路 git apply 的安全逃生通道(design §接口定义 generateRescueCommands)。
|
|
50
|
+
*
|
|
51
|
+
* 逐文件四分类(按优先级判定,命中即 continue):
|
|
52
|
+
* 1. DELETE ∈ deletedFiles → rm "<projectRoot>/<f>"(不计 cpFileCount 也不计 excludedCount)
|
|
53
|
+
* 2. EXCLUDE-DIRTY ∈ dirtyFiles → warning(cp 会覆盖 main 工作区未提交改动),excludedCount++
|
|
54
|
+
* 3. EXCLUDE-MISMATCH ∈ hashMismatchFiles → warning(cp 会回退主干已提交推进改动),excludedCount++
|
|
55
|
+
* 4. SAFE-CP 其余(main 该文件干净)→ cp "<worktreePath>/<f>" "<projectRoot>/<f>",cpFileCount++
|
|
56
|
+
*
|
|
57
|
+
* 路径用 path.join 拼接后 replace 反斜杠为正斜杠(Git Bash 兼容,规则 13)。
|
|
58
|
+
*
|
|
59
|
+
* 纯函数:无 git/fs 调用无副作用,不读 meta/worktree 状态,不 mutate 入参;
|
|
60
|
+
* 所有 dirtyFiles/hashMismatchFiles/deletedFiles 集合均由调用方(step4.5/5a/assess)算好传入。
|
|
61
|
+
*
|
|
62
|
+
* @param {object} args
|
|
63
|
+
* @param {string[]} args.changedFiles filterDeliverableFiles 后的实际变更路径
|
|
64
|
+
* @param {Set<string>|string[]} args.dirtyFiles main 工作区未提交文件集(统一口径:tracked-modified ∪ untracked,排 .sillyspec/+meta.json)
|
|
65
|
+
* @param {string[]} args.hashMismatchFiles 主干已提交推进文件(step3.5 前移算,依赖 baseHash/HEAD blob 对比)
|
|
66
|
+
* @param {string[]} [args.deletedFiles=[]] worktree 删除文件(git diff name-status D)
|
|
67
|
+
* @param {string} args.worktreePath
|
|
68
|
+
* @param {string} args.projectRoot
|
|
69
|
+
* @returns {{
|
|
70
|
+
* commands: string[], // 可复制粘贴 shell 命令(cp/rm),正斜杠路径
|
|
71
|
+
* warnings: string[], // 被排除文件(dirty/mismatch)的风险标注
|
|
72
|
+
* cpFileCount: number, // SAFE-CP 文件数
|
|
73
|
+
* excludedCount: number // EXCLUDE-DIRTY + EXCLUDE-MISMATCH 数
|
|
74
|
+
* }}
|
|
75
|
+
*/
|
|
76
|
+
export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchFiles, deletedFiles = [], worktreePath, projectRoot }) {
|
|
77
|
+
// 集合归一:dirtyFiles 接受 Set 或数组(调用方 step4.5/5a 可能传任一形态),其余统一数组→Set
|
|
78
|
+
const dirtySet = dirtyFiles instanceof Set ? dirtyFiles : new Set(dirtyFiles || []);
|
|
79
|
+
const mismatchSet = new Set(hashMismatchFiles || []);
|
|
80
|
+
const deletedSet = new Set(deletedFiles || []);
|
|
81
|
+
|
|
82
|
+
const commands = [];
|
|
83
|
+
const warnings = [];
|
|
84
|
+
let cpFileCount = 0;
|
|
85
|
+
let excludedCount = 0;
|
|
86
|
+
|
|
87
|
+
for (const f of changedFiles) {
|
|
88
|
+
// 优先级 1→2→3→4,命中即 continue(DELETE 最先判)
|
|
89
|
+
if (deletedSet.has(f)) {
|
|
90
|
+
commands.push(`rm "${join(projectRoot, f).replace(/\\/g, '/')}"`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (dirtySet.has(f)) {
|
|
94
|
+
warnings.push(`跳过 ${f}:EXCLUDE-DIRTY(main 工作区该文件有未提交改动,cp 会覆盖)`);
|
|
95
|
+
excludedCount++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (mismatchSet.has(f)) {
|
|
99
|
+
warnings.push(`跳过 ${f}:EXCLUDE-MISMATCH(主干已提交推进该文件,cp 会回退他人改动;请先 commit main 未提交改动再正常 apply 走 --3way 合并)`);
|
|
100
|
+
excludedCount++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
commands.push(`cp "${join(worktreePath, f).replace(/\\/g, '/')}" "${join(projectRoot, f).replace(/\\/g, '/')}"`);
|
|
104
|
+
cpFileCount++;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { commands, warnings, cpFileCount, excludedCount };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 算 rescue 用的统一 dirtyFiles 口径:main 工作区所有未提交文件 = tracked-modified(git diff HEAD)
|
|
112
|
+
* ∪ untracked(git ls-files others),再用 filterDeliverableFiles 过滤(排 .sillyspec/changes/.runtime/quicklog
|
|
113
|
+
* + meta.json,保留 .sillyspec/docs/——对齐 changedFiles 同宇宙,闭合 design Grill 的 .sillyspec/docs/ 残留 gap)。
|
|
114
|
+
* 注意:此口径与 step4.5「是否触发拦截」的判定口径(排除 .claude/docs/CLAUDE.md)不同,二者不混用(design §dirtyFiles 口径统一)。
|
|
115
|
+
* @param {string} projectRoot
|
|
116
|
+
* @returns {string[]} 未提交文件(filterDeliverableFiles 过滤后)
|
|
117
|
+
*/
|
|
118
|
+
export function computeRescueDirtyFiles(projectRoot) {
|
|
119
|
+
const tracked = (gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean);
|
|
120
|
+
const untracked = (gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean);
|
|
121
|
+
return filterDeliverableFiles([...new Set([...tracked, ...untracked])]);
|
|
50
122
|
}
|
|
51
123
|
|
|
52
124
|
/**
|
|
@@ -63,6 +135,23 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
|
63
135
|
return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
|
|
64
136
|
}
|
|
65
137
|
|
|
138
|
+
/**
|
|
139
|
+
* 确定进 patch 的文件:有清单取「实际变更 ∩ 清单(pathMatches 容差)」,无清单取全部变更。
|
|
140
|
+
*
|
|
141
|
+
* 口径须与 classifyAllowListViolations 一致——否则 design §6 用 glob(如 test_*.py)或多路径
|
|
142
|
+
* 单 cell 覆盖的文件过 manifest 校验(Gate1 用 pathMatches 放行),却因字面 includes 不在
|
|
143
|
+
* changedFiles → 被滤出 patchFiles → patch 不含 → 主工作区静默丢失(坑 apply-glob-manifest)。
|
|
144
|
+
* 以 changedFiles 为基按容差圈定,glob/前缀覆盖的具体文件都能进 patch。
|
|
145
|
+
*
|
|
146
|
+
* @param {string[]} changedFiles filterDeliverableFiles 已过滤的实际变更路径
|
|
147
|
+
* @param {Set<string>} allowSet design §6 清单 ∪ task allowed_paths(可含 glob / 目录前缀)
|
|
148
|
+
* @param {boolean} hasAllowList 是否有清单
|
|
149
|
+
* @returns {string[]} 进 patch 的文件
|
|
150
|
+
*/
|
|
151
|
+
export function resolvePatchFiles(changedFiles, allowSet, hasAllowList) {
|
|
152
|
+
if (!hasAllowList) return changedFiles;
|
|
153
|
+
return changedFiles.filter(f => [...allowSet].some(ap => pathMatches(f, ap)));
|
|
154
|
+
}
|
|
66
155
|
/**
|
|
67
156
|
* 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
|
|
68
157
|
*
|
|
@@ -71,7 +160,7 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
|
71
160
|
* - 文件不在 tree 中 → 不在 Map 中(调用方 map.get(f) ?? null 得 null,等同 rev-parse 失败→null)
|
|
72
161
|
* ls-tree 输出 "<mode> <type> <hash>\t<path>"。path 恒为文件路径(来自 git diff/ls-files),
|
|
73
162
|
* 非目录,故不带 -r 也正确——文件在 tree 时 rev-parse treeish:path 与 ls-tree 都给同一 hash,
|
|
74
|
-
*
|
|
163
|
+
* 不在时都不给,等价。(沿用公共 git() 数组形式,不经 shell、不引入引号/空格破法。)
|
|
75
164
|
*
|
|
76
165
|
* @param {string} cwd
|
|
77
166
|
* @param {string} treeish
|
|
@@ -81,7 +170,7 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
|
81
170
|
function getBlobHashMap(cwd, treeish, files) {
|
|
82
171
|
const map = new Map();
|
|
83
172
|
if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
|
|
84
|
-
const raw = gitQuiet(cwd,
|
|
173
|
+
const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
|
|
85
174
|
if (!raw) return map;
|
|
86
175
|
for (const line of raw.split('\n')) {
|
|
87
176
|
if (!line) continue;
|
|
@@ -107,7 +196,7 @@ function getBlobHashMap(cwd, treeish, files) {
|
|
|
107
196
|
* @returns {Set<string>} 并集清单(无 design 清单且无 task 卡片时为空集)
|
|
108
197
|
*/
|
|
109
198
|
export function resolveApplyAllowSet(projectRoot, changeName) {
|
|
110
|
-
const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'));
|
|
199
|
+
const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'), { keepSillyspecDocs: true });
|
|
111
200
|
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
112
201
|
if (existsSync(tasksDir)) {
|
|
113
202
|
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
@@ -140,6 +229,8 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
140
229
|
changedFiles: [],
|
|
141
230
|
extraFiles: [],
|
|
142
231
|
hashMismatchFiles: [],
|
|
232
|
+
deletedFiles: [],
|
|
233
|
+
rescueCommands: null,
|
|
143
234
|
patchPath: null,
|
|
144
235
|
errors: [],
|
|
145
236
|
merged: false,
|
|
@@ -164,9 +255,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
164
255
|
// worktree 内修改可能没有 commit,用 git diff <baseHash>(比较 baseHash 到工作区内容)
|
|
165
256
|
// 同时检测 untracked 新文件(git diff 不包含 untracked)
|
|
166
257
|
let changedFiles;
|
|
258
|
+
const deletedFiles = [];
|
|
167
259
|
try {
|
|
168
260
|
// 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
|
|
169
|
-
const statusRaw = git(worktreePath,
|
|
261
|
+
const statusRaw = git(worktreePath, ['diff', '--name-status', diffBase]);
|
|
170
262
|
const statusFiles = new Set();
|
|
171
263
|
if (statusRaw) {
|
|
172
264
|
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
@@ -174,11 +266,13 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
174
266
|
// R100 old.txt new.txt → 提取两个文件
|
|
175
267
|
if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
|
|
176
268
|
if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
|
|
269
|
+
// 删除文件(status D / D100)→ 收集到 deletedFiles(rescue DELETE 分类用,design §逐文件分类)
|
|
270
|
+
if (parts[0].startsWith('D')) deletedFiles.push(parts[parts.length - 1]);
|
|
177
271
|
}
|
|
178
272
|
}
|
|
179
273
|
|
|
180
274
|
// untracked 新文件(diffBase 中不存在的文件)
|
|
181
|
-
const untrackedRaw = gitQuiet(worktreePath,
|
|
275
|
+
const untrackedRaw = gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard']);
|
|
182
276
|
const untrackedFiles = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [];
|
|
183
277
|
|
|
184
278
|
// 排除 worktree 基础设施文件(meta.json / .sillyspec/,见 filterDeliverableFiles)。
|
|
@@ -191,6 +285,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
191
285
|
}
|
|
192
286
|
|
|
193
287
|
result.changedFiles = changedFiles;
|
|
288
|
+
result.deletedFiles = deletedFiles;
|
|
194
289
|
|
|
195
290
|
if (changedFiles.length === 0) {
|
|
196
291
|
// 没有变更
|
|
@@ -205,6 +300,22 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
205
300
|
const allowSet = resolveApplyAllowSet(projectRoot, changeName);
|
|
206
301
|
const hasAllowList = allowSet.size > 0;
|
|
207
302
|
|
|
303
|
+
// --- 3.5 主干已提交推进检测:hashMismatch 前移(Grill P0 修复,design §step 顺序修正)---
|
|
304
|
+
// 原在 step5b,但 step4.5/5a dirty 拦截短路在 step5b 之前,致 rescue 拿不到 hashMismatchFiles
|
|
305
|
+
// → EXCLUDE-MISMATCH 失效 → cp 覆盖主干已提交推进。前移到 step4.5 之前(仅依赖 baseHash/HEAD blob
|
|
306
|
+
// 对比,无 dirty 依赖;allowSet/changedFiles/baseHash 均在本步之前可得,前移安全)。
|
|
307
|
+
// 仅记录不拦截(交 step7 --3way 实测,真重叠回滚提示 --merge)。
|
|
308
|
+
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
309
|
+
const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
|
|
310
|
+
const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
|
|
311
|
+
for (const f of targetFiles) {
|
|
312
|
+
const wtBlob = wtHashMap.get(f) ?? null;
|
|
313
|
+
const mainBlob = mainHashMap.get(f) ?? null;
|
|
314
|
+
if (wtBlob === null && mainBlob === null) continue;
|
|
315
|
+
if (wtBlob === mainBlob) continue;
|
|
316
|
+
result.hashMismatchFiles.push(f);
|
|
317
|
+
}
|
|
318
|
+
|
|
208
319
|
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
209
320
|
if (hasAllowList) {
|
|
210
321
|
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
@@ -227,31 +338,51 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
227
338
|
}
|
|
228
339
|
|
|
229
340
|
// --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
|
|
230
|
-
// 分工:4.5
|
|
341
|
+
// 分工:4.5(排除规则下当前 dirty)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
|
|
231
342
|
// 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
|
|
232
|
-
// 哪怕脏文件与 patch 不重叠)——这是 git
|
|
343
|
+
// 哪怕脏文件与 patch 不重叠)——这是 Windows/autocrlf 的 CRLF 副作用(非 git 本质限制:
|
|
344
|
+
// autocrlf off 时 --3way 在不重叠 dirty 树能 Applied cleanly,on 时才报 does not match index)。
|
|
345
|
+
// 但仓库 CRLF 混用 + 规则13 要求 Windows 兼容,fail-loud 在 Windows 仍有据,故此处友好拦截,引导用户先 commit/stash。
|
|
233
346
|
// 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
|
|
234
|
-
// 否则别人改 CLAUDE.md/docs/.claude →
|
|
235
|
-
//
|
|
347
|
+
// 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
|
|
348
|
+
// 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
|
|
236
349
|
if (meta.baselineHash) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
350
|
+
// pathspec:`--` 结束选项,`.` 包含全部,后续 :(exclude) 排除非交付物元数据/文档 churn
|
|
351
|
+
// (与 computeBaselineHash 同口径)。数组形式逐元素传递,:(exclude) magic 字面直传不经 shell。
|
|
352
|
+
const exclude = ['--', '.', ':(exclude).sillyspec/', ':(exclude).claude/', ':(exclude)docs/', ':(exclude)CLAUDE.md'];
|
|
353
|
+
const staged = gitQuiet(projectRoot, ['diff', '--cached', ...exclude]) || '';
|
|
354
|
+
const unstaged = gitQuiet(projectRoot, ['diff', ...exclude]) || '';
|
|
355
|
+
const untracked = gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard', ...exclude]) || '';
|
|
356
|
+
// 意图(与 computeBaselineHash 注释一致):只挡「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
|
|
357
|
+
// 不比对 hash 是否等于 execute 启动时 baselineHash:主仓 dirty→clean(execute 期间 commit 无关文件)后
|
|
358
|
+
// hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
|
|
359
|
+
const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
|
|
360
|
+
if (hasUncommittedDirty) {
|
|
244
361
|
// 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
|
|
245
362
|
const dirtyFiles = [...new Set(
|
|
246
|
-
((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
|
|
247
|
-
.concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
|
|
363
|
+
((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
364
|
+
.concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
248
365
|
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
366
|
+
// rescue:dirty 拦截触发时生成逐文件 cp 安全子集(旁路 git apply),写 result.rescueCommands 供 assess/打印器消费
|
|
367
|
+
const rescueDirty = computeRescueDirtyFiles(projectRoot);
|
|
368
|
+
result.rescueCommands = generateRescueCommands({
|
|
369
|
+
changedFiles,
|
|
370
|
+
dirtyFiles: rescueDirty,
|
|
371
|
+
hashMismatchFiles: result.hashMismatchFiles,
|
|
372
|
+
deletedFiles: result.deletedFiles,
|
|
373
|
+
worktreePath,
|
|
374
|
+
projectRoot,
|
|
375
|
+
});
|
|
249
376
|
result.errors.push(
|
|
250
|
-
|
|
377
|
+
`主工作区有未提交的改动,git apply 无法安全应用。\n` +
|
|
251
378
|
(dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
|
|
252
379
|
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
|
|
253
|
-
|
|
254
|
-
|
|
380
|
+
(result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
|
|
381
|
+
? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
|
|
382
|
+
result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
|
|
383
|
+
(result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
|
|
384
|
+
`\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
|
|
385
|
+
: '')
|
|
255
386
|
);
|
|
256
387
|
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
257
388
|
}
|
|
@@ -259,40 +390,31 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
259
390
|
|
|
260
391
|
// --- 5. 校验:主工作区文件 base hash 一致 ---
|
|
261
392
|
// 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
|
|
262
|
-
const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
|
|
393
|
+
const mainDirtyRaw = gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']);
|
|
263
394
|
const mainDirtyFiles = mainDirtyRaw ? mainDirtyRaw.split('\n').filter(Boolean) : [];
|
|
264
395
|
if (mainDirtyFiles.length > 0) {
|
|
265
396
|
// 如果脏文件和本次 apply 的文件有交集 → 报错
|
|
266
397
|
const conflictDirty = mainDirtyFiles.filter(f => changedFiles.includes(f));
|
|
267
398
|
if (conflictDirty.length > 0) {
|
|
399
|
+
const rescueDirty5a = computeRescueDirtyFiles(projectRoot);
|
|
400
|
+
result.rescueCommands = generateRescueCommands({
|
|
401
|
+
changedFiles, dirtyFiles: rescueDirty5a, hashMismatchFiles: result.hashMismatchFiles,
|
|
402
|
+
deletedFiles: result.deletedFiles, worktreePath, projectRoot,
|
|
403
|
+
});
|
|
268
404
|
result.errors.push(
|
|
269
|
-
`主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
|
|
405
|
+
`主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。` +
|
|
406
|
+
(result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
|
|
407
|
+
? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
|
|
408
|
+
result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
|
|
409
|
+
(result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
|
|
410
|
+
`\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
|
|
411
|
+
: '')
|
|
270
412
|
);
|
|
271
413
|
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
272
414
|
}
|
|
273
415
|
}
|
|
274
416
|
|
|
275
|
-
// 5b
|
|
276
|
-
// 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
|
|
277
|
-
// 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
|
|
278
|
-
// 放宽(2026-07):blob 不一致不再 BLOCKED——它意味着主干「已提交」推进改了同文件,
|
|
279
|
-
// 而 git apply --3way 能自动三路合并这种场景(不同区域直接合,同区域留冲突标记)。
|
|
280
|
-
// 故仅记录为风险提示(hashMismatchFiles → assess WARNING / summary 展示),放行交 step7 --3way 实测。
|
|
281
|
-
// 真重叠时 --3way 冲突,由 step7 回滚并提示 --merge 兜底。
|
|
282
|
-
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
283
|
-
const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
|
|
284
|
-
const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
|
|
285
|
-
for (const f of targetFiles) {
|
|
286
|
-
const wtBlob = wtHashMap.get(f) ?? null;
|
|
287
|
-
const mainBlob = mainHashMap.get(f) ?? null;
|
|
288
|
-
|
|
289
|
-
// 两者都为 null(文件在 base 时不存在)→ OK
|
|
290
|
-
if (wtBlob === null && mainBlob === null) continue;
|
|
291
|
-
// 两者一致 → OK
|
|
292
|
-
if (wtBlob === mainBlob) continue;
|
|
293
|
-
// 不一致 → 主干已提交推进改了此文件,记风险提示(不拦截,交 --3way)
|
|
294
|
-
result.hashMismatchFiles.push(f);
|
|
295
|
-
}
|
|
417
|
+
// 5b hashMismatch 计算已前移到 step3.5(Grill P0),见上
|
|
296
418
|
|
|
297
419
|
// --- 6. checkOnly 模式:到此返回 ---
|
|
298
420
|
if (checkOnly) {
|
|
@@ -301,10 +423,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
301
423
|
}
|
|
302
424
|
|
|
303
425
|
// --- 7. 生成 patch 并 apply ---
|
|
304
|
-
// 确定要包含在 patch
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
: changedFiles;
|
|
426
|
+
// 确定要包含在 patch 中的文件:有清单用「实际变更 ∩ 清单(pathMatches 容差)」,无清单用全部变更。
|
|
427
|
+
// 口径与 classifyAllowListViolations 一致(坑 apply-glob-manifest-passes-check-but-not-patch)。
|
|
428
|
+
const patchFiles = resolvePatchFiles(changedFiles, allowSet, hasAllowList);
|
|
308
429
|
|
|
309
430
|
// 创建临时文件
|
|
310
431
|
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
|
|
@@ -321,35 +442,36 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
321
442
|
// ls-files --error-unmatch f 成功 ⟺ f 在 ls-files -- 输出。
|
|
322
443
|
const inTree = getBlobHashMap(worktreePath, diffBase, patchFiles);
|
|
323
444
|
const inIndexList = patchFiles.length > 0
|
|
324
|
-
? (gitQuiet(worktreePath,
|
|
445
|
+
? (gitQuiet(worktreePath, ['ls-files', '--', ...patchFiles]) || '').split('\n').filter(Boolean)
|
|
325
446
|
: [];
|
|
326
447
|
const inIndex = new Set(inIndexList);
|
|
327
448
|
const trackedFiles = patchFiles.filter(f => inTree.has(f) || inIndex.has(f));
|
|
328
449
|
const trackedSet = new Set(trackedFiles);
|
|
329
450
|
const untrackedPatchFiles = patchFiles.filter(f => !trackedSet.has(f));
|
|
330
451
|
|
|
331
|
-
// tracked 文件:git diff baseHash
|
|
452
|
+
// tracked 文件:git diff baseHash(数组形式,文件名逐个展开为独立 argv,不经 shell;
|
|
453
|
+
// trim:false 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时——原裸 execSync 无 timeout)
|
|
332
454
|
if (trackedFiles.length > 0) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
{
|
|
455
|
+
patchContent += git(
|
|
456
|
+
worktreePath,
|
|
457
|
+
['diff', '--binary', diffBase, '--', ...trackedFiles],
|
|
458
|
+
{ trim: false, timeout: 60000 }
|
|
337
459
|
);
|
|
338
460
|
}
|
|
339
461
|
|
|
340
|
-
// untracked 新文件:git add 到 index,git diff --cached,然后 reset
|
|
462
|
+
// untracked 新文件:git add 到 index,git diff --cached,然后 reset(均数组形式,文件名逐个展开)
|
|
341
463
|
if (untrackedPatchFiles.length > 0) {
|
|
342
|
-
|
|
343
|
-
git(worktreePath, `add ${addArgs}`);
|
|
464
|
+
git(worktreePath, ['add', '--', ...untrackedPatchFiles]);
|
|
344
465
|
try {
|
|
345
|
-
|
|
346
|
-
patchContent +=
|
|
347
|
-
|
|
348
|
-
|
|
466
|
+
// trim:false 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时
|
|
467
|
+
patchContent += git(
|
|
468
|
+
worktreePath,
|
|
469
|
+
['diff', '--binary', '--cached', '--', ...untrackedPatchFiles],
|
|
470
|
+
{ trim: false, timeout: 60000 }
|
|
349
471
|
);
|
|
350
472
|
} finally {
|
|
351
473
|
// 重置 index(不保留 staged 状态)
|
|
352
|
-
gitQuiet(worktreePath,
|
|
474
|
+
gitQuiet(worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
|
|
353
475
|
}
|
|
354
476
|
}
|
|
355
477
|
|
|
@@ -370,13 +492,13 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
370
492
|
// 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
|
|
371
493
|
const trackedPatchFiles = patchFiles.filter(f => {
|
|
372
494
|
// 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
|
|
373
|
-
return gitQuiet(projectRoot,
|
|
495
|
+
return gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null ? false : true;
|
|
374
496
|
});
|
|
375
497
|
const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
|
|
376
498
|
|
|
377
499
|
// apply --3way 正式应用(主干已提交推进时自动三路合并)
|
|
378
500
|
try {
|
|
379
|
-
git(projectRoot,
|
|
501
|
+
git(projectRoot, ['apply', '--3way', patchPath], { timeout: 30000 });
|
|
380
502
|
} catch (e) {
|
|
381
503
|
// --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
|
|
382
504
|
const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
|
|
@@ -427,13 +549,13 @@ function rollbackApply(projectRoot, trackedFiles, newFiles) {
|
|
|
427
549
|
// 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
|
|
428
550
|
let conflicts = [];
|
|
429
551
|
try {
|
|
430
|
-
const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
|
|
552
|
+
const unmerged = gitQuiet(projectRoot, ['diff', '--name-only', '--diff-filter=U']) || '';
|
|
431
553
|
conflicts = unmerged.split('\n').filter(Boolean);
|
|
432
554
|
} catch {}
|
|
433
555
|
// 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
|
|
434
556
|
// `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
|
|
435
557
|
for (const f of trackedFiles) {
|
|
436
|
-
try { gitQuiet(projectRoot,
|
|
558
|
+
try { gitQuiet(projectRoot, ['checkout', 'HEAD', '--', f]); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
|
|
437
559
|
}
|
|
438
560
|
// 删除 --3way 可能新建的文件(apply 前不存在)
|
|
439
561
|
for (const f of newFiles) {
|
|
@@ -443,7 +565,7 @@ function rollbackApply(projectRoot, trackedFiles, newFiles) {
|
|
|
443
565
|
} catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
|
|
444
566
|
}
|
|
445
567
|
// 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
|
|
446
|
-
try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
|
|
568
|
+
try { gitQuiet(projectRoot, ['reset', '--quiet']); } catch {}
|
|
447
569
|
return { conflicts, error };
|
|
448
570
|
}
|
|
449
571
|
|
|
@@ -471,15 +593,15 @@ function applyByMerge(result, changeName, projectRoot, wm) {
|
|
|
471
593
|
const branch = meta.branch || `sillyspec/${changeName}`;
|
|
472
594
|
|
|
473
595
|
try {
|
|
474
|
-
git(projectRoot,
|
|
596
|
+
git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
|
|
475
597
|
} catch (e) {
|
|
476
598
|
// merge 冲突:取冲突文件列表 + abort 回滚(不 cleanup,保留 worktree)
|
|
477
599
|
let conflictFiles = [];
|
|
478
600
|
try {
|
|
479
|
-
const cf = gitQuiet(projectRoot,
|
|
601
|
+
const cf = gitQuiet(projectRoot, ['diff', '--name-only', '--diff-filter=U']);
|
|
480
602
|
conflictFiles = cf ? cf.split('\n').filter(Boolean) : [];
|
|
481
603
|
} catch {}
|
|
482
|
-
try { gitQuiet(projectRoot,
|
|
604
|
+
try { gitQuiet(projectRoot, ['merge', '--abort']); } catch {}
|
|
483
605
|
result.errors.push(
|
|
484
606
|
`git merge ${branch} 冲突,请手动解决。冲突文件:\n` +
|
|
485
607
|
(conflictFiles.length ? ` ${conflictFiles.join('\n ')}\n` : ` (未能获取冲突文件列表)\n`) +
|
|
@@ -492,7 +614,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
|
|
|
492
614
|
// 分支可能只含 baseline checkpoint(子代理改动未 commit),merge 产生空内容合并,文件零落地。
|
|
493
615
|
// 逐个确认 changedFiles 在 main HEAD 存在。任一缺失 → 不 cleanup(fail-open:保留 worktree 唯一副本)。
|
|
494
616
|
result.merged = true;
|
|
495
|
-
const notLanded = changedFiles.filter(f => gitQuiet(projectRoot,
|
|
617
|
+
const notLanded = changedFiles.filter(f => gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null);
|
|
496
618
|
if (notLanded.length > 0) {
|
|
497
619
|
result.ok = false;
|
|
498
620
|
result.errors.push(
|
|
@@ -504,7 +626,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
|
|
|
504
626
|
}
|
|
505
627
|
|
|
506
628
|
result.ok = true;
|
|
507
|
-
try { result.mergeSummary = git(projectRoot,
|
|
629
|
+
try { result.mergeSummary = git(projectRoot, ['log', '--oneline', '-1']); } catch {}
|
|
508
630
|
try {
|
|
509
631
|
wm.cleanup(changeName);
|
|
510
632
|
} catch (cleanupErr) {
|
|
@@ -553,14 +675,15 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
553
675
|
if (changedFiles.length === 0) {
|
|
554
676
|
// 无变更:若仍有 Gate 错误(如主区 dirty)则 BLOCKED,否则 SAFE
|
|
555
677
|
if (reasons.length > 0) {
|
|
556
|
-
return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 } };
|
|
678
|
+
return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 }, rescueCommands: checkResult.rescueCommands || null };
|
|
557
679
|
}
|
|
558
680
|
return {
|
|
559
681
|
decision: 'SAFE',
|
|
560
682
|
changedFiles: [],
|
|
561
683
|
reasons: ['无变更需要应用'],
|
|
562
684
|
warnings: [],
|
|
563
|
-
stats: { additions: 0, deletions: 0 }
|
|
685
|
+
stats: { additions: 0, deletions: 0 },
|
|
686
|
+
rescueCommands: checkResult.rescueCommands || null
|
|
564
687
|
};
|
|
565
688
|
}
|
|
566
689
|
|
|
@@ -632,7 +755,7 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
632
755
|
let additions = 0, deletions = 0;
|
|
633
756
|
if (wtPath && diffBase) {
|
|
634
757
|
try {
|
|
635
|
-
const shortstat = gitQuiet(wtPath,
|
|
758
|
+
const shortstat = gitQuiet(wtPath, ['diff', '--shortstat', diffBase]);
|
|
636
759
|
const insMatch = shortstat?.match(/(\d+) insertion/);
|
|
637
760
|
const delMatch = shortstat?.match(/(\d+) deletion/);
|
|
638
761
|
additions = insMatch ? parseInt(insMatch[1]) : 0;
|
|
@@ -653,7 +776,7 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
653
776
|
decision = 'SAFE';
|
|
654
777
|
}
|
|
655
778
|
|
|
656
|
-
return { decision, changedFiles, reasons, warnings, stats: { additions, deletions } };
|
|
779
|
+
return { decision, changedFiles, reasons, warnings, stats: { additions, deletions }, rescueCommands: checkResult.rescueCommands || null };
|
|
657
780
|
}
|
|
658
781
|
|
|
659
782
|
/**
|
|
@@ -721,7 +844,9 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
|
|
|
721
844
|
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
722
845
|
try {
|
|
723
846
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
724
|
-
|
|
847
|
+
// execFileSync 数组形式:路径无需引号化(含空格也安全);stdio stderr=ignore
|
|
848
|
+
// 替代 `2>/dev/null`(Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
|
|
849
|
+
const filesRaw = execFileSync('git', ['-C', meta.worktreePath, 'diff', '--name-only', diffBase], { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] });
|
|
725
850
|
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
726
851
|
if (files.length > 0) {
|
|
727
852
|
lines.push(``);
|