@jspg-ai/coding-bb 0.0.4 → 0.0.5-beta.2

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/README.md CHANGED
@@ -32,6 +32,7 @@ cbb setup
32
32
  | 文档 | 说明 |
33
33
  |------|------|
34
34
  | [快速开始](docs/快速开始.md) | 安装、初始化、更新、卸载完整指南 |
35
+ | [新增业务空间](docs/新增业务空间.md) | 业务模块负责人登记与维护者发布流程(操作手册) |
35
36
  | [CLI 参考](docs/CLI参考.md) | CLI 命令一览(人用 / Agent 用 / 自动) |
36
37
  | [Skills 参考](docs/Skills参考.md) | 编码规范与 OpenSpec 工作流命令一览 |
37
38
  | [开发工作流](docs/开发工作流.md) | OpenSpec 工作流完整说明 |
@@ -10,20 +10,24 @@ require('./silence-popup');
10
10
  * "由 agent 直接完成合并"通道。
11
11
  *
12
12
  * 用法:
13
- * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--allow-checkout] [--dry-run]
13
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--dry-run]
14
14
  * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
15
15
  *
16
- * 目标分支:
17
- * 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push)
18
- * --target 指定非主干分支 → 临时 worktree(基于 origin/<target>,不动用户检出)
16
+ * 执行模型(统一"零接触"):
17
+ * 目标 = 缺省探测主干或 --target 指定;每仓库在**临时 worktree**(基于 origin/<目标>,
18
+ * 远端缺失时退回本地 <目标>)中 merge --no-ff → push origin HEAD:<目标> → 清理。
19
+ * 合并全程不触碰本地检出:被其他需求占用(本地模式进行中)、有未提交改动(含未跟踪)
20
+ * 均不影响,不产生 stash、不切分支、不改本地主干 ref。代价:本地主干检出不随交付前移,
21
+ * 由下次 pull 追平(基准 = origin/<主干>,ADR 0002)。
19
22
  *
20
23
  * 模式:
21
24
  * --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
22
25
  * 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
23
26
  * 默认 两阶段(ADR 0007):
24
- * 1. 预检:逐仓库校验(目标可解析 / 检出干净 / 分支存在)
27
+ * 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 目标分支存在)
25
28
  * 任一不过 → 退出 2,不执行任何合并
26
- * 2. 执行:merge --no-ff → push;执行中失败 → 停止并报告已完成清单(不回滚;重跑幂等)
29
+ * 2. 执行:临时 worktree 合并 → push;执行中失败 → 停止并报告已完成清单
30
+ * (不回滚;重跑幂等)
27
31
  * --dry-run 只做预检,不执行
28
32
  *
29
33
  * 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
@@ -147,7 +151,11 @@ function main() {
147
151
  const branch = args[1];
148
152
 
149
153
  if (!wsRoot || !branch || branch.indexOf('--') === 0) {
150
- console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--allow-checkout] [--dry-run] [--check]');
154
+ console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--dry-run] [--check]');
155
+ process.exit(1);
156
+ }
157
+ if (args.includes('--allow-checkout')) {
158
+ console.error('--allow-checkout 已退役:合并统一走临时 worktree,不触碰本地检出');
151
159
  process.exit(1);
152
160
  }
153
161
 
@@ -161,7 +169,6 @@ function main() {
161
169
  console.error('--target 需要提供分支名');
162
170
  process.exit(1);
163
171
  }
164
- const allowCheckout = args.includes('--allow-checkout');
165
172
  const dryRun = args.includes('--dry-run');
166
173
  const checkOnly = args.includes('--check');
167
174
 
