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 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
- npm install
15
- npm run build
16
- npm link
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 prompts from "prompts";
4
- import { isGitRepo, hasStagedChanges, getStagedDiff, gitCommit } from "./git.js";
5
- import { ollamaChat, checkOllamaConnection } from "./ollama.js";
6
- import { buildMessages } from "./prompt.js";
7
- import { clampDiff } from "./util.js";
8
- const ALLOWED_TYPES = new Set(["feat", "fix", "chore", "refactor", "docs", "test", "perf", "build", "ci"]);
9
- function validateMessage(msg) {
10
- const s = (msg ?? "").trim();
11
- if (!s)
12
- return { ok: false, reason: "Message is empty" };
13
- if (s.length > 72)
14
- return { ok: false, reason: "Message is too long" };
15
- if (s.includes("```"))
16
- return { ok: false, reason: "No markdown/code fences" };
17
- const firtstLine = s.split("\n")[0].trim();
18
- const cc = /^([a-z]+)(\([^)]+\))?!?:\s.+$/;
19
- if (!cc.test(firtstLine))
20
- return { ok: false, reason: "Not Conventional Commits format" };
21
- const type = firtstLine.match(/^([a-z]+)\b/)?.[1];
22
- if (!type || !ALLOWED_TYPES.has(type)) {
23
- return { ok: false, reason: `Type must be one of: ${[...ALLOWED_TYPES].join(", ")}` };
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
- if (firtstLine.length > 72)
26
- return { ok: false, reason: "Subject line > 72 chars" };
27
- if (firtstLine.endsWith("."))
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>", "Max diff characters sent to model", (v) => parseInt(v, 10), 16000)
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
- const opts = program.opts();
47
- if (!(await isGitRepo())) {
48
- console.error("Not a git repository.");
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
- // Check Ollama connection first
56
- const isUp = await checkOllamaConnection(opts.host);
57
- if (!isUp) {
58
- console.error(`Cannot reach Ollama at ${opts.host}. Is it running?`);
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
- if (opts.type && !ALLOWED_TYPES.has(opts.type)) {
63
- console.error(`Invalid --type. Must be one of: ${[...ALLOWED_TYPES].join(", ")}`);
64
- process.exit(1);
118
+ const result = await runWorkflow(options);
119
+ if (options.output === "json") {
120
+ printJson(result);
65
121
  }
66
- const stagedDiff = await getStagedDiff();
67
- const diff = clampDiff(stagedDiff, opts.maxChars);
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((err) => {
163
- console.error(err instanceof Error ? err.stack : String(err));
164
- process.exit(1);
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 (e) {
18
- if (typeof e?.exitCode === "number" && e.exitCode === 1)
25
+ catch (error) {
26
+ if (getExitCode(error) === 1)
19
27
  return true;
20
- throw e;
28
+ throw error;
21
29
  }
22
30
  }
23
31
  export async function getStagedDiff() {
package/dist/ollama.js CHANGED
@@ -1,49 +1,165 @@
1
- export async function checkOllamaConnection(host) {
2
- try {
3
- const url = `${host.replace(/\/$/, "")}/api/tags`; // "tags" endpoint is lightweight
4
- const controller = new AbortController();
5
- const id = setTimeout(() => controller.abort(), 2000); // 2s timeout for check
6
- const res = await fetch(url, { signal: controller.signal });
7
- clearTimeout(id);
8
- return res.ok;
9
- }
10
- catch {
11
- return false;
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
- export async function ollamaChat(opts) {
15
- const url = `${opts.host.replace(/\/$/, "")}/api/chat`;
16
- // Default 60s timeout for generation
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(), 60000);
20
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
19
21
  try {
20
- const body = {
21
- model: opts.model,
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 (e) {
41
- if (e.name === "AbortError") {
42
- throw new Error("Ollama request timed out (60s).");
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
- throw e;
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": "commit message string" }
5
- - NO markdown, NO commentary. JUST JSON.
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
- Output the commit message only. Do not repeat the task description.`;
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 s = diff ?? "";
3
- if (s.length <= maxChars)
4
- return s;
5
- const head = Math.floor(maxChars * 0.7);
6
- const tail = maxChars - head - 200;
7
- return (s.slice(0, head) +
8
- "\n\n--- DIFF TRUNCATED ---\n\n" +
9
- s.slice(Math.max(0, s.length - tail)));
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
+ }
@@ -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": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "AI git commit messages using local Ollama",
5
- "main": "index.js",
5
+ "main": "dist/cli.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1",
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
- "lint": "eslint .",
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