@git-ai/cli 1.0.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.
@@ -0,0 +1,484 @@
1
+ import { isAxiosError } from "axios";
2
+ import { config } from "../utils/Storage.mjs";
3
+ import {
4
+ BIN,
5
+ OPENAI_COMMIT_MESSAGE_TYPES,
6
+ OPENAI_MAX_TOKEN_DEFAULT,
7
+ } from "../const.mjs";
8
+ import { formatMessage } from "../utils/MessageUtils.mjs";
9
+ import { findConflictFiles } from "../utils/ConflictUtils.mjs";
10
+ import { GitService } from "../services/GitService.mjs";
11
+ import { AIService } from "../services/AIService.mjs";
12
+ import Logger from "../utils/Logger.mjs";
13
+ import Spinner from "../utils/Spinner.mjs";
14
+ import BaseAction from "./BaseAction.mjs";
15
+ import inquirer from "inquirer";
16
+ import chalk from "chalk";
17
+ import { collectError, collectWarning } from "../utils/Log.mjs";
18
+
19
+ /**
20
+ * Commit Action 类
21
+ * 负责处理 git commit 的完整流程
22
+ */
23
+ class CommitAction extends BaseAction {
24
+ OPENAI_COMMIT_MESSAGE_REGEXP = "";
25
+ constructor({ dryRun, allowEmpty, noVerify, skip }) {
26
+ super({ dryRun, allowEmpty, noVerify, skip });
27
+
28
+ this.dryRun = dryRun;
29
+ this.allowEmpty = allowEmpty;
30
+ this.noVerify = noVerify;
31
+ this.skip = skip;
32
+ // 是否有冲突文件
33
+ this.isConflit = false;
34
+
35
+ // 初始化服务
36
+ this.gitService = new GitService();
37
+ this.aiService = null;
38
+
39
+ // 配置信息
40
+ this.maxToken = 0;
41
+
42
+ // 工作信息
43
+ this.workingPrefix = "";
44
+ this.userName = "";
45
+ this.diffString = "";
46
+ this.commitCommand = "";
47
+ this.commitMessage = "";
48
+ this.currentBranch = "";
49
+ this.remoteName = "";
50
+ }
51
+
52
+ /**
53
+ * 初始化并执行完整流程
54
+ */
55
+ async execute() {
56
+ this.prepare();
57
+ this.gitService.checkInstalled();
58
+ this.gitService.checkRepository();
59
+ this.checkWorkingPrefix();
60
+ this.checkGitConflict();
61
+ await this.addCommand();
62
+ this.commitMerge();
63
+ this.getGitUserInfo();
64
+ this.getDiffString();
65
+ await this.commit();
66
+ this.getBranchInfo();
67
+ await this.getRemoteInfo();
68
+ this.gitFetch();
69
+ this.gitMerge();
70
+ this.checkGitConflict();
71
+ this.push();
72
+ }
73
+
74
+ /**
75
+ * 准备配置
76
+ */
77
+ prepare() {
78
+ this.maxToken = parseInt(
79
+ config.get("maxToken") || OPENAI_MAX_TOKEN_DEFAULT
80
+ );
81
+ this.commitCommand = this.dryRun
82
+ ? "git commit --dry-run -m"
83
+ : this.allowEmpty
84
+ ? "git commit --allow-empty -m"
85
+ : this.noVerify
86
+ ? "git commit --no-verify -m"
87
+ : "git commit -m";
88
+ }
89
+
90
+ /**
91
+ * 检查工作目录
92
+ */
93
+ checkWorkingPrefix() {
94
+ this.workingPrefix = this.gitService.getWorkingPrefix();
95
+ if (this.workingPrefix) {
96
+ Logger.warn(
97
+ `当前在子目录 ${this.workingPrefix} 下操作,将只处理此目录下的文件`
98
+ );
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 检查 Git 冲突
104
+ */
105
+ checkGitConflict() {
106
+ // 检查状态
107
+ const statusOutput = this.gitService.getStatus();
108
+ const lines = statusOutput.trim().split("\n");
109
+ let modified = [];
110
+ // 冲突文件列表
111
+ const conflict = [];
112
+
113
+ lines.forEach((line) => {
114
+ const item = line.trim();
115
+ const p = item.slice(2).trim();
116
+ if (item.startsWith("UU")) {
117
+ conflict.push(`[冲突]${p}`);
118
+ } else if (item.startsWith("AA")) {
119
+ conflict.push(`[已添加,又被添加]${p}`);
120
+ } else if (item.startsWith("DD")) {
121
+ conflict.push(`[已删除,又被删除]${p}`);
122
+ } else if (item.startsWith("MM") || item.startsWith("M")) {
123
+ modified.push(p);
124
+ }
125
+ });
126
+
127
+ const { conflictFiles, ignoreFiles } = findConflictFiles(
128
+ Array.from(new Set(modified)),
129
+ this.workingPrefix
130
+ );
131
+ if (
132
+ this.workingPrefix &&
133
+ ignoreFiles.length &&
134
+ typeof this.isConflit === "boolean"
135
+ ) {
136
+ Logger.info(
137
+ ignoreFiles.length > 6
138
+ ? `已忽略${ignoreFiles.length}个文件的冲突标记检查`
139
+ : `冲突标记检查忽略文件:\n - ${ignoreFiles.join("\n - ")}`
140
+ );
141
+ this.isConflit = 0;
142
+ }
143
+
144
+ if (conflictFiles.length > 0) {
145
+ const str = conflictFiles.join("\n - ");
146
+ Logger.warn(`存在冲突标记,需要手动合并文件:\n - ${str}`);
147
+ throw "请手动解决 git 冲突...";
148
+ }
149
+ if (!conflict.length) return;
150
+ Logger.warn(`Git 冲突文件:\n - ${conflict.join("\n - ")}`);
151
+ this.isConflit = this.isConflit ? this.isConflit + 1 : 1;
152
+ }
153
+ // 使用 inquirer 输入 y 确认是否解决冲突
154
+ async confirmConflict() {
155
+ const answer = await inquirer.prompt([
156
+ {
157
+ type: "confirm",
158
+ name: "confirm",
159
+ message: "确认是否已解决冲突?",
160
+ default: false,
161
+ },
162
+ ]);
163
+ return answer.confirm;
164
+ }
165
+ /**
166
+ * 执行 git add
167
+ */
168
+ async addCommand() {
169
+ if (this.isConflit && !(await this.confirmConflict())) {
170
+ throw "请手动解决冲突";
171
+ }
172
+ if (this.skip) return;
173
+ this.gitService.add();
174
+ if (!this.isConflit) return;
175
+ this.checkGitConflict();
176
+ }
177
+
178
+ /**
179
+ * 获取 Git 用户信息
180
+ */
181
+ getGitUserInfo() {
182
+ this.userName = this.gitService.getUserName();
183
+ if (this.userName) {
184
+ this.OPENAI_COMMIT_MESSAGE_REGEXP = new RegExp(
185
+ `^((${OPENAI_COMMIT_MESSAGE_TYPES.join("|")})\\(${
186
+ this.userName
187
+ }\\)):[\\s\\S]*`
188
+ );
189
+ return;
190
+ }
191
+ throw `获取 git 用户信息时出错,\n 请执行 \`git config user.name <your name>\` 设置 git 用户名`;
192
+ }
193
+
194
+ /**
195
+ * 获取差异字符串
196
+ */
197
+ getDiffString() {
198
+ this.diffString = this.gitService.getDiff(this.maxToken);
199
+ }
200
+
201
+ /**
202
+ * 生成提交消息
203
+ */
204
+ async generateMessage() {
205
+ Logger.verbose(`按 Ctrl+C 退出...`);
206
+
207
+ const spinner = new Spinner(`正在生成提交消息...`);
208
+ spinner.start();
209
+
210
+ try {
211
+ // 初始化 AI 服务
212
+ this.aiService = new AIService(this.userName);
213
+ const result = await this.aiService.generateCommitMessage(
214
+ this.diffString
215
+ );
216
+ spinner.stop();
217
+ this.commitMessage = formatMessage(
218
+ result,
219
+ this.OPENAI_COMMIT_MESSAGE_REGEXP
220
+ );
221
+ } catch (error) {
222
+ collectError(error);
223
+ spinner.error("生成 commit message 失败");
224
+ if (isAxiosError(error)) {
225
+ this.gitService.reset();
226
+ if (
227
+ error.response &&
228
+ error.response.status === 400 &&
229
+ error.response.data &&
230
+ error.response.data.error &&
231
+ error.response.data.error.code === "context_length_exceeded"
232
+ ) {
233
+ Logger.error(
234
+ `更新内容超过模型支持的最大 token 数,请选择更小的文件集,使用 \`${BIN} set-max-token < maxToken >\` 设置最大 token 数。`
235
+ );
236
+ } else {
237
+ if (
238
+ error.response &&
239
+ error.response.data &&
240
+ error.response.data.error &&
241
+ error.response.data.error.message
242
+ ) {
243
+ Logger.error(`错误原因:${error.response.data.error.message}`);
244
+ } else if (error.response && error.response.data) {
245
+ Logger.error("发生了一个意外的 Axios 错误,响应数据。");
246
+ } else if (error.response) {
247
+ Logger.error(
248
+ `发生了一个 Axios 错误,状态 ${error.response.status}.`
249
+ );
250
+ } else {
251
+ Logger.error("发生了一个 Axios 网络错误。");
252
+ }
253
+ }
254
+ }
255
+ throw error && error.message ? error.message : error;
256
+ }
257
+
258
+ const messageContent = this.commitMessage
259
+ ? `:\n${chalk.blue(this.commitMessage)}`
260
+ : "为空。";
261
+ const verify = this.OPENAI_COMMIT_MESSAGE_REGEXP.test(this.commitMessage);
262
+ spinner[verify ? "success" : "error"](`AI 生成的内容${messageContent}`);
263
+ if (!verify) {
264
+ throw "AI 生成的内容不符合规则,请重新生成...";
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 判断是否需要通过 commit 去 merge
270
+ */
271
+ commitMerge() {
272
+ if (!this.gitService.isMerging()) {
273
+ return;
274
+ }
275
+
276
+ // 确保没有未解决的冲突
277
+ const statusOutput = this.gitService.getStatus();
278
+ const hasUnresolved = statusOutput
279
+ .split("\n")
280
+ .some(
281
+ (line) =>
282
+ line.startsWith("UU") ||
283
+ line.startsWith("AA") ||
284
+ line.startsWith("DD")
285
+ );
286
+
287
+ if (hasUnresolved) {
288
+ throw "仍有未解决的合并冲突,请手动解决后再继续。";
289
+ }
290
+ Logger.info("检测到处于合并流程,正在结束合并...");
291
+ try {
292
+ this.gitService.finishMerge();
293
+ Logger.success("合并已完成");
294
+ } catch (error) {
295
+ collectError(error);
296
+ Logger.error(`合并失败`);
297
+ throw error && error.message ? error.message : error;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Git commit
303
+ */
304
+ async commit() {
305
+ if (!this.diffString) {
306
+ Logger.warn(
307
+ this.workingPrefix ? "当前目录下没有要提交的更改" : "没有要提交的更改"
308
+ );
309
+ return;
310
+ }
311
+ await this.generateMessage();
312
+ const usageMessage = this.aiService.getUsageMessage();
313
+ if (usageMessage) {
314
+ Logger.info(usageMessage);
315
+ }
316
+ const spinner = new Spinner("正在提交代码...");
317
+ spinner.start();
318
+ await spinner.sleep();
319
+ try {
320
+ await this.gitService.commit(
321
+ `${this.commitCommand} "${this.commitMessage}"`,
322
+ this.debug
323
+ );
324
+ spinner.success("git commit 提交成功...");
325
+ } catch (error) {
326
+ spinner.stop();
327
+ collectError(error);
328
+ let aiDiagnosis = "";
329
+ let usageMessage = "";
330
+ if (
331
+ this.aiService &&
332
+ typeof this.aiService.analyzeCommitFailure === "function"
333
+ ) {
334
+ const errSpinner = new Spinner("AI 诊断 git commit 失败原因...");
335
+ errSpinner.start();
336
+ try {
337
+ const gitStatus = this.gitService.getStatus();
338
+ const errorMessage = error && error.message ? error.message : error;
339
+ const hookLogs = [error && error.stdout, error && error.stderr]
340
+ .map((log) =>
341
+ typeof log === "string"
342
+ ? log.trim()
343
+ : log && Buffer.isBuffer(log)
344
+ ? log.toString("utf8").trim()
345
+ : ""
346
+ )
347
+ .filter(Boolean)
348
+ .join("\n");
349
+ aiDiagnosis = await this.aiService.analyzeCommitFailure({
350
+ errorMessage,
351
+ gitStatus,
352
+ hookLogs,
353
+ });
354
+ errSpinner.stop();
355
+
356
+ usageMessage = this.aiService.getUsageMessage();
357
+ } catch (diagnosisError) {
358
+ collectWarning(diagnosisError);
359
+ errSpinner.warn("AI 诊断 git commit 失败原因时出错");
360
+ }
361
+ }
362
+ const baseMessage = "git commit 提交失败";
363
+ this.gitService.reset();
364
+ throw aiDiagnosis
365
+ ? `${baseMessage},AI 诊断:\n${aiDiagnosis}${
366
+ usageMessage ? "\n\n" : ""
367
+ }${usageMessage}`
368
+ : baseMessage;
369
+ }
370
+ }
371
+
372
+ /**
373
+ * 获取分支信息
374
+ */
375
+ getBranchInfo() {
376
+ try {
377
+ this.currentBranch = this.gitService.getCurrentBranch();
378
+ } catch (error) {
379
+ collectError(error);
380
+ Logger.error(`获取分支名称失败,请检查 Git 是否正确配置`);
381
+ throw error && error.message ? error.message : error;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * 获取远程仓库信息
387
+ */
388
+ async getRemoteInfo() {
389
+ Logger.info(`获取 git 远程仓库地址`);
390
+ try {
391
+ this.remoteName = await this.gitService.getRemoteName(this.currentBranch);
392
+ Logger.success(`获取 git 远程仓库地址成功`);
393
+ } catch (error) {
394
+ collectError(error);
395
+ Logger.error(`获取 git 远程仓库地址失败,请检查 Git 是否正确配置`);
396
+ throw error && error.message ? error.message : error;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Git fetch
402
+ */
403
+ gitFetch() {
404
+ Logger.info(`获取远程仓库最新状态,执行 git fetch...`);
405
+ try {
406
+ this.gitService.fetch(this.remoteName);
407
+ Logger.success(`远程分支 ${this.remoteName} 的最新更改状态获取成功`);
408
+ } catch (error) {
409
+ collectWarning(error);
410
+ Logger.error(
411
+ `fetch 失败:${error && error.message ? error.message : error}`
412
+ );
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Git merge
418
+ */
419
+ gitMerge() {
420
+ Logger.info("正在检测是否需要拉取...");
421
+ try {
422
+ const aheadCount = this.gitService.getAheadCount(
423
+ this.remoteName,
424
+ this.currentBranch
425
+ );
426
+ if (aheadCount > 0) {
427
+ this.gitService.merge(this.remoteName, this.currentBranch);
428
+ Logger.success(
429
+ `本地分支落后远程分支 \x1b[32m${aheadCount}次\x1b[0m,已合并最新代码`
430
+ );
431
+ } else {
432
+ Logger.success("本地代码是最新,无需合并");
433
+ }
434
+ } catch (error) {
435
+ collectWarning(error);
436
+ Logger.warn(
437
+ `合并失败:${error && error.message ? error.message : error}`
438
+ );
439
+ this.commitMerge();
440
+ }
441
+ }
442
+
443
+ /**
444
+ * 推送代码
445
+ */
446
+ async push() {
447
+ const pushCount = this.gitService.getPushCount(
448
+ this.remoteName,
449
+ this.currentBranch
450
+ );
451
+ const originHasBranch = pushCount === -1;
452
+
453
+ if (pushCount !== 0) {
454
+ Logger.info(
455
+ originHasBranch
456
+ ? `远程分支${this.currentBranch}不存在,推送新分支...`
457
+ : "正在推送本地分支与远程分支的差异..."
458
+ );
459
+ try {
460
+ await this.gitService.push(this.remoteName, this.currentBranch);
461
+ Logger.success(
462
+ originHasBranch
463
+ ? "新分支已推送到远程仓库。"
464
+ : "本地分支与远程分支的差异已推送。"
465
+ );
466
+ } catch (error) {
467
+ collectError(error);
468
+ Logger.error("本地分支与远程分支的差异推送失败");
469
+ this.gitService.reset();
470
+ throw error && error.message ? error.message : error;
471
+ }
472
+ } else {
473
+ Logger.warn("本地分支与远程分支没有差异,无需推送");
474
+ }
475
+ }
476
+ }
477
+
478
+ /**
479
+ * 导出 commit 命令处理函数
480
+ */
481
+ export default async function (args) {
482
+ const action = new CommitAction(args);
483
+ await action.run();
484
+ }
@@ -0,0 +1,38 @@
1
+ import { config } from "../utils/Storage.mjs";
2
+ import { OPENAI_MAX_TOKEN_DEFAULT } from "../const.mjs";
3
+ import Logger from "../utils/Logger.mjs";
4
+ import BaseAction from "./BaseAction.mjs";
5
+
6
+ class SetMaxTokenAction extends BaseAction {
7
+ constructor(maxToken) {
8
+ super(maxToken);
9
+ this.maxToken = maxToken;
10
+ }
11
+
12
+ async execute() {
13
+ const newValue =
14
+ typeof this.maxToken === "string" && this.maxToken.trim()
15
+ ? this.maxToken.trim()
16
+ : "";
17
+ const numberReg = /^[1-9]\d*$/;
18
+
19
+ if (!numberReg.test(newValue)) {
20
+ Logger.error(`请输入正确的数字,当前值: "${this.maxToken}"`);
21
+ throw new Error("无效的数字格式");
22
+ }
23
+
24
+ if (parseInt(newValue) > OPENAI_MAX_TOKEN_DEFAULT) {
25
+ Logger.warn(`不建议 max-token 设置太大`);
26
+ }
27
+
28
+ config.set("maxToken", newValue);
29
+ Logger.success(
30
+ `最大 token 数已设置: ${newValue} (${Math.round(newValue / 1000)}k)`
31
+ );
32
+ }
33
+ }
34
+
35
+ export default async function (maxToken) {
36
+ const action = new SetMaxTokenAction(maxToken);
37
+ await action.run();
38
+ }
@@ -0,0 +1,131 @@
1
+ import { chat, getModelList } from "../utils/OpenAI.mjs";
2
+ import { config } from "../utils/Storage.mjs";
3
+ import inquirer from "inquirer";
4
+ import { BIN } from "../const.mjs";
5
+ import Logger from "../utils/Logger.mjs";
6
+ import Spinner from "../utils/Spinner.mjs";
7
+ import BaseAction from "./BaseAction.mjs";
8
+ class SetModelAction extends BaseAction {
9
+ constructor(modelId) {
10
+ super(modelId);
11
+ this.modelId = typeof modelId === "string" ? modelId.trim() : "";
12
+ this.defaultModel = config.get("model") || "";
13
+ this.key = config.get("key");
14
+ this.baseURL = config.get("baseURL");
15
+ }
16
+
17
+ async selectModels() {
18
+ const list = [];
19
+ Logger.info(`Base URL:${this.baseURL}`);
20
+ const spinner = new Spinner("正在加载模型列表...");
21
+ spinner.start();
22
+
23
+ try {
24
+ list.push(...(await getModelList()));
25
+
26
+ if (!list.length) {
27
+ throw "未找到可用的模型";
28
+ }
29
+ spinner.success(`可用模型:${list.length}个`);
30
+ } catch (error) {
31
+ spinner.error("加载模型列表失败");
32
+ throw error && error.message ? error.message : error;
33
+ }
34
+ try {
35
+ const answers = await inquirer.prompt([
36
+ {
37
+ type: "checkbox",
38
+ name: "models",
39
+ message: `请选择模型`,
40
+ choices: list,
41
+ loop: false,
42
+ validate(input) {
43
+ if (!input || input.length === 0) {
44
+ return "请至少选择 1 个模型";
45
+ }
46
+ return true;
47
+ },
48
+ default: [],
49
+ },
50
+ ]);
51
+ if (!answers.models.length) {
52
+ throw "最少选择 1 个模型";
53
+ }
54
+ this.modelId = answers.models.join(",");
55
+ config.set("model", this.modelId);
56
+ } catch (error) {
57
+ throw error && error.message ? error.message : error;
58
+ }
59
+ }
60
+ // 验证模型是否可用
61
+ async validateModel() {
62
+ const spinner = new Spinner("正在验证模型是否可用...");
63
+ spinner.start();
64
+
65
+ const startTimestamp = Date.now();
66
+ const configModel = config.get("model")
67
+ ? config.get("model").split(",")
68
+ : [];
69
+ if (!this.baseURL || !configModel.length) return;
70
+ const total = configModel.length;
71
+ const errTotal = [];
72
+ while (configModel.length) {
73
+ const model = configModel.shift();
74
+ try {
75
+ const result = await chat({
76
+ model: model,
77
+ messages: [
78
+ {
79
+ role: "user",
80
+ content: "1+1=?",
81
+ },
82
+ ],
83
+ });
84
+ formatCompletions(result);
85
+ } catch {
86
+ errTotal.push(model);
87
+ }
88
+ }
89
+ spinner.stop();
90
+ if (total - errTotal.length > 0) {
91
+ Logger.success(`模型验证通过 ${total - errTotal.length} 个`);
92
+ }
93
+ if (errTotal.length) {
94
+ Logger.error(
95
+ `模型验证失败 ${errTotal.length} 个,分别是:\n${errTotal.join(
96
+ "\n - "
97
+ )}`
98
+ );
99
+ }
100
+
101
+ const endTimestamp = Date.now();
102
+ const duration = endTimestamp - startTimestamp;
103
+
104
+ Logger.success(`本次检查模型用时: ${(duration / 1000).toFixed(3)} 秒`);
105
+ }
106
+ async execute() {
107
+ config.set("model", this.modelId);
108
+ if (!this.modelId && this.defaultModel) {
109
+ Logger.warn("已清空设置的模型");
110
+ }
111
+ if (!this.modelId) {
112
+ if (this.baseURL && !this.key) {
113
+ Logger.warn(
114
+ `检测到 api key 为空,可能获取列表失败。设置 \`${BIN} set-key <your key>\``
115
+ );
116
+ }
117
+ await this.selectModels();
118
+ }
119
+ if (this.modelId) {
120
+ Logger.success(
121
+ `已设置模型: \n - ${this.modelId.split(`,`).join("\n - ")}`
122
+ );
123
+ }
124
+ await this.validateModel();
125
+ }
126
+ }
127
+
128
+ export default async function (event) {
129
+ const action = new SetModelAction(event);
130
+ await action.run();
131
+ }