git-commit-message 0.7.0__py3-none-any.whl → 0.8.1__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.
@@ -17,7 +17,9 @@ from ._git import (
17
17
  commit_with_message,
18
18
  get_repo_root,
19
19
  get_staged_diff,
20
+ has_head_commit,
20
21
  has_staged_changes,
22
+ resolve_amend_base_ref,
21
23
  )
22
24
  from ._llm import (
23
25
  CommitMessageResult,
@@ -27,6 +29,40 @@ from ._llm import (
27
29
  )
28
30
 
29
31
 
32
+ class CliArgs(Namespace):
33
+ __slots__ = (
34
+ "description",
35
+ "commit",
36
+ "amend",
37
+ "edit",
38
+ "provider",
39
+ "model",
40
+ "language",
41
+ "debug",
42
+ "one_line",
43
+ "max_length",
44
+ "chunk_tokens",
45
+ "host",
46
+ )
47
+
48
+ def __init__(
49
+ self,
50
+ /,
51
+ ) -> None:
52
+ self.description: str | None = None
53
+ self.commit: bool = False
54
+ self.amend: bool = False
55
+ self.edit: bool = False
56
+ self.provider: str | None = None
57
+ self.model: str | None = None
58
+ self.language: str | None = None
59
+ self.debug: bool = False
60
+ self.one_line: bool = False
61
+ self.max_length: int | None = None
62
+ self.chunk_tokens: int | None = None
63
+ self.host: str | None = None
64
+
65
+
30
66
  def _env_chunk_tokens_default() -> int | None:
31
67
  """Return chunk token default from env if valid, else None."""
32
68
 
@@ -67,6 +103,16 @@ def _build_parser() -> ArgumentParser:
67
103
  help="Commit immediately with the generated message.",
68
104
  )
69
105
 
106
+ parser.add_argument(
107
+ "--amend",
108
+ action="store_true",
109
+ help=(
110
+ "Generate a message suitable for amending the previous commit. "
111
+ "When set, the diff is computed from the amended commit's parent to the staged index. "
112
+ "Use with '--commit' to run the amend, or omit '--commit' to print the message only."
113
+ ),
114
+ )
115
+
70
116
  parser.add_argument(
71
117
  "--edit",
72
118
  action="store_true",
@@ -87,7 +133,7 @@ def _build_parser() -> ArgumentParser:
87
133
  "--model",
88
134
  default=None,
89
135
  help=(
90
- "Model name to use. If unspecified, uses GIT_COMMIT_MESSAGE_MODEL or a provider-specific default (openai: gpt-5-mini; google: gemini-2.5-flash)."
136
+ "Model name to use. If unspecified, uses GIT_COMMIT_MESSAGE_MODEL or a provider-specific default (openai: gpt-5-mini; google: gemini-2.5-flash; ollama: gpt-oss:20b)."
91
137
  ),
92
138
  )
93
139
 
@@ -134,11 +180,21 @@ def _build_parser() -> ArgumentParser:
134
180
  ),
135
181
  )
136
182
 
183
+ parser.add_argument(
184
+ "--host",
185
+ dest="host",
186
+ default=None,
187
+ help=(
188
+ "Host URL for API providers like Ollama (default: http://localhost:11434). "
189
+ "You may also set OLLAMA_HOST for Ollama."
190
+ ),
191
+ )
192
+
137
193
  return parser
138
194
 
139
195
 
140
196
  def _run(
141
- args: Namespace,
197
+ args: CliArgs,
142
198
  /,
143
199
  ) -> int:
