Compare commits

..

15 Commits

Author SHA1 Message Date
史悦
8e1f3d8259 优化 generatePrompt 函数,通过注释添加提示,以确保仅输出Git提交信息。
Some checks failed
Test / Test (ubuntu-latest) (push) Has been cancelled
Test / Test (windows-latest) (push) Has been cancelled
2025-10-21 10:12:03 +08:00
史悦
35372013a1 ``refactor(prompt): 优化并规范化生成提交信息的提示词- 将 conventional 类型的提示词从 JSON 格式更改为更简洁的列表格式。- 将通用规则格式化为项目列表,并增加“使用动词开头”的祈使句要求,以提高生成消息的一致性和质量。``
Some checks failed
Test / Test (ubuntu-latest) (push) Has been cancelled
Test / Test (windows-latest) (push) Has been cancelled
2025-10-21 10:02:56 +08:00
史悦
65da1dc301 feat: 在aicommits中添加提交、推送、重写和取消操作选项
Some checks failed
Test / Test (ubuntu-latest) (push) Has been cancelled
Test / Test (windows-latest) (push) Has been cancelled
2025-08-28 10:53:49 +08:00
史悦
69d3ef5040 文档: 在README.md中增加baseURL配置项说明 2025-08-27 17:50:39 +08:00
史悦
b01e7fe566 docs: 将README.md翻译为中文
Some checks failed
Test / Test (ubuntu-latest) (push) Has been cancelled
Test / Test (windows-latest) (push) Has been cancelled
2025-08-27 16:56:47 +08:00
史悦
d23dfecbcd 重构配置模块以移除ini依赖,增强baseURL支持并汉化AI提示词
Some checks failed
Test / Test (ubuntu-latest) (push) Has been cancelled
Test / Test (windows-latest) (push) Has been cancelled
2025-08-27 16:52:04 +08:00
史悦
e7f26efc71 feat: 添加对自定义 baseURL 的支持
- 在 config.ts 中添加 baseURL 解析器
- 在 openai.ts 中更新 createChatCompletion 和 generateCommitMessage 函数
- 在 aicommits.ts 中传递 baseURL 参数
- 支持使用自定义 API 端点替代默认的 OpenAI API
2025-08-27 15:20:19 +08:00
Hassan El Mghari
604def8284 formatting and fixed type error 2024-01-26 10:02:33 -08:00
Hassan El Mghari
723c171417 fixed type error in aicommits 2024-01-26 10:00:18 -08:00
Hassan El Mghari
f63dc33385 updated github action to pnpm v8 2024-01-26 09:58:25 -08:00
Hassan El Mghari
7e2f0000b2 edited README 2024-01-26 09:54:43 -08:00
Hassan El Mghari
f33bfeed60 formatting 2024-01-26 09:53:16 -08:00
Wieger Wolf
578572b1d9 docs: type flag/config for Conventional Commits (#215) 2023-05-12 10:29:07 -04:00
Hiroki Osame
7631c2fc0b fix(hook): install from subdirectory
fix #199
2023-05-03 23:24:34 +09:00
Hiroki Osame
dda7f8d424 refactor: organize prompt generation to its own file 2023-05-03 23:10:15 +09:00
25 changed files with 3641 additions and 3528 deletions

View File

@@ -6,6 +6,7 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
quote_type = single
[*.yml] [*.yml]
indent_style = space indent_style = space

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: privatenumber

View File

@@ -13,23 +13,22 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout
uses: actions/checkout@v3
- name: Checkout - name: Setup Node.js
uses: actions/checkout@v3 uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: Setup Node.js - name: Setup pnpm
uses: actions/setup-node@v3 uses: pnpm/action-setup@v2
with: with:
node-version-file: '.nvmrc' version: 7
run_install: true
- name: Setup pnpm - name: Release
uses: pnpm/action-setup@v2 env:
with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
version: 7 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run_install: true run: pnpm dlx semantic-release
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm dlx semantic-release

View File

@@ -16,37 +16,34 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
version: 7 version: 8
run_install: true run_install: true
- name: Type check - name: Type check
run: pnpm type-check run: pnpm type-check
- name: Lint - name: Build
run: pnpm lint run: pnpm build
- name: Build - name: Install tinyproxy
run: pnpm build if: matrix.os == 'ubuntu-latest'
run: |
- name: Install tinyproxy sudo apt-get install tinyproxy
if: matrix.os == 'ubuntu-latest' tinyproxy
run: | - name: Test
sudo apt-get install tinyproxy env:
tinyproxy OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
- name: Test run: |
env: pnpm test
OPENAI_KEY: ${{ secrets.OPENAI_KEY }} pnpm --use-node-version=14.21.3 test
run: |
pnpm test
pnpm --use-node-version=14.21.3 test

206
README.md
View File

@@ -3,174 +3,193 @@
<img src=".github/screenshot.png" alt="AI Commits"/> <img src=".github/screenshot.png" alt="AI Commits"/>
<h1 align="center">AI Commits</h1> <h1 align="center">AI Commits</h1>
</div> </div>
<p>A CLI that writes your git commit messages for you with AI. Never write a commit message again.</p> <p>一个使用 AI 为您编写 git 提交消息的 CLI 工具。再也不用手写提交消息了。</p>
<a href="https://www.npmjs.com/package/aicommits"><img src="https://img.shields.io/npm/v/aicommits" alt="Current version"></a> <a href="https://www.npmjs.com/package/aicommits"><img src="https://img.shields.io/npm/v/aicommits" alt="当前版本"></a>
</div> </div>
--- ---
## Setup ## 设置
> The minimum supported version of Node.js is the latest v14. Check your Node.js version with `node --version`. > 支持的最低 Node.js 版本是最新的 v14。使用 `node --version` 检查您的 Node.js 版本。
1. 安装 _aicommits_
1. Install _aicommits_: ```sh
npm install -g aicommits
```
```sh 2. 从 [OpenAI](https://platform.openai.com/account/api-keys) 获取您的 API 密钥
npm install -g aicommits
```
2. Retrieve your API key from [OpenAI](https://platform.openai.com/account/api-keys) > 注意:如果您还没有账户,您需要创建一个账户并设置计费。
> Note: If you haven't already, you'll have to create an account and set up billing. 3. 设置密钥以便 aicommits 可以使用它:
3. Set the key so aicommits can use it: ```sh
aicommits config set OPENAI_KEY=<your token>
```
```sh 这将在您的主目录中创建一个 `.aicommits` 文件。
aicommits config set OPENAI_KEY=<your token>
```
This will create a `.aicommits` file in your home directory. ### 升级
使用以下命令检查已安装的版本:
### Upgrading
Check the installed version with:
``` ```
aicommits --version aicommits --version
``` ```
If it's not the [latest version](https://github.com/Nutlope/aicommits/releases/latest), run: 如果它不是[最新版本](https://github.com/Nutlope/aicommits/releases/latest),请运行:
```sh ```sh
npm update -g aicommits npm update -g aicommits
``` ```
## Usage ## 使用方法
### CLI mode
You can call `aicommits` directly to generate a commit message for your staged changes: ### CLI 模式
您可以直接调用 `aicommits` 来为您的暂存更改生成提交消息:
```sh ```sh
git add <files...> git add <files...>
aicommits aicommits
``` ```
`aicommits` passes down unknown flags to `git commit`, so you can pass in [`commit` flags](https://git-scm.com/docs/git-commit). `aicommits` 会将未知标志传递给 `git commit`,因此您可以传入 [`commit` 标志](https://git-scm.com/docs/git-commit)
例如,您可以在提交时暂存所有跟踪文件中的更改:
For example, you can stage all changes in tracked files with as you commit:
```sh ```sh
aicommits --all # or -a aicommits --all # -a
``` ```
> 👉 **Tip:** Use the `aic` alias if `aicommits` is too long for you. > 👉 **提示:** 如果 `aicommits` 对您来说太长,可以使用 `aic` 别名。
#### Generate multiple recommendations #### 生成多个建议
有时推荐的提交消息不是最好的,所以您希望它生成几个以供选择。您可以通过传入 `--generate <i>` 标志来一次生成多个提交消息,其中 'i' 是生成的消息数量:
Sometimes the recommended commit message isn't the best so you want it to generate a few to pick from. You can generate multiple commit messages at once by passing in the `--generate <i>` flag, where 'i' is the number of generated messages:
```sh ```sh
aicommits --generate <i> # or -g <i> aicommits --generate <i> # -g <i>
``` ```
> Warning: this uses more tokens, meaning it costs more. > 警告:这会使用更多的令牌,意味着成本更高。
### Git hook #### 生成约定式提交
You can also integrate _aicommits_ with Git via the [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. This lets you use Git like you normally would, and edit the commit message before committing. 如果您想生成[约定式提交](https://conventionalcommits.org/),可以使用 `--type` 标志,后跟 `conventional`。这将提示 `aicommits` 根据约定式提交规范格式化提交消息:
#### Install ```sh
aicommits --type conventional # 或 -t conventional
```
如果您的项目遵循约定式提交标准,或者您使用依赖此提交格式的工具,此功能会很有用。
### Git 钩子
您还可以通过 [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) 钩子将 _aicommits_ 与 Git 集成。这让您可以像平常一样使用 Git并在提交之前编辑提交消息。
#### 安装
在您要安装钩子的 Git 仓库中:
In the Git repository you want to install the hook in:
```sh ```sh
aicommits hook install aicommits hook install
``` ```
#### Uninstall #### 卸载
In the Git repository you want to uninstall the hook from:
在您要卸载钩子的 Git 仓库中:
```sh ```sh
aicommits hook uninstall aicommits hook uninstall
``` ```
#### Usage #### 使用方法
1. Stage your files and commit: 1. 暂存您的文件并提交:
```sh
git add <files...>
git commit # Only generates a message when it's not passed in
```
> If you ever want to write your own message instead of generating one, you can simply pass one in: `git commit -m "My message"` ```sh
git add <files...>
git commit # 仅在未传入消息时生成消息
```
2. Aicommits will generate the commit message for you and pass it back to Git. Git will open it with the [configured editor](https://docs.github.com/en/get-started/getting-started-with-git/associating-text-editors-with-git) for you to review/edit it. > 如果您想自己编写消息而不是生成消息,可以简单地传入一个:`git commit -m "My message"`
3. Save and close the editor to commit! 2. Aicommits 将为您生成提交消息并将其传递回 Git。Git 将使用[配置的编辑器](https://docs.github.com/en/get-started/getting-started-with-git/associating-text-editors-with-git)打开它供您查看/编辑。
## Configuration 3. 保存并关闭编辑器以提交!
### Reading a configuration value ## 配置
To retrieve a configuration option, use the command:
### 读取配置值
要检索配置选项,请使用命令:
```sh ```sh
aicommits config get <key> aicommits config get <key>
``` ```
For example, to retrieve the API key, you can use: 例如,要检索 API 密钥,您可以使用:
```sh ```sh
aicommits config get OPENAI_KEY aicommits config get OPENAI_KEY
``` ```
You can also retrieve multiple configuration options at once by separating them with spaces: 您还可以通过用空格分隔它们来一次检索多个配置选项:
```sh ```sh
aicommits config get OPENAI_KEY generate aicommits config get OPENAI_KEY generate
``` ```
### Setting a configuration value ### 设置配置值
To set a configuration option, use the command: 要设置配置选项,请使用命令:
```sh ```sh
aicommits config set <key>=<value> aicommits config set <key>=<value>
``` ```
For example, to set the API key, you can use: 例如,要设置 API 密钥,您可以使用:
```sh ```sh
aicommits config set OPENAI_KEY=<your-api-key> aicommits config set OPENAI_KEY=<your-api-key>
``` ```
You can also set multiple configuration options at once by separating them with spaces, like 您还可以通过用空格分隔它们来一次设置多个配置选项,例如
```sh ```sh
aicommits config set OPENAI_KEY=<your-api-key> generate=3 locale=en aicommits config set OPENAI_KEY=<your-api-key> generate=3 locale=en
``` ```
### Options ### 选项
#### OPENAI_KEY #### OPENAI_KEY
Required 必需
The OpenAI API key. You can retrieve it from [OpenAI API Keys page](https://platform.openai.com/account/api-keys). OpenAI API 密钥。您可以从 [OpenAI API 密钥页面](https://platform.openai.com/account/api-keys) 检索它。
#### locale #### locale
Default: `en`
The locale to use for the generated commit messages. Consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes. 默认值:`en`
用于生成提交消息的语言环境。请参阅以下列表中的代码https://wikipedia.org/wiki/List_of_ISO_639-1_codes。
#### generate #### generate
Default: `1` 默认值:`1`
The number of commit messages to generate to pick from. 要生成的提交消息数量以供选择。
Note, this will use more tokens as it generates more results. 注意,这会使用更多的令牌,因为它会生成更多的结果。
#### proxy #### proxy
Set a HTTP/HTTPS proxy to use for requests. 设置用于请求的 HTTP/HTTPS 代理。
To clear the proxy option, you can use the command (note the empty value after the equals sign): 要清除代理选项,您可以使用命令(注意等号后的空值):
```sh ```sh
aicommits config set proxy= aicommits config set proxy=
@@ -178,45 +197,72 @@ aicommits config set proxy=
#### model #### model
Default: `gpt-3.5-turbo` 默认值:`gpt-3.5-turbo`
The Chat Completions (`/v1/chat/completions`) model to use. Consult the list of models available in the [OpenAI Documentation](https://platform.openai.com/docs/models/model-endpoint-compatibility). 要使用的聊天完成(`/v1/chat/completions`)模型。请参阅 [OpenAI 文档](https://platform.openai.com/docs/models/model-endpoint-compatibility)中可用模型的列表。
> Tip: If you have access, try upgrading to [`gpt-4`](https://platform.openai.com/docs/models/gpt-4) for next-level code analysis. It can handle double the input size, but comes at a higher cost. Check out OpenAI's website to learn more.
> 提示:如果您有权限,尝试升级到 [`gpt-4`](https://platform.openai.com/docs/models/gpt-4) 以获得更高级的代码分析。它可以处理双倍的输入大小,但成本更高。请访问 OpenAI 的网站了解更多信息。
#### timeout #### timeout
The timeout for network requests to the OpenAI API in milliseconds.
Default: `10000` (10 seconds) 向 OpenAI API 发出网络请求的超时时间(以毫秒为单位)。
默认值:`10000`10 秒)
```sh ```sh
aicommits config set timeout=20000 # 20s aicommits config set timeout=20000 # 20s
``` ```
#### max-length #### max-length
The maximum character length of the generated commit message.
Default: `50` 生成的提交消息的最大字符长度。
默认值:`50`
```sh ```sh
aicommits config set max-length=100 aicommits config set max-length=100
``` ```
## How it works #### type
This CLI tool runs `git diff` to grab all your latest code changes, sends them to OpenAI's GPT-3, then returns the AI generated commit message. 默认值:`""`(空字符串)
Video coming soon where I rebuild it from scratch to show you how to easily build your own CLI tools powered by AI. 要生成的提交消息类型。将其设置为 "conventional" 以生成遵循约定式提交规范的提交消息:
## Maintainers ```sh
aicommits config set type=conventional
```
您可以通过将其设置为空字符串来清除此选项:
```sh
aicommits config set type=
```
#### baseURL
默认值:`https://api.openai.com`
用于设置 OpenAI API 的基础 URL。如果您使用的是 OpenAI 的代理服务或其他兼容的 API 端点,可以通过此选项进行配置:
```sh
aicommits config set baseURL=https://your-proxy-url.com
```
这对于使用自定义 API 端点或代理服务的用户非常有用。
## 工作原理
这个 CLI 工具运行 `git diff` 来获取您所有的最新代码更改,将它们发送到 OpenAI 的 GPT-3然后返回 AI 生成的提交消息。
视频即将推出,我将从头开始重建它,向您展示如何轻松构建由 AI 驱动的自己的 CLI 工具。
## 维护者
- **Hassan El Mghari**: [@Nutlope](https://github.com/Nutlope) [<img src="https://img.shields.io/twitter/follow/nutlope?style=flat&label=nutlope&logo=twitter&color=0bf&logoColor=fff" align="center">](https://twitter.com/nutlope) - **Hassan El Mghari**: [@Nutlope](https://github.com/Nutlope) [<img src="https://img.shields.io/twitter/follow/nutlope?style=flat&label=nutlope&logo=twitter&color=0bf&logoColor=fff" align="center">](https://twitter.com/nutlope)
- **Hiroki Osame**: [@privatenumber](https://github.com/privatenumber) [<img src="https://img.shields.io/twitter/follow/privatenumbr?style=flat&label=privatenumbr&logo=twitter&color=0bf&logoColor=fff" align="center">](https://twitter.com/privatenumbr) - **Hiroki Osame**: [@privatenumber](https://github.com/privatenumber) [<img src="https://img.shields.io/twitter/follow/privatenumbr?style=flat&label=privatenumbr&logo=twitter&color=0bf&logoColor=fff" align="center">](https://twitter.com/privatenumbr)
## 贡献
## Contributing 如果您想在 [Issues](https://github.com/Nutlope/aicommits/issues) 中帮助修复错误或实现功能,请查看 [贡献指南](CONTRIBUTING.md) 以了解如何设置和测试项目。
If you want to help fix a bug or implement a feature in [Issues](https://github.com/Nutlope/aicommits/issues), checkout the [Contribution Guide](CONTRIBUTING.md) to learn how to setup and test the project.

2284
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,66 +19,36 @@
"aic": "./dist/cli.mjs" "aic": "./dist/cli.mjs"
}, },
"scripts": { "scripts": {
"prepare": "simple-git-hooks",
"build": "pkgroll --minify", "build": "pkgroll --minify",
"lint": "eslint --cache .", "lint": "",
"type-check": "tsc", "type-check": "tsc",
"test": "tsx tests", "test": "tsx tests",
"prepack": "pnpm build && clean-pkg-json" "prepack": "pnpm build && clean-pkg-json"
}, },
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.ts": "eslint --cache"
},
"dependencies": { "dependencies": {
"@dqbd/tiktoken": "^1.0.2" "@dqbd/tiktoken": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@clack/prompts": "^0.6.1", "@clack/prompts": "^0.7.0",
"@pvtnbr/eslint-config": "^0.33.0",
"@types/ini": "^1.3.31", "@types/ini": "^1.3.31",
"@types/inquirer": "^9.0.3", "@types/inquirer": "^9.0.3",
"@types/node": "^18.14.2", "@types/node": "^18.14.2",
"clean-pkg-json": "^1.2.0", "clean-pkg-json": "^1.2.0",
"cleye": "^1.3.2", "cleye": "^1.3.2",
"eslint": "^8.35.0",
"execa": "^7.0.0", "execa": "^7.0.0",
"fs-fixture": "^1.2.0", "fs-fixture": "^1.2.0",
"https-proxy-agent": "^5.0.1", "https-proxy-agent": "^5.0.1",
"ini": "^3.0.1", "ini": "^3.0.1",
"kolorist": "^1.7.0", "kolorist": "^1.7.0",
"lint-staged": "^13.1.2",
"manten": "^0.7.0", "manten": "^0.7.0",
"openai": "^3.2.1", "openai": "^3.2.1",
"pkgroll": "^1.9.0", "pkgroll": "^1.9.0",
"simple-git-hooks": "^2.8.1",
"tsx": "^3.12.3", "tsx": "^3.12.3",
"typescript": "^4.9.5" "typescript": "^4.9.5"
}, },
"eslintConfig": {
"extends": "@pvtnbr",
"rules": {
"unicorn/no-process-exit": "off"
},
"overrides": [
{
"files": "./src/commands/prepare-commit-msg-hook.ts",
"rules": {
"unicorn/prevent-abbreviations": "off"
}
}
]
},
"release": { "release": {
"branches": [ "branches": [
"main" "main"
] ]
},
"pnpm": {
"patchedDependencies": {
"@clack/prompts@0.6.1": "patches/@clack__prompts@0.6.1.patch"
}
} }
} }

3262
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@ cli(
flags: { flags: {
generate: { generate: {
type: Number, type: Number,
description: 'Number of messages to generate (Warning: generating multiple costs more) (default: 1)', description:
'Number of messages to generate (Warning: generating multiple costs more) (default: 1)',
alias: 'g', alias: 'g',
}, },
exclude: { exclude: {
@@ -31,7 +32,8 @@ cli(
}, },
all: { all: {
type: Boolean, type: Boolean,
description: 'Automatically stage changes in tracked files for the commit', description:
'Automatically stage changes in tracked files for the commit',
alias: 'a', alias: 'a',
default: false, default: false,
}, },
@@ -42,16 +44,13 @@ cli(
}, },
}, },
commands: [ commands: [configCommand, hookCommand],
configCommand,
hookCommand,
],
help: { help: {
description, description,
}, },
ignoreArgv: type => type === 'unknown-flag' || type === 'argument', ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
}, },
(argv) => { (argv) => {
if (isCalledFromGitHook) { if (isCalledFromGitHook) {
@@ -62,9 +61,9 @@ cli(
argv.flags.exclude, argv.flags.exclude,
argv.flags.all, argv.flags.all,
argv.flags.type, argv.flags.type,
rawArgv, rawArgv
); );
} }
}, },
rawArgv, rawArgv
); );

