@deepstorm/cli 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6351,10 +6351,11 @@ async function promptContinueSetup(targetDir, projectName, registry2) {
6351
6351
  }
6352
6352
  function buildContext(opts) {
6353
6353
  const projectName = opts.projectName || "project";
6354
+ const packageName = projectName.replace(/[_-]/g, "").toLowerCase();
6354
6355
  return {
6355
6356
  projectName,
6356
- packageName: projectName.replace(/[_-]/g, "").toLowerCase(),
6357
- groupId: "com.example",
6357
+ packageName,
6358
+ groupId: `com.${packageName}`,
6358
6359
  frontend: opts.frontend || false,
6359
6360
  backend: opts.backend || false,
6360
6361
  uiLib: opts.uiLib || false,
@@ -6822,7 +6823,7 @@ tasks.withType<Test> {
6822
6823
  useJUnitPlatform()
6823
6824
  }
6824
6825
  `);
6825
- const appPackage = `${ctx.groupId}.${ctx.packageName}`.replace(/[.-]/g, ".");
6826
+ const appPackage = ctx.groupId.replace(/[.-]/g, ".");
6826
6827
  const appDir = `src/main/java/${appPackage.replace(/\./g, "/")}`;
6827
6828
  ensureDir2(path2.join(projectDir, appDir));
6828
6829
  writeTemplate(projectDir, `${appDir}/Application.java`, `package ${appPackage};
@@ -6917,6 +6918,8 @@ logging:
6917
6918
  ${ctx.packageName}: DEBUG
6918
6919
  `);
6919
6920
  if (hasLiquibase) {
6921
+ const now = /* @__PURE__ */ new Date();
6922
+ const ts = now.toISOString().replace(/\D/g, "").slice(0, 12);
6920
6923
  ensureDir2(path2.join(projectDir, "src/main/resources/db/changelog"));
6921
6924
  writeTemplate(projectDir, "src/main/resources/db/changelog/db.changelog-master.xml", `<?xml version="1.0" encoding="UTF-8"?>
6922
6925
  <databaseChangeLog
@@ -6925,7 +6928,7 @@ logging:
6925
6928
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
6926
6929
  http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
6927
6930
 
6928
- <changeSet id="001" author="deepstorm">
6931
+ <changeSet id="${ts}-001" author="deepstorm">
6929
6932
  <createTable tableName="flyway_schema_history">
6930
6933
  <column name="version" type="VARCHAR(50)"/>
6931
6934
  </createTable>
@@ -9979,7 +9982,7 @@ function runDoctor(targetDir) {
9979
9982
  checks.push({
9980
9983
  name: "CLI \u7248\u672C",
9981
9984
  status: "pass",
9982
- message: `v${version}\uFF08\u8FD0\u884C deepstorm update --check \u68C0\u67E5\u6700\u65B0\u7248\u672C\uFF09`
9985
+ message: `v${version}\uFF08\u8FD0\u884C deepstorm update \u68C0\u67E5\u6700\u65B0\u7248\u672C\uFF09`
9983
9986
  });
9984
9987
  let hasDeepStorm = false;
9985
9988
  if (!fs25.existsSync(settingsPath)) {
@@ -41,75 +41,32 @@ git status --short
41
41
 
42
42
  ### 2. 分支名与任务相关性检查
43
43
 
44
- 在审查待提交文件之前,先检查当前分支是否与待提交的任务相关。如果当前在 `main`/`master` 分支上则**必须**创建新分支;如果分支名与任务内容明显不匹配(如命名随意、与 OpenSpec 任务不符),建议创建新分支。
44
+ 通过 `branch-check.mjs` 检查当前分支是否合法:
45
45
 
46
46
  ```bash
47
- BRANCH=$(git branch --show-current)
48
- echo "当前分支: $BRANCH"
49
-
50
- # 检测是否需要创建新分支
51
- MUST_NEW_BRANCH=false
52
-
53
- # 条件一:在 main 或 master 上必须创建新分支
54
- if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
55
- echo "⚠️ 当前在 $BRANCH 分支上,不允许直接提交,必须创建新分支"
56
- MUST_NEW_BRANCH=true
57
- fi
58
-
59
- # 条件二:检查分支名是否包含 temp、wip、test、tmp、dev 等临时名称
60
- TEMP_PATTERN='^(temp|wip|test|tmp|dev)(/.*)?$'
61
- if echo "$BRANCH" | grep -qE "$TEMP_PATTERN"; then
62
- echo "⚠️ 当前分支名 ($BRANCH) 看起来是临时分支,建议创建有意义的新分支"
63
- fi
64
-
65
- # 收集 OpenSpec 任务上下文
66
- for dir in openspec/changes/*/; do
67
- if [ -f "$dir/proposal.md" ]; then
68
- TASK_NAME=$(basename "$dir")
69
- echo "发现 OpenSpec 任务: $TASK_NAME"
70
- fi
71
- done
47
+ node packages/reef/skills/reef-commit/scripts/branch-check.mjs
72
48
  ```
73
49
 
74
- **判断规则(LLM 自行推理执行):**
50
+ 输出 JSON:
51
+ ```json
52
+ {"isValid":true,"warning":false,"action":"continue"}
53
+ ```
75
54
 
76
- 1. **如果 MUST_NEW_BRANCH=true**(当前在 main/master):
77
- - 直接进入步骤 3「创建新分支」,无需询问用户
78
- 2. **如果分支名明显不相关**(如 `temp-xxx`、`test-foo`、或随意命名与当前变更毫无关联):
79
- - 向用户说明:「当前分支 $BRANCH 与待提交的变更内容似乎不匹配,是否创建一个新分支?」
80
- - 用户同意进入步骤 3「创建新分支」
81
- - 用户不同意 → 继续当前分支
82
- 3. **如果分支名合理**(如与 OpenSpec 任务同名,或包含功能描述的 kebab-case):
83
- - 直接继续
55
+ **判断规则:**
56
+ - `isValid: false, action: "create-branch"`(当前在 main/master)→ **直接进入步骤 3**
57
+ - `warning: true, action: "suggest-rename"`(分支名像临时分支)→ 询问用户是否改名
58
+ - `matchedTask` 有值 → 说明已匹配到 OpenSpec 任务
59
+ - 其他直接继续
84
60
 
85
61
  ### 3. 创建新分支
86
62
 
87
- 当需要创建新分支时,按以下逻辑确定分支名:
88
-
89
- **分支名生成规则(优先级从高到低):**
90
- 1. **OpenSpec 任务名**:如果检测到 `openspec/changes/<task>/proposal.md`,使用 `<task>` 作为分支名
63
+ 按以下逻辑确定分支名(优先级从高到低):
64
+ 1. **OpenSpec 任务名**:由 `branch-check.mjs` 输出的 `matchedTask` 确定
91
65
  2. **用户输入**:询问用户想要的分支名
92
- 3. **AI 推导**:根据变更内容总结生成 kebab-case 分支名(例如 `feat/add-user-auth`、`fix/login-timeout`)
66
+ 3. **AI 推导**:根据变更内容总结生成 kebab-case 分支名
93
67
 
94
68
  ```bash
95
- # 暂存当前未提交变更
96
- STASHED=false
97
- if [ -n "$(git status --porcelain)" ]; then
98
- git stash push -m "reef-commit-auto-stash"
99
- STASHED=true
100
- fi
101
-
102
- # 创建并切换到新分支(基于 main)
103
- git checkout main
104
- git pull origin main 2>/dev/null || true
105
- git checkout -b <new-branch-name>
106
-
107
- # 恢复暂存的变更
108
- if [ "$STASHED" = true ]; then
109
- git stash pop
110
- fi
111
-
112
- echo "✅ 已切换到新分支: <new-branch-name>"
69
+ bash packages/reef/skills/reef-commit/scripts/stash-and-switch.sh <new-branch-name>
113
70
  ```
114
71
 
115
72
  > **注意**:创建新分支后,后续步骤(审查文件、范围检查、测试等)在新分支上继续执行。
@@ -129,9 +86,6 @@ git diff --stat
129
86
  检查当前分支是否涉及多个业务领域。如果跨领域,给出拆分建议并中止提交。
130
87
 
131
88
  ```bash
132
- BRANCH=$(git branch --show-current)
133
- FORK_POINT=$(git merge-base main HEAD 2>/dev/null || echo "")
134
- echo "检查分支范围(基准: main)..."
135
89
  SCOPE_HOOK="packages/reef/hooks/reef-scope-gate.sh"
136
90
  if [ -f "$SCOPE_HOOK" ]; then
137
91
  bash "$SCOPE_HOOK" || {
@@ -146,64 +100,37 @@ fi
146
100
  ### 6. 运行单元测试
147
101
 
148
102
  ```bash
149
- git status --short
150
- ```
151
-
152
- 如有变更则全量运行测试:
153
-
154
- ```bash
155
- ./gradlew test
156
- (cd src/main/web && pnpm test -- --run)
103
+ bash packages/reef/skills/reef-commit/scripts/run-tests.sh --json
157
104
  ```
158
105
 
159
- - 测试全部通过 继续
160
- - 任一测试失败 提示用户修复后再提交
106
+ - 全部通过(`allPassed: true`)→ 继续
107
+ - 任一测试失败(`allPassed: false`)→ 提示用户修复后再提交
161
108
 
162
109
  ### 6.5 OpenSpec 验证与归档检查
163
110
 
164
111
  > 提交前检查关联的 OpenSpec change 是否已完成验证和归档。如果 `openspec/` 目录不存在或无活跃 change 则跳过本步骤。
165
112
 
166
- **判断流程(LLM 自行推理执行):**
113
+ **判断流程:**
167
114
 
168
115
  1. **查找关联的 OpenSpec change:**
169
116
  ```bash
170
- BRANCH=$(git branch --show-current)
171
- echo "当前分支: $BRANCH"
172
-
173
- for dir in openspec/changes/*/; do
174
- CHANGE_NAME=$(basename "$dir")
175
- if [ -f "$dir/.openspec.yaml" ] && [ "$CHANGE_NAME" != "archive" ]; then
176
- echo "发现活跃 OpenSpec change: $CHANGE_NAME"
177
- cat "$dir/.openspec.yaml"
178
- fi
179
- done
117
+ node packages/reef/skills/reef-commit/scripts/check-openspec-status.mjs \
118
+ --branch "$(git branch --show-current)"
180
119
  ```
181
120
 
182
- 2. **匹配规则:** 扫描 `openspec/changes/*/` 下活跃 change(不包含 `archive/`),与当前分支名比对;无匹配则跳过;多匹配则让用户选择。
183
-
184
- 3. **检查归档状态:** 读取 `.openspec.yaml` `status` 字段。`archived` 跳过后续检查;否则继续。
185
-
186
- 4. **运行验证:** 确认 `tasks.md` 全部 checkbox 已完成 → 通过 Skill 工具自动调用 `/opsx:verify`。有 CRITICAL 问题则中止;仅 WARNING/SUGGESTION 则通过。
187
-
121
+ 2. **匹配规则:** 脚本输出中 `name` 匹配当前分支名,`tasksAllDone` 检查任务完成状态。
122
+ 3. **检查归档状态:** 无输出(`noMatch: true`)→ 跳过。`hasTasksMd: true` + `tasksAllDone: true` → 已全部完成 → 调用 `/opsx:verify` 和 `/opsx:archive`。
123
+ 4. **运行验证:** 确认后通过 Skill 工具自动调用 `/opsx:verify`。有 CRITICAL 问题则中止;仅 WARNING/SUGGESTION 则通过。
188
124
  5. **运行归档:** 验证通过后 → 通过 Skill 工具自动调用 `/opsx:archive`。执行失败则提示用户手动处理。
189
125
 
190
- 6. **确认已就绪:** 校验状态并向用户报告。
191
-
192
- > **提示:** verify/archive 执行后产生额外文件变更的,后续步骤会重新检测并纳入提交。
193
-
194
126
  ### 7. 收集上下文
195
127
 
196
128
  ```bash
197
- BRANCH=$(git branch --show-current)
198
- FORK_POINT=$(git merge-base main HEAD 2>/dev/null)
199
- echo "Branch: $BRANCH"; git diff "$FORK_POINT"..HEAD --stat
200
- ls -d openspec/changes/*/ 2>/dev/null | grep -v archive
201
- [ -f "openspec/changes/$BRANCH/proposal.md" ] && head -5 "openspec/changes/$BRANCH/proposal.md"
202
- # 从 proposal/commit 提取 Issue 引用
203
- grep -iE '(issue|jira|lc-|proj-)' "openspec/changes/$BRANCH/proposal.md" 2>/dev/null | head -3
204
- git log "$FORK_POINT"..HEAD --format="%B" 2>/dev/null | grep -ioP '[A-Z]+-\d+' | head -1
129
+ node packages/reef/skills/reef-commit/scripts/collect-git-context.mjs
205
130
  ```
206
131
 
132
+ 输出 JSON 包含 `branch`、`diffStat`、`commitLog`、`openspecChanges` 等字段。提取 `openspecChanges` 中 `matched: true` 项的 `proposal.md` 标题作为提交信息素材。
133
+
207
134
  ### 8. 生成提交信息
208
135
 
209
136
  **标题:** 优先 OpenSpec proposal 标题 → 分支名(kebab-case 转中文)→ 变更总结。依赖更新用 `更新 {依赖} 至 {版本}`,问题修复用 `解决{问题描述}的问题`。
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * branch-check.mjs
5
+ * 检查当前分支是否合法。
6
+ *
7
+ * 用法:
8
+ * node branch-check.mjs
9
+ * node branch-check.mjs --branch feat/add-auth
10
+ * node branch-check.mjs --help
11
+ */
12
+
13
+ import { execSync } from 'node:child_process';
14
+ import { existsSync, readdirSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ // ── Exported constants (for test access) ──────────────────────────
18
+
19
+ /** 临时分支名正则 */
20
+ export const TEMP_PATTERN = /^(temp|wip|test|tmp|dev)(\/.*)?$/;
21
+
22
+ // ── Pure functions ────────────────────────────────────────────────
23
+
24
+ export function isMainOrMaster(branch) {
25
+ return branch === 'main' || branch === 'master';
26
+ }
27
+
28
+ /**
29
+ * 检查分支是否合法。
30
+ * @param {string} branch
31
+ * @param {Array<{name:string}>} openspecTasks
32
+ * @returns {{isValid:boolean, reason?:string, action:string,
33
+ * warning:boolean, matchedTask?:string}}
34
+ */
35
+ export function checkBranch(branch, openspecTasks = []) {
36
+ if (isMainOrMaster(branch)) {
37
+ return {
38
+ isValid: false,
39
+ reason: `不允许在 ${branch} 分支上直接提交`,
40
+ action: 'create-branch',
41
+ warning: false,
42
+ };
43
+ }
44
+
45
+ const isTemp = TEMP_PATTERN.test(branch);
46
+ const action = isTemp ? 'suggest-rename' : 'continue';
47
+ const matchedTask = openspecTasks.find(t =>
48
+ branch === t.name || branch.includes(t.name)
49
+ );
50
+
51
+ return {
52
+ isValid: true,
53
+ warning: isTemp,
54
+ action,
55
+ ...(isTemp ? { reason: '分支名像临时分支,建议改名' } : {}),
56
+ ...(matchedTask ? { matchedTask: matchedTask.name } : {}),
57
+ };
58
+ }
59
+
60
+ // ── Shell helpers ─────────────────────────────────────────────────
61
+
62
+ function exec(command) {
63
+ try {
64
+ return execSync(command, { encoding: 'utf8', timeout: 5000 }).trim();
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ function findOpenspecTasks() {
71
+ const root = exec(
72
+ 'git rev-parse --show-toplevel 2>/dev/null || echo ""'
73
+ );
74
+ if (!root) return [];
75
+ const changesDir = join(root, 'openspec', 'changes');
76
+ if (!existsSync(changesDir)) return [];
77
+
78
+ const entries = readdirSync(changesDir, { withFileTypes: true });
79
+ return entries
80
+ .filter(e => e.isDirectory() && e.name !== 'archive')
81
+ .map(e => ({
82
+ name: e.name,
83
+ hasProposal: existsSync(join(changesDir, e.name, 'proposal.md')),
84
+ }));
85
+ }
86
+
87
+ // ── CLI entry ─────────────────────────────────────────────────────
88
+
89
+ if (import.meta.filename === process.argv[1]) {
90
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
91
+ console.log(`
92
+ branch-check.mjs — 检查分支合法性
93
+
94
+ 用法:
95
+ node branch-check.mjs 自动检测
96
+ node branch-check.mjs --branch <name> 指定分支
97
+ node branch-check.mjs --help 显示帮助
98
+ `);
99
+ process.exit(0);
100
+ }
101
+
102
+ const idx = process.argv.indexOf('--branch');
103
+ const branch = idx >= 0
104
+ ? process.argv[idx + 1]
105
+ : exec('git branch --show-current');
106
+ const tasks = findOpenspecTasks();
107
+ const result = checkBranch(branch, tasks);
108
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
109
+ }
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * check-openspec-status.mjs
5
+ * 扫描 OpenSpec change 的状态。
6
+ *
7
+ * 用法:
8
+ * node check-openspec-status.mjs
9
+ * node check-openspec-status.mjs --branch add-auth
10
+ * node check-openspec-status.mjs --help
11
+ */
12
+
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const TASKS_CHECKBOX_RE = /^- \[([ xX])\]/gm;
17
+
18
+ // ── Pure functions ────────────────────────────────────────────────
19
+
20
+ export function isArchived(changePath) {
21
+ return changePath.includes('/archive/');
22
+ }
23
+
24
+ export function parseTasksStatus(content) {
25
+ const matches = [...content.matchAll(TASKS_CHECKBOX_RE)];
26
+ const total = matches.length;
27
+ const complete = matches.filter(m => m[1] !== ' ').length;
28
+ return {
29
+ total,
30
+ complete,
31
+ allDone: total === 0 ? true : total === complete,
32
+ };
33
+ }
34
+
35
+ export function scanChanges(changesDir) {
36
+ if (!existsSync(changesDir)) return [];
37
+ const entries = readdirSync(changesDir, { withFileTypes: true });
38
+ return entries
39
+ .filter(e => e.isDirectory() && e.name !== 'archive')
40
+ .map(e => {
41
+ const dirPath = join(changesDir, e.name);
42
+ const tasksPath = join(dirPath, 'tasks.md');
43
+ const hasTasksMd = existsSync(tasksPath);
44
+ let tasksStatus = null;
45
+ if (hasTasksMd) {
46
+ tasksStatus = parseTasksStatus(
47
+ readFileSync(tasksPath, 'utf8')
48
+ );
49
+ }
50
+ return {
51
+ name: e.name,
52
+ archived: false,
53
+ hasTasksMd,
54
+ tasksAllDone: tasksStatus ? tasksStatus.allDone : null,
55
+ tasksTotal: tasksStatus ? tasksStatus.total : 0,
56
+ tasksComplete: tasksStatus ? tasksStatus.complete : 0,
57
+ };
58
+ });
59
+ }
60
+
61
+ // ── CLI entry ─────────────────────────────────────────────────────
62
+
63
+ if (import.meta.filename === process.argv[1]) {
64
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
65
+ console.log(`
66
+ check-openspec-status.mjs — 扫描 OpenSpec change 状态
67
+
68
+ 用法:
69
+ node check-openspec-status.mjs
70
+ node check-openspec-status.mjs --branch <name>
71
+ node check-openspec-status.mjs --help
72
+ `);
73
+ process.exit(0);
74
+ }
75
+
76
+ const root = process.env.INIT_CWD || process.cwd();
77
+ const changesDir = join(root, 'openspec', 'changes');
78
+ const results = scanChanges(changesDir);
79
+
80
+ const branchIdx = process.argv.indexOf('--branch');
81
+ if (branchIdx >= 0) {
82
+ const branch = process.argv[branchIdx + 1];
83
+ const matched = results.filter(r =>
84
+ r.name === branch || branch.includes(r.name)
85
+ );
86
+ process.stdout.write(JSON.stringify(
87
+ matched.length > 0 ? matched : { noMatch: true, branch }, null, 2
88
+ ) + '\n');
89
+ } else {
90
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
91
+ }
92
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * collect-git-context.mjs
5
+ * 收集 git 上下文,输出结构化 JSON。
6
+ *
7
+ * 用法:
8
+ * node collect-git-context.mjs # 输出 JSON
9
+ * node collect-git-context.mjs --help # 查看帮助
10
+ */
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ // ── Pure parse functions (testable) ──────────────────────────────────
17
+
18
+ /**
19
+ * 解析 `git diff --stat` 输出
20
+ * @param {string} output
21
+ * @returns {{ files: number, insertions: number, deletions: number, changedFiles: string[] }}
22
+ */
23
+ export function parseDiffStat(output) {
24
+ const lines = output.trim().split('\n').filter(Boolean);
25
+ const changedFiles = [];
26
+ let files = 0, insertions = 0, deletions = 0;
27
+
28
+ for (const line of lines) {
29
+ // 匹配汇总行: "2 files changed, 13 insertions(+), 2 deletions(-)"
30
+ const summaryMatch = line.match(/(\d+)\s+files?\s+changed/);
31
+ if (summaryMatch) {
32
+ files = parseInt(summaryMatch[1], 10);
33
+ const insMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
34
+ if (insMatch) insertions = parseInt(insMatch[1], 10);
35
+ const delMatch = line.match(/(\d+)\s+deletions?\(-\)/);
36
+ if (delMatch) deletions = parseInt(delMatch[1], 10);
37
+ continue;
38
+ }
39
+ // 匹配文件行: "src/main.ts | 10 ++++++++--"
40
+ const fileMatch = line.match(/^\s*(.+?)\s+\|/);
41
+ if (fileMatch) {
42
+ changedFiles.push(fileMatch[1].trim());
43
+ }
44
+ }
45
+
46
+ return { files, insertions, deletions, changedFiles };
47
+ }
48
+
49
+ /**
50
+ * 解析 `git log --oneline` 输出
51
+ * @param {string} output
52
+ * @returns {Array<{ hash: string, message: string }>}
53
+ */
54
+ export function parseCommitLog(output) {
55
+ const lines = output.trim().split('\n').filter(Boolean);
56
+ return lines.map(line => {
57
+ const match = line.match(/^(\S+)\s+(.*)/);
58
+ return match ? { hash: match[1], message: match[2] } : null;
59
+ }).filter(Boolean);
60
+ }
61
+
62
+ /**
63
+ * 从分支名或 commit 信息中提取 JIRA 引用编号
64
+ * @param {string} branchName
65
+ * @param {Array<{hash:string,message:string}>} [commitLog]
66
+ * @returns {string|null}
67
+ */
68
+ export function extractJiraRef(branchName, commitLog) {
69
+ const JIRA_RE = /[A-Z]{2,}-\d+/;
70
+ const branchMatch = branchName.match(JIRA_RE);
71
+ if (branchMatch) return branchMatch[0];
72
+ if (commitLog) {
73
+ for (const commit of commitLog) {
74
+ const match = commit.message.match(JIRA_RE);
75
+ if (match) return match[0];
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ // ── Shell exec helpers ──────────────────────────────────────────────
82
+
83
+ function exec(command) {
84
+ try {
85
+ return execSync(command, { encoding: 'utf8', timeout: 10000 }).trim();
86
+ } catch {
87
+ return '';
88
+ }
89
+ }
90
+
91
+ // ── Context collection ──────────────────────────────────────────────
92
+
93
+ /**
94
+ * 收集完整的 git 上下文
95
+ * @returns {{ branch: string, forkPoint: string|null, diffStat: object|null, commitLog: Array, hasUncommitted: boolean, openspecChanges: Array|null }}
96
+ */
97
+ export function collectContext() {
98
+ const branch = exec('git branch --show-current');
99
+ let forkPoint;
100
+ let error;
101
+ try {
102
+ forkPoint = execSync('git merge-base main HEAD', { encoding: 'utf8' }).trim();
103
+ } catch {
104
+ error = '无法找到基准点';
105
+ }
106
+ const diffStat = exec(`git diff "${forkPoint || 'main'}..HEAD" --stat`);
107
+ const commitLogRaw = forkPoint
108
+ ? exec(`git log "${forkPoint}"..HEAD --oneline`)
109
+ : '';
110
+ const statusShort = exec('git status --short');
111
+ const hasUncommitted = statusShort.length > 0;
112
+
113
+ // OpenSpec change 扫描
114
+ let openspecChanges = null;
115
+ const root = exec('git rev-parse --show-toplevel 2>/dev/null || echo ""');
116
+ if (root) {
117
+ const changesDir = join(root, 'openspec', 'changes');
118
+ if (existsSync(changesDir)) {
119
+ try {
120
+ const entries = readdirSync(changesDir, { withFileTypes: true });
121
+ const changeDirs = entries
122
+ .filter(e => e.isDirectory() && e.name !== 'archive')
123
+ .map(e => {
124
+ const proposalPath = join(changesDir, e.name, 'proposal.md');
125
+ const hasProposal = existsSync(proposalPath);
126
+ let title = e.name;
127
+ if (hasProposal) {
128
+ const firstLine = readFileSync(proposalPath, 'utf8').split('\n')[0] || '';
129
+ title = firstLine.replace(/^#\s*/, '').trim() || e.name;
130
+ }
131
+ return {
132
+ name: e.name,
133
+ hasProposal,
134
+ matched: branch === e.name,
135
+ title,
136
+ };
137
+ });
138
+ if (changeDirs.length > 0) openspecChanges = changeDirs;
139
+ } catch {
140
+ // ignore
141
+ }
142
+ }
143
+ }
144
+
145
+ const parsedCommitLog = commitLogRaw ? parseCommitLog(commitLogRaw) : [];
146
+
147
+ return {
148
+ branch,
149
+ forkPoint: forkPoint || null,
150
+ diffStat: diffStat ? parseDiffStat(diffStat) : null,
151
+ commitLog: parsedCommitLog,
152
+ hasUncommitted,
153
+ openspecChanges,
154
+ jiraRef: extractJiraRef(branch, parsedCommitLog),
155
+ ...(error ? { error } : {}),
156
+ };
157
+ }
158
+
159
+ // ── CLI entry ───────────────────────────────────────────────────────
160
+
161
+ function printHelp() {
162
+ console.log(`
163
+ collect-git-context.mjs — 收集 git 上下文
164
+
165
+ 用法:
166
+ node collect-git-context.mjs 输出 JSON 格式的 git 上下文
167
+ node collect-git-context.mjs --help 显示本帮助
168
+
169
+ 输出字段:
170
+ branch 当前分支名
171
+ forkPoint main 的 merge-base
172
+ diffStat { files, insertions, deletions, changedFiles }
173
+ commitLog [{ hash, message }]
174
+ hasUncommitted 是否有未提交变更
175
+ openspecChanges [{ name, hasProposal, matched, title }] 或 null
176
+ `);
177
+ }
178
+
179
+ if (import.meta.filename === process.argv[1]) {
180
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
181
+ printHelp();
182
+ process.exit(0);
183
+ }
184
+ const context = collectContext();
185
+ process.stdout.write(JSON.stringify(context, null, 2) + '\n');
186
+ }