@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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cbb-local-mode
3
- description: "业务空间本地模式开发:空间根与各涉及应用在 .codespace/<app>/ 就地建同名分支,当前会话直接开发(不建 worktree、不换目录)。触发:用户说'本地模式'/'就地开发'/'不走 worktree'/'在这个窗口开发',或双模式选择时用户选本地模式。覆盖开工(状态自检→布局确认→切分支)、开发、提交推送(转 cbb-worktree-push)、收尾(收尾合并门禁 ADR 0007:MR / 直合并 / 确认废弃后回 main + 删分支)、升级 worktree(重跑 cbb-worktree-init)。一次只承载一个需求。"
3
+ description: "业务空间本地模式开发:空间根与各涉及应用在 .codespace/<app>/ 就地建同名分支,当前会话直接开发(不建 worktree、不换目录)。触发:用户说'本地模式'/'就地开发'/'不走 worktree'/'在这个窗口开发',或双模式选择时用户选本地模式。覆盖开工(状态自检→布局确认→切分支)、开发、提交推送(转 cbb-worktree-push)、收尾(收尾合并门禁 ADR 0007:MR / 直合并 / 确认废弃后回主干 + 删分支)、升级 worktree(重跑 cbb-worktree-init)。一次只承载一个需求。"
4
4
  ---
5
5
 
6
6
  # Local Mode(本地模式)
@@ -15,7 +15,7 @@ description: "业务空间本地模式开发:空间根与各涉及应用在 .c
15
15
  - **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**:本地模式暂态占用视图合法;main 检出是基准快照,**严禁直接在 main 检出上提交**(pre-commit hook 会拦)
16
16
  - **一次只承载一个需求**:本地模式独占空间根与各 app 的本地检出;期间并行新需求只能走 worktree 模式(两模式互不干扰,worktree 分支从 `origin/main` 建,不依赖本地检出状态)
17
17
  - **提交防线在 hook**:分支感知 pre-commit hook 拦截一切 main 上的载荷提交(workspace 白名单外 / 应用全拦;merge 提交放行);被拦 = 大声失败,按指引回分支,**禁止自动 `--no-verify` 绕过**
18
- - **收尾合并门禁(ADR 0007,与 worktree 模式对称)**:收尾前每个参与仓库必须为"已合并",未合并时三选一——**提 MR** / **直合并**(`merge-branches.js --allow-checkout`)/ **确认废弃**(远程重命名 `<需求名>-deprecated`)。**硬门禁:未合并且非废弃一律不允许收尾**
18
+ - **收尾合并门禁(ADR 0007,与 worktree 模式对称)**:收尾前每个参与仓库必须为"已合并",未合并时三选一——**提 MR** / **直合并**(`merge-branches.js`,临时 worktree 零接触)/ **确认废弃**(远程重命名 `<需求名>-deprecated`)。**硬门禁:未合并且非废弃一律不允许收尾**
19
19
  - 所有原子操作封装为 `scripts/*.js`(跨平台 Node.js),调用方式与 worktree 三件套一致:`node "<skill_dir>/scripts/<name>.js" <参数>`,cwd 保持空间根
20
20
 
21
21
  ---
@@ -88,40 +88,50 @@ MR 合并、验收通过后回到本会话收尾。**与 worktree 模式同门
88
88
  node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --check
89
89
  ```
90
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
91
+ - 解读末行 `MERGE_CHECK_RESULT`:每仓库 `merged`(含 `origin/main` / `main-via-release-*`)/ `unmerged` / `no-branch`(该仓库无此本地分支,不参与放行)/ `no-repo`(目录缺失,不参与放行)/ `failed`(主干探测或 fetch 失败)
92
+ - 全部 `merged` / `no-branch` / `no-repo`(退出码 0)→ 直接进 5.4
93
+ - 存在 `unmerged`(退出码 2 且状态为 `unmerged`)→ 进 5.3
94
+ - 存在 `failed`(退出码 2,**与 `unmerged` 撞码,先看状态再行动**)→ 这不是"未合并":报告失败仓库与原因(`cannot-detect-main` / `fetch-failed`),修复环境(远端可达性 / 远端默认分支设置)后重跑 5.2;**禁止按未合并走 5.3 门禁**
94
95
 
95
96
  **5.3 门禁三选一**(`AskUserQuestion`,同 close Step 3.4):
96
97
 
97
98
  - **提 MR** → **中止收尾**,引导用户运行 `cbb-worktree-push`(说"提交并推送")生成 MR 预填链接(其 Step 7:目标分支候选交互 + `make-mr-urls.js`)→ 合并完重跑本步
98
- - **直合并** → `merge-branches.js`(本地检出被需求分支占用,**必须加 `--allow-checkout`** 切回主干):
99
+ - **直合并** → `merge-branches.js`(临时 worktree 合并,**不碰本地检出**——被需求分支占用也无需处理;交付后本地主干检出不随之前移,由 5.4 的 `return-to-trunk.js` 追平):
99
100
 
100
101
  ```bash
