kld-sdd 2.4.17 → 2.4.19

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.
@@ -0,0 +1,551 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SDD Apply Worktree Finish — 本地并行加速的收尾工具
4
+ *
5
+ * record-base: 记录 Apply 开始时主仓库的集成分支(merge 目标)
6
+ * finish: merge apply 分支 → integration_base → remove worktree → 删 apply 分支
7
+ *
8
+ * 必须在主仓库根目录执行,不要在 .worktrees/ 内。
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execFileSync } = require('child_process');
14
+
15
+ const TEMP_DIR_NAMES = ['node_modules', 'dist', '.turbo', '.next', 'coverage', 'build'];
16
+
17
+ function parseArgs(argv) {
18
+ const args = {};
19
+ for (let i = 2; i < argv.length; i++) {
20
+ const token = argv[i];
21
+ if (token === '--') continue;
22
+ if (token.startsWith('--')) {
23
+ const eq = token.indexOf('=');
24
+ if (eq !== -1) {
25
+ args[token.slice(2, eq)] = token.slice(eq + 1);
26
+ } else {
27
+ const key = token.slice(2);
28
+ const next = argv[i + 1];
29
+ if (next && !next.startsWith('--')) {
30
+ args[key] = next;
31
+ i++;
32
+ } else {
33
+ args[key] = true;
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return args;
39
+ }
40
+
41
+ function git(cwd, gitArgs, opts = {}) {
42
+ return execFileSync('git', gitArgs, {
43
+ cwd,
44
+ encoding: 'utf8',
45
+ stdio: opts.silent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'],
46
+ }).trim();
47
+ }
48
+
49
+ function gitTry(cwd, gitArgs) {
50
+ try {
51
+ return git(cwd, gitArgs, { silent: true });
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function gitThrow(cwd, gitArgs) {
58
+ try {
59
+ return execFileSync('git', gitArgs, {
60
+ cwd,
61
+ encoding: 'utf8',
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ }).trim();
64
+ } catch (err) {
65
+ const stderr = err.stderr ? err.stderr.toString().trim() : '';
66
+ const msg = stderr || err.message || String(err);
67
+ const e = new Error(msg);
68
+ e.stderr = stderr;
69
+ throw e;
70
+ }
71
+ }
72
+
73
+ function fail(message) {
74
+ console.error(`错误: ${message}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ function info(message) {
79
+ console.log(message);
80
+ }
81
+
82
+ function warn(message) {
83
+ console.warn(`警告: ${message}`);
84
+ }
85
+
86
+ function findRepoRoot(startDir) {
87
+ let dir = path.resolve(startDir);
88
+ for (;;) {
89
+ if (fs.existsSync(path.join(dir, '.git'))) {
90
+ return dir;
91
+ }
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) return null;
94
+ dir = parent;
95
+ }
96
+ }
97
+
98
+ function assertMainRepo(repoRoot) {
99
+ const cwd = path.resolve(process.cwd());
100
+ const worktreesSegment = `${path.sep}.worktrees${path.sep}`;
101
+ if (cwd.includes(worktreesSegment)) {
102
+ fail(
103
+ `当前目录在 worktree 内 (${cwd})。请在主仓库根目录执行本脚本,不要在 .worktrees/ 子目录中运行。`
104
+ );
105
+ }
106
+ const commonDir = gitTry(repoRoot, ['rev-parse', '--git-common-dir']);
107
+ const gitDir = gitTry(repoRoot, ['rev-parse', '--git-dir']);
108
+ if (commonDir && gitDir && path.resolve(repoRoot, commonDir) !== path.resolve(repoRoot, gitDir)) {
109
+ fail('当前检出为 linked worktree,请 cd 到主仓库根目录后重试。');
110
+ }
111
+ }
112
+
113
+ function stateFilePath(repoRoot, change, capability) {
114
+ return path.join(repoRoot, 'skywalk-sdd', 'state', `apply-${change}-${capability}.json`);
115
+ }
116
+
117
+ function readApplyState(repoRoot, change, capability) {
118
+ const filePath = stateFilePath(repoRoot, change, capability);
119
+ if (!fs.existsSync(filePath)) return null;
120
+ try {
121
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function writeApplyState(repoRoot, change, capability, payload) {
128
+ const stateDir = path.join(repoRoot, 'skywalk-sdd', 'state');
129
+ fs.mkdirSync(stateDir, { recursive: true });
130
+ const filePath = stateFilePath(repoRoot, change, capability);
131
+ fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
132
+ return filePath;
133
+ }
134
+
135
+ function branchRef(branch) {
136
+ return branch.startsWith('refs/') ? branch : `refs/heads/${branch}`;
137
+ }
138
+
139
+ function branchExists(repoRoot, branch) {
140
+ return Boolean(gitTry(repoRoot, ['rev-parse', '--verify', branchRef(branch)]));
141
+ }
142
+
143
+ function isBranchMergedInto(repoRoot, branch, target) {
144
+ const merged = gitTry(repoRoot, ['branch', '--merged', target]);
145
+ if (!merged) return false;
146
+ return merged.split('\n').some((line) => {
147
+ const name = line.replace(/^\*?\s+/, '').trim();
148
+ return name === branch;
149
+ });
150
+ }
151
+
152
+ function listGitWorktrees(repoRoot) {
153
+ const raw = gitTry(repoRoot, ['worktree', 'list', '--porcelain']);
154
+ if (!raw) return [];
155
+ const entries = [];
156
+ let current = {};
157
+ for (const line of raw.split('\n')) {
158
+ if (!line) {
159
+ if (current.path) entries.push(current);
160
+ current = {};
161
+ continue;
162
+ }
163
+ if (line.startsWith('worktree ')) current.path = line.slice('worktree '.length);
164
+ if (line.startsWith('branch ')) current.branch = line.slice('branch '.length);
165
+ if (line.startsWith('detached')) current.detached = true;
166
+ }
167
+ if (current.path) entries.push(current);
168
+ return entries;
169
+ }
170
+
171
+ function resolveWorktreeDir(repoRoot, change, capability, applyBranch) {
172
+ const candidates = [
173
+ path.join(repoRoot, '.worktrees', `apply-${change}-${capability}`),
174
+ path.join(repoRoot, '.worktrees', `apply-${change}`),
175
+ ];
176
+ for (const dir of candidates) {
177
+ if (fs.existsSync(dir)) {
178
+ return dir;
179
+ }
180
+ }
181
+
182
+ const state = readApplyState(repoRoot, change, capability);
183
+ if (state && state.worktree_dir && fs.existsSync(state.worktree_dir)) {
184
+ return state.worktree_dir;
185
+ }
186
+
187
+ const ref = branchRef(applyBranch);
188
+ for (const wt of listGitWorktrees(repoRoot)) {
189
+ if (wt.branch === ref || wt.branch === applyBranch) {
190
+ return wt.path;
191
+ }
192
+ const base = path.basename(wt.path);
193
+ if (base === `apply-${change}-${capability}` || base === `apply-${change}`) {
194
+ return wt.path;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function resolveMergeTarget(repoRoot, change, capability, explicitTarget) {
201
+ if (explicitTarget) return explicitTarget;
202
+
203
+ const state = readApplyState(repoRoot, change, capability);
204
+ if (state && state.integration_base) {
205
+ const current = gitTry(repoRoot, ['branch', '--show-current']);
206
+ if (current && current !== state.integration_base) {
207
+ warn(
208
+ `主仓库当前分支为 ${current},与 record-base 记录的集成分支 ${state.integration_base} 不一致;仍将 merge 到 ${state.integration_base}。`
209
+ );
210
+ }
211
+ return state.integration_base;
212
+ }
213
+
214
+ fail(
215
+ '无法确定合并目标分支:缺少 state 文件且未传 --target。\n' +
216
+ '请在 §1.5 创建 worktree 前执行 --record-base,或收尾时传入 --target=<集成分支>。'
217
+ );
218
+ }
219
+
220
+ function warnIntegrationBaseDrift(repoRoot, state, target) {
221
+ if (!state || !state.integration_base_sha) return;
222
+ const head = gitTry(repoRoot, ['rev-parse', target]);
223
+ if (!head || head === state.integration_base_sha) return;
224
+ warn(
225
+ `集成分支 ${target} 的 HEAD (${head.slice(0, 7)}) 已不同于 record-base 时 (${state.integration_base_sha.slice(0, 7)})。` +
226
+ ' 若其它 capability 已 merge,请先在 apply worktree 内 merge/rebase 集成分支,再执行 finish。'
227
+ );
228
+ }
229
+
230
+ function rmTempDirs(worktreeDir, dryRun) {
231
+ if (!fs.existsSync(worktreeDir)) return;
232
+ for (const name of TEMP_DIR_NAMES) {
233
+ const target = path.join(worktreeDir, name);
234
+ if (!fs.existsSync(target)) continue;
235
+ if (dryRun) {
236
+ info(` [dry-run] 删除 ${target}`);
237
+ } else {
238
+ fs.rmSync(target, { recursive: true, force: true });
239
+ info(` ✓ 已删除 ${path.relative(process.cwd(), target) || target}`);
240
+ }
241
+ }
242
+ }
243
+
244
+ function checkWorktreeClean(worktreeDir) {
245
+ const status = git(worktreeDir, ['status', '--porcelain'], { silent: true });
246
+ if (status) {
247
+ fail(
248
+ `worktree 存在未提交变更 (${worktreeDir})。请先在 worktree 内 commit 全部实现代码后再执行收尾。`
249
+ );
250
+ }
251
+ }
252
+
253
+ function verifyWorktreeBranch(worktreeDir, applyBranch) {
254
+ const current = gitTry(worktreeDir, ['branch', '--show-current']);
255
+ if (current && current !== applyBranch) {
256
+ warn(`worktree 当前分支为 ${current},预期 ${applyBranch}。`);
257
+ }
258
+ }
259
+
260
+ function listMergePaths(repoRoot, target, branch) {
261
+ const out = gitTry(repoRoot, ['diff', '--name-only', `${target}...${branch}`]);
262
+ if (!out) return [];
263
+ return out.split('\n').map((line) => line.trim()).filter(Boolean);
264
+ }
265
+
266
+ function checkUntrackedConflicts(repoRoot, target, branch) {
267
+ const mergePaths = listMergePaths(repoRoot, target, branch);
268
+ if (mergePaths.length === 0) return;
269
+
270
+ const untrackedRaw = gitTry(repoRoot, ['ls-files', '--others', '--exclude-standard']);
271
+ if (!untrackedRaw) return;
272
+
273
+ const untracked = new Set(
274
+ untrackedRaw.split('\n').map((line) => line.trim()).filter(Boolean)
275
+ );
276
+ const conflicts = mergePaths.filter((p) => untracked.has(p));
277
+ if (conflicts.length > 0) {
278
+ fail(
279
+ `主仓库存在未跟踪文件,merge 将被覆盖:\n` +
280
+ conflicts.map((p) => ` - ${p}`).join('\n') +
281
+ `\n请在 Apply 开始前移走或提交这些文件。`
282
+ );
283
+ }
284
+ }
285
+
286
+ function cmdRecordBase(args) {
287
+ const change = args.change;
288
+ if (!change) {
289
+ fail('缺少 --change 参数');
290
+ }
291
+ const capability = args.capability || change;
292
+ const repoRoot = findRepoRoot(process.cwd());
293
+ if (!repoRoot) {
294
+ fail('未找到 Git 仓库根目录');
295
+ }
296
+ assertMainRepo(repoRoot);
297
+
298
+ const integrationBase = gitTry(repoRoot, ['branch', '--show-current']);
299
+ if (!integrationBase) {
300
+ fail(
301
+ '当前为 detached HEAD,无法记录集成分支。请先在主仓库 checkout 到具名分支再执行 --record-base。'
302
+ );
303
+ }
304
+ const integrationBaseSha = git(repoRoot, ['rev-parse', 'HEAD'], { silent: true });
305
+ const applyBranch = `kld-sdd/${change}/${capability}`;
306
+
307
+ const existing = readApplyState(repoRoot, change, capability);
308
+ if (existing && existing.integration_base && existing.integration_base !== integrationBase) {
309
+ warn(
310
+ `覆盖已有 state:原集成分支 ${existing.integration_base} → 现 ${integrationBase}。` +
311
+ ' 若已有进行中的 worktree,请确认是否应继续。'
312
+ );
313
+ }
314
+
315
+ const payload = {
316
+ change,
317
+ capability,
318
+ integration_base: integrationBase,
319
+ integration_base_sha: integrationBaseSha,
320
+ apply_branch: applyBranch,
321
+ recorded_at: new Date().toISOString(),
322
+ finished_at: null,
323
+ };
324
+
325
+ const filePath = writeApplyState(repoRoot, change, capability, payload);
326
+ info(`✓ 已记录集成分支: ${integrationBase} (${integrationBaseSha.slice(0, 7)})`);
327
+ info(` apply 分支(建议): ${applyBranch}`);
328
+ info(` 状态文件: ${path.relative(repoRoot, filePath)}`);
329
+ info(JSON.stringify(payload));
330
+ }
331
+
332
+ function showHelp() {
333
+ console.log(`
334
+ SDD Apply Worktree — 记录集成分支 / 收尾 merge+清理
335
+
336
+ 记录集成分支(§1.5,创建 worktree 之前):
337
+ node skywalk-sdd/apply-worktree-finish.cjs --record-base --change=<变更名> [--capability=<能力名>]
338
+
339
+ 收尾(§6.1,worktree 内已全部 commit):
340
+ node skywalk-sdd/apply-worktree-finish.cjs --change=<变更名> [--capability=<能力名>] [--target=<集成分支>]
341
+
342
+ 选项:
343
+ --record-base / --target / --dry-run / --skip-merge / --keep-branch
344
+ --no-check-untracked 跳过主仓库未跟踪文件冲突检查
345
+ --force-worktree-remove worktree remove 失败时尝试 --force
346
+
347
+ 状态文件: skywalk-sdd/state/apply-<change>-<capability>.json
348
+ `);
349
+ }
350
+
351
+ function cmdFinish(args) {
352
+ const change = args.change;
353
+ if (!change) {
354
+ showHelp();
355
+ fail('缺少 --change 参数');
356
+ }
357
+
358
+ const capability = args.capability || change;
359
+ const repoRoot = findRepoRoot(process.cwd());
360
+ if (!repoRoot) {
361
+ fail('未找到 Git 仓库根目录');
362
+ }
363
+
364
+ assertMainRepo(repoRoot);
365
+
366
+ const branch = `kld-sdd/${change}/${capability}`;
367
+ const worktreeDir = resolveWorktreeDir(repoRoot, change, capability, branch);
368
+ if (!worktreeDir) {
369
+ fail(
370
+ `未找到 worktree。已尝试:\n` +
371
+ ` .worktrees/apply-${change}-${capability}\n` +
372
+ ` .worktrees/apply-${change}\n` +
373
+ ` git worktree list(匹配分支 ${branch})`
374
+ );
375
+ }
376
+
377
+ const target = resolveMergeTarget(repoRoot, change, capability, args.target);
378
+ const dryRun = Boolean(args['dry-run'] || args.dryRun);
379
+ const skipMerge = Boolean(args['skip-merge'] || args.skipMerge);
380
+ const keepBranch = Boolean(args['keep-branch'] || args.keepBranch);
381
+ const checkUntracked = !(args['no-check-untracked'] || args.noCheckUntracked);
382
+ const forceRemove = Boolean(args['force-worktree-remove'] || args.forceWorktreeRemove);
383
+
384
+ if (!branchExists(repoRoot, branch)) {
385
+ fail(`apply 分支不存在: ${branch}`);
386
+ }
387
+ if (!branchExists(repoRoot, target)) {
388
+ fail(`集成分支不存在: ${target}`);
389
+ }
390
+
391
+ const state = readApplyState(repoRoot, change, capability);
392
+
393
+ info(`仓库根目录: ${repoRoot}`);
394
+ info(`Worktree: ${worktreeDir}`);
395
+ info(`Apply 分支: ${branch}`);
396
+ info(`集成分支: ${target}${state ? ' (来自 state)' : args.target ? ' (来自 --target)' : ''}`);
397
+
398
+ checkWorktreeClean(worktreeDir);
399
+ verifyWorktreeBranch(worktreeDir, branch);
400
+ warnIntegrationBaseDrift(repoRoot, state, target);
401
+
402
+ const alreadyMerged = isBranchMergedInto(repoRoot, branch, target);
403
+
404
+ if (!skipMerge && !alreadyMerged && checkUntracked) {
405
+ if (dryRun) {
406
+ info('[dry-run] 检查主仓库未跟踪文件冲突');
407
+ } else {
408
+ checkUntrackedConflicts(repoRoot, target, branch);
409
+ }
410
+ }
411
+
412
+ if (dryRun) {
413
+ info('\n[dry-run] 计划步骤:');
414
+ if (!skipMerge && !alreadyMerged) {
415
+ info(` 1. git checkout ${target} && git merge --no-ff ${branch}`);
416
+ } else if (alreadyMerged) {
417
+ info(' 1. 跳过 merge(apply 分支已合入集成分支)');
418
+ }
419
+ info(` 2. 删除 worktree 内临时目录`);
420
+ info(` 3. git worktree remove ${worktreeDir}`);
421
+ info(` 4. git worktree prune`);
422
+ if (!keepBranch) info(` 5. git branch -d ${branch}`);
423
+ return;
424
+ }
425
+
426
+ let mergeCommit = null;
427
+
428
+ if (!skipMerge) {
429
+ if (alreadyMerged) {
430
+ info(`⏭ apply 分支已合入 ${target},跳过 merge`);
431
+ mergeCommit = gitTry(repoRoot, ['rev-parse', target]);
432
+ } else {
433
+ const current = git(repoRoot, ['branch', '--show-current'], { silent: true });
434
+ if (current !== target) {
435
+ git(repoRoot, ['checkout', target]);
436
+ }
437
+ try {
438
+ gitThrow(repoRoot, [
439
+ 'merge',
440
+ '--no-ff',
441
+ branch,
442
+ '-m',
443
+ `merge(apply): ${change}/${capability}`,
444
+ ]);
445
+ mergeCommit = git(repoRoot, ['rev-parse', 'HEAD'], { silent: true });
446
+ info(`✓ 已合并到 ${target} (${mergeCommit.slice(0, 7)})`);
447
+ } catch (err) {
448
+ fail(
449
+ `merge 失败(可能存在冲突,请在主仓库解决后重试或 --skip-merge):\n${err.message || err}`
450
+ );
451
+ }
452
+ }
453
+ } else {
454
+ info('⏭ 跳过 merge (--skip-merge)');
455
+ }
456
+
457
+ rmTempDirs(worktreeDir, false);
458
+
459
+ try {
460
+ git(repoRoot, ['worktree', 'remove', worktreeDir]);
461
+ info(`✓ 已移除 worktree: ${path.relative(repoRoot, worktreeDir)}`);
462
+ } catch (err) {
463
+ if (forceRemove) {
464
+ try {
465
+ git(repoRoot, ['worktree', 'remove', '--force', worktreeDir]);
466
+ info(`✓ 已强制移除 worktree: ${path.relative(repoRoot, worktreeDir)}`);
467
+ } catch (err2) {
468
+ fail(`worktree remove 失败: ${err2.message || err2}`);
469
+ }
470
+ } else {
471
+ fail(
472
+ `worktree remove 失败: ${err.message || err}\n` +
473
+ ' 若目录仍被占用可重试并加 --force-worktree-remove'
474
+ );
475
+ }
476
+ }
477
+
478
+ gitTry(repoRoot, ['worktree', 'prune']);
479
+
480
+ if (!keepBranch) {
481
+ try {
482
+ git(repoRoot, ['branch', '-d', branch]);
483
+ info(`✓ 已删除分支: ${branch}`);
484
+ } catch (err) {
485
+ info(`⚠️ 分支删除失败(可手动 git branch -D ${branch}): ${err.message || err}`);
486
+ }
487
+ } else {
488
+ info(`⏭ 保留分支 (--keep-branch): ${branch}`);
489
+ }
490
+
491
+ const finishedAt = new Date().toISOString();
492
+ const nextState = {
493
+ ...(state || {}),
494
+ change,
495
+ capability,
496
+ integration_base: target,
497
+ apply_branch: branch,
498
+ worktree_dir: worktreeDir,
499
+ finished_at: finishedAt,
500
+ merge_commit: mergeCommit,
501
+ worktree_removed: true,
502
+ branch_deleted: !keepBranch,
503
+ };
504
+ writeApplyState(repoRoot, change, capability, nextState);
505
+
506
+ const summary = {
507
+ change,
508
+ capability,
509
+ integration_base: target,
510
+ branch,
511
+ merge_commit: mergeCommit,
512
+ worktree_removed: true,
513
+ branch_deleted: !keepBranch,
514
+ skipped_merge: alreadyMerged,
515
+ };
516
+
517
+ info('\n✅ Apply worktree 收尾完成');
518
+ info(JSON.stringify(summary));
519
+ }
520
+
521
+ function main() {
522
+ const args = parseArgs(process.argv);
523
+ if (args.help || args.h) {
524
+ showHelp();
525
+ return;
526
+ }
527
+
528
+ if (args['record-base'] || args.recordBase) {
529
+ cmdRecordBase(args);
530
+ return;
531
+ }
532
+
533
+ cmdFinish(args);
534
+ }
535
+
536
+ if (require.main === module) {
537
+ main();
538
+ }
539
+
540
+ module.exports = {
541
+ main,
542
+ parseArgs,
543
+ resolveWorktreeDir,
544
+ readApplyState,
545
+ writeApplyState,
546
+ resolveMergeTarget,
547
+ branchExists,
548
+ branchRef,
549
+ stateFilePath,
550
+ listGitWorktrees,
551
+ };
@@ -2264,6 +2264,7 @@ function cmdRecord(args) {
2264
2264
  'survey_result',
2265
2265
  'baseline_record',
2266
2266
  'telemetry_warning',
2267
+ 'worktree_finish',
2267
2268
  ];
2268
2269
 
2269
2270
  if (!type) {
@@ -2640,6 +2641,11 @@ SDD Telemetry CLI - 流程度量采集工具
2640
2641
  node skywalk-sdd/log.cjs doctor --project=/my/project --change=user-auth
2641
2642
  node skywalk-sdd/log.cjs tasks-status --project=/my/project --change=user-auth --require-complete
2642
2643
  node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive --report-output=skywalk-sdd/reports/user-auth-report.md
2644
+ node skywalk-sdd/log.cjs record --type=worktree_finish --command=apply --project=/my/project --change=user-auth --capability=user-auth --result=success --summary="merge+remove completed"
2645
+
2646
+ Apply worktree(主仓库根目录,非 log.cjs 子命令):
2647
+ node skywalk-sdd/apply-worktree-finish.cjs --record-base --change=<name> --capability=<name>
2648
+ node skywalk-sdd/apply-worktree-finish.cjs --change=<name> --capability=<name> [--target=<integration-base>] [--dry-run]
2643
2649
  `);
2644
2650
  }
2645
2651