sillyspec 3.20.2 → 3.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -5
  2. package/.claude/skills/sillyspec-auto/SKILL.md +2 -8
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -28
  4. package/.claude/skills/sillyspec-commit/SKILL.md +3 -4
  5. package/.claude/skills/sillyspec-continue/SKILL.md +4 -5
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +3 -12
  7. package/.claude/skills/sillyspec-execute/SKILL.md +2 -15
  8. package/.claude/skills/sillyspec-explore/SKILL.md +1 -14
  9. package/.claude/skills/sillyspec-plan/SKILL.md +36 -5
  10. package/.claude/skills/sillyspec-propose/SKILL.md +0 -4
  11. package/.claude/skills/sillyspec-quick/SKILL.md +1 -5
  12. package/.claude/skills/sillyspec-resume/SKILL.md +66 -23
  13. package/.claude/skills/sillyspec-scan/SKILL.md +1 -5
  14. package/.claude/skills/sillyspec-state/SKILL.md +8 -8
  15. package/.claude/skills/sillyspec-status/SKILL.md +1 -5
  16. package/.claude/skills/sillyspec-verify/SKILL.md +1 -5
  17. package/.claude/skills/sillyspec-workspace/SKILL.md +3 -11
  18. package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +97 -0
  19. package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +51 -0
  20. package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +29 -0
  21. package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +34 -0
  22. package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +13 -0
  23. package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +43 -0
  24. package/.sillyspec/changes/auto-mode/design.md +50 -0
  25. package/.sillyspec/changes/auto-mode/proposal.md +19 -0
  26. package/.sillyspec/changes/auto-mode/requirements.md +21 -0
  27. package/.sillyspec/changes/auto-mode/tasks.md +7 -0
  28. package/.sillyspec/changes/brainstorm-archive/2026-04-05-dashboard-design.md +206 -0
  29. package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +199 -0
  30. package/.sillyspec/changes/dashboard/design.md +219 -0
  31. package/.sillyspec/changes/dashboard/design.md.braindraft +206 -0
  32. package/.sillyspec/changes/run-command-design/design.md +1230 -0
  33. package/.sillyspec/changes/unified-docs-design/design.md +199 -0
  34. package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
  35. package/.sillyspec/knowledge/INDEX.md +8 -0
  36. package/.sillyspec/knowledge/uncategorized.md +3 -0
  37. package/.sillyspec/plans/2026-04-05-dashboard.md +737 -0
  38. package/.sillyspec/projects/sillyspec.yaml +3 -0
  39. package/README.md +11 -13
  40. package/SKILL.md +40 -44
  41. package/dist/steps/brainstorm/01-load-context.md +30 -0
  42. package/dist/steps/brainstorm/02-reuse-check.md +6 -0
  43. package/dist/steps/brainstorm/03-prototype-analysis.md +11 -0
  44. package/dist/steps/brainstorm/04-module-split.md +23 -0
  45. package/dist/steps/brainstorm/05-dialog-explore.md +8 -0
  46. package/dist/steps/brainstorm/06-propose-approaches.md +3 -0
  47. package/dist/steps/brainstorm/07-present-design.md +3 -0
  48. package/dist/steps/brainstorm/08-write-design.md +21 -0
  49. package/dist/steps/brainstorm/09-self-review.md +15 -0
  50. package/dist/steps/brainstorm/10-user-confirm.md +3 -0
  51. package/dist/steps/brainstorm/11-output-spec.md +7 -0
  52. package/dist/steps/brainstorm/manifest.yaml +26 -0
  53. package/dist/steps/execute/01-load-context.md +41 -0
  54. package/dist/steps/execute/02-scan-conventions.md +47 -0
  55. package/dist/steps/execute/03-skill-mcp.md +19 -0
  56. package/dist/steps/execute/04-assign-task.md +22 -0
  57. package/dist/steps/execute/04b-prompt-template.md +54 -0
  58. package/dist/steps/execute/05-write-test.md +7 -0
  59. package/dist/steps/execute/06-write-code.md +8 -0
  60. package/dist/steps/execute/07-run-test.md +26 -0
  61. package/dist/steps/execute/08-fix-issues.md +28 -0
  62. package/dist/steps/execute/09-next-task.md +33 -0
  63. package/dist/steps/execute/manifest.yaml +28 -0
  64. package/dist/steps/plan/01-load-context.md +22 -0
  65. package/dist/steps/plan/02-anchor-confirm.md +1 -0
  66. package/dist/steps/plan/03-expand-tasks.md +33 -0
  67. package/dist/steps/plan/04-mark-order.md +15 -0
  68. package/dist/steps/plan/05-e2e-planning.md +17 -0
  69. package/dist/steps/plan/06-self-check.md +16 -0
  70. package/dist/steps/plan/07-save.md +1 -0
  71. package/dist/steps/plan/manifest.yaml +18 -0
  72. package/dist/steps/scan/01-env-detect.md +51 -0
  73. package/dist/steps/scan/02-tech-stack.md +16 -0
  74. package/dist/steps/scan/03-conventions.md +16 -0
  75. package/dist/steps/scan/04-structure.md +19 -0
  76. package/dist/steps/scan/05-quality.md +18 -0
  77. package/dist/steps/scan/06-complete.md +49 -0
  78. package/dist/steps/scan/manifest.yaml +16 -0
  79. package/dist/steps/verify/01-load-specs.md +28 -0
  80. package/dist/steps/verify/02-check-tasks.md +1 -0
  81. package/dist/steps/verify/03-check-design.md +6 -0
  82. package/dist/steps/verify/04-run-tests.md +7 -0
  83. package/dist/steps/verify/05-e2e-tests.md +27 -0
  84. package/dist/steps/verify/05b-e2e-fix.md +33 -0
  85. package/dist/steps/verify/06-code-quality.md +25 -0
  86. package/dist/steps/verify/07-lint-check.md +27 -0
  87. package/dist/steps/verify/08-output-report.md +14 -0
  88. package/dist/steps/verify/manifest.yaml +22 -0
  89. package/package.json +1 -7
  90. package/packages/dashboard/dist/assets/{index-Bq_Z2hne.js → index-D1EVTLmc.js} +1264 -1264
  91. package/packages/dashboard/dist/assets/index-DGe8CqeP.css +1 -0
  92. package/packages/dashboard/dist/index.html +2 -2
  93. package/packages/dashboard/package-lock.json +6 -6
  94. package/packages/dashboard/server/executor.js +1 -1
  95. package/packages/dashboard/server/index.js +18 -98
  96. package/packages/dashboard/server/parser.js +71 -139
  97. package/packages/dashboard/server/watcher.js +6 -14
  98. package/packages/dashboard/src/App.vue +185 -418
  99. package/packages/dashboard/src/components/ActionBar.vue +1 -10
  100. package/packages/dashboard/src/components/CommandPalette.vue +1 -5
  101. package/packages/dashboard/src/components/DocPreview.vue +8 -105
  102. package/packages/dashboard/src/components/DocTree.vue +19 -75
  103. package/packages/dashboard/src/components/PipelineStage.vue +2 -22
  104. package/packages/dashboard/src/components/PipelineView.vue +5 -32
  105. package/packages/dashboard/src/components/ProjectOverview.vue +139 -113
  106. package/packages/dashboard/src/components/StageBadge.vue +3 -17
  107. package/packages/dashboard/src/components/StepCard.vue +2 -7
  108. package/packages/dashboard/src/composables/useDashboard.js +0 -28
  109. package/src/derive.js +147 -0
  110. package/src/index.js +40 -688
  111. package/src/init.js +63 -119
  112. package/src/migrate.js +7 -7
  113. package/src/progress.js +248 -1474
  114. package/src/run.js +302 -3008
  115. package/src/setup.js +64 -2
  116. package/src/stages/archive.js +17 -123
  117. package/src/stages/brainstorm.js +48 -454
  118. package/src/stages/doctor.js +46 -99
  119. package/src/stages/execute.js +59 -420
  120. package/src/stages/index.js +18 -12
  121. package/src/stages/plan.js +189 -492
  122. package/src/stages/propose.js +11 -70
  123. package/src/stages/quick.js +13 -52
  124. package/src/stages/scan.js +68 -485
  125. package/src/stages/status.js +1 -1
  126. package/src/stages/verify.js +16 -203
  127. package/src/step.js +543 -0
  128. package/.claude/skills/sillyspec-knowledge/SKILL.md +0 -270
  129. package/.husky/pre-push +0 -13
  130. package/CLAUDE.md +0 -18
  131. package/docs/brainstorm-plan-contract.md +0 -64
  132. package/docs/plan-execute-contract.md +0 -123
  133. package/docs/platform-scan-protocol.md +0 -298
  134. package/docs/revision-mode.md +0 -115
  135. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
  136. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -218
  137. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
  138. package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
  139. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -193
  140. package/docs/sillyspec/file-lifecycle.md +0 -125
  141. package/docs/workflow-contract-regression.md +0 -106
  142. package/docs/worktree-isolation.md +0 -252
  143. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +0 -1
  144. package/packages/dashboard/dist/prototype-dashboard.html +0 -836
  145. package/packages/dashboard/dist/prototype-overview.html +0 -256
  146. package/packages/dashboard/public/prototype-dashboard.html +0 -836
  147. package/packages/dashboard/public/prototype-overview.html +0 -256
  148. package/packages/dashboard/src/components/HResizeHandle.vue +0 -48
  149. package/packages/dashboard/src/components/ProjectCard.vue +0 -187
  150. package/packages/dashboard/src/components/VResizeHandle.vue +0 -61
  151. package/packages/dashboard/src/composables/useLayout.js +0 -131
  152. package/src/brainstorm-postcheck.js +0 -158
  153. package/src/change-list.js +0 -52
  154. package/src/change-risk-profile.js +0 -352
  155. package/src/classify-change.js +0 -73
  156. package/src/constants.js +0 -70
  157. package/src/contract-matrix.js +0 -278
  158. package/src/db.js +0 -201
  159. package/src/endpoint-extractor.js +0 -315
  160. package/src/hooks/claude-pre-tool-use.cjs +0 -125
  161. package/src/hooks/worktree-guard.js +0 -761
  162. package/src/knowledge-match.js +0 -130
  163. package/src/modules.js +0 -482
  164. package/src/scan-postcheck.js +0 -383
  165. package/src/stage-contract.js +0 -700
  166. package/src/stages/brainstorm-auto.js +0 -229
  167. package/src/stages/explore.js +0 -34
  168. package/src/stages/knowledge.js +0 -498
  169. package/src/stages/plan-postcheck.js +0 -513
  170. package/src/sync.js +0 -497
  171. package/src/task-review.js +0 -346
  172. package/src/workflow.js +0 -785
  173. package/src/worktree-apply.js +0 -549
  174. package/src/worktree.js +0 -932
  175. package/templates/workflows/archive-impact.yaml +0 -79
  176. package/templates/workflows/scan-docs.yaml +0 -132
  177. package/test/brainstorm-plan-contract.test.mjs +0 -273
  178. package/test/check-syntax.mjs +0 -26
  179. package/test/cli-top-level-aliases.test.mjs +0 -174
  180. package/test/contract-artifacts.test.mjs +0 -323
  181. package/test/decision-supersede.test.mjs +0 -277
  182. package/test/knowledge-match.test.mjs +0 -231
  183. package/test/plan-execute-contract.test.mjs +0 -330
  184. package/test/plan-optimization.test.mjs +0 -572
  185. package/test/platform-artifacts.test.mjs +0 -190
  186. package/test/platform-failure-samples.test.mjs +0 -199
  187. package/test/platform-recovery-chain.test.mjs +0 -179
  188. package/test/platform-recovery.test.mjs +0 -167
  189. package/test/platform-scan-p0.test.mjs +0 -175
  190. package/test/revision-v1.test.mjs +0 -1145
  191. package/test/run-sanitize-project-name.test.mjs +0 -51
  192. package/test/run-scan-postcheck-fail.test.mjs +0 -64
  193. package/test/run-scan-project-parse.test.mjs +0 -200
  194. package/test/run-tests.mjs +0 -48
  195. package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
  196. package/test/scan-knowledge.test.mjs +0 -175
  197. package/test/scan-paths.test.mjs +0 -68
  198. package/test/scan-postcheck-project-priority.test.mjs +0 -85
  199. package/test/scan-postcheck.test.mjs +0 -197
  200. package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
  201. package/test/spec-dir.test.mjs +0 -206
  202. package/test/stage-contract-failed-post-check.test.mjs +0 -102
  203. package/test/stage-contract.test.mjs +0 -299
  204. package/test/stage-definitions.test.mjs +0 -39
  205. package/test/wait-gates.test.mjs +0 -501
  206. package/test/workflow-spec-base.test.mjs +0 -142
  207. package/test/worktree-guard.test.mjs +0 -136
  208. package/test/worktree-native-overlay.test.mjs +0 -188