144
200
  """Main execution logic.
@@ -156,11 +212,19 @@ def _run(
156
212
 
157
213
  repo_root: Path = get_repo_root()
158
214
 
159
- if not has_staged_changes(repo_root):
160
- print("No staged changes. Run 'git add' and try again.", file=stderr)
161
- return 2
215
+ if args.amend:
216
+ if not has_head_commit(repo_root):
217
+ print("Cannot amend: the repository has no commits yet.", file=stderr)
218
+ return 2
219
+
220
+ base_ref = resolve_amend_base_ref(repo_root)
221
+ diff_text: str = get_staged_diff(repo_root, base_ref=base_ref)
222
+ else:
223
+ if not has_staged_changes(repo_root):
224
+ print("No staged changes. Run 'git add' and try again.", file=stderr)
225
+ return 2
162
226
 
163
- diff_text: str = get_staged_diff(repo_root)
227
+ diff_text = get_staged_diff(repo_root)
164
228
 
165
229
  hint: str | None = args.description if isinstance(args.description, str) else None
166
230
 
@@ -177,11 +241,12 @@ def _run(
177
241
  diff_text,
178
242
  hint,
179
243
  args.model,
180
- getattr(args, "one_line", False),
181
- getattr(args, "max_length", None),
182
- getattr(args, "language", None),
244
+ args.one_line,
245
+ args.max_length,
246
+ args.language,
183
247
  chunk_tokens,
184
- getattr(args, "provider", None),
248
+ args.provider,
249
+ args.host,
185
250
  )
186
251
  message = result.message
187
252
  else:
@@ -189,11 +254,12 @@ def _run(
189
254
  diff_text,
190
255
  hint,
191
256
  args.model,
192
- getattr(args, "one_line", False),
193
- getattr(args, "max_length", None),
194
- getattr(args, "language", None),
257
+ args.one_line,
258
+ args.max_length,
259
+ args.language,
195
260
  chunk_tokens,
196
- getattr(args, "provider", None),
261
+ args.provider,
262
+ args.host,
197
263
  )
198
264
  except UnsupportedProviderError as exc:
199
265
  print(str(exc), file=stderr)
@@ -203,7 +269,7 @@ def _run(
203
269
  return 3
204
270
 
205
271
  # Option: force single-line message
206
- if getattr(args, "one_line", False):
272
+ if args.one_line:
207
273
  # Use the first non-empty line only
208
274
  for line in (ln.strip() for ln in message.splitlines()):
209
275
  if line:
@@ -218,7 +284,7 @@ def _run(
218
284
  print(f"==== {result.provider} Usage ====")
219
285
  print(f"provider: {result.provider}")
220
286
  print(f"model: {result.model}")
221
- print(f"response_id: {getattr(result, 'response_id', '(n/a)')}")
287
+ print(f"response_id: {result.response_id or '(n/a)'}")
222
288
  if result.total_tokens is not None:
223
289
  print(
224
290
  f"tokens: prompt={result.prompt_tokens} completion={result.completion_tokens} total={result.total_tokens}"
@@ -240,7 +306,7 @@ def _run(
240
306
  print(f"==== {result.provider} Usage ====")
241
307
  print(f"provider: {result.provider}")
242
308
  print(f"model: {result.model}")
243
- print(f"response_id: {getattr(result, 'response_id', '(n/a)')}")
309
+ print(f"response_id: {result.response_id or '(n/a)'}")
244
310
  if result.total_tokens is not None:
245
311
  print(
246
312
  f"tokens: prompt={result.prompt_tokens} completion={result.completion_tokens} total={result.total_tokens}"
@@ -255,9 +321,9 @@ def _run(
255
321
  print(message)
256
322
 
257
323
  if args.edit:
258
- rc: int = commit_with_message(message, True, repo_root)
324
+ rc: int = commit_with_message(message, True, repo_root, amend=args.amend)
259
325
  else:
260
- rc = commit_with_message(message, False, repo_root)
326
+ rc = commit_with_message(message, False, repo_root, amend=args.amend)
261
327
 
262
328
  return rc
263
329
 
@@ -269,7 +335,8 @@ def main() -> None:
269
335
  """
270
336
 
271
337
  parser: Final[ArgumentParser] = _build_parser()
272
- args: Namespace = parser.parse_args()
338
+ args = CliArgs()
339
+ parser.parse_args(namespace=args)
273
340
 
274
341
  if args.edit and not args.commit:
275
342
  print("'--edit' must be used together with '--commit'.", file=stderr)
@@ -7,6 +7,7 @@ Provider-agnostic orchestration/prompt logic lives in `_llm.py`.
7
7
  from __future__ import annotations
8
8
 
9
9
  from os import environ
10
+ from typing import ClassVar
10
11
 
11
12
  from google import genai
12
13
  from google.genai import types
@@ -15,7 +16,11 @@ from ._llm import LLMTextResult, LLMUsage
15
16
 
16
17
 
17
18
  class GoogleGenAIProvider:
18
- name = "google"
19
+ __slots__ = (
20
+ "_client",
21
+ )
22
+
23
+ name: ClassVar[str] = "google"
19
24
 