101
- node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --allow-checkout
102
+ node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>"
102
103
  ```
103
104
 
104
- `need-mr`(远端主干受保护)→ 改用提 MR;`failed` → 输出明细、修复后重跑;`AUTOSTASH_PENDING`(`MERGED` 但退出码 2)→ 交付已完成,仅暂存恢复遇冲突——**本地模式的改动会被恢复到已合入需求的 trunk 上(基线已变,冲突概率高于 worktree 模式)**,列冲突点问用户二选一(agent 解决 / 手动解决,协议同 close 出口二),处理前不得 drop stash
105
+ `need-mr`(远端主干受保护)→ 改用提 MR;`failed` → 输出明细、修复后重跑
105
106
  - **确认废弃** → `deprecate-branches.js`(远程重命名 `<需求名>-deprecated`;本地分支保留原名)
106
107
 
107
- **5.4 交付完成 → 各仓库回主干并删分支**:
108
+ **5.4 交付完成 → 各仓库回主干并删分支**(脚本化;主干名逐仓库经远端 HEAD 探测,兼容 main / master):
109
+
110
+ 1. 各仓库切回主干并追平远端(替代原裸 git 三连;不自动 stash,失败即报告):
108
111
 
109
112
  ```bash
110
- git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" branch -d "<需求名>"
113
+ node "<skill_dir>/scripts/return-to-trunk.js" "<ws_root>" --apps "<app1,app2>"
111
114
  ```
112
115
 
113
- - 各仓库本地分支删除后,远程分支删除由共享脚本完成(删除前输出将删清单):
116
+ **解读末行 `TRUNK_RESULT`**:
117
+ - 退出码 0 → 全部已回主干并追平
118
+ - 某仓库 `dirty-worktree` → 检出上有未提交改动(回归 5.1 前置,先处理再重跑);**不自动 stash**
119
+ - 某仓库 `pull-failed`(本地主干与 origin 分叉)→ 输出详情,交用户决策
120
+ - 某仓库 `cannot-detect-main` → 检查该仓库远端与主干分支(`fetch-failed` 同理)
121
+ - 幂等:已在主干的仓库仅追平远端,重复执行无害
122
+
123
+ 2. 本地 + 远程需求分支删除(本地删除含 squash 合并的 `-d` → `-D` 兜底;删除前输出将删清单):
114
124
 
115
125
  ```bash
116
126
  node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_remote