@@ -209,7 +216,7 @@ function main() {
209
216
  // ── 阶段 1:预检 ──────────────────────────────────────────
210
217
  const prechecks = [];
211
218
  for (const rp of repos) {
212
- const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
219
+ const rec = { name: rp.name, status: 'ok', main: null, target: null, reason: null };
213
220
  if (!fs.existsSync(path.join(rp.repo, '.git'))) {
214
221
  rec.status = 'skipped';
215
222
  rec.reason = 'no-repo';
@@ -249,55 +256,15 @@ function main() {
249
256
  continue;
250
257
  }
251
258
 
252
- if (target === main) {
253
- // 主干路径(快路径,与 ADR 0007 收尾门禁一致)
254
- if (mergedToMain(rp.repo, branch, main)) {
255
- rec.status = 'skipped';
256
- rec.reason = 'already-merged';
257
- prechecks.push(rec);
258
- continue;
259
- }
260
- const status = run('git status --porcelain', rp.work);
261
- if (!status.ok || status.out) {
262
- rec.status = 'failed';
263
- rec.reason = 'dirty-worktree';
264
- prechecks.push(rec);
265
- continue;
266
- }
267
- const current = run('git branch --show-current', rp.work);
268
- if (current.out && current.out !== main && !allowCheckout) {
269
- rec.status = 'failed';
270
- rec.reason = 'not-on-main';
271
- prechecks.push(rec);
272
- continue;
273
- }
274
- const ahead = run('git rev-list --count "origin/' + main + '..' + main + '"', rp.repo);
275
- if (ahead.ok && ahead.out && parseInt(ahead.out, 10) > 0) {
276
- rec.status = 'failed';
277
- rec.reason = 'main-ahead-of-origin';
278
- prechecks.push(rec);
279
- continue;
280
- }
281
- } else {
282
- // 指定分支路径:已合并则跳过;当前检出即目标且干净 → 就地;否则临时 worktree
283
- if (mergedToTarget(rp.repo, branch, target)) {
284
- rec.status = 'skipped';
285
- rec.reason = 'already-merged';
286
- prechecks.push(rec);
287
- continue;
288
- }
289
- const current = run('git branch --show-current', rp.work);
290
- if (current.out === target) {
291
- const status = run('git status --porcelain', rp.work);
292
- if (!status.ok || status.out) {
293
- rec.status = 'failed';
294
- rec.reason = 'dirty-worktree';
295
- prechecks.push(rec);
296
- continue;
297
- }
298
- } else {
299
- rec.temp = true; // 执行阶段创建临时 worktree
300
- }
259
+ // 合并一律走临时 worktree(零接触本地检出):预检不看检出分支与脏状态
260
+ const merged = (!targetArg || target === main)
261
+ ? mergedToMain(rp.repo, branch, main)
262
+ : mergedToTarget(rp.repo, branch, target);
263
+ if (merged) {
264
+ rec.status = 'skipped';
265
+ rec.reason = 'already-merged';
266
+ prechecks.push(rec);
267
+ continue;
301
268
  }
302
269
  prechecks.push(rec);
303
270
  }
@@ -306,7 +273,7 @@ function main() {
306
273
  prechecks.forEach(p => {
307
274
  if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
308
275
  else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
309
- else console.log('OK ' + p.name + ' → 合并至 ' + p.target + (p.temp ? '(临时 worktree)' : ''));
276
+ else console.log('OK ' + p.name + ' → 合并至 ' + p.target + '(临时 worktree,不碰本地检出)');
310
277
  });
311
278
 
312
279
  if (hasPrecheckFailure) {
@@ -337,86 +304,20 @@ function main() {
337
304
  const target = p.target;
338
305
  const mergeMsg = "Merge branch '" + branch + "'";
339
306
 
340
- if (target === p.main) {
341
- // ── 主干路径(快路径) ──
342
- const current = run('git branch --show-current', rp.work);
343
- if (current.out !== target) {
344
- const co = run('git checkout --quiet "' + target + '"', rp.work);
345
- if (!co.ok) {
346
- results[i].status = 'failed';
347
- results[i].detail = 'checkout-failed: ' + co.err.split(/\r?\n/)[0];
348
- stopped = true;
349
- continue;
350
- }
351
- }
352
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
353
- if (!pull.ok) {
354
- results[i].status = 'failed';
355
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
356
- stopped = true;
357
- continue;
358
- }
359
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
360
- if (!merge.ok) {
361
- run('git merge --abort', rp.repo);
362
- results[i].status = 'failed';
363
- results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
364
- stopped = true;
365
- continue;
366
- }
367
- let push = run('git push origin "' + target + '"', rp.repo);
368
- if (!push.ok) {
369
- const code = classifyPushError(push.err);
370
- if (code === 'non-fast-forward') {
371
- const retryPull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
372
- push = retryPull.ok ? run('git push origin "' + target + '"', rp.repo) : retryPull;
373
- }
374
- if (!push.ok) {
375
- const code2 = classifyPushError(push.err);
376
- if (code2 === 'branch-protected') {
377
- results[i].status = 'need-mr';
378
- results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
379
- } else {
380
- results[i].status = 'failed';
381
- results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
382
- }
383
- stopped = true;
384
- continue;
385
- }
386
- }
387
- results[i].status = 'merged';
388
- results[i].detail = 'merged+pushed origin/' + target;
307
+ // ── 统一临时 worktree:基于 origin/<target>(远端缺失退回本地 <target>),零接触本地检出 ──
308
+ const tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
309
+ const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
310
+ const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
311
+ if (!add.ok) {
312
+ results[i].status = 'failed';
313
+ results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
314
+ stopped = true;
389
315
  continue;
390
316
  }
391
317
 
392
- // ── 指定分支路径(当前检出即目标 → 就地;否则临时 worktree) ──
393
- let workDir = rp.work;
394
- let tmpDir = null;
395
- const current = run('git branch --show-current', rp.work);
396
- if (current.out !== target) {
397
- tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
398
- const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
399
- const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
400
- if (!add.ok) {
401
- results[i].status = 'failed';
402
- results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
403
- stopped = true;
404
- continue;
405
- }
406
- workDir = tmpDir;
407
- } else {
408
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
409
- if (!pull.ok) {
410
- results[i].status = 'failed';
411
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
412
- stopped = true;
413
- continue;
414
- }
415
- }
416
-
417
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
318
+ const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
418
319
  if (!merge.ok) {
419
- run('git merge --abort', workDir);
320
+ run('git merge --abort', tmpDir);
420
321
  cleanupTempWorktree(rp.repo, tmpDir);
421
322
  results[i].status = 'failed';
422
323
  results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
@@ -424,15 +325,15 @@ function main() {
424
325
  continue;
425
326
  }
426
327
 
427
- let push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
328
+ let push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
428
329
  if (!push.ok) {
429
330
  const code = classifyPushError(push.err);
430
331
  if (code === 'non-fast-forward') {
431
332
  // 远端已前进:在合并工作区 ff 同步后重试一次
432
- const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', workDir);
333
+ const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', tmpDir);
433
334
  if (retry.ok) {
434
- const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
435
- if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
335
+ const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
336
+ if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
436
337
  }
437
338
  }
438
339
  if (!push.ok) {
@@ -451,7 +352,7 @@ function main() {
451
352
  }
452
353
  cleanupTempWorktree(rp.repo, tmpDir);
453
354
  results[i].status = 'merged';
454
- results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
355
+ results[i].detail = 'merged+pushed origin/' + target + '(临时 worktree,本地检出一分未动)';
455
356
  }
456
357
 
457
358
  results.forEach(r => {
@@ -15,7 +15,7 @@ description: "业务空间本地模式开发:空间根与各涉及应用在 .c
15
15
  - **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**:本地模式暂态占用视图合法;main 检出是基准快照,**严禁直接在 main 检出上提交**(pre-commit hook 会拦)
16
16
  - **一次只承载一个需求**:本地模式独占空间根与各 app 的本地检出;期间并行新需求只能走 worktree 模式(两模式互不干扰,worktree 分支从 `origin/main` 建,不依赖本地检出状态)
17
17
  - **提交防线在 hook**:分支感知 pre-commit hook 拦截一切 main 上的载荷提交(workspace 白名单外 / 应用全拦;merge 提交放行);被拦 = 大声失败,按指引回分支,**禁止自动 `--no-verify` 绕过**
18
- - **收尾合并门禁(ADR 0007,与 worktree 模式对称)**:收尾前每个参与仓库必须为"已合并",未合并时三选一——**提 MR** / **直合并**(`merge-branches.js --allow-checkout`)/ **确认废弃**(远程重命名 `<需求名>-deprecated`)。**硬门禁:未合并且非废弃一律不允许收尾**
18
+ - **收尾合并门禁(ADR 0007,与 worktree 模式对称)**:收尾前每个参与仓库必须为"已合并",未合并时三选一——**提 MR** / **直合并**(`merge-branches.js`,临时 worktree 零接触)/ **确认废弃**(远程重命名 `<需求名>-deprecated`)。**硬门禁:未合并且非废弃一律不允许收尾**
19
19
  - 所有原子操作封装为 `scripts/*.js`(跨平台 Node.js),调用方式与 worktree 三件套一致:`node "<skill_dir>/scripts/<name>.js" <参数>`,cwd 保持空间根
20
20
 
21
21
  ---
@@ -95,10 +95,10 @@ node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<
95
95
  **5.3 门禁三选一**(`AskUserQuestion`,同 close Step 3.4):
96
96
 
97
97
  - **提 MR** → **中止收尾**,引导用户运行 `cbb-worktree-push`(说"提交并推送")生成 MR 预填链接(其 Step 7:目标分支候选交互 + `make-mr-urls.js`)→ 合并完重跑本步
98
- - **直合并** → `merge-branches.js`(本地检出被需求分支占用,**必须加 `--allow-checkout`** 切回主干):
98
+ - **直合并** → `merge-branches.js`(临时 worktree 合并,**不碰本地检出**——被需求分支占用也无需处理;交付后本地主干检出不随之前移,由 5.4 的 checkout main + pull 追平):
99
99
 
100
100
  ```bash
101
- node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --allow-checkout
101
+ node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>"
102
102
  ```
103
103
 
104
104
  `need-mr`(远端主干受保护)→ 改用提 MR;`failed` → 输出明细、修复后重跑
@@ -136,7 +136,7 @@ node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_re
136
136
  | commit 被 hook 拦(main 载荷) | 大声报错是预期行为:回分支重做;禁止自动 --no-verify |
137
137
  | 误在 main 检出上写了文件 | `git checkout -b <需求名>` 后改动随分支走;已 staged 则 unstage 后再切 |
138
138
  | 提交推送 | 说"提交并推送" → cbb-worktree-push(本地布局自动发现) |
139
- | 收尾 | 收尾合并门禁(ADR 0007):`merge-branches.js --check` 判已合并 → 全合并则各仓库 checkout main + pull + 删分支;否则三选一(MR / 直合并 `--allow-checkout` / 确认废弃) |
139
+ | 收尾 | 收尾合并门禁(ADR 0007):`merge-branches.js --check` 判已合并 → 全合并则各仓库 checkout main + pull + 删分支;否则三选一(MR / 直合并(临时 worktree) / 确认废弃) |
140
140
  | 要并行新需求 | 转 worktree 模式(init 分支从 origin/main 建,不占本地检出);本地需求继续占用根 |
141
141
  | 升级隔离 | 重跑 cbb-worktree-init(提交随身带走);先回 main 腾检出 |
142
142
 
@@ -189,6 +189,6 @@ node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_re
189
189
  - 布局展示 + 用户确认后才切分支
190
190
  - 切分支统一走 `start-local.js`(基线 = `<remote>/<main>`,不取 HEAD)
191
191
  - 收尾先过门禁判定(`merge-branches.js --check`);未合并必须三选一后收尾
192
- - 直合并走 `merge-branches.js --allow-checkout`(本地检出需从需求分支切回主干)
192
+ - 直合并走 `merge-branches.js`(临时 worktree,零接触;本地检出由 5.4 收尾三连切回主干)
193
193
  - 收尾三连(checkout main / pull / 删分支)完成后报告干净态
194
194
  - 所有写操作前的分支确认(当前检出 = 需求分支)
@@ -10,20 +10,24 @@ require('./silence-popup');
10
10
  * "由 agent 直接完成合并"通道。
11
11
  *
12
12
  * 用法:
13
- * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--allow-checkout] [--dry-run]
13
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--dry-run]
14
14
  * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
15
15
  *
16
- * 目标分支:
17
- * 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push)
18
- * --target 指定非主干分支 → 临时 worktree(基于 origin/<target>,不动用户检出)
16
+ * 执行模型(统一"零接触"):
17
+ * 目标 = 缺省探测主干或 --target 指定;每仓库在**临时 worktree**(基于 origin/<目标>,
18
+ * 远端缺失时退回本地 <目标>)中 merge --no-ff → push origin HEAD:<目标> → 清理。
19
+ * 合并全程不触碰本地检出:被其他需求占用(本地模式进行中)、有未提交改动(含未跟踪)
20
+ * 均不影响,不产生 stash、不切分支、不改本地主干 ref。代价:本地主干检出不随交付前移,
21
+ * 由下次 pull 追平(基准 = origin/<主干>,ADR 0002)。
19
22
  *
20
23
  * 模式:
21
24
  * --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
22
25
  * 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
23
26
  * 默认 两阶段(ADR 0007):
24
- * 1. 预检:逐仓库校验(目标可解析 / 检出干净 / 分支存在)
27
+ * 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 目标分支存在)
25
28
  * 任一不过 → 退出 2,不执行任何合并
26
- * 2. 执行:merge --no-ff → push;执行中失败 → 停止并报告已完成清单(不回滚;重跑幂等)
29
+ * 2. 执行:临时 worktree 合并 → push;执行中失败 → 停止并报告已完成清单
30
+ * (不回滚;重跑幂等)
27
31
  * --dry-run 只做预检,不执行
28
32
  *
29
33
  * 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
@@ -147,7 +151,11 @@ function main() {
147
151
  const branch = args[1];
148
152
 
149
153
  if (!wsRoot || !branch || branch.indexOf('--') === 0) {
150
- console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--allow-checkout] [--dry-run] [--check]');
154
+ console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--dry-run] [--check]');
155
+ process.exit(1);
156
+ }
157
+ if (args.includes('--allow-checkout')) {
158
+ console.error('--allow-checkout 已退役:合并统一走临时 worktree,不触碰本地检出');
151
159
  process.exit(1);
152
160
  }
153
161
 
@@ -161,7 +169,6 @@ function main() {
161
169
  console.error('--target 需要提供分支名');
162
170
  process.exit(1);
163
171
  }
164
- const allowCheckout = args.includes('--allow-checkout');
165
172
  const dryRun = args.includes('--dry-run');
166
173
  const checkOnly = args.includes('--check');
167
174
 
@@ -209,7 +216,7 @@ function main() {
209
216
  // ── 阶段 1:预检 ──────────────────────────────────────────
210
217
  const prechecks = [];
211
218
  for (const rp of repos) {
212
- const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
219
+ const rec = { name: rp.name, status: 'ok', main: null, target: null, reason: null };
213
220
  if (!fs.existsSync(path.join(rp.repo, '.git'))) {
214
221
  rec.status = 'skipped';
215
222
  rec.reason = 'no-repo';
@@ -249,55 +256,15 @@ function main() {
249
256
  continue;
250
257
  }
251
258
 
252
- if (target === main) {
253
- // 主干路径(快路径,与 ADR 0007 收尾门禁一致)
254
- if (mergedToMain(rp.repo, branch, main)) {
255
- rec.status = 'skipped';
256
- rec.reason = 'already-merged';
257
- prechecks.push(rec);
258
- continue;
259
- }
260
- const status = run('git status --porcelain', rp.work);
261
- if (!status.ok || status.out) {
262
- rec.status = 'failed';
263
- rec.reason = 'dirty-worktree';
264
- prechecks.push(rec);
265
- continue;
266
- }
267
- const current = run('git branch --show-current', rp.work);
268
- if (current.out && current.out !== main && !allowCheckout) {
269
- rec.status = 'failed';
270
- rec.reason = 'not-on-main';
271
- prechecks.push(rec);
272
- continue;
273
- }
274
- const ahead = run('git rev-list --count "origin/' + main + '..' + main + '"', rp.repo);
275
- if (ahead.ok && ahead.out && parseInt(ahead.out, 10) > 0) {
276
- rec.status = 'failed';
277
- rec.reason = 'main-ahead-of-origin';
278
- prechecks.push(rec);
279
- continue;
280
- }
281
- } else {
282
- // 指定分支路径:已合并则跳过;当前检出即目标且干净 → 就地;否则临时 worktree
283
- if (mergedToTarget(rp.repo, branch, target)) {
284
- rec.status = 'skipped';
285
- rec.reason = 'already-merged';
286
- prechecks.push(rec);
287
- continue;
288
- }
289
- const current = run('git branch --show-current', rp.work);
290
- if (current.out === target) {
291
- const status = run('git status --porcelain', rp.work);
292
- if (!status.ok || status.out) {
293
- rec.status = 'failed';
294
- rec.reason = 'dirty-worktree';
295
- prechecks.push(rec);
296
- continue;
297
- }
298
- } else {
299
- rec.temp = true; // 执行阶段创建临时 worktree
300
- }
259
+ // 合并一律走临时 worktree(零接触本地检出):预检不看检出分支与脏状态
260
+ const merged = (!targetArg || target === main)
261
+ ? mergedToMain(rp.repo, branch, main)
262
+ : mergedToTarget(rp.repo, branch, target);
263
+ if (merged) {
264
+ rec.status = 'skipped';
265
+ rec.reason = 'already-merged';
266
+ prechecks.push(rec);
267
+ continue;
301
268
  }
302
269
  prechecks.push(rec);
303
270
  }
@@ -306,7 +273,7 @@ function main() {
306
273
  prechecks.forEach(p => {
307
274
  if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
308
275
  else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
309
- else console.log('OK ' + p.name + ' → 合并至 ' + p.target + (p.temp ? '(临时 worktree)' : ''));
276
+ else console.log('OK ' + p.name + ' → 合并至 ' + p.target + '(临时 worktree,不碰本地检出)');
310
277
  });
311
278
 
312
279
  if (hasPrecheckFailure) {
@@ -337,86 +304,20 @@ function main() {
337
304
  const target = p.target;
338
305
  const mergeMsg = "Merge branch '" + branch + "'";
339
306
 
340
- if (target === p.main) {
341
- // ── 主干路径(快路径) ──
342
- const current = run('git branch --show-current', rp.work);
343
- if (current.out !== target) {
344
- const co = run('git checkout --quiet "' + target + '"', rp.work);
345
- if (!co.ok) {
346
- results[i].status = 'failed';
347
- results[i].detail = 'checkout-failed: ' + co.err.split(/\r?\n/)[0];
348
- stopped = true;
349
- continue;
350
- }
351
- }
352
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
353
- if (!pull.ok) {
354
- results[i].status = 'failed';
355
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
356
- stopped = true;
357
- continue;
358
- }
359
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
360
- if (!merge.ok) {
361
- run('git merge --abort', rp.repo);
362
- results[i].status = 'failed';
363
- results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
364
- stopped = true;
365
- continue;
366
- }
367
- let push = run('git push origin "' + target + '"', rp.repo);
368
- if (!push.ok) {
369
- const code = classifyPushError(push.err);
370
- if (code === 'non-fast-forward') {
371
- const retryPull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
372
- push = retryPull.ok ? run('git push origin "' + target + '"', rp.repo) : retryPull;
373
- }
374
- if (!push.ok) {
375
- const code2 = classifyPushError(push.err);
376
- if (code2 === 'branch-protected') {
377
- results[i].status = 'need-mr';
378
- results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
379
- } else {
380
- results[i].status = 'failed';
381
- results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
382
- }
383
- stopped = true;
384
- continue;
385
- }
386
- }
387
- results[i].status = 'merged';
388
- results[i].detail = 'merged+pushed origin/' + target;
307
+ // ── 统一临时 worktree:基于 origin/<target>(远端缺失退回本地 <target>),零接触本地检出 ──
308
+ const tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
309
+ const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
310
+ const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
311
+ if (!add.ok) {
312
+ results[i].status = 'failed';
313
+ results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
314
+ stopped = true;
389
315
  continue;
390
316
  }
391
317
 
392
- // ── 指定分支路径(当前检出即目标 → 就地;否则临时 worktree) ──
393
- let workDir = rp.work;
394
- let tmpDir = null;
395
- const current = run('git branch --show-current', rp.work);
396
- if (current.out !== target) {
397
- tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
398
- const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
399
- const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
400
- if (!add.ok) {
401
- results[i].status = 'failed';
402
- results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
403
- stopped = true;
404
- continue;
405
- }
406
- workDir = tmpDir;
407
- } else {
408
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
409
- if (!pull.ok) {
410
- results[i].status = 'failed';
411
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
412
- stopped = true;
413
- continue;
414
- }
415
- }
416
-
417
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
318
+ const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
418
319
  if (!merge.ok) {
419
- run('git merge --abort', workDir);
320
+ run('git merge --abort', tmpDir);
420
321
  cleanupTempWorktree(rp.repo, tmpDir);
421
322
  results[i].status = 'failed';
422
323
  results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
@@ -424,15 +325,15 @@ function main() {
424
325
  continue;
425
326
  }
426
327
 
427
- let push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
328
+ let push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
428
329
  if (!push.ok) {
429
330
  const code = classifyPushError(push.err);
430
331
  if (code === 'non-fast-forward') {
431
332
  // 远端已前进:在合并工作区 ff 同步后重试一次
432
- const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', workDir);
333
+ const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', tmpDir);
433
334
  if (retry.ok) {
434
- const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
435
- if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
335
+ const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
336
+ if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
436
337
  }
437
338
  }
438
339
  if (!push.ok) {
@@ -451,7 +352,7 @@ function main() {
451
352
  }
452
353
  cleanupTempWorktree(rp.repo, tmpDir);
453
354
  results[i].status = 'merged';
454
- results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
355
+ results[i].detail = 'merged+pushed origin/' + target + '(临时 worktree,本地检出一分未动)';
455
356
  }
456
357
 
457
358
  results.forEach(r => {
@@ -282,12 +282,13 @@ AskUserQuestion({
282
282
  node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<branch>" --apps "<app1,app2>"
283
283
  ```
284
284
 
285
- - 两阶段:预检(主干可探测 / 主干检出干净且不领先 origin / 分支存在;全过才执行)→ 执行(checkout 主干 → pull --ff-only → merge --no-ff → push origin 主干)
285
+ - 两阶段:预检(主干可探测 / 分支存在 / 目标分支存在;**不再校验检出状态**)→ 执行(**临时 worktree**(基于 `origin/主干`)中 `merge --no-ff` → `push origin HEAD:主干` → 清理;**全程不碰本地检出**——被其他需求占用、有未提交改动均不影响)
286
286
  - 解读末行 `MERGE_RESULT`:
287
287
  - 退出码 0 → 交付完成,继续 Step 4
288
288
  - `need-mr`(`branch-protected`:远端主干受保护)→ 远端保护是常态,**改用出口一**(不是故障)
289
289
  - `failed`(含 `merge-failed` / `push-failed`)→ 输出失败明细,阻塞并询问用户(修复后重跑;重跑幂等)
290
- - `failed` 且 `not-on-main`(空间根检出不是主干:被其他需求占用或异常)→ 先让该占用回到主干(收尾本地需求)或改用出口一;**不要用 `--allow-checkout`**(会切走占用者的工作区)
290
+
291
+ > ⓘ 合并零接触的语义:本地主干检出不随交付前移(交付以 `origin/主干` 为准,ADR 0002),由本地模式收尾 / 下次开工自检时 `pull` 追平;本地检出上正被其他需求占用或有未提交改动都**不阻断、不暂存、不切换**。
291
292
 
292
293
  **出口三 · 确认废弃(`deprecate-branches.js`)**
293
294
 
@@ -506,5 +507,5 @@ git -C "<ws_root>" worktree list
506
507
  | `remove-worktrees.js` | 移除 worktree(先拆 junction,三层降级 + 残留验证) | ws_root, branch, force | 0/1 |
507
508
  | `junction-ai-config.js` | AI 配置 junction 创建 / `--remove` 拆除(共享脚本,源仓库真源在 `_shared/scripts/`) | ws_root, wt_path, [--remove] | 0/1/2 |
508
509
  | `delete-branches.js` | 删除分支(按 mode;交付完成后由 Step 4.4 自动以 local_remote 调用) | ws_root, branch, mode | 0/1/2 |
509
- | `merge-branches.js` | 收尾合并门禁·直合并(预检 → merge --no-ff → push 主干,共享脚本,真源在 `_shared/scripts/`) | ws_root, branch, --apps / --target / --allow-checkout / --dry-run / --check | 0/1/2 |
510
+ | `merge-branches.js` | 收尾合并门禁·直合并(临时 worktree 合并 → push 目标;零接触本地检出,共享脚本,真源在 `_shared/scripts/`) | ws_root, branch, --apps / --target / --dry-run / --check | 0/1/2 |
510
511
  | `deprecate-branches.js` | 收尾合并门禁·废弃(远程分支重命名为 `<分支>-deprecated`,共享脚本) | ws_root, branch, --apps | 0/1/2 |
@@ -10,20 +10,24 @@ require('./silence-popup');
10
10
  * "由 agent 直接完成合并"通道。
11
11
  *
12
12
  * 用法:
13
- * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--allow-checkout] [--dry-run]
13
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--dry-run]
14
14
  * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
15
15
  *
16
- * 目标分支:
17
- * 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push)
18
- * --target 指定非主干分支 → 临时 worktree(基于 origin/<target>,不动用户检出)
16
+ * 执行模型(统一"零接触"):
17
+ * 目标 = 缺省探测主干或 --target 指定;每仓库在**临时 worktree**(基于 origin/<目标>,
18
+ * 远端缺失时退回本地 <目标>)中 merge --no-ff → push origin HEAD:<目标> → 清理。
19
+ * 合并全程不触碰本地检出:被其他需求占用(本地模式进行中)、有未提交改动(含未跟踪)
20
+ * 均不影响,不产生 stash、不切分支、不改本地主干 ref。代价:本地主干检出不随交付前移,
21
+ * 由下次 pull 追平(基准 = origin/<主干>,ADR 0002)。
19
22
  *
20
23
  * 模式:
21
24
  * --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
22
25
  * 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
23
26
  * 默认 两阶段(ADR 0007):
24
- * 1. 预检:逐仓库校验(目标可解析 / 检出干净 / 分支存在)
27
+ * 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 目标分支存在)
25
28
  * 任一不过 → 退出 2,不执行任何合并
26
- * 2. 执行:merge --no-ff → push;执行中失败 → 停止并报告已完成清单(不回滚;重跑幂等)
29
+ * 2. 执行:临时 worktree 合并 → push;执行中失败 → 停止并报告已完成清单
30
+ * (不回滚;重跑幂等)
27
31
  * --dry-run 只做预检,不执行
28
32
  *
29
33
  * 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
@@ -147,7 +151,11 @@ function main() {
147
151
  const branch = args[1];
148
152
 
149
153
  if (!wsRoot || !branch || branch.indexOf('--') === 0) {
150
- console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--allow-checkout] [--dry-run] [--check]');
154
+ console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--dry-run] [--check]');
155
+ process.exit(1);
156
+ }
157
+ if (args.includes('--allow-checkout')) {
158
+ console.error('--allow-checkout 已退役:合并统一走临时 worktree,不触碰本地检出');
151
159
  process.exit(1);
152
160
  }
153
161
 
@@ -161,7 +169,6 @@ function main() {
161
169
  console.error('--target 需要提供分支名');
162
170
  process.exit(1);
163
171
  }
164
- const allowCheckout = args.includes('--allow-checkout');
165
172
  const dryRun = args.includes('--dry-run');
166
173
  const checkOnly = args.includes('--check');
167
174
 
@@ -209,7 +216,7 @@ function main() {
209
216
  // ── 阶段 1:预检 ──────────────────────────────────────────
210
217
  const prechecks = [];
211
218
  for (const rp of repos) {
212
- const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
219
+ const rec = { name: rp.name, status: 'ok', main: null, target: null, reason: null };
213
220
  if (!fs.existsSync(path.join(rp.repo, '.git'))) {
214
221
  rec.status = 'skipped';
215
222
  rec.reason = 'no-repo';
@@ -249,55 +256,15 @@ function main() {
249
256
  continue;
250
257
  }
251
258
 
252
- if (target === main) {
253
- // 主干路径(快路径,与 ADR 0007 收尾门禁一致)
254
- if (mergedToMain(rp.repo, branch, main)) {
255
- rec.status = 'skipped';
256
- rec.reason = 'already-merged';
257
- prechecks.push(rec);
258
- continue;
259
- }
260
- const status = run('git status --porcelain', rp.work);
261
- if (!status.ok || status.out) {
262
- rec.status = 'failed';
263
- rec.reason = 'dirty-worktree';
264
- prechecks.push(rec);
265
- continue;
266
- }
267
- const current = run('git branch --show-current', rp.work);
268
- if (current.out && current.out !== main && !allowCheckout) {
269
- rec.status = 'failed';
270
- rec.reason = 'not-on-main';
271
- prechecks.push(rec);
272
- continue;
273
- }
274
- const ahead = run('git rev-list --count "origin/' + main + '..' + main + '"', rp.repo);
275
- if (ahead.ok && ahead.out && parseInt(ahead.out, 10) > 0) {
276
- rec.status = 'failed';
277
- rec.reason = 'main-ahead-of-origin';
278
- prechecks.push(rec);
279
- continue;
280
- }
281
- } else {
282
- // 指定分支路径:已合并则跳过;当前检出即目标且干净 → 就地;否则临时 worktree
283
- if (mergedToTarget(rp.repo, branch, target)) {
284
- rec.status = 'skipped';
285
- rec.reason = 'already-merged';
286
- prechecks.push(rec);
287
- continue;
288
- }
289
- const current = run('git branch --show-current', rp.work);
290
- if (current.out === target) {
291
- const status = run('git status --porcelain', rp.work);
292
- if (!status.ok || status.out) {
293
- rec.status = 'failed';
294
- rec.reason = 'dirty-worktree';
295
- prechecks.push(rec);
296
- continue;
297
- }
298
- } else {
299
- rec.temp = true; // 执行阶段创建临时 worktree
300
- }
259
+ // 合并一律走临时 worktree(零接触本地检出):预检不看检出分支与脏状态
260
+ const merged = (!targetArg || target === main)
261
+ ? mergedToMain(rp.repo, branch, main)
262
+ : mergedToTarget(rp.repo, branch, target);
263
+ if (merged) {
264
+ rec.status = 'skipped';
265
+ rec.reason = 'already-merged';
266
+ prechecks.push(rec);
267
+ continue;
301
268
  }
302
269
  prechecks.push(rec);
303
270
  }
@@ -306,7 +273,7 @@ function main() {
306
273
  prechecks.forEach(p => {
307
274
  if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
308
275
  else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
309
- else console.log('OK ' + p.name + ' → 合并至 ' + p.target + (p.temp ? '(临时 worktree)' : ''));
276
+ else console.log('OK ' + p.name + ' → 合并至 ' + p.target + '(临时 worktree,不碰本地检出)');
310
277
  });
311
278
 
312
279
  if (hasPrecheckFailure) {
@@ -337,86 +304,20 @@ function main() {
337
304
  const target = p.target;
338
305
  const mergeMsg = "Merge branch '" + branch + "'";
339
306
 
340
- if (target === p.main) {
341
- // ── 主干路径(快路径) ──
342
- const current = run('git branch --show-current', rp.work);
343
- if (current.out !== target) {
344
- const co = run('git checkout --quiet "' + target + '"', rp.work);
345
- if (!co.ok) {
346
- results[i].status = 'failed';
347
- results[i].detail = 'checkout-failed: ' + co.err.split(/\r?\n/)[0];
348
- stopped = true;
349
- continue;
350
- }
351
- }
352
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
353
- if (!pull.ok) {
354
- results[i].status = 'failed';
355
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
356
- stopped = true;
357
- continue;
358
- }
359
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
360
- if (!merge.ok) {
361
- run('git merge --abort', rp.repo);
362
- results[i].status = 'failed';
363
- results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
364
- stopped = true;
365
- continue;
366
- }
367
- let push = run('git push origin "' + target + '"', rp.repo);
368
- if (!push.ok) {
369
- const code = classifyPushError(push.err);
370
- if (code === 'non-fast-forward') {
371
- const retryPull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
372
- push = retryPull.ok ? run('git push origin "' + target + '"', rp.repo) : retryPull;
373
- }
374
- if (!push.ok) {
375
- const code2 = classifyPushError(push.err);
376
- if (code2 === 'branch-protected') {
377
- results[i].status = 'need-mr';
378
- results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
379
- } else {
380
- results[i].status = 'failed';
381
- results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
382
- }
383
- stopped = true;
384
- continue;
385
- }
386
- }
387
- results[i].status = 'merged';
388
- results[i].detail = 'merged+pushed origin/' + target;
307
+ // ── 统一临时 worktree:基于 origin/<target>(远端缺失退回本地 <target>),零接触本地检出 ──
308
+ const tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
309
+ const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
310
+ const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
311
+ if (!add.ok) {
312
+ results[i].status = 'failed';
313
+ results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
314
+ stopped = true;
389
315
  continue;
390
316
  }
391
317
 
392
- // ── 指定分支路径(当前检出即目标 → 就地;否则临时 worktree) ──
393
- let workDir = rp.work;
394
- let tmpDir = null;
395
- const current = run('git branch --show-current', rp.work);
396
- if (current.out !== target) {
397
- tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
398
- const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
399
- const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
400
- if (!add.ok) {
401
- results[i].status = 'failed';
402
- results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
403
- stopped = true;
404
- continue;
405
- }
406
- workDir = tmpDir;
407
- } else {
408
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
409
- if (!pull.ok) {
410
- results[i].status = 'failed';
411
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
412
- stopped = true;
413
- continue;
414
- }
415
- }
416
-
417
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
318
+ const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
418
319
  if (!merge.ok) {
419
- run('git merge --abort', workDir);
320
+ run('git merge --abort', tmpDir);
420
321
  cleanupTempWorktree(rp.repo, tmpDir);
421
322
  results[i].status = 'failed';
422
323
  results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
@@ -424,15 +325,15 @@ function main() {
424
325
  continue;
425
326
  }
426
327
 
427
- let push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
328
+ let push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
428
329
  if (!push.ok) {
429
330
  const code = classifyPushError(push.err);
430
331
  if (code === 'non-fast-forward') {
431
332
  // 远端已前进:在合并工作区 ff 同步后重试一次
432
- const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', workDir);
333
+ const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', tmpDir);
433
334
  if (retry.ok) {
434
- const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
435
- if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
335
+ const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
336
+ if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
436
337
  }
437
338
  }
438
339
  if (!push.ok) {
@@ -451,7 +352,7 @@ function main() {
451
352
  }
452
353
  cleanupTempWorktree(rp.repo, tmpDir);
453
354
  results[i].status = 'merged';
454
- results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
355
+ results[i].detail = 'merged+pushed origin/' + target + '(临时 worktree,本地检出一分未动)';
455
356
  }
456
357
 
457
358
  results.forEach(r => {
@@ -337,14 +337,13 @@ node "<skill_dir>/scripts/check-unarchived.js" "<ws_root>" "<branch>"
337
337
  node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<branch>" --apps "<app1,app2>" --target "<目标>"
338
338
  ```
339
339
 
340
- - 缺省 / `--target` 等于探测主干 → 主干快路径(本地主干检出上 `merge --no-ff` → push)
341
- - `--target` 为非主干分支 → **临时 worktree** 合并(基于 `origin/<目标>`,不动用户检出)
340
+ - 统一**临时 worktree** 通道(零接触本地检出):基于 `origin/<目标>`(远端缺失退回本地 `<目标>`)`merge --no-ff` → `push origin HEAD:<目标>` → 清理;主干与测试分支同一条路径
341
+ - 本地检出被其他需求占用(本地模式进行中)、有未提交改动(含未跟踪)均**不影响**:不产生 stash、不切分支、不动本地主干 ref
342
342
 
343
343
  **解读末行 `MERGE_RESULT`**:
344
344
  - 退出码 0 → 交付完成
345
345
  - `need-mr`(目标分支受保护)→ 报明细并**转出口 A**
346
346
  - `failed`(含 `merge-failed` / `push-failed`)→ 报明细,修复后重跑(幂等)
347
- - `not-on-main`(仅主干路径)→ 空间根检出被其他需求占用(本地模式进行中):**先收尾该本地需求或改用出口 A 提 MR**;不得用 `--allow-checkout` 抢占(该参数属本地模式收尾,会切走占用者的工作区)
348
347
 
349
348
  报告形态:
350
349
 
@@ -496,4 +495,4 @@ node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<branch>" --apps "<app
496
495
  | `make-mr-urls.js` | 生成预填 MR/PR 创建链接(平台识别 + 远端校验 + diff 判定 + `--open` 开浏览器) | ws_root, branch, target(或 wt_path, target), --platform, --open | 0/1/2 |
497
496
  | `push-core.js` | 推送核心(classifyError / pushSingle / collectRemoteMetadata,共享脚本,源仓库真源在 `_shared/scripts/`) | (共享模块) | - |
498
497
  | `check-unarchived.js` | 未归档 change 检查(Step 7 直合并的归档前置,共享脚本) | ws_root, branch | 0/1 |
499
- | `merge-branches.js` | 直合并(Step 7 出口 B / 收尾门禁;预检 → merge --no-ff → push;`--target` 支持测试分支,共享脚本) | ws_root, branch, --apps / --target / --allow-checkout / --dry-run / --check | 0/1/2 |
498
+ | `merge-branches.js` | 直合并(Step 7 出口 B / 收尾门禁;临时 worktree 合并 → push 目标,零接触本地检出;`--target` 支持测试分支,共享脚本) | ws_root, branch, --apps / --target / --dry-run / --check | 0/1/2 |
@@ -10,20 +10,24 @@ require('./silence-popup');
10
10
  * "由 agent 直接完成合并"通道。
11
11
  *
12
12
  * 用法:
13
- * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--allow-checkout] [--dry-run]
13
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--dry-run]
14
14
  * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
15
15
  *
16
- * 目标分支:
17
- * 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push)
18
- * --target 指定非主干分支 → 临时 worktree(基于 origin/<target>,不动用户检出)
16
+ * 执行模型(统一"零接触"):
17
+ * 目标 = 缺省探测主干或 --target 指定;每仓库在**临时 worktree**(基于 origin/<目标>,
18
+ * 远端缺失时退回本地 <目标>)中 merge --no-ff → push origin HEAD:<目标> → 清理。
19
+ * 合并全程不触碰本地检出:被其他需求占用(本地模式进行中)、有未提交改动(含未跟踪)
20
+ * 均不影响,不产生 stash、不切分支、不改本地主干 ref。代价:本地主干检出不随交付前移,
21
+ * 由下次 pull 追平(基准 = origin/<主干>,ADR 0002)。
19
22
  *
20
23
  * 模式:
21
24
  * --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
22
25
  * 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
23
26
  * 默认 两阶段(ADR 0007):
24
- * 1. 预检:逐仓库校验(目标可解析 / 检出干净 / 分支存在)
27
+ * 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 目标分支存在)
25
28
  * 任一不过 → 退出 2,不执行任何合并
26
- * 2. 执行:merge --no-ff → push;执行中失败 → 停止并报告已完成清单(不回滚;重跑幂等)
29
+ * 2. 执行:临时 worktree 合并 → push;执行中失败 → 停止并报告已完成清单
30
+ * (不回滚;重跑幂等)
27
31
  * --dry-run 只做预检,不执行
28
32
  *
29
33
  * 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
@@ -147,7 +151,11 @@ function main() {
147
151
  const branch = args[1];
148
152
 
149
153
  if (!wsRoot || !branch || branch.indexOf('--') === 0) {
150
- console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--allow-checkout] [--dry-run] [--check]');
154
+ console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--dry-run] [--check]');
155
+ process.exit(1);
156
+ }
157
+ if (args.includes('--allow-checkout')) {
158
+ console.error('--allow-checkout 已退役:合并统一走临时 worktree,不触碰本地检出');
151
159
  process.exit(1);
152
160
  }
153
161
 
@@ -161,7 +169,6 @@ function main() {
161
169
  console.error('--target 需要提供分支名');
162
170
  process.exit(1);
163
171
  }
164
- const allowCheckout = args.includes('--allow-checkout');
165
172
  const dryRun = args.includes('--dry-run');
166
173
  const checkOnly = args.includes('--check');
167
174
 
@@ -209,7 +216,7 @@ function main() {
209
216
  // ── 阶段 1:预检 ──────────────────────────────────────────
210
217
  const prechecks = [];
211
218
  for (const rp of repos) {
212
- const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
219
+ const rec = { name: rp.name, status: 'ok', main: null, target: null, reason: null };
213
220
  if (!fs.existsSync(path.join(rp.repo, '.git'))) {
214
221
  rec.status = 'skipped';
215
222
  rec.reason = 'no-repo';
@@ -249,55 +256,15 @@ function main() {
249
256
  continue;
250
257
  }
251
258
 
252
- if (target === main) {
253
- // 主干路径(快路径,与 ADR 0007 收尾门禁一致)
254
- if (mergedToMain(rp.repo, branch, main)) {
255
- rec.status = 'skipped';
256
- rec.reason = 'already-merged';
257
- prechecks.push(rec);
258
- continue;
259
- }
260
- const status = run('git status --porcelain', rp.work);
261
- if (!status.ok || status.out) {
262
- rec.status = 'failed';
263
- rec.reason = 'dirty-worktree';
264
- prechecks.push(rec);
265
- continue;
266
- }
267
- const current = run('git branch --show-current', rp.work);
268
- if (current.out && current.out !== main && !allowCheckout) {
269
- rec.status = 'failed';
270
- rec.reason = 'not-on-main';
271
- prechecks.push(rec);
272
- continue;
273
- }
274
- const ahead = run('git rev-list --count "origin/' + main + '..' + main + '"', rp.repo);
275
- if (ahead.ok && ahead.out && parseInt(ahead.out, 10) > 0) {
276
- rec.status = 'failed';
277
- rec.reason = 'main-ahead-of-origin';
278
- prechecks.push(rec);
279
- continue;
280
- }
281
- } else {
282
- // 指定分支路径:已合并则跳过;当前检出即目标且干净 → 就地;否则临时 worktree
283
- if (mergedToTarget(rp.repo, branch, target)) {
284
- rec.status = 'skipped';
285
- rec.reason = 'already-merged';
286
- prechecks.push(rec);
287
- continue;
288
- }
289
- const current = run('git branch --show-current', rp.work);
290
- if (current.out === target) {
291
- const status = run('git status --porcelain', rp.work);
292
- if (!status.ok || status.out) {
293
- rec.status = 'failed';
294
- rec.reason = 'dirty-worktree';
295
- prechecks.push(rec);
296
- continue;
297
- }
298
- } else {
299
- rec.temp = true; // 执行阶段创建临时 worktree
300
- }
259
+ // 合并一律走临时 worktree(零接触本地检出):预检不看检出分支与脏状态
260
+ const merged = (!targetArg || target === main)
261
+ ? mergedToMain(rp.repo, branch, main)
262
+ : mergedToTarget(rp.repo, branch, target);
263
+ if (merged) {
264
+ rec.status = 'skipped';
265
+ rec.reason = 'already-merged';
266
+ prechecks.push(rec);
267
+ continue;
301
268
  }
302
269
  prechecks.push(rec);
303
270
  }
@@ -306,7 +273,7 @@ function main() {
306
273
  prechecks.forEach(p => {
307
274
  if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
308
275
  else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
309
- else console.log('OK ' + p.name + ' → 合并至 ' + p.target + (p.temp ? '(临时 worktree)' : ''));
276
+ else console.log('OK ' + p.name + ' → 合并至 ' + p.target + '(临时 worktree,不碰本地检出)');
310
277
  });
311
278
 
312
279
  if (hasPrecheckFailure) {
@@ -337,86 +304,20 @@ function main() {
337
304
  const target = p.target;
338
305
  const mergeMsg = "Merge branch '" + branch + "'";
339
306
 
340
- if (target === p.main) {
341
- // ── 主干路径(快路径) ──
342
- const current = run('git branch --show-current', rp.work);
343
- if (current.out !== target) {
344
- const co = run('git checkout --quiet "' + target + '"', rp.work);
345
- if (!co.ok) {
346
- results[i].status = 'failed';
347
- results[i].detail = 'checkout-failed: ' + co.err.split(/\r?\n/)[0];
348
- stopped = true;
349
- continue;
350
- }
351
- }
352
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
353
- if (!pull.ok) {
354
- results[i].status = 'failed';
355
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
356
- stopped = true;
357
- continue;
358
- }
359
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
360
- if (!merge.ok) {
361
- run('git merge --abort', rp.repo);
362
- results[i].status = 'failed';
363
- results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
364
- stopped = true;
365
- continue;
366
- }
367
- let push = run('git push origin "' + target + '"', rp.repo);
368
- if (!push.ok) {
369
- const code = classifyPushError(push.err);
370
- if (code === 'non-fast-forward') {
371
- const retryPull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
372
- push = retryPull.ok ? run('git push origin "' + target + '"', rp.repo) : retryPull;
373
- }
374
- if (!push.ok) {
375
- const code2 = classifyPushError(push.err);
376
- if (code2 === 'branch-protected') {
377
- results[i].status = 'need-mr';
378
- results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
379
- } else {
380
- results[i].status = 'failed';
381
- results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
382
- }
383
- stopped = true;
384
- continue;
385
- }
386
- }
387
- results[i].status = 'merged';
388
- results[i].detail = 'merged+pushed origin/' + target;
307
+ // ── 统一临时 worktree:基于 origin/<target>(远端缺失退回本地 <target>),零接触本地检出 ──
308
+ const tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
309
+ const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
310
+ const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
311
+ if (!add.ok) {
312
+ results[i].status = 'failed';
313
+ results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
314
+ stopped = true;
389
315
  continue;
390
316
  }
391
317
 
392
- // ── 指定分支路径(当前检出即目标 → 就地;否则临时 worktree) ──
393
- let workDir = rp.work;
394
- let tmpDir = null;
395
- const current = run('git branch --show-current', rp.work);
396
- if (current.out !== target) {
397
- tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
398
- const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
399
- const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
400
- if (!add.ok) {
401
- results[i].status = 'failed';
402
- results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
403
- stopped = true;
404
- continue;
405
- }
406
- workDir = tmpDir;
407
- } else {
408
- const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
409
- if (!pull.ok) {
410
- results[i].status = 'failed';
411
- results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
412
- stopped = true;
413
- continue;
414
- }
415
- }
416
-
417
- const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
318
+ const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
418
319
  if (!merge.ok) {
419
- run('git merge --abort', workDir);
320
+ run('git merge --abort', tmpDir);
420
321
  cleanupTempWorktree(rp.repo, tmpDir);
421
322
  results[i].status = 'failed';
422
323
  results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
@@ -424,15 +325,15 @@ function main() {
424
325
  continue;
425
326
  }
426
327
 
427
- let push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
328
+ let push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
428
329
  if (!push.ok) {
429
330
  const code = classifyPushError(push.err);
430
331
  if (code === 'non-fast-forward') {
431
332
  // 远端已前进:在合并工作区 ff 同步后重试一次
432
- const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', workDir);
333
+ const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', tmpDir);
433
334
  if (retry.ok) {
434
- const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
435
- if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
335
+ const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
336
+ if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
436
337
  }
437
338
  }
438
339
  if (!push.ok) {
@@ -451,7 +352,7 @@ function main() {
451
352
  }
452
353
  cleanupTempWorktree(rp.repo, tmpDir);
453
354
  results[i].status = 'merged';
454
- results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
355
+ results[i].detail = 'merged+pushed origin/' + target + '(临时 worktree,本地检出一分未动)';
455
356
  }
456
357
 
457
358
  results.forEach(r => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jspg-ai/coding-bb",
3
- "version": "0.0.4",
3
+ "version": "0.0.5-beta.2",
4
4
  "description": "整合业界热门且高价值的工具、框架与技能,为 AI CODING AGENT 提供统一的行为准则与工作流,辅助开发者将需求高效落地为符合规范的代码",
5
5
  "main": "cbb/lib/install/init.js",
6
6
  "bin": {