20
25
  def __init__(
21
26
  self,
@@ -10,6 +10,61 @@ from pathlib import Path
10
10
  from subprocess import CalledProcessError, check_call, check_output, run
11
11
 
12
12
 
13
+ def _get_empty_tree_hash(
14
+ cwd: Path,
15
+ /,
16
+ ) -> str:
17
+ """Return the empty tree hash for this repository.
18
+
19
+ Parameters
20
+ ----------
21
+ cwd
22
+ Repository directory in which to run Git.
23
+
24
+ Notes
25
+ -----
26
+ Do not hard-code the SHA, because repositories may use different
27
+ hash algorithms (e.g. SHA-1 vs SHA-256). We ask Git to compute the
28
+ empty tree object ID for the current repo.
29
+
30
+ Returns
31
+ -------
32
+ str
33
+ The empty tree object ID for the current repository.
34
+ """
35
+
36
+ try:
37
+ completed = run(
38
+ [
39
+ "git",
40
+ "hash-object",
41
+ "-t",
42
+ "tree",
43
+ "--stdin",
44
+ ],
45
+ cwd=str(cwd),
46
+ check=True,
47
+ input=b"",
48
+ capture_output=True,
49
+ )
50
+ except CalledProcessError as exc:
51
+ stderr_text = (exc.stderr or b"").decode(errors="replace").strip()
52
+ suffix = f"\nGit stderr: {stderr_text}" if stderr_text else ""
53
+ raise RuntimeError(
54
+ f"Failed to compute empty tree hash (git exited with {exc.returncode}).{suffix}"
55
+ ) from exc
56
+ except OSError as exc:
57
+ raise RuntimeError(
58
+ f"Failed to run git to compute empty tree hash: {exc}"
59
+ ) from exc
60
+ oid = completed.stdout.decode().strip()
61
+ if not oid:
62
+ raise RuntimeError(
63
+ "Failed to compute empty tree hash: git returned an empty object ID."
64
+ )
65
+ return oid
66
+
67
+
13
68
  def get_repo_root(
14
69
  cwd: Path | None = None,
15
70
  /,
@@ -60,23 +115,114 @@ def has_staged_changes(
60
115
  return True
61
116
 
62
117
 
63
- def get_staged_diff(
118
+ def has_head_commit(
119
+ cwd: Path,
120
+ /,
121
+ ) -> bool:
122
+ """Return True if the repository has at least one commit (HEAD exists).
123
+
124
+ Parameters
125
+ ----------
126
+ cwd
127
+ Repository directory in which to run Git.
128
+
129
+ Returns
130
+ -------
131
+ bool
132
+ True if ``HEAD`` exists in the repository, False otherwise.
133
+ """
134
+
135
+ completed = run(
136
+ ["git", "rev-parse", "--verify", "HEAD"],
137
+ cwd=str(cwd),
138
+ check=False,
139
+ capture_output=True,
140
+ )
141
+ return completed.returncode == 0
142
+
143
+
144
+ def resolve_amend_base_ref(
64
145
  cwd: Path,
65
146
  /,
66
147
  ) -> str:
67
- """Return the staged changes as diff text."""
68
-
69
- out: bytes = check_output(
70
- [
71
- "git",
72
- "diff",
73
- "--cached",
74
- "--patch",
75
- "--minimal",
76
- "--no-color",
77
- ],
148
+ """Resolve the base ref for an amend diff.
149
+
150
+ Parameters
151
+ ----------
152
+ cwd
153
+ Repository directory in which to run Git.
154
+
155
+ Notes
156
+ -----
157
+ The amended commit keeps the same parent as the current HEAD commit.
158
+
159
+ - If HEAD has a parent, base is ``HEAD^``.
160
+ - If HEAD is a root commit (no parent), base is the empty tree.
161
+
162
+ Returns
163
+ -------
164
+ str
165
+ The base reference for the amend diff: either ``HEAD^`` (when the
166
+ current ``HEAD`` commit has a parent) or the empty tree object ID
167
+ (when ``HEAD`` is a root commit).
168
+ """
169
+
170
+ completed = run(
171
+ ["git", "rev-parse", "--verify", "HEAD^"],
78
172
  cwd=str(cwd),
173
+ check=False,
174
+ capture_output=True,
79
175
  )
176
+ if completed.returncode == 0:
177
+ return "HEAD^"
178
+ return _get_empty_tree_hash(cwd)
179
+
180
+
181
+ def get_staged_diff(
182
+ cwd: Path,
183
+ /,
184
+ *,
185
+ base_ref: str | None = None,
186
+ ) -> str:
187
+ """Return the staged changes as diff text.
188
+
189
+ Parameters
190
+ ----------
191
+ cwd
192
+ Git working directory.
193
+ base_ref
194
+ Optional Git reference or tree object ID (e.g., branch name, tag,
195
+ commit hash, or the empty tree hash) to diff against. When provided,
196
+ the diff shows changes from ``base_ref`` to the staged index, instead
197
+ of changes from ``HEAD`` to the staged index.
198
+
199
+ Returns
200
+ -------
201
+ str
202
+ Unified diff text for the staged changes.
203
+ """
204
+
205
+ cmd: list[str] = [
206
+ "git",
207
+ "diff",
208
+ "--cached",
209
+ "--patch",
210
+ "--minimal",
211
+ "--no-color",
212
+ ]
213
+ if base_ref:
214
+ cmd.append(base_ref)
215
+
216
+ try:
217
+ out: bytes = check_output(cmd, cwd=str(cwd))
218
+ except CalledProcessError as exc:
219
+ message = "Failed to retrieve staged diff from Git."
220
+ if base_ref:
221
+ message += (
222
+ " Ensure that the provided base_ref exists and is a valid Git reference."
223
+ )
224
+ raise RuntimeError(message) from exc
225
+
80
226
  return out.decode()
81
227
 
82
228
 
@@ -85,6 +231,8 @@ def commit_with_message(
85
231
  edit: bool,
86
232
  cwd: Path,
87
233
  /,
234
+ *,
235
+ amend: bool = False,
88
236
  ) -> int:
89
237
  """Create a commit with the given message.
90
238
 
@@ -96,6 +244,9 @@ def commit_with_message(
96
244
  If True, use the `--edit` flag to open an editor for amendments.
97
245
  cwd
98
246
  Git working directory.
247
+ amend
248
+ If True, pass ``--amend`` to Git to amend the current ``HEAD`` commit
249
+ instead of creating a new commit.
99
250
 
100
251
  Returns
101
252
  -------
@@ -103,7 +254,11 @@ def commit_with_message(
103
254
  The subprocess exit code.
104
255
  """
105
256
 
106
- cmd: list[str] = ["git", "commit", "-m", message]
257
+ cmd: list[str] = ["git", "commit"]
258
+ if amend:
259
+ cmd.append("--amend")
260
+
261
+ cmd.extend(["-m", message])
107
262
  if edit:
108
263
  cmd.append("--edit")
109
264
 
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
  from openai import OpenAI
10
10
  from openai.types.responses import Response
11
11
  from os import environ
12
+ from typing import ClassVar
13
+
12
14
  from tiktoken import Encoding, encoding_for_model, get_encoding
13
15
  from ._llm import LLMTextResult, LLMUsage
14
16
 
@@ -24,7 +26,11 @@ def _encoding_for_model(
24
26
 
25
27
 
26
28
  class OpenAIResponsesProvider:
27
- name = "openai"
29
+ __slots__ = (
30
+ "_client",
31
+ )
32
+
33
+ name: ClassVar[str] = "openai"
28
34
 
29
35
  def __init__(
30
36
  self,
@@ -12,16 +12,19 @@ from __future__ import annotations
12
12
 
13
13
  from babel import Locale
14
14
  from os import environ
15
- from typing import Final, Protocol
15
+ from typing import ClassVar, Final, Protocol
16
16
 
17
17
 
18
18
  _DEFAULT_PROVIDER: Final[str] = "openai"
19
19
  _DEFAULT_MODEL_OPENAI: Final[str] = "gpt-5-mini"
20
20
  _DEFAULT_MODEL_GOOGLE: Final[str] = "gemini-2.5-flash"
21
+ _DEFAULT_MODEL_OLLAMA: Final[str] = "gpt-oss:20b"
21
22
  _DEFAULT_LANGUAGE: Final[str] = "en-GB"
22
23
 
23
24
 
24
25
  class UnsupportedProviderError(RuntimeError):
26
+ __slots__ = ()
27
+
25
28
  pass
26
29
 
27
30
 
@@ -66,7 +69,9 @@ class LLMTextResult:
66
69
 
67
70
 
68
71
  class CommitMessageProvider(Protocol):
69
- name: str
72
+ __slots__ = ()
73
+
74
+ name: ClassVar[str]
70
75
 
71
76
  def generate_text(
72
77
  self,
@@ -147,6 +152,9 @@ def _resolve_model(
147
152
  if provider_name == "google":
148
153
  default_model = _DEFAULT_MODEL_GOOGLE
149
154
  provider_model = None
155
+ elif provider_name == "ollama":
156
+ default_model = _DEFAULT_MODEL_OLLAMA
157
+ provider_model = environ.get("OLLAMA_MODEL")
150
158
  else:
151
159
  default_model = _DEFAULT_MODEL_OPENAI
152
160
  provider_model = environ.get("OPENAI_MODEL")
@@ -164,6 +172,8 @@ def _resolve_language(
164
172
  def get_provider(
165
173
  provider: str | None,
166
174
  /,
175
+ *,
176
+ host: str | None = None,
167
177
  ) -> CommitMessageProvider:
168
178
  name = _resolve_provider(provider)
169
179
 
@@ -179,8 +189,14 @@ def get_provider(
179
189
 
180
190
  return GoogleGenAIProvider()
181
191
 
192
+ if name == "ollama":
193
+ # Local import to avoid import cycles: providers may import shared types from this module.
194
+ from ._ollama import OllamaProvider
195
+
196
+ return OllamaProvider(host=host)
197
+
182
198
  raise UnsupportedProviderError(
183
- f"Unsupported provider: {name}. Supported providers: openai, google"
199
+ f"Unsupported provider: {name}. Supported providers: openai, google, ollama"
184
200
  )
185
201
 
186
202
 
@@ -459,13 +475,14 @@ def generate_commit_message(
459
475
  language: str | None = None,
460
476
  chunk_tokens: int | None = 0,
461
477
  provider: str | None = None,
478
+ host: str | None = None,
462
479
  /,
463
480
  ) -> str:
464
481
  chosen_provider = _resolve_provider(provider)
465
482
  chosen_model = _resolve_model(model, chosen_provider)
466
483
  chosen_language = _resolve_language(language)
467
484
 
468
- llm = get_provider(chosen_provider)
485
+ llm = get_provider(chosen_provider, host=host)
469
486
 
470
487
  normalized_chunk_tokens = 0 if chunk_tokens is None else chunk_tokens
471
488
 
@@ -513,13 +530,14 @@ def generate_commit_message_with_info(
513
530
  language: str | None = None,
514
531
  chunk_tokens: int | None = 0,
515
532
  provider: str | None = None,
533
+ host: str | None = None,
516
534
  /,
517
535
  ) -> CommitMessageResult:
518
536
  chosen_provider = _resolve_provider(provider)
519
537
  chosen_model = _resolve_model(model, chosen_provider)
520
538
  chosen_language = _resolve_language(language)
521
539
 
522
- llm = get_provider(chosen_provider)
540
+ llm = get_provider(chosen_provider, host=host)
523
541
 
524
542
  normalized_chunk_tokens = 0 if chunk_tokens is None else chunk_tokens
525
543
 
@@ -0,0 +1,122 @@
1
+ """Ollama provider implementation.
2
+
3
+ Mirrors the Gemini provider structure: a single provider class that exposes
4
+ `generate_text` and `count_tokens`. Provider-agnostic orchestration lives in
5
+ `_llm.py`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from os import environ
11
+ from typing import ClassVar, Final
12
+
13
+ from ollama import Client, ResponseError
14
+ from tiktoken import Encoding, get_encoding
15
+
16
+ from ._llm import LLMTextResult, LLMUsage
17
+
18
+
19
+ _DEFAULT_OLLAMA_HOST: Final[str] = "http://localhost:11434"
20
+
21
+
22
+ def _resolve_ollama_host(
23
+ host: str | None,
24
+ /,
25
+ ) -> str:
26
+ """Resolve the Ollama host URL from arg, env, or default."""
27
+
28
+ return host or environ.get("OLLAMA_HOST") or _DEFAULT_OLLAMA_HOST
29
+
30
+
31
+ def _get_encoding() -> Encoding:
32
+ """Get a fallback encoding for token counting."""
33
+
34
+ try:
35
+ return get_encoding("cl100k_base")
36
+ except Exception:
37
+ return get_encoding("gpt2")
38
+
39
+
40
+ class OllamaProvider:
41
+ """Ollama provider implementation for the LLM protocol."""
42
+
43
+ __slots__ = (
44
+ "_host",
45
+ "_client",
46
+ )
47
+
48
+ name: ClassVar[str] = "ollama"
49
+
50
+ def __init__(
51
+ self,
52
+ /,
53
+ *,
54
+ host: str | None = None,
55
+ ) -> None:
56
+ self._host = _resolve_ollama_host(host)
57
+ self._client = Client(host=self._host)
58
+
59
+ def generate_text(
60
+ self,
61
+ /,
62
+ *,
63
+ model: str,
64
+ instructions: str,
65
+ user_text: str,
66
+ ) -> LLMTextResult:
67
+ """Generate text using Ollama (non-streaming)."""
68
+
69
+ messages = [
70
+ {"role": "system", "content": instructions},
71
+ {"role": "user", "content": user_text},
72
+ ]
73
+
74
+ try:
75
+ response = self._client.chat(model=model, messages=messages)
76
+ except ResponseError as exc:
77
+ raise RuntimeError(
78
+ f"Ollama API error: {exc}"
79
+ ) from exc
80
+ except Exception as exc:
81
+ raise RuntimeError(
82
+ f"Failed to connect to Ollama at {self._host}. Make sure Ollama is running: {exc}"
83
+ ) from exc
84
+
85
+ response_text = response.message.content or ""
86
+
87
+ # Extract token usage if available
88
+ prompt_tokens: int | None = None
89
+ completion_tokens: int | None = None
90
+ total_tokens: int | None = None
91
+
92
+ if hasattr(response, "prompt_eval_count") and response.prompt_eval_count:
93
+ prompt_tokens = response.prompt_eval_count
94
+ if hasattr(response, "eval_count") and response.eval_count:
95
+ completion_tokens = response.eval_count
96
+ if prompt_tokens is not None and completion_tokens is not None:
97
+ total_tokens = prompt_tokens + completion_tokens
98
+
99
+ return LLMTextResult(
100
+ text=response_text.strip(),
101
+ response_id=None,
102
+ usage=LLMUsage(
103
+ prompt_tokens=prompt_tokens,
104
+ completion_tokens=completion_tokens,
105
+ total_tokens=total_tokens,
106
+ ),
107
+ )
108
+
109
+ def count_tokens(
110
+ self,
111
+ /,
112
+ *,
113
+ model: str,
114
+ text: str,
115
+ ) -> int:
116
+ """Approximate token count using tiktoken; fallback to whitespace split."""
117
+
118
+ try:
119
+ encoding = _get_encoding()
120
+ return len(encoding.encode(text))
121
+ except Exception:
122
+ return len(text.split())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-message
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: Generate Git commit messages from staged changes using LLM
5
5
  Maintainer-email: Mina Her <minacle@live.com>
6
6
  License: This is free and unencumbered software released into the public domain.
@@ -45,16 +45,22 @@ Requires-Python: >=3.13
45
45
  Description-Content-Type: text/markdown
46
46
  Requires-Dist: babel>=2.17.0
47
47
  Requires-Dist: google-genai>=1.56.0
48
+ Requires-Dist: ollama>=0.4.0
48
49
  Requires-Dist: openai>=2.6.1
49
50
  Requires-Dist: tiktoken>=0.12.0
50
51
 
51
52
  # git-commit-message
52
53
 
53
- Staged changes -> GPT commit message generator.
54
+ Generate a commit message from your staged changes using OpenAI, Google Gemini, or Ollama.
54
55
 
55
56
  [![asciicast](https://asciinema.org/a/jk0phFqNnc5vaCiIZEYBwZOyN.svg)](https://asciinema.org/a/jk0phFqNnc5vaCiIZEYBwZOyN)
56
57
 
57
- ## Install (PyPI)
58
+ ## Requirements
59
+
60
+ - Python 3.13+
61
+ - A Git repo with staged changes (`git add ...`) (or use `--amend` even if nothing is staged)
62
+
63
+ ## Install
58
64
 
59
65
  Install the latest released version from PyPI:
60
66
 
@@ -78,19 +84,43 @@ Quick check:
78
84
  git-commit-message --help
79
85
  ```
80
86
 
81
- Set your API key (POSIX sh):
87
+ ## Setup
88
+
89
+ ### OpenAI
82
90
 
83
91
  ```sh
84
92
  export OPENAI_API_KEY="sk-..."
85
93
  ```
86
94
 
87
- Or for the Google provider:
95
+ ### Google Gemini
88
96
 
89
97
  ```sh
90
98
  export GOOGLE_API_KEY="..."
91
99
  ```
92
100
 
93
- Note (fish): In fish, set it as follows.
101
+ ### Ollama (local models)
102
+
103
+ 1. Install Ollama: https://ollama.ai
104
+ 2. Start the server:
105
+
106
+ ```sh
107
+ ollama serve
108
+ ```
109
+
110
+ 3. Pull a model:
111
+
112
+ ```sh
113
+ ollama pull mistral
114
+ ```
115
+
116
+ Optional: set defaults:
117
+
118
+ ```sh
119
+ export GIT_COMMIT_MESSAGE_PROVIDER=ollama
120
+ export OLLAMA_MODEL=mistral
121
+ ```
122
+
123
+ Note (fish):
94
124
 
95
125
  ```fish
96
126
  set -x OPENAI_API_KEY "sk-..."
@@ -104,96 +134,130 @@ python -m pip install -e .
104
134
 
105
135
  ## Usage
106
136
 
107
- - Print commit message only:
137
+ Generate and print a commit message:
108
138
 
109
139
  ```sh
110
140
  git add -A
111
141
  git-commit-message "optional extra context about the change"
112
142
  ```
113
143
 
114
- - Force single-line subject only:
144
+ Generate a single-line subject only:
115
145
 
116
146
  ```sh
117
147
  git-commit-message --one-line "optional context"
118
148
  ```
119
149
 
120
- - Select provider (default: openai):
150
+ Select provider:
121
151
 
122
152
  ```sh
123
- git-commit-message --provider openai "optional context"
153
+ # OpenAI (default)
154
+ git-commit-message --provider openai
155
+
156
+ # Google Gemini (via google-genai)
157
+ git-commit-message --provider google
158
+
159
+ # Ollama
160
+ git-commit-message --provider ollama
161
+ ```
162
+
163
+ Commit immediately (optionally open editor):
164
+
165
+ ```sh
166
+ git-commit-message --commit "refactor parser for speed"
167
+ git-commit-message --commit --edit "refactor parser for speed"
124
168
  ```
125
169
 
126
- - Select provider (Google Gemini via google-genai):
170
+ Amend the previous commit:
127
171
 
128
172
  ```sh
129
- git-commit-message --provider google "optional context"
173
+ # print only (useful for pasting into a GUI editor)
174
+ git-commit-message --amend "optional context"
175
+
176
+ # amend immediately
177
+ git-commit-message --commit --amend "optional context"
178
+
179
+ # amend immediately, but open editor for final tweaks
180
+ git-commit-message --commit --amend --edit "optional context"
130
181
  ```
131
182
 
132
- - Limit subject length (default 72):
183
+ Limit subject length:
133
184
 
134
185
  ```sh
135
- git-commit-message --one-line --max-length 50 "optional context"
186
+ git-commit-message --one-line --max-length 50
136
187
  ```
137
188
 
138
- - Chunk long diffs by token budget (0 = single chunk + summary, -1 = disable chunking):
189
+ Chunk/summarise long diffs by token budget:
139
190
 
140
191
  ```sh
141
192
  # force a single summary pass over the whole diff (default)
142
- git-commit-message --chunk-tokens 0 "optional context"
193
+ git-commit-message --chunk-tokens 0
143
194
 
144
195
  # chunk the diff into ~4000-token pieces before summarising
145
- git-commit-message --chunk-tokens 4000 "optional context"
196
+ git-commit-message --chunk-tokens 4000
146
197
 
147
198
  # disable summarisation and use the legacy one-shot prompt
148
- git-commit-message --chunk-tokens -1 "optional context"
199
+ git-commit-message --chunk-tokens -1
149
200
  ```
150
201
 
151
- - Commit immediately with editor:
202
+ Select output language/locale (IETF language tag):
152
203
 
153
204
  ```sh
154
- git-commit-message --commit --edit "refactor parser for speed"
205
+ git-commit-message --language en-US
206
+ git-commit-message --language ko-KR
207
+ git-commit-message --language ja-JP
155
208
  ```
156
209
 
157
- - Print debug info (prompt/response + token usage):
210
+ Print debug info:
158
211
 
159
212
  ```sh
160
- git-commit-message --debug "optional context"
213
+ git-commit-message --debug
161
214
  ```
162
215
 
163
- - Select output language/locale (default: en-GB):
216
+ Configure Ollama host (if running on a different machine):
164
217
 
165
218
  ```sh
166
- # American English
167
- git-commit-message --language en-US "optional context"
219
+ git-commit-message --provider ollama --host http://192.168.1.100:11434
220
+ ```
168
221
 
169
- # Korean
170
- git-commit-message --language ko-KR
222
+ ## Options
171
223
 
172
- # Japanese
173
- git-commit-message --language ja-JP
174
- ```
224
+ - `--provider {openai,google,ollama}`: provider to use (default: `openai`)
225
+ - `--model MODEL`: model override (provider-specific)
226
+ - `--language TAG`: output language/locale (default: `en-GB`)
227
+ - `--one-line`: output subject only
228
+ - `--max-length N`: max subject length (default: 72)
229
+ - `--chunk-tokens N`: token budget per diff chunk (`0` = single summary pass, `-1` disables summarisation)
230
+ - `--debug`: print request/response details
231
+ - `--commit`: run `git commit -m <message>`
232
+ - `--amend`: generate a message suitable for amending the previous commit (diff is from the amended commit's parent to the staged index; if nothing is staged, this effectively becomes the diff introduced by `HEAD`)
233
+ - `--edit`: with `--commit`, open editor for final message
234
+ - `--host URL`: host URL for providers like Ollama (default: `http://localhost:11434`)
235
+
236
+ ## Environment variables
175
237
 
176
- Notes:
238
+ Required:
177
239
 
178
- - The model is instructed to write using the selected language/locale.
179
- - In multi-line mode, the only allowed label ("Rationale:") is also translated into the target language.
240
+ - `OPENAI_API_KEY`: when provider is `openai`
241
+ - `GOOGLE_API_KEY`: when provider is `google`
180
242
 
181
- Environment:
243
+ Optional:
182
244
 
183
- - `OPENAI_API_KEY`: required when provider is `openai`
184
- - `GOOGLE_API_KEY`: required when provider is `google`
185
- - `GIT_COMMIT_MESSAGE_PROVIDER`: optional (default: `openai`). `--provider` overrides this value.
186
- - `GIT_COMMIT_MESSAGE_MODEL`: optional model override (defaults: `openai` -> `gpt-5-mini`, `google` -> `gemini-2.5-flash`)
187
- - `OPENAI_MODEL`: optional OpenAI-only model override
188
- - `GIT_COMMIT_MESSAGE_LANGUAGE`: optional (default: `en-GB`)
189
- - `GIT_COMMIT_MESSAGE_CHUNK_TOKENS`: optional token budget per diff chunk (default: 0 = single chunk + summary; -1 disables summarisation)
245
+ - `GIT_COMMIT_MESSAGE_PROVIDER`: default provider (`openai` by default). `--provider` overrides this.
246
+ - `GIT_COMMIT_MESSAGE_MODEL`: model override for any provider. `--model` overrides this.
247
+ - `OPENAI_MODEL`: OpenAI-only model override (used if `--model`/`GIT_COMMIT_MESSAGE_MODEL` are not set)
248
+ - `OLLAMA_MODEL`: Ollama-only model override (used if `--model`/`GIT_COMMIT_MESSAGE_MODEL` are not set)
249
+ - `OLLAMA_HOST`: Ollama server URL (default: `http://localhost:11434`)
250
+ - `GIT_COMMIT_MESSAGE_LANGUAGE`: default language/locale (default: `en-GB`)
251
+ - `GIT_COMMIT_MESSAGE_CHUNK_TOKENS`: default chunk token budget (default: `0`)
190
252
 
191
- Notes:
253
+ Default models (if not overridden):
192
254
 
193
- - If token counting fails for your provider while chunking, try `--chunk-tokens 0` (default) or `--chunk-tokens -1`.
255
+ - OpenAI: `gpt-5-mini`
256
+ - Google: `gemini-2.5-flash`
257
+ - Ollama: `gpt-oss:20b`
194
258
 
195
- ## AIgenerated code notice
259
+ ## AI-generated code notice
196
260
 
197
261
  Parts of this project were created with assistance from AI tools (e.g. large language models).
198
- All AIassisted contributions were reviewed and adapted by maintainers before inclusion.
262
+ All AI-assisted contributions were reviewed and adapted by maintainers before inclusion.
199
263
  If you need provenance for specific changes, please refer to the Git history and commit messages.
@@ -0,0 +1,13 @@
1
+ git_commit_message/__init__.py,sha256=bmUVTlV1SYJAnoSaIKcpDCPkJ5JW2BANfFGvKt_A22w,190
2
+ git_commit_message/__main__.py,sha256=n5lvkLiCZ1Q4dwhEwonWntcKTeTaJL9qOJzdiLf0Gfk,99
3
+ git_commit_message/_cli.py,sha256=vF3HCFOUNtfYf1mUgSfUm_SocFpXfdOmTEDWHeuyS6Y,9726
4
+ git_commit_message/_gemini.py,sha256=QBKWnFCgc62KqC8agPhfO6eIizNj3V0oPTj6rqbTKiY,3487
5
+ git_commit_message/_git.py,sha256=qj8LuIA-qu4zQfe5CRzBPGNWVeicP9JZibVvCaXLoaY,6354
6
+ git_commit_message/_gpt.py,sha256=RSw3JyjuzfQ3sRHJu224CosfEHhjdUOGmRjU079G7io,2414
7
+ git_commit_message/_llm.py,sha256=_d0pKfs8BArx6RlJ-09DRMOj8WzYOmYQVJVTVxNli98,18279
8
+ git_commit_message/_ollama.py,sha256=-HI73J_n4kU7WGl3oBweRHw23JRWq7QH9tsEy-6dxlA,3308
9
+ git_commit_message-0.8.1.dist-info/METADATA,sha256=b1ONHLiGa723SrVBszsYPe6wa1VtUWVY75jFfL-73HU,7615
10
+ git_commit_message-0.8.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ git_commit_message-0.8.1.dist-info/entry_points.txt,sha256=e2cRvoyZnmP7yVItmFKwZofYG86WWKhm8KbzZSo2mf0,63
12
+ git_commit_message-0.8.1.dist-info/top_level.txt,sha256=qeP45y7y44R4KrPEihvMdwdM8tXYDY_3nCvCD3I9EcI,19
13
+ git_commit_message-0.8.1.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- git_commit_message/__init__.py,sha256=bmUVTlV1SYJAnoSaIKcpDCPkJ5JW2BANfFGvKt_A22w,190
2
- git_commit_message/__main__.py,sha256=n5lvkLiCZ1Q4dwhEwonWntcKTeTaJL9qOJzdiLf0Gfk,99
3
- git_commit_message/_cli.py,sha256=62lKSJmeW5JoEc-6-Xq2uZn_KK_o6zvU4V3oEYRcdUY,7982
4
- git_commit_message/_gemini.py,sha256=AwevAnGB8LPekwVGroS2lwDVPszfGw0Xa_aPP9DP2yY,3400
5
- git_commit_message/_git.py,sha256=foQIG6e4QLv00JAhQgMUQ1cw7WExxU5SFezfgXJ10XA,2424
6
- git_commit_message/_gpt.py,sha256=in6hf78pQKt3PWEgw8-kD9X33yicY-Bg7COVcabCur8,2326
7
- git_commit_message/_llm.py,sha256=ZUl1mRxuz37UIWXPXLBJJgppVS-a3HrnxdYTVvkXYm8,17699
8
- git_commit_message-0.7.0.dist-info/METADATA,sha256=Z1Xni2T94vfpow9g_fHwrscaZETMtjNtnVklSAFYL7Y,6106
9
- git_commit_message-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- git_commit_message-0.7.0.dist-info/entry_points.txt,sha256=e2cRvoyZnmP7yVItmFKwZofYG86WWKhm8KbzZSo2mf0,63
11
- git_commit_message-0.7.0.dist-info/top_level.txt,sha256=qeP45y7y44R4KrPEihvMdwdM8tXYDY_3nCvCD3I9EcI,19
12
- git_commit_message-0.7.0.dist-info/RECORD,,