View File

@@ -1,9 +1,12 @@
import { execa } from 'execa'; import { execa } from 'execa';
import { black, dim, green, red, bgCyan } from 'kolorist';
import { import {
black, dim, green, red, bgCyan, intro,
} from 'kolorist'; outro,
import { spinner,
intro, outro, spinner, select, confirm, isCancel, select,
confirm,
isCancel,
} from '@clack/prompts'; } from '@clack/prompts';
import { import {
assertGitRepo, assertGitRepo,
@@ -19,90 +22,190 @@ export default async (
excludeFiles: string[], excludeFiles: string[],
stageAll: boolean, stageAll: boolean,
commitType: string | undefined, commitType: string | undefined,
rawArgv: string[], rawArgv: string[]
) => (async () => { ) =>
intro(bgCyan(black(' aicommits '))); (async () => {
await assertGitRepo(); intro(bgCyan(black(' aicommits ')));
await assertGitRepo();
const detectingFiles = spinner(); const detectingFiles = spinner();
if (stageAll) { if (stageAll) {
// This should be equivalent behavior to `git commit --all` // This should be equivalent behavior to `git commit --all`
await execa('git', ['add', '--update']); await execa('git', ['add', '--update']);
} }
detectingFiles.start('Detecting staged files'); detectingFiles.start('Detecting staged files');
const staged = await getStagedDiff(excludeFiles); const staged = await getStagedDiff(excludeFiles);
if (!staged) { if (!staged) {
detectingFiles.stop('Detecting staged files'); detectingFiles.stop('Detecting staged files');
throw new KnownError('No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'); throw new KnownError(
} 'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'
);
}
detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${staged.files.map(file => ` ${file}`).join('\n') detectingFiles.stop(
}`); `${getDetectedMessage(staged.files)}:\n${staged.files
.map((file) => ` ${file}`)
const { env } = process; .join('\n')}`
const config = await getConfig({
OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
proxy: env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
generate: generate?.toString(),
type: commitType?.toString(),
});
const s = spinner();
s.start('The AI is analyzing your changes');
let messages: string[];
try {
messages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
); );
} finally {
s.stop('Changes analyzed');
}
if (messages.length === 0) { const { env } = process;
throw new KnownError('No commit messages were generated. Try again.'); const config = await getConfig({
} OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
proxy:
let message: string; env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
if (messages.length === 1) { generate: generate?.toString(),
[message] = messages; type: commitType?.toString(),
const confirmed = await confirm({
message: `Use this commit message?\n\n ${message}\n`,
}); });
if (!confirmed || isCancel(confirmed)) { const s = spinner();
outro('Commit cancelled'); s.start('正在分析您的更改');
return; let messages: string[];
} try {
} else { messages = await generateCommitMessage(
const selected = await select({ config.OPENAI_KEY,
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`, config.model,
options: messages.map(value => ({ label: value, value })), config.locale,
}); staged.diff,
config.generate,
if (isCancel(selected)) { config['max-length'],
outro('Commit cancelled'); config.type,
return; config.timeout,
config.proxy,
config.baseURL
);
} finally {
s.stop('Changes analyzed');
} }
message = selected; if (messages.length === 0) {
} throw new KnownError('No commit messages were generated. Try again.');
}
await execa('git', ['commit', '-m', message, ...rawArgv]); // 处理单个或多个提交消息的函数
const handleCommitAction = async (msg: string, isSingleMessage: boolean = true) => {
outro(`${green('✔')} Successfully committed!`); const action = await select({
})().catch((error) => { message: isSingleMessage ? `使用此提交消息?\n\n ${msg}\n` : '选择操作:',
outro(`${red('✖')} ${error.message}`); options: [
handleCliError(error); { label: '提交', value: 'commit' },
process.exit(1); { label: '提交并推送', value: 'commit-and-push' },
}); { label: '重写', value: 'rewrite' },
{ label: '取消', value: 'cancel' },
],
});
if (isCancel(action) || action === 'cancel') {
outro('提交已取消');
return false;
}
if (action === 'rewrite') {
return 'rewrite';
}
if (action === 'commit') {
await execa('git', ['commit', '-m', msg, ...rawArgv]);
outro(`${green('✔')} 提交成功!`);
return true;
} else if (action === 'commit-and-push') {
await execa('git', ['commit', '-m', msg, ...rawArgv]);
const pushSpinner = spinner();
pushSpinner.start('正在推送到远程仓库...');
try {
await execa('git', ['push']);
pushSpinner.stop('推送成功');
outro(`${green('✔')} 提交并推送成功!`);
return true;
} catch (error) {
pushSpinner.stop('推送失败');
outro(`${red('✖')} 提交成功但推送失败: ${(error as Error).message}`);
return true;
}
}
return false;
};
// 重新生成提交消息的函数
const regenerateMessages = async () => {
const s = spinner();
s.start('正在重新生成提交消息...');
try {
const config = await getConfig({
OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
proxy:
env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
generate: generate?.toString(),
type: commitType?.toString(),
});
const newMessages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
config.baseURL
);
s.stop('已重新生成提交消息');
if (newMessages.length === 0) {
throw new KnownError('没有重新生成提交消息。请重试。');
}
return newMessages;
} catch (error) {
s.stop('重新生成失败');
throw error;
}
};
// 处理消息选择和提交的主循环
let currentMessages = messages;
while (true) {
let message: string;
if (currentMessages.length === 1) {
[message] = currentMessages;
const result = await handleCommitAction(message, true);
if (result === 'rewrite') {
currentMessages = await regenerateMessages();
continue;
} else if (result === true || result === false) {
return;
}
} else {
const selected = await select({
message: `选择一个提交消息: ${dim('(Ctrl+c 退出)')}`,
options: currentMessages.map((value) => ({ label: value, value })),
});
if (isCancel(selected)) {
outro('提交已取消');
return;
}
message = selected as string;
const result = await handleCommitAction(message, false);
if (result === 'rewrite') {
currentMessages = await regenerateMessages();
continue;
} else if (result === true || result === false) {
return;
}
}
}
})().catch((error) => {
outro(`${red('✖')} ${error.message}`);
handleCliError(error);
process.exit(1);
});

