@thankarezos/git-ai-commit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/bin/clean.js +21 -0
- package/bin/extract.js +26 -0
- package/bin/get-config-path.js +20 -0
- package/bin/git-aic.js +275 -0
- package/bin/presets.js +29 -0
- package/bin/prompts.js +32 -0
- package/bin/prompts.json +44 -0
- package/bin/run-provider.js +35 -0
- package/bin/wizard.js +136 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thankarezos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# git-aic
|
|
2
|
+
|
|
3
|
+
Generate a git commit message from your staged diff using any AI CLI you already have on your machine (Claude Code, Gemini CLI, Codex, `llm`, Ollama, or a custom command). The message opens in your editor — save to commit, quit without saving to abort.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ git aic -ap "mention the migration"
|
|
7
|
+
# (AI runs against your staged diff, message opens in nano)
|
|
8
|
+
# Save -> commit. Quit without saving -> abort.
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Until this is published to npm, install from source:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
git clone https://github.com/<you>/git-ai-commit.git
|
|
17
|
+
cd git-ai-commit
|
|
18
|
+
npm link # symlinks `git-aic` onto your PATH
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
> If `npm link` fails with `EACCES`, set a user-owned npm prefix:
|
|
22
|
+
> ```sh
|
|
23
|
+
> npm config set prefix ~/.npm-global
|
|
24
|
+
> mkdir -p ~/.npm-global
|
|
25
|
+
> # add to ~/.bashrc or ~/.zshrc:
|
|
26
|
+
> export PATH="$HOME/.npm-global/bin:$PATH"
|
|
27
|
+
> ```
|
|
28
|
+
|
|
29
|
+
Requires **Node 18+**. Because the binary is named `git-aic`, git resolves `git aic` automatically.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
git add <files>
|
|
35
|
+
git aic # generate, edit, commit
|
|
36
|
+
git aic -a # stage everything first (git add -A)
|
|
37
|
+
git aic -p "be terse" # add hints to the prompt
|
|
38
|
+
git aic -ap "be terse" # combine -a and -p
|
|
39
|
+
git aic --no-commit # generate + edit only, don't commit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
First run launches an **interactive wizard** that asks you to pick a provider, editor, and behavior flags, and writes the result to `~/.config/git-aic/config.json`. You can re-run it any time with `git aic --init`.
|
|
43
|
+
|
|
44
|
+
## Flags
|
|
45
|
+
|
|
46
|
+
| Flag | Meaning |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `-a`, `--add` | Run `git add -A` before generating |
|
|
49
|
+
| `-p "..."`, `--prompt "..."` | Append extra instructions to the AI prompt |
|
|
50
|
+
| `-ap "..."`, `-pa "..."` | Combine `-a` and `-p` |
|
|
51
|
+
| `--no-commit` | Generate and edit, but skip the `git commit` |
|
|
52
|
+
| `--commit` | Force commit (overrides `commit: false` in config) |
|
|
53
|
+
| `--require-edit` | Refuse to commit if the saved message is byte-identical to what the AI produced |
|
|
54
|
+
| `--no-require-edit` | Allow committing the AI message unchanged |
|
|
55
|
+
| `--init` | Run the interactive setup wizard |
|
|
56
|
+
| `--config` | Print the active config path and contents |
|
|
57
|
+
| `-h` | Show usage. (`--help` is intercepted by git, so use `-h`.) |
|
|
58
|
+
|
|
59
|
+
## How it decides to commit
|
|
60
|
+
|
|
61
|
+
After the AI generates a message, the file opens in your editor:
|
|
62
|
+
|
|
63
|
+
- **Save** (`Ctrl+X` → `Y` in nano, `:wq` in vim) → commits.
|
|
64
|
+
- **Quit without saving** (`Ctrl+X` → `N`, `:q!`) → aborts with `File not saved. Aborted.`
|
|
65
|
+
- With `requireEdit: true`, the saved content must additionally differ from the AI's output, otherwise it aborts with `No edit detected. Aborted.`
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Stored at `~/.config/git-aic/config.json` (override with `GIT_AIC_CONFIG=/path/to/config.json`):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"provider": {
|
|
74
|
+
"command": "claude",
|
|
75
|
+
"args": ["-p", "{prompt}"]
|
|
76
|
+
},
|
|
77
|
+
"editor": "nano",
|
|
78
|
+
"commit": true,
|
|
79
|
+
"requireEdit": false
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- **`provider.command`** — the binary to invoke.
|
|
84
|
+
- **`provider.args`** — argv passed to that binary. `{prompt}` and `{diff}` are placeholders. If neither `{prompt}` nor `{diff}` appear in the args, the prompt is appended as the last arg and the diff is piped on stdin.
|
|
85
|
+
- **`editor`** — the editor command (overridden by `$EDITOR` if set).
|
|
86
|
+
- **`commit`** — whether to actually `git commit` after editing.
|
|
87
|
+
- **`requireEdit`** — require the saved message to differ from the AI output.
|
|
88
|
+
|
|
89
|
+
### Overriding prompts and cleanup patterns
|
|
90
|
+
|
|
91
|
+
The wizard (`git aic --init`) writes the full defaults into your config so you can see and edit everything in one place. Any of these fields can be removed, changed, or extended:
|
|
92
|
+
|
|
93
|
+
- **`prompt`** — array of lines sent to the AI. Include `{extraPrompt}` somewhere if you want `-p "..."` to keep working.
|
|
94
|
+
- **`commitMessageTag`** — the XML tag the AI is told to wrap the message in.
|
|
95
|
+
- **`removeLinesMatching`** — case-insensitive regex strings; matching lines are stripped from the AI's output.
|
|
96
|
+
- **`stopLinesMatching`** — case-insensitive regex strings; lines from the first match onward are dropped (used to cut off AI rambling).
|
|
97
|
+
|
|
98
|
+
Each field in your user config **replaces** the default if present. If you delete a field entirely, the built-in default from `bin/prompts.json` is used instead.
|
|
99
|
+
|
|
100
|
+
## Providers
|
|
101
|
+
|
|
102
|
+
The wizard ships with presets for:
|
|
103
|
+
|
|
104
|
+
- **Claude Code** — `claude -p "{prompt}"` (reads diff from stdin)
|
|
105
|
+
- **Gemini CLI** — `gemini -p "{prompt}"`
|
|
106
|
+
- **Codex CLI** — `codex exec --skip-git-repo-check "{prompt}"`
|
|
107
|
+
- **llm** (Simon Willison) — `llm "{prompt}"`
|
|
108
|
+
- **Ollama** — `ollama run llama3 "{prompt}"`
|
|
109
|
+
- **Custom** — type your own `command` and `args`
|
|
110
|
+
|
|
111
|
+
You can edit `provider` in the config file at any time, or rerun `git aic --init`.
|
|
112
|
+
|
|
113
|
+
## Customizing the prompt
|
|
114
|
+
|
|
115
|
+
The base prompt template, the `<commit_message>` tag, and the cleanup patterns live in `bin/prompts.json`. Edit that file to:
|
|
116
|
+
|
|
117
|
+
- Change tone, rules, or allowed Conventional Commit types in `prompt`.
|
|
118
|
+
- Add line patterns to `removeLinesMatching` to strip more AI boilerplate.
|
|
119
|
+
- Add line patterns to `stopLinesMatching` to cut off output when the AI starts rambling.
|
|
120
|
+
|
|
121
|
+
Patterns are case-insensitive JS regex strings.
|
|
122
|
+
|
|
123
|
+
## Tests
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
npm test
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Uses Node's built-in test runner (no external dependencies). Tests cover `cleanCommitMessage`, `extractCommitMessage`, and the config merge logic.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/bin/clean.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getConfiguration } from "./prompts.js";
|
|
2
|
+
|
|
3
|
+
export function cleanCommitMessage(text) {
|
|
4
|
+
const cfg = getConfiguration();
|
|
5
|
+
const remove = (cfg.removeLinesMatching ?? []).map(
|
|
6
|
+
(source) => new RegExp(source, "i")
|
|
7
|
+
);
|
|
8
|
+
const stop = (cfg.stopLinesMatching ?? []).map(
|
|
9
|
+
(source) => new RegExp(source, "i")
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const result = [];
|
|
13
|
+
|
|
14
|
+
for (const line of text.split("\n")) {
|
|
15
|
+
if (stop.some((re) => re.test(line))) break;
|
|
16
|
+
if (remove.some((re) => re.test(line))) continue;
|
|
17
|
+
result.push(line);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return result.join("\n").trim();
|
|
21
|
+
}
|
package/bin/extract.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getConfiguration } from "./prompts.js";
|
|
2
|
+
import { cleanCommitMessage } from "./clean.js";
|
|
3
|
+
|
|
4
|
+
export function extractCommitMessage(raw) {
|
|
5
|
+
const cfg = getConfiguration();
|
|
6
|
+
const tagRegex = new RegExp(
|
|
7
|
+
`<${cfg.commitMessageTag}>\\s*([\\s\\S]*?)\\s*<\\/${cfg.commitMessageTag}>`,
|
|
8
|
+
"i"
|
|
9
|
+
);
|
|
10
|
+
const tagMatch = raw.match(tagRegex);
|
|
11
|
+
|
|
12
|
+
if (tagMatch?.[1]) {
|
|
13
|
+
return cleanCommitMessage(tagMatch[1]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lines = raw.split("\n");
|
|
17
|
+
const startIndex = lines.findIndex((line) =>
|
|
18
|
+
/^[a-z]+(\([^)]+\))?!?: /.test(line.trim())
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (startIndex === -1) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return cleanCommitMessage(lines.slice(startIndex).join("\n"));
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function getConfigPath() {
|
|
6
|
+
return (
|
|
7
|
+
process.env.GIT_AIC_CONFIG ||
|
|
8
|
+
join(homedir(), ".config", "git-aic", "config.json")
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadConfig() {
|
|
13
|
+
const path = getConfigPath();
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Failed to read config: ${path}\n${error.message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/bin/git-aic.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { runInitWizard } from "./wizard.js";
|
|
16
|
+
import { cleanCommitMessage } from "./clean.js";
|
|
17
|
+
import { extractCommitMessage } from "./extract.js";
|
|
18
|
+
import { runProvider } from "./run-provider.js";
|
|
19
|
+
import { applyUserOverrides, getConfiguration } from "./prompts.js";
|
|
20
|
+
import { getConfigPath, loadConfig } from "./get-config-path.js";
|
|
21
|
+
|
|
22
|
+
function fail(message, code = 1) {
|
|
23
|
+
console.error(message);
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(command, args, options = {}) {
|
|
28
|
+
return spawnSync(command, args, {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
let extraPrompt = "";
|
|
36
|
+
let shouldCommit = null;
|
|
37
|
+
let requireEdit = null;
|
|
38
|
+
let printConfig = false;
|
|
39
|
+
let runInit = false;
|
|
40
|
+
let shouldAdd = false;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const arg = argv[i];
|
|
44
|
+
|
|
45
|
+
if (arg === "-ap" || arg === "-pa") {
|
|
46
|
+
shouldAdd = true;
|
|
47
|
+
extraPrompt = argv.slice(i + 1).join(" ");
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg === "-p" || arg === "--prompt") {
|
|
52
|
+
extraPrompt = argv.slice(i + 1).join(" ");
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (arg === "-a" || arg === "--add") {
|
|
57
|
+
shouldAdd = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg === "--no-commit") {
|
|
62
|
+
shouldCommit = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (arg === "--commit") {
|
|
67
|
+
shouldCommit = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (arg === "--require-edit") {
|
|
72
|
+
requireEdit = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (arg === "--no-require-edit") {
|
|
77
|
+
requireEdit = false;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (arg === "--config") {
|
|
82
|
+
printConfig = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (arg === "--init") {
|
|
87
|
+
runInit = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (arg === "-h") {
|
|
92
|
+
console.log(`
|
|
93
|
+
Usage:
|
|
94
|
+
git aic
|
|
95
|
+
git aic -a # stage all changes first (git add -A)
|
|
96
|
+
git aic -p "extra prompt"
|
|
97
|
+
git aic -ap "extra prompt" # combine -a and -p
|
|
98
|
+
git aic --no-commit
|
|
99
|
+
git aic --config
|
|
100
|
+
git aic --init
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
git aic
|
|
104
|
+
git aic -a
|
|
105
|
+
git aic -p "prefer docs(specs), mention Asset Management API"
|
|
106
|
+
git aic -ap "mention Asset Management API"
|
|
107
|
+
git aic --no-commit -p "only generate the message"
|
|
108
|
+
git aic --init # interactive config wizard
|
|
109
|
+
|
|
110
|
+
Config:
|
|
111
|
+
~/.config/git-aic/config.json
|
|
112
|
+
|
|
113
|
+
Environment:
|
|
114
|
+
GIT_AIC_CONFIG=/path/to/config.json
|
|
115
|
+
`);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
extraPrompt += `${arg} `;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
extraPrompt: extraPrompt.trim(),
|
|
124
|
+
shouldCommit,
|
|
125
|
+
requireEdit,
|
|
126
|
+
printConfig,
|
|
127
|
+
runInit,
|
|
128
|
+
shouldAdd,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sha256(text) {
|
|
133
|
+
return crypto.createHash("sha256").update(text).digest("hex");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildPrompt(extraPrompt) {
|
|
137
|
+
const cfg = getConfiguration();
|
|
138
|
+
const { extraPrompt: token, extraPromptFallback } = cfg.placeholders;
|
|
139
|
+
return cfg.prompt.join("\n").replaceAll(token, extraPrompt || extraPromptFallback);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
const args = parseArgs(process.argv.slice(2));
|
|
144
|
+
const configPath = getConfigPath();
|
|
145
|
+
|
|
146
|
+
if (args.runInit) {
|
|
147
|
+
await runInitWizard(configPath);
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!existsSync(configPath)) {
|
|
152
|
+
console.log(`No config found at ${configPath}`);
|
|
153
|
+
console.log("Launching first-time setup...");
|
|
154
|
+
await runInitWizard(configPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let config;
|
|
158
|
+
try {
|
|
159
|
+
config = loadConfig();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
fail(error.message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
applyUserOverrides(config);
|
|
165
|
+
|
|
166
|
+
if (args.printConfig) {
|
|
167
|
+
console.log(getConfigPath());
|
|
168
|
+
console.log(JSON.stringify(config, null, 2));
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const shouldCommit = args.shouldCommit ?? config.commit ?? true;
|
|
173
|
+
const requireEdit = args.requireEdit ?? config.requireEdit ?? false;
|
|
174
|
+
|
|
175
|
+
const insideRepo = run("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
176
|
+
|
|
177
|
+
if (insideRepo.status !== 0) {
|
|
178
|
+
fail("Not inside a git repository.");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (args.shouldAdd) {
|
|
182
|
+
const addResult = run("git", ["add", "-A"]);
|
|
183
|
+
if (addResult.status !== 0) {
|
|
184
|
+
fail(addResult.stderr || "git add -A failed.");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const hasStagedChanges = run("git", ["diff", "--cached", "--quiet"]);
|
|
189
|
+
|
|
190
|
+
if (hasStagedChanges.status === 0) {
|
|
191
|
+
fail("No staged changes found. Use: git add <files>");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const diff = run("git", ["diff", "--cached"]);
|
|
195
|
+
|
|
196
|
+
if (diff.status !== 0) {
|
|
197
|
+
fail(diff.stderr || "Failed to read staged diff.");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const prompt = buildPrompt(args.extraPrompt);
|
|
201
|
+
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = runProvider(config.provider, prompt, diff.stdout);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
fail(error.message);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const message = extractCommitMessage(raw);
|
|
210
|
+
|
|
211
|
+
if (!message) {
|
|
212
|
+
fail("AI provider returned no valid commit message. Aborted.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const dir = mkdtempSync(join(tmpdir(), "git-aic-"));
|
|
216
|
+
const file = join(dir, "COMMIT_EDITMSG");
|
|
217
|
+
|
|
218
|
+
writeFileSync(file, `${message.trim()}\n`);
|
|
219
|
+
|
|
220
|
+
const before = sha256(readFileSync(file, "utf8"));
|
|
221
|
+
const mtimeBefore = statSync(file).mtimeMs;
|
|
222
|
+
|
|
223
|
+
const editor = process.env.EDITOR || config.editor || "nano";
|
|
224
|
+
const editorResult = spawnSync(editor, [file], {
|
|
225
|
+
stdio: "inherit",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (editorResult.status !== 0) {
|
|
229
|
+
rmSync(dir, { recursive: true, force: true });
|
|
230
|
+
fail("Editor exited with an error. Aborted.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mtimeAfter = statSync(file).mtimeMs;
|
|
234
|
+
|
|
235
|
+
if (mtimeAfter === mtimeBefore) {
|
|
236
|
+
rmSync(dir, { recursive: true, force: true });
|
|
237
|
+
fail("File not saved. Aborted.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const edited = readFileSync(file, "utf8");
|
|
241
|
+
const after = sha256(edited);
|
|
242
|
+
|
|
243
|
+
if (requireEdit && before === after) {
|
|
244
|
+
rmSync(dir, { recursive: true, force: true });
|
|
245
|
+
fail("No edit detected. Aborted.");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const finalMessage = cleanCommitMessage(edited);
|
|
249
|
+
|
|
250
|
+
if (!finalMessage.trim()) {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
fail("Commit message is empty. Aborted.");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
writeFileSync(file, `${finalMessage.trim()}\n`);
|
|
256
|
+
|
|
257
|
+
console.log();
|
|
258
|
+
console.log("Final commit message:");
|
|
259
|
+
console.log("---------------------");
|
|
260
|
+
console.log(finalMessage.trim());
|
|
261
|
+
console.log("---------------------");
|
|
262
|
+
console.log();
|
|
263
|
+
|
|
264
|
+
if (!shouldCommit) {
|
|
265
|
+
rmSync(dir, { recursive: true, force: true });
|
|
266
|
+
process.exit(0);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const commit = spawnSync("git", ["commit", "-F", file], {
|
|
270
|
+
stdio: "inherit",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
rmSync(dir, { recursive: true, force: true });
|
|
274
|
+
|
|
275
|
+
process.exit(commit.status ?? 1);
|
package/bin/presets.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const PROVIDER_PRESETS = [
|
|
2
|
+
{
|
|
3
|
+
name: "Claude Code",
|
|
4
|
+
command: "claude",
|
|
5
|
+
args: ["-p", "{prompt}"]
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
name: "Gemini CLI",
|
|
9
|
+
command: "gemini",
|
|
10
|
+
args: ["-p", "{prompt}"]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: "Codex CLI",
|
|
14
|
+
command: "codex",
|
|
15
|
+
args: ["exec", "--skip-git-repo-check", "{prompt}"]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "llm (Simon Willison)",
|
|
19
|
+
command: "llm",
|
|
20
|
+
args: ["{prompt}"]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "Ollama (llama3)",
|
|
24
|
+
command: "ollama",
|
|
25
|
+
args: ["run", "llama3", "{prompt}"]
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const EDITOR_PRESETS = ["nano", "vim", "nvim", "code --wait", "micro", "hx"];
|
package/bin/prompts.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
export const defaults = JSON.parse(
|
|
8
|
+
readFileSync(join(here, "prompts.json"), "utf8")
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
let merged = defaults;
|
|
12
|
+
|
|
13
|
+
export function mergeConfig(base, userConfig = {}) {
|
|
14
|
+
return {
|
|
15
|
+
...base,
|
|
16
|
+
prompt: userConfig.prompt ?? base.prompt,
|
|
17
|
+
commitMessageTag: userConfig.commitMessageTag ?? base.commitMessageTag,
|
|
18
|
+
placeholders: base.placeholders,
|
|
19
|
+
removeLinesMatching:
|
|
20
|
+
userConfig.removeLinesMatching ?? base.removeLinesMatching,
|
|
21
|
+
stopLinesMatching:
|
|
22
|
+
userConfig.stopLinesMatching ?? base.stopLinesMatching,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function applyUserOverrides(userConfig = {}) {
|
|
27
|
+
merged = mergeConfig(defaults, userConfig);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getConfiguration() {
|
|
31
|
+
return merged;
|
|
32
|
+
}
|
package/bin/prompts.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"prompt": [
|
|
3
|
+
"Generate a git commit message for the staged diff.",
|
|
4
|
+
"",
|
|
5
|
+
"Return the commit message ONLY inside these tags:",
|
|
6
|
+
"",
|
|
7
|
+
"<commit_message>",
|
|
8
|
+
"type(scope): summary",
|
|
9
|
+
"",
|
|
10
|
+
"optional body",
|
|
11
|
+
"</commit_message>",
|
|
12
|
+
"",
|
|
13
|
+
"Rules:",
|
|
14
|
+
"- Use Conventional Commits.",
|
|
15
|
+
"- Allowed types: feat, fix, docs, style, refactor, test, chore, build, ci, perf.",
|
|
16
|
+
"- Keep the first line under 72 characters.",
|
|
17
|
+
"- Body is optional.",
|
|
18
|
+
"- Do NOT include markdown.",
|
|
19
|
+
"- Do NOT include backticks.",
|
|
20
|
+
"- Do NOT include explanations.",
|
|
21
|
+
"- Do NOT include Co-Authored-By.",
|
|
22
|
+
"- Do NOT include Signed-off-by.",
|
|
23
|
+
"- Do NOT include AI attribution.",
|
|
24
|
+
"",
|
|
25
|
+
"Extra user instructions:",
|
|
26
|
+
"{extraPrompt}"
|
|
27
|
+
],
|
|
28
|
+
"commitMessageTag": "commit_message",
|
|
29
|
+
"placeholders": {
|
|
30
|
+
"extraPrompt": "{extraPrompt}",
|
|
31
|
+
"extraPromptFallback": "None"
|
|
32
|
+
},
|
|
33
|
+
"removeLinesMatching": [
|
|
34
|
+
"^```",
|
|
35
|
+
"^Here.*commit message",
|
|
36
|
+
"^Co-Authored-By:",
|
|
37
|
+
"^Signed-off-by:",
|
|
38
|
+
"Claude",
|
|
39
|
+
"Anthropic"
|
|
40
|
+
],
|
|
41
|
+
"stopLinesMatching": [
|
|
42
|
+
"^Wait.*rules"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function runProvider(provider, prompt, diff) {
|
|
4
|
+
if (!provider?.command) {
|
|
5
|
+
throw new Error("Config error: provider.command is required.");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const args = provider.args ?? [];
|
|
9
|
+
|
|
10
|
+
const finalArgs = args.map((arg) =>
|
|
11
|
+
arg
|
|
12
|
+
.replaceAll("{prompt}", prompt)
|
|
13
|
+
.replaceAll("{diff}", diff)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const usesPromptPlaceholder = args.some((arg) => arg.includes("{prompt}"));
|
|
17
|
+
const usesDiffPlaceholder = args.some((arg) => arg.includes("{diff}"));
|
|
18
|
+
|
|
19
|
+
const input = usesDiffPlaceholder ? undefined : diff;
|
|
20
|
+
|
|
21
|
+
if (!usesPromptPlaceholder) {
|
|
22
|
+
finalArgs.push(prompt);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = spawnSync(provider.command, finalArgs, {
|
|
26
|
+
input,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (result.status !== 0) {
|
|
31
|
+
throw new Error(result.stderr || `${provider.command} failed.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result.stdout;
|
|
35
|
+
}
|
package/bin/wizard.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
|
|
6
|
+
import { PROVIDER_PRESETS, EDITOR_PRESETS } from "./presets.js";
|
|
7
|
+
import { defaults } from "./prompts.js";
|
|
8
|
+
|
|
9
|
+
export async function runInitWizard(path) {
|
|
10
|
+
const rl = createInterface({ input, output });
|
|
11
|
+
|
|
12
|
+
console.log();
|
|
13
|
+
console.log("git-aic config wizard");
|
|
14
|
+
console.log("=====================");
|
|
15
|
+
console.log(`Target: ${path}`);
|
|
16
|
+
|
|
17
|
+
if (existsSync(path)) {
|
|
18
|
+
const answer = (await rl.question("Config already exists. Overwrite? [y/N]: "))
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
if (answer !== "y" && answer !== "yes") {
|
|
22
|
+
console.log("Aborted.");
|
|
23
|
+
rl.close();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const provider = await pickProvider(rl);
|
|
29
|
+
const editor = await pickEditor(rl);
|
|
30
|
+
|
|
31
|
+
console.log();
|
|
32
|
+
const commitAnswer = (
|
|
33
|
+
await rl.question("Auto-commit after editing the message? [Y/n]: ")
|
|
34
|
+
).trim().toLowerCase();
|
|
35
|
+
const commit = commitAnswer !== "n" && commitAnswer !== "no";
|
|
36
|
+
|
|
37
|
+
const requireEditAnswer = (
|
|
38
|
+
await rl.question("Require the message to be edited before commit? [y/N]: ")
|
|
39
|
+
).trim().toLowerCase();
|
|
40
|
+
const requireEdit = requireEditAnswer === "y" || requireEditAnswer === "yes";
|
|
41
|
+
|
|
42
|
+
rl.close();
|
|
43
|
+
|
|
44
|
+
const config = {
|
|
45
|
+
provider,
|
|
46
|
+
editor,
|
|
47
|
+
commit,
|
|
48
|
+
requireEdit,
|
|
49
|
+
prompt: defaults.prompt,
|
|
50
|
+
commitMessageTag: defaults.commitMessageTag,
|
|
51
|
+
removeLinesMatching: defaults.removeLinesMatching,
|
|
52
|
+
stopLinesMatching: defaults.stopLinesMatching,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
57
|
+
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(`Wrote: ${path}`);
|
|
60
|
+
console.log(JSON.stringify(config, null, 2));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pickProvider(rl) {
|
|
64
|
+
console.log();
|
|
65
|
+
console.log("Choose a provider:");
|
|
66
|
+
PROVIDER_PRESETS.forEach((preset, index) => {
|
|
67
|
+
const argsPreview = preset.args.join(" ");
|
|
68
|
+
console.log(` ${index + 1}) ${preset.name} (${preset.command} ${argsPreview})`);
|
|
69
|
+
});
|
|
70
|
+
console.log(` ${PROVIDER_PRESETS.length + 1}) Custom`);
|
|
71
|
+
|
|
72
|
+
while (true) {
|
|
73
|
+
const choice = (await rl.question(`Provider [1-${PROVIDER_PRESETS.length + 1}]: `)).trim();
|
|
74
|
+
const index = Number.parseInt(choice, 10) - 1;
|
|
75
|
+
|
|
76
|
+
if (index >= 0 && index < PROVIDER_PRESETS.length) {
|
|
77
|
+
const preset = PROVIDER_PRESETS[index];
|
|
78
|
+
return { command: preset.command, args: preset.args };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (index === PROVIDER_PRESETS.length) {
|
|
82
|
+
const command = (await rl.question("Command (e.g. claude): ")).trim();
|
|
83
|
+
|
|
84
|
+
if (!command) {
|
|
85
|
+
console.log("Command cannot be empty.");
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const argsRaw = (
|
|
90
|
+
await rl.question('Args (space-separated, use {prompt} and {diff} as placeholders) [-p "{prompt}"]: ')
|
|
91
|
+
).trim();
|
|
92
|
+
const args = argsRaw ? argsRaw.split(/\s+/) : ["-p", "{prompt}"];
|
|
93
|
+
return { command, args };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log("Invalid choice.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function pickEditor(rl) {
|
|
101
|
+
console.log();
|
|
102
|
+
console.log("Choose an editor:");
|
|
103
|
+
EDITOR_PRESETS.forEach((preset, index) => {
|
|
104
|
+
console.log(` ${index + 1}) ${preset}`);
|
|
105
|
+
});
|
|
106
|
+
console.log(` ${EDITOR_PRESETS.length + 1}) Custom`);
|
|
107
|
+
|
|
108
|
+
while (true) {
|
|
109
|
+
const choice = (
|
|
110
|
+
await rl.question(`Editor [1-${EDITOR_PRESETS.length + 1}, default 1]: `)
|
|
111
|
+
).trim();
|
|
112
|
+
|
|
113
|
+
if (choice === "") {
|
|
114
|
+
return EDITOR_PRESETS[0];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const index = Number.parseInt(choice, 10) - 1;
|
|
118
|
+
|
|
119
|
+
if (index >= 0 && index < EDITOR_PRESETS.length) {
|
|
120
|
+
return EDITOR_PRESETS[index];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (index === EDITOR_PRESETS.length) {
|
|
124
|
+
const custom = (await rl.question("Editor command: ")).trim();
|
|
125
|
+
|
|
126
|
+
if (!custom) {
|
|
127
|
+
console.log("Editor cannot be empty.");
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return custom;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log("Invalid choice.");
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thankarezos/git-ai-commit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate editable git commit messages using a configurable AI CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-aic": "bin/git-aic.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/*.test.js"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/thankarezos/git-ai-commit.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/thankarezos/git-ai-commit#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/thankarezos/git-ai-commit/issues"
|
|
27
|
+
},
|
|
28
|
+
"author": "thankarezos <thankarezos@gmail.com>",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"git",
|
|
31
|
+
"ai",
|
|
32
|
+
"commit",
|
|
33
|
+
"cli",
|
|
34
|
+
"claude",
|
|
35
|
+
"gemini",
|
|
36
|
+
"conventional-commits"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|