@jspg-ai/coding-bb 0.0.3-beta.21 → 0.0.3-beta.22
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 +21 -14
- package/cbb/lib/install/init.js +29 -1
- package/cbb/lib/utils/githook.js +224 -0
- package/cbb/lib/utils/gitignore.js +5 -1
- package/cbb/worktrees/_shared/scripts/check-local-state.js +114 -0
- package/cbb/worktrees/_shared/scripts/find-target-worktree.js +148 -96
- package/cbb/worktrees/_shared/scripts/install-git-hook.js +70 -0
- package/cbb/worktrees/_shared/scripts/junction-ai-config.js +127 -0
- package/cbb/worktrees/_shared/scripts/start-local.js +180 -0
- package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +158 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/check-local-state.js +114 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +70 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/silence-popup.js +23 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/start-local.js +180 -0
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +9 -6
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +75 -48
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-target-worktree.js +148 -96
- package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +85 -91
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-branches.js +31 -6
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-worktrees.js +23 -3
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +70 -0
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/junction-ai-config.js +127 -0
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/sync-repos.js +5 -1
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/update-gitignore.js +17 -5
- package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +5 -5
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/commit-worktrees.js +48 -12
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-target-worktree.js +148 -96
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/push-worktrees.js +45 -20
- package/config/workspace-agents.sample.md +50 -45
- package/package.json +2 -2
|
@@ -99,30 +99,37 @@ trigger: always_on
|
|
|
99
99
|
|
|
100
100
|
**检验标准:** 用户从未因"AI 直接改了代码没打招呼"而感到意外。
|
|
101
101
|
|
|
102
|
-
## 8.
|
|
102
|
+
## 8. 业务空间开发模式(双模式,用户选择制)
|
|
103
103
|
|
|
104
|
-
**仅当工作目录是业务空间根(存在 `workspace-config.json
|
|
104
|
+
**仅当工作目录是业务空间根(存在 `workspace-config.json` 且 `.git` 为目录)时生效。空间支持两种开发模式,能力对称(都能修改 workspace 内容与应用代码、都强制分支隔离、交付都走 MR),由用户显式选择,agent 只给推荐不代决。**
|
|
105
105
|
|
|
106
|
-
**模式一 ·
|
|
106
|
+
**模式一 · 本地模式(单线程快改,当前会话就地干):**
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
- 就地切分支:根 + 各涉及应用 `.codespace/<app>/` 同名 `checkout -b`(基线 = `origin/<main>`,经 `cbb-local-mode` 技能执行)
|
|
109
|
+
- 当前会话原地开发,相对路径原样生效;**一次只承载一个需求**(独占本地检出;期间并行新需求走 worktree 模式,互不干扰)
|
|
110
|
+
- 收尾:MR 合并后各参与仓库 `checkout main && pull && 删分支`
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
- 不在空间根主分支提交应用代码、不在空间根创建本模式的 openspec 变更产物
|
|
112
|
-
- 会话开始时确认本次需求对应的工作树路径;存在多个未完成工作树时,先与用户确认目标,不要猜
|
|
112
|
+
**模式二 · worktree 模式(并行 / 隔离):**
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
- `cbb-worktree-init` 建隔离 worktree(AI 配置内容子目录已 junction 就位),**开发会话开进 `.worktrees/worktree-<需求名>/`**,可多需求并行
|
|
115
|
+
- 根会话保留作控制面(worktree 生灭、`cbb update` 等空间级操作)
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
**基准与防线(两模式共同,ADR 0002):**
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
- **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**——本地模式可暂态被需求分支占用,收尾恢复
|
|
120
|
+
- **严禁直接在任何 main 检出上提交载荷**:分支感知 pre-commit hook 机械拦截(workspace 仓库仅放行控制面白名单:`workspace-config.json` / `.gitignore` / `AGENTS.md` / `openspec/config.yaml` / `openspec/schemas/**`;应用仓库 main 全拦)——**被拦是防线生效,不是故障**;`--no-verify` 绕过仅限用户显式要求
|
|
121
|
+
- 远端推送唯一出口 = `cbb-worktree-push`(init 创建流程零远端操作,lazy push;空分支尸体不存在)
|
|
122
|
+
- **模式误判零代价**:本地模式中途要并行/隔离 → 重跑 `cbb-worktree-init`(分支已存在自动复用,提交随身带走)
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
- 只读命令(git status / log、查看文件)在空间根执行无妨
|
|
124
|
+
**三时刻对齐(agent 必须遵守):**
|
|
122
125
|
|
|
123
|
-
|
|
126
|
+
1. **开工前——状态自检**(`cbb-local-mode` 的 `check-local-state.js`):干净态零交互直接开工;**遗留态(别的需求未收尾,任一仓库检出在非主干分支)必须让用户四选一**(继续 / 先收尾 / 升级 worktree / 丢弃),不得自行决定
|
|
127
|
+
2. **开工时——布局确认**:向用户展示参与仓库与分支名,确认后执行
|
|
128
|
+
3. **进行中——插单归属**:用户插入非本需求的改动,先确认归属(并入 / 收尾后处理 / 转 worktree),不得静默混入
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
**读基准代码约定:** 本地模式进行中,工作区是开发态而非基准——要读"main 现状"先确认检出分支,或直接 `git show main:<path>` / `git diff main`;不要默认工作区即基准。
|
|
131
|
+
|
|
132
|
+
**检验标准:** 任何 main 检出上无载荷提交(hook 兜底,git log 可机械审计);本地模式收尾后全部仓库回主干且 clean;worktree 模式的变更产物只出现在需求工作树内;远端分支只经 push 技能产生。
|
|
126
133
|
|
|
127
134
|
---
|
|
128
135
|
|
package/cbb/lib/install/init.js
CHANGED
|
@@ -76,7 +76,7 @@ const SUPERPOWERS_WHITELIST = [
|
|
|
76
76
|
// worktree 管理 skill 清单:init / close / push 三件套(SKILL.md + scripts)。
|
|
77
77
|
// 按 skill 形态部署到各工具的 skillsDir,使这三个能力可被 AI 在对话中自动触发
|
|
78
78
|
// (description 驱动),同时工具也会把它们暴露为斜杠命令供用户手动指定。
|
|
79
|
-
const WORKTREE_SKILLS = ['cbb-worktree-init', 'cbb-worktree-close', 'cbb-worktree-push'];
|
|
79
|
+
const WORKTREE_SKILLS = ['cbb-worktree-init', 'cbb-worktree-close', 'cbb-worktree-push', 'cbb-local-mode'];
|
|
80
80
|
|
|
81
81
|
// 装到空间的 tools skills:当前不分发。
|
|
82
82
|
// cbb-design-to-wiki(Wiki 发布)与 cbb-wiki-ops 保留在源仓库,需要时加入本清单即恢复分发;Java 工具不装。
|
|
@@ -124,6 +124,9 @@ const { applyRuleRewrites } = require('./rule-rewrite');
|
|
|
124
124
|
// .gitignore 条目决策(纯函数:检测/写入/卸载移除)
|
|
125
125
|
const gitignore = require('../utils/gitignore');
|
|
126
126
|
|
|
127
|
+
// git pre-commit hook:分支感知提交防线(ADR 0002,install/uninstall 调用)
|
|
128
|
+
const githook = require('../utils/githook');
|
|
129
|
+
|
|
127
130
|
// 业务空间配置(cbb setup 命令专用)
|
|
128
131
|
const workspaces = require('./workspaces');
|
|
129
132
|
|
|
@@ -1232,6 +1235,14 @@ async function init(targetDir, options = {}) {
|
|
|
1232
1235
|
// 更新 .gitignore
|
|
1233
1236
|
updateGitignore();
|
|
1234
1237
|
|
|
1238
|
+
// 安装分支感知提交防线(ADR 0002,仅业务空间根:空间根仓库 + .codespace/ 各应用)
|
|
1239
|
+
let hookSummary = null;
|
|
1240
|
+
if (isWorkspaceRoot) {
|
|
1241
|
+
hookSummary = githook.installGitHooksForWorkspace(TARGET_DIR);
|
|
1242
|
+
const appDone = hookSummary.apps.length;
|
|
1243
|
+
log(`提交防线 pre-commit hook → 空间根 + ${appDone} 个应用仓库(${hookSummary.root})`, 'success');
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1235
1246
|
// 完成摘要
|
|
1236
1247
|
console.log(`\n ✅ ${action}完成!\n`);
|
|
1237
1248
|
|
|
@@ -1266,6 +1277,7 @@ async function init(targetDir, options = {}) {
|
|
|
1266
1277
|
if (hasSkills) extras.push('Skills');
|
|
1267
1278
|
if (hasCommands) extras.push('Commands');
|
|
1268
1279
|
if (hasHooks) extras.push('版本检查 hook');
|
|
1280
|
+
if (hookSummary) extras.push('提交防线 pre-commit hook');
|
|
1269
1281
|
if (agentsMdState === 'created') extras.push('AGENTS.md(空间导航)');
|
|
1270
1282
|
if (extras.length > 0) {
|
|
1271
1283
|
console.log(` ${extras.join(' / ')} 已配置`);
|
|
@@ -2012,6 +2024,22 @@ async function uninstall() {
|
|
|
2012
2024
|
// 清理 .gitignore
|
|
2013
2025
|
cleanupGitignore();
|
|
2014
2026
|
|
|
2027
|
+
// 移除分支感知提交防线(ADR 0002):空间根 + .codespace/ 各应用(业务空间根专属)
|
|
2028
|
+
if (detectWorkspaceRoot()) {
|
|
2029
|
+
const results = [githook.uninstallGitHook(TARGET_DIR)];
|
|
2030
|
+
const codeDir = path.join(TARGET_DIR, '.codespace');
|
|
2031
|
+
if (fs.existsSync(codeDir)) {
|
|
2032
|
+
for (const name of fs.readdirSync(codeDir)) {
|
|
2033
|
+
const appDir = path.join(codeDir, name);
|
|
2034
|
+
if (fs.existsSync(path.join(appDir, '.git'))) {
|
|
2035
|
+
results.push(githook.uninstallGitHook(appDir));
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
const removedHooks = results.filter(r => r !== 'noop').length;
|
|
2040
|
+
if (removedHooks > 0) log(`移除提交防线 pre-commit hook(${removedHooks} 个仓库)`, 'success');
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2015
2043
|
// 卸载用户级组件(如已确认)
|
|
2016
2044
|
if (removeUserLevel) {
|
|
2017
2045
|
console.log('');
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* git pre-commit hook:分支感知提交防线(ADR 0002)。
|
|
5
|
+
*
|
|
6
|
+
* 策略(对当前仓库生效,worktree 与主仓库共享 .git/hooks 故一并覆盖):
|
|
7
|
+
* - 当前分支非主干(含 detached HEAD)→ 一律放行(两模式开发态都在需求分支上)
|
|
8
|
+
* - workspace 仓库(检出含 workspace-config.json)在主干上 → 只放行控制面白名单路径:
|
|
9
|
+
* workspace-config.json / .gitignore / AGENTS.md / openspec/config.yaml /
|
|
10
|
+
* openspec/schemas/** / .cbb/**(配置变更与 cbb update 部署物,直改即推)
|
|
11
|
+
* - 应用仓库在主干上 → 全拦(交付只走 MR)
|
|
12
|
+
* - 逃生口:用户显式 git commit --no-verify(AI 侧纪律禁止自动绕过)
|
|
13
|
+
*
|
|
14
|
+
* 本模块是 hook 源码的唯一真源;cbb/worktrees/_shared/scripts/install-git-hook.js
|
|
15
|
+
* 部署到业务空间后独立运行,内嵌同一份源码副本(改动须两处同步)。
|
|
16
|
+
*
|
|
17
|
+
* 提供纯函数(isCbbHookContent / buildPreCommitSource)与 I/O 包装
|
|
18
|
+
* (installGitHook / uninstallGitHook),供 init.js 的 setup / update / uninstall 调用。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const HOOK_MARKER = 'managed-by-cbb';
|
|
26
|
+
const HOOK_FILE = 'pre-commit';
|
|
27
|
+
const BACKUP_SUFFIX = '.cbb-backup';
|
|
28
|
+
|
|
29
|
+
// 部署到仓库后的 hook 源码(独立 Node 脚本,无外部依赖)。
|
|
30
|
+
// 注意:源码内不使用反引号,便于本模块以模板字符串承载。
|
|
31
|
+
const PRE_COMMIT_SOURCE = [
|
|
32
|
+
'#!/usr/bin/env node',
|
|
33
|
+
'// managed-by-cbb v1 —— 分支感知提交防线(ADR 0002)',
|
|
34
|
+
'// main 上:workspace 仓库只放行控制面白名单,应用仓库全拦;非 main 一律放行。',
|
|
35
|
+
'// 绕过逃生口仅限用户显式 git commit --no-verify。',
|
|
36
|
+
"const { execSync } = require('child_process');",
|
|
37
|
+
"const fs = require('fs');",
|
|
38
|
+
'function run(cmd) {',
|
|
39
|
+
" try { return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }",
|
|
40
|
+
' catch (e) { return null; }',
|
|
41
|
+
'}',
|
|
42
|
+
"const branch = run('git branch --show-current') || '';",
|
|
43
|
+
'// 主干名:优先远端 HEAD 指向;探测失败按本地 main/master 存在性回退',
|
|
44
|
+
'let main = null;',
|
|
45
|
+
"const remotes = run('git remote');",
|
|
46
|
+
'if (remotes) {',
|
|
47
|
+
" const r = remotes.split(/\\r?\\n/)[0];",
|
|
48
|
+
" const sym = run('git symbolic-ref \"refs/remotes/' + r + '/HEAD\"');",
|
|
49
|
+
" if (sym) main = sym.replace('refs/remotes/' + r + '/', '');",
|
|
50
|
+
'}',
|
|
51
|
+
"if (!main) {",
|
|
52
|
+
" if (run('git rev-parse --verify --quiet refs/heads/main')) main = 'main';",
|
|
53
|
+
" else if (run('git rev-parse --verify --quiet refs/heads/master')) main = 'master';",
|
|
54
|
+
'}',
|
|
55
|
+
'// 非主干(含 detached / 探测失败)→ 放行',
|
|
56
|
+
'if (!main || branch !== main) process.exit(0);',
|
|
57
|
+
'',
|
|
58
|
+
"// 主干上的提交:收集 staged 变更(空提交放行)",
|
|
59
|
+
"const staged = run('git diff --cached --name-only') || '';",
|
|
60
|
+
"const files = staged.split(/\\r?\\n/).filter(Boolean);",
|
|
61
|
+
'if (files.length === 0) process.exit(0);',
|
|
62
|
+
'',
|
|
63
|
+
'// 仓库类型:workspace 仓库 = 检出含 workspace-config.json',
|
|
64
|
+
"const isWorkspace = fs.existsSync('workspace-config.json');",
|
|
65
|
+
'',
|
|
66
|
+
'function deny(bad) {',
|
|
67
|
+
" console.error('[cbb-guard] 主干上拦截载荷提交(ADR 0002):');",
|
|
68
|
+
" bad.forEach(f => console.error(' ' + f));",
|
|
69
|
+
" console.error('需求载荷须在需求分支上提交:本地模式先 git checkout -b <需求名>;worktree 模式在 .worktrees/ 工作树内提交。');",
|
|
70
|
+
" console.error('控制面白名单:workspace-config.json / .gitignore / AGENTS.md / openspec/config.yaml / openspec/schemas/** / .cbb/**');",
|
|
71
|
+
" console.error('如确需在主干提交,须由用户显式 git commit --no-verify。');",
|
|
72
|
+
' process.exit(1);',
|
|
73
|
+
'}',
|
|
74
|
+
'',
|
|
75
|
+
'if (isWorkspace) {',
|
|
76
|
+
" const EXACT = new Set(['workspace-config.json', '.gitignore', 'AGENTS.md', 'openspec/config.yaml']);",
|
|
77
|
+
" const PREFIXES = ['openspec/schemas/', '.cbb/'];",
|
|
78
|
+
' const bad = files.filter(f => !EXACT.has(f) && !PREFIXES.some(p => f.indexOf(p) === 0));',
|
|
79
|
+
' if (bad.length) deny(bad);',
|
|
80
|
+
'} else {',
|
|
81
|
+
' deny(files);',
|
|
82
|
+
'}',
|
|
83
|
+
'process.exit(0);',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 内容是否为本包管理的 hook(含标记行)。
|
|
88
|
+
*/
|
|
89
|
+
function isCbbHookContent(content) {
|
|
90
|
+
return typeof content === 'string' && content.includes(HOOK_MARKER);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* hook 源码(纯函数,便于单测与未来按仓库定制)。
|
|
95
|
+
*/
|
|
96
|
+
function buildPreCommitSource() {
|
|
97
|
+
return PRE_COMMIT_SOURCE + '\n';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 解析仓库的 hooks 目录。
|
|
102
|
+
* 优先 core.hooksPath 配置(相对路径基于仓库根);未配置或读取失败回退 .git/hooks。
|
|
103
|
+
* @returns {string|null} 绝对路径;目录不可用时返回 null(调用方跳过安装)
|
|
104
|
+
*/
|
|
105
|
+
function resolveHooksDir(repoPath) {
|
|
106
|
+
let configured = null;
|
|
107
|
+
try {
|
|
108
|
+
configured = execSync('git config core.hooksPath', {
|
|
109
|
+
cwd: repoPath,
|
|
110
|
+
encoding: 'utf-8',
|
|
111
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
|
+
timeout: 5000,
|
|
113
|
+
}).trim();
|
|
114
|
+
} catch (_) { /* 未配置 */ }
|
|
115
|
+
|
|
116
|
+
let dir;
|
|
117
|
+
if (configured) {
|
|
118
|
+
dir = path.isAbsolute(configured) ? configured : path.join(repoPath, configured);
|
|
119
|
+
} else {
|
|
120
|
+
const gitDir = path.join(repoPath, '.git');
|
|
121
|
+
if (!fs.existsSync(gitDir)) return null; // 非 git 仓库(或 .git 为 worktree 文件,见下)
|
|
122
|
+
// worktree 检出的 .git 是文件(内容 "gitdir: ...");hook 安装统一走主仓库,
|
|
123
|
+
// 调用方应传主仓库路径。此处对文件形态直接放弃,避免误建目录。
|
|
124
|
+
const st = fs.statSync(gitDir);
|
|
125
|
+
if (!st.isDirectory()) return null;
|
|
126
|
+
dir = path.join(gitDir, 'hooks');
|
|
127
|
+
}
|
|
128
|
+
return dir;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 安装(幂等,可用于升级覆盖):
|
|
133
|
+
* - 无既有 hook → 写入
|
|
134
|
+
* - 既有本包 hook → 覆盖(升级)
|
|
135
|
+
* - 既有用户 hook → 备份为 pre-commit.cbb-backup 后写入;卸载时恢复
|
|
136
|
+
* @param {string} repoPath 仓库根(主仓库;worktree 共享 hooks 无需重复安装)
|
|
137
|
+
* @returns {'installed' | 'updated' | 'skipped-not-git' | 'skipped-no-dir'}
|
|
138
|
+
*/
|
|
139
|
+
function installGitHook(repoPath) {
|
|
140
|
+
const dir = resolveHooksDir(repoPath);
|
|
141
|
+
if (!dir) return 'skipped-not-git';
|
|
142
|
+
|
|
143
|
+
const hookPath = path.join(dir, HOOK_FILE);
|
|
144
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
let prev = null;
|
|
147
|
+
if (fs.existsSync(hookPath)) {
|
|
148
|
+
prev = fs.readFileSync(hookPath, 'utf-8');
|
|
149
|
+
if (isCbbHookContent(prev)) {
|
|
150
|
+
if (prev === buildPreCommitSource()) return 'updated'; // 幂等:同版本
|
|
151
|
+
fs.writeFileSync(hookPath, buildPreCommitSource());
|
|
152
|
+
return 'updated';
|
|
153
|
+
}
|
|
154
|
+
// 用户自有 hook:备份后接管
|
|
155
|
+
fs.writeFileSync(hookPath + BACKUP_SUFFIX, prev);
|
|
156
|
+
}
|
|
157
|
+
fs.writeFileSync(hookPath, buildPreCommitSource());
|
|
158
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) { /* Windows 无压力 */ }
|
|
159
|
+
return 'installed';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 卸载:
|
|
164
|
+
* - 当前 hook 是本包的 → 删除;有备份则恢复
|
|
165
|
+
* - 当前 hook 非本包(用户已接管)→ 不动,仅清掉残留备份
|
|
166
|
+
* @returns {'removed' | 'restored-backup' | 'noop'}
|
|
167
|
+
*/
|
|
168
|
+
function uninstallGitHook(repoPath) {
|
|
169
|
+
const dir = resolveHooksDir(repoPath);
|
|
170
|
+
if (!dir) return 'noop';
|
|
171
|
+
|
|
172
|
+
const hookPath = path.join(dir, HOOK_FILE);
|
|
173
|
+
const backupPath = hookPath + BACKUP_SUFFIX;
|
|
174
|
+
let result = 'noop';
|
|
175
|
+
|
|
176
|
+
if (fs.existsSync(hookPath)) {
|
|
177
|
+
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
178
|
+
if (isCbbHookContent(content)) {
|
|
179
|
+
fs.unlinkSync(hookPath);
|
|
180
|
+
result = 'removed';
|
|
181
|
+
if (fs.existsSync(backupPath)) {
|
|
182
|
+
fs.renameSync(backupPath, hookPath);
|
|
183
|
+
result = 'restored-backup';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} else if (fs.existsSync(backupPath)) {
|
|
187
|
+
// hook 已被用户删除但备份残留:不擅自恢复,只清备份
|
|
188
|
+
fs.unlinkSync(backupPath);
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 对业务空间批量安装:空间根仓库 + .codespace/ 下每个应用仓库。
|
|
195
|
+
* @param {string} wsRoot 业务空间根
|
|
196
|
+
* @returns {{ root: string, apps: {name: string, status: string}[] }}
|
|
197
|
+
*/
|
|
198
|
+
function installGitHooksForWorkspace(wsRoot) {
|
|
199
|
+
const root = installGitHook(wsRoot);
|
|
200
|
+
const apps = [];
|
|
201
|
+
const codeDir = path.join(wsRoot, '.codespace');
|
|
202
|
+
if (fs.existsSync(codeDir)) {
|
|
203
|
+
for (const name of fs.readdirSync(codeDir)) {
|
|
204
|
+
const appDir = path.join(codeDir, name);
|
|
205
|
+
if (fs.existsSync(path.join(appDir, '.git'))) {
|
|
206
|
+
apps.push({ name, status: installGitHook(appDir) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { root, apps };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
HOOK_MARKER,
|
|
215
|
+
HOOK_FILE,
|
|
216
|
+
BACKUP_SUFFIX,
|
|
217
|
+
PRE_COMMIT_SOURCE,
|
|
218
|
+
isCbbHookContent,
|
|
219
|
+
buildPreCommitSource,
|
|
220
|
+
resolveHooksDir,
|
|
221
|
+
installGitHook,
|
|
222
|
+
uninstallGitHook,
|
|
223
|
+
installGitHooksForWorkspace,
|
|
224
|
+
};
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* .gitignore 管理的纯函数工具(无副作用,便于单测;由 init.js 的 I/O 层调用)。
|
|
5
5
|
*
|
|
6
6
|
* 安装时:若 .gitignore 已含某目录(认可带/不带尾斜杠两种形式),则不重复添加;
|
|
7
|
-
* 否则新增无尾斜杠形式(.claude / .qoder,更通用)。.cbb/
|
|
7
|
+
* 否则新增无尾斜杠形式(.claude / .qoder,更通用)。.cbb/ / .codespace/ /
|
|
8
|
+
* .worktrees/ 这类纯目录路径维持带斜杠。
|
|
8
9
|
* 卸载时:移除本包曾写入的所有形式(新旧版本均覆盖)。
|
|
9
10
|
*
|
|
10
11
|
* 注意:.gitignore 条目无 _managedBy 之类的标记,卸载按"形式匹配"移除,
|
|
@@ -13,11 +14,14 @@
|
|
|
13
14
|
|
|
14
15
|
// 各目录:add = 写入形式;accept = 视为"已存在 / 需卸载移除"的可接受形式(含尾斜杠变体)。
|
|
15
16
|
// .claude / .qoder 写无尾斜杠形式,但检测/卸载时同时认可带斜杠变体,避免重复添加、覆盖旧版本。
|
|
17
|
+
// .worktrees/ 自 0.0.3-beta.22 起随 setup 写入(ADR 0002:本地模式下 main 工作区恒净,
|
|
18
|
+
// 不再依赖 worktree-init 把该条目提交到 feature 分支——那会导致首个分支合并前 main 恒脏)。
|
|
16
19
|
const GITIGNORE_RULES = [
|
|
17
20
|
{ add: '.claude', accept: ['.claude', '.claude/'] },
|
|
18
21
|
{ add: '.qoder', accept: ['.qoder', '.qoder/'] },
|
|
19
22
|
{ add: '.cbb/', accept: ['.cbb/'] },
|
|
20
23
|
{ add: '.codespace/', accept: ['.codespace/', '.codespace'] },
|
|
24
|
+
{ add: '.worktrees/', accept: ['.worktrees/', '.worktrees'] },
|
|
21
25
|
{ add: '.opencode', accept: ['.opencode', '.opencode/'] },
|
|
22
26
|
// .codebuddy:beta.15 及以前写入的旧目录,不再新增(add: null),但卸载/清理时仍移除其行
|
|
23
27
|
{ add: null, accept: ['.codebuddy', '.codebuddy/'] },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 本地模式状态自检(ADR 0002,改动清单 #12)。
|
|
4
|
+
*
|
|
5
|
+
* 用法:node check-local-state.js <ws_root>
|
|
6
|
+
*
|
|
7
|
+
* 扫描空间根 + 所有 .codespace/<app>/ 的 { 当前分支, dirty },
|
|
8
|
+
* 判定「干净态」(全部在主干且 clean)或「遗留态」(任一仓库检出在非主干分支)。
|
|
9
|
+
* 干净态 → 零交互直接进入模式选择;遗留态 → agent 升级为用户决策
|
|
10
|
+
* (继续 / 收尾 / 升级 worktree / 丢弃)。同名分支集合 = 遗留需求的仓库布局。
|
|
11
|
+
*
|
|
12
|
+
* 输出:
|
|
13
|
+
* 人类可读报告(逐仓库状态 + 态判定 + 推断的需求名)
|
|
14
|
+
* 末行 LOCAL_STATE { state, branches[], repos[{name,branch,dirty}] }
|
|
15
|
+
* - state: 'clean' | 'active'(active = 存在非主干检出)
|
|
16
|
+
* - branches: 非主干分支名集合(通常为 1 个 = 遗留需求名;多个不同名 → 多头遗留,需用户逐个处理)
|
|
17
|
+
*
|
|
18
|
+
* 退出码:
|
|
19
|
+
* 0 - 扫描完成(无论何种状态;态判定交给 agent 解读)
|
|
20
|
+
* 1 - 参数错误
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const wsRoot = process.argv[2];
|
|
28
|
+
|
|
29
|
+
if (!wsRoot) {
|
|
30
|
+
console.error('Usage: node check-local-state.js <ws_root>');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function run(cmd, cwd) {
|
|
35
|
+
try {
|
|
36
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 主干名:symbolic-ref 远端 HEAD → 本地 main/master 存在性回退;探测失败 null */
|
|
43
|
+
function detectMain(cwd) {
|
|
44
|
+
const remotes = run('git remote', cwd);
|
|
45
|
+
if (remotes) {
|
|
46
|
+
const r = remotes.split(/\r?\n/)[0];
|
|
47
|
+
const sym = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', cwd);
|
|
48
|
+
if (sym) return sym.replace('refs/remotes/' + r + '/', '');
|
|
49
|
+
}
|
|
50
|
+
if (run('git rev-parse --verify --quiet refs/heads/main', cwd)) return 'main';
|
|
51
|
+
if (run('git rev-parse --verify --quiet refs/heads/master', cwd)) return 'master';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scanRepo(cwd, name) {
|
|
56
|
+
const branch = run('git branch --show-current', cwd) || '(detached)';
|
|
57
|
+
const status = run('git status --porcelain', cwd);
|
|
58
|
+
const dirty = !!(status && status.length);
|
|
59
|
+
const main = detectMain(cwd);
|
|
60
|
+
const repo = { name, branch, dirty };
|
|
61
|
+
if (main) repo.main = main;
|
|
62
|
+
return repo;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 扫描空间根 ─────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(path.join(wsRoot, '.git'))) {
|
|
68
|
+
console.error('❌ ' + wsRoot + ' 不是 git 仓库');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const repos = [scanRepo(wsRoot, 'workspace')];
|
|
73
|
+
|
|
74
|
+
// ── 扫描 .codespace/ 各应用 ───────────────────────────────
|
|
75
|
+
|
|
76
|
+
const codeDir = path.join(wsRoot, '.codespace');
|
|
77
|
+
if (fs.existsSync(codeDir)) {
|
|
78
|
+
for (const name of fs.readdirSync(codeDir).sort()) {
|
|
79
|
+
const appDir = path.join(codeDir, name);
|
|
80
|
+
if (fs.existsSync(path.join(appDir, '.git'))) {
|
|
81
|
+
repos.push(scanRepo(appDir, name));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── 态判定 ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
for (const r of repos) {
|
|
89
|
+
const icon = (r.main && r.branch === r.main) ? '📄' : '🌿';
|
|
90
|
+
const dirtyTag = r.dirty ? '(有未提交修改)' : '';
|
|
91
|
+
const baseTag = (r.main && r.branch !== r.main) ? ' ← 主干 ' + r.main : '';
|
|
92
|
+
console.log(icon + ' ' + r.name + ': ' + r.branch + dirtyTag + baseTag);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const active = repos.filter(r => !(r.main && r.branch === r.main));
|
|
96
|
+
const branches = [...new Set(active.map(r => r.branch))];
|
|
97
|
+
|
|
98
|
+
let state;
|
|
99
|
+
if (active.length === 0) {
|
|
100
|
+
state = 'clean';
|
|
101
|
+
console.log('✅ 干净态:全部仓库在主干且可开工(模式选择不受限)');
|
|
102
|
+
} else {
|
|
103
|
+
state = 'active';
|
|
104
|
+
console.log('⚠️ 遗留态:检测到本地需求进行中(分支:' + branches.join(', ') + ')');
|
|
105
|
+
console.log(' 参与仓库:' + active.map(r => r.name).join(', '));
|
|
106
|
+
if (branches.length > 1) {
|
|
107
|
+
console.log(' ⚠️ 存在多个不同分支名——多头遗留,需用户逐个处理');
|
|
108
|
+
}
|
|
109
|
+
console.log(' 处理选项:继续该需求 / 先收尾 / 升级 worktree(git worktree add)/ 丢弃');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('---');
|
|
113
|
+
console.log('LOCAL_STATE', JSON.stringify({ state, branches, repos }));
|
|
114
|
+
process.exit(0);
|