@spaceflow/core 0.7.0 → 0.9.0

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.
Files changed (88) hide show
  1. package/dist/index.js +5621 -1027
  2. package/dist/index.js.map +1 -1
  3. package/package.json +9 -3
  4. package/src/cli-runtime/di/config.ts +157 -0
  5. package/src/cli-runtime/di/container.ts +120 -0
  6. package/src/cli-runtime/di/index.ts +3 -0
  7. package/src/cli-runtime/di/services.ts +53 -0
  8. package/src/cli-runtime/extension-loader.ts +74 -0
  9. package/src/cli-runtime/i18n/i18n.ts +89 -0
  10. package/src/cli-runtime/i18n/index.ts +20 -0
  11. package/src/cli-runtime/i18n/init.ts +117 -0
  12. package/src/cli-runtime/index.ts +131 -0
  13. package/src/cli-runtime/internal-extensions.ts +33 -0
  14. package/src/commands/build/build.service.ts +323 -0
  15. package/src/commands/build/index.ts +49 -0
  16. package/src/commands/clear/clear.service.ts +159 -0
  17. package/src/commands/clear/index.ts +35 -0
  18. package/src/commands/commit/commit.config.ts +168 -0
  19. package/src/commands/commit/commit.service.ts +950 -0
  20. package/src/commands/commit/index.ts +58 -0
  21. package/src/commands/create/create.service.ts +318 -0
  22. package/src/commands/create/index.ts +42 -0
  23. package/src/commands/dev/index.ts +30 -0
  24. package/src/commands/install/index.ts +65 -0
  25. package/src/commands/install/install.service.ts +1539 -0
  26. package/src/commands/list/index.ts +33 -0
  27. package/src/commands/list/list.service.ts +127 -0
  28. package/src/commands/mcp/index.ts +37 -0
  29. package/src/commands/mcp/mcp.service.ts +246 -0
  30. package/src/commands/runx/index.ts +47 -0
  31. package/src/commands/runx/runx.service.ts +142 -0
  32. package/src/commands/runx/runx.utils.ts +83 -0
  33. package/src/commands/schema/index.ts +30 -0
  34. package/src/commands/setup/index.ts +34 -0
  35. package/src/commands/setup/setup.service.ts +234 -0
  36. package/src/commands/uninstall/index.ts +42 -0
  37. package/src/commands/uninstall/uninstall.service.ts +166 -0
  38. package/src/commands/update/index.ts +42 -0
  39. package/src/commands/update/update.service.ts +373 -0
  40. package/src/config/index.ts +1 -30
  41. package/src/config/spaceflow.config.ts +226 -278
  42. package/src/index.ts +11 -1
  43. package/src/locales/en/build.json +22 -0
  44. package/src/locales/en/clear.json +16 -0
  45. package/src/locales/en/commit.json +45 -0
  46. package/src/locales/en/create.json +27 -0
  47. package/src/locales/en/dev.json +5 -0
  48. package/src/locales/en/install.json +71 -0
  49. package/src/locales/en/list.json +8 -0
  50. package/src/locales/en/mcp.json +19 -0
  51. package/src/locales/en/runx.json +13 -0
  52. package/src/locales/en/schema.json +4 -0
  53. package/src/locales/en/setup.json +14 -0
  54. package/src/locales/en/uninstall.json +18 -0
  55. package/src/locales/en/update.json +28 -0
  56. package/src/locales/zh-cn/build.json +22 -0
  57. package/src/locales/zh-cn/clear.json +16 -0
  58. package/src/locales/zh-cn/commit.json +45 -0
  59. package/src/locales/zh-cn/create.json +27 -0
  60. package/src/locales/zh-cn/dev.json +5 -0
  61. package/src/locales/zh-cn/install.json +71 -0
  62. package/src/locales/zh-cn/list.json +8 -0
  63. package/src/locales/zh-cn/mcp.json +19 -0
  64. package/src/locales/zh-cn/runx.json +13 -0
  65. package/src/locales/zh-cn/schema.json +4 -0
  66. package/src/locales/zh-cn/setup.json +14 -0
  67. package/src/locales/zh-cn/uninstall.json +18 -0
  68. package/src/locales/zh-cn/update.json +28 -0
  69. package/src/shared/editor-config/index.ts +2 -21
  70. package/src/shared/llm-proxy/adapters/openai.adapter.ts +3 -1
  71. package/src/shared/package-manager/index.ts +5 -76
  72. package/src/shared/source-utils/index.ts +12 -130
  73. package/src/shared/spaceflow-dir/index.ts +13 -135
  74. package/src/shared/verbose/index.ts +10 -87
  75. package/dist/524.js +0 -9
  76. package/src/config/ci.config.ts +0 -29
  77. package/src/config/config-loader.ts +0 -100
  78. package/src/config/config-reader.service.ts +0 -128
  79. package/src/config/config-reader.ts +0 -75
  80. package/src/config/feishu.config.ts +0 -35
  81. package/src/config/git-provider.config.ts +0 -29
  82. package/src/config/llm.config.ts +0 -110
  83. package/src/config/load-env.ts +0 -15
  84. package/src/config/storage.config.ts +0 -33
  85. package/src/shared/i18n/i18n.ts +0 -112
  86. package/src/shared/i18n/index.ts +0 -1
  87. /package/src/{shared → cli-runtime}/i18n/i18n.spec.ts +0 -0
  88. /package/src/{shared → cli-runtime}/i18n/locale-detect.ts +0 -0
