Compare commits
10 Commits
eda3462fec
...
d23dfecbcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d23dfecbcd | ||
|
|
e7f26efc71 | ||
|
|
604def8284 | ||
|
|
723c171417 | ||
|
|
f63dc33385 | ||
|
|
7e2f0000b2 | ||
|
|
f33bfeed60 | ||
|
|
578572b1d9 | ||
|
|
7631c2fc0b | ||
|
|
dda7f8d424 |
@@ -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
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
|||||||
github: privatenumber
|
|
||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -13,7 +13,6 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
|||||||
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -27,15 +27,12 @@ jobs:
|
|||||||
- 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
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
> The minimum supported version of Node.js is the latest v14. Check your Node.js version with `node --version`.
|
> The minimum supported version of Node.js is the latest v14. Check your Node.js version with `node --version`.
|
||||||
|
|
||||||
|
|
||||||
1. Install _aicommits_:
|
1. Install _aicommits_:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -32,10 +31,10 @@
|
|||||||
|
|
||||||
This will create a `.aicommits` file in your home directory.
|
This will create a `.aicommits` file in your home directory.
|
||||||
|
|
||||||
|
|
||||||
### Upgrading
|
### Upgrading
|
||||||
|
|
||||||
Check the installed version with:
|
Check the installed version with:
|
||||||
|
|
||||||
```
|
```
|
||||||
aicommits --version
|
aicommits --version
|
||||||
```
|
```
|
||||||
@@ -47,6 +46,7 @@ npm update -g aicommits
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### CLI mode
|
### CLI mode
|
||||||
|
|
||||||
You can call `aicommits` directly to generate a commit message for your staged changes:
|
You can call `aicommits` directly to generate a commit message for your staged changes:
|
||||||
@@ -59,6 +59,7 @@ aicommits
|
|||||||
`aicommits` passes down unknown flags to `git commit`, so you can pass in [`commit` flags](https://git-scm.com/docs/git-commit).
|
`aicommits` passes down unknown flags to `git commit`, so you can pass in [`commit` flags](https://git-scm.com/docs/git-commit).
|
||||||
|
|
||||||
For example, you can stage all changes in tracked files with as you commit:
|
For example, you can stage all changes in tracked files with as you commit:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
aicommits --all # or -a
|
aicommits --all # or -a
|
||||||
```
|
```
|
||||||
@@ -68,12 +69,23 @@ aicommits --all # or -a
|
|||||||
#### Generate multiple recommendations
|
#### Generate multiple recommendations
|
||||||
|
|
||||||
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:
|
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> # or -g <i>
|
||||||
```
|
```
|
||||||
|
|
||||||
> Warning: this uses more tokens, meaning it costs more.
|
> Warning: this uses more tokens, meaning it costs more.
|
||||||
|
|
||||||
|
#### Generating Conventional Commits
|
||||||
|
|
||||||
|
If you'd like to generate [Conventional Commits](https://conventionalcommits.org/), you can use the `--type` flag followed by `conventional`. This will prompt `aicommits` to format the commit message according to the Conventional Commits specification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aicommits --type conventional # or -t conventional
|
||||||
|
```
|
||||||
|
|
||||||
|
This feature can be useful if your project follows the Conventional Commits standard or if you're using tools that rely on this commit format.
|
||||||
|
|
||||||
### Git hook
|
### 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.
|
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.
|
||||||
@@ -81,11 +93,13 @@ You can also integrate _aicommits_ with Git via the [`prepare-commit-msg`](https
|
|||||||
#### Install
|
#### Install
|
||||||
|
|
||||||
In the Git repository you want to install the hook in:
|
In the Git repository you want to install the hook in:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
aicommits hook install
|
aicommits hook install
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Uninstall
|
#### Uninstall
|
||||||
|
|
||||||
In the Git repository you want to uninstall the hook from:
|
In the Git repository you want to uninstall the hook from:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -95,6 +109,7 @@ aicommits hook uninstall
|
|||||||
#### Usage
|
#### Usage
|
||||||
|
|
||||||
1. Stage your files and commit:
|
1. Stage your files and commit:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git add <files...>
|
git add <files...>
|
||||||
git commit # Only generates a message when it's not passed in
|
git commit # Only generates a message when it's not passed in
|
||||||
@@ -109,6 +124,7 @@ aicommits hook uninstall
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Reading a configuration value
|
### Reading a configuration value
|
||||||
|
|
||||||
To retrieve a configuration option, use the command:
|
To retrieve a configuration option, use the command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -116,6 +132,7 @@ aicommits config get <key>
|
|||||||
```
|
```
|
||||||
|
|
||||||
For example, to retrieve the API key, you can use:
|
For example, to retrieve the API key, you can use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
aicommits config get OPENAI_KEY
|
aicommits config get OPENAI_KEY
|
||||||
```
|
```
|
||||||
@@ -147,6 +164,7 @@ aicommits config set OPENAI_KEY=<your-api-key> generate=3 locale=en
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
#### OPENAI_KEY
|
#### OPENAI_KEY
|
||||||
|
|
||||||
Required
|
Required
|
||||||
@@ -154,6 +172,7 @@ Required
|
|||||||
The OpenAI API key. You can retrieve it from [OpenAI API Keys page](https://platform.openai.com/account/api-keys).
|
The OpenAI API key. You can retrieve it from [OpenAI API Keys page](https://platform.openai.com/account/api-keys).
|
||||||
|
|
||||||
#### locale
|
#### locale
|
||||||
|
|
||||||
Default: `en`
|
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.
|
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.
|
||||||
@@ -184,8 +203,8 @@ The Chat Completions (`/v1/chat/completions`) model to use. Consult the list of
|
|||||||
|
|
||||||
> 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.
|
> 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.
|
||||||
|
|
||||||
|
|
||||||
#### timeout
|
#### timeout
|
||||||
|
|
||||||
The timeout for network requests to the OpenAI API in milliseconds.
|
The timeout for network requests to the OpenAI API in milliseconds.
|
||||||
|
|
||||||
Default: `10000` (10 seconds)
|
Default: `10000` (10 seconds)
|
||||||
@@ -195,6 +214,7 @@ aicommits config set timeout=20000 # 20s
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### max-length
|
#### max-length
|
||||||
|
|
||||||
The maximum character length of the generated commit message.
|
The maximum character length of the generated commit message.
|
||||||
|
|
||||||
Default: `50`
|
Default: `50`
|
||||||
@@ -203,6 +223,22 @@ Default: `50`
|
|||||||
aicommits config set max-length=100
|
aicommits config set max-length=100
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### type
|
||||||
|
|
||||||
|
Default: `""` (Empty string)
|
||||||
|
|
||||||
|
The type of commit message to generate. Set this to "conventional" to generate commit messages that follow the Conventional Commits specification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aicommits config set type=conventional
|
||||||
|
```
|
||||||
|
|
||||||
|
You can clear this option by setting it to an empty string:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aicommits config set type=
|
||||||
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -213,10 +249,8 @@ Video coming soon where I rebuild it from scratch to show you how to easily buil
|
|||||||
|
|
||||||
- **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
|
## Contributing
|
||||||
|
|
||||||
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.
|
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
2284
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -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
3262
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
17
src/cli.ts
17
src/cli.ts
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,8 +22,9 @@ export default async (
|
|||||||
excludeFiles: string[],
|
excludeFiles: string[],
|
||||||
stageAll: boolean,
|
stageAll: boolean,
|
||||||
commitType: string | undefined,
|
commitType: string | undefined,
|
||||||
rawArgv: string[],
|
rawArgv: string[]
|
||||||
) => (async () => {
|
) =>
|
||||||
|
(async () => {
|
||||||
intro(bgCyan(black(' aicommits ')));
|
intro(bgCyan(black(' aicommits ')));
|
||||||
await assertGitRepo();
|
await assertGitRepo();
|
||||||
|
|
||||||
@@ -36,16 +40,22 @@ export default async (
|
|||||||
|
|
||||||
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}`)
|
||||||
|
.join('\n')}`
|
||||||
|
);
|
||||||
|
|
||||||
const { env } = process;
|
const { env } = process;
|
||||||
const config = await getConfig({
|
const config = await getConfig({
|
||||||
OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
|
OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
|
||||||
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,
|
||||||
generate: generate?.toString(),
|
generate: generate?.toString(),
|
||||||
type: commitType?.toString(),
|
type: commitType?.toString(),
|
||||||
});
|
});
|
||||||
@@ -64,6 +74,7 @@ export default async (
|
|||||||
config.type,
|
config.type,
|
||||||
config.timeout,
|
config.timeout,
|
||||||
config.proxy,
|
config.proxy,
|
||||||
|
config.baseURL
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
s.stop('Changes analyzed');
|
s.stop('Changes analyzed');
|
||||||
@@ -87,7 +98,7 @@ export default async (
|
|||||||
} else {
|
} else {
|
||||||
const selected = await select({
|
const selected = await select({
|
||||||
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
|
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
|
||||||
options: messages.map(value => ({ label: value, value })),
|
options: messages.map((value) => ({ label: value, value })),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCancel(selected)) {
|
if (isCancel(selected)) {
|
||||||
@@ -95,7 +106,7 @@ export default async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
message = selected;
|
message = selected as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
await execa('git', ['commit', '-m', message, ...rawArgv]);
|
await execa('git', ['commit', '-m', message, ...rawArgv]);
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ 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) => {
|
},
|
||||||
|
(argv) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const { mode, keyValue: keyValues } = argv._;
|
const { mode, keyValue: keyValues } = argv._;
|
||||||
|
|
||||||
@@ -23,7 +25,12 @@ export default command({
|
|||||||
|
|
||||||
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('=');
|
||||||
|
const key = keyValue.slice(0, separatorIndex);
|
||||||
|
const value = keyValue.slice(separatorIndex + 1);
|
||||||
|
return [key, value] as [string, string];
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,4 +41,5 @@ export default command({
|
|||||||
handleCliError(error);
|
handleCliError(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,38 +22,41 @@ const windowsHook = `
|
|||||||
import(${JSON.stringify(pathToFileURL(hookPath))})
|
import(${JSON.stringify(pathToFileURL(hookPath))})
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
export default command({
|
export default command(
|
||||||
|
{
|
||||||
name: 'hook',
|
name: 'hook',
|
||||||
parameters: ['<install/uninstall>'],
|
parameters: ['<install/uninstall>'],
|
||||||
}, (argv) => {
|
},
|
||||||
|
(argv) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await assertGitRepo();
|
const gitRepoPath = await assertGitRepo();
|
||||||
|
|
||||||
const { installUninstall: mode } = argv._;
|
const { installUninstall: mode } = argv._;
|
||||||
|
|
||||||
const hookExists = await fileExists(symlinkPath);
|
const absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath);
|
||||||
|
const hookExists = await fileExists(absoltueSymlinkPath);
|
||||||
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.realpath(symlinkPath).catch(() => {});
|
const realpath = await fs
|
||||||
|
.realpath(absoltueSymlinkPath)
|
||||||
|
.catch(() => {});
|
||||||
if (realpath === hookPath) {
|
if (realpath === hookPath) {
|
||||||
console.warn('The hook is already installed');
|
console.warn('The hook is already installed');
|
||||||
return;
|
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,
|
|
||||||
windowsHook,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await fs.symlink(hookPath, symlinkPath, 'file');
|
await fs.symlink(hookPath, absoltueSymlinkPath, 'file');
|
||||||
await fs.chmod(symlinkPath, 0o755);
|
await fs.chmod(absoltueSymlinkPath, 0o755);
|
||||||
}
|
}
|
||||||
console.log(`${green('✔')} Hook installed`);
|
console.log(`${green('✔')} Hook installed`);
|
||||||
return;
|
return;
|
||||||
@@ -68,20 +69,20 @@ export default command({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
const scriptContent = await fs.readFile(symlinkPath, 'utf8');
|
const scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8');
|
||||||
if (scriptContent !== windowsHook) {
|
if (scriptContent !== windowsHook) {
|
||||||
console.warn('Hook is not installed');
|
console.warn('Hook is not installed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const realpath = await fs.realpath(symlinkPath);
|
const realpath = await fs.realpath(absoltueSymlinkPath);
|
||||||
if (realpath !== hookPath) {
|
if (realpath !== hookPath) {
|
||||||
console.warn('Hook is not installed');
|
console.warn('Hook is not installed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.rm(symlinkPath);
|
await fs.rm(absoltueSymlinkPath);
|
||||||
console.log(`${green('✔')} Hook uninstalled`);
|
console.log(`${green('✔')} Hook uninstalled`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,4 +93,5 @@ export default command({
|
|||||||
handleCliError(error);
|
handleCliError(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,9 +8,12 @@ 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 () =>
|
||||||
|
(async () => {
|
||||||
if (!messageFilePath) {
|
if (!messageFilePath) {
|
||||||
throw new KnownError('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook');
|
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
|
||||||
@@ -32,7 +31,8 @@ export default () => (async () => {
|
|||||||
|
|
||||||
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();
|
const s = spinner();
|
||||||
@@ -48,7 +48,7 @@ export default () => (async () => {
|
|||||||
config['max-length'],
|
config['max-length'],
|
||||||
config.type,
|
config.type,
|
||||||
config.timeout,
|
config.timeout,
|
||||||
config.proxy,
|
config.proxy
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
s.stop('Changes analyzed');
|
s.stop('Changes analyzed');
|
||||||
@@ -67,14 +67,19 @@ export default () => (async () => {
|
|||||||
let instructions = '';
|
let instructions = '';
|
||||||
|
|
||||||
if (supportsComments) {
|
if (supportsComments) {
|
||||||
instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`;
|
instructions = `# 🤖 AI generated commit${
|
||||||
|
hasMultipleMessages ? 's' : ''
|
||||||
|
}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMultipleMessages) {
|
if (hasMultipleMessages) {
|
||||||
if (supportsComments) {
|
if (supportsComments) {
|
||||||
instructions += '# Select one of the following messages by uncommeting:\n';
|
instructions +=
|
||||||
|
'# Select one of the following messages by uncommeting:\n';
|
||||||
}
|
}
|
||||||
instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`;
|
instructions += `\n${messages
|
||||||
|
.map((message) => `# ${message}`)
|
||||||
|
.join('\n')}`;
|
||||||
} else {
|
} else {
|
||||||
if (supportsComments) {
|
if (supportsComments) {
|
||||||
instructions += '# Edit the message below and commit:\n';
|
instructions += '# Edit the message below and commit:\n';
|
||||||
@@ -82,10 +87,7 @@ export default () => (async () => {
|
|||||||
instructions += `\n${messages[0]}\n`;
|
instructions += `\n${messages[0]}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.appendFile(
|
await fs.appendFile(messageFilePath, instructions);
|
||||||
messageFilePath,
|
|
||||||
instructions,
|
|
||||||
);
|
|
||||||
outro(`${green('✔')} Saved commit message!`);
|
outro(`${green('✔')} Saved commit message!`);
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
outro(`${red('✖')} ${error.message}`);
|
outro(`${red('✖')} ${error.message}`);
|
||||||
|
|||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
...diffCached,
|
||||||
'--name-only',
|
'--name-only',
|
||||||
...filesToExclude,
|
...filesToExclude,
|
||||||
...(
|
...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
|
||||||
excludeFiles
|
]);
|
||||||
? excludeFiles.map(excludeFromDiff)
|
|
||||||
: []
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!files) {
|
if (!files) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout: diff } = await execa(
|
const { stdout: diff } = await execa('git', [
|
||||||
'git',
|
|
||||||
[
|
|
||||||
...diffCached,
|
...diffCached,
|
||||||
...filesToExclude,
|
...filesToExclude,
|
||||||
...(
|
...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
|
||||||
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' : ''
|
||||||
|
}`;
|
||||||
|
|||||||
@@ -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,8 +19,9 @@ 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<{
|
) =>
|
||||||
|
new Promise<{
|
||||||
request: ClientRequest;
|
request: ClientRequest;
|
||||||
response: IncomingMessage;
|
response: IncomingMessage;
|
||||||
data: string;
|
data: string;
|
||||||
@@ -34,15 +39,11 @@ const httpsPost = async (
|
|||||||
'Content-Length': Buffer.byteLength(postContent),
|
'Content-Length': Buffer.byteLength(postContent),
|
||||||
},
|
},
|
||||||
timeout,
|
timeout,
|
||||||
agent: (
|
agent: (proxy && proxy.trim() !== '') ? createHttpsProxyAgent(proxy) : undefined as any,
|
||||||
proxy
|
|
||||||
? createHttpsProxyAgent(proxy)
|
|
||||||
: undefined
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
const body: Buffer[] = [];
|
const body: Buffer[] = [];
|
||||||
response.on('data', chunk => body.push(chunk));
|
response.on('data', (chunk) => body.push(chunk));
|
||||||
response.on('end', () => {
|
response.on('end', () => {
|
||||||
resolve({
|
resolve({
|
||||||
request,
|
request,
|
||||||
@@ -50,12 +51,16 @@ const httpsPost = async (
|
|||||||
data: Buffer.concat(body).toString(),
|
data: Buffer.concat(body).toString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
request.on('error', reject);
|
request.on('error', reject);
|
||||||
request.on('timeout', () => {
|
request.on('timeout', () => {
|
||||||
request.destroy();
|
request.destroy();
|
||||||
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`));
|
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`
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
request.write(postContent);
|
request.write(postContent);
|
||||||
@@ -67,22 +72,28 @@ const createChatCompletion = async (
|
|||||||
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,58 +108,28 @@ const createChatCompletion = async (
|
|||||||
throw new KnownError(errorMessage);
|
throw new KnownError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.parse(data) as CreateChatCompletionResponse;
|
try {
|
||||||
};
|
const json = JSON.parse(data);
|
||||||
|
|
||||||
const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1');
|
if (json.error) {
|
||||||
|
throw new KnownError(json.error.message);
|
||||||
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>';
|
return json as CreateChatCompletionResponse;
|
||||||
|
} catch (error) {
|
||||||
|
throw new KnownError(
|
||||||
|
`Error parsing response: ${error}\n\n${data}`
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const sanitizeMessage = (message: string) =>
|
||||||
* References:
|
message
|
||||||
* Commitlint:
|
.trim()
|
||||||
* https://github.com/conventional-changelog/commitlint/blob/18fbed7ea86ac0ec9d5449b4979b762ec4305a92/%40commitlint/config-conventional/index.js#L40-L100
|
.replace(/[\n\r]/g, '')
|
||||||
*
|
.replace(/(\w)\.$/, '$1');
|
||||||
* Conventional Changelog:
|
|
||||||
* https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193
|
const deduplicateMessages = (array: string[]) => Array.from(new Set(array));
|
||||||
*/
|
|
||||||
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 = '';
|
||||||
@@ -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;
|
||||||
|
|||||||
58
src/utils/prompt.ts
Normal file
58
src/utils/prompt.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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${JSON.stringify(
|
||||||
|
{
|
||||||
|
docs: '仅文档更改',
|
||||||
|
style:
|
||||||
|
'不影响代码含义的更改(空格、格式、缺少分号等)',
|
||||||
|
refactor: '既不修复错误也不添加功能的代码更改',
|
||||||
|
perf: '提高性能的代码更改',
|
||||||
|
test: '添加缺失的测试或更正现有测试',
|
||||||
|
build: '影响构建系统或外部依赖的更改',
|
||||||
|
ci: '对我们的CI配置文件和脚本的更改',
|
||||||
|
chore: '不修改src或测试文件的其他更改',
|
||||||
|
revert: '恢复之前的提交',
|
||||||
|
feat: '新功能',
|
||||||
|
fix: '错误修复',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatePrompt = (
|
||||||
|
locale: string,
|
||||||
|
maxLength: number,
|
||||||
|
type: CommitType
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
'为以下代码差异生成一个简洁的、使用现在时态的中文git提交消息,并遵循以下规范:',
|
||||||
|
`提交消息使用中文编写。`,
|
||||||
|
`提交消息最多${maxLength}个字符。`,
|
||||||
|
'提交消息包含具体的类名、方法名或其他关键信息,不能过于笼统。',
|
||||||
|
'对于功能添加,应该指明具体的类或模块,如"在UserController中添加了用户权限验证功能"。',
|
||||||
|
'对于代码重构,应该指明重构的具体类或方法,如"重构了PaymentProcessor类的金额计算逻辑"。',
|
||||||
|
'排除任何不必要的内容,如翻译。您的整个响应将直接传递到git提交中。',
|
||||||
|
commitTypes[type],
|
||||||
|
specifyCommitFormat(type),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
['config', 'set', 'max-length=abc'],
|
||||||
|
{
|
||||||
reject: false,
|
reject: false,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
expect(stderr).toMatch('Must be an integer');
|
expect(stderr).toMatch('Must be an integer');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ 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,
|
|
||||||
) => execaNode(aicommitsPath, args, {
|
|
||||||
...options,
|
|
||||||
cwd: fixture.path,
|
cwd: fixture.path,
|
||||||
|
...options,
|
||||||
extendEnv: false,
|
extendEnv: false,
|
||||||
env: {
|
env: {
|
||||||
...homeEnv,
|
...homeEnv,
|
||||||
@@ -33,28 +31,16 @@ const createAicommits = (fixture: FsFixture) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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[],
|
|
||||||
options?: Options,
|
|
||||||
) => (
|
|
||||||
execa(
|
|
||||||
'git',
|
|
||||||
[command, ...(args || [])],
|
|
||||||
{
|
|
||||||
cwd,
|
cwd,
|
||||||
...options,
|
...options,
|
||||||
},
|
});
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await git(
|
await git('init', [
|
||||||
'init',
|
|
||||||
[
|
|
||||||
// In case of different default branch name
|
// In case of different default branch name
|
||||||
'--initial-branch=master',
|
'--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',
|
|
||||||
);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user