View File

@@ -3,35 +3,43 @@ import { red } from 'kolorist';
import { hasOwn, getConfig, setConfigs } from '../utils/config.js'; import { hasOwn, getConfig, setConfigs } from '../utils/config.js';
import { KnownError, handleCliError } from '../utils/error.js'; import { KnownError, handleCliError } from '../utils/error.js';
export default command({ export default command(
name: 'config', {
name: 'config',
parameters: ['<mode>', '<key=value...>'], parameters: ['<mode>', '<key=value...>'],
}, (argv) => { },
(async () => { (argv) => {
const { mode, keyValue: keyValues } = argv._; (async () => {
const { mode, keyValue: keyValues } = argv._;
if (mode === 'get') { if (mode === 'get') {
const config = await getConfig({}, true); const config = await getConfig({}, true);
for (const key of keyValues) { for (const key of keyValues) {
if (hasOwn(config, key)) { if (hasOwn(config, key)) {
console.log(`${key}=${config[key as keyof typeof config]}`); console.log(`${key}=${config[key as keyof typeof config]}`);
}
} }
return;
} }
return;
}
if (mode === 'set') { if (mode === 'set') {
await setConfigs( await setConfigs(
keyValues.map(keyValue => keyValue.split('=') as [string, string]), keyValues.map((keyValue) => {
); const separatorIndex = keyValue.indexOf('=');
return; const key = keyValue.slice(0, separatorIndex);
} const value = keyValue.slice(separatorIndex + 1);
return [key, value] as [string, string];
})
);
return;
}
throw new KnownError(`Invalid mode: ${mode}`); throw new KnownError(`Invalid mode: ${mode}`);
})().catch((error) => { })().catch((error) => {
console.error(`${red('✖')} ${error.message}`); console.error(`${red('✖')} ${error.message}`);
handleCliError(error); handleCliError(error);
process.exit(1); process.exit(1);
}); });
}); }
);

