@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 +1 -0
- package/cbb/worktrees/_shared/scripts/merge-branches.js +41 -140
- package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +5 -5
- package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +41 -140
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +4 -3
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +41 -140
- package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +3 -4
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/merge-branches.js +41 -140
- package/package.json +1 -1
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 <分支>] [--
|
|
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
|
-
*
|
|
18
|
-
* --
|
|
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.
|
|
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] [--
|
|
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,
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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 +
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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',
|
|
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 + '"',
|
|
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',
|
|
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 + '"',
|
|
435
|
-
if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"',
|
|
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 +
|
|
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
|
|
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
|
|
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>"
|
|
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 /
|
|
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
|
|
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 <分支>] [--
|
|
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
|
-
*
|
|
18
|
-
* --
|
|
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.
|
|
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] [--
|
|
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,
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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 +
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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',
|
|
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 + '"',
|
|
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',
|
|
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 + '"',
|
|
435
|
-
if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"',
|
|
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 +
|
|
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
|
-
- 两阶段:预检(主干可探测 /
|
|
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
|
-
|
|
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` |
|
|
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 <分支>] [--
|
|
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
|
-
*
|
|
18
|
-
* --
|
|
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.
|
|
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] [--
|
|
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,
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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 +
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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',
|
|
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 + '"',
|
|
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',
|
|
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 + '"',
|
|
435
|
-
if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"',
|
|
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 +
|
|
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
|
-
-
|
|
341
|
-
-
|
|
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 /
|
|
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 <分支>] [--
|
|
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
|
-
*
|
|
18
|
-
* --
|
|
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.
|
|
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] [--
|
|
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,
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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 +
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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',
|
|
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 + '"',
|
|
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',
|
|
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 + '"',
|
|
435
|
-
if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"',
|
|
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 +
|
|
355
|
+
results[i].detail = 'merged+pushed origin/' + target + '(临时 worktree,本地检出一分未动)';
|
|
455
356
|
}
|
|
456
357
|
|
|
457
358
|
results.forEach(r => {
|