@jspg-ai/coding-bb 0.0.4 → 0.0.5-beta.1
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 +170 -59
- package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +1 -1
- package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +170 -59
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +2 -1
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +170 -59
- package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +4 -3
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/merge-branches.js +170 -59
- package/package.json +1 -1
|
@@ -14,19 +14,29 @@ require('./silence-popup');
|
|
|
14
14
|
* node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
|
|
15
15
|
*
|
|
16
16
|
* 目标分支:
|
|
17
|
-
* 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push
|
|
18
|
-
*
|
|
17
|
+
* 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push;
|
|
18
|
+
* 检出未提交改动 autostash 让路,交付后自动恢复)
|
|
19
|
+
* --target 指定非主干分支:目标 == 当前检出 → 就地合并(同主干快路径处理)
|
|
20
|
+
* 否则 → 临时 worktree(基于 origin/<target>,不动用户检出)
|
|
19
21
|
*
|
|
20
22
|
* 模式:
|
|
21
23
|
* --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
|
|
22
24
|
* 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
|
|
23
25
|
* 默认 两阶段(ADR 0007):
|
|
24
|
-
* 1. 预检:逐仓库校验(目标可解析 /
|
|
26
|
+
* 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 主干不领先 origin);
|
|
27
|
+
* 检出上未提交改动(含未跟踪)不再拦截——执行阶段自动暂存让路(autostash)
|
|
25
28
|
* 任一不过 → 退出 2,不执行任何合并
|
|
26
|
-
* 2. 执行:
|
|
29
|
+
* 2. 执行:autostash(如有)→ checkout 目标 → pull --ff-only → merge --no-ff
|
|
30
|
+
* → push → 自动恢复暂存;执行中失败 → 先恢复暂存再停止并报告已完成清单
|
|
31
|
+
* (不回滚;重跑幂等)。恢复冲突时保留 stash 条目交用户决策
|
|
32
|
+
* ("后提交者解决":暂存侧改动适配已交付主干,agent 代解或用户手动二选一)
|
|
27
33
|
* --dry-run 只做预检,不执行
|
|
28
34
|
*
|
|
29
|
-
*
|
|
35
|
+
* 恢复机制取舍:先 pop --index(还原 staged 分区);若其失败且有冲突,reset --hard 回到
|
|
36
|
+
* 本次合并的 HEAD 后 plain apply 一次——保证冲突只"单次应用"落盘(双重应用风险为零),
|
|
37
|
+
* 代价是冲突场景下 staged/unstaged 分区扁平化(内容无损)。ignored 文件不参与暂存。
|
|
38
|
+
* 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr /
|
|
39
|
+
* unmerged / autostashPending——交付已完成,只是暂存未恢复,按 AUTOSTASH_PENDING 行处理)
|
|
30
40
|
* 末行:MERGE_RESULT [...] 或 MERGE_CHECK_RESULT [...]
|
|
31
41
|
*/
|
|
32
42
|
|
|
@@ -141,6 +151,101 @@ function cleanupTempWorktree(repoPath, tmpDir) {
|
|
|
141
151
|
run('git worktree prune', repoPath);
|
|
142
152
|
}
|
|
143
153
|
|
|
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
|
+
|
|
144
249
|
function main() {
|
|
145
250
|
const args = process.argv.slice(2);
|
|
146
251
|
const wsRoot = args[0];
|
|
@@ -209,7 +314,7 @@ function main() {
|
|
|
209
314
|
// ── 阶段 1:预检 ──────────────────────────────────────────
|
|
210
315
|
const prechecks = [];
|
|
211
316
|
for (const rp of repos) {
|
|
212
|
-
const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
|
|
317
|
+
const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null, uncommitted: 0 };
|
|
213
318
|
if (!fs.existsSync(path.join(rp.repo, '.git'))) {
|
|
214
319
|
rec.status = 'skipped';
|
|
215
320
|
rec.reason = 'no-repo';
|
|
@@ -257,13 +362,8 @@ function main() {
|
|
|
257
362
|
prechecks.push(rec);
|
|
258
363
|
continue;
|
|
259
364
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
rec.status = 'failed';
|
|
263
|
-
rec.reason = 'dirty-worktree';
|
|
264
|
-
prechecks.push(rec);
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
365
|
+
// 检出脏不再拦截:记录未提交项数,执行阶段 autostash 让路、合并后恢复(ADR 0008 扩展)
|
|
366
|
+
rec.uncommitted = countUncommitted(rp.work);
|
|
267
367
|
const current = run('git branch --show-current', rp.work);
|
|
268
368
|
if (current.out && current.out !== main && !allowCheckout) {
|
|
269
369
|
rec.status = 'failed';
|
|
@@ -279,7 +379,7 @@ function main() {
|
|
|
279
379
|
continue;
|
|
280
380
|
}
|
|
281
381
|
} else {
|
|
282
|
-
//
|
|
382
|
+
// 指定分支路径:已合并则跳过;当前检出即目标 → 就地(autostash);否则临时 worktree
|
|
283
383
|
if (mergedToTarget(rp.repo, branch, target)) {
|
|
284
384
|
rec.status = 'skipped';
|
|
285
385
|
rec.reason = 'already-merged';
|
|
@@ -288,15 +388,10 @@ function main() {
|
|
|
288
388
|
}
|
|
289
389
|
const current = run('git branch --show-current', rp.work);
|
|
290
390
|
if (current.out === target) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
rec.status = 'failed';
|
|
294
|
-
rec.reason = 'dirty-worktree';
|
|
295
|
-
prechecks.push(rec);
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
391
|
+
// 就地合并:同主干快路径,检出脏不拦截,执行阶段 autostash 让路 / 恢复
|
|
392
|
+
rec.uncommitted = countUncommitted(rp.work);
|
|
298
393
|
} else {
|
|
299
|
-
rec.temp = true; // 执行阶段创建临时 worktree
|
|
394
|
+
rec.temp = true; // 执行阶段创建临时 worktree(基于 origin/<target>,不碰用户检出)
|
|
300
395
|
}
|
|
301
396
|
}
|
|
302
397
|
prechecks.push(rec);
|
|
@@ -306,7 +401,11 @@ function main() {
|
|
|
306
401
|
prechecks.forEach(p => {
|
|
307
402
|
if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
|
|
308
403
|
else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
|
|
309
|
-
else
|
|
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
|
+
}
|
|
310
409
|
});
|
|
311
410
|
|
|
312
411
|
if (hasPrecheckFailure) {
|
|
@@ -336,32 +435,49 @@ function main() {
|
|
|
336
435
|
|
|
337
436
|
const target = p.target;
|
|
338
437
|
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
|
+
};
|
|
339
456
|
|
|
340
457
|
if (target === p.main) {
|
|
341
|
-
// ──
|
|
458
|
+
// ── 主干路径(快路径):检出未提交改动 autostash 让路,交付后自动恢复 ──
|
|
459
|
+
st = autostashPush(rp.work, 'merge-' + branch + '-into-' + target);
|
|
460
|
+
if (!st.ok) {
|
|
461
|
+
finish('failed', 'autostash-failed: ' + st.err);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
342
464
|
const current = run('git branch --show-current', rp.work);
|
|
343
465
|
if (current.out !== target) {
|
|
344
466
|
const co = run('git checkout --quiet "' + target + '"', rp.work);
|
|
345
467
|
if (!co.ok) {
|
|
346
|
-
|
|
347
|
-
results[i].detail = 'checkout-failed: ' + co.err.split(/\r?\n/)[0];
|
|
348
|
-
stopped = true;
|
|
468
|
+
finish('failed', 'checkout-failed: ' + co.err.split(/\r?\n/)[0]);
|
|
349
469
|
continue;
|
|
350
470
|
}
|
|
351
471
|
}
|
|
352
472
|
const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
|
|
353
473
|
if (!pull.ok) {
|
|
354
|
-
|
|
355
|
-
results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
|
|
356
|
-
stopped = true;
|
|
474
|
+
finish('failed', 'pull-failed: ' + pull.err.split(/\r?\n/)[0]);
|
|
357
475
|
continue;
|
|
358
476
|
}
|
|
359
477
|
const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
|
|
360
478
|
if (!merge.ok) {
|
|
361
479
|
run('git merge --abort', rp.repo);
|
|
362
|
-
|
|
363
|
-
results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
|
|
364
|
-
stopped = true;
|
|
480
|
+
finish('failed', 'merge-failed: ' + merge.err.split(/\r?\n/)[0]);
|
|
365
481
|
continue;
|
|
366
482
|
}
|
|
367
483
|
let push = run('git push origin "' + target + '"', rp.repo);
|
|
@@ -374,22 +490,18 @@ function main() {
|
|
|
374
490
|
if (!push.ok) {
|
|
375
491
|
const code2 = classifyPushError(push.err);
|
|
376
492
|
if (code2 === 'branch-protected') {
|
|
377
|
-
|
|
378
|
-
results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
|
|
493
|
+
finish('need-mr', 'branch-protected(目标分支受保护,请改用 MR 出口)');
|
|
379
494
|
} else {
|
|
380
|
-
|
|
381
|
-
results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
|
|
495
|
+
finish('failed', 'push-failed: ' + (push.err || '').split(/\r?\n/)[0]);
|
|
382
496
|
}
|
|
383
|
-
stopped = true;
|
|
384
497
|
continue;
|
|
385
498
|
}
|
|
386
499
|
}
|
|
387
|
-
|
|
388
|
-
results[i].detail = 'merged+pushed origin/' + target;
|
|
500
|
+
finish('merged', 'merged+pushed origin/' + target);
|
|
389
501
|
continue;
|
|
390
502
|
}
|
|
391
503
|
|
|
392
|
-
// ── 指定分支路径(当前检出即目标 →
|
|
504
|
+
// ── 指定分支路径(当前检出即目标 → 就地 + autostash;否则临时 worktree,不碰检出) ──
|
|
393
505
|
let workDir = rp.work;
|
|
394
506
|
let tmpDir = null;
|
|
395
507
|
const current = run('git branch --show-current', rp.work);
|
|
@@ -398,18 +510,19 @@ function main() {
|
|
|
398
510
|
const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
|
|
399
511
|
const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
|
|
400
512
|
if (!add.ok) {
|
|
401
|
-
|
|
402
|
-
results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
|
|
403
|
-
stopped = true;
|
|
513
|
+
finish('failed', 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0]);
|
|
404
514
|
continue;
|
|
405
515
|
}
|
|
406
516
|
workDir = tmpDir;
|
|
407
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
|
+
}
|
|
408
523
|
const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
|
|
409
524
|
if (!pull.ok) {
|
|
410
|
-
|
|
411
|
-
results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
|
|
412
|
-
stopped = true;
|
|
525
|
+
finish('failed', 'pull-failed: ' + pull.err.split(/\r?\n/)[0]);
|
|
413
526
|
continue;
|
|
414
527
|
}
|
|
415
528
|
}
|
|
@@ -418,9 +531,7 @@ function main() {
|
|
|
418
531
|
if (!merge.ok) {
|
|
419
532
|
run('git merge --abort', workDir);
|
|
420
533
|
cleanupTempWorktree(rp.repo, tmpDir);
|
|
421
|
-
|
|
422
|
-
results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
|
|
423
|
-
stopped = true;
|
|
534
|
+
finish('failed', 'merge-failed: ' + merge.err.split(/\r?\n/)[0]);
|
|
424
535
|
continue;
|
|
425
536
|
}
|
|
426
537
|
|
|
@@ -439,26 +550,26 @@ function main() {
|
|
|
439
550
|
const code2 = classifyPushError(push.err);
|
|
440
551
|
cleanupTempWorktree(rp.repo, tmpDir);
|
|
441
552
|
if (code2 === 'branch-protected') {
|
|
442
|
-
|
|
443
|
-
results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
|
|
553
|
+
finish('need-mr', 'branch-protected(目标分支受保护,请改用 MR 出口)');
|
|
444
554
|
} else {
|
|
445
|
-
|
|
446
|
-
results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
|
|
555
|
+
finish('failed', 'push-failed: ' + (push.err || '').split(/\r?\n/)[0]);
|
|
447
556
|
}
|
|
448
|
-
stopped = true;
|
|
449
557
|
continue;
|
|
450
558
|
}
|
|
451
559
|
}
|
|
452
560
|
cleanupTempWorktree(rp.repo, tmpDir);
|
|
453
|
-
|
|
454
|
-
results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
|
|
561
|
+
finish('merged', 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : ''));
|
|
455
562
|
}
|
|
456
563
|
|
|
457
564
|
results.forEach(r => {
|
|
458
565
|
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
|
+
}
|
|
459
569
|
});
|
|
460
570
|
|
|
461
|
-
|
|
571
|
+
// autostashPending 按失败退出:交付可能已完成,但用户改动还挂在 stash,必须决策处理后重跑确认
|
|
572
|
+
const ok = results.every(r => (r.status === 'merged' || r.status === 'skipped') && !r.autostashPending);
|
|
462
573
|
console.log('---');
|
|
463
574
|
console.log('MERGE_RESULT', JSON.stringify({ phase: 'execute', target: targetArg || null, results }));
|
|
464
575
|
process.exit(ok ? 0 : 2);
|
|
@@ -466,4 +577,4 @@ function main() {
|
|
|
466
577
|
|
|
467
578
|
if (require.main === module) main();
|
|
468
579
|
|
|
469
|
-
module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos };
|
|
580
|
+
module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos, autostashPush, autostashPop };
|