package/src/worktree.js DELETED
@@ -1,932 +0,0 @@
1
- /**
2
- * SillySpec WorktreeManager — git worktree 生命周期管理
3
- *
4
- * 封装 git worktree 的 create/list/cleanup/getMeta 操作,
5
- * 为 execute 阶段提供代码隔离环境。
6
- *
7
- * worktree 存储目录:.sillyspec/.runtime/worktrees/<change-name>/
8
- * 分支命名:sillyspec/<change-name>
9
- */
10
-
11
- import { execSync } from 'child_process';
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
13
- import { join, resolve, dirname } from 'path';
14
- import { createHash } from 'crypto';
15
-
16
- const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
17
- const BRANCH_PREFIX = 'sillyspec/';
18
- const META_FILE = 'meta.json';
19
-
20
- /**
21
- * 检测当前目录的隔离状态
22
- * 返回 { inWorktree: boolean, inSubmodule: boolean }
23
- *
24
- * 用 git rev-parse --git-dir 和 --git-common-dir 判断:
25
- * - GIT_DIR != GIT_COMMON 通常是 linked worktree
26
- * - 但在 git submodule 里也会出现这种情况
27
- * - 所以必须额外检查 --show-superproject-working-tree 排除 submodule
28
- */
29
- export function detectIsolation(cwd = process.cwd()) {
30
- try {
31
- const gitDir = execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
32
- const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
33
- const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
34
-
35
- const inWorktree = gitDir !== gitCommonDir && !superProject;
36
- const inSubmodule = !!superProject;
37
-
38
- return { inWorktree, inSubmodule, gitDir, gitCommonDir };
39
- } catch {
40
- return { inWorktree: false, inSubmodule: false, gitDir: null, gitCommonDir: null };
41
- }
42
- }
43
-
44
- /**
45
- * 检查 worktree 存储目录是否被 .gitignore 忽略
46
- * @param {string} cwd - 项目根目录
47
- * @returns {{ ignored: boolean, path: string }}
48
- */
49
- export function checkWorktreeDirIgnored(cwd = process.cwd()) {
50
- const relPath = WORKTREES_REL;
51
- try {
52
- execSync(`git check-ignore -q ${relPath}`, { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
53
- return { ignored: true, path: relPath };
54
- } catch {
55
- return { ignored: false, path: relPath };
56
- }
57
- }
58
-
59
- function git(cwd, args) {
60
- return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
61
- }
62
-
63
- function gitQuiet(cwd, args) {
64
- try {
65
- return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
66
- } catch {
67
- return null;
68
- }
69
- }
70
-
71
- function parseJSON(raw) {
72
- try { return JSON.parse(raw); } catch { return null; }
73
- }
74
-
75
- function computeBaselineHash(cwd) {
76
- // 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
77
- const exclude = '-- . ":(exclude).sillyspec/"';
78
- const staged = gitQuiet(cwd, `diff --cached ${exclude}`) || '';
79
- const unstaged = gitQuiet(cwd, `diff ${exclude}`) || '';
80
- const untracked = gitQuiet(cwd, `ls-files --others --exclude-standard ${exclude}`) || '';
81
- const raw = `staged:${staged}
82
- unstaged:${unstaged}
83
- untracked:${untracked}`;
84
- return createHash('sha256').update(raw).digest('hex').slice(0, 16);
85
- }
86
-
87
- function validateChangeName(changeName) {
88
- if (!changeName || typeof changeName !== 'string' || changeName.trim() === '') {
89
- throw new Error('changeName 不能为空');
90
- }
91
- const trimmed = changeName.trim();
92
- // 禁止路径穿越
93
- if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
94
- throw new Error(`changeName 不合法: "${changeName}",不能包含 ..、/ 或 \\`);
95
- }
96
- return trimmed;
97
- }
98
-
99
- /**
100
- * 检测 git worktree 是否可用
101
- * @param {string} cwd
102
- * @returns {{ supported: boolean, version: string|null, reason?: string }}
103
- */
104
- export function isGitWorktreeSupported(cwd = process.cwd()) {
105
- try {
106
- const raw = execSync('git --version', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
107
- const match = raw.match(/git version (\d+)\.(\d+)/);
108
- if (!match) return { supported: false, version: raw, reason: 'cannot parse version' };
109
- const major = parseInt(match[1], 10);
110
- const minor = parseInt(match[2], 10);
111
- if (major > 2 || (major === 2 && minor >= 15)) {
112
- return { supported: true, version: raw };
113
- }
114
- return { supported: false, version: raw, reason: 'git version < 2.15' };
115
- } catch {
116
- return { supported: false, version: null, reason: 'git not found' };
117
- }
118
- }
119
-
120
- export class WorktreeManager {
121
- constructor({ cwd, worktreeDir } = {}) {
122
- this.cwd = cwd || process.cwd();
123
-
124
- // worktreeBase 必须固定到主仓库路径,不能跟着 cwd 变化。
125
- // native-worktree 模式下 cwd 是 worktree 子目录,用 cwd 推导 worktreeBase
126
- // 会导致 meta 写入 worktree 内部路径,worktree 内再次执行时找不到。
127
- // 解决:用 git rev-parse --git-common-dir 反推主仓库路径。
128
- if (worktreeDir) {
129
- this.worktreeBase = worktreeDir;
130
- } else {
131
- this.worktreeBase = resolve(this._resolveMainRepoRoot(), WORKTREES_REL);
132
- }
133
- }
134
-
135
- /**
136
- * 解析当前 git 环境对应的主仓库根目录
137
- * 在主仓库内执行:返回 cwd 自身
138
- * 在 linked worktree 内执行:返回 git-common-dir 的父目录(即主仓库 .git 所在地)
139
- * @private
140
- */
141
- _resolveMainRepoRoot() {
142
- try {
143
- // git-common-dir 在主仓库内 = <main>/.git
144
- // 在 linked worktree 内 = <main>/.git(git 共享 .git 目录)
145
- const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
146
- if (!commonDir) return this.cwd;
147
-
148
- // commonDir 应该是 <main-repo>/.git
149
- // dirname(commonDir) = <main-repo>
150
- if (existsSync(commonDir)) {
151
- const st = statSync(commonDir);
152
- if (st.isDirectory()) {
153
- return dirname(commonDir);
154
- }
155
- }
156
- } catch (e) {
157
- // 静默 fallback:主仓库内执行或 git 异常
158
- }
159
- return this.cwd;
160
- }
161
-
162
- /**
163
- * 获取 worktree 目录绝对路径
164
- * @param {string} changeName
165
- * @returns {string}
166
- */
167
- getWorktreePath(changeName) {
168
- return resolve(this.worktreeBase, changeName);
169
- }
170
-
171
- /**
172
- * 读取 worktree 元数据
173
- * @param {string} changeName
174
- * @returns {object|null} meta.json 内容,不存在或损坏返回 null
175
- */
176
- getMeta(changeName) {
177
- const name = validateChangeName(changeName);
178
- const metaPath = join(this.getWorktreePath(name), META_FILE);
179
- if (!existsSync(metaPath)) return null;
180
- return parseJSON(readFileSync(metaPath, 'utf8'));
181
- }
182
-
183
- /**
184
- * 创建 worktree
185
- * @param {string} changeName - 变更名
186
- * @param {{ base?: string }} opts - base: 基础分支,默认当前 HEAD
187
- * @returns {{ branch: string, worktreePath: string, baseHash: string }}
188
- * @throws {Error} worktree 已存在、git 不可用、changeName 为空
189
- */
190
- create(changeName, { base } = {}) {
191
- const name = validateChangeName(changeName);
192
- const worktreePath = this.getWorktreePath(name);
193
- const branch = BRANCH_PREFIX + name;
194
-
195
- // 0. 检测当前环境隔离状态(submodule guard)
196
- const isolation = detectIsolation(this.cwd);
197
- if (isolation.inSubmodule) {
198
- throw new Error(
199
- '当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
200
- '\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
201
- );
202
- }
203
- if (isolation.inWorktree) {
204
- // 已在 linked worktree 中,复用当前目录作为 worktree 路径
205
- console.log(`ℹ️ 已在 linked worktree 中(git-dir: ${isolation.gitDir}),复用当前隔离环境。`);
206
-
207
- // 幂等守卫:meta 已存在时不重新 overlay baseline
208
- const existingMeta = this.getMeta(name)
209
- if (existingMeta) {
210
- return { branch: existingMeta.branch, worktreePath: existingMeta.worktreePath, baseHash: existingMeta.baseHash, mode: existingMeta.mode }
211
- }
212
-
213
- // meta 不存在但已在 worktree 内:可能是 meta 被损坏/误删。
214
- // 绝对禁止 overlay baseline(source === target 会冲突),
215
- // 只恢复 meta 引用,不触碰文件系统。
216
- return this._recoverNativeWorktreeMeta(name, {
217
- worktreePath: this.cwd,
218
- branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
219
- });
220
- }
221
-
222
- // 1. 检查 worktree 目录是否被 gitignore
223
- const ignoreStatus = checkWorktreeDirIgnored(this.cwd);
224
- if (!ignoreStatus.ignored) {
225
- throw new Error(
226
- `worktree 存储目录 ${ignoreStatus.path} 未被 .gitignore 忽略,` +
227
- `创建 worktree 可能导致内容被误提交。\n` +
228
- `请先在 .gitignore 中添加: ${ignoreStatus.path}/\n` +
229
- `或运行 sillyspec doctor 检查修复。`
230
- );
231
- }
232
-
233
- // 2. 检查 worktree 是否已存在
234
- if (existsSync(worktreePath)) {
235
- // 目录在但 meta.json 不存在(幽灵状态),自动清理
236
- if (!this.getMeta(name)) {
237
- console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json),自动清理...`);
238
- try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
239
- } else {
240
- throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
241
- }
242
- }
243
-
244
- // 2. 检查分支是否已存在
245
- if (gitQuiet(this.cwd, `rev-parse --verify refs/heads/${branch}`)) {
246
- throw new Error(`branch already exists: ${branch}. Run cleanup first.`);
247
- }
248
-
249
- // 3. 解析 base 分支
250
- let baseBranch = base;
251
- let baseHash;
252
- if (baseBranch) {
253
- baseHash = git(this.cwd, `rev-parse ${baseBranch}`);
254
- } else {
255
- // 默认用当前 HEAD
256
- baseBranch = gitQuiet(this.cwd, `symbolic-ref --short HEAD`) || git(this.cwd, `rev-parse HEAD`);
257
- baseHash = git(this.cwd, `rev-parse HEAD`);
258
- }
259
-
260
- // 4. 创建 worktree 根目录
261
- if (!existsSync(this.worktreeBase)) {
262
- mkdirSync(this.worktreeBase, { recursive: true });
263
- }
264
-
265
- // 5. 创建 worktree(含版本检测 + sandbox fallback)
266
- try {
267
- git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
268
- } catch (e) {
269
- const check = isGitWorktreeSupported(this.cwd);
270
- if (!check.supported) {
271
- throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
272
- }
273
- // sandbox/permission fallback: 降级为 in-place + baseline protection
274
- console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
275
- console.log(` 原因: ${e.stderr || e.message}`);
276
- return this._createInPlaceMeta(name, {
277
- worktreePath: this.cwd,
278
- branch,
279
- baseBranch,
280
- baseHash,
281
- mode: 'in-place-fallback',
282
- });
283
- }
284
-
285
- // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
286
- let syncStatus = 'ok';
287
- let syncError = null;
288
- try {
289
- // 先 fetch origin
290
- gitQuiet(worktreePath, 'fetch origin');
291
-
292
- // 尝试 merge origin/main(或 origin/master)到 worktree 分支
293
- const defaultBranch = gitQuiet(this.cwd, 'symbolic-ref refs/remotes/origin/HEAD --short')?.replace('origin/', '')
294
- || gitQuiet(this.cwd, 'rev-parse --abbrev-ref origin/main') ? 'main'
295
- : gitQuiet(this.cwd, 'rev-parse --abbrev-ref origin/master') ? 'master'
296
- : null;
297
-
298
- if (defaultBranch) {
299
- // 检查 worktree 是否落后于远程
300
- const localHead = gitQuiet(worktreePath, 'rev-parse HEAD');
301
- const remoteHead = gitQuiet(worktreePath, `rev-parse origin/${defaultBranch}`);
302
-
303
- if (localHead && remoteHead && localHead !== remoteHead) {
304
- // 检查是否有共同祖先(避免完全不相关的分支强行 merge)
305
- const mergeBase = gitQuiet(worktreePath, `merge-base ${localHead} origin/${defaultBranch}`);
306
- if (mergeBase) {
307
- git(worktreePath, `merge origin/${defaultBranch} --ff-only`);
308
- }
309
- }
310
- }
311
- } catch (e) {
312
- syncStatus = 'failed';
313
- syncError = e.message || String(e);
314
- console.warn(`⚠️ worktree 远程同步失败:${syncError}`);
315
- }
316
-
317
- // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
318
- const baselineResult = this._overlayBaseline(this.cwd, worktreePath);
319
- const baselineFiles = baselineResult.files;
320
- const baselineHash = baselineResult.baselineHash;
321
-
322
- // 5.7 创建 baseline checkpoint(有 dirty baseline 时才创建)
323
- let baselineCommit = null;
324
- if (baselineFiles.length > 0) {
325
- baselineCommit = this._createBaselineCheckpoint(worktreePath, name);
326
- }
327
-
328
- // 6. 写入 meta.json
329
- const meta = {
330
- changeName: name,
331
- branch,
332
- baseBranch,
333
- baseHash,
334
- actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
335
- createdAt: new Date().toISOString(),
336
- worktreePath,
337
- mode: 'worktree',
338
- baselineFiles,
339
- baselineCommit,
340
- baselineHash,
341
- syncStatus,
342
- ...(syncError ? { syncError } : {}),
343
- };
344
-
345
- const metaPath = join(worktreePath, META_FILE);
346
- writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
347
-
348
- return { branch, worktreePath, baseHash, mode: meta.mode };
349
- }
350
-
351
- /**
352
- * native-worktree 模式下恢复 meta 引用
353
- * 当 meta.json 被损坏/误删时,只重建 meta 文件,不触碰文件系统(不 overlay)
354
- * @private
355
- */
356
- _recoverNativeWorktreeMeta(name, { worktreePath, branch }) {
357
- const baseHash = gitQuiet(worktreePath, 'rev-parse HEAD') || null
358
- const meta = {
359
- changeName: name,
360
- branch: branch || BRANCH_PREFIX + name,
361
- baseBranch: branch,
362
- baseHash,
363
- actualBaseHash: baseHash,
364
- createdAt: new Date().toISOString(),
365
- worktreePath,
366
- mode: 'native-worktree',
367
- baselineFiles: [],
368
- baselineCommit: null,
369
- baselineHash: null,
370
- recoveredAt: new Date().toISOString(),
371
- recoveryNote: 'meta was missing in native-worktree; recovered without baseline overlay',
372
- }
373
- if (!existsSync(this.worktreeBase)) mkdirSync(this.worktreeBase, { recursive: true })
374
- const metaDir = join(this.worktreeBase, name)
375
- if (!existsSync(metaDir)) mkdirSync(metaDir, { recursive: true })
376
- writeFileSync(join(metaDir, META_FILE), JSON.stringify(meta, null, 2) + '\n')
377
- console.log(`🔗 native-worktree meta 已恢复: ${metaDir}/meta.json`)
378
- return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode }
379
- }
380
-
381
- /**
382
- * 创建 in-place 模式的 meta.json(降级路径)
383
- * 不创建 git worktree,直接在当前目录记录 baseline 并写入 meta
384
- * @private
385
- */
386
- _createInPlaceMeta(name, { worktreePath, branch, baseBranch, baseHash, mode } = {}) {
387
- // 幂等守卫:meta 已存在时不重新创建(避免 overlay baseline 和已有改动冲突)
388
- const existingMeta = this.getMeta(name)
389
- if (existingMeta) {
390
- return { branch: existingMeta.branch, worktreePath: existingMeta.worktreePath, baseHash: existingMeta.baseHash, mode: existingMeta.mode }
391
- }
392
-
393
- // 硬规则:禁止 self-overlay(source 和 target 相同时 overlay 必然冲突)
394
- const resolvedSource = resolve(this.cwd)
395
- const resolvedTarget = resolve(worktreePath)
396
- if (resolvedSource === resolvedTarget) {
397
- console.warn('⚠️ 跳过 baseline overlay:当前目录与目标目录相同(native-worktree 或 in-place 模式)')
398
- // 写 meta 但不 overlay
399
- baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD')
400
- baseHash = baseHash || git(this.cwd, 'rev-parse HEAD')
401
- const meta = {
402
- changeName: name,
403
- branch: branch || BRANCH_PREFIX + name,
404
- baseBranch,
405
- baseHash,
406
- actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
407
- createdAt: new Date().toISOString(),
408
- worktreePath,
409
- mode: mode || 'in-place-fallback',
410
- baselineFiles: [],
411
- baselineCommit: null,
412
- baselineHash: null,
413
- }
414
- if (!existsSync(this.worktreeBase)) mkdirSync(this.worktreeBase, { recursive: true })
415
- const metaDir = join(this.worktreeBase, name)
416
- if (!existsSync(metaDir)) mkdirSync(metaDir, { recursive: true })
417
- writeFileSync(join(metaDir, META_FILE), JSON.stringify(meta, null, 2) + '\n')
418
- return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode }
419
- }
420
-
421
- // 解析 base
422
- if (!baseHash) {
423
- baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
424
- baseHash = git(this.cwd, 'rev-parse HEAD');
425
- }
426
-
427
- const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
428
- const baselineFiles = baselineResult.files;
429
- const baselineHash = baselineResult.baselineHash;
430
-
431
- let baselineCommit = null;
432
- if (baselineFiles.length > 0) {
433
- baselineCommit = this._createBaselineCheckpoint(this.cwd, name);
434
- }
435
-
436
- const meta = {
437
- changeName: name,
438
- branch: branch || BRANCH_PREFIX + name,
439
- baseBranch,
440
- baseHash,
441
- actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
442
- createdAt: new Date().toISOString(),
443
- worktreePath,
444
- mode: mode || 'in-place-fallback',
445
- baselineFiles,
446
- baselineCommit,
447
- baselineHash,
448
- };
449
-
450
- // in-place 模式下 meta 写入 worktreeBase(避免污染主工作区)
451
- if (!existsSync(this.worktreeBase)) {
452
- mkdirSync(this.worktreeBase, { recursive: true });
453
- }
454
- const metaPath = join(this.worktreeBase, name, META_FILE);
455
- const metaDir = join(this.worktreeBase, name);
456
- if (!existsSync(metaDir)) {
457
- mkdirSync(metaDir, { recursive: true });
458
- }
459
- writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
460
-
461
- return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode };
462
- }
463
-
464
- /**
465
- * 构建 isolation 信息对象,用于写入 gate-status.json
466
- * @param {string} changeName
467
- * @returns {{ status: string, mode: string, path: string } | null}
468
- */
469
- getIsolationInfo(changeName) {
470
- const meta = this.getMeta(changeName);
471
- if (!meta) return null;
472
-
473
- const mode = meta.mode || 'worktree';
474
- const statusMap = {
475
- 'worktree': 'verified',
476
- 'native-worktree': 'verified',
477
- 'in-place-fallback': 'degraded',
478
- };
479
-
480
- return {
481
- status: statusMap[mode] || 'verified',
482
- mode,
483
- path: meta.worktreePath,
484
- branch: meta.branch,
485
- };
486
- }
487
-
488
- /**
489
- * 获取 worktree 的运行模式
490
- * @param {string} changeName
491
- * @returns {'worktree'|'native-worktree'|'in-place-fallback'|null}
492
- */
493
- getMode(changeName) {
494
- const meta = this.getMeta(changeName);
495
- return meta?.mode || null;
496
- }
497
-
498
- /**
499
- * 列出所有活跃 worktree
500
- * @returns {Array<{ changeName: string, branch: string, baseHash: string, createdAt: string, worktreePath: string }>}
501
- */
502
- list() {
503
- const results = [];
504
- if (!existsSync(this.worktreeBase)) return results;
505
-
506
- const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
507
- for (const entry of entries) {
508
- if (!entry.isDirectory()) continue;
509
- const metaPath = join(this.worktreeBase, entry.name, META_FILE);
510
- if (!existsSync(metaPath)) continue;
511
- const meta = parseJSON(readFileSync(metaPath, 'utf8'));
512
- if (!meta) continue;
513
- results.push({
514
- changeName: meta.changeName,
515
- branch: meta.branch,
516
- baseHash: meta.baseHash,
517
- baseBranch: meta.baseBranch,
518
- createdAt: meta.createdAt,
519
- worktreePath: meta.worktreePath,
520
- mode: meta.mode || 'worktree',
521
- });
522
- }
523
-
524
- return results;
525
- }
526
-
527
- /**
528
- * 清理 worktree(仅限 SillySpec 创建的临时 worktree)
529
- * 幂等:重复调用不报错。
530
- * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
531
- * @param {string} changeName
532
- * @param {{ force?: boolean, maxRetries?: number }} opts
533
- * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
534
- */
535
- cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
536
- const name = validateChangeName(changeName);
537
- const meta = this.getMeta(name);
538
- const worktreePath = this.getWorktreePath(name);
539
- const metaDir = join(this.worktreeBase, name);
540
- const details = [];
541
-
542
- // 幂等:什么都不存在 → 直接跳过
543
- if (!meta && !existsSync(worktreePath) && !existsSync(metaDir)) {
544
- return { result: 'skipped', mode: null, details };
545
- }
546
-
547
- const mode = meta?.mode || 'worktree';
548
-
549
- // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
550
- if (!force) {
551
- if (mode === 'native-worktree') {
552
- return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'] };
553
- }
554
- if (mode === 'in-place-fallback') {
555
- return { result: 'skipped', mode, details: ['in-place-fallback: 无隔离目录,跳过清理'] };
556
- }
557
- }
558
-
559
- const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
560
-
561
- // 1. git worktree remove(带 retry)
562
- let gitRemoveOk = false;
563
- if (existsSync(worktreePath)) {
564
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
565
- try {
566
- git(this.cwd, `worktree remove ${worktreePath} --force`);
567
- gitRemoveOk = true;
568
- details.push(`git worktree remove succeeded (attempt ${attempt})`);
569
- break;
570
- } catch (e) {
571
- details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
572
- if (attempt < maxRetries) {
573
- // 短暂等待后重试
574
- execSync('sleep 0.5', { stdio: 'pipe' });
575
- }
576
- }
577
- }
578
- }
579
-
580
- // 2. fallback: 确保 worktree 目录已删除
581
- if (existsSync(worktreePath)) {
582
- try {
583
- rmSync(worktreePath, { recursive: true, force: true });
584
- details.push('worktree directory force-removed (fallback)');
585
- } catch (e) {
586
- details.push(`worktree directory force-remove failed: ${e.message}`);
587
- }
588
- }
589
-
590
- // 3. git worktree prune(清理 git 内部注册信息)
591
- try {
592
- gitQuiet(this.cwd, 'worktree prune');
593
- } catch {
594
- // prune 失败不阻断
595
- }
596
-
597
- // 4. 删除分支(忽略分支不存在的错误)
598
- try {
599
- gitQuiet(this.cwd, `branch -D ${branch}`);
600
- details.push('branch deleted');
601
- } catch {
602
- // 分支可能已被删除,幂等跳过
603
- }
604
-
605
- // 5. 清除 meta 目录
606
- if (existsSync(metaDir)) {
607
- try {
608
- rmSync(metaDir, { recursive: true, force: true });
609
- details.push('meta directory cleaned');
610
- } catch (e) {
611
- details.push(`meta directory cleanup failed: ${e.message}`);
612
- }
613
- }
614
-
615
- // 6. 最终验证:确认三重清理完成
616
- const residual = [];
617
- if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
618
- if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
619
- if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
620
- residual.push('git worktree list still references this worktree');
621
- }
622
- if (residual.length > 0) {
623
- details.push(`⚠️ 残留: ${residual.join('; ')}`);
624
- }
625
-
626
- return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode, details };
627
- }
628
-
629
- /**
630
- * worktree 健康检查 + 可选修复
631
- * 检查项:
632
- * - git worktree list 中的孤儿条目(目录不存在)
633
- * - worktree 目录存在但 git 不认识
634
- * - meta 存在但 worktree 目录不存在
635
- * - worktree 目录存在但 meta 不存在(幽灵目录)
636
- * - SillySpec 分支残留(sillyspec/* 但无对应 meta)
637
- * - 超过指定小时的过期 worktree
638
- *
639
- * @param {{ fix?: boolean, staleHours?: number }} opts
640
- * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
641
- */
642
- doctor({ fix = false, staleHours = 24 } = {}) {
643
- const issues = [];
644
- const fixed = [];
645
- const unfixable = [];
646
-
647
- // 1. 列出 git worktree list 中的条目
648
- let gitWorktreeList = [];
649
- try {
650
- const raw = execSync(`git worktree list --porcelain`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
651
- const entries = raw.split(/\n\n/).filter(Boolean);
652
- for (const entry of entries) {
653
- const lines = entry.split('\n');
654
- const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
655
- if (wtPath && wtPath !== this.cwd) { // 排除主工作区
656
- gitWorktreeList.push({ path: wtPath, raw: entry });
657
- }
658
- }
659
- } catch {
660
- // git worktree 不可用,跳过
661
- }
662
-
663
- // 2. 列出 SillySpec meta 条目
664
- const metaEntries = this.list();
665
- const metaNames = new Set(metaEntries.map(m => m.changeName));
666
-
667
- // 3. 检查 git worktree list 中的孤儿条目
668
- for (const wt of gitWorktreeList) {
669
- if (!existsSync(wt.path)) {
670
- const name = this._pathToChangeName(wt.path);
671
- issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 引用存在但目录不存在: ${wt.path}`, fixable: true });
672
- if (fix) {
673
- try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
674
- }
675
- }
676
- }
677
-
678
- // 4. 扫描 worktreeBase 目录,检查幽灵目录和孤儿 meta
679
- if (existsSync(this.worktreeBase)) {
680
- const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
681
- for (const entry of entries) {
682
- if (!entry.isDirectory()) continue;
683
- const name = entry.name;
684
- const dirPath = join(this.worktreeBase, name);
685
- const hasMeta = existsSync(join(dirPath, META_FILE));
686
- const meta = hasMeta ? this.getMeta(name) : null;
687
-
688
- // meta 存在但 worktree 目录不存在
689
- if (meta && meta.worktreePath && !existsSync(meta.worktreePath)) {
690
- issues.push({ type: 'meta-no-dir', name, detail: `meta 存在但 worktree 目录不存在: ${meta.worktreePath}`, fixable: true });
691
- if (fix) {
692
- try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`cleaned orphan meta: ${name}`); } catch { unfixable.push(`cleanup failed for: ${name}`); }
693
- }
694
- }
695
-
696
- // worktree 目录存在但 meta 不存在(幽灵目录)
697
- if (!hasMeta && existsSync(dirPath)) {
698
- // 可能是 in-place 模式的 meta-only 目录,或者真正的幽灵
699
- const files = readdirSync(dirPath);
700
- if (files.length === 0 || (files.length === 1 && files[0] === META_FILE)) {
701
- issues.push({ type: 'ghost-dir', name, detail: `空目录/幽灵目录: ${dirPath}`, fixable: true });
702
- if (fix) {
703
- try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`removed ghost dir: ${name}`); } catch { unfixable.push(`remove failed for: ${name}`); }
704
- }
705
- } else {
706
- issues.push({ type: 'ghost-dir-with-files', name, detail: `目录存在但无 meta.json: ${dirPath} (含 ${files.length} 文件)`, fixable: false });
707
- }
708
- }
709
-
710
- // 检查过期 worktree
711
- if (meta && meta.createdAt) {
712
- const ageMs = Date.now() - new Date(meta.createdAt).getTime();
713
- const ageHours = ageMs / (1000 * 60 * 60);
714
- if (ageHours > staleHours) {
715
- issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)`, fixable: true });
716
- if (fix && meta.mode !== 'native-worktree') {
717
- try {
718
- const result = this.cleanup(name);
719
- if (result.result === 'cleaned' || result.result === 'force-cleaned') {
720
- fixed.push(`cleaned stale: ${name}`);
721
- } else {
722
- unfixable.push(`cleanup skipped: ${name}`);
723
- }
724
- } catch { unfixable.push(`cleanup failed: ${name}`); }
725
- }
726
- }
727
- }
728
- }
729
- }
730
-
731
- // 5. 检查 SillySpec 分支残留
732
- try {
733
- const branches = execSync(`git branch --list '${BRANCH_PREFIX}*'`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
734
- if (branches) {
735
- for (const line of branches.split('\n').filter(Boolean)) {
736
- const branch = line.replace(/^\*?\s+/, '').trim();
737
- const name = branch.replace(BRANCH_PREFIX, '');
738
- if (!metaNames.has(name)) {
739
- issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
740
- if (fix) {
741
- try { gitQuiet(this.cwd, `branch -D ${branch}`); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
742
- }
743
- }
744
- }
745
- }
746
- } catch {}
747
-
748
- return { issues, fixed, unfixable };
749
- }
750
-
751
- /**
752
- * 检查 worktree 是否有未 apply 到主工作区的变更
753
- * @param {string} changeName
754
- * @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
755
- */
756
- hasUnappliedChanges(changeName) {
757
- const name = validateChangeName(changeName);
758
- const meta = this.getMeta(name);
759
- if (!meta) return { hasChanges: false, changedFiles: [], reason: 'no meta' };
760
-
761
- const worktreePath = meta.worktreePath;
762
- if (!worktreePath || !existsSync(worktreePath)) {
763
- return { hasChanges: false, changedFiles: [], reason: 'worktree dir not found' };
764
- }
765
-
766
- // in-place 模式没有隔离目录,不算有未 apply 的变更
767
- if (meta.mode === 'in-place-fallback') {
768
- return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
769
- }
770
-
771
- const diffBase = meta.baselineCommit || meta.baseHash;
772
- if (!diffBase) {
773
- return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
774
- }
775
-
776
- try {
777
- // tracked 文件变更
778
- const statusRaw = gitQuiet(worktreePath, `diff --name-status ${diffBase}`) || '';
779
- const statusFiles = new Set();
780
- if (statusRaw) {
781
- for (const line of statusRaw.split('\n').filter(Boolean)) {
782
- const parts = line.split('\t');
783
- if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
784
- if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
785
- }
786
- }
787
-
788
- // untracked 文件
789
- const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '';
790
- const untrackedFiles = untrackedRaw
791
- ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
792
- : [];
793
-
794
- const changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
795
- return { hasChanges: changedFiles.length > 0, changedFiles };
796
- } catch (e) {
797
- // 检测失败时保守处理:视为有变更
798
- return { hasChanges: true, changedFiles: [], reason: `diff failed: ${e.message}` };
799
- }
800
- }
801
-
802
- /**
803
- * 从 worktree 路径反推 changeName
804
- * @private
805
- */
806
- _pathToChangeName(wtPath) {
807
- try {
808
- const resolved = resolve(wtPath);
809
- const baseResolved = resolve(this.worktreeBase);
810
- if (resolved.startsWith(baseResolved + '/')) {
811
- return resolved.slice(baseResolved.length + 1);
812
- }
813
- } catch {}
814
- return null;
815
- }
816
-
817
- /**
818
- * 将主工作区未提交变更同步到 worktree(dirty baseline overlay)
819
- * 覆盖 staged + unstaged 的文件变更,以及 untracked 文件。
820
- * 使用 git diff + git apply 确保正确处理删除/rename/binary。
821
- * @param {string} mainCwd - 主工作区路径
822
- * @param {string} worktreePath - worktree 路径
823
- * @returns {Array<string>} overlay 的文件列表
824
- */
825
- _overlayBaseline(mainCwd, worktreePath) {
826
- const files = [];
827
- const errors = [];
828
-
829
- try {
830
- // staged 变更
831
- const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
832
- if (staged) {
833
- try {
834
- // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
835
- const patchBuf = execSync(`git diff --cached --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
836
- if (patchBuf && patchBuf.length > 0) {
837
- const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
838
- writeFileSync(patchFile, patchBuf);
839
- git(worktreePath, `apply --binary ${patchFile}`);
840
- rmSync(patchFile, { force: true });
841
- }
842
- } catch (e) {
843
- errors.push(`staged: ${e.message}`);
844
- }
845
- files.push(...staged.split('\n').filter(Boolean));
846
- }
847
-
848
- // unstaged 变更
849
- const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
850
- if (unstaged) {
851
- try {
852
- // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
853
- const patchBuf = execSync(`git diff --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
854
- if (patchBuf && patchBuf.length > 0) {
855
- const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
856
- writeFileSync(patchFile, patchBuf);
857
- git(worktreePath, `apply --binary ${patchFile}`);
858
- rmSync(patchFile, { force: true });
859
- }
860
- } catch (e) {
861
- errors.push(`unstaged: ${e.message}`);
862
- }
863
- files.push(...unstaged.split('\n').filter(Boolean));
864
- }
865
-
866
- // untracked 文件(排除 .sillyspec/.runtime 等)
867
- const untracked = gitQuiet(mainCwd, 'ls-files --others --exclude-standard') || '';
868
- if (untracked) {
869
- for (const f of untracked.split('\n').filter(Boolean)) {
870
- const src = join(mainCwd, f);
871
- const dst = join(worktreePath, f);
872
- if (existsSync(src)) {
873
- mkdirSync(dirname(dst), { recursive: true });
874
- try { writeFileSync(dst, readFileSync(src)); files.push(f); } catch {}
875
- }
876
- }
877
- }
878
-
879
- if (files.length > 0) {
880
- console.log(`📁 baseline overlay: ${files.length} 个未提交文件已同步到 worktree`);
881
- }
882
- } catch (e) {
883
- errors.push(`unexpected: ${e.message}`);
884
- }
885
-
886
- // 有 pending 文件但 overlay 部分失败 → fail-fast
887
- if (errors.length > 0) {
888
- throw new Error(`baseline overlay 失败 (${errors.length} 个错误): ${errors.join('; ')}`);
889
- }
890
-
891
- const uniqueFiles = [...new Set(files)];
892
-
893
- // 计算 baseline hash(用于 merge 前校验主工作区是否变化)
894
- const baselineHash = uniqueFiles.length > 0 ? computeBaselineHash(mainCwd) : null;
895
-
896
- return { files: uniqueFiles, baselineHash };
897
- }
898
-
899
- /**
900
- * 在 worktree 内创建 baseline checkpoint commit
901
- * 用于区分 "前置 dirty baseline" 和 "子代理新增改动"
902
- * @param {string} worktreePath
903
- * @param {string} changeName
904
- * @returns {string} commit hash
905
- */
906
- _createBaselineCheckpoint(worktreePath, changeName) {
907
- // 使用临时 git identity,避免用户未配置 user.name/user.email 导致失败
908
- const env = {
909
- GIT_AUTHOR_NAME: 'sillyspec',
910
- GIT_AUTHOR_EMAIL: 'sillyspec@baseline',
911
- GIT_COMMITTER_NAME: 'sillyspec',
912
- GIT_COMMITTER_EMAIL: 'sillyspec@baseline',
913
- };
914
- try {
915
- execSync('git add -A', { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env });
916
- // 检查是否有实际变更(可能 overlay 后和 HEAD 完全一致)
917
- const status = gitQuiet(worktreePath, 'status --porcelain');
918
- if (!status) {
919
- return gitQuiet(worktreePath, 'rev-parse HEAD');
920
- }
921
- execSync(
922
- `git commit -m "sillyspec: baseline checkpoint for ${changeName}"`,
923
- { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
924
- );
925
- const hash = git(worktreePath, 'rev-parse HEAD');
926
- console.log(`📌 baseline checkpoint: ${hash}`);
927
- return hash;
928
- } catch (e) {
929
- throw new Error(`baseline checkpoint 创建失败: ${e.message}`);
930
- }
931
- }
932
- }