git-message-ai-commit 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -4
- package/dist/cli.js +110 -145
- package/dist/exit-codes.js +19 -0
- package/dist/git.js +11 -3
- package/dist/ollama.js +152 -36
- package/dist/prompt.js +4 -3
- package/dist/util.js +31 -8
- package/dist/validation.js +203 -0
- package/dist/workflow.js +217 -0
- package/package.json +19 -6
- package/eslint.config.mjs +0 -14
- package/git-message-ai-commit-1.0.0.tgz +0 -0
package/README.md
CHANGED
|
@@ -11,9 +11,11 @@ A CLI tool that uses a local [Ollama](https://ollama.com/) instance to generate
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
15
|
-
npm
|
|
16
|
-
|
|
14
|
+
# Global installation
|
|
15
|
+
npm install -g git-message-ai-commit
|
|
16
|
+
|
|
17
|
+
# Or run directly with npx
|
|
18
|
+
npx git-message-ai-commit
|
|
17
19
|
```
|
|
18
20
|
|
|
19
21
|
## Usage
|
|
@@ -34,6 +36,48 @@ git-ai-commit
|
|
|
34
36
|
|
|
35
37
|
- `-m, --model <name>`: Specify Ollama model (default: `llama3`)
|
|
36
38
|
- `--host <url>`: Ollama host (default: `http://localhost:11434`)
|
|
37
|
-
- `--max-chars <n>`: Max diff characters sent (default: `16000`)
|
|
39
|
+
- `--max-chars <n>`: Max diff characters sent (range: `500-200000`, default: `16000`)
|
|
38
40
|
- `--type <type>`: Force a commit type (feat, fix, etc.)
|
|
41
|
+
- `--scope <scope>`: Optional commit scope
|
|
39
42
|
- `--dry-run`: Print message to stdout without committing
|
|
43
|
+
- `--ci`: Non-interactive mode for CI/hooks
|
|
44
|
+
- `--allow-invalid`: Override validation and allow invalid messages
|
|
45
|
+
- `--timeout-ms <n>`: Ollama request timeout in milliseconds (range: `1000-300000`, default: `60000`)
|
|
46
|
+
- `--retries <n>`: Retry count for transient Ollama failures (range: `0-5`, default: `2`)
|
|
47
|
+
- `--output <text|json>`: Output format (default: `text`)
|
|
48
|
+
- `--no-verify`: Pass `--no-verify` to `git commit`
|
|
49
|
+
|
|
50
|
+
### Environment variables
|
|
51
|
+
|
|
52
|
+
- `GIT_AI_MODEL`
|
|
53
|
+
- `GIT_AI_HOST`
|
|
54
|
+
- `GIT_AI_TIMEOUT_MS`
|
|
55
|
+
- `GIT_AI_RETRIES`
|
|
56
|
+
|
|
57
|
+
### CI usage
|
|
58
|
+
|
|
59
|
+
Generate JSON output in non-interactive mode:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git-ai-commit --ci --dry-run --output json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Exit codes
|
|
66
|
+
|
|
67
|
+
- `0`: success
|
|
68
|
+
- `1`: usage/configuration error
|
|
69
|
+
- `2`: git context error (not a repo or no staged changes)
|
|
70
|
+
- `3`: Ollama/model error
|
|
71
|
+
- `4`: invalid AI output (blocked by default)
|
|
72
|
+
- `5`: `git commit` failed
|
|
73
|
+
- `6`: unexpected internal error
|
|
74
|
+
|
|
75
|
+
## Tips
|
|
76
|
+
|
|
77
|
+
### Set an alias
|
|
78
|
+
|
|
79
|
+
You can set a shorter alias (e.g. `gmac`) in your shell config (`.zshrc`, `.bashrc`, etc.):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
alias gmac="git-ai-commit"
|
|
83
|
+
```
|
package/dist/cli.js
CHANGED
|
@@ -1,165 +1,130 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
3
|
+
import { ExitCode } from "./exit-codes.js";
|
|
4
|
+
import { parseBoundedInteger } from "./util.js";
|
|
5
|
+
import { isAllowedType } from "./validation.js";
|
|
6
|
+
import { runWorkflow } from "./workflow.js";
|
|
7
|
+
const MIN_MAX_CHARS = 500;
|
|
8
|
+
const MAX_MAX_CHARS = 200000;
|
|
9
|
+
const MIN_TIMEOUT_MS = 1000;
|
|
10
|
+
const MAX_TIMEOUT_MS = 300000;
|
|
11
|
+
const MIN_RETRIES = 0;
|
|
12
|
+
const MAX_RETRIES = 5;
|
|
13
|
+
function readEnv(name, fallback) {
|
|
14
|
+
const value = process.env[name]?.trim();
|
|
15
|
+
return value && value.length > 0 ? value : fallback;
|
|
16
|
+
}
|
|
17
|
+
function parseOutput(value) {
|
|
18
|
+
if (value === "text" || value === "json")
|
|
19
|
+
return value;
|
|
20
|
+
throw new Error("--output must be one of: text, json.");
|
|
21
|
+
}
|
|
22
|
+
function parseType(value) {
|
|
23
|
+
if (!value)
|
|
24
|
+
return null;
|
|
25
|
+
const normalized = value.toLowerCase();
|
|
26
|
+
if (!isAllowedType(normalized)) {
|
|
27
|
+
throw new Error("Invalid --type. Must be one of: feat, fix, chore, refactor, docs, test, perf, build, ci.");
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
function buildOptions(raw) {
|
|
32
|
+
const model = raw.model.trim();
|
|
33
|
+
if (!model)
|
|
34
|
+
throw new Error("--model must be a non-empty string.");
|
|
35
|
+
const host = raw.host.trim();
|
|
36
|
+
if (!host)
|
|
37
|
+
throw new Error("--host must be a non-empty URL.");
|
|
38
|
+
return {
|
|
39
|
+
model,
|
|
40
|
+
host,
|
|
41
|
+
maxChars: parseBoundedInteger(raw.maxChars, "--max-chars", MIN_MAX_CHARS, MAX_MAX_CHARS),
|
|
42
|
+
type: parseType(raw.type),
|
|
43
|
+
scope: raw.scope?.trim() ? raw.scope.trim() : null,
|
|
44
|
+
dryRun: raw.dryRun,
|
|
45
|
+
noVerify: raw.noVerify,
|
|
46
|
+
ci: raw.ci,
|
|
47
|
+
allowInvalid: raw.allowInvalid,
|
|
48
|
+
timeoutMs: parseBoundedInteger(raw.timeoutMs, "--timeout-ms", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
49
|
+
retries: parseBoundedInteger(raw.retries, "--retries", MIN_RETRIES, MAX_RETRIES),
|
|
50
|
+
output: parseOutput(raw.output.toLowerCase())
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function printJson(result) {
|
|
54
|
+
if (result.ok) {
|
|
55
|
+
console.log(JSON.stringify({
|
|
56
|
+
status: "ok",
|
|
57
|
+
message: result.message,
|
|
58
|
+
source: result.source,
|
|
59
|
+
committed: result.committed
|
|
60
|
+
}));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
console.log(JSON.stringify({
|
|
64
|
+
status: "error",
|
|
65
|
+
code: result.code,
|
|
66
|
+
hint: result.hint ?? result.message
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
function printText(result, options) {
|
|
70
|
+
if (result.ok) {
|
|
71
|
+
if (result.cancelled) {
|
|
72
|
+
console.log("Cancelled.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!result.committed) {
|
|
76
|
+
console.log(result.message);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (options.ci) {
|
|
80
|
+
console.log(result.message);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log("Committed.");
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
24
86
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { ok: false, reason: "Subject should not end with a period" };
|
|
29
|
-
return { ok: true };
|
|
87
|
+
console.error(result.message);
|
|
88
|
+
if (result.hint)
|
|
89
|
+
console.error(result.hint);
|
|
30
90
|
}
|
|
31
91
|
async function main() {
|
|
32
|
-
// ... (keep program definition)
|
|
33
92
|
const program = new Command();
|
|
34
93
|
program
|
|
35
94
|
.name("git-ai-commit")
|
|
36
|
-
// ... (keep options)
|
|
37
95
|
.description("Generate a Conventional Commit message from staged changes using local Ollama")
|
|
38
|
-
.option("-m, --model <name>", "Ollama model name", "llama3")
|
|
39
|
-
.option("--host <url>", "Ollama host", "http://localhost:11434")
|
|
40
|
-
.option("--max-chars <n>",
|
|
96
|
+
.option("-m, --model <name>", "Ollama model name", readEnv("GIT_AI_MODEL", "llama3"))
|
|
97
|
+
.option("--host <url>", "Ollama host", readEnv("GIT_AI_HOST", "http://localhost:11434"))
|
|
98
|
+
.option("--max-chars <n>", `Max diff characters sent to model (${MIN_MAX_CHARS}-${MAX_MAX_CHARS})`, "16000")
|
|
41
99
|
.option("--type <type>", "Force commit type (feat|fix|chore|refactor|docs|test|perf|build|ci)")
|
|
42
100
|
.option("--scope <scope>", "Optional scope, e.g. api, infra")
|
|
43
101
|
.option("--dry-run", "Print message only, do not commit", false)
|
|
44
102
|
.option("--no-verify", "Pass --no-verify to git commit", false)
|
|
103
|
+
.option("--ci", "Non-interactive mode for CI usage", false)
|
|
104
|
+
.option("--allow-invalid", "Allow commit even if validation fails", false)
|
|
105
|
+
.option("--timeout-ms <n>", `Ollama request timeout in milliseconds (${MIN_TIMEOUT_MS}-${MAX_TIMEOUT_MS})`, readEnv("GIT_AI_TIMEOUT_MS", "60000"))
|
|
106
|
+
.option("--retries <n>", `Retry count for transient Ollama failures (${MIN_RETRIES}-${MAX_RETRIES})`, readEnv("GIT_AI_RETRIES", "2"))
|
|
107
|
+
.option("--output <format>", "Output format (text|json)", "text")
|
|
45
108
|
.parse(process.argv);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
process.exit(1);
|
|
50
|
-
}
|
|
51
|
-
if (!(await hasStagedChanges())) {
|
|
52
|
-
console.error("No staged changes. Stage files first: git add <files>.");
|
|
53
|
-
process.exit(1);
|
|
109
|
+
let options;
|
|
110
|
+
try {
|
|
111
|
+
options = buildOptions(program.opts());
|
|
54
112
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
console.error("Try running 'ollama list' or 'ollama serve' in another terminal.");
|
|
60
|
-
process.exit(1);
|
|
113
|
+
catch (error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
console.error(message);
|
|
116
|
+
process.exit(ExitCode.UsageError);
|
|
61
117
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
118
|
+
const result = await runWorkflow(options);
|
|
119
|
+
if (options.output === "json") {
|
|
120
|
+
printJson(result);
|
|
65
121
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
while (true) {
|
|
69
|
-
const messages = buildMessages({
|
|
70
|
-
diff,
|
|
71
|
-
forcedType: opts.type ?? null,
|
|
72
|
-
scope: opts.scope ?? null
|
|
73
|
-
});
|
|
74
|
-
process.stdout.write("⏳ Generating commit message... ");
|
|
75
|
-
let suggestion = "";
|
|
76
|
-
try {
|
|
77
|
-
const raw = (await ollamaChat({
|
|
78
|
-
host: opts.host,
|
|
79
|
-
model: opts.model,
|
|
80
|
-
messages,
|
|
81
|
-
json: true
|
|
82
|
-
})).trim();
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(raw);
|
|
85
|
-
suggestion = parsed.message ?? raw;
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
// Fallback if model ignored json mode (rare)
|
|
89
|
-
suggestion = raw;
|
|
90
|
-
}
|
|
91
|
-
// Auto-fix: remove trailing period
|
|
92
|
-
if (suggestion.endsWith(".")) {
|
|
93
|
-
suggestion = suggestion.slice(0, -1);
|
|
94
|
-
}
|
|
95
|
-
process.stdout.write("Done!\n");
|
|
96
|
-
}
|
|
97
|
-
catch (e) {
|
|
98
|
-
process.stdout.write("Failed.\n");
|
|
99
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
const v = validateMessage(suggestion);
|
|
103
|
-
if (!v.ok)
|
|
104
|
-
console.warn(`AI output validation failed: ${v.reason}`);
|
|
105
|
-
console.log("\n--- Suggested commit message ---\n");
|
|
106
|
-
console.log(suggestion);
|
|
107
|
-
console.log("\n-------------------------------\n");
|
|
108
|
-
const { action } = await prompts({
|
|
109
|
-
type: "select",
|
|
110
|
-
name: "action",
|
|
111
|
-
message: "What next?",
|
|
112
|
-
choices: [
|
|
113
|
-
{ title: "✅ Accept and commit", value: "accept" },
|
|
114
|
-
{ title: "✏️ Edit", value: "edit" },
|
|
115
|
-
{ title: "🔁 Regenerate", value: "regen" },
|
|
116
|
-
{ title: "🧪 Dry-run (print only)", value: "dry" },
|
|
117
|
-
{ title: "❌ Cancel", value: "cancel" }
|
|
118
|
-
],
|
|
119
|
-
initial: 0
|
|
120
|
-
});
|
|
121
|
-
// ... (keep rest of loop)
|
|
122
|
-
if (!action || action === "cancel") {
|
|
123
|
-
console.log("Cancelled.");
|
|
124
|
-
process.exit(0);
|
|
125
|
-
}
|
|
126
|
-
if (action === "regen")
|
|
127
|
-
continue;
|
|
128
|
-
let finalMsg = suggestion;
|
|
129
|
-
if (action === "edit") {
|
|
130
|
-
const { msg } = await prompts({
|
|
131
|
-
type: "text",
|
|
132
|
-
name: "msg",
|
|
133
|
-
message: "Edit commit message",
|
|
134
|
-
initial: finalMsg
|
|
135
|
-
});
|
|
136
|
-
if (!msg) {
|
|
137
|
-
console.log("Cancelled.");
|
|
138
|
-
process.exit(0);
|
|
139
|
-
}
|
|
140
|
-
finalMsg = String(msg).trim();
|
|
141
|
-
}
|
|
142
|
-
if (action === "dry" || opts.dryRun) {
|
|
143
|
-
console.log(finalMsg);
|
|
144
|
-
process.exit(0);
|
|
145
|
-
}
|
|
146
|
-
const v2 = validateMessage(finalMsg);
|
|
147
|
-
if (!v2.ok) {
|
|
148
|
-
const { yes } = await prompts({
|
|
149
|
-
type: "confirm",
|
|
150
|
-
name: "yes",
|
|
151
|
-
message: `Still fails validation (${v2.reason}). Commit anyway?`,
|
|
152
|
-
initial: false
|
|
153
|
-
});
|
|
154
|
-
if (!yes)
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
await gitCommit(finalMsg, { noVerify: opts.noVerify });
|
|
158
|
-
console.log("Committed.");
|
|
159
|
-
process.exit(0);
|
|
122
|
+
else {
|
|
123
|
+
printText(result, options);
|
|
160
124
|
}
|
|
125
|
+
process.exit(result.exitCode);
|
|
161
126
|
}
|
|
162
|
-
main().catch((
|
|
163
|
-
console.error(
|
|
164
|
-
process.exit(
|
|
127
|
+
main().catch((error) => {
|
|
128
|
+
console.error(error instanceof Error ? error.stack : String(error));
|
|
129
|
+
process.exit(ExitCode.InternalError);
|
|
165
130
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export var ExitCode;
|
|
2
|
+
(function (ExitCode) {
|
|
3
|
+
ExitCode[ExitCode["Success"] = 0] = "Success";
|
|
4
|
+
ExitCode[ExitCode["UsageError"] = 1] = "UsageError";
|
|
5
|
+
ExitCode[ExitCode["GitContextError"] = 2] = "GitContextError";
|
|
6
|
+
ExitCode[ExitCode["OllamaError"] = 3] = "OllamaError";
|
|
7
|
+
ExitCode[ExitCode["InvalidAiOutput"] = 4] = "InvalidAiOutput";
|
|
8
|
+
ExitCode[ExitCode["GitCommitError"] = 5] = "GitCommitError";
|
|
9
|
+
ExitCode[ExitCode["InternalError"] = 6] = "InternalError";
|
|
10
|
+
})(ExitCode || (ExitCode = {}));
|
|
11
|
+
export const EXIT_CODE_LABEL = {
|
|
12
|
+
[ExitCode.Success]: "SUCCESS",
|
|
13
|
+
[ExitCode.UsageError]: "USAGE_ERROR",
|
|
14
|
+
[ExitCode.GitContextError]: "GIT_CONTEXT_ERROR",
|
|
15
|
+
[ExitCode.OllamaError]: "OLLAMA_ERROR",
|
|
16
|
+
[ExitCode.InvalidAiOutput]: "INVALID_AI_OUTPUT",
|
|
17
|
+
[ExitCode.GitCommitError]: "GIT_COMMIT_ERROR",
|
|
18
|
+
[ExitCode.InternalError]: "INTERNAL_ERROR"
|
|
19
|
+
};
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
|
+
function getExitCode(error) {
|
|
3
|
+
if (!error || typeof error !== "object")
|
|
4
|
+
return null;
|
|
5
|
+
if (!("exitCode" in error))
|
|
6
|
+
return null;
|
|
7
|
+
const value = error.exitCode;
|
|
8
|
+
return typeof value === "number" ? value : null;
|
|
9
|
+
}
|
|
2
10
|
export async function isGitRepo() {
|
|
3
11
|
try {
|
|
4
12
|
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
@@ -14,10 +22,10 @@ export async function hasStagedChanges() {
|
|
|
14
22
|
await execa("git", ["diff", "--staged", "--quiet"]);
|
|
15
23
|
return false;
|
|
16
24
|
}
|
|
17
|
-
catch (
|
|
18
|
-
if (
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (getExitCode(error) === 1)
|
|
19
27
|
return true;
|
|
20
|
-
throw
|
|
28
|
+
throw error;
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
31
|
export async function getStagedDiff() {
|
package/dist/ollama.js
CHANGED
|
@@ -1,49 +1,165 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
export class OllamaError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
hint;
|
|
4
|
+
status;
|
|
5
|
+
retryable;
|
|
6
|
+
constructor(message, code, opts) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "OllamaError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.hint = opts?.hint ?? null;
|
|
11
|
+
this.status = opts?.status ?? null;
|
|
12
|
+
this.retryable = opts?.retryable ?? false;
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
function toUrl(host, path) {
|
|
16
|
+
return `${host.replace(/\/$/, "")}${path}`;
|
|
17
|
+
}
|
|
18
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
17
19
|
const controller = new AbortController();
|
|
18
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
20
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
21
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
messages: opts.messages,
|
|
23
|
-
stream: false
|
|
24
|
-
};
|
|
25
|
-
if (opts.json)
|
|
26
|
-
body.format = "json";
|
|
27
|
-
const res = await fetch(url, {
|
|
28
|
-
method: "POST",
|
|
29
|
-
headers: { "content-type": "application/json" },
|
|
30
|
-
body: JSON.stringify(body),
|
|
22
|
+
return await fetch(url, {
|
|
23
|
+
...init,
|
|
31
24
|
signal: controller.signal
|
|
32
25
|
});
|
|
33
|
-
if (!res.ok) {
|
|
34
|
-
const text = await res.text().catch(() => "");
|
|
35
|
-
throw new Error(`Ollama error ${res.status}: ${text}`);
|
|
36
|
-
}
|
|
37
|
-
const data = await res.json();
|
|
38
|
-
return data?.message?.content ?? "";
|
|
39
26
|
}
|
|
40
|
-
catch (
|
|
41
|
-
if (
|
|
42
|
-
throw new
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
29
|
+
throw new OllamaError(`Ollama request timed out (${timeoutMs}ms).`, "TIMEOUT", {
|
|
30
|
+
hint: "Ensure Ollama is running and/or increase --timeout-ms.",
|
|
31
|
+
retryable: true
|
|
32
|
+
});
|
|
43
33
|
}
|
|
44
|
-
|
|
34
|
+
if (error instanceof TypeError) {
|
|
35
|
+
throw new OllamaError("Cannot reach Ollama.", "UNREACHABLE", {
|
|
36
|
+
hint: "Run `ollama serve` and verify --host points to the local Ollama endpoint.",
|
|
37
|
+
retryable: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
45
41
|
}
|
|
46
42
|
finally {
|
|
47
43
|
clearTimeout(timeoutId);
|
|
48
44
|
}
|
|
49
45
|
}
|
|
46
|
+
export async function listLocalModels(host, timeoutMs = 2000) {
|
|
47
|
+
const url = toUrl(host, "/api/tags");
|
|
48
|
+
const res = await fetchWithTimeout(url, { method: "GET" }, timeoutMs);
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new OllamaError(`Ollama returned HTTP ${res.status} while checking models.`, "HTTP_ERROR", {
|
|
51
|
+
status: res.status,
|
|
52
|
+
retryable: res.status >= 500,
|
|
53
|
+
hint: "Confirm Ollama is healthy and reachable on --host."
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
let data;
|
|
57
|
+
try {
|
|
58
|
+
data = await res.json();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new OllamaError("Failed to parse Ollama model list response.", "INVALID_RESPONSE", {
|
|
62
|
+
hint: "Update Ollama and retry. The /api/tags response was not valid JSON."
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return (data.models ?? [])
|
|
66
|
+
.map((model) => model.name?.trim())
|
|
67
|
+
.filter((name) => Boolean(name));
|
|
68
|
+
}
|
|
69
|
+
function matchesModel(requested, available) {
|
|
70
|
+
const req = requested.toLowerCase();
|
|
71
|
+
const model = available.toLowerCase();
|
|
72
|
+
if (req === model)
|
|
73
|
+
return true;
|
|
74
|
+
if (!req.includes(":") && model.startsWith(`${req}:`))
|
|
75
|
+
return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
export async function ensureLocalModel(host, model, timeoutMs) {
|
|
79
|
+
const availableModels = await listLocalModels(host, timeoutMs);
|
|
80
|
+
const found = availableModels.some((available) => matchesModel(model, available));
|
|
81
|
+
if (!found) {
|
|
82
|
+
throw new OllamaError(`Model "${model}" is not available in local Ollama.`, "MODEL_NOT_FOUND", {
|
|
83
|
+
hint: `Run \`ollama pull ${model}\` and try again.`
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function checkOllamaConnection(host) {
|
|
88
|
+
try {
|
|
89
|
+
await listLocalModels(host, 2000);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function shouldRetry(error) {
|
|
97
|
+
if (error.retryable)
|
|
98
|
+
return true;
|
|
99
|
+
return error.code === "HTTP_ERROR" && Boolean(error.status && error.status >= 500);
|
|
100
|
+
}
|
|
101
|
+
function normalizeOllamaError(error) {
|
|
102
|
+
if (error instanceof OllamaError)
|
|
103
|
+
return error;
|
|
104
|
+
if (error instanceof Error) {
|
|
105
|
+
return new OllamaError(error.message, "HTTP_ERROR");
|
|
106
|
+
}
|
|
107
|
+
return new OllamaError(String(error), "HTTP_ERROR");
|
|
108
|
+
}
|
|
109
|
+
async function sleep(ms) {
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
111
|
+
}
|
|
112
|
+
export async function ollamaChat(opts) {
|
|
113
|
+
const timeoutMs = opts.timeoutMs ?? 60000;
|
|
114
|
+
const retries = Math.max(0, Math.floor(opts.retries ?? 2));
|
|
115
|
+
const url = toUrl(opts.host, "/api/chat");
|
|
116
|
+
let lastError = null;
|
|
117
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
118
|
+
try {
|
|
119
|
+
const body = {
|
|
120
|
+
model: opts.model,
|
|
121
|
+
messages: opts.messages,
|
|
122
|
+
stream: false
|
|
123
|
+
};
|
|
124
|
+
if (opts.json)
|
|
125
|
+
body.format = "json";
|
|
126
|
+
const res = await fetchWithTimeout(url, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify(body)
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const text = await res.text().catch(() => "");
|
|
133
|
+
if (res.status === 404 && text.toLowerCase().includes("model")) {
|
|
134
|
+
throw new OllamaError(`Model "${opts.model}" is not available in local Ollama.`, "MODEL_NOT_FOUND", {
|
|
135
|
+
status: res.status,
|
|
136
|
+
hint: `Run \`ollama pull ${opts.model}\` and try again.`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
throw new OllamaError(`Ollama error ${res.status}: ${text}`, "HTTP_ERROR", {
|
|
140
|
+
status: res.status,
|
|
141
|
+
retryable: res.status >= 500,
|
|
142
|
+
hint: "Check Ollama logs and confirm the local model can run."
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
const content = data?.message?.content;
|
|
147
|
+
if (typeof content !== "string" || content.trim() === "") {
|
|
148
|
+
throw new OllamaError("Ollama returned an empty response.", "INVALID_RESPONSE", {
|
|
149
|
+
hint: "Try a larger --timeout-ms, then retry generation."
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return content;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const normalized = normalizeOllamaError(error);
|
|
156
|
+
lastError = normalized;
|
|
157
|
+
if (attempt < retries && shouldRetry(normalized)) {
|
|
158
|
+
await sleep(150 * (attempt + 1));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
throw normalized;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw (lastError ?? new OllamaError("Unknown Ollama failure.", "HTTP_ERROR"));
|
|
165
|
+
}
|
package/dist/prompt.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const SYSTEM = `You write excellent git commit messages.
|
|
2
2
|
|
|
3
3
|
Rules:
|
|
4
|
-
- Output valid JSON object: { "message": "
|
|
5
|
-
-
|
|
4
|
+
- Output ONLY valid JSON object with exactly one key: { "message": "..." }
|
|
5
|
+
- Do not include any keys other than "message".
|
|
6
|
+
- NO markdown and NO commentary.
|
|
6
7
|
- Use Conventional Commits: type(scope optional): subject
|
|
7
8
|
- Allowed types: feat, fix, chore, refactor, docs, test, perf, build, ci
|
|
8
9
|
- Subject line: <= 72 chars, imperative mood, present tense, NO trailing period.
|
|
@@ -25,7 +26,7 @@ Input Data:
|
|
|
25
26
|
${opts.diff}
|
|
26
27
|
--- END DIFF ---
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
Return JSON only, exactly in this shape: { "message": "..." }.`;
|
|
29
30
|
return [
|
|
30
31
|
{ role: "system", content: SYSTEM },
|
|
31
32
|
{ role: "user", content: user }
|
package/dist/util.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
|
+
const DIFF_TRUNCATION_MARKER = "\n\n--- DIFF TRUNCATED ---\n\n";
|
|
1
2
|
export function clampDiff(diff, maxChars) {
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
const source = diff ?? "";
|
|
4
|
+
const limit = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0;
|
|
5
|
+
if (limit === 0)
|
|
6
|
+
return "";
|
|
7
|
+
if (source.length <= limit)
|
|
8
|
+
return source;
|
|
9
|
+
if (limit <= DIFF_TRUNCATION_MARKER.length + 20) {
|
|
10
|
+
return source.slice(0, limit);
|
|
11
|
+
}
|
|
12
|
+
const available = limit - DIFF_TRUNCATION_MARKER.length;
|
|
13
|
+
const headSize = Math.max(1, Math.floor(available * 0.7));
|
|
14
|
+
const tailSize = Math.max(1, available - headSize);
|
|
15
|
+
return `${source.slice(0, headSize)}${DIFF_TRUNCATION_MARKER}${source.slice(source.length - tailSize)}`;
|
|
16
|
+
}
|
|
17
|
+
export function parseBoundedInteger(value, name, min, max) {
|
|
18
|
+
const parsed = Number.parseInt(value, 10);
|
|
19
|
+
if (!Number.isFinite(parsed)) {
|
|
20
|
+
throw new Error(`${name} must be a number.`);
|
|
21
|
+
}
|
|
22
|
+
if (parsed < min || parsed > max) {
|
|
23
|
+
throw new Error(`${name} must be between ${min} and ${max}.`);
|
|
24
|
+
}
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
export function normalizeErrorMessage(error, fallback) {
|
|
28
|
+
if (error instanceof Error && error.message.trim())
|
|
29
|
+
return error.message;
|
|
30
|
+
if (typeof error === "string" && error.trim())
|
|
31
|
+
return error;
|
|
32
|
+
return fallback;
|
|
10
33
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export const ALLOWED_TYPES = ["feat", "fix", "chore", "refactor", "docs", "test", "perf", "build", "ci"];
|
|
2
|
+
export const ALLOWED_TYPES_SET = new Set(ALLOWED_TYPES);
|
|
3
|
+
const SUBJECT_REGEX = /^([a-z]+)(\([^)]+\))?!?:\s(.+)$/;
|
|
4
|
+
export function isAllowedType(value) {
|
|
5
|
+
return ALLOWED_TYPES_SET.has(value);
|
|
6
|
+
}
|
|
7
|
+
export function normalizeMessage(input) {
|
|
8
|
+
const lines = (input ?? "")
|
|
9
|
+
.replace(/\r\n?/g, "\n")
|
|
10
|
+
.split("\n")
|
|
11
|
+
.map((line) => line.replace(/[ \t]+$/g, ""));
|
|
12
|
+
while (lines.length > 0 && lines[0] === "")
|
|
13
|
+
lines.shift();
|
|
14
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
15
|
+
lines.pop();
|
|
16
|
+
return lines.join("\n");
|
|
17
|
+
}
|
|
18
|
+
function stripWrappingCodeFence(input) {
|
|
19
|
+
const trimmed = (input ?? "").trim();
|
|
20
|
+
const match = trimmed.match(/^```(?:json|txt|text)?\s*([\s\S]*?)\s*```$/i);
|
|
21
|
+
return match ? match[1].trim() : trimmed;
|
|
22
|
+
}
|
|
23
|
+
function parseMessageFromJson(input) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(input);
|
|
26
|
+
if (!parsed || typeof parsed !== "object")
|
|
27
|
+
return null;
|
|
28
|
+
if (!("message" in parsed))
|
|
29
|
+
return null;
|
|
30
|
+
const value = parsed.message;
|
|
31
|
+
return typeof value === "string" ? value : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseMessageFromEmbeddedJson(input) {
|
|
38
|
+
const start = input.indexOf("{");
|
|
39
|
+
const end = input.lastIndexOf("}");
|
|
40
|
+
if (start < 0 || end <= start)
|
|
41
|
+
return null;
|
|
42
|
+
const block = input.slice(start, end + 1);
|
|
43
|
+
return parseMessageFromJson(block);
|
|
44
|
+
}
|
|
45
|
+
export function extractMessageFromModelOutput(raw) {
|
|
46
|
+
const noFence = stripWrappingCodeFence(raw ?? "");
|
|
47
|
+
const jsonMessage = parseMessageFromJson(noFence) ?? parseMessageFromEmbeddedJson(noFence);
|
|
48
|
+
const candidate = jsonMessage ?? noFence;
|
|
49
|
+
return normalizeMessage(stripWrappingCodeFence(candidate));
|
|
50
|
+
}
|
|
51
|
+
export function validateMessage(message) {
|
|
52
|
+
const normalized = normalizeMessage(message);
|
|
53
|
+
if (!normalized)
|
|
54
|
+
return { ok: false, reason: "Message is empty" };
|
|
55
|
+
if (normalized.includes("```"))
|
|
56
|
+
return { ok: false, reason: "No markdown/code fences" };
|
|
57
|
+
const subject = normalized.split("\n")[0].trim();
|
|
58
|
+
if (!subject)
|
|
59
|
+
return { ok: false, reason: "Subject line is empty" };
|
|
60
|
+
if (subject.length > 72)
|
|
61
|
+
return { ok: false, reason: "Subject line > 72 chars" };
|
|
62
|
+
if (subject.endsWith("."))
|
|
63
|
+
return { ok: false, reason: "Subject should not end with a period" };
|
|
64
|
+
const conventional = subject.match(SUBJECT_REGEX);
|
|
65
|
+
if (!conventional)
|
|
66
|
+
return { ok: false, reason: "Not Conventional Commits format" };
|
|
67
|
+
const type = conventional[1];
|
|
68
|
+
if (!isAllowedType(type)) {
|
|
69
|
+
return { ok: false, reason: `Type must be one of: ${ALLOWED_TYPES.join(", ")}` };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
export function inferTypeFromDiff(diff) {
|
|
74
|
+
const filePattern = /^diff --git a\/(.+?) b\/(.+)$/gm;
|
|
75
|
+
const files = [];
|
|
76
|
+
for (const match of diff.matchAll(filePattern)) {
|
|
77
|
+
const file = match[2]?.trim();
|
|
78
|
+
if (file)
|
|
79
|
+
files.push(file.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
if (files.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
if (files.every(isDocumentationFile))
|
|
84
|
+
return "docs";
|
|
85
|
+
if (files.every(isTestFile))
|
|
86
|
+
return "test";
|
|
87
|
+
if (files.every(isCiFile))
|
|
88
|
+
return "ci";
|
|
89
|
+
if (files.every(isBuildFile))
|
|
90
|
+
return "build";
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function normalizeScope(scope) {
|
|
94
|
+
if (!scope)
|
|
95
|
+
return null;
|
|
96
|
+
const compact = scope.trim().replace(/\s+/g, "-").replace(/[()]/g, "");
|
|
97
|
+
return compact || null;
|
|
98
|
+
}
|
|
99
|
+
function parseTypedSubject(subject) {
|
|
100
|
+
const typed = subject.match(/^([A-Za-z]+)(?:\(([^)]+)\))?!?:\s*(.+)$/);
|
|
101
|
+
if (!typed)
|
|
102
|
+
return null;
|
|
103
|
+
return {
|
|
104
|
+
type: typed[1].toLowerCase(),
|
|
105
|
+
scope: typed[2] ? typed[2].trim() : null,
|
|
106
|
+
description: typed[3].trim()
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function parseLooseTypedSubject(subject) {
|
|
110
|
+
const loose = subject.match(/^([A-Za-z]+)\s*[-:]\s*(.+)$/);
|
|
111
|
+
if (!loose)
|
|
112
|
+
return null;
|
|
113
|
+
return {
|
|
114
|
+
type: loose[1].toLowerCase(),
|
|
115
|
+
description: loose[2].trim()
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function normalizeDescription(description) {
|
|
119
|
+
const cleaned = description
|
|
120
|
+
.trim()
|
|
121
|
+
.replace(/\.$/, "")
|
|
122
|
+
.replace(/\s+/g, " ");
|
|
123
|
+
return cleaned || "update project files";
|
|
124
|
+
}
|
|
125
|
+
export function repairMessage(options) {
|
|
126
|
+
const original = normalizeMessage(stripWrappingCodeFence(options.message));
|
|
127
|
+
if (!original)
|
|
128
|
+
return { message: original, didRepair: false };
|
|
129
|
+
const lines = original.split("\n");
|
|
130
|
+
const originalSubject = lines[0].trim().replace(/^["'`]+|["'`]+$/g, "").trim();
|
|
131
|
+
const body = normalizeMessage(lines.slice(1).join("\n"));
|
|
132
|
+
const bodyBlock = body ? `\n\n${body}` : "";
|
|
133
|
+
const forcedType = options.forcedType;
|
|
134
|
+
const normalizedScope = normalizeScope(options.scope);
|
|
135
|
+
const inferredType = forcedType ?? inferTypeFromDiff(options.diff);
|
|
136
|
+
let selectedType = null;
|
|
137
|
+
let selectedScope = null;
|
|
138
|
+
let description = originalSubject;
|
|
139
|
+
const typed = parseTypedSubject(originalSubject);
|
|
140
|
+
if (typed) {
|
|
141
|
+
if (isAllowedType(typed.type))
|
|
142
|
+
selectedType = typed.type;
|
|
143
|
+
selectedScope = typed.scope;
|
|
144
|
+
description = typed.description;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const loose = parseLooseTypedSubject(originalSubject);
|
|
148
|
+
if (loose && isAllowedType(loose.type)) {
|
|
149
|
+
selectedType = loose.type;
|
|
150
|
+
description = loose.description;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (forcedType)
|
|
154
|
+
selectedType = forcedType;
|
|
155
|
+
if (!selectedType && inferredType)
|
|
156
|
+
selectedType = inferredType;
|
|
157
|
+
if (normalizedScope)
|
|
158
|
+
selectedScope = normalizedScope;
|
|
159
|
+
description = normalizeDescription(description);
|
|
160
|
+
let repairedSubject;
|
|
161
|
+
if (selectedType) {
|
|
162
|
+
const scopePart = selectedScope ? `(${selectedScope})` : "";
|
|
163
|
+
repairedSubject = `${selectedType}${scopePart}: ${description}`;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
repairedSubject = description;
|
|
167
|
+
}
|
|
168
|
+
const repaired = normalizeMessage(`${repairedSubject}${bodyBlock}`);
|
|
169
|
+
return {
|
|
170
|
+
message: repaired,
|
|
171
|
+
didRepair: repaired !== original
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function isDocumentationFile(path) {
|
|
175
|
+
return path.startsWith("docs/")
|
|
176
|
+
|| path.endsWith(".md")
|
|
177
|
+
|| path.endsWith(".mdx")
|
|
178
|
+
|| path.endsWith(".rst")
|
|
179
|
+
|| path.includes("/docs/");
|
|
180
|
+
}
|
|
181
|
+
function isTestFile(path) {
|
|
182
|
+
return path.includes("/test/")
|
|
183
|
+
|| path.includes("/tests/")
|
|
184
|
+
|| path.includes("__tests__")
|
|
185
|
+
|| path.endsWith(".spec.ts")
|
|
186
|
+
|| path.endsWith(".test.ts")
|
|
187
|
+
|| path.endsWith(".spec.js")
|
|
188
|
+
|| path.endsWith(".test.js");
|
|
189
|
+
}
|
|
190
|
+
function isCiFile(path) {
|
|
191
|
+
return path.startsWith(".github/workflows/")
|
|
192
|
+
|| path.startsWith(".gitlab-ci")
|
|
193
|
+
|| path.startsWith(".circleci/")
|
|
194
|
+
|| path.startsWith("azure-pipelines");
|
|
195
|
+
}
|
|
196
|
+
function isBuildFile(path) {
|
|
197
|
+
return path === "package-lock.json"
|
|
198
|
+
|| path === "yarn.lock"
|
|
199
|
+
|| path === "pnpm-lock.yaml"
|
|
200
|
+
|| path === "package.json"
|
|
201
|
+
|| path.endsWith("/dockerfile")
|
|
202
|
+
|| path.endsWith("/docker-compose.yml");
|
|
203
|
+
}
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import { ExitCode, EXIT_CODE_LABEL } from "./exit-codes.js";
|
|
3
|
+
import { getStagedDiff, gitCommit, hasStagedChanges, isGitRepo } from "./git.js";
|
|
4
|
+
import { ensureLocalModel, ollamaChat, OllamaError } from "./ollama.js";
|
|
5
|
+
import { buildMessages } from "./prompt.js";
|
|
6
|
+
import { clampDiff, normalizeErrorMessage } from "./util.js";
|
|
7
|
+
import { extractMessageFromModelOutput, normalizeMessage, repairMessage, validateMessage } from "./validation.js";
|
|
8
|
+
class WorkflowError extends Error {
|
|
9
|
+
exitCode;
|
|
10
|
+
code;
|
|
11
|
+
hint;
|
|
12
|
+
constructor(exitCode, message, opts) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "WorkflowError";
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
this.code = opts?.code ?? EXIT_CODE_LABEL[exitCode];
|
|
17
|
+
this.hint = opts?.hint ?? null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function generateCandidate(diff, opts) {
|
|
21
|
+
const messages = buildMessages({
|
|
22
|
+
diff,
|
|
23
|
+
forcedType: opts.type,
|
|
24
|
+
scope: opts.scope
|
|
25
|
+
});
|
|
26
|
+
const raw = (await ollamaChat({
|
|
27
|
+
host: opts.host,
|
|
28
|
+
model: opts.model,
|
|
29
|
+
messages,
|
|
30
|
+
json: true,
|
|
31
|
+
timeoutMs: opts.timeoutMs,
|
|
32
|
+
retries: opts.retries
|
|
33
|
+
})).trim();
|
|
34
|
+
const extracted = extractMessageFromModelOutput(raw);
|
|
35
|
+
const repaired = repairMessage({
|
|
36
|
+
message: extracted,
|
|
37
|
+
diff,
|
|
38
|
+
forcedType: opts.type,
|
|
39
|
+
scope: opts.scope
|
|
40
|
+
});
|
|
41
|
+
const candidate = normalizeMessage(repaired.message);
|
|
42
|
+
return {
|
|
43
|
+
message: candidate,
|
|
44
|
+
source: repaired.didRepair ? "repaired" : "model",
|
|
45
|
+
validation: validateMessage(candidate)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function ensureValid(candidate, allowInvalid) {
|
|
49
|
+
if (candidate.validation.ok || allowInvalid)
|
|
50
|
+
return;
|
|
51
|
+
throw new WorkflowError(ExitCode.InvalidAiOutput, `AI output failed validation: ${candidate.validation.reason}`, {
|
|
52
|
+
hint: "Regenerate/edit the message or pass --allow-invalid to override."
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function toErrorResult(error) {
|
|
56
|
+
if (error instanceof WorkflowError) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
exitCode: error.exitCode,
|
|
60
|
+
code: error.code,
|
|
61
|
+
message: error.message,
|
|
62
|
+
hint: error.hint
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof OllamaError) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
exitCode: ExitCode.OllamaError,
|
|
69
|
+
code: EXIT_CODE_LABEL[ExitCode.OllamaError],
|
|
70
|
+
message: error.message,
|
|
71
|
+
hint: error.hint
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
exitCode: ExitCode.InternalError,
|
|
77
|
+
code: EXIT_CODE_LABEL[ExitCode.InternalError],
|
|
78
|
+
message: normalizeErrorMessage(error, "Unexpected internal error."),
|
|
79
|
+
hint: null
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function commitMessage(message, noVerify) {
|
|
83
|
+
try {
|
|
84
|
+
await gitCommit(message, { noVerify });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new WorkflowError(ExitCode.GitCommitError, normalizeErrorMessage(error, "git commit failed."), { hint: "Resolve git hook or repository errors, then retry." });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function runNonInteractive(diff, opts) {
|
|
91
|
+
const candidate = await generateCandidate(diff, opts);
|
|
92
|
+
ensureValid(candidate, opts.allowInvalid);
|
|
93
|
+
if (opts.dryRun) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
exitCode: ExitCode.Success,
|
|
97
|
+
message: candidate.message,
|
|
98
|
+
source: candidate.source,
|
|
99
|
+
committed: false,
|
|
100
|
+
cancelled: false
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
await commitMessage(candidate.message, opts.noVerify);
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
exitCode: ExitCode.Success,
|
|
107
|
+
message: candidate.message,
|
|
108
|
+
source: candidate.source,
|
|
109
|
+
committed: true,
|
|
110
|
+
cancelled: false
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function runInteractive(diff, opts) {
|
|
114
|
+
while (true) {
|
|
115
|
+
process.stdout.write("Generating commit message... ");
|
|
116
|
+
const candidate = await generateCandidate(diff, opts);
|
|
117
|
+
process.stdout.write("Done.\n");
|
|
118
|
+
if (!candidate.validation.ok) {
|
|
119
|
+
console.warn(`AI output validation failed: ${candidate.validation.reason}`);
|
|
120
|
+
}
|
|
121
|
+
console.log("\n--- Suggested commit message ---\n");
|
|
122
|
+
console.log(candidate.message);
|
|
123
|
+
console.log("\n-------------------------------\n");
|
|
124
|
+
const response = await prompts({
|
|
125
|
+
type: "select",
|
|
126
|
+
name: "action",
|
|
127
|
+
message: "What next?",
|
|
128
|
+
choices: [
|
|
129
|
+
{ title: "Accept and commit", value: "accept" },
|
|
130
|
+
{ title: "Edit", value: "edit" },
|
|
131
|
+
{ title: "Regenerate", value: "regen" },
|
|
132
|
+
{ title: "Dry-run (print only)", value: "dry" },
|
|
133
|
+
{ title: "Cancel", value: "cancel" }
|
|
134
|
+
],
|
|
135
|
+
initial: 0
|
|
136
|
+
});
|
|
137
|
+
const action = response.action;
|
|
138
|
+
if (!action || action === "cancel") {
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
exitCode: ExitCode.Success,
|
|
142
|
+
message: candidate.message,
|
|
143
|
+
source: candidate.source,
|
|
144
|
+
committed: false,
|
|
145
|
+
cancelled: true
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (action === "regen")
|
|
149
|
+
continue;
|
|
150
|
+
if (action === "dry") {
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
exitCode: ExitCode.Success,
|
|
154
|
+
message: candidate.message,
|
|
155
|
+
source: candidate.source,
|
|
156
|
+
committed: false,
|
|
157
|
+
cancelled: false
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
let finalMessage = candidate.message;
|
|
161
|
+
if (action === "edit") {
|
|
162
|
+
const edited = await prompts({
|
|
163
|
+
type: "text",
|
|
164
|
+
name: "message",
|
|
165
|
+
message: "Edit commit message",
|
|
166
|
+
initial: finalMessage
|
|
167
|
+
});
|
|
168
|
+
const editedMessage = edited.message;
|
|
169
|
+
if (!editedMessage) {
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
exitCode: ExitCode.Success,
|
|
173
|
+
message: candidate.message,
|
|
174
|
+
source: candidate.source,
|
|
175
|
+
committed: false,
|
|
176
|
+
cancelled: true
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
finalMessage = normalizeMessage(editedMessage);
|
|
180
|
+
}
|
|
181
|
+
const validation = validateMessage(finalMessage);
|
|
182
|
+
if (!validation.ok && !opts.allowInvalid) {
|
|
183
|
+
console.error(`Cannot commit invalid message: ${validation.reason}`);
|
|
184
|
+
console.error("Use Edit/Regenerate, or rerun with --allow-invalid to override.");
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
await commitMessage(finalMessage, opts.noVerify);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
exitCode: ExitCode.Success,
|
|
191
|
+
message: finalMessage,
|
|
192
|
+
source: finalMessage === candidate.message ? candidate.source : "repaired",
|
|
193
|
+
committed: true,
|
|
194
|
+
cancelled: false
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function runWorkflow(opts) {
|
|
199
|
+
try {
|
|
200
|
+
if (!(await isGitRepo())) {
|
|
201
|
+
throw new WorkflowError(ExitCode.GitContextError, "Not a git repository.");
|
|
202
|
+
}
|
|
203
|
+
if (!(await hasStagedChanges())) {
|
|
204
|
+
throw new WorkflowError(ExitCode.GitContextError, "No staged changes. Stage files first: git add <files>.");
|
|
205
|
+
}
|
|
206
|
+
await ensureLocalModel(opts.host, opts.model, Math.min(opts.timeoutMs, 10000));
|
|
207
|
+
const stagedDiff = await getStagedDiff();
|
|
208
|
+
const diff = clampDiff(stagedDiff, opts.maxChars);
|
|
209
|
+
if (opts.ci || opts.dryRun) {
|
|
210
|
+
return await runNonInteractive(diff, opts);
|
|
211
|
+
}
|
|
212
|
+
return await runInteractive(diff, opts);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
return toErrorResult(error);
|
|
216
|
+
}
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,33 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-message-ai-commit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "AI git commit messages using local Ollama",
|
|
5
|
-
"main": "
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "vitest run --coverage",
|
|
8
|
+
"test:unit": "vitest run tests/unit",
|
|
9
|
+
"test:integration": "vitest run tests/integration",
|
|
10
|
+
"test:e2e": "vitest run tests/e2e",
|
|
8
11
|
"build": "tsc",
|
|
9
12
|
"dev": "tsx src/cli.ts",
|
|
10
|
-
"
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint": "eslint . --max-warnings 0",
|
|
15
|
+
"check": "npm run lint && npm run typecheck && npm run test",
|
|
11
16
|
"prepare": "npm run build"
|
|
12
17
|
},
|
|
13
18
|
"keywords": [],
|
|
14
19
|
"author": "Denis Tola",
|
|
15
20
|
"license": "ISC",
|
|
16
21
|
"type": "module",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
17
29
|
"dependencies": {
|
|
18
|
-
"-": "^0.0.1",
|
|
19
30
|
"commander": "^14.0.2",
|
|
20
31
|
"execa": "^9.6.1",
|
|
21
32
|
"prompts": "^2.4.2"
|
|
22
33
|
},
|
|
23
34
|
"devDependencies": {
|
|
24
35
|
"@eslint/js": "^9.39.2",
|
|
36
|
+
"@vitest/coverage-v8": "^4.0.7",
|
|
25
37
|
"@types/node": "^25.0.8",
|
|
26
38
|
"@types/prompts": "^2.4.9",
|
|
27
39
|
"eslint": "^9.39.2",
|
|
28
40
|
"tsx": "^4.21.0",
|
|
29
41
|
"typescript": "^5.9.3",
|
|
30
|
-
"typescript-eslint": "^8.53.0"
|
|
42
|
+
"typescript-eslint": "^8.53.0",
|
|
43
|
+
"vitest": "^4.0.7"
|
|
31
44
|
},
|
|
32
45
|
"bin": {
|
|
33
46
|
"git-ai-commit": "dist/cli.js"
|
package/eslint.config.mjs
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import js from "@eslint/js";
|
|
2
|
-
import tseslint from "typescript-eslint";
|
|
3
|
-
|
|
4
|
-
export default tseslint.config(
|
|
5
|
-
{ ignores: ["**/dist/**", "dist/", "node_modules/"] },
|
|
6
|
-
js.configs.recommended,
|
|
7
|
-
...tseslint.configs.recommended,
|
|
8
|
-
{
|
|
9
|
-
rules: {
|
|
10
|
-
"@typescript-eslint/no-explicit-any": "warn",
|
|
11
|
-
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }]
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
);
|
|
Binary file
|