View File

@@ -12,11 +12,9 @@ const symlinkPath = `.git/hooks/${hookName}`;
const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url)); const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));
export const isCalledFromGitHook = ( export const isCalledFromGitHook = process.argv[1]
process.argv[1] .replace(/\\/g, '/') // Replace Windows back slashes with forward slashes
.replace(/\\/g, '/') // Replace Windows back slashes with forward slashes .endsWith(`/${symlinkPath}`);
.endsWith(`/${symlinkPath}`)
);
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
const windowsHook = ` const windowsHook = `
@@ -24,72 +22,76 @@ const windowsHook = `
import(${JSON.stringify(pathToFileURL(hookPath))}) import(${JSON.stringify(pathToFileURL(hookPath))})
`.trim(); `.trim();
export default command({ export default command(
name: 'hook', {
parameters: ['<install/uninstall>'], name: 'hook',
}, (argv) => { parameters: ['<install/uninstall>'],
(async () => { },
await assertGitRepo(); (argv) => {
(async () => {
const gitRepoPath = await assertGitRepo();
const { installUninstall: mode } = argv._;
const { installUninstall: mode } = argv._; const absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath);
const hookExists = await fileExists(absoltueSymlinkPath);
const hookExists = await fileExists(symlinkPath); if (mode === 'install') {
if (mode === 'install') { if (hookExists) {
if (hookExists) { // If the symlink is broken, it will throw an error
// If the symlink is broken, it will throw an error // eslint-disable-next-line @typescript-eslint/no-empty-function
// eslint-disable-next-line @typescript-eslint/no-empty-function const realpath = await fs
const realpath = await fs.realpath(symlinkPath).catch(() => {}); .realpath(absoltueSymlinkPath)
if (realpath === hookPath) { .catch(() => {});
console.warn('The hook is already installed'); if (realpath === hookPath) {
return; console.warn('The hook is already installed');
return;
}
throw new KnownError(
`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`
);
} }
throw new KnownError(`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`);
}
await fs.mkdir(path.dirname(symlinkPath), { recursive: true }); await fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true });
if (isWindows) { if (isWindows) {
await fs.writeFile( await fs.writeFile(absoltueSymlinkPath, windowsHook);
symlinkPath, } else {
windowsHook, await fs.symlink(hookPath, absoltueSymlinkPath, 'file');
); await fs.chmod(absoltueSymlinkPath, 0o755);
} else { }
await fs.symlink(hookPath, symlinkPath, 'file'); console.log(`${green('✔')} Hook installed`);
await fs.chmod(symlinkPath, 0o755);
}
console.log(`${green('✔')} Hook installed`);
return;
}
if (mode === 'uninstall') {
if (!hookExists) {
console.warn('Hook is not installed');
return; return;
} }
if (isWindows) { if (mode === 'uninstall') {
const scriptContent = await fs.readFile(symlinkPath, 'utf8'); if (!hookExists) {
if (scriptContent !== windowsHook) {
console.warn('Hook is not installed'); console.warn('Hook is not installed');
return; return;
} }
} else {
const realpath = await fs.realpath(symlinkPath); if (isWindows) {
if (realpath !== hookPath) { const scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8');
console.warn('Hook is not installed'); if (scriptContent !== windowsHook) {
return; console.warn('Hook is not installed');
return;
}
} else {
const realpath = await fs.realpath(absoltueSymlinkPath);
if (realpath !== hookPath) {
console.warn('Hook is not installed');
return;
}
} }
await fs.rm(absoltueSymlinkPath);
console.log(`${green('✔')} Hook uninstalled`);
return;
} }
await fs.rm(symlinkPath); throw new KnownError(`Invalid mode: ${mode}`);
console.log(`${green('✔')} Hook uninstalled`); })().catch((error) => {
return; console.error(`${red('✖')} ${error.message}`);
} handleCliError(error);
process.exit(1);
throw new KnownError(`Invalid mode: ${mode}`); });
})().catch((error) => { }
console.error(`${red('✖')} ${error.message}`); );
handleCliError(error);
process.exit(1);
});
});

View File

@@ -1,10 +1,6 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import { import { intro, outro, spinner } from '@clack/prompts';
intro, outro, spinner, import { black, green, red, bgCyan } from 'kolorist';
} from '@clack/prompts';
import {
black, green, red, bgCyan,
} from 'kolorist';
import { getStagedDiff } from '../utils/git.js'; import { getStagedDiff } from '../utils/git.js';
import { getConfig } from '../utils/config.js'; import { getConfig } from '../utils/config.js';
import { generateCommitMessage } from '../utils/openai.js'; import { generateCommitMessage } from '../utils/openai.js';
@@ -12,83 +8,89 @@ import { KnownError, handleCliError } from '../utils/error.js';
const [messageFilePath, commitSource] = process.argv.slice(2); const [messageFilePath, commitSource] = process.argv.slice(2);
export default () => (async () => { export default () =>
if (!messageFilePath) { (async () => {
throw new KnownError('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'); if (!messageFilePath) {
} throw new KnownError(
'Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'
);
}
// If a commit message is passed in, ignore // If a commit message is passed in, ignore
if (commitSource) { if (commitSource) {
return; return;
} }
// All staged files can be ignored by our filter // All staged files can be ignored by our filter
const staged = await getStagedDiff(); const staged = await getStagedDiff();
if (!staged) { if (!staged) {
return; return;
} }
intro(bgCyan(black(' aicommits '))); intro(bgCyan(black(' aicommits ')));
const { env } = process; const { env } = process;
const config = await getConfig({ const config = await getConfig({
proxy: env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY, proxy:
env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
});
const s = spinner();
s.start('正在分析您的更改');
let messages: string[];
try {
messages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged!.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy
);
} finally {
s.stop('Changes analyzed');
}
/**
* When `--no-edit` is passed in, the base commit message is empty,
* and even when you use pass in comments via #, they are ignored.
*
* Note: `--no-edit` cannot be detected in argvs so this is the only way to check
*/
const baseMessage = await fs.readFile(messageFilePath, 'utf8');
const supportsComments = baseMessage !== '';
const hasMultipleMessages = messages.length > 1;
let instructions = '';
if (supportsComments) {
instructions = `# 🤖 AI generated commit${
hasMultipleMessages ? 's' : ''
}\n`;
}
if (hasMultipleMessages) {
if (supportsComments) {
instructions +=
'# Select one of the following messages by uncommeting:\n';
}
instructions += `\n${messages
.map((message) => `# ${message}`)
.join('\n')}`;
} else {
if (supportsComments) {
instructions += '# Edit the message below and commit:\n';
}
instructions += `\n${messages[0]}\n`;
}
await fs.appendFile(messageFilePath, instructions);
outro(`${green('✔')} Saved commit message!`);
})().catch((error) => {
outro(`${red('✖')} ${error.message}`);
handleCliError(error);
process.exit(1);
}); });
const s = spinner();
s.start('The AI is analyzing your changes');
let messages: string[];
try {
messages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged!.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
);
} finally {
s.stop('Changes analyzed');
}
/**
* When `--no-edit` is passed in, the base commit message is empty,
* and even when you use pass in comments via #, they are ignored.
*
* Note: `--no-edit` cannot be detected in argvs so this is the only way to check
*/
const baseMessage = await fs.readFile(messageFilePath, 'utf8');
const supportsComments = baseMessage !== '';
const hasMultipleMessages = messages.length > 1;
let instructions = '';
if (supportsComments) {
instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`;
}
if (hasMultipleMessages) {
if (supportsComments) {
instructions += '# Select one of the following messages by uncommeting:\n';
}
instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`;
} else {
if (supportsComments) {
instructions += '# Edit the message below and commit:\n';
}
instructions += `\n${messages[0]}\n`;
}
await fs.appendFile(
messageFilePath,
instructions,
);
outro(`${green('✔')} Saved commit message!`);
})().catch((error) => {
outro(`${red('✖')} ${error.message}`);
handleCliError(error);
process.exit(1);
});

View File

@@ -1,23 +1,19 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import ini from 'ini';
import type { TiktokenModel } from '@dqbd/tiktoken'; import type { TiktokenModel } from '@dqbd/tiktoken';
import { fileExists } from './fs.js'; import { fileExists } from './fs.js';
import { KnownError } from './error.js'; import { KnownError } from './error.js';
const commitTypes = ['', 'conventional'] as const; const commitTypes = ['', 'conventional'] as const;
export type CommitType = typeof commitTypes[number]; export type CommitType = (typeof commitTypes)[number];
const { hasOwnProperty } = Object.prototype; const { hasOwnProperty } = Object.prototype;
export const hasOwn = (object: unknown, key: PropertyKey) => hasOwnProperty.call(object, key); export const hasOwn = (object: unknown, key: PropertyKey) =>
hasOwnProperty.call(object, key);
const parseAssert = ( const parseAssert = (name: string, condition: any, message: string) => {
name: string,
condition: any,
message: string,
) => {
if (!condition) { if (!condition) {
throw new KnownError(`Invalid config property ${name}: ${message}`); throw new KnownError(`Invalid config property ${name}: ${message}`);
} }
@@ -26,7 +22,9 @@ const parseAssert = (
const configParsers = { const configParsers = {
OPENAI_KEY(key?: string) { OPENAI_KEY(key?: string) {
if (!key) { if (!key) {
throw new KnownError('Please set your OpenAI API key via `aicommits config set OPENAI_KEY=<your token>`'); throw new KnownError(
'Please set your OpenAI API key via `aicommits config set OPENAI_KEY=<your token>`'
);
} }
parseAssert('OPENAI_KEY', key.startsWith('sk-'), 'Must start with "sk-"'); parseAssert('OPENAI_KEY', key.startsWith('sk-'), 'Must start with "sk-"');
// Key can range from 43~51 characters. There's no spec to assert this. // Key can range from 43~51 characters. There's no spec to assert this.
@@ -39,7 +37,11 @@ const configParsers = {
} }
parseAssert('locale', locale, 'Cannot be empty'); parseAssert('locale', locale, 'Cannot be empty');
parseAssert('locale', /^[a-z-]+$/i.test(locale), 'Must be a valid locale (letters and dashes/underscores). You can consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes'); parseAssert(
'locale',
/^[a-z-]+$/i.test(locale),
'Must be a valid locale (letters and dashes/underscores). You can consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes'
);
return locale; return locale;
}, },
generate(count?: string) { generate(count?: string) {
@@ -60,7 +62,11 @@ const configParsers = {
return ''; return '';
} }
parseAssert('type', commitTypes.includes(type as CommitType), 'Invalid commit type'); parseAssert(
'type',
commitTypes.includes(type as CommitType),
'Invalid commit type'
);
return type as CommitType; return type as CommitType;
}, },
@@ -100,10 +106,23 @@ const configParsers = {
parseAssert('max-length', /^\d+$/.test(maxLength), 'Must be an integer'); parseAssert('max-length', /^\d+$/.test(maxLength), 'Must be an integer');
const parsed = Number(maxLength); const parsed = Number(maxLength);
parseAssert('max-length', parsed >= 20, 'Must be greater than 20 characters'); parseAssert(
'max-length',
parsed >= 20,
'Must be greater than 20 characters'
);
return parsed; return parsed;
}, },
baseURL(url?: string) {
if (!url || url.length === 0) {
return undefined;
}
parseAssert('baseURL', /^https?:\/\//.test(url), 'Must be a valid URL');
return url;
},
} as const; } as const;
type ConfigKeys = keyof typeof configParsers; type ConfigKeys = keyof typeof configParsers;
@@ -113,7 +132,7 @@ type RawConfig = {
}; };
export type ValidConfig = { export type ValidConfig = {
[Key in ConfigKeys]: ReturnType<typeof configParsers[Key]>; [Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>;
}; };
const configPath = path.join(os.homedir(), '.aicommits'); const configPath = path.join(os.homedir(), '.aicommits');
@@ -125,12 +144,21 @@ const readConfigFile = async (): Promise<RawConfig> => {
} }
const configString = await fs.readFile(configPath, 'utf8'); const configString = await fs.readFile(configPath, 'utf8');
return ini.parse(configString); const config = Object.create(null);
for (const line of configString.split(/\r?\n/)) {
const separatorIndex = line.indexOf('=');
if (separatorIndex !== -1) {
const key = line.slice(0, separatorIndex);
const value = line.slice(separatorIndex + 1);
config[key.trim()] = value.trim();
}
}
return config;
}; };
export const getConfig = async ( export const getConfig = async (
cliConfig?: RawConfig, cliConfig?: RawConfig,
suppressErrors?: boolean, suppressErrors?: boolean
): Promise<ValidConfig> => { ): Promise<ValidConfig> => {
const config = await readConfigFile(); const config = await readConfigFile();
const parsedConfig: Record<string, unknown> = {}; const parsedConfig: Record<string, unknown> = {};
@@ -151,9 +179,7 @@ export const getConfig = async (
return parsedConfig as ValidConfig; return parsedConfig as ValidConfig;
}; };
export const setConfigs = async ( export const setConfigs = async (keyValues: [key: string, value: string][]) => {
keyValues: [key: string, value: string][],
) => {
const config = await readConfigFile(); const config = await readConfigFile();
for (const [key, value] of keyValues) { for (const [key, value] of keyValues) {
@@ -165,5 +191,9 @@ export const setConfigs = async (
config[key as ConfigKeys] = parsed as any; config[key as ConfigKeys] = parsed as any;
} }
await fs.writeFile(configPath, ini.stringify(config), 'utf8'); const configString = Object.entries(config)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join(os.EOL);
await fs.writeFile(configPath, configString, 'utf8');
}; };

View File

@@ -6,15 +6,16 @@ export class KnownError extends Error {}
const indent = ' '; const indent = ' ';
export const handleCliError = (error: any) => { export const handleCliError = (error: any) => {
if ( if (error instanceof Error && !(error instanceof KnownError)) {
error instanceof Error
&& !(error instanceof KnownError)
) {
if (error.stack) { if (error.stack) {
console.error(dim(error.stack.split('\n').slice(1).join('\n'))); console.error(dim(error.stack.split('\n').slice(1).join('\n')));
} }
console.error(`\n${indent}${dim(`aicommits v${version}`)}`); console.error(`\n${indent}${dim(`aicommits v${version}`)}`);
console.error(`\n${indent}Please open a Bug report with the information above:`); console.error(
console.error(`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`); `\n${indent}Please open a Bug report with the information above:`
);
console.error(
`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`
);
} }
}; };

View File

@@ -1,4 +1,8 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
// lstat is used because this is also used to check if a symlink file exists // lstat is used because this is also used to check if a symlink file exists
export const fileExists = (filePath: string) => fs.lstat(filePath).then(() => true, () => false); export const fileExists = (filePath: string) =>
fs.lstat(filePath).then(
() => true,
() => false
);

View File

@@ -2,11 +2,17 @@ import { execa } from 'execa';
import { KnownError } from './error.js'; import { KnownError } from './error.js';
export const assertGitRepo = async () => { export const assertGitRepo = async () => {
const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }); const { stdout, failed } = await execa(
'git',
['rev-parse', '--show-toplevel'],
{ reject: false }
);
if (stdout !== 'true') { if (failed) {
throw new KnownError('The current directory must be a Git repository!'); throw new KnownError('The current directory must be a Git repository!');
} }
return stdout;
}; };
const excludeFromDiff = (path: string) => `:(exclude)${path}`; const excludeFromDiff = (path: string) => `:(exclude)${path}`;
@@ -21,36 +27,22 @@ const filesToExclude = [
export const getStagedDiff = async (excludeFiles?: string[]) => { export const getStagedDiff = async (excludeFiles?: string[]) => {
const diffCached = ['diff', '--cached', '--diff-algorithm=minimal']; const diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];
const { stdout: files } = await execa( const { stdout: files } = await execa('git', [
'git', ...diffCached,
[ '--name-only',
...diffCached, ...filesToExclude,
'--name-only', ...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
...filesToExclude, ]);
...(
excludeFiles
? excludeFiles.map(excludeFromDiff)
: []
),
],
);
if (!files) { if (!files) {
return; return;
} }
const { stdout: diff } = await execa( const { stdout: diff } = await execa('git', [
'git', ...diffCached,
[ ...filesToExclude,
...diffCached, ...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
...filesToExclude, ]);
...(
excludeFiles
? excludeFiles.map(excludeFromDiff)
: []
),
],
);
return { return {
files: files.split('\n'), files: files.split('\n'),
@@ -58,4 +50,7 @@ export const getStagedDiff = async (excludeFiles?: string[]) => {
}; };
}; };
export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`; export const getDetectedMessage = (files: string[]) =>
`Detected ${files.length.toLocaleString()} staged file${
files.length > 1 ? 's' : ''
}`;

View File

@@ -1,6 +1,9 @@
import https from 'https'; import https from 'https';
import type { ClientRequest, IncomingMessage } from 'http'; import type { ClientRequest, IncomingMessage } from 'http';
import type { ChatCompletionRequestMessage, CreateChatCompletionRequest, CreateChatCompletionResponse } from 'openai'; import type {
CreateChatCompletionRequest,
CreateChatCompletionResponse,
} from 'openai';
import { import {
type TiktokenModel, type TiktokenModel,
// encoding_for_model, // encoding_for_model,
@@ -8,6 +11,7 @@ import {
import createHttpsProxyAgent from 'https-proxy-agent'; import createHttpsProxyAgent from 'https-proxy-agent';
import { KnownError } from './error.js'; import { KnownError } from './error.js';
import type { CommitType } from './config.js'; import type { CommitType } from './config.js';
import { generatePrompt } from './prompt.js';
const httpsPost = async ( const httpsPost = async (
hostname: string, hostname: string,
@@ -15,74 +19,81 @@ const httpsPost = async (
headers: Record<string, string>, headers: Record<string, string>,
json: unknown, json: unknown,
timeout: number, timeout: number,
proxy?: string, proxy?: string
) => new Promise<{ ) =>
request: ClientRequest; new Promise<{
response: IncomingMessage; request: ClientRequest;
data: string; response: IncomingMessage;
}>((resolve, reject) => { data: string;
const postContent = JSON.stringify(json); }>((resolve, reject) => {
const request = https.request( const postContent = JSON.stringify(json);
{ const request = https.request(
port: 443, {
hostname, port: 443,
path, hostname,
method: 'POST', path,
headers: { method: 'POST',
...headers, headers: {
'Content-Type': 'application/json', ...headers,
'Content-Length': Buffer.byteLength(postContent), 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postContent),
},
timeout,
agent: (proxy && proxy.trim() !== '') ? createHttpsProxyAgent(proxy) : undefined as any,
}, },
timeout, (response) => {
agent: ( const body: Buffer[] = [];
proxy response.on('data', (chunk) => body.push(chunk));
? createHttpsProxyAgent(proxy) response.on('end', () => {
: undefined resolve({
), request,
}, response,
(response) => { data: Buffer.concat(body).toString(),
const body: Buffer[] = []; });
response.on('data', chunk => body.push(chunk));
response.on('end', () => {
resolve({
request,
response,
data: Buffer.concat(body).toString(),
}); });
}); }
}, );
); request.on('error', reject);
request.on('error', reject); request.on('timeout', () => {
request.on('timeout', () => { request.destroy();
request.destroy(); reject(
reject(new KnownError(`Time out error: request took over ${timeout}ms. Try increasing the \`timeout\` config, or checking the OpenAI API status https://status.openai.com`)); new KnownError(
}); `Time out error: request took over ${timeout}ms. Try increasing the \`timeout\` config, or checking the OpenAI API status https://status.openai.com`
)
);
});
request.write(postContent); request.write(postContent);
request.end(); request.end();
}); });
const createChatCompletion = async ( const createChatCompletion = async (
apiKey: string, apiKey: string,
json: CreateChatCompletionRequest, json: CreateChatCompletionRequest,
timeout: number, timeout: number,
proxy?: string, proxy?: string,
baseURL?: string
) => { ) => {
const url = baseURL ? new URL(baseURL) : new URL('https://api.openai.com/v1/chat/completions');
if (url.pathname === '/') {
url.pathname = '/v1/chat/completions';
}
const { response, data } = await httpsPost( const { response, data } = await httpsPost(
'api.openai.com', url.hostname,
'/v1/chat/completions', url.pathname,
{ {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
json, json,
timeout, timeout,
proxy, proxy
); );
if ( if (
!response.statusCode !response.statusCode ||
|| response.statusCode < 200 response.statusCode < 200 ||
|| response.statusCode > 299 response.statusCode > 299
) { ) {
let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`; let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`;
@@ -97,59 +108,29 @@ const createChatCompletion = async (
throw new KnownError(errorMessage); throw new KnownError(errorMessage);
} }
return JSON.parse(data) as CreateChatCompletionResponse; try {
const json = JSON.parse(data);
if (json.error) {
throw new KnownError(json.error.message);
}
return json as CreateChatCompletionResponse;
} catch (error) {
throw new KnownError(
`Error parsing response: ${error}\n\n${data}`
);
}
}; };
const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1'); const sanitizeMessage = (message: string) =>
message
.trim()
.replace(/[\n\r]/g, '')
.replace(/(\w)\.$/, '$1');
const deduplicateMessages = (array: string[]) => Array.from(new Set(array)); const deduplicateMessages = (array: string[]) => Array.from(new Set(array));
const getBasePrompt = (
locale: string,
maxLength: number,
) => `${[
'Generate a concise git commit message written in present tense for the following code diff with the given specifications below:',
`Message language: ${locale}`,
`Commit message must be a maximum of ${maxLength} characters.`,
'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
].join('\n')}`;
const getCommitMessageFormatOutputExample = (type: CommitType) => `The output response must be in format:\n${getCommitMessageFormat(type)}`;
const getCommitMessageFormat = (type: CommitType) => {
if (type === 'conventional') {
return '<type>(<optional scope>): <commit message>';
}
return '<commit message>';
};
/**
* References:
* Commitlint:
* https://github.com/conventional-changelog/commitlint/blob/18fbed7ea86ac0ec9d5449b4979b762ec4305a92/%40commitlint/config-conventional/index.js#L40-L100
*
* Conventional Changelog:
* https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193
*/
const getExtraContextForConventionalCommits = () => (
`Choose a type from the type-to-description JSON below that best describes the git diff:\n${
JSON.stringify({
docs: 'Documentation only changes',
style: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
refactor: 'A code change that neither fixes a bug nor adds a feature',
perf: 'A code change that improves performance',
test: 'Adding missing tests or correcting existing tests',
build: 'Changes that affect the build system or external dependencies',
ci: 'Changes to our CI configuration files and scripts',
chore: "Other changes that don't modify src or test files",
revert: 'Reverts a previous commit',
feat: 'A new feature',
fix: 'A bug fix',
}, null, 2)
}`
);
// const generateStringFromLength = (length: number) => { // const generateStringFromLength = (length: number) => {
// let result = ''; // let result = '';
// const highestTokenChar = 'z'; // const highestTokenChar = 'z';
@@ -177,32 +158,23 @@ export const generateCommitMessage = async (
type: CommitType, type: CommitType,
timeout: number, timeout: number,
proxy?: string, proxy?: string,
baseURL?: string
) => { ) => {
const prompt = getBasePrompt(locale, maxLength);
const conventionalCommitsExtraContext = type === 'conventional'
? getExtraContextForConventionalCommits()
: '';
const commitMessageFormatOutputExample = getCommitMessageFormatOutputExample(type);
const messages: ChatCompletionRequestMessage[] = [
{
role: 'system',
content: `${prompt}\n${conventionalCommitsExtraContext}\n${commitMessageFormatOutputExample}`,
},
{
role: 'user',
content: diff,
},
];
try { try {
const completion = await createChatCompletion( const completion = await createChatCompletion(
apiKey, apiKey,
{ {
model, model,
messages, messages: [
{
role: 'system',
content: generatePrompt(locale, maxLength, type),
},
{
role: 'user',
content: diff,
},
],
temperature: 0.7, temperature: 0.7,
top_p: 1, top_p: 1,
frequency_penalty: 0, frequency_penalty: 0,
@@ -213,17 +185,20 @@ export const generateCommitMessage = async (
}, },
timeout, timeout,
proxy, proxy,
baseURL
); );
return deduplicateMessages( return deduplicateMessages(
completion.choices completion.choices
.filter(choice => choice.message?.content) .filter((choice) => choice.message?.content)
.map(choice => sanitizeMessage(choice.message!.content)), .map((choice) => sanitizeMessage(choice.message!.content as string))
); );
} catch (error) { } catch (error) {
const errorAsAny = error as any; const errorAsAny = error as any;
if (errorAsAny.code === 'ENOTFOUND') { if (errorAsAny.code === 'ENOTFOUND') {
throw new KnownError(`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`); throw new KnownError(
`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`
);
} }
throw errorAsAny; throw errorAsAny;

50
src/utils/prompt.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { CommitType } from './config.js';
const commitTypeFormats: Record<CommitType, string> = {
'': '<commit message>',
conventional: '<type>(<optional scope>): <commit message>',
};
const specifyCommitFormat = (type: CommitType) =>
`输出响应必须使用以下格式:\n${commitTypeFormats[type]}`;
const commitTypes: Record<CommitType, string> = {
'': '',
/**
* References:
* Commitlint:
* https://github.com/conventional-changelog/commitlint/blob/18fbed7ea86ac0ec9d5449b4979b762ec4305a92/%40commitlint/config-conventional/index.js#L40-L100
*
* Conventional Changelog:
* https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193
*/
conventional: `从下面的类型到描述的JSON中选择最能描述git差异的类型(中文即可,注意后面加冒号)\n
\n- feat: 新功能
\n- fix: 修复 bug
\n- docs: 更新文档
\n- style: 格式调整(不影响代码执行)
\n- refactor: 重构(非功能更新)
\n- test: 测试相关内容
\n- chore: 构建或工具变更`,
};
export const generatePrompt = (
locale: string,
maxLength: number,
type: CommitType
) =>
[
'为以下代码差异生成一个简洁的、使用现在时态的中文git提交消息并遵循以下规范',
`- 提交消息使用中文编写。`,
`- 提交消息最多${maxLength}个字符。`,
`- 使用动词作开头(祈使句形式)`,
'- 提交消息包含具体的类名、方法名或其他关键信息,不能过于笼统。',
'- 对于功能添加,应该指明具体的类或模块,如"在UserController中添加了用户权限验证功能"。',
'- 对于代码重构,应该指明重构的具体类或方法,如"重构了PaymentProcessor类的金额计算逻辑"。',
'- 排除任何不必要的内容如翻译。您的整个响应将直接传递到git提交中。',
commitTypes[type],
specifyCommitFormat(type),
//`只输出Git的提交信息即可不要输出其他任何信息`,
]
.filter(Boolean)
.join('\n');

View File

@@ -9,7 +9,9 @@ import {
export default testSuite(({ describe }) => { export default testSuite(({ describe }) => {
if (process.platform === 'win32') { if (process.platform === 'win32') {
// https://github.com/nodejs/node/issues/31409 // https://github.com/nodejs/node/issues/31409
console.warn('Skipping tests on Windows because Node.js spawn cant open TTYs'); console.warn(
'Skipping tests on Windows because Node.js spawn cant open TTYs'
);
return; return;
} }
@@ -21,10 +23,15 @@ export default testSuite(({ describe }) => {
const git = await createGit(fixture.path); const git = await createGit(fixture.path);
await git('add', ['data.json']); await git('add', ['data.json']);
const statusBefore = await git('status', ['--porcelain', '--untracked-files=no']); const statusBefore = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusBefore.stdout).toBe('A data.json'); expect(statusBefore.stdout).toBe('A data.json');
const { stdout, exitCode } = await aicommits(['--exclude', 'data.json'], { reject: false }); const { stdout, exitCode } = await aicommits(['--exclude', 'data.json'], {
reject: false,
});
expect(exitCode).toBe(1); expect(exitCode).toBe(1);
expect(stdout).toMatch('No staged changes found.'); expect(stdout).toMatch('No staged changes found.');
await fixture.rm(); await fixture.rm();
@@ -47,10 +54,15 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,
@@ -81,7 +93,9 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const { stdout: commitMessage } = await git('log', ['--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,
@@ -118,7 +132,10 @@ export default testSuite(({ describe }) => {
const statusAfter = await git('status', ['--short']); const statusAfter = await git('status', ['--short']);
expect(statusAfter.stdout).toBe('?? .aicommits'); expect(statusAfter.stdout).toBe('?? .aicommits');
const { stdout: commitMessage } = await git('log', ['-n1', '--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'-n1',
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,
@@ -128,7 +145,9 @@ export default testSuite(({ describe }) => {
await fixture.rm(); await fixture.rm();
}); });
test('Accepts --generate flag, overriding config', async ({ onTestFail }) => { test('Accepts --generate flag, overriding config', async ({
onTestFail,
}) => {
const { fixture, aicommits } = await createFixture({ const { fixture, aicommits } = await createFixture({
...files, ...files,
'.aicommits': `${files['.aicommits']}\ngenerate=4`, '.aicommits': `${files['.aicommits']}\ngenerate=4`,
@@ -138,9 +157,7 @@ export default testSuite(({ describe }) => {
await git('add', ['data.json']); await git('add', ['data.json']);
// Generate flag should override generate config // Generate flag should override generate config
const committing = aicommits([ const committing = aicommits(['--generate', '2']);
'--generate', '2',
]);
// Hit enter to accept the commit message // Hit enter to accept the commit message
committing.stdout!.on('data', function onPrompt(buffer: Buffer) { committing.stdout!.on('data', function onPrompt(buffer: Buffer) {
@@ -158,10 +175,15 @@ export default testSuite(({ describe }) => {
onTestFail(() => console.log({ stdout })); onTestFail(() => console.log({ stdout }));
expect(countChoices).toBe(2); expect(countChoices).toBe(2);
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,
@@ -173,7 +195,8 @@ export default testSuite(({ describe }) => {
test('Generates Japanese commit message via locale config', async () => { test('Generates Japanese commit message via locale config', async () => {
// https://stackoverflow.com/a/15034560/911407 // https://stackoverflow.com/a/15034560/911407
const japanesePattern = /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/; const japanesePattern =
/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/;
const { fixture, aicommits } = await createFixture({ const { fixture, aicommits } = await createFixture({
...files, ...files,
@@ -195,10 +218,15 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,
@@ -211,7 +239,8 @@ export default testSuite(({ describe }) => {
describe('commit types', ({ test }) => { describe('commit types', ({ test }) => {
test('Should not use conventional commits by default', async () => { test('Should not use conventional commits by default', async () => {
const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; const conventionalCommitPattern =
/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
const { fixture, aicommits } = await createFixture({ const { fixture, aicommits } = await createFixture({
...files, ...files,
}); });
@@ -231,7 +260,10 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--oneline']); const { stdout: commitMessage } = await git('log', ['--oneline']);
@@ -242,7 +274,8 @@ export default testSuite(({ describe }) => {
}); });
test('Conventional commits', async () => { test('Conventional commits', async () => {
const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; const conventionalCommitPattern =
/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
const { fixture, aicommits } = await createFixture({ const { fixture, aicommits } = await createFixture({
...files, ...files,
'.aicommits': `${files['.aicommits']}\ntype=conventional`, '.aicommits': `${files['.aicommits']}\ntype=conventional`,
@@ -263,7 +296,10 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--oneline']); const { stdout: commitMessage } = await git('log', ['--oneline']);
@@ -274,7 +310,8 @@ export default testSuite(({ describe }) => {
}); });
test('Accepts --type flag, overriding config', async () => { test('Accepts --type flag, overriding config', async () => {
const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; const conventionalCommitPattern =
/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
const { fixture, aicommits } = await createFixture({ const { fixture, aicommits } = await createFixture({
...files, ...files,
'.aicommits': `${files['.aicommits']}\ntype=other`, '.aicommits': `${files['.aicommits']}\ntype=other`,
@@ -284,9 +321,7 @@ export default testSuite(({ describe }) => {
await git('add', ['data.json']); await git('add', ['data.json']);
// Generate flag should override generate config // Generate flag should override generate config
const committing = aicommits([ const committing = aicommits(['--type', 'conventional']);
'--type', 'conventional',
]);
committing.stdout!.on('data', (buffer: Buffer) => { committing.stdout!.on('data', (buffer: Buffer) => {
const stdout = buffer.toString(); const stdout = buffer.toString();
@@ -298,7 +333,10 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--oneline']); const { stdout: commitMessage } = await git('log', ['--oneline']);
@@ -309,7 +347,8 @@ export default testSuite(({ describe }) => {
}); });
test('Accepts empty --type flag', async () => { test('Accepts empty --type flag', async () => {
const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; const conventionalCommitPattern =
/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/;
const { fixture, aicommits } = await createFixture({ const { fixture, aicommits } = await createFixture({
...files, ...files,
'.aicommits': `${files['.aicommits']}\ntype=conventional`, '.aicommits': `${files['.aicommits']}\ntype=conventional`,
@@ -318,9 +357,7 @@ export default testSuite(({ describe }) => {
await git('add', ['data.json']); await git('add', ['data.json']);
const committing = aicommits([ const committing = aicommits(['--type', '']);
'--type', '',
]);
committing.stdout!.on('data', (buffer: Buffer) => { committing.stdout!.on('data', (buffer: Buffer) => {
const stdout = buffer.toString(); const stdout = buffer.toString();
@@ -332,7 +369,10 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--oneline']); const { stdout: commitMessage } = await git('log', ['--oneline']);
@@ -394,10 +434,15 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,
@@ -429,10 +474,15 @@ export default testSuite(({ describe }) => {
await committing; await committing;
const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); const statusAfter = await git('status', [
'--porcelain',
'--untracked-files=no',
]);
expect(statusAfter.stdout).toBe(''); expect(statusAfter.stdout).toBe('');
const { stdout: commitMessage } = await git('log', ['--pretty=format:%s']); const { stdout: commitMessage } = await git('log', [
'--pretty=format:%s',
]);
console.log({ console.log({
commitMessage, commitMessage,
length: commitMessage.length, length: commitMessage.length,

View File

@@ -17,7 +17,9 @@ export default testSuite(({ describe }) => {
const { stdout, exitCode } = await aicommits([], { reject: false }); const { stdout, exitCode } = await aicommits([], { reject: false });
expect(exitCode).toBe(1); expect(exitCode).toBe(1);
expect(stdout).toMatch('No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'); expect(stdout).toMatch(
'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'
);
await fixture.rm(); await fixture.rm();
}); });
}); });

View File

@@ -22,7 +22,9 @@ export default testSuite(({ describe }) => {
reject: false, reject: false,
}); });
expect(stderr).toMatch('Invalid config property OPENAI_KEY: Must start with "sk-"'); expect(stderr).toMatch(
'Invalid config property OPENAI_KEY: Must start with "sk-"'
);
}); });
await test('set config file', async () => { await test('set config file', async () => {
@@ -71,9 +73,12 @@ export default testSuite(({ describe }) => {
await describe('max-length', ({ test }) => { await describe('max-length', ({ test }) => {
test('must be an integer', async () => { test('must be an integer', async () => {
const { stderr } = await aicommits(['config', 'set', 'max-length=abc'], { const { stderr } = await aicommits(
reject: false, ['config', 'set', 'max-length=abc'],
}); {
reject: false,
}
);
expect(stderr).toMatch('Must be an integer'); expect(stderr).toMatch('Must be an integer');
}); });

View File

@@ -1,3 +1,4 @@
import path from 'path';
import { testSuite, expect } from 'manten'; import { testSuite, expect } from 'manten';
import { import {
assertOpenAiToken, assertOpenAiToken,
@@ -22,6 +23,25 @@ export default testSuite(({ describe }) => {
await fixture.rm(); await fixture.rm();
}); });
test('installs from Git repo subdirectory', async () => {
const { fixture, aicommits } = await createFixture({
...files,
'some-dir': {
'file.txt': '',
},
});
await createGit(fixture.path);
const { stdout } = await aicommits(['hook', 'install'], {
cwd: path.join(fixture.path, 'some-dir'),
});
expect(stdout).toMatch('Hook installed');
expect(await fixture.exists('.git/hooks/prepare-commit-msg')).toBe(true);
await fixture.rm();
});
test('Commits', async () => { test('Commits', async () => {
const { fixture, aicommits } = await createFixture(files); const { fixture, aicommits } = await createFixture(files);
const git = await createGit(fixture.path); const git = await createGit(fixture.path);

View File

@@ -1,7 +1,5 @@
import { expect, testSuite } from 'manten'; import { expect, testSuite } from 'manten';
import { import { generateCommitMessage } from '../../../src/utils/openai.js';
generateCommitMessage,
} from '../../../src/utils/openai.js';
import type { ValidConfig } from '../../../src/utils/config.js'; import type { ValidConfig } from '../../../src/utils/config.js';
import { getDiff } from '../../utils.js'; import { getDiff } from '../../utils.js';
@@ -9,13 +7,16 @@ const { OPENAI_KEY } = process.env;
export default testSuite(({ describe }) => { export default testSuite(({ describe }) => {
if (!OPENAI_KEY) { if (!OPENAI_KEY) {
console.warn('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'); console.warn(
'⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'
);
return; return;
} }
describe('Conventional Commits', async ({ test }) => { describe('Conventional Commits', async ({ test }) => {
await test('Should not translate conventional commit type to Japanase when locale config is set to japanese', async () => { await test('Should not translate conventional commit type to Japanase when locale config is set to japanese', async () => {
const japaneseConventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.*\))?: [\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/; const japaneseConventionalCommitPattern =
/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.*\))?: [\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/;
const gitDiff = await getDiff('new-feature.diff'); const gitDiff = await getDiff('new-feature.diff');
@@ -58,9 +59,7 @@ export default testSuite(({ describe }) => {
}); });
await test('Should use "build:" conventional commit when change relate to github action build pipeline', async () => { await test('Should use "build:" conventional commit when change relate to github action build pipeline', async () => {
const gitDiff = await getDiff( const gitDiff = await getDiff('github-action-build-pipeline.diff');
'github-action-build-pipeline.diff',
);
const commitMessage = await runGenerateCommitMessage(gitDiff); const commitMessage = await runGenerateCommitMessage(gitDiff);
@@ -128,8 +127,10 @@ export default testSuite(({ describe }) => {
console.log('Generated message:', commitMessage); console.log('Generated message:', commitMessage);
}); });
async function runGenerateCommitMessage(gitDiff: string, async function runGenerateCommitMessage(
configOverrides: Partial<ValidConfig> = {}): Promise<string> { gitDiff: string,
configOverrides: Partial<ValidConfig> = {}
): Promise<string> {
const config = { const config = {
locale: 'en', locale: 'en',
type: 'conventional', type: 'conventional',
@@ -137,7 +138,16 @@ export default testSuite(({ describe }) => {
'max-length': 50, 'max-length': 50,
...configOverrides, ...configOverrides,
} as ValidConfig; } as ValidConfig;
const commitMessages = await generateCommitMessage(OPENAI_KEY!, 'gpt-3.5-turbo', config.locale, gitDiff, config.generate, config['max-length'], config.type, 7000); const commitMessages = await generateCommitMessage(
OPENAI_KEY!,
'gpt-3.5-turbo',
config.locale,
gitDiff,
config.generate,
config['max-length'],
config.type,
7000
);
return commitMessages[0]; return commitMessages[0];
} }

View File

@@ -15,46 +15,32 @@ const createAicommits = (fixture: FsFixture) => {
USERPROFILE: fixture.path, // Windows USERPROFILE: fixture.path, // Windows
}; };
return ( return (args?: string[], options?: Options) =>
args?: string[], execaNode(aicommitsPath, args, {
options?: Options, cwd: fixture.path,
) => execaNode(aicommitsPath, args, { ...options,
...options, extendEnv: false,
cwd: fixture.path, env: {
extendEnv: false, ...homeEnv,
env: { ...options?.env,
...homeEnv, },
...options?.env,
},
// Block tsx nodeOptions // Block tsx nodeOptions
nodeOptions: [], nodeOptions: [],
}); });
}; };
export const createGit = async (cwd: string) => { export const createGit = async (cwd: string) => {
const git = ( const git = (command: string, args?: string[], options?: Options) =>
command: string, execa('git', [command, ...(args || [])], {
args?: string[], cwd,
options?: Options, ...options,
) => ( });
execa(
'git',
[command, ...(args || [])],
{
cwd,
...options,
},
)
);
await git( await git('init', [
'init', // In case of different default branch name
[ '--initial-branch=master',
// In case of different default branch name ]);
'--initial-branch=master',
],
);
await git('config', ['user.name', 'name']); await git('config', ['user.name', 'name']);
await git('config', ['user.email', 'email']); await git('config', ['user.email', 'email']);
@@ -62,9 +48,7 @@ export const createGit = async (cwd: string) => {
return git; return git;
}; };
export const createFixture = async ( export const createFixture = async (source?: string | FileTree) => {
source?: string | FileTree,
) => {
const fixture = await createFixtureBase(source); const fixture = await createFixtureBase(source);
const aicommits = createAicommits(fixture); const aicommits = createAicommits(fixture);
@@ -76,17 +60,20 @@ export const createFixture = async (
export const files = Object.freeze({ export const files = Object.freeze({
'.aicommits': `OPENAI_KEY=${process.env.OPENAI_KEY}`, '.aicommits': `OPENAI_KEY=${process.env.OPENAI_KEY}`,
'data.json': Array.from({ length: 10 }, (_, i) => `${i}. Lorem ipsum dolor sit amet`).join('\n'), 'data.json': Array.from(
{ length: 10 },
(_, i) => `${i}. Lorem ipsum dolor sit amet`
).join('\n'),
}); });
export const assertOpenAiToken = () => { export const assertOpenAiToken = () => {
if (!process.env.OPENAI_KEY) { if (!process.env.OPENAI_KEY) {
throw new Error('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'); throw new Error(
'⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'
);
} }
}; };
// See ./diffs/README.md in order to generate diff files // See ./diffs/README.md in order to generate diff files
export const getDiff = async (diffName: string): Promise<string> => fs.readFile( export const getDiff = async (diffName: string): Promise<string> =>
new URL(`fixtures/${diffName}`, import.meta.url), fs.readFile(new URL(`fixtures/${diffName}`, import.meta.url), 'utf8');
'utf8',
);