117
127
  ```
118
- - `checkout` 被 hook 拦(说明 main 落后或本地 main 有分叉)→ 先 `pull --ff-only`,再删分支
128
+
119
129
  - openspec change 归档在合并前执行(产物在分支上随交付合入)
120
- - 全部仓库回 main 后向用户报告"本地模式已收尾,空间回到干净态"
130
+ - 全部仓库回主干且分支删除后,向用户报告"本地模式已收尾,空间回到干净态"
121
131
 
122
132
  ## 升级 worktree(中途要并行 / 要隔离)
123
133
 
124
- **直接重跑 `cbb-worktree-init`(幂等)**:分支已存在自动复用(reused),worktree 从分支创建——本地阶段的提交**随身带走**,零成本升级。升级前提:本地检出先回 main(`git checkout main`,未提交改动先 commit 或用户决策),否则 worktree 检出与本地检出冲突。
134
+ **直接重跑 `cbb-worktree-init`(幂等)**:分支已存在自动复用(reused),worktree 从分支创建——本地阶段的提交**随身带走**,零成本升级。升级前提:本地检出先回主干(跑 5.4 的 `return-to-trunk.js`,主干名以远端 HEAD 为准;未提交改动先 commit 或用户决策),否则 worktree 检出与本地检出冲突。
125
135
 
126
136
  ---
127
137
 
@@ -136,9 +146,9 @@ node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_re
136
146
  | commit 被 hook 拦(main 载荷) | 大声报错是预期行为:回分支重做;禁止自动 --no-verify |
137
147
  | 误在 main 检出上写了文件 | `git checkout -b <需求名>` 后改动随分支走;已 staged 则 unstage 后再切 |
138
148
  | 提交推送 | 说"提交并推送" → cbb-worktree-push(本地布局自动发现) |
139
- | 收尾 | 收尾合并门禁(ADR 0007):`merge-branches.js --check` 判已合并 → 全合并则各仓库 checkout main + pull + 删分支;否则三选一(MR / 直合并 `--allow-checkout` / 确认废弃) |
149
+ | 收尾 | 收尾合并门禁(ADR 0007):`merge-branches.js --check` 判已合并 → 全合并则 `return-to-trunk.js` 回主干 + `delete-branches.js` 删分支(主干名探测);否则三选一(MR / 直合并(临时 worktree) / 确认废弃) |
140
150
  | 要并行新需求 | 转 worktree 模式(init 分支从 origin/main 建,不占本地检出);本地需求继续占用根 |
141
- | 升级隔离 | 重跑 cbb-worktree-init(提交随身带走);先回 main 腾检出 |
151
+ | 升级隔离 | 重跑 cbb-worktree-init(提交随身带走);先回主干腾检出(return-to-trunk.js) |
142
152
 
143
153
  ---
144
154
 
@@ -161,8 +171,8 @@ node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_re
161
171
 
162
172
  ### 收尾忘记 pull
163
173
 
164
- - **问题:** `checkout main` 后直接删分支,本地 main 陈旧,下次 `start-local` 从旧基线建分支
165
- - **修复:** 收尾固定三连:checkout main && pull && branch -d
174
+ - **问题:** 回主干后未追平远端,本地主干陈旧,下次 `start-local` 从旧基线建分支
175
+ - **修复:** 收尾固定两步脚本:`return-to-trunk.js`(切回主干 + `pull --ff-only` 追平)→ `delete-branches.js` 删分支
166
176
 
167
177
  ### 中途插单静默混入
168
178
 
@@ -189,6 +199,6 @@ node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_re
189
199
  - 布局展示 + 用户确认后才切分支
190
200
  - 切分支统一走 `start-local.js`(基线 = `<remote>/<main>`,不取 HEAD)
191
201
  - 收尾先过门禁判定(`merge-branches.js --check`);未合并必须三选一后收尾
192
- - 直合并走 `merge-branches.js --allow-checkout`(本地检出需从需求分支切回主干)
193
- - 收尾三连(checkout main / pull / 删分支)完成后报告干净态
202
+ - 直合并走 `merge-branches.js`(临时 worktree,零接触;本地检出由 5.4 `return-to-trunk.js` 切回主干)
203
+ - 收尾脚本化(`return-to-trunk.js` 回主干 + `delete-branches.js` 删分支)完成后报告干净态
194
204
  - 所有写操作前的分支确认(当前检出 = 需求分支)
@@ -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 <分支>] [--allow-checkout] [--dry-run]
13
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--dry-run]
14
14
  * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
15
15
  *
16
- * 目标分支:
17
- * 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push;
18
- * 检出未提交改动 autostash 让路,交付后自动恢复)
19
- * --target 指定非主干分支:目标 == 当前检出 → 就地合并(同主干快路径处理)
20
- * 否则 → 临时 worktree(基于 origin/<target>,不动用户检出)
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. 预检:逐仓库校验(目标可解析 / 分支存在 / 主干不领先 origin);
27
- * 检出上未提交改动(含未跟踪)不再拦截——执行阶段自动暂存让路(autostash)
27
+ * 1. 预检:逐仓库校验(目标可解析 / 分支存在 / 目标分支存在)
28
28
  * 任一不过 → 退出 2,不执行任何合并
29
- * 2. 执行:autostash(如有)→ checkout 目标 → pull --ff-only → merge --no-ff
30
- * → push → 自动恢复暂存;执行中失败 → 先恢复暂存再停止并报告已完成清单
31
- * (不回滚;重跑幂等)。恢复冲突时保留 stash 条目交用户决策
32
- * ("后提交者解决":暂存侧改动适配已交付主干,agent 代解或用户手动二选一)
29
+ * 2. 执行:临时 worktree 合并 → push;执行中失败 → 停止并报告已完成清单
30
+ * (不回滚;重跑幂等)
33
31
  * --dry-run 只做预检,不执行
34
32
  *
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 行处理)
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] [--allow-checkout] [--dry-run] [--check]');
154
+ console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--dry-run] [--check]');
155
+ process.exit(1);
156
+ }
157
+ if (args.includes('--allow-checkout')) {
158
+ console.error('--allow-checkout 已退役:合并统一走临时 worktree,不触碰本地检出');
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, temp: false, reason: null, uncommitted: 0 };
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
- if (target === main) {
358
- // 主干路径(快路径,与 ADR 0007 收尾门禁一致)
359
- if (mergedToMain(rp.repo, branch, main)) {
360
- rec.status = 'skipped';
361
- rec.reason = 'already-merged';
362
- prechecks.push(rec);
363
- continue;
364
- }
365
- // 检出脏不再拦截:记录未提交项数,执行阶段 autostash 让路、合并后恢复(ADR 0008 扩展)
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
- if (target === p.main) {
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
- }
464
- const current = run('git branch --show-current', rp.work);
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
- // ── 指定分支路径(当前检出即目标 → 就地 + autostash;否则临时 worktree,不碰检出) ──
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', workDir);
320
+ run('git merge --abort', tmpDir);
533
321
  cleanupTempWorktree(rp.repo, tmpDir);
534
- finish('failed', 'merge-failed: ' + merge.err.split(/\r?\n/)[0]);
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 + '"', workDir);
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', workDir);
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 + '"', workDir);
546
- if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
335
+ const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', tmpDir);
336
+ if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', tmpDir);
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
- finish('need-mr', 'branch-protected(目标分支受保护,请改用 MR 出口)');
343
+ results[i].status = 'need-mr';
344
+ results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
554
345
  } else {
555
- finish('failed', 'push-failed: ' + (push.err || '').split(/\r?\n/)[0]);
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
- finish('merged', 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : ''));
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
- // autostashPending 按失败退出:交付可能已完成,但用户改动还挂在 stash,必须决策处理后重跑确认
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, autostashPush, autostashPop };
370
+ module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos };