commitmate 1.0.0__py3-none-any.whl
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.
- commitmate/__init__.py +0 -0
- commitmate/cli.py +129 -0
- commitmate/exceptions.py +47 -0
- commitmate/git_utils.py +90 -0
- commitmate/message_gen.py +136 -0
- commitmate/ollama_client.py +31 -0
- commitmate-1.0.0.dist-info/METADATA +113 -0
- commitmate-1.0.0.dist-info/RECORD +10 -0
- commitmate-1.0.0.dist-info/WHEEL +4 -0
- commitmate-1.0.0.dist-info/entry_points.txt +2 -0
commitmate/__init__.py
ADDED
|
File without changes
|
commitmate/cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from commitmate.git_utils import get_staged_diff, get_staged_files, git_commit
|
|
2
|
+
from commitmate.message_gen import build_prompt, clean_response, parse_model_response, assemble_commit_message
|
|
3
|
+
from commitmate.ollama_client import generate_commit_message
|
|
4
|
+
from commitmate.exceptions import CommitMateError, InvalidModelResponseError
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.prompt import Prompt
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
import subprocess
|
|
11
|
+
import shlex
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
MAX_ATTEMPTS = 3
|
|
15
|
+
DEFAULT_MODEL = "llama3"
|
|
16
|
+
|
|
17
|
+
def edit_in_editor(initial_text: str) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Opens the user's $EDITOR with initial_text pre-filled in a temp file,
|
|
20
|
+
waits for them to edit and close it, then returns the saved content.
|
|
21
|
+
Falls back to nano (Mac/Linux) or notepad (Windows) if $EDITOR isn't set.
|
|
22
|
+
"""
|
|
23
|
+
editor_cmd = os.environ.get("EDITOR", "notepad" if os.name == "nt" else "nano")
|
|
24
|
+
|
|
25
|
+
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as tf:
|
|
26
|
+
tf.write(initial_text)
|
|
27
|
+
temp_path = tf.name
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
subprocess.run(shlex.split(editor_cmd) + [temp_path])
|
|
31
|
+
with open(temp_path, "r") as tf:
|
|
32
|
+
edited = tf.read()
|
|
33
|
+
finally:
|
|
34
|
+
os.remove(temp_path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
return edited.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_valid_commit_message(prompt: str, console: Console, model: str) -> dict:
|
|
42
|
+
"""
|
|
43
|
+
Calls Ollama and validates the response, retrying up to MAX_ATTEMPTS times
|
|
44
|
+
if the model returns something that fails validation.
|
|
45
|
+
"""
|
|
46
|
+
last_error = None
|
|
47
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
48
|
+
with console.status(f"[bold cyan]Generating commit message with {model} (attempt {attempt}/{MAX_ATTEMPTS})..."):
|
|
49
|
+
raw = generate_commit_message(prompt, model)
|
|
50
|
+
try:
|
|
51
|
+
cleaned = clean_response(raw)
|
|
52
|
+
parsed = parse_model_response(cleaned)
|
|
53
|
+
return parsed
|
|
54
|
+
except InvalidModelResponseError as e:
|
|
55
|
+
last_error = e
|
|
56
|
+
continue
|
|
57
|
+
raise last_error
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_args():
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="commitmate",
|
|
64
|
+
description="Generate a Conventional Commits-style message from staged git changes using a local Ollama model."
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--model",
|
|
68
|
+
default=DEFAULT_MODEL,
|
|
69
|
+
help=f"Ollama model to use (default: {DEFAULT_MODEL}). Example: qwen2.5-coder:7b"
|
|
70
|
+
)
|
|
71
|
+
return parser.parse_args()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main():
|
|
76
|
+
console = Console()
|
|
77
|
+
args = parse_args()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
codes_changed = get_staged_diff()
|
|
81
|
+
files_changed = get_staged_files()
|
|
82
|
+
prompt = build_prompt(codes_changed, files_changed)
|
|
83
|
+
parsed = generate_valid_commit_message(prompt, console, model=args.model)
|
|
84
|
+
commit_message = assemble_commit_message(parsed)
|
|
85
|
+
|
|
86
|
+
except InvalidModelResponseError:
|
|
87
|
+
console.print(
|
|
88
|
+
"[bold red]Error:[/bold red] The model couldn't produce a well-formatted "
|
|
89
|
+
"commit message after several attempts."
|
|
90
|
+
)
|
|
91
|
+
console.print(
|
|
92
|
+
"[yellow]Tip: this often happens with diffs spanning many unrelated files. "
|
|
93
|
+
"Try staging fewer files at a time.[/yellow]"
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
except CommitMateError as e:
|
|
97
|
+
console.print(f"[bold red]Error:[/bold red] {escape(str(e))}")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
while True:
|
|
101
|
+
console.print("\n[bold]Generated commit message:[/bold]")
|
|
102
|
+
|
|
103
|
+
console.print(f"[cyan]{escape(commit_message)}[/cyan]")
|
|
104
|
+
|
|
105
|
+
choice = Prompt.ask(
|
|
106
|
+
"Select an action",
|
|
107
|
+
choices=["accept", "edit", "cancel"],
|
|
108
|
+
default="accept"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if choice == "accept":
|
|
112
|
+
try:
|
|
113
|
+
git_commit(commit_message)
|
|
114
|
+
console.print("[bold green]Committed.[/bold green]")
|
|
115
|
+
except CommitMateError as e:
|
|
116
|
+
console.print(f"[bold red]Commit failed:[/bold red] {escape(str(e))}")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
elif choice == "cancel":
|
|
120
|
+
console.print("[bold red]Cancelled. No commit made.[/bold red]")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
elif choice == "edit":
|
|
124
|
+
console.print("[dim]Opening editor — save and close the file/tab when you're done.[/dim]")
|
|
125
|
+
commit_message = edit_in_editor(commit_message)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
commitmate/exceptions.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
class CommitMateError(Exception):
|
|
2
|
+
"""Base exception for all CommitMate-specific errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NotAGitRepositoryError(CommitMateError):
|
|
7
|
+
"""Raised when the current directory is not inside a git repository."""
|
|
8
|
+
def __init__(self, message="This command must be run inside a valid Git repository."):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoStagedChangesError(CommitMateError):
|
|
13
|
+
"""Raised when there are no staged changes to generate a commit message from."""
|
|
14
|
+
def __init__(self, message="No staged changes found. Stage your changes with 'git add' first."):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitCommandError(CommitMateError):
|
|
19
|
+
"""Raised when a git command fails for a reason other than 'not a repo'.
|
|
20
|
+
No default message — the caller must supply the actual git stderr output,
|
|
21
|
+
since the failure reason varies every time."""
|
|
22
|
+
def __init__(self, message):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GitNotInstalledError(CommitMateError):
|
|
27
|
+
"""Raised when the git executable can't be found on PATH."""
|
|
28
|
+
def __init__(self, message="Git is not installed or not found on PATH."):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Ollama Errors
|
|
34
|
+
class OllamaConnectionError(CommitMateError):
|
|
35
|
+
""" Raised when the Ollama is not running at all"""
|
|
36
|
+
def __init__(self, message="Ollama is not running"):
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
|
|
39
|
+
class OllamaTimeoutError(CommitMateError):
|
|
40
|
+
""" Raised when the Ollama connection timed out"""
|
|
41
|
+
def __init__(self, message="Connection timed out, Ollama taking too long to respond"):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
|
|
44
|
+
class InvalidModelResponseError(CommitMateError):
|
|
45
|
+
"""Raised when the model's response isn't valid JSON or is missing required fields."""
|
|
46
|
+
def __init__(self, message):
|
|
47
|
+
super().__init__(message)
|
commitmate/git_utils.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from commitmate.exceptions import (
|
|
3
|
+
GitNotInstalledError,
|
|
4
|
+
NotAGitRepositoryError,
|
|
5
|
+
GitCommandError,
|
|
6
|
+
NoStagedChangesError
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_in_git_repo():
|
|
11
|
+
"""
|
|
12
|
+
Uses `git rev-parse --is-inside-work-tree` to reliably check whether the
|
|
13
|
+
current directory is inside a git repository. This is more robust than
|
|
14
|
+
parsing `git diff`'s error text, which can vary across git versions.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
subprocess.run(
|
|
18
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
check=True
|
|
22
|
+
)
|
|
23
|
+
except subprocess.CalledProcessError as e:
|
|
24
|
+
raise NotAGitRepositoryError() from e
|
|
25
|
+
except FileNotFoundError as e:
|
|
26
|
+
raise GitNotInstalledError() from e
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_staged_diff():
|
|
31
|
+
"""
|
|
32
|
+
Runs `git diff --staged` and returns the diff as a string.
|
|
33
|
+
Raises NoStagedChangesError if there's nothing staged.
|
|
34
|
+
"""
|
|
35
|
+
_ensure_in_git_repo()
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
['git', 'diff', '--staged'],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
check=True
|
|
42
|
+
)
|
|
43
|
+
except subprocess.CalledProcessError as e:
|
|
44
|
+
if "not a git repository" in e.stderr.strip().casefold():
|
|
45
|
+
raise NotAGitRepositoryError() from e
|
|
46
|
+
else:
|
|
47
|
+
raise GitCommandError(e.stderr.strip()) from e
|
|
48
|
+
except FileNotFoundError as e:
|
|
49
|
+
raise GitNotInstalledError() from e
|
|
50
|
+
|
|
51
|
+
if not result.stdout.strip():
|
|
52
|
+
raise NoStagedChangesError()
|
|
53
|
+
|
|
54
|
+
return result.stdout
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_staged_files():
|
|
58
|
+
"""
|
|
59
|
+
Runs `git diff --staged --name-only` and returns a list of changed file paths.
|
|
60
|
+
"""
|
|
61
|
+
_ensure_in_git_repo()
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
['git', 'diff', '--staged', '--name-only'],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
check=True
|
|
68
|
+
)
|
|
69
|
+
except subprocess.CalledProcessError as e:
|
|
70
|
+
if "not a git repository" in e.stderr.strip().casefold():
|
|
71
|
+
raise NotAGitRepositoryError() from e
|
|
72
|
+
else:
|
|
73
|
+
raise GitCommandError(e.stderr.strip()) from e
|
|
74
|
+
except FileNotFoundError as e:
|
|
75
|
+
raise GitNotInstalledError() from e
|
|
76
|
+
|
|
77
|
+
return result.stdout.strip().splitlines()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def git_commit(message: str):
|
|
81
|
+
"""Runs 'git commit -m ...' with the approved message."""
|
|
82
|
+
try:
|
|
83
|
+
subprocess.run(
|
|
84
|
+
["git", "commit", "-m", message],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text= True,
|
|
87
|
+
check=True
|
|
88
|
+
)
|
|
89
|
+
except subprocess.CalledProcessError as e:
|
|
90
|
+
raise GitCommandError(e.stderr.strip()) from e
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from commitmate.exceptions import InvalidModelResponseError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
COMMIT_TYPES = ["feat", "fix", "docs", "refactor", "test", "chore", "style", "perf"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_prompt(diff: str, files: list[str]) -> str:
|
|
9
|
+
"""Constructs the full prompt sent to Ollama for commit message generation."""
|
|
10
|
+
|
|
11
|
+
file_list = "\n".join(f"- {f}" for f in files)
|
|
12
|
+
types_list = ", ".join(COMMIT_TYPES)
|
|
13
|
+
|
|
14
|
+
return f"""You are an expert software engineer writing a Conventional Git Commit message.
|
|
15
|
+
|
|
16
|
+
Your task is to analyze the staged changes below and output a single JSON object.
|
|
17
|
+
|
|
18
|
+
CRITICAL RULES:
|
|
19
|
+
1. Output ONLY valid JSON. Do not include markdown code blocks (```), introductory text, or explanations.
|
|
20
|
+
2. "reasoning": briefly note, in one short sentence, what the key change is across all files. This is scratch space to help you think before answering, keep it under 15 words.
|
|
21
|
+
3. "type": must be exactly one of {types_list}.
|
|
22
|
+
4. "scope": a short noun for the changed module. Leave empty ("") if broad.
|
|
23
|
+
5. "description": MUST be a short, descriptive phrase starting with an imperative verb (e.g., "add support for user login", NOT just a single word like "add" or "update").
|
|
24
|
+
6. "description": MUST NOT have trailing punctuation (no periods or semicolons at the end). MUST be 5-9 words long, this is a hard limit, not a suggestion.
|
|
25
|
+
7. "body": 1-3 short bullet points explaining WHY the change was made, or an empty string ("").
|
|
26
|
+
|
|
27
|
+
Respond with ONLY the JSON object, with keys in this exact order: reasoning, type, scope, description, body. No markdown fences, no explanation, no text outside the JSON.
|
|
28
|
+
|
|
29
|
+
[EXAMPLES]
|
|
30
|
+
Input changes: Added support for fetching from tavily and startup search.
|
|
31
|
+
Output:
|
|
32
|
+
{{
|
|
33
|
+
"reasoning": "added tavily search integration and startup-specific query support",
|
|
34
|
+
"type": "feat",
|
|
35
|
+
"scope": "web_search",
|
|
36
|
+
"description": "add tavily search and startup query support",
|
|
37
|
+
"body": "- integrate tavily API for expanded data sources\\n- enable targeted startup queries"
|
|
38
|
+
}}
|
|
39
|
+
|
|
40
|
+
Input changes: fixed the api timeout crash
|
|
41
|
+
Output:
|
|
42
|
+
{{
|
|
43
|
+
"reasoning": "client was crashing on slow api responses due to no timeout handling",
|
|
44
|
+
"type": "fix",
|
|
45
|
+
"scope": "api",
|
|
46
|
+
"description": "resolve timeout crash in client module",
|
|
47
|
+
"body": ""
|
|
48
|
+
}}
|
|
49
|
+
[END OF EXAMPLES]
|
|
50
|
+
Before writing your answer, briefly consider each changed file listed above individually.
|
|
51
|
+
CHANGED FILES:
|
|
52
|
+
{file_list}
|
|
53
|
+
|
|
54
|
+
--- DIFF ---
|
|
55
|
+
{diff}
|
|
56
|
+
--- END DIFF ---
|
|
57
|
+
""".strip()
|
|
58
|
+
|
|
59
|
+
def clean_response(raw: str) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Strips markdown code fences that Ollama sometimes adds even with format=json.
|
|
62
|
+
"""
|
|
63
|
+
text = raw.strip()
|
|
64
|
+
lines = text.splitlines()
|
|
65
|
+
|
|
66
|
+
if lines and lines[0].strip().startswith("```"):
|
|
67
|
+
lines = lines[1:]
|
|
68
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
69
|
+
lines = lines[:-1]
|
|
70
|
+
|
|
71
|
+
return "\n".join(lines).strip()
|
|
72
|
+
|
|
73
|
+
def parse_model_response(cleaned: str) -> dict:
|
|
74
|
+
"""
|
|
75
|
+
Parses the cleaned JSON string into a dict and validates required fields.
|
|
76
|
+
Raises InvalidModelResponseError if parsing fails, fields are missing,
|
|
77
|
+
or fields are the wrong type.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(cleaned)
|
|
81
|
+
except json.JSONDecodeError as e:
|
|
82
|
+
raise InvalidModelResponseError(f"Model did not return valid JSON: {e}") from e
|
|
83
|
+
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise InvalidModelResponseError(f"Model response must be a JSON object, got {type(data).__name__}")
|
|
86
|
+
|
|
87
|
+
if "type" not in data or "description" not in data:
|
|
88
|
+
raise InvalidModelResponseError(
|
|
89
|
+
f"Model response missing required fields 'type' or 'description': {data}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if data["type"] not in COMMIT_TYPES:
|
|
93
|
+
raise InvalidModelResponseError(
|
|
94
|
+
f"Model returned invalid commit type '{data['type']}'. Expected one of {COMMIT_TYPES}."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not isinstance(data["description"], str):
|
|
98
|
+
raise InvalidModelResponseError(
|
|
99
|
+
f"'description' must be a string, got {type(data['description']).__name__}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if len(data["description"].split()) < 3:
|
|
103
|
+
raise InvalidModelResponseError(
|
|
104
|
+
f"Description too short (must be at least 3 words): '{data['description']}'"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if "scope" in data and not isinstance(data["scope"], str):
|
|
108
|
+
raise InvalidModelResponseError(
|
|
109
|
+
f"'scope' must be a string, got {type(data['scope']).__name__}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if isinstance(data.get("body"), list):
|
|
113
|
+
data["body"] = "\n".join(str(item) for item in data["body"])
|
|
114
|
+
elif "body" in data and not isinstance(data["body"], str):
|
|
115
|
+
raise InvalidModelResponseError(
|
|
116
|
+
f"'body' must be a string or list, got {type(data['body']).__name__}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def assemble_commit_message(data: dict) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Builds the final 'type(scope): description' string (plus optional body)
|
|
125
|
+
from the parsed JSON dict. Pure string formatting, no LLM involved.
|
|
126
|
+
"""
|
|
127
|
+
commit_type = data["type"]
|
|
128
|
+
scope = data.get("scope", "").strip()
|
|
129
|
+
description = data["description"].strip()
|
|
130
|
+
body = data.get("body", "").strip()
|
|
131
|
+
|
|
132
|
+
header = f"{commit_type}({scope}): {description}" if scope else f"{commit_type}: {description}"
|
|
133
|
+
|
|
134
|
+
if body:
|
|
135
|
+
return f"{header}\n\n{body}"
|
|
136
|
+
return header
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from commitmate.exceptions import (
|
|
3
|
+
OllamaConnectionError,
|
|
4
|
+
OllamaTimeoutError
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_commit_message(prompt: str, model: str = "llama3", timeout: int = 30, num_ctx: int = 8192) -> str:
|
|
9
|
+
|
|
10
|
+
url = "http://localhost:11434/api/generate"
|
|
11
|
+
|
|
12
|
+
payload = {
|
|
13
|
+
"model": model,
|
|
14
|
+
"prompt": prompt,
|
|
15
|
+
"format" : "json",
|
|
16
|
+
"stream": False,
|
|
17
|
+
"options": {
|
|
18
|
+
"num_ctx": num_ctx
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
response = requests.post(url, json = payload, timeout=timeout)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
|
|
26
|
+
data = response.json()
|
|
27
|
+
return data["response"]
|
|
28
|
+
except requests.exceptions.Timeout as e:
|
|
29
|
+
raise OllamaTimeoutError() from e
|
|
30
|
+
except requests.exceptions.ConnectionError as e:
|
|
31
|
+
raise OllamaConnectionError() from e
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commitmate
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Generates Conventional Commits-style messages from staged git diffs using a local Ollama model.
|
|
5
|
+
Project-URL: Homepage, https://github.com/theishanpathak/commitmate
|
|
6
|
+
Author: Ishan Pathak
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: requests
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# CommitMate
|
|
14
|
+
|
|
15
|
+
A command-line tool that reads your staged git changes and generates a [Conventional Commits](https://www.conventionalcommits.org/)-style commit message using a local LLM via [Ollama](https://ollama.com/) — no cloud API, no API keys, nothing leaves your machine.
|
|
16
|
+
|
|
17
|
+
You review the generated message and choose to accept it, edit it, or cancel — CommitMate never commits anything without your explicit approval.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Writing good commit messages consistently is tedious, and generic "wip" or "fix stuff" commits make a project's history much less useful. CommitMate looks at what you've actually staged and proposes a properly formatted message you can accept as-is or tweak, without sending your code to any external service.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
- Python 3.11 or later
|
|
26
|
+
- [Ollama](https://ollama.com/) installed and running locally
|
|
27
|
+
- A model pulled in Ollama. The tool defaults to `llama3`:
|
|
28
|
+
```bash
|
|
29
|
+
ollama pull llama3
|
|
30
|
+
```
|
|
31
|
+
`qwen2.5-coder:7b` is also recommended and performed better in testing on multi-file diffs (see Known Limitations below). If you want to use it, pull it too and pass `--model qwen2.5-coder:7b`:
|
|
32
|
+
```bash
|
|
33
|
+
ollama pull qwen2.5-coder:7b
|
|
34
|
+
```
|
|
35
|
+
- Ollama's server running (usually automatic after install, or start manually):
|
|
36
|
+
```bash
|
|
37
|
+
ollama serve
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Clone the repo and install it as an editable local package:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/theishanpathak/commitmate.git
|
|
46
|
+
cd commitmate
|
|
47
|
+
pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This registers a `commitmate` command on your system, callable from any git repository.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
1. Stage some changes:
|
|
55
|
+
```bash
|
|
56
|
+
git add <files>
|
|
57
|
+
```
|
|
58
|
+
Tip: for best results, stage related changes together rather than many unrelated files at once. Smaller local models can struggle to summarize a diff spanning several unrelated files into a single, well-formatted commit message.
|
|
59
|
+
2. Run:
|
|
60
|
+
```bash
|
|
61
|
+
commitmate
|
|
62
|
+
```
|
|
63
|
+
3. CommitMate will show a generated commit message and prompt you to choose:
|
|
64
|
+
- **accept** — commits immediately with the generated message
|
|
65
|
+
- **edit** — opens the message in your `$EDITOR` (falls back to `nano` on Mac/Linux, `notepad` on Windows) so you can revise it before committing
|
|
66
|
+
- **cancel** — exits without committing; your staged changes are left untouched
|
|
67
|
+
|
|
68
|
+
## How It Works
|
|
69
|
+
|
|
70
|
+
- **`git_utils.py`** — the only module that shells out to git (via `subprocess`). Retrieves the staged diff and file list, and performs the actual commit.
|
|
71
|
+
- **`ollama_client.py`** — a thin HTTP wrapper around Ollama's local `/api/generate` endpoint. Knows nothing about commit messages or git; it just sends a prompt and returns whatever text comes back.
|
|
72
|
+
- **`message_gen.py`** — pure, I/O-free logic. Builds the prompt (asking the model to return structured JSON rather than free text), and parses/validates/assembles that JSON into the final `type(scope): description` message.
|
|
73
|
+
- **`exceptions.py`** — a small hierarchy of custom exceptions (e.g. `NotAGitRepositoryError`, `NoStagedChangesError`, `OllamaConnectionError`, `InvalidModelResponseError`) so failures are caught and reported cleanly instead of surfacing as raw stack traces.
|
|
74
|
+
- **`cli.py`** — the composition root. Orchestrates the pipeline above, retries generation a few times if the model's response fails validation, and drives the accept/edit/cancel loop using [`rich`](https://github.com/Textualize/rich) for terminal output.
|
|
75
|
+
|
|
76
|
+
The model is prompted to return a JSON object (`reasoning`, `type`, `scope`, `description`, `body`) rather than a fully formatted commit message string. The actual `type(scope): description` text is then assembled in plain Python. This was a deliberate pivot after an earlier version — which asked the model to return the final formatted string directly — proved unreliable: local models frequently added preambles, markdown fences, or trailing explanations despite explicit instructions not to. Constraining the model to fill in a small number of discrete fields, and letting Python own the final formatting, removed that failure mode almost entirely.
|
|
77
|
+
|
|
78
|
+
A `reasoning` field is requested first in the JSON schema, before the other fields. Since JSON keys are generated in order, this gives the model a place to briefly "think" about the change before committing to a `type`/`description`, without breaking strict JSON output. This measurably improved how often the description stayed within a reasonable length.
|
|
79
|
+
|
|
80
|
+
If the model's response fails validation (wrong type, description too short, malformed JSON, etc.) after a few attempts, CommitMate shows a friendly message suggesting the diff may be too large or span too many unrelated files, rather than surfacing the raw internal validation error.
|
|
81
|
+
|
|
82
|
+
## Known Limitations
|
|
83
|
+
|
|
84
|
+
- **Multi-file diffs can produce longer subject lines.** Smaller/general-purpose local models (tested: `llama3`, the default) sometimes struggle to keep the commit description under the ~72-character convention when a diff spans multiple unrelated files. In testing, `qwen2.5-coder:7b` performed noticeably better at staying within length limits on the same diffs. Consider passing `--model qwen2.5-coder:7b` if you run into this.
|
|
85
|
+
- **No diff truncation.** Very large staged diffs are sent to the model in full, with no length guard. This could hit context limits or slow down generation on large changes.
|
|
86
|
+
- **Self-referential prompt confusion.** If a staged diff itself contains text that looks like prompt instructions (for example, testing CommitMate on a diff to its own `message_gen.py`, which contains the prompt-building code), the model can occasionally get confused about what's an instruction versus what's data to summarize.
|
|
87
|
+
- **GUI editors need a `--wait`-style flag.** The `edit` flow waits for your editor process to exit before continuing. Terminal editors (`nano`, `vim`) work out of the box. GUI editors need an explicit wait flag, e.g.:
|
|
88
|
+
```bash
|
|
89
|
+
export EDITOR="code --wait"
|
|
90
|
+
```
|
|
91
|
+
Without it, editors like VS Code return immediately and CommitMate will read the file back before you've finished editing.
|
|
92
|
+
- **No automated test suite yet.** The pure functions in `message_gen.py` (especially `clean_response` and `parse_model_response`) are strong candidates for unit tests; this hasn't been added yet.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
- **`$EDITOR`** — controls which editor opens during the `edit` flow. Defaults to `nano` (Mac/Linux) or `notepad` (Windows) if unset.
|
|
97
|
+
- **`--model`** — choose which Ollama model to use. Defaults to `llama3`:
|
|
98
|
+
```bash
|
|
99
|
+
commitmate --model qwen2.5-coder:7b
|
|
100
|
+
```
|
|
101
|
+
See Known Limitations above for why `qwen2.5-coder:7b` may give better results on multi-file diffs.
|
|
102
|
+
|
|
103
|
+
## Requirements
|
|
104
|
+
|
|
105
|
+
- `requests`
|
|
106
|
+
- `rich`
|
|
107
|
+
|
|
108
|
+
(See `requirements.txt`.)
|
|
109
|
+
|
|
110
|
+
## Author
|
|
111
|
+
|
|
112
|
+
Ishan Pathak
|
|
113
|
+
[theishanpathak.com](https://theishanpathak.com)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
commitmate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
commitmate/cli.py,sha256=3TWeJn65hJNQr8nKVf5ZSfKFYd5E06mcxerb5uumvvk,4275
|
|
3
|
+
commitmate/exceptions.py,sha256=UhUiEewAZ65tc2Djm9UfxIIQ2JXV7ArT0rkNih763I8,1807
|
|
4
|
+
commitmate/git_utils.py,sha256=g7JV2UPfrz2T6nEHRn10oaRAbB7UHtvrzWxe0oPH12w,2645
|
|
5
|
+
commitmate/message_gen.py,sha256=ahysigKOhfXFYW4WPbFSL3YZpCA63gt_AFCLukKoE7E,5188
|
|
6
|
+
commitmate/ollama_client.py,sha256=yt1rGXNANA9XpkA2Fd9T0tyZRDLLkG1YrfFE0JmDs3U,820
|
|
7
|
+
commitmate-1.0.0.dist-info/METADATA,sha256=KBi3-8HaxKf7xsaHVuRrbFM7E2rlRnC4UugT3RZTE9s,7172
|
|
8
|
+
commitmate-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
commitmate-1.0.0.dist-info/entry_points.txt,sha256=6YQ6ml1hqruxutoSfnAy6JdxwKsYdnapOyoFxtcnEuk,51
|
|
10
|
+
commitmate-1.0.0.dist-info/RECORD,,
|