@jspg-ai/coding-bb 0.0.5-beta.1 → 0.0.5-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cbb/dev-standards/rules/cbb-workspace-discipline.md +1 -1
- package/cbb/worktrees/_shared/scripts/merge-branches.js +53 -263
- package/cbb/worktrees/_shared/scripts/return-to-trunk.js +184 -0
- package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +30 -20
- package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +53 -263
- package/cbb/worktrees/skills/cbb-local-mode/scripts/return-to-trunk.js +184 -0
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +4 -4
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +53 -263
- package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +1 -1
- package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +3 -5
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/merge-branches.js +53 -263
- package/package.json +2 -2
|
@@ -10,7 +10,7 @@ trigger: always_on
|
|
|
10
10
|
|
|
11
11
|
- 就地切分支:根 + 各涉及应用 `.codespace/<app>/` 同名 `checkout -b`(基线 = `origin/<main>`,经 `cbb-local-mode` 技能执行)
|
|
12
12
|
- 当前会话原地开发,相对路径原样生效;**一次只承载一个需求**(独占本地检出;期间并行新需求走 worktree 模式,互不干扰)
|
|
13
|
-
- 收尾(收尾合并门禁,ADR 0007):`merge-branches.js --check` 判定已合并;未合并须三选一(提 MR /
|
|
13
|
+
- 收尾(收尾合并门禁,ADR 0007):`merge-branches.js --check` 判定已合并;未合并须三选一(提 MR / 直合并(临时 worktree 零接触,ADR 0008) / 确认废弃)后方可收尾;交付完成后各参与仓库回主干并删分支(`return-to-trunk.js` + `delete-branches.js`,主干名经远端 HEAD 探测,兼容 main / master)
|
|
14
14
|
|
|
15
15
|
**模式二 · worktree 模式(并行 / 隔离):**
|
|
16
16
|
|
|
@@ -10,33 +10,27 @@ 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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* 执行模型(统一"零接触"):
|
|
17
|
+
* 目标 = 缺省探测主干或 --target 指定;每仓库在**临时 worktree**(基于 origin/<目标>,
|
|
18
|
+
* 远端缺失时退回本地 <目标>)中 merge --no-ff → push origin HEAD:<目标> → 清理。
|
|
19
|
+
* 合并全程不触碰本地检出:被其他需求占用(本地模式进行中)、有未提交改动(含未跟踪)
|
|
20
|
+
* 均不影响,不产生 stash、不切分支、不改本地主干 ref。代价:本地主干检出不随交付前移,
|
|
21
|
+
* 由下次 pull 追平(基准 = origin/<主干>,ADR 0002)。
|
|
21
22
|
*
|
|
22
23
|
* 模式:
|
|
23
24
|
* --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
|
|
24
25
|
* 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
|
|
25
26
|
* 默认 两阶段(ADR 0007):
|
|
26
|
-
* 1. 预检:逐仓库校验(目标可解析 / 分支存在 /
|
|
27
|
-
* 检出上未提交改动(含未跟踪)不再拦截——执行阶段自动暂存让路(autostash)
|
|
27
|
+
* 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 目标分支存在)
|
|
28
28
|
* 任一不过 → 退出 2,不执行任何合并
|
|
29
|
-
* 2.
|
|
30
|
-
*
|
|
31
|
-
* (不回滚;重跑幂等)。恢复冲突时保留 stash 条目交用户决策
|
|
32
|
-
* ("后提交者解决":暂存侧改动适配已交付主干,agent 代解或用户手动二选一)
|
|
29
|
+
* 2. 执行:临时 worktree 合并 → push;执行中失败 → 停止并报告已完成清单
|
|
30
|
+
* (不回滚;重跑幂等)
|
|
33
31
|
* --dry-run 只做预检,不执行
|
|
34
32
|
*
|
|
35
|
-
*
|
|
36
|
-
* 本次合并的 HEAD 后 plain apply 一次——保证冲突只"单次应用"落盘(双重应用风险为零),
|
|
37
|
-
* 代价是冲突场景下 staged/unstaged 分区扁平化(内容无损)。ignored 文件不参与暂存。
|
|
38
|
-
* 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr /
|
|
39
|
-
* unmerged / autostashPending——交付已完成,只是暂存未恢复,按 AUTOSTASH_PENDING 行处理)
|
|
33
|
+
* 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
|
|
40
34
|
* 末行:MERGE_RESULT [...] 或 MERGE_CHECK_RESULT [...]
|
|
41
35
|
*/
|
|
42
36
|
|
|
@@ -151,108 +145,17 @@ function cleanupTempWorktree(repoPath, tmpDir) {
|
|
|
151
145
|
run('git worktree prune', repoPath);
|
|
152
146
|
}
|
|
153
147
|
|
|
154
|
-
// ── autostash 让路 / 恢复(检出上的未提交改动不阻断直合并,ADR 0008 扩展) ──
|
|
155
|
-
|
|
156
|
-
/** 统计检出上未提交项(含未跟踪),仅用于预检输出注记;-1 = 状态不可读 */
|
|
157
|
-
function countUncommitted(workPath) {
|
|
158
|
-
const s = run('git status --porcelain', workPath);
|
|
159
|
-
if (!s.ok) return -1;
|
|
160
|
-
return s.out ? s.out.split(/\r?\n/).filter(Boolean).length : 0;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* 暂存检出上的未提交改动(含未跟踪,不含 ignored),为合并让路。
|
|
165
|
-
* 经 refs/stash 前后对比判断是否真的建了条目(无改动时 stash 不建条目)。
|
|
166
|
-
* 返回 { ok, created, oid, stashId } 或 { ok: false, err }
|
|
167
|
-
*/
|
|
168
|
-
function autostashPush(workPath, tag) {
|
|
169
|
-
const before = run('git rev-parse -q --verify "refs/stash"', workPath);
|
|
170
|
-
const stamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
171
|
-
const msg = 'cbb-autostash ' + tag + ' [' + stamp + ']';
|
|
172
|
-
const push = run('git stash push --include-untracked -m "' + msg + '"', workPath);
|
|
173
|
-
if (!push.ok) return { ok: false, err: (push.err || push.out || 'stash push 失败').split(/\r?\n/)[0] };
|
|
174
|
-
const after = run('git rev-parse -q --verify "refs/stash"', workPath);
|
|
175
|
-
const oid = after.out || '';
|
|
176
|
-
return { ok: true, created: !!oid && oid !== (before.out || ''), oid, stashId: msg };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function conflictedPaths(workPath) {
|
|
180
|
-
const d = run('git diff --name-only --diff-filter=U', workPath);
|
|
181
|
-
return d.ok && d.out ? d.out.split(/\r?\n/).filter(Boolean) : [];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* 单次恢复尝试 + 分类。返回:
|
|
186
|
-
* { done } 完全恢复(pop 成功,或 apply 完整成功 + drop 成功)
|
|
187
|
-
* { conflicted } 冲突标记留在树里,stash 条目保留
|
|
188
|
-
* { dirty, files } 有改动但非标记冲突 / apply 异常部分应用——现场保留,交人工核对
|
|
189
|
-
* { preserved } 无法自动恢复且树未被污染(如未跟踪占位冲突)
|
|
190
|
-
* 防双重应用:pop --index 失败可能留下"半应用"树(工作区改了、index 没还原),
|
|
191
|
-
* 盲目 plain pop 会在半应用之上再应用一次。故失败后先 reset --hard 回到 HEAD,
|
|
192
|
-
* 再 plain apply 一次(干净基线上的单次应用),结果只有"标记冲突"或"完整应用"两种。
|
|
193
|
-
*/
|
|
194
|
-
function tryRestoreStash(workPath, idx) {
|
|
195
|
-
const ref = '"stash@{' + idx + '}"';
|
|
196
|
-
const pop = run('git stash pop --index ' + ref, workPath);
|
|
197
|
-
if (pop.ok) return { done: true };
|
|
198
|
-
const conflicted = conflictedPaths(workPath);
|
|
199
|
-
if (conflicted.length === 0) {
|
|
200
|
-
// 非冲突类失败(如未跟踪文件还原撞车):树可能已被部分应用——如实分类,绝不谎报"未动"
|
|
201
|
-
const s = run('git status --porcelain --untracked-files=no', workPath);
|
|
202
|
-
if (s.ok && s.out) return { dirty: true, files: s.out.split(/\r?\n/).filter(Boolean) };
|
|
203
|
-
return { preserved: true };
|
|
204
|
-
}
|
|
205
|
-
// 有冲突标记:--index 的失败树状态不透明(半应用 + unmerged 索引,checkout -- 会拒绝)。
|
|
206
|
-
// 先 reset --hard 回到本次合并的 HEAD(用户改动安全躺在 stash 里),再 plain apply 一次,
|
|
207
|
-
// 让冲突以标准 stash-pop-conflict 形态单次落盘(标记 + 条目保留),杜绝双重应用。
|
|
208
|
-
const undo = run('git reset --hard --quiet', workPath);
|
|
209
|
-
if (!undo.ok) return { conflicted: true };
|
|
210
|
-
const apply = run('git stash apply ' + ref, workPath);
|
|
211
|
-
if (conflictedPaths(workPath).length > 0) return { conflicted: true };
|
|
212
|
-
if (!apply.ok) {
|
|
213
|
-
const s2 = run('git status --porcelain --untracked-files=no', workPath);
|
|
214
|
-
if (s2.ok && s2.out) return { dirty: true, files: s2.out.split(/\r?\n/).filter(Boolean) };
|
|
215
|
-
return { preserved: true };
|
|
216
|
-
}
|
|
217
|
-
// plain apply 完全成功(首轮失败仅因 --index 还原不了 staged 分区):补 drop 收尾
|
|
218
|
-
const drop = run('git stash drop ' + ref, workPath);
|
|
219
|
-
if (drop.ok) return { done: true };
|
|
220
|
-
return { dirty: true, files: conflictedPaths(workPath) };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* 恢复 autostash。返回 { state, files?, note? }:
|
|
225
|
-
* none 未建条目,无需恢复
|
|
226
|
-
* restored 已完全恢复
|
|
227
|
-
* conflict 恢复冲突(标记在树里、条目保留)——后提交者解决,交用户决策
|
|
228
|
-
* dirty apply 异常 / 部分应用——现场保留交人工核对
|
|
229
|
-
* preserved 无法自动恢复且树未被污染——条目保留交人工
|
|
230
|
-
* stash-moved stash 顶层已非本次条目——绝不动别人的 stash,保留现场
|
|
231
|
-
*/
|
|
232
|
-
function autostashPop(workPath, st) {
|
|
233
|
-
if (!st.ok || !st.created) return { state: 'none' };
|
|
234
|
-
const cur = run('git rev-parse -q --verify "refs/stash"', workPath);
|
|
235
|
-
if (!cur.ok || cur.out !== st.oid) {
|
|
236
|
-
return { state: 'stash-moved', note: 'stash 顶层条目非本次自动暂存(' + st.stashId + '),未自动恢复' };
|
|
237
|
-
}
|
|
238
|
-
const list = run('git stash list', workPath);
|
|
239
|
-
const top = list.ok ? (list.out.split(/\r?\n/)[0] || '') : '';
|
|
240
|
-
const m = top.match(/^stash@\{(\d+)\}/);
|
|
241
|
-
if (!m) return { state: 'preserved', note: 'stash 列表解析失败(' + st.stashId + ')' };
|
|
242
|
-
const r = tryRestoreStash(workPath, m[1]);
|
|
243
|
-
if (r.done) return { state: 'restored' };
|
|
244
|
-
if (r.conflicted) return { state: 'conflict', files: conflictedPaths(workPath), note: '条目保留:' + top };
|
|
245
|
-
if (r.dirty) return { state: 'dirty', files: r.files, note: '条目保留:' + top };
|
|
246
|
-
return { state: 'preserved', note: '条目保留:' + top };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
148
|
function main() {
|
|
250
149
|
const args = process.argv.slice(2);
|
|
251
150
|
const wsRoot = args[0];
|
|
252
151
|
const branch = args[1];
|
|
253
152
|
|
|
254
153
|
if (!wsRoot || !branch || branch.indexOf('--') === 0) {
|
|
255
|
-
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,不触碰本地检出');
|
|
256
159
|
process.exit(1);
|
|
257
160
|
}
|
|
258
161
|
|
|
@@ -266,7 +169,6 @@ function main() {
|
|
|
266
169
|
console.error('--target 需要提供分支名');
|
|
267
170
|
process.exit(1);
|
|
268
171
|
}
|
|
269
|
-
const allowCheckout = args.includes('--allow-checkout');
|
|
270
172
|
const dryRun = args.includes('--dry-run');
|
|
271
173
|
const checkOnly = args.includes('--check');
|
|
272
174
|
|
|
@@ -314,7 +216,7 @@ function main() {
|
|
|
314
216
|
// ── 阶段 1:预检 ──────────────────────────────────────────
|
|
315
217
|
const prechecks = [];
|
|
316
218
|
for (const rp of repos) {
|
|
317
|
-
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 };
|
|
318
220
|
if (!fs.existsSync(path.join(rp.repo, '.git'))) {
|
|
319
221
|
rec.status = 'skipped';
|
|
320
222
|
rec.reason = 'no-repo';
|
|
@@ -354,45 +256,15 @@ function main() {
|
|
|
354
256
|
continue;
|
|
355
257
|
}
|
|
356
258
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
rec.uncommitted = countUncommitted(rp.work);
|
|
367
|
-
const current = run('git branch --show-current', rp.work);
|
|
368
|
-
if (current.out && current.out !== main && !allowCheckout) {
|
|
369
|
-
rec.status = 'failed';
|
|
370
|
-
rec.reason = 'not-on-main';
|
|
371
|
-
prechecks.push(rec);
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const ahead = run('git rev-list --count "origin/' + main + '..' + main + '"', rp.repo);
|
|
375
|
-
if (ahead.ok && ahead.out && parseInt(ahead.out, 10) > 0) {
|
|
376
|
-
rec.status = 'failed';
|
|
377
|
-
rec.reason = 'main-ahead-of-origin';
|
|
378
|
-
prechecks.push(rec);
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
} else {
|
|
382
|
-
// 指定分支路径:已合并则跳过;当前检出即目标 → 就地(autostash);否则临时 worktree
|
|
383
|
-
if (mergedToTarget(rp.repo, branch, target)) {
|
|
384
|
-
rec.status = 'skipped';
|
|
385
|
-
rec.reason = 'already-merged';
|
|
386
|
-
prechecks.push(rec);
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
const current = run('git branch --show-current', rp.work);
|
|
390
|
-
if (current.out === target) {
|
|
391
|
-
// 就地合并:同主干快路径,检出脏不拦截,执行阶段 autostash 让路 / 恢复
|
|
392
|
-
rec.uncommitted = countUncommitted(rp.work);
|
|
393
|
-
} else {
|
|
394
|
-
rec.temp = true; // 执行阶段创建临时 worktree(基于 origin/<target>,不碰用户检出)
|
|
395
|
-
}
|
|
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;
|
|
396
268
|
}
|
|
397
269
|
prechecks.push(rec);
|
|
398
270
|
}
|
|
@@ -401,11 +273,7 @@ function main() {
|
|
|
401
273
|
prechecks.forEach(p => {
|
|
402
274
|
if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
|
|
403
275
|
else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
|
|
404
|
-
else
|
|
405
|
-
let note = p.temp ? '(临时 worktree)' : '';
|
|
406
|
-
if (!p.temp && p.uncommitted > 0) note += '(检出有 ' + p.uncommitted + ' 项未提交改动,将自动暂存、合并后恢复)';
|
|
407
|
-
console.log('OK ' + p.name + ' → 合并至 ' + p.target + note);
|
|
408
|
-
}
|
|
276
|
+
else console.log('OK ' + p.name + ' → 合并至 ' + p.target + '(临时 worktree,不碰本地检出)');
|
|
409
277
|
});
|
|
410
278
|
|
|
411
279
|
if (hasPrecheckFailure) {
|
|
@@ -435,141 +303,63 @@ function main() {
|
|
|
435
303
|
|
|
436
304
|
const target = p.target;
|
|
437
305
|
const mergeMsg = "Merge branch '" + branch + "'";
|
|
438
|
-
let st = null; // autostash 句柄(仅主干 / 就地路径建)
|
|
439
|
-
// 统一出口:凡建过暂存,离开本仓库前必恢复;恢复未完全 → autostashPending(退出码 2 交用户决策)
|
|
440
|
-
const finish = (status, detail) => {
|
|
441
|
-
let pending = false;
|
|
442
|
-
if (st) {
|
|
443
|
-
const pr = autostashPop(rp.work, st);
|
|
444
|
-
if (pr.state !== 'none' && pr.state !== 'restored') {
|
|
445
|
-
pending = true;
|
|
446
|
-
detail += '; autostash-' + pr.state +
|
|
447
|
-
(pr.files && pr.files.length ? ' [' + pr.files.join(', ') + ']' : '') +
|
|
448
|
-
(pr.note ? ' ' + pr.note : '');
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
results[i].status = status;
|
|
452
|
-
results[i].detail = detail;
|
|
453
|
-
results[i].autostashPending = pending;
|
|
454
|
-
if (status !== 'merged' && status !== 'skipped') stopped = true;
|
|
455
|
-
};
|
|
456
306
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (current.out !== target) {
|
|
466
|
-
const co = run('git checkout --quiet "' + target + '"', rp.work);
|
|
467
|
-
if (!co.ok) {
|
|
468
|
-
finish('failed', 'checkout-failed: ' + co.err.split(/\r?\n/)[0]);
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
|
|
473
|
-
if (!pull.ok) {
|
|
474
|
-
finish('failed', 'pull-failed: ' + pull.err.split(/\r?\n/)[0]);
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
|
|
478
|
-
if (!merge.ok) {
|
|
479
|
-
run('git merge --abort', rp.repo);
|
|
480
|
-
finish('failed', 'merge-failed: ' + merge.err.split(/\r?\n/)[0]);
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
let push = run('git push origin "' + target + '"', rp.repo);
|
|
484
|
-
if (!push.ok) {
|
|
485
|
-
const code = classifyPushError(push.err);
|
|
486
|
-
if (code === 'non-fast-forward') {
|
|
487
|
-
const retryPull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
|
|
488
|
-
push = retryPull.ok ? run('git push origin "' + target + '"', rp.repo) : retryPull;
|
|
489
|
-
}
|
|
490
|
-
if (!push.ok) {
|
|
491
|
-
const code2 = classifyPushError(push.err);
|
|
492
|
-
if (code2 === 'branch-protected') {
|
|
493
|
-
finish('need-mr', 'branch-protected(目标分支受保护,请改用 MR 出口)');
|
|
494
|
-
} else {
|
|
495
|
-
finish('failed', 'push-failed: ' + (push.err || '').split(/\r?\n/)[0]);
|
|
496
|
-
}
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
finish('merged', '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;
|
|
501
315
|
continue;
|
|
502
316
|
}
|
|
503
317
|
|
|
504
|
-
|
|
505
|
-
let workDir = rp.work;
|
|
506
|
-
let tmpDir = null;
|
|
507
|
-
const current = run('git branch --show-current', rp.work);
|
|
508
|
-
if (current.out !== target) {
|
|
509
|
-
tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
|
|
510
|
-
const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
|
|
511
|
-
const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
|
|
512
|
-
if (!add.ok) {
|
|
513
|
-
finish('failed', 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0]);
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
workDir = tmpDir;
|
|
517
|
-
} else {
|
|
518
|
-
st = autostashPush(rp.work, 'merge-' + branch + '-into-' + target);
|
|
519
|
-
if (!st.ok) {
|
|
520
|
-
finish('failed', 'autostash-failed: ' + st.err);
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
|
|
524
|
-
if (!pull.ok) {
|
|
525
|
-
finish('failed', 'pull-failed: ' + pull.err.split(/\r?\n/)[0]);
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
|
|
318
|
+
const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
|
|
531
319
|
if (!merge.ok) {
|
|
532
|
-
run('git merge --abort',
|
|
320
|
+
run('git merge --abort', tmpDir);
|
|
533
321
|
cleanupTempWorktree(rp.repo, tmpDir);
|
|
534
|
-
|
|
322
|
+
results[i].status = 'failed';
|
|
323
|
+
results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
|
|
324
|
+
stopped = true;
|
|
535
325
|
continue;
|
|
536
326
|
}
|
|
537
327
|
|
|
538
|
-
let push = run('git push origin "HEAD:refs/heads/' + target + '"',
|
|
328
|
+
let push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
|
|
539
329
|
if (!push.ok) {
|
|
540
330
|
const code = classifyPushError(push.err);
|
|
541
331
|
if (code === 'non-fast-forward') {
|
|
542
332
|
// 远端已前进:在合并工作区 ff 同步后重试一次
|
|
543
|
-
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);
|
|
544
334
|
if (retry.ok) {
|
|
545
|
-
const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"',
|
|
546
|
-
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);
|
|
547
337
|
}
|
|
548
338
|
}
|
|
549
339
|
if (!push.ok) {
|
|
550
340
|
const code2 = classifyPushError(push.err);
|
|
551
341
|
cleanupTempWorktree(rp.repo, tmpDir);
|
|
552
342
|
if (code2 === 'branch-protected') {
|
|
553
|
-
|
|
343
|
+
results[i].status = 'need-mr';
|
|
344
|
+
results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
|
|
554
345
|
} else {
|
|
555
|
-
|
|
346
|
+
results[i].status = 'failed';
|
|
347
|
+
results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
|
|
556
348
|
}
|
|
349
|
+
stopped = true;
|
|
557
350
|
continue;
|
|
558
351
|
}
|
|
559
352
|
}
|
|
560
353
|
cleanupTempWorktree(rp.repo, tmpDir);
|
|
561
|
-
|
|
354
|
+
results[i].status = 'merged';
|
|
355
|
+
results[i].detail = 'merged+pushed origin/' + target + '(临时 worktree,本地检出一分未动)';
|
|
562
356
|
}
|
|
563
357
|
|
|
564
358
|
results.forEach(r => {
|
|
565
359
|
console.log(r.status.toUpperCase() + ' ' + r.name + (r.detail ? ' — ' + r.detail : ''));
|
|
566
|
-
if (r.autostashPending) {
|
|
567
|
-
console.log('AUTOSTASH_PENDING ' + r.name + ': 暂存的未提交改动未自动恢复(stash 条目保留)——列冲突点,用户二选一:agent 解决 / 手动解决(协议见 SKILL 解读)');
|
|
568
|
-
}
|
|
569
360
|
});
|
|
570
361
|
|
|
571
|
-
|
|
572
|
-
const ok = results.every(r => (r.status === 'merged' || r.status === 'skipped') && !r.autostashPending);
|
|
362
|
+
const ok = results.every(r => r.status === 'merged' || r.status === 'skipped');
|
|
573
363
|
console.log('---');
|
|
574
364
|
console.log('MERGE_RESULT', JSON.stringify({ phase: 'execute', target: targetArg || null, results }));
|
|
575
365
|
process.exit(ok ? 0 : 2);
|
|
@@ -577,4 +367,4 @@ function main() {
|
|
|
577
367
|
|
|
578
368
|
if (require.main === module) main();
|
|
579
369
|
|
|
580
|
-
module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos
|
|
370
|
+
module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require('./silence-popup');
|
|
3
|
+
/**
|
|
4
|
+
* 收尾回主干 —— 交付完成后把各参与仓库的本地检出切回主干并追平(ADR 0007 / 0008)
|
|
5
|
+
*
|
|
6
|
+
* 背景:merge-branches.js 统一零接触通道后,合并在临时 worktree 内完成、
|
|
7
|
+
* 不触碰本地检出(本地主干检出不随交付前移)。本地模式收尾需把各仓库
|
|
8
|
+
* 本地检出从需求分支切回主干——本脚本替代曾经写死 `git checkout main`
|
|
9
|
+
* 的裸命令:主干名逐仓库经远端 HEAD 探测(与 check-local-state.js 同源,
|
|
10
|
+
* 兼容 master 等),不再假设主干叫 main。
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node return-to-trunk.js <ws_root> [--apps a,b] [--dry-run]
|
|
14
|
+
*
|
|
15
|
+
* 行为(逐仓库,相互独立、失败不停链):
|
|
16
|
+
* 1. 目录无 .git → SKIP no-repo
|
|
17
|
+
* 2. git fetch --quiet origin(失败仅降级,后续判定退回本地主干)
|
|
18
|
+
* 3. 主干名 detectMain:远端 HEAD → 本地 main → 本地 master → origin/main → origin/master
|
|
19
|
+
* → FAIL cannot-detect-main
|
|
20
|
+
* 4. 当前检出 ≠ 主干:
|
|
21
|
+
* - 检出 dirty(含未跟踪)→ FAIL dirty-worktree(列未提交项数;不自动 stash,交用户决策)
|
|
22
|
+
* - 本地有主干分支 → git checkout <主干>;仅远端有 → git checkout -b <主干> --track origin/<主干>
|
|
23
|
+
* 5. 追平远端:origin/<主干> 存在 → git pull --ff-only origin <主干>
|
|
24
|
+
* (本地主干与 origin 分叉 → FAIL pull-failed,交用户决策;远端无主干 → 注记跳过)
|
|
25
|
+
* 6. 已在主干 → 跳过切回,仍追平(幂等)
|
|
26
|
+
*
|
|
27
|
+
* 退出码:0 全部成功(含 no-repo / 已在主干)/ 1 参数错误 / 2 任一仓库失败
|
|
28
|
+
* 末行:TRUNK_RESULT [...]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { execSync } = require('child_process');
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
function run(cmd, cwd) {
|
|
36
|
+
try {
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
out: execSync(cmd, {
|
|
40
|
+
cwd,
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
timeout: 120000,
|
|
44
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
45
|
+
}).trim(),
|
|
46
|
+
};
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return { ok: false, err: (e.stderr || e.message || String(e)).trim() };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasRef(repoPath, ref) {
|
|
53
|
+
return run('git rev-parse --verify --quiet "' + ref + '"', repoPath).ok;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadApps(wsRoot) {
|
|
57
|
+
try {
|
|
58
|
+
const config = JSON.parse(fs.readFileSync(path.join(wsRoot, 'CBB-WORKSPACE.json'), 'utf-8'));
|
|
59
|
+
return Array.isArray(config.apps) ? config.apps : [];
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 主干名:symbolic-ref 远端 HEAD → 本地 main/master → 远端跟踪 origin/main|master → 空串(与 check-local-state.js 同源,回退链更强) */
|
|
66
|
+
function detectMain(repoPath) {
|
|
67
|
+
const remote = run('git remote', repoPath);
|
|
68
|
+
if (remote.ok && remote.out) {
|
|
69
|
+
const r = remote.out.split(/\r?\n/)[0];
|
|
70
|
+
const symbolic = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', repoPath);
|
|
71
|
+
if (symbolic.ok && symbolic.out) return symbolic.out.replace('refs/remotes/' + r + '/', '');
|
|
72
|
+
}
|
|
73
|
+
if (hasRef(repoPath, 'refs/heads/main')) return 'main';
|
|
74
|
+
if (hasRef(repoPath, 'refs/heads/master')) return 'master';
|
|
75
|
+
if (hasRef(repoPath, 'refs/remotes/origin/main')) return 'main';
|
|
76
|
+
if (hasRef(repoPath, 'refs/remotes/origin/master')) return 'master';
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 检出未提交项数(含未跟踪,不含 ignored);-1 = 状态不可读 */
|
|
81
|
+
function countUncommitted(repoPath) {
|
|
82
|
+
const s = run('git status --porcelain', repoPath);
|
|
83
|
+
if (!s.ok) return -1;
|
|
84
|
+
return s.out ? s.out.split(/\r?\n/).filter(Boolean).length : 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main() {
|
|
88
|
+
const args = process.argv.slice(2);
|
|
89
|
+
const wsRoot = args[0];
|
|
90
|
+
if (!wsRoot || wsRoot.indexOf('--') === 0) {
|
|
91
|
+
console.error('Usage: node return-to-trunk.js <ws_root> [--apps a,b] [--dry-run]');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const appsIdx = args.indexOf('--apps');
|
|
95
|
+
const appFilter = appsIdx >= 0
|
|
96
|
+
? new Set((args[appsIdx + 1] || '').split(',').map(s => s.trim()).filter(Boolean))
|
|
97
|
+
: null;
|
|
98
|
+
const dryRun = args.includes('--dry-run');
|
|
99
|
+
|
|
100
|
+
const repos = [{ name: 'workspace', repo: wsRoot }];
|
|
101
|
+
for (const app of loadApps(wsRoot)) {
|
|
102
|
+
if (appFilter && !appFilter.has(app.name)) continue;
|
|
103
|
+
repos.push({ name: app.name, repo: path.join(wsRoot, '.codespace', app.name) });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const results = [];
|
|
107
|
+
let failed = false;
|
|
108
|
+
|
|
109
|
+
for (const rp of repos) {
|
|
110
|
+
const rec = { name: rp.name, status: 'ok', main: null, switched: false, uncommitted: 0, reason: null, detail: null };
|
|
111
|
+
if (!fs.existsSync(path.join(rp.repo, '.git'))) {
|
|
112
|
+
rec.status = 'no-repo';
|
|
113
|
+
results.push(rec);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
run('git fetch --quiet origin', rp.repo);
|
|
117
|
+
const main = detectMain(rp.repo);
|
|
118
|
+
if (!main) {
|
|
119
|
+
rec.status = 'failed';
|
|
120
|
+
rec.reason = 'cannot-detect-main';
|
|
121
|
+
results.push(rec);
|
|
122
|
+
failed = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
rec.main = main;
|
|
126
|
+
rec.uncommitted = countUncommitted(rp.repo);
|
|
127
|
+
const current = run('git branch --show-current', rp.repo);
|
|
128
|
+
|
|
129
|
+
if (current.ok && current.out !== main) {
|
|
130
|
+
// detached(out 为空)也需切回——「不在主干」含 detached
|
|
131
|
+
if (dryRun) {
|
|
132
|
+
rec.reason = 'would-switch (from ' + current.out + ')';
|
|
133
|
+
results.push(rec);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (rec.uncommitted > 0) {
|
|
137
|
+
rec.status = 'failed';
|
|
138
|
+
rec.reason = 'dirty-worktree';
|
|
139
|
+
rec.detail = '检出 ' + current.out + ' 上有 ' + rec.uncommitted + ' 项未提交改动,不自动 stash——先处理再重跑';
|
|
140
|
+
results.push(rec);
|
|
141
|
+
failed = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const co = hasRef(rp.repo, 'refs/heads/' + main)
|
|
145
|
+
? run('git checkout --quiet "' + main + '"', rp.repo)
|
|
146
|
+
: run('git checkout -b "' + main + '" --track origin/' + main, rp.repo);
|
|
147
|
+
if (!co.ok) {
|
|
148
|
+
rec.status = 'failed';
|
|
149
|
+
rec.reason = 'checkout-failed';
|
|
150
|
+
rec.detail = (co.err || '').split(/\r?\n/)[0];
|
|
151
|
+
results.push(rec);
|
|
152
|
+
failed = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
rec.switched = true;
|
|
156
|
+
} else if (dryRun) {
|
|
157
|
+
rec.reason = 'already-on-trunk';
|
|
158
|
+
results.push(rec);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (hasRef(rp.repo, 'refs/remotes/origin/' + main)) {
|
|
163
|
+
const pull = run('git pull --ff-only --quiet origin "' + main + '"', rp.repo);
|
|
164
|
+
if (!pull.ok) {
|
|
165
|
+
rec.status = 'failed';
|
|
166
|
+
rec.reason = 'pull-failed';
|
|
167
|
+
rec.detail = (pull.err || '').split(/\r?\n/)[0];
|
|
168
|
+
failed = true;
|
|
169
|
+
}
|
|
170
|
+
} else if (!rec.reason) {
|
|
171
|
+
rec.reason = 'no-remote-trunk';
|
|
172
|
+
}
|
|
173
|
+
results.push(rec);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
results.forEach(r => {
|
|
177
|
+
console.log(r.status.toUpperCase() + ' ' + r.name + (r.main ? ' → ' + r.main : '') + (r.reason ? ': ' + r.reason : '') + (r.detail ? ' — ' + r.detail : ''));
|
|
178
|
+
});
|
|
179
|
+
console.log('---');
|
|
180
|
+
console.log('TRUNK_RESULT', JSON.stringify({ results }));
|
|
181
|
+
process.exit(failed ? 2 : 0);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (require.main === module) main();
|