git-message-ai-commit 1.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 ADDED
@@ -0,0 +1,39 @@
1
+ # Git Message AI Commit
2
+
3
+ A CLI tool that uses a local [Ollama](https://ollama.com/) instance to generate Conventional Commits messages from your staged changes.
4
+
5
+ ## Prerequisites
6
+
7
+ - [Node.js](https://nodejs.org/) (v18+)
8
+ - [Ollama](https://ollama.com/) running locally (default: `http://localhost:11434`)
9
+ - `git`
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install
15
+ npm run build
16
+ npm link
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Stage your changes:
22
+
23
+ ```bash
24
+ git add .
25
+ ```
26
+
27
+ Run the tool:
28
+
29
+ ```bash
30
+ git-ai-commit
31
+ ```
32
+
33
+ ### Options
34
+
35
+ - `-m, --model <name>`: Specify Ollama model (default: `llama3`)
36
+ - `--host <url>`: Ollama host (default: `http://localhost:11434`)
37
+ - `--max-chars <n>`: Max diff characters sent (default: `16000`)
38
+ - `--type <type>`: Force a commit type (feat, fix, etc.)
39
+ - `--dry-run`: Print message to stdout without committing
package/dist/cli.js ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
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(", ")}` };
24
+ }
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 };
30
+ }
31
+ async function main() {
32
+ // ... (keep program definition)
33
+ const program = new Command();
34
+ program
35
+ .name("git-ai-commit")
36
+ // ... (keep options)
37
+ .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)
41
+ .option("--type <type>", "Force commit type (feat|fix|chore|refactor|docs|test|perf|build|ci)")
42
+ .option("--scope <scope>", "Optional scope, e.g. api, infra")
43
+ .option("--dry-run", "Print message only, do not commit", false)
44
+ .option("--no-verify", "Pass --no-verify to git commit", false)
45
+ .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);
54
+ }
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);
61
+ }
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);
65
+ }
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);
160
+ }
161
+ }
162
+ main().catch((err) => {
163
+ console.error(err instanceof Error ? err.stack : String(err));
164
+ process.exit(1);
165
+ });
package/dist/git.js ADDED
@@ -0,0 +1,32 @@
1
+ import { execa } from "execa";
2
+ export async function isGitRepo() {
3
+ try {
4
+ await execa("git", ["rev-parse", "--is-inside-work-tree"]);
5
+ return true;
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ export async function hasStagedChanges() {
12
+ // exit code 0 => no diff, 1 => diff exists
13
+ try {
14
+ await execa("git", ["diff", "--staged", "--quiet"]);
15
+ return false;
16
+ }
17
+ catch (e) {
18
+ if (typeof e?.exitCode === "number" && e.exitCode === 1)
19
+ return true;
20
+ throw e;
21
+ }
22
+ }
23
+ export async function getStagedDiff() {
24
+ const { stdout } = await execa("git", ["diff", "--staged", "--"]);
25
+ return stdout ?? "";
26
+ }
27
+ export async function gitCommit(message, opts = {}) {
28
+ const args = ["commit", "-m", message];
29
+ if (opts.noVerify)
30
+ args.push("--no-verify");
31
+ await execa("git", args, { stdio: "inherit" });
32
+ }
package/dist/ollama.js ADDED
@@ -0,0 +1,49 @@
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;
12
+ }
13
+ }
14
+ export async function ollamaChat(opts) {
15
+ const url = `${opts.host.replace(/\/$/, "")}/api/chat`;
16
+ // Default 60s timeout for generation
17
+ const controller = new AbortController();
18
+ const timeoutId = setTimeout(() => controller.abort(), 60000);
19
+ 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),
31
+ signal: controller.signal
32
+ });
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
+ }
40
+ catch (e) {
41
+ if (e.name === "AbortError") {
42
+ throw new Error("Ollama request timed out (60s).");
43
+ }
44
+ throw e;
45
+ }
46
+ finally {
47
+ clearTimeout(timeoutId);
48
+ }
49
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,33 @@
1
+ const SYSTEM = `You write excellent git commit messages.
2
+
3
+ Rules:
4
+ - Output valid JSON object: { "message": "commit message string" }
5
+ - NO markdown, NO commentary. JUST JSON.
6
+ - Use Conventional Commits: type(scope optional): subject
7
+ - Allowed types: feat, fix, chore, refactor, docs, test, perf, build, ci
8
+ - Subject line: <= 72 chars, imperative mood, present tense, NO trailing period.
9
+ - If really useful, add a blank line and a short body explaining "what" and "why".
10
+ - Never mention that you are an AI.
11
+ - Keep it short and simple and dont be chatty.
12
+ - Do not use "!" (breaking change) unless the diff contains significant breaking changes.`;
13
+ export function buildMessages(opts) {
14
+ const constraints = [
15
+ opts.forcedType ? `Forced type: ${opts.forcedType}` : "Choose the best type from allowed list.",
16
+ opts.scope ? `Use scope: ${opts.scope}` : "Use a scope only if it helps; otherwise omit."
17
+ ].join("\n");
18
+ const user = `Task: Generate a specific and concise Conventional Commit message for the following git diff.
19
+
20
+ Constraints:
21
+ ${constraints}
22
+
23
+ Input Data:
24
+ --- BEGIN DIFF ---
25
+ ${opts.diff}
26
+ --- END DIFF ---
27
+
28
+ Output the commit message only. Do not repeat the task description.`;
29
+ return [
30
+ { role: "system", content: SYSTEM },
31
+ { role: "user", content: user }
32
+ ];
33
+ }
package/dist/util.js ADDED
@@ -0,0 +1,10 @@
1
+ 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)));
10
+ }
@@ -0,0 +1,14 @@
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
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "git-message-ai-commit",
3
+ "version": "1.0.0",
4
+ "description": "AI git commit messages using local Ollama",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "build": "tsc",
9
+ "dev": "tsx src/cli.ts",
10
+ "lint": "eslint .",
11
+ "prepare": "npm run build"
12
+ },
13
+ "keywords": [],
14
+ "author": "Denis Tola",
15
+ "license": "ISC",
16
+ "type": "module",
17
+ "dependencies": {
18
+ "-": "^0.0.1",
19
+ "commander": "^14.0.2",
20
+ "execa": "^9.6.1",
21
+ "prompts": "^2.4.2"
22
+ },
23
+ "devDependencies": {
24
+ "@eslint/js": "^9.39.2",
25
+ "@types/node": "^25.0.8",
26
+ "@types/prompts": "^2.4.9",
27
+ "eslint": "^9.39.2",
28
+ "tsx": "^4.21.0",
29
+ "typescript": "^5.9.3",
30
+ "typescript-eslint": "^8.53.0"
31
+ },
32
+ "bin": {
33
+ "git-ai-commit": "dist/cli.js"
34
+ }
35
+ }