@@ -0,0 +1,950 @@
1
+ import type { IConfigReader } from "@spaceflow/core";
2
+ import { execSync, spawnSync } from "child_process";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import micromatch from "micromatch";
5
+ import { dirname, join } from "path";
6
+ import {
7
+ LlmProxyService,
8
+ type LlmMessage,
9
+ LlmJsonPut,
10
+ parallel,
11
+ shouldLog,
12
+ t,
13
+ } from "@spaceflow/core";
14
+ import {
15
+ CommitScopeConfigSchema,
16
+ formatCommitMessage,
17
+ parseCommitMessage,
18
+ type CommitConfig,
19
+ type CommitGroup,
20
+ type CommitMessage,
21
+ type CommitOptions,
22
+ type CommitResult,
23
+ type CommitScopeConfig,
24
+ type CommitType,
25
+ type PackageInfo,
26
+ type ScopeRule,
27
+ type SplitAnalysis,
28
+ } from "./commit.config";
29
+
30
+ // 重新导出类型,保持向后兼容
31
+ export type {
32
+ CommitConfig,
33
+ CommitGroup,
34
+ CommitMessage,
35
+ CommitOptions,
36
+ CommitResult,
37
+ CommitScopeConfig,
38
+ CommitType,
39
+ PackageInfo,
40
+ ScopeRule,
41
+ SplitAnalysis,
42
+ };
43
+ export { formatCommitMessage, parseCommitMessage };
44
+
45
+ /**
46
+ * Commit 上下文,包含生成 commit message 所需的所有信息
47
+ */
48
+ interface CommitContext {
49
+ files: string[];
50
+ diff: string;
51
+ scope: string;
52
+ packageInfo?: PackageInfo;
53
+ }
54
+
55
+ export class CommitService {
56
+ constructor(
57
+ private readonly configReader: IConfigReader,
58
+ private readonly llmProxyService: LlmProxyService,
59
+ ) {}
60
+
61
+ // ============================================================
62
+ // Git 基础操作
63
+ // ============================================================
64
+
65
+ /**
66
+ * 执行 git 命令并返回输出
67
+ */
68
+ private execGit(command: string, options?: { maxBuffer?: number }): string {
69
+ return execSync(command, {
70
+ encoding: "utf-8",
71
+ maxBuffer: options?.maxBuffer ?? 1024 * 1024 * 10,
72
+ stdio: ["pipe", "pipe", "pipe"],
73
+ }).trim();
74
+ }
75
+
76
+ /**
77
+ * 安全执行 git 命令,失败返回空字符串
78
+ */
79
+ private execGitSafe(command: string, options?: { maxBuffer?: number }): string {
80
+ try {
81
+ return this.execGit(command, options);
82
+ } catch {
83
+ return "";
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 获取文件列表(从 git 命令输出解析)
89
+ */
90
+ private parseFileList(output: string): string[] {
91
+ return output
92
+ .split("\n")
93
+ .map((f) => f.trim())
94
+ .filter((f) => f.length > 0);
95
+ }
96
+
97
+ // ============================================================
98
+ // 暂存区操作
99
+ // ============================================================
100
+
101
+ getStagedFiles(): string[] {
102
+ return this.parseFileList(this.execGitSafe("git diff --cached --name-only"));
103
+ }
104
+
105
+ getStagedDiff(): string {
106
+ try {
107
+ return this.execGit("git diff --cached --no-color");
108
+ } catch {
109
+ throw new Error(t("commit:getDiffFailed"));
110
+ }
111
+ }
112
+
113
+ hasStagedFiles(): boolean {
114
+ return this.getStagedFiles().length > 0;
115
+ }
116
+
117
+ getFileDiff(files: string[]): string {
118
+ if (files.length === 0) return "";
119
+ const fileArgs = files.map((f) => `"${f}"`).join(" ");
120
+ return this.execGitSafe(`git diff --cached --no-color -- ${fileArgs}`);
121
+ }
122
+
123
+ // ============================================================
124
+ // 工作区操作
125
+ // ============================================================
126
+
127
+ getUnstagedFiles(): string[] {
128
+ return this.parseFileList(this.execGitSafe("git diff --name-only"));
129
+ }
130
+
131
+ getUntrackedFiles(): string[] {
132
+ return this.parseFileList(this.execGitSafe("git ls-files --others --exclude-standard"));
133
+ }
134
+
135
+ getAllWorkingFiles(): string[] {
136
+ return [...new Set([...this.getUnstagedFiles(), ...this.getUntrackedFiles()])];
137
+ }
138
+
139
+ hasWorkingFiles(): boolean {
140
+ return this.getAllWorkingFiles().length > 0;
141
+ }
142
+
143
+ getUnstagedFileDiff(files: string[]): string {
144
+ if (files.length === 0) return "";
145
+ const fileArgs = files.map((f) => `"${f}"`).join(" ");
146
+ return this.execGitSafe(`git diff --no-color -- ${fileArgs}`);
147
+ }
148
+
149
+ // ============================================================
150
+ // 历史记录
151
+ // ============================================================
152
+
153
+ getRecentCommits(count: number = 10): string {
154
+ return this.execGitSafe(`git log --oneline -n ${count} --no-color`);
155
+ }
156
+
157
+ // ============================================================
158
+ // 配置获取
159
+ // ============================================================
160
+
161
+ getCommitTypes(): CommitType[] {
162
+ const publishConfig = this.configReader.getPluginConfig<CommitConfig>("publish");
163
+
164
+ const defaultTypes: CommitType[] = [
165
+ { type: "feat", section: "新特性" },
166
+ { type: "fix", section: "修复BUG" },
167
+ { type: "perf", section: "性能优化" },
168
+ { type: "refactor", section: "代码重构" },
169
+ { type: "docs", section: "文档更新" },
170
+ { type: "style", section: "代码格式" },
171
+ { type: "test", section: "测试用例" },
172
+ { type: "chore", section: "其他修改" },
173
+ ];
174
+
175
+ return publishConfig?.changelog?.preset?.type || defaultTypes;
176
+ }
177
+
178
+ getScopeConfig(): CommitScopeConfig {
179
+ const commitConfig = this.configReader.getPluginConfig<Record<string, unknown>>("commit");
180
+ return CommitScopeConfigSchema.parse(commitConfig ?? {});
181
+ }
182
+
183
+ // ============================================================
184
+ // Package.json 和 Scope 处理
185
+ // ============================================================
186
+
187
+ /**
188
+ * 从路径提取 scope(目录名)
189
+ * 根目录返回空字符串
190
+ */
191
+ extractScopeFromPath(packagePath: string): string {
192
+ if (!packagePath || packagePath === process.cwd()) return "";
193
+ const parts = packagePath.split("/").filter(Boolean);
194
+ return parts[parts.length - 1] || "";
195
+ }
196
+
197
+ /**
198
+ * 查找文件所属的 package.json
199
+ */
200
+ findPackageForFile(file: string): PackageInfo {
201
+ const cwd = process.cwd();
202
+ let dir = dirname(join(cwd, file));
203
+
204
+ while (dir !== dirname(dir)) {
205
+ const pkgPath = join(dir, "package.json");
206
+ if (existsSync(pkgPath)) {
207
+ try {
208
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
209
+ return {
210
+ name: pkg.name || "root",
211
+ description: pkg.description,
212
+ path: dir,
213
+ };
214
+ } catch {
215
+ // 解析失败,继续向上
216
+ }
217
+ }
218
+ dir = dirname(dir);
219
+ }
220
+
221
+ // 回退到根目录
222
+ return this.getRootPackageInfo();
223
+ }
224
+
225
+ /**
226
+ * 获取根目录的 package.json 信息
227
+ */
228
+ private getRootPackageInfo(): PackageInfo {
229
+ const cwd = process.cwd();
230
+ const pkgPath = join(cwd, "package.json");
231
+ if (existsSync(pkgPath)) {
232
+ try {
233
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
234
+ return { name: pkg.name || "root", description: pkg.description, path: cwd };
235
+ } catch {
236
+ // ignore
237
+ }
238
+ }
239
+ return { name: "root", path: cwd };
240
+ }
241
+
242
+ /**
243
+ * 按 package.json 对文件分组
244
+ */
245
+ groupFilesByPackage(files: string[]): Map<string, { files: string[]; packageInfo: PackageInfo }> {
246
+ const groups = new Map<string, { files: string[]; packageInfo: PackageInfo }>();
247
+
248
+ for (const file of files) {
249
+ const pkgInfo = this.findPackageForFile(file);
250
+ const key = pkgInfo.path;
251
+
252
+ if (!groups.has(key)) {
253
+ groups.set(key, { files: [], packageInfo: pkgInfo });
254
+ }
255
+ groups.get(key)!.files.push(file);
256
+ }
257
+
258
+ return groups;
259
+ }
260
+
261
+ /**
262
+ * 根据自定义规则匹配文件的 scope
263
+ */
264
+ matchFileToScope(file: string, rules: ScopeRule[]): string | null {
265
+ for (const rule of rules) {
266
+ if (micromatch.isMatch(file, rule.pattern)) {
267
+ return rule.scope;
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * 根据配置策略对文件分组
275
+ */
276
+ groupFiles(
277
+ files: string[],
278
+ ): Map<string, { files: string[]; scope: string; packageInfo?: PackageInfo }> {
279
+ const config = this.getScopeConfig();
280
+ const groups = new Map<string, { files: string[]; scope: string; packageInfo?: PackageInfo }>();
281
+
282
+ for (const file of files) {
283
+ let scope: string | null = null;
284
+ let packageInfo: PackageInfo | undefined;
285
+
286
+ // 规则匹配
287
+ if (config.strategy === "rules" || config.strategy === "rules-first") {
288
+ scope = this.matchFileToScope(file, config.rules || []);
289
+ }
290
+
291
+ // 包目录匹配
292
+ if (scope === null && (config.strategy === "package" || config.strategy === "rules-first")) {
293
+ packageInfo = this.findPackageForFile(file);
294
+ scope = this.extractScopeFromPath(packageInfo.path);
295
+ }
296
+
297
+ const finalScope = scope || "";
298
+ if (!groups.has(finalScope)) {
299
+ groups.set(finalScope, { files: [], scope: finalScope, packageInfo });
300
+ }
301
+ groups.get(finalScope)!.files.push(file);
302
+ }
303
+
304
+ return groups;
305
+ }
306
+
307
+ // ============================================================
308
+ // Commit 上下文获取
309
+ // ============================================================
310
+
311
+ /**
312
+ * 获取 commit 上下文(统一获取 files, diff, scope, packageInfo)
313
+ */
314
+ getCommitContext(files: string[], useUnstaged = false): CommitContext {
315
+ const diff = useUnstaged ? this.getUnstagedFileDiff(files) : this.getFileDiff(files);
316
+ const packageGroups = this.groupFilesByPackage(files);
317
+ const firstGroup = [...packageGroups.values()][0];
318
+ const packageInfo = firstGroup?.packageInfo;
319
+ const scope = packageInfo ? this.extractScopeFromPath(packageInfo.path) : "";
320
+
321
+ return { files, diff, scope, packageInfo };
322
+ }
323
+
324
+ // ============================================================
325
+ // Prompt 构建
326
+ // ============================================================
327
+
328
+ /**
329
+ * 构建 commit message 生成的 prompt
330
+ */
331
+ private buildCommitPrompt(ctx: CommitContext): { system: string; user: string } {
332
+ const commitTypes = this.getCommitTypes();
333
+ const typesList = commitTypes.map((t) => `- ${t.type}: ${t.section}`).join("\n");
334
+ const recentCommits = this.getRecentCommits();
335
+
336
+ const packageContext = ctx.packageInfo
337
+ ? `\n## 包信息\n- 包名: ${ctx.packageInfo.name}\n- scope: ${ctx.scope || "无(根目录)"}${ctx.packageInfo.description ? `\n- 描述: ${ctx.packageInfo.description}` : ""}`
338
+ : "";
339
+
340
+ const system = `你是一个专业的 Git commit message 生成器。请根据提供的代码变更生成符合 Conventional Commits 规范的 commit message。
341
+
342
+ ## Commit 类型规范
343
+ ${typesList}
344
+
345
+ ## 输出格式
346
+ 请严格按照以下 JSON 格式输出,不要包含任何其他内容:
347
+ {
348
+ "type": "feat",
349
+ "scope": "${ctx.scope}",
350
+ "subject": "简短描述(不超过50字符,中文)",
351
+ "body": "详细描述(可选,中文)"
352
+ }
353
+
354
+ ## 规则
355
+ 1. type 必须是上述类型之一
356
+ 2. scope ${ctx.scope ? `必须使用 "${ctx.scope}"` : "必须为空字符串(根目录不需要 scope)"}
357
+ 3. subject 是简短描述,不超过 50 个字符,使用中文
358
+ 4. body 是详细描述,可选,使用中文,如果没有则设为空字符串
359
+ 5. 如果变更涉及多个方面,选择最主要的类型`;
360
+
361
+ const truncatedDiff =
362
+ ctx.diff.length > 8000 ? ctx.diff.substring(0, 8000) + "\n... (diff 过长,已截断)" : ctx.diff;
363
+
364
+ const user = `请根据以下信息生成 commit message:
365
+ ${packageContext}
366
+ ## 暂存的文件
367
+ ${ctx.files.join("\n")}
368
+
369
+ ## 最近的 commit 历史(参考风格)
370
+ ${recentCommits || "无历史记录"}
371
+
372
+ ## 代码变更 (diff)
373
+ \`\`\`diff
374
+ ${truncatedDiff}
375
+ \`\`\`
376
+
377
+ 请直接输出 JSON 格式的 commit message。`;
378
+
379
+ return { system, user };
380
+ }
381
+
382
+ // ============================================================
383
+ // AI 响应解析
384
+ // ============================================================
385
+
386
+ /**
387
+ * 解析 AI 响应为结构化 CommitMessage
388
+ */
389
+ private async parseAIResponse(content: string, expectedScope: string): Promise<CommitMessage> {
390
+ const jsonPut = new LlmJsonPut<{
391
+ type: string;
392
+ scope?: string;
393
+ subject: string;
394
+ body?: string;
395
+ }>({
396
+ type: "object",
397
+ properties: {
398
+ type: { type: "string", description: "commit 类型" },
399
+ scope: { type: "string", description: "影响范围" },
400
+ subject: { type: "string", description: "简短描述" },
401
+ body: { type: "string", description: "详细描述" },
402
+ },
403
+ required: ["type", "subject"],
404
+ });
405
+
406
+ try {
407
+ const parsed = await jsonPut.parse(content, { disableRequestRetry: true });
408
+ return this.normalizeScope(
409
+ {
410
+ type: parsed.type || "chore",
411
+ subject: parsed.subject || "",
412
+ scope: parsed.scope || undefined,
413
+ body: parsed.body || undefined,
414
+ },
415
+ expectedScope,
416
+ );
417
+ } catch {
418
+ return this.normalizeScope(parseCommitMessage(content), expectedScope);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * 规范化 scope(强制使用预期值或移除)
424
+ */
425
+ private normalizeScope(commit: CommitMessage, expectedScope: string): CommitMessage {
426
+ return expectedScope ? { ...commit, scope: expectedScope } : { ...commit, scope: undefined };
427
+ }
428
+
429
+ // ============================================================
430
+ // Commit Message 生成
431
+ // ============================================================
432
+
433
+ /**
434
+ * 生成 commit message(核心方法)
435
+ */
436
+ async generateCommitMessage(
437
+ options?: CommitOptions & { files?: string[]; useUnstaged?: boolean },
438
+ ): Promise<CommitMessage> {
439
+ // 获取文件列表
440
+ const files = options?.files ?? this.getStagedFiles();
441
+ if (files.length === 0) {
442
+ throw new Error(t("commit:noFilesToCommit"));
443
+ }
444
+
445
+ // 获取上下文
446
+ const ctx = this.getCommitContext(files, options?.useUnstaged);
447
+ if (!ctx.diff) {
448
+ throw new Error(t("commit:noChanges"));
449
+ }
450
+
451
+ // 构建 prompt
452
+ const prompt = this.buildCommitPrompt(ctx);
453
+ const messages: LlmMessage[] = [
454
+ { role: "system", content: prompt.system },
455
+ { role: "user", content: prompt.user },
456
+ ];
457
+
458
+ if (shouldLog(options?.verbose, 1)) {
459
+ console.log(t("commit:generatingMessage"));
460
+ }
461
+
462
+ // 调用 AI
463
+ const response = await this.llmProxyService.chat(messages, {
464
+ verbose: options?.verbose,
465
+ });
466
+
467
+ // 解析响应并填充上下文
468
+ const commit = await this.parseAIResponse(response.content, ctx.scope);
469
+ return {
470
+ ...commit,
471
+ files: ctx.files,
472
+ packageInfo: ctx.packageInfo,
473
+ };
474
+ }
475
+
476
+ /**
477
+ * 为指定文件生成 commit message(兼容旧 API)
478
+ */
479
+ async generateCommitMessageForFiles(
480
+ files: string[],
481
+ options?: CommitOptions,
482
+ useUnstaged = false,
483
+ _packageInfo?: { name: string; description?: string },
484
+ ): Promise<CommitMessage> {
485
+ return this.generateCommitMessage({ ...options, files, useUnstaged });
486
+ }
487
+
488
+ // ============================================================
489
+ // 拆分分析
490
+ // ============================================================
491
+
492
+ /**
493
+ * 分析如何拆分 commit
494
+ */
495
+ async analyzeSplitStrategy(options?: CommitOptions, useUnstaged = false): Promise<SplitAnalysis> {
496
+ const files = useUnstaged ? this.getUnstagedFiles() : this.getStagedFiles();
497
+ const config = this.getScopeConfig();
498
+
499
+ if (shouldLog(options?.verbose, 1)) {
500
+ const strategyName =
501
+ config.strategy === "rules"
502
+ ? t("commit:strategyRules")
503
+ : config.strategy === "rules-first"
504
+ ? t("commit:strategyRulesFirst")
505
+ : t("commit:strategyPackage");
506
+ console.log(t("commit:groupingByStrategy", { strategy: strategyName }));
507
+ }
508
+
509
+ const scopeGroups = this.groupFiles(files);
510
+
511
+ // 单个组:让 AI 进一步分析
512
+ if (scopeGroups.size === 1) {
513
+ const [, groupData] = [...scopeGroups.entries()][0];
514
+ const packageInfo = groupData.packageInfo || {
515
+ name: groupData.scope || "root",
516
+ path: process.cwd(),
517
+ };
518
+ return this.analyzeWithinPackage(groupData.files, packageInfo, options, useUnstaged);
519
+ }
520
+
521
+ // 多个组:每个组作为独立 commit
522
+ if (shouldLog(options?.verbose, 1)) {
523
+ console.log(t("commit:detectedGroups", { count: scopeGroups.size }));
524
+ }
525
+
526
+ const groups: CommitGroup[] = [];
527
+ for (const [, groupData] of scopeGroups) {
528
+ groups.push({
529
+ files: groupData.files,
530
+ reason: groupData.scope
531
+ ? t("commit:scopeChanges", { scope: groupData.scope })
532
+ : t("commit:rootChanges"),
533
+ packageInfo: groupData.packageInfo
534
+ ? { name: groupData.packageInfo.name, description: groupData.packageInfo.description }
535
+ : undefined,
536
+ });
537
+ }
538
+
539
+ return { groups };
540
+ }
541
+
542
+ /**
543
+ * 在单个包内分析拆分策略
544
+ */
545
+ private async analyzeWithinPackage(
546
+ files: string[],
547
+ packageInfo: PackageInfo,
548
+ options?: CommitOptions,
549
+ useUnstaged = false,
550
+ ): Promise<SplitAnalysis> {
551
+ const diff = useUnstaged ? this.getUnstagedFileDiff(files) : this.getFileDiff(files);
552
+ const scope = this.extractScopeFromPath(packageInfo.path);
553
+ const commitTypes = this.getCommitTypes();
554
+ const typesList = commitTypes.map((t) => `- ${t.type}: ${t.section}`).join("\n");
555
+
556
+ const systemPrompt = `你是一个专业的 Git commit 拆分分析器。请根据暂存的文件和代码变更,分析如何将这些变更拆分为多个独立的 commit。
557
+
558
+ ## 当前包信息
559
+ - 包名: ${packageInfo.name}
560
+ - scope: ${scope || "无"}
561
+ ${packageInfo.description ? `- 描述: ${packageInfo.description}` : ""}
562
+
563
+ ## Commit 类型规范
564
+ ${typesList}
565
+
566
+ ## 拆分原则
567
+ 1. **按逻辑拆分**:相关的功能变更放在一起
568
+ 2. **按业务拆分**:不同业务模块的变更分开
569
+ 3. **保持原子性**:每个 commit 是完整的、可独立理解的变更
570
+ 4. **最小化拆分**:如果变更本身是整体,不要强行拆分
571
+
572
+ ## 输出格式
573
+ 请严格按照以下 JSON 格式输出:
574
+ {
575
+ "groups": [
576
+ { "files": ["file1.ts", "file2.ts"], "reason": "简短描述" }
577
+ ]
578
+ }`;
579
+
580
+ const truncatedDiff =
581
+ diff.length > 12000 ? diff.substring(0, 12000) + "\n... (diff 过长,已截断)" : diff;
582
+
583
+ const userPrompt = `请分析以下改动文件,决定如何拆分 commit:
584
+
585
+ ## 改动的文件
586
+ ${files.join("\n")}
587
+
588
+ ## 代码变更 (diff)
589
+ \`\`\`diff
590
+ ${truncatedDiff}
591
+ \`\`\`
592
+
593
+ 请输出 JSON 格式的拆分策略。`;
594
+
595
+ if (shouldLog(options?.verbose, 1)) {
596
+ console.log(t("commit:analyzingSplit"));
597
+ }
598
+
599
+ const response = await this.llmProxyService.chat(
600
+ [
601
+ { role: "system", content: systemPrompt },
602
+ { role: "user", content: userPrompt },
603
+ ],
604
+ { verbose: options?.verbose },
605
+ );
606
+
607
+ return this.parseSplitAnalysis(response.content, files, packageInfo);
608
+ }
609
+
610
+ /**
611
+ * 解析拆分分析结果
612
+ */
613
+ private parseSplitAnalysis(
614
+ content: string,
615
+ files: string[],
616
+ packageInfo: PackageInfo,
617
+ ): SplitAnalysis {
618
+ let text = content
619
+ .trim()
620
+ .replace(/^```[\w]*\n?/, "")
621
+ .replace(/\n?```$/, "")
622
+ .trim();
623
+
624
+ try {
625
+ const analysis = JSON.parse(text) as SplitAnalysis;
626
+
627
+ if (!analysis.groups || !Array.isArray(analysis.groups)) {
628
+ throw new Error("Invalid response");
629
+ }
630
+
631
+ const validFiles = new Set(files);
632
+
633
+ // 过滤无效文件,移除空组
634
+ for (const group of analysis.groups) {
635
+ group.files = (group.files || []).filter((f) => validFiles.has(f));
636
+ }
637
+ analysis.groups = analysis.groups.filter((g) => g.files.length > 0);
638
+
639
+ // 补充未分配的文件
640
+ const assignedFiles = new Set(analysis.groups.flatMap((g) => g.files));
641
+ const missingFiles = files.filter((f) => !assignedFiles.has(f));
642
+ if (missingFiles.length > 0) {
643
+ if (analysis.groups.length > 0) {
644
+ analysis.groups[analysis.groups.length - 1].files.push(...missingFiles);
645
+ } else {
646
+ analysis.groups.push({ files: missingFiles, reason: t("commit:otherChanges") });
647
+ }
648
+ }
649
+
650
+ // 添加 packageInfo
651
+ for (const group of analysis.groups) {
652
+ group.packageInfo = { name: packageInfo.name, description: packageInfo.description };
653
+ }
654
+
655
+ return analysis;
656
+ } catch {
657
+ return {
658
+ groups: [
659
+ {
660
+ files,
661
+ reason: t("commit:allChanges"),
662
+ packageInfo: { name: packageInfo.name, description: packageInfo.description },
663
+ },
664
+ ],
665
+ };
666
+ }
667
+ }
668
+
669
+ // ============================================================
670
+ // Commit 执行
671
+ // ============================================================
672
+
673
+ /**
674
+ * 执行 git commit
675
+ */
676
+ async commit(message: string, options?: CommitOptions): Promise<CommitResult> {
677
+ if (options?.dryRun) {
678
+ return { success: true, message: t("commit:dryRunMessage", { message }) };
679
+ }
680
+
681
+ try {
682
+ const args = ["commit", "-m", message];
683
+ if (options?.noVerify) args.push("--no-verify");
684
+
685
+ const result = spawnSync("git", args, { encoding: "utf-8", stdio: "pipe" });
686
+
687
+ if (result.status !== 0) {
688
+ return { success: false, error: result.stderr || result.stdout || t("commit:commitFail") };
689
+ }
690
+ return { success: true, message: result.stdout };
691
+ } catch (error) {
692
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
693
+ }
694
+ }
695
+
696
+ /**
697
+ * 暂存文件
698
+ */
699
+ private stageFiles(files: string[]): { success: boolean; error?: string } {
700
+ const result = spawnSync("git", ["add", ...files], { encoding: "utf-8", stdio: "pipe" });
701
+ if (result.status !== 0) {
702
+ return { success: false, error: t("commit:stageFilesFailed", { error: result.stderr }) };
703
+ }
704
+ return { success: true };
705
+ }
706
+
707
+ /**
708
+ * 重置暂存区
709
+ */
710
+ private resetStaging(): boolean {
711
+ try {
712
+ execSync("git reset HEAD", { encoding: "utf-8", stdio: "pipe" });
713
+ return true;
714
+ } catch {
715
+ return false;
716
+ }
717
+ }
718
+
719
+ // ============================================================
720
+ // 批量提交
721
+ // ============================================================
722
+
723
+ /**
724
+ * 排序 groups:子包优先,根目录最后
725
+ */
726
+ private sortGroupsForCommit(groups: CommitGroup[]): CommitGroup[] {
727
+ return [...groups].sort((a, b) => {
728
+ const aScope = this.getGroupScope(a);
729
+ const bScope = this.getGroupScope(b);
730
+ if (!aScope && bScope) return 1;
731
+ if (aScope && !bScope) return -1;
732
+ return 0;
733
+ });
734
+ }
735
+
736
+ private getGroupScope(group: CommitGroup): string {
737
+ if (!group.packageInfo) return "";
738
+ const pkgGroups = this.groupFilesByPackage(group.files);
739
+ const first = [...pkgGroups.values()][0];
740
+ return first ? this.extractScopeFromPath(first.packageInfo.path) : "";
741
+ }
742
+
743
+ /**
744
+ * 分批提交
745
+ */
746
+ async commitInBatches(options?: CommitOptions, useWorking = false): Promise<CommitResult> {
747
+ const files = useWorking ? this.getAllWorkingFiles() : this.getStagedFiles();
748
+
749
+ if (files.length === 0) {
750
+ return {
751
+ success: false,
752
+ error: useWorking ? t("commit:noWorkingChanges") : t("commit:noStagedFiles"),
753
+ };
754
+ }
755
+
756
+ const analysis = await this.analyzeSplitStrategy(options, useWorking);
757
+ const sortedGroups = this.sortGroupsForCommit(analysis.groups);
758
+
759
+ // 单个组:简化处理
760
+ if (sortedGroups.length <= 1) {
761
+ const group = sortedGroups[0];
762
+ if (shouldLog(options?.verbose, 1)) {
763
+ console.log(t("commit:singleCommit"));
764
+ }
765
+
766
+ if (useWorking) {
767
+ const stageResult = this.stageFiles(files);
768
+ if (!stageResult.success) return { success: false, error: stageResult.error };
769
+ }
770
+
771
+ const commitObj = await this.generateCommitMessage({ ...options, files });
772
+ const message = formatCommitMessage(commitObj);
773
+
774
+ if (shouldLog(options?.verbose, 1)) {
775
+ console.log(t("commit:generatedMessage"));
776
+ console.log("─".repeat(50));
777
+ console.log(message);
778
+ console.log("─".repeat(50));
779
+ }
780
+
781
+ return this.commit(message, options);
782
+ }
783
+
784
+ // 多个组:并行生成 message,顺序提交
785
+ if (shouldLog(options?.verbose, 1)) {
786
+ console.log(t("commit:splitIntoCommits", { count: sortedGroups.length }));
787
+ sortedGroups.forEach((g, i) => {
788
+ const pkgStr = g.packageInfo ? ` [${g.packageInfo.name}]` : "";
789
+ console.log(
790
+ t("commit:groupItem", {
791
+ index: i + 1,
792
+ reason: g.reason,
793
+ pkg: pkgStr,
794
+ count: g.files.length,
795
+ }),
796
+ );
797
+ });
798
+ console.log(t("commit:parallelGenerating", { count: sortedGroups.length }));
799
+ }
800
+
801
+ // 并行生成 messages
802
+ const executor = parallel({ concurrency: 5 });
803
+ const messageResults = await executor.map(
804
+ sortedGroups,
805
+ async (group: CommitGroup) =>
806
+ this.generateCommitMessage({ ...options, files: group.files, useUnstaged: useWorking }),
807
+ (group: CommitGroup) => group.files[0] || "unknown",
808
+ );
809
+
810
+ const failedResults = messageResults.filter((r) => !r.success);
811
+ if (failedResults.length > 0) {
812
+ return {
813
+ success: false,
814
+ error: t("commit:generateMessageFailed", {
815
+ errors: failedResults.map((r) => r.error?.message).join(", "),
816
+ }),
817
+ };
818
+ }
819
+
820
+ const generatedMessages = messageResults.map((r) => r.result!);
821
+
822
+ if (shouldLog(options?.verbose, 1)) {
823
+ console.log(t("commit:allMessagesGenerated"));
824
+ }
825
+
826
+ // Dry run 模式
827
+ if (options?.dryRun) {
828
+ const preview = sortedGroups
829
+ .map((g, i) => {
830
+ const pkgStr = g.packageInfo ? `\n包: ${g.packageInfo.name}` : "";
831
+ return `[Commit ${i + 1}/${sortedGroups.length}] ${g.reason}${pkgStr}\n文件: ${g.files.join(", ")}\n\n${formatCommitMessage(generatedMessages[i])}`;
832
+ })
833
+ .join("\n\n" + "═".repeat(50) + "\n\n");
834
+ return { success: true, message: preview, commitCount: sortedGroups.length };
835
+ }
836
+
837
+ // 实际提交
838
+ if (this.hasStagedFiles() && !this.resetStaging()) {
839
+ return { success: false, error: t("commit:resetStagingFailed") };
840
+ }
841
+
842
+ const committedMessages: string[] = [];
843
+ let successCount = 0;
844
+
845
+ for (let i = 0; i < sortedGroups.length; i++) {
846
+ const group = sortedGroups[i];
847
+ const commitObj = generatedMessages[i];
848
+ const messageStr = formatCommitMessage(commitObj);
849
+
850
+ if (shouldLog(options?.verbose, 1)) {
851
+ const pkgStr = group.packageInfo ? ` [${group.packageInfo.name}]` : "";
852
+ console.log(
853
+ t("commit:committingGroup", {
854
+ current: i + 1,
855
+ total: sortedGroups.length,
856
+ reason: group.reason,
857
+ pkg: pkgStr,
858
+ }),
859
+ );
860
+ }
861
+
862
+ try {
863
+ const stageResult = this.stageFiles(group.files);
864
+ if (!stageResult.success) throw new Error(stageResult.error);
865
+
866
+ const diff = this.getFileDiff(group.files);
867
+ if (!diff) {
868
+ if (shouldLog(options?.verbose, 1)) console.log(t("commit:skippingNoChanges"));
869
+ continue;
870
+ }
871
+
872
+ if (shouldLog(options?.verbose, 1)) {
873
+ console.log(t("commit:commitMessage"));
874
+ console.log("─".repeat(50));
875
+ console.log(messageStr);
876
+ console.log("─".repeat(50));
877
+ }
878
+
879
+ const commitResult = await this.commit(messageStr, options);
880
+ if (!commitResult.success) throw new Error(commitResult.error);
881
+
882
+ successCount++;
883
+ const shortMsg = formatCommitMessage({ ...commitObj, body: undefined });
884
+ committedMessages.push(`✅ Commit ${i + 1}: ${shortMsg}`);
885
+ console.log(committedMessages[committedMessages.length - 1]);
886
+ } catch (error) {
887
+ const errorMsg = error instanceof Error ? error.message : String(error);
888
+ committedMessages.push(t("commit:commitItemFailed", { index: i + 1, error: errorMsg }));
889
+
890
+ // 恢复剩余文件
891
+ const remaining = sortedGroups.slice(i).flatMap((g) => g.files);
892
+ if (remaining.length > 0) this.stageFiles(remaining);
893
+
894
+ return {
895
+ success: false,
896
+ error: t("commit:commitItemFailedDetail", {
897
+ index: i + 1,
898
+ error: errorMsg,
899
+ committed: committedMessages.join("\n"),
900
+ }),
901
+ commitCount: successCount,
902
+ };
903
+ }
904
+ }
905
+
906
+ return { success: true, message: committedMessages.join("\n"), commitCount: successCount };
907
+ }
908
+
909
+ // ============================================================
910
+ // 主入口
911
+ // ============================================================
912
+
913
+ /**
914
+ * 生成并提交(主入口)
915
+ */
916
+ async generateAndCommit(options?: CommitOptions): Promise<CommitResult> {
917
+ // Split 模式
918
+ if (options?.split) {
919
+ const hasWorking = this.hasWorkingFiles();
920
+ const hasStaged = this.hasStagedFiles();
921
+
922
+ if (!hasWorking && !hasStaged) {
923
+ return { success: false, error: t("commit:noChangesAll") };
924
+ }
925
+
926
+ return this.commitInBatches(options, hasWorking);
927
+ }
928
+
929
+ // 普通模式
930
+ if (!this.hasStagedFiles()) {
931
+ return { success: false, error: t("commit:noStagedFilesHint") };
932
+ }
933
+
934
+ try {
935
+ const commitObj = await this.generateCommitMessage(options);
936
+ const message = formatCommitMessage(commitObj);
937
+
938
+ if (shouldLog(options?.verbose, 1)) {
939
+ console.log(t("commit:generatedMessage"));
940
+ console.log("─".repeat(50));
941
+ console.log(message);
942
+ console.log("─".repeat(50));
943
+ }
944
+
945
+ return this.commit(message, options);
946
+ } catch (error) {
947
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
948
+ }
949
+ }
950
+ }