@jspg-ai/coding-bb 0.0.3-beta.32 → 0.0.3-beta.33
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-ai-behavior.md +5 -4
- package/cbb/lib/utils/githook.js +6 -2
- package/cbb/worktrees/_shared/scripts/check-unarchived.js +77 -0
- package/cbb/worktrees/_shared/scripts/deprecate-branches.js +119 -0
- package/cbb/worktrees/_shared/scripts/install-git-hook.js +1 -1
- package/cbb/worktrees/_shared/scripts/merge-branches.js +469 -0
- package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +38 -6
- package/cbb/worktrees/skills/cbb-local-mode/scripts/deprecate-branches.js +119 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +1 -1
- package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +469 -0
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +86 -51
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +3 -1
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/delete-branches.js +4 -2
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/deprecate-branches.js +119 -0
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +469 -0
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/safety-check.js +4 -2
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +1 -1
- package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +143 -20
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/check-unarchived.js +77 -0
- package/config/openspec/schemas/spec-driven/schema.yaml +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require('./silence-popup');
|
|
3
|
+
/**
|
|
4
|
+
* 收尾 / 交付合并门禁 —— 直合并通道 + 合并判定(ADR 0007 / 0008)
|
|
5
|
+
*
|
|
6
|
+
* 把需求分支合并进目标分支并推送远端:
|
|
7
|
+
* - 供 cbb-worktree-close / cbb-local-mode 的收尾门禁调用(默认目标 = 主干)
|
|
8
|
+
* - 供 cbb-worktree-push Step 7 的"直接合并"出口调用(--target 可指定测试分支)
|
|
9
|
+
* 默认交付走 MR(人工在浏览器合入);本脚本是用户在门禁处**显式确认**后的
|
|
10
|
+
* "由 agent 直接完成合并"通道。
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--allow-checkout] [--dry-run]
|
|
14
|
+
* node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
|
|
15
|
+
*
|
|
16
|
+
* 目标分支:
|
|
17
|
+
* 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push)
|
|
18
|
+
* --target 指定非主干分支 → 临时 worktree(基于 origin/<target>,不动用户检出)
|
|
19
|
+
*
|
|
20
|
+
* 模式:
|
|
21
|
+
* --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
|
|
22
|
+
* 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
|
|
23
|
+
* 默认 两阶段(ADR 0007):
|
|
24
|
+
* 1. 预检:逐仓库校验(目标可解析 / 检出干净 / 分支存在)
|
|
25
|
+
* 任一不过 → 退出 2,不执行任何合并
|
|
26
|
+
* 2. 执行:merge --no-ff → push;执行中失败 → 停止并报告已完成清单(不回滚;重跑幂等)
|
|
27
|
+
* --dry-run 只做预检,不执行
|
|
28
|
+
*
|
|
29
|
+
* 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
|
|
30
|
+
* 末行:MERGE_RESULT [...] 或 MERGE_CHECK_RESULT [...]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { execSync } = require('child_process');
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
function run(cmd, cwd) {
|
|
39
|
+
try {
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
out: execSync(cmd, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
timeout: 120000,
|
|
47
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
48
|
+
}).trim(),
|
|
49
|
+
};
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return { ok: false, err: (e.stderr || e.message || String(e)).trim() };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function detectMain(repoPath) {
|
|
56
|
+
const remote = run('git remote', repoPath);
|
|
57
|
+
if (!remote.ok || !remote.out) return '';
|
|
58
|
+
const r = remote.out.split(/\r?\n/)[0];
|
|
59
|
+
const symbolic = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', repoPath);
|
|
60
|
+
if (symbolic.ok && symbolic.out) return symbolic.out.replace('refs/remotes/' + r + '/', '');
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 合并判定(与 cbb-worktree-close/scripts/safety-check.js 同源) ──
|
|
65
|
+
// 0=merged, 1=unmerged, 2=to-ref-missing
|
|
66
|
+
function mergedTo(repoPath, from, to) {
|
|
67
|
+
if (!run('git rev-parse --verify --quiet "' + from + '"', repoPath).ok) return 1;
|
|
68
|
+
if (!run('git rev-parse --verify --quiet "' + to + '"', repoPath).ok) return 2;
|
|
69
|
+
if (run('git merge-base --is-ancestor "' + from + '" "' + to + '" && echo Y', repoPath).out === 'Y') return 0;
|
|
70
|
+
// 跨平台:不用 grep(Windows 无 grep),解析 git cherry 输出(+ 前缀 = 未合并提交)
|
|
71
|
+
const cherry = run('git cherry "' + to + '" "' + from + '"', repoPath);
|
|
72
|
+
if (!cherry.ok) return 1;
|
|
73
|
+
if (cherry.out.split(/\r?\n/).filter(Boolean).some(l => l.indexOf('+') === 0)) return 1;
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mergedToMain(repoPath, branchName, main) {
|
|
78
|
+
if (mergedTo(repoPath, branchName, main) === 0) return 'main';
|
|
79
|
+
if (mergedTo(repoPath, branchName, 'origin/' + main) === 0) return 'origin/' + main;
|
|
80
|
+
const releaseList = run('git for-each-ref --format="%(refname:short)" "refs/heads/release-*" "refs/remotes/origin/release-*"', repoPath);
|
|
81
|
+
if (releaseList.ok && releaseList.out) {
|
|
82
|
+
const releases = releaseList.out.split(/\r?\n/).filter(Boolean).sort();
|
|
83
|
+
for (const rel of releases) {
|
|
84
|
+
if (mergedTo(repoPath, branchName, rel) === 0) {
|
|
85
|
+
if (mergedTo(repoPath, rel, main) === 0) return 'main-via-' + rel;
|
|
86
|
+
if (mergedTo(repoPath, rel, 'origin/' + main) === 0) return 'origin/' + main + '-via-' + rel;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 目标为具体分支时的已合并判定(含 origin 镜像,不做 release 中转) */
|
|
94
|
+
function mergedToTarget(repoPath, branchName, target) {
|
|
95
|
+
if (mergedTo(repoPath, branchName, target) === 0) return target;
|
|
96
|
+
if (mergedTo(repoPath, branchName, 'origin/' + target) === 0) return 'origin/' + target;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function classifyPushError(stderr) {
|
|
101
|
+
const s = (stderr || '').toLowerCase();
|
|
102
|
+
if (s.includes('protected branch') || s.includes('deny updating')) return 'branch-protected';
|
|
103
|
+
if (s.includes('non-fast-forward') || s.includes('fetch first') || s.includes('rejected')) return 'non-fast-forward';
|
|
104
|
+
if (s.includes('permission denied (publickey)')) return 'ssh-auth-failed';
|
|
105
|
+
if (s.includes('authentication failed')) return 'https-auth-failed';
|
|
106
|
+
if (s.includes('could not resolve host') || s.includes('connection refused') || s.includes('hung up')) return 'network-unreachable';
|
|
107
|
+
return 'unknown';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadApps(wsRoot) {
|
|
111
|
+
try {
|
|
112
|
+
const config = JSON.parse(fs.readFileSync(path.join(wsRoot, 'CBB-WORKSPACE.json'), 'utf-8'));
|
|
113
|
+
return Array.isArray(config.apps) ? config.apps : [];
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 参与仓库:workspace(根 = 主干检出)+ 各 app(.codespace/<app> = 主干检出) */
|
|
120
|
+
function buildRepos(wsRoot, appFilter) {
|
|
121
|
+
const repos = [{ name: 'workspace', repo: wsRoot, work: wsRoot }];
|
|
122
|
+
for (const app of loadApps(wsRoot)) {
|
|
123
|
+
if (appFilter && !appFilter.has(app.name)) continue;
|
|
124
|
+
const dir = path.join(wsRoot, '.codespace', app.name);
|
|
125
|
+
repos.push({ name: app.name, repo: dir, work: dir });
|
|
126
|
+
}
|
|
127
|
+
return repos;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasRef(repoPath, ref) {
|
|
131
|
+
return run('git rev-parse --verify --quiet "' + ref + '"', repoPath).ok;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 清理临时 worktree(失败仅警告,不阻断) */
|
|
135
|
+
function cleanupTempWorktree(repoPath, tmpDir) {
|
|
136
|
+
if (!tmpDir) return;
|
|
137
|
+
const removed = run('git worktree remove --force "' + tmpDir + '"', repoPath);
|
|
138
|
+
if (!removed.ok && fs.existsSync(tmpDir)) {
|
|
139
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* Windows 锁:留给用户 */ }
|
|
140
|
+
}
|
|
141
|
+
run('git worktree prune', repoPath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function main() {
|
|
145
|
+
const args = process.argv.slice(2);
|
|
146
|
+
const wsRoot = args[0];
|
|
147
|
+
const branch = args[1];
|
|
148
|
+
|
|
149
|
+
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]');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const appsIdx = args.indexOf('--apps');
|
|
155
|
+
const appFilter = appsIdx >= 0
|
|
156
|
+
? new Set((args[appsIdx + 1] || '').split(',').map(s => s.trim()).filter(Boolean))
|
|
157
|
+
: null;
|
|
158
|
+
const targetIdx = args.indexOf('--target');
|
|
159
|
+
const targetArg = targetIdx >= 0 ? (args[targetIdx + 1] || '').trim() : '';
|
|
160
|
+
if (targetIdx >= 0 && !targetArg) {
|
|
161
|
+
console.error('--target 需要提供分支名');
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
const allowCheckout = args.includes('--allow-checkout');
|
|
165
|
+
const dryRun = args.includes('--dry-run');
|
|
166
|
+
const checkOnly = args.includes('--check');
|
|
167
|
+
|
|
168
|
+
const repos = buildRepos(wsRoot, appFilter);
|
|
169
|
+
|
|
170
|
+
// ── --check:仅判定每仓库是否已合并到目标 ─────────────────
|
|
171
|
+
if (checkOnly) {
|
|
172
|
+
const results = [];
|
|
173
|
+
for (const rp of repos) {
|
|
174
|
+
const rec = { name: rp.name, status: 'unmerged', merged: null, main: null, target: null };
|
|
175
|
+
if (!fs.existsSync(path.join(rp.repo, '.git'))) {
|
|
176
|
+
rec.status = 'no-repo';
|
|
177
|
+
results.push(rec);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
run('git fetch --quiet origin', rp.repo);
|
|
181
|
+
const main = detectMain(rp.repo);
|
|
182
|
+
if (!main) {
|
|
183
|
+
rec.status = 'failed';
|
|
184
|
+
results.push(rec);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
rec.main = main;
|
|
188
|
+
const target = targetArg || main;
|
|
189
|
+
rec.target = target;
|
|
190
|
+
if (!hasRef(rp.repo, 'refs/heads/' + branch)) {
|
|
191
|
+
rec.status = 'no-branch';
|
|
192
|
+
results.push(rec);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const merged = (!targetArg || target === main)
|
|
196
|
+
? mergedToMain(rp.repo, branch, main)
|
|
197
|
+
: mergedToTarget(rp.repo, branch, target);
|
|
198
|
+
rec.merged = merged;
|
|
199
|
+
rec.status = merged ? 'merged' : 'unmerged';
|
|
200
|
+
results.push(rec);
|
|
201
|
+
}
|
|
202
|
+
results.forEach(r => console.log(r.status.toUpperCase() + ' ' + r.name + (r.merged ? ' → ' + r.merged : '') + (r.target ? ' [target: ' + r.target + ']' : '')));
|
|
203
|
+
const ok = results.every(r => r.status !== 'unmerged' && r.status !== 'failed');
|
|
204
|
+
console.log('---');
|
|
205
|
+
console.log('MERGE_CHECK_RESULT', JSON.stringify({ branch, target: targetArg || null, results }));
|
|
206
|
+
process.exit(ok ? 0 : 2);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── 阶段 1:预检 ──────────────────────────────────────────
|
|
210
|
+
const prechecks = [];
|
|
211
|
+
for (const rp of repos) {
|
|
212
|
+
const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
|
|
213
|
+
if (!fs.existsSync(path.join(rp.repo, '.git'))) {
|
|
214
|
+
rec.status = 'skipped';
|
|
215
|
+
rec.reason = 'no-repo';
|
|
216
|
+
prechecks.push(rec);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const fetch = run('git fetch --quiet origin', rp.repo);
|
|
220
|
+
if (!fetch.ok) {
|
|
221
|
+
rec.status = 'failed';
|
|
222
|
+
rec.reason = 'fetch-failed';
|
|
223
|
+
prechecks.push(rec);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const main = detectMain(rp.repo);
|
|
227
|
+
if (!main) {
|
|
228
|
+
rec.status = 'failed';
|
|
229
|
+
rec.reason = 'cannot-detect-main';
|
|
230
|
+
prechecks.push(rec);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
rec.main = main;
|
|
234
|
+
const target = targetArg || main;
|
|
235
|
+
rec.target = target;
|
|
236
|
+
|
|
237
|
+
if (!hasRef(rp.repo, 'refs/heads/' + branch)) {
|
|
238
|
+
rec.status = 'skipped';
|
|
239
|
+
rec.reason = 'no-branch';
|
|
240
|
+
prechecks.push(rec);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const targetLocal = hasRef(rp.repo, 'refs/heads/' + target);
|
|
244
|
+
const targetRemote = hasRef(rp.repo, 'refs/remotes/origin/' + target);
|
|
245
|
+
if (!targetLocal && !targetRemote) {
|
|
246
|
+
rec.status = 'failed';
|
|
247
|
+
rec.reason = 'target-not-found';
|
|
248
|
+
prechecks.push(rec);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
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
|
+
}
|
|
301
|
+
}
|
|
302
|
+
prechecks.push(rec);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const hasPrecheckFailure = prechecks.some(p => p.status === 'failed');
|
|
306
|
+
prechecks.forEach(p => {
|
|
307
|
+
if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
|
|
308
|
+
else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
|
|
309
|
+
else console.log('OK ' + p.name + ' → 合并至 ' + p.target + (p.temp ? '(临时 worktree)' : ''));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (hasPrecheckFailure) {
|
|
313
|
+
console.log('---');
|
|
314
|
+
console.log('MERGE_RESULT', JSON.stringify({ phase: 'precheck', results: prechecks }));
|
|
315
|
+
process.exit(2);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (dryRun) {
|
|
319
|
+
console.log('---');
|
|
320
|
+
console.log('MERGE_RESULT', JSON.stringify({ phase: 'precheck', dry_run: true, results: prechecks }));
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── 阶段 2:执行(停止式,不回滚) ────────────────────────
|
|
325
|
+
const results = prechecks.map(p => ({ ...p, status: p.status, detail: null }));
|
|
326
|
+
let stopped = false;
|
|
327
|
+
|
|
328
|
+
for (let i = 0; i < prechecks.length; i += 1) {
|
|
329
|
+
const p = prechecks[i];
|
|
330
|
+
const rp = repos[i];
|
|
331
|
+
if (p.status !== 'ok') continue;
|
|
332
|
+
if (stopped) {
|
|
333
|
+
results[i].status = 'not-attempted';
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const target = p.target;
|
|
338
|
+
const mergeMsg = "Merge branch '" + branch + "'";
|
|
339
|
+
|
|
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;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
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);
|
|
418
|
+
if (!merge.ok) {
|
|
419
|
+
run('git merge --abort', workDir);
|
|
420
|
+
cleanupTempWorktree(rp.repo, tmpDir);
|
|
421
|
+
results[i].status = 'failed';
|
|
422
|
+
results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
|
|
423
|
+
stopped = true;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
|
|
428
|
+
if (!push.ok) {
|
|
429
|
+
const code = classifyPushError(push.err);
|
|
430
|
+
if (code === 'non-fast-forward') {
|
|
431
|
+
// 远端已前进:在合并工作区 ff 同步后重试一次
|
|
432
|
+
const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', workDir);
|
|
433
|
+
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);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!push.ok) {
|
|
439
|
+
const code2 = classifyPushError(push.err);
|
|
440
|
+
cleanupTempWorktree(rp.repo, tmpDir);
|
|
441
|
+
if (code2 === 'branch-protected') {
|
|
442
|
+
results[i].status = 'need-mr';
|
|
443
|
+
results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
|
|
444
|
+
} else {
|
|
445
|
+
results[i].status = 'failed';
|
|
446
|
+
results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
|
|
447
|
+
}
|
|
448
|
+
stopped = true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
cleanupTempWorktree(rp.repo, tmpDir);
|
|
453
|
+
results[i].status = 'merged';
|
|
454
|
+
results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
results.forEach(r => {
|
|
458
|
+
console.log(r.status.toUpperCase() + ' ' + r.name + (r.detail ? ' — ' + r.detail : ''));
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const ok = results.every(r => r.status === 'merged' || r.status === 'skipped');
|
|
462
|
+
console.log('---');
|
|
463
|
+
console.log('MERGE_RESULT', JSON.stringify({ phase: 'execute', target: targetArg || null, results }));
|
|
464
|
+
process.exit(ok ? 0 : 2);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (require.main === module) main();
|
|
468
|
+
|
|
469
|
+
module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cbb-local-mode
|
|
3
|
-
description: "业务空间本地模式开发:空间根与各涉及应用在 .codespace/<app>/ 就地建同名分支,当前会话直接开发(不建 worktree、不换目录)。触发:用户说'本地模式'/'就地开发'/'不走 worktree'/'在这个窗口开发',或双模式选择时用户选本地模式。覆盖开工(状态自检→布局确认→切分支)、开发、提交推送(转 cbb-worktree-push
|
|
3
|
+
description: "业务空间本地模式开发:空间根与各涉及应用在 .codespace/<app>/ 就地建同名分支,当前会话直接开发(不建 worktree、不换目录)。触发:用户说'本地模式'/'就地开发'/'不走 worktree'/'在这个窗口开发',或双模式选择时用户选本地模式。覆盖开工(状态自检→布局确认→切分支)、开发、提交推送(转 cbb-worktree-push)、收尾(收尾合并门禁 ADR 0007:MR / 直合并 / 确认废弃后回 main + 删分支)、升级 worktree(重跑 cbb-worktree-init)。一次只承载一个需求。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Local Mode(本地模式)
|
|
@@ -14,7 +14,8 @@ description: "业务空间本地模式开发:空间根与各涉及应用在 .c
|
|
|
14
14
|
- **两模式由用户选择,agent 只给推荐**:需要并行 / 隔离 → cbb-worktree-init;单线程快改 → 本技能。涉及应用代码不再是 worktree 独占——本地模式同样支持(`.codespace/<app>/` 就地切分支)
|
|
15
15
|
- **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**:本地模式暂态占用视图合法;main 检出是基准快照,**严禁直接在 main 检出上提交**(pre-commit hook 会拦)
|
|
16
16
|
- **一次只承载一个需求**:本地模式独占空间根与各 app 的本地检出;期间并行新需求只能走 worktree 模式(两模式互不干扰,worktree 分支从 `origin/main` 建,不依赖本地检出状态)
|
|
17
|
-
- **提交防线在 hook**:分支感知 pre-commit hook 拦截一切 main 上的载荷提交(workspace 白名单外 /
|
|
17
|
+
- **提交防线在 hook**:分支感知 pre-commit hook 拦截一切 main 上的载荷提交(workspace 白名单外 / 应用全拦;merge 提交放行);被拦 = 大声失败,按指引回分支,**禁止自动 `--no-verify` 绕过**
|
|
18
|
+
- **收尾合并门禁(ADR 0007,与 worktree 模式对称)**:收尾前每个参与仓库必须为"已合并",未合并时三选一——**提 MR** / **直合并**(`merge-branches.js --allow-checkout`)/ **确认废弃**(远程重命名 `<需求名>-deprecated`)。**硬门禁:未合并且非废弃一律不允许收尾**
|
|
18
19
|
- 所有原子操作封装为 `scripts/*.js`(跨平台 Node.js),调用方式与 worktree 三件套一致:`node "<skill_dir>/scripts/<name>.js" <参数>`,cwd 保持空间根
|
|
19
20
|
|
|
20
21
|
---
|
|
@@ -75,16 +76,43 @@ node "<skill_dir>/scripts/start-local.js" "<ws_root>" "<branch>" --apps "<app1>,
|
|
|
75
76
|
|
|
76
77
|
对话中说"提交并推送"即触发 `cbb-worktree-push`——其仓库发现已支持**本地布局**(根 + 持有同名分支的 `.codespace/<app>/`),dry-run 预览(含 agent 生成的 commit 信息)→ 批量 commit → push → MR 预填链接,全流程与 worktree 模式一致。
|
|
77
78
|
|
|
78
|
-
## Step 5
|
|
79
|
+
## Step 5:收尾(收尾合并门禁 · ADR 0007)
|
|
79
80
|
|
|
80
|
-
MR
|
|
81
|
+
MR 合并、验收通过后回到本会话收尾。**与 worktree 模式同门禁**:每个参与仓库(空间根 + 各参与 `.codespace/<app>/`)必须为"已合并"。
|
|
82
|
+
|
|
83
|
+
**5.1 交付前置**:未提交改动 → 先提交;未归档 change → 先归档并提交(**合并前必须已归档 + 已提交**)。
|
|
84
|
+
|
|
85
|
+
**5.2 判定是否已合并**(复用共享判定,算法与 close 的 `safety-check` 同源):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --check
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- 解读末行 `MERGE_CHECK_RESULT`:每仓库 `merged`(含 `origin/main` / `main-via-release-*`)/ `unmerged` / `no-branch`
|
|
92
|
+
- 全部 `merged` / `no-branch`(退出码 0)→ 直接进 5.4
|
|
93
|
+
- 存在 `unmerged`(退出码 2)→ 进 5.3
|
|
94
|
+
|
|
95
|
+
**5.3 门禁三选一**(`AskUserQuestion`,同 close Step 3.4):
|
|
96
|
+
|
|
97
|
+
- **提 MR** → **中止收尾**,引导用户运行 `cbb-worktree-push`(说"提交并推送")生成 MR 预填链接(其 Step 7:目标分支候选交互 + `make-mr-urls.js`)→ 合并完重跑本步
|
|
98
|
+
- **直合并** → `merge-branches.js`(本地检出被需求分支占用,**必须加 `--allow-checkout`** 切回主干):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --allow-checkout
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`need-mr`(远端主干受保护)→ 改用提 MR;`failed` → 输出明细、修复后重跑
|
|
105
|
+
- **确认废弃** → `deprecate-branches.js`(远程重命名 `<需求名>-deprecated`;本地分支保留原名)
|
|
106
|
+
|
|
107
|
+
**5.4 交付完成 → 各仓库回主干并删分支**:
|
|
81
108
|
|
|
82
109
|
```bash
|
|
83
110
|
git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" branch -d "<需求名>"
|
|
84
111
|
```
|
|
85
112
|
|
|
113
|
+
- 各仓库本地分支删除后,远程分支删除由 `delete-branches.js local_remote` 完成(删除前输出将删清单)
|
|
86
114
|
- `checkout` 被 hook 拦(说明 main 落后或本地 main 有分叉)→ 先 `pull --ff-only`,再删分支
|
|
87
|
-
- openspec change
|
|
115
|
+
- openspec change 归档在合并前执行(产物在分支上随交付合入)
|
|
88
116
|
- 全部仓库回 main 后向用户报告"本地模式已收尾,空间回到干净态"
|
|
89
117
|
|
|
90
118
|
## 升级 worktree(中途要并行 / 要隔离)
|
|
@@ -104,7 +132,7 @@ git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" b
|
|
|
104
132
|
| commit 被 hook 拦(main 载荷) | 大声报错是预期行为:回分支重做;禁止自动 --no-verify |
|
|
105
133
|
| 误在 main 检出上写了文件 | `git checkout -b <需求名>` 后改动随分支走;已 staged 则 unstage 后再切 |
|
|
106
134
|
| 提交推送 | 说"提交并推送" → cbb-worktree-push(本地布局自动发现) |
|
|
107
|
-
| 收尾 |
|
|
135
|
+
| 收尾 | 收尾合并门禁(ADR 0007):`merge-branches.js --check` 判已合并 → 全合并则各仓库 checkout main + pull + 删分支;否则三选一(MR / 直合并 `--allow-checkout` / 确认废弃) |
|
|
108
136
|
| 要并行新需求 | 转 worktree 模式(init 分支从 origin/main 建,不占本地检出);本地需求继续占用根 |
|
|
109
137
|
| 升级隔离 | 重跑 cbb-worktree-init(提交随身带走);先回 main 腾检出 |
|
|
110
138
|
|
|
@@ -148,11 +176,15 @@ git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" b
|
|
|
148
176
|
- 自动 stash 兜底切换失败(用户决策)
|
|
149
177
|
- 同时承载第二个本地需求(并行走 worktree)
|
|
150
178
|
- 把 `.codespace/<app>/` 的 main 检出当开发区(基准快照,本地模式只在分支上开发)
|
|
179
|
+
- **未合并且未确认为废弃时收尾**(违反 ADR 0007 硬门禁)
|
|
180
|
+
- 未经二次确认执行废弃重命名(远程重命名不可逆)
|
|
151
181
|
|
|
152
182
|
**必须执行**
|
|
153
183
|
|
|
154
184
|
- Step 0 自检 → clean 才开工
|
|
155
185
|
- 布局展示 + 用户确认后才切分支
|
|
156
186
|
- 切分支统一走 `start-local.js`(基线 = `<remote>/<main>`,不取 HEAD)
|
|
187
|
+
- 收尾先过门禁判定(`merge-branches.js --check`);未合并必须三选一后收尾
|
|
188
|
+
- 直合并走 `merge-branches.js --allow-checkout`(本地检出需从需求分支切回主干)
|
|
157
189
|
- 收尾三连(checkout main / pull / 删分支)完成后报告干净态
|
|
158
190
|
- 所有写操作前的分支确认(当前检出 = 需求分支)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require('./silence-popup');
|
|
3
|
+
/**
|
|
4
|
+
* 收尾合并门禁 —— 废弃分支出口(ADR 0007)
|
|
5
|
+
*
|
|
6
|
+
* 用户在门禁处确认"分支废弃、不再交付"后,把各参与仓库远端的需求分支重命名为
|
|
7
|
+
* <branch>-deprecated(保留历史、去掉占用中的旧名),**不删除任何提交**。
|
|
8
|
+
* 本地分支保持原名不动;未推送的仓库(远端无该分支)跳过。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* node deprecate-branches.js <ws_root> <branch> [--apps a,b]
|
|
12
|
+
*
|
|
13
|
+
* 退出码:0 全部成功(含 skipped)/ 1 参数错误 / 2 存在失败
|
|
14
|
+
* 末行:DEPRECATE_RESULT [...]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { execSync } = require('child_process');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
function run(cmd, cwd) {
|
|
22
|
+
try {
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
out: execSync(cmd, {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
timeout: 120000,
|
|
30
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
31
|
+
}).trim(),
|
|
32
|
+
};
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { ok: false, err: (e.stderr || e.message || String(e)).trim() };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadApps(wsRoot) {
|
|
39
|
+
try {
|
|
40
|
+
const config = JSON.parse(fs.readFileSync(path.join(wsRoot, 'CBB-WORKSPACE.json'), 'utf-8'));
|
|
41
|
+
return Array.isArray(config.apps) ? config.apps : [];
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 对单个仓库执行废弃重命名 */
|
|
48
|
+
function deprecateRepo(name, repo, branch) {
|
|
49
|
+
const newName = branch + '-deprecated';
|
|
50
|
+
const rec = { name, status: 'skipped', detail: null };
|
|
51
|
+
if (!fs.existsSync(path.join(repo, '.git'))) {
|
|
52
|
+
rec.detail = 'no-repo';
|
|
53
|
+
return rec;
|
|
54
|
+
}
|
|
55
|
+
const remote = run('git remote', repo);
|
|
56
|
+
if (!remote.ok || !remote.out) {
|
|
57
|
+
rec.detail = 'no-remote';
|
|
58
|
+
return rec;
|
|
59
|
+
}
|
|
60
|
+
const r = remote.out.split(/\r?\n/)[0];
|
|
61
|
+
// 仅重命名远端确实存在的分支
|
|
62
|
+
const exists = run('git ls-remote --heads "' + r + '" "refs/heads/' + branch + '"', repo);
|
|
63
|
+
if (!exists.ok || !exists.out) {
|
|
64
|
+
rec.detail = 'remote-has-no-branch';
|
|
65
|
+
return rec;
|
|
66
|
+
}
|
|
67
|
+
const pushNew = run('git push "' + r + '" "refs/heads/' + branch + ':refs/heads/' + newName + '"', repo);
|
|
68
|
+
if (!pushNew.ok) {
|
|
69
|
+
rec.status = 'failed';
|
|
70
|
+
rec.detail = 'push-new-failed: ' + (pushNew.err || '').split(/\r?\n/)[0];
|
|
71
|
+
return rec;
|
|
72
|
+
}
|
|
73
|
+
const delOld = run('git push "' + r + '" --delete "' + branch + '"', repo);
|
|
74
|
+
if (!delOld.ok) {
|
|
75
|
+
rec.status = 'failed';
|
|
76
|
+
rec.detail = 'delete-old-failed: ' + (delOld.err || '').split(/\r?\n/)[0];
|
|
77
|
+
return rec;
|
|
78
|
+
}
|
|
79
|
+
rec.status = 'deprecated';
|
|
80
|
+
rec.detail = 'remote ' + branch + ' → ' + newName;
|
|
81
|
+
return rec;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function main() {
|
|
85
|
+
const args = process.argv.slice(2);
|
|
86
|
+
const wsRoot = args[0];
|
|
87
|
+
const branch = args[1];
|
|
88
|
+
|
|
89
|
+
if (!wsRoot || !branch || branch.indexOf('--') === 0) {
|
|
90
|
+
console.error('Usage: node deprecate-branches.js <ws_root> <branch> [--apps a,b]');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
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
|
+
|
|
99
|
+
const repos = [{ name: 'workspace', repo: wsRoot }];
|
|
100
|
+
for (const app of loadApps(wsRoot)) {
|
|
101
|
+
if (appFilter && !appFilter.has(app.name)) continue;
|
|
102
|
+
repos.push({ name: app.name, repo: path.join(wsRoot, '.codespace', app.name) });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const results = repos.map(rp => deprecateRepo(rp.name, rp.repo, branch));
|
|
106
|
+
|
|
107
|
+
results.forEach(r => {
|
|
108
|
+
console.log(r.status.toUpperCase() + ' ' + r.name + (r.detail ? ' — ' + r.detail : ''));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const ok = results.every(r => r.status !== 'failed');
|
|
112
|
+
console.log('---');
|
|
113
|
+
console.log('DEPRECATE_RESULT', JSON.stringify({ branch, new_name: branch + '-deprecated', results }));
|
|
114
|
+
process.exit(ok ? 0 : 2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (require.main === module) main();
|
|
118
|
+
|
|
119
|
+
module.exports = { run, loadApps, deprecateRepo };
|