git-commit-msg-ai 2.1.1__py3-none-any.whl → 2.2.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.
@@ -27,16 +27,36 @@ def _build_system_prompt(types: tuple[str, ...]) -> list[TextBlockParam]:
27
27
  return [TextBlockParam(type="text", text=text, cache_control=CacheControlEphemeralParam(type="ephemeral"))]
28
28
 
29
29
 
30
- def generate_commit_message(diff: str, types: tuple[str, ...]) -> str:
30
+ def _build_user_message(diff: str, recent_commits: list[str] | None, branch_name: str | None) -> str:
31
+ parts: list[str] = []
32
+ if branch_name:
33
+ parts.append(f"Branch: {branch_name}")
34
+ if recent_commits:
35
+ commit_lines = ["Recent commits (most recent first):"] + [f"- {commit}" for commit in recent_commits]
36
+ parts.append("\n".join(commit_lines))
37
+ if parts:
38
+ parts.append("\n".join(["Staged diff:", diff]))
39
+ return "\n\n".join(parts)
40
+ return diff
41
+
42
+
43
+ def generate_commit_message(
44
+ diff: str,
45
+ types: tuple[str, ...],
46
+ *,
47
+ recent_commits: list[str] | None = None,
48
+ branch_name: str | None = None,
49
+ ) -> str:
31
50
  try:
32
51
  anthropic_client = anthropic.Anthropic()
33
52
 
53
+ user_message = _build_user_message(diff, recent_commits, branch_name)
34
54
  logger.debug(f"Calling Anthropic API: model={MODEL} max_tokens={MAX_TOKENS}")
35
55
  anthropic_api_response = anthropic_client.messages.create(
36
56
  model=MODEL,
37
57
  max_tokens=MAX_TOKENS,
38
58
  system=_build_system_prompt(types),
39
- messages=[{"role": "user", "content": diff}],
59
+ messages=[{"role": "user", "content": user_message}],
40
60
  )
41
61
  except anthropic.AuthenticationError:
42
62
  raise AIError("Anthropic API key is missing or invalid. Set the ANTHROPIC_API_KEY environment variable.")
git_commit_msg_ai/cli.py CHANGED
@@ -24,12 +24,13 @@ def main() -> None:
24
24
 
25
25
  logging.basicConfig(level=effective_log_level, format=LOG_FORMAT, stream=sys.stderr)
26
26
 
27
- optional_types = config.load_optional_types()
28
- all_types = config.get_all_types(optional_types)
27
+ app_config = config.load_config()
29
28
 
30
29
  try:
31
30
  diff = git_ops.get_staged_diff()
32
- commit_message = ai_client.generate_commit_message(diff, all_types)
31
+ recent_commits = git_ops.get_recent_commit_messages(app_config.context_commits)
32
+ branch_name = git_ops.get_branch_name()
33
+ commit_message = ai_client.generate_commit_message(diff, app_config.types, recent_commits=recent_commits, branch_name=branch_name)
33
34
  print(commit_message)
34
35
 
35
36
  print()
@@ -1,12 +1,20 @@
1
1
  import logging
2
2
  import tomllib
3
+ from dataclasses import dataclass
3
4
  from pathlib import Path
4
- from typing import Final
5
+ from typing import Any, Final, cast
5
6
 
6
7
  logger = logging.getLogger(__name__)
7
8
 
8
9
  MANDATORY_TYPES: Final[tuple[str, ...]] = ("feat", "fix", "revert")
9
10
  DEFAULT_OPTIONAL_TYPES: Final[tuple[str, ...]] = ("build", "ci", "docs", "perf", "refactor", "style", "test")
11
+ CONTEXT_COMMITS_DEFAULT: Final[int] = 5
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AppConfig:
16
+ types: tuple[str, ...]
17
+ context_commits: int
10
18
 
11
19
 
12
20
  def _find_pyproject_toml() -> Path | None:
@@ -25,10 +33,20 @@ def _parse_types_list(types_value: object, source: str) -> tuple[str, ...] | Non
25
33
  logger.warning(f"'types' in {source} must be a TOML array, skipping")
26
34
  return None
27
35
 
28
- return tuple(entry.strip().lower() for entry in types_value if isinstance(entry, str) and entry.strip())
36
+ entries = cast(list[object], types_value)
37
+ return tuple(entry.strip().lower() for entry in entries if isinstance(entry, str) and entry.strip())
38
+
29
39
 
40
+ def _parse_context_commits(value: object, source: str) -> int | None:
41
+ if value is None:
42
+ return None
43
+ if isinstance(value, bool) or not isinstance(value, int):
44
+ logger.warning(f"'context_commits' in {source} must be an integer, skipping")
45
+ return None
46
+ return max(0, value)
30
47
 
31
- def _load_from_pyproject_toml(path: Path) -> tuple[str, ...] | None:
48
+
49
+ def _read_pkg_section_from_pyproject_toml(path: Path) -> dict[str, object] | None:
32
50
  try:
33
51
  with open(path, "rb") as toml_file:
34
52
  data = tomllib.load(toml_file)
@@ -40,41 +58,85 @@ def _load_from_pyproject_toml(path: Path) -> tuple[str, ...] | None:
40
58
  if not isinstance(tool_section, dict):
41
59
  return None
42
60
 
43
- pkg_section = tool_section.get("git-commit-msg-ai")
44
- if not isinstance(pkg_section, dict):
45
- return None
46
-
47
- return _parse_types_list(pkg_section.get("types"), f"[tool.git-commit-msg-ai] in {path}")
61
+ pkg_section = cast(dict[str, object], tool_section).get("git-commit-msg-ai")
62
+ return cast(dict[str, object], pkg_section) if isinstance(pkg_section, dict) else None
48
63
 
49
64
 
50
- def _load_from_global_config() -> tuple[str, ...] | None:
51
- global_config = Path.home() / ".git-commit-msg-ai.toml"
52
- if not global_config.is_file():
65
+ def _read_global_config_data() -> dict[str, Any] | None:
66
+ path = Path.home() / ".git-commit-msg-ai.toml"
67
+ if not path.is_file():
53
68
  return None
54
69
 
55
70
  try:
56
- with open(global_config, "rb") as toml_file:
57
- data = tomllib.load(toml_file)
71
+ with open(path, "rb") as toml_file:
72
+ return tomllib.load(toml_file)
58
73
  except tomllib.TOMLDecodeError:
59
- logger.warning(f"Could not parse {global_config}, using default commit types")
74
+ logger.warning(f"Could not parse {path}, using defaults")
75
+ return None
76
+
77
+
78
+ def _load_types_from_pyproject_toml(path: Path) -> tuple[str, ...] | None:
79
+ section = _read_pkg_section_from_pyproject_toml(path)
80
+ if section is None:
60
81
  return None
82
+ return _parse_types_list(section.get("types"), f"[tool.git-commit-msg-ai] in {path}")
61
83
 
62
- return _parse_types_list(data.get("types"), str(global_config))
84
+
85
+ def _load_types_from_global_config() -> tuple[str, ...] | None:
86
+ data = _read_global_config_data()
87
+ if data is None:
88
+ return None
89
+ return _parse_types_list(data.get("types"), str(Path.home() / ".git-commit-msg-ai.toml"))
90
+
91
+
92
+ def _load_context_commits_from_pyproject_toml(path: Path) -> int | None:
93
+ section = _read_pkg_section_from_pyproject_toml(path)
94
+ if section is None:
95
+ return None
96
+ return _parse_context_commits(section.get("context_commits"), f"[tool.git-commit-msg-ai] in {path}")
97
+
98
+
99
+ def _load_context_commits_from_global_config() -> int | None:
100
+ data = _read_global_config_data()
101
+ if data is None:
102
+ return None
103
+ return _parse_context_commits(data.get("context_commits"), str(Path.home() / ".git-commit-msg-ai.toml"))
63
104
 
64
105
 
65
106
  def load_optional_types() -> tuple[str, ...]:
66
107
  pyproject_path = _find_pyproject_toml()
67
108
  if pyproject_path is not None:
68
- result = _load_from_pyproject_toml(pyproject_path)
109
+ result = _load_types_from_pyproject_toml(pyproject_path)
69
110
  if result is not None:
70
111
  return result
71
112
 
72
- result = _load_from_global_config()
113
+ result = _load_types_from_global_config()
73
114
  if result is not None:
74
115
  return result
75
116
 
76
117
  return DEFAULT_OPTIONAL_TYPES
77
118
 
78
119
 
120
+ def load_context_commits() -> int:
121
+ pyproject_path = _find_pyproject_toml()
122
+ if pyproject_path is not None:
123
+ result = _load_context_commits_from_pyproject_toml(pyproject_path)
124
+ if result is not None:
125
+ return result
126
+
127
+ result = _load_context_commits_from_global_config()
128
+ if result is not None:
129
+ return result
130
+
131
+ return CONTEXT_COMMITS_DEFAULT
132
+
133
+
134
+ def load_config() -> AppConfig:
135
+ return AppConfig(
136
+ types=get_all_types(load_optional_types()),
137
+ context_commits=load_context_commits(),
138
+ )
139
+
140
+
79
141
  def get_all_types(optional_types: tuple[str, ...]) -> tuple[str, ...]:
80
142
  return tuple(dict.fromkeys(MANDATORY_TYPES + optional_types))
@@ -1,18 +1,15 @@
1
1
  import logging
2
2
  import subprocess
3
- from typing import Final
4
3
 
5
4
  from git_commit_msg_ai.exceptions import GitError
6
5
 
7
- GIT_COMMAND: Final[str] = "git"
8
-
9
6
  logger = logging.getLogger(__name__)
10
7
 
11
8
 
12
9
  def get_staged_diff() -> str:
13
10
  try:
14
- logger.debug(f"Running: {GIT_COMMAND} diff --cached")
15
- raw_diff_bytes = subprocess.check_output([GIT_COMMAND, "diff", "--cached"])
11
+ logger.debug("Running: git diff --cached")
12
+ raw_diff_bytes = subprocess.check_output(["git", "diff", "--cached"])
16
13
  staged_diff = raw_diff_bytes.decode("utf-8")
17
14
  except FileNotFoundError:
18
15
  raise GitError("git is not installed or not on PATH.")
@@ -29,10 +26,34 @@ def get_staged_diff() -> str:
29
26
  return staged_diff
30
27
 
31
28
 
29
+ def get_recent_commit_messages(n: int) -> list[str]:
30
+ if n <= 0:
31
+ return []
32
+ try:
33
+ logger.debug(f"Running: git log --format=%s -n {n}")
34
+ raw_git_log_bytes = subprocess.check_output(["git", "log", "--format=%s", f"-n{n}"])
35
+ commit_message_lines = raw_git_log_bytes.decode("utf-8").splitlines()
36
+ return [commit_message for commit_message in commit_message_lines if commit_message.strip()]
37
+ except Exception:
38
+ logger.debug("Could not retrieve recent commit messages; proceeding without context")
39
+ return []
40
+
41
+
42
+ def get_branch_name() -> str | None:
43
+ try:
44
+ logger.debug("Running: git rev-parse --abbrev-ref HEAD")
45
+ raw_branch_name_bytes = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
46
+ branch_name = raw_branch_name_bytes.decode("utf-8").strip()
47
+ return branch_name if branch_name != "HEAD" else None
48
+ except Exception:
49
+ logger.debug("Could not retrieve branch name; proceeding without context")
50
+ return None
51
+
52
+
32
53
  def commit(message: str) -> None:
33
54
  try:
34
- logger.debug(f"Running: {GIT_COMMAND} commit -m <message>")
35
- subprocess.run([GIT_COMMAND, "commit", "-m", message], check=True)
55
+ logger.debug("Running: git commit -m <message>")
56
+ subprocess.run(["git", "commit", "-m", message], check=True)
36
57
  except FileNotFoundError:
37
58
  raise GitError("git is not installed or not on PATH.")
38
59
  except subprocess.CalledProcessError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-msg-ai
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License: MIT License
6
6
 
@@ -24,6 +24,10 @@ License: MIT License
24
24
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
25
  SOFTWARE.
26
26
 
27
+ Classifier: Programming Language :: Python :: 3
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Classifier: Operating System :: OS Independent
27
31
  Requires-Python: >=3.14
28
32
  Description-Content-Type: text/markdown
29
33
  License-File: LICENSE
@@ -41,6 +45,12 @@ Dynamic: license-file
41
45
 
42
46
  AI-powered git commit message generator that follows the [Conventional Commits](https://www.conventionalcommits.org/) specification.
43
47
 
48
+ [![CI](https://github.com/ankit-d-joshi/git-commit-msg-ai/actions/workflows/ci.yml/badge.svg)](https://github.com/ankit-d-joshi/git-commit-msg-ai/actions/workflows/ci.yml)
49
+ [![PyPI](https://img.shields.io/pypi/v/git-commit-msg-ai)](https://pypi.org/project/git-commit-msg-ai/)
50
+ [![Python](https://img.shields.io/pypi/pyversions/git-commit-msg-ai)](https://pypi.org/project/git-commit-msg-ai/)
51
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
52
+ [![Downloads](https://static.pepy.tech/badge/git-commit-msg-ai)](https://pepy.tech/project/git-commit-msg-ai)
53
+
44
54
  ## Prerequisites
45
55
 
46
56
  - Python 3.14+
@@ -81,9 +91,10 @@ pip install -e ".[dev]"
81
91
  After activation the `git-commit-msg-ai` entry-point is on your PATH. You can also run the dev toolchain:
82
92
 
83
93
  ```sh
84
- pytest # run tests with coverage
85
- ruff check . # lint
86
- mypy . # type-check
94
+ pytest # run tests with coverage
95
+ ruff check . # lint
96
+ ruff format --check . # format check
97
+ mypy . # type-check
87
98
  ```
88
99
 
89
100
  ## Installation
@@ -114,6 +125,8 @@ The tool will:
114
125
  - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
115
126
  - **r** - exits without committing
116
127
 
128
+ The tool automatically reads the current branch name and recent commit history and includes them in the request to the AI. This helps the AI match the commit style already established in the project. No configuration is required to enable this; see [Configuration](#configuration) to control how many recent commits are included.
129
+
117
130
  ## Commit message format
118
131
 
119
132
  Generated messages follow the Conventional Commits specification:
@@ -145,11 +158,11 @@ The set of optional commit types the AI may use can be customised through a conf
145
158
 
146
159
  ### Config file locations and precedence
147
160
 
148
- The tool checks the following locations in order, using the first one that defines a `types` list:
161
+ The tool checks the following locations in order, using the first one that defines a given setting:
149
162
 
150
163
  1. **Project-level** — `pyproject.toml` walked up from the current working directory, under `[tool.git-commit-msg-ai]`
151
164
  2. **User-level** — `~/.git-commit-msg-ai.toml` in your home directory
152
- 3. **Built-in defaults** — used when neither config file is present or neither defines `types`
165
+ 3. **Built-in defaults** — used when neither config file is present or neither defines the setting
153
166
 
154
167
  ### Config file formats
155
168
 
@@ -158,16 +171,20 @@ Project-level (`pyproject.toml`):
158
171
  ```toml
159
172
  [tool.git-commit-msg-ai]
160
173
  types = ["build", "ci", "docs", "perf", "refactor", "style", "test"]
174
+ context_commits = 10 # include last 10 commits for context (default: 5)
161
175
  ```
162
176
 
163
177
  User-level (`~/.git-commit-msg-ai.toml`):
164
178
 
165
179
  ```toml
166
180
  types = ["build", "ci", "docs", "chore"]
181
+ context_commits = 0 # disable commit context
167
182
  ```
168
183
 
169
184
  Setting `types = []` restricts the AI to only the three mandatory types (`feat`, `fix`, `revert`).
170
185
 
186
+ `context_commits` controls how many recent commit messages are sent to the AI as context. The default is `5`. Set it to `0` to disable context entirely.
187
+
171
188
  ### Default optional types
172
189
 
173
190
  | Type | Purpose |
@@ -197,11 +214,11 @@ Pushing a version tag (e.g. `v1.5.2`) triggers the CD pipeline:
197
214
  # 1. Bump the version in pyproject.toml
198
215
  # 2. Commit and push
199
216
  git add pyproject.toml
200
- git commit -m "chore: bump version to 1.5.2"
217
+ git commit -m "chore: bump version to 2.2.0"
201
218
  git push origin main
202
219
  # 3. Tag and push - this triggers the CD pipeline
203
- git tag v1.5.2
204
- git push origin v1.5.2
220
+ git tag v2.2.0
221
+ git push origin v2.2.0
205
222
  ```
206
223
 
207
224
  ## Debugging
@@ -0,0 +1,13 @@
1
+ git_commit_msg_ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ git_commit_msg_ai/ai_client.py,sha256=zG5lzubMjvSnhhm9r9myesCdnxwg4IvpjLBOFCj6jxA,3517
3
+ git_commit_msg_ai/cli.py,sha256=m2b_5p8kg1JJZz0EnN0n6GtRKOcJJiDS3Uu-mb3FqOg,2183
4
+ git_commit_msg_ai/config.py,sha256=5TqQR7VGzpev2_4a2tlsKD00tdFeOoJ1_XDIBhy_gDA,4513
5
+ git_commit_msg_ai/editor.py,sha256=wW7TJ4zVvvN7ThLtrJthAf9FjdmIDTbBS7V5pIW490s,2143
6
+ git_commit_msg_ai/exceptions.py,sha256=7Hwluf3zHMjs4lpGktWS-Lwgo8y_4Xbb1WqzPQHkkUA,352
7
+ git_commit_msg_ai/git_ops.py,sha256=Ve4ngXj0B3sYI4fKjIm-jKKe982Ohr9nbv1ZhNRGWFk,2329
8
+ git_commit_msg_ai-2.2.0.dist-info/licenses/LICENSE,sha256=BBR2CbV1SRenzTi4vvRx5wTIg32q898vdAyaLDN5wDc,1068
9
+ git_commit_msg_ai-2.2.0.dist-info/METADATA,sha256=XzIWw6I7vCZQpPyW8yXow7fEoa7xOQkYkWK4E0zhbF4,8919
10
+ git_commit_msg_ai-2.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ git_commit_msg_ai-2.2.0.dist-info/entry_points.txt,sha256=KTu6wUhl0p3nf27k8L4vpSH_hpeRQpwzMPSmKv7K5Cs,65
12
+ git_commit_msg_ai-2.2.0.dist-info/top_level.txt,sha256=XYQC2BXvrcREGKEG7sm9nbwO7ifqcUSVgU7SW8BTURs,18
13
+ git_commit_msg_ai-2.2.0.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- git_commit_msg_ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- git_commit_msg_ai/ai_client.py,sha256=7B4bBrh9ZSd-1uChccnK3KYPnk9tXs9gOez5ASC5P54,2836
3
- git_commit_msg_ai/cli.py,sha256=MzFQWHLAcheg7ldWwDQG05KcAQeIjlKtiEmcSKzvDUk,2049
4
- git_commit_msg_ai/config.py,sha256=auD_QRw-8ngRz2p1DT5L5G5riszegZItSasmczv5W2s,2523
5
- git_commit_msg_ai/editor.py,sha256=wW7TJ4zVvvN7ThLtrJthAf9FjdmIDTbBS7V5pIW490s,2143
6
- git_commit_msg_ai/exceptions.py,sha256=7Hwluf3zHMjs4lpGktWS-Lwgo8y_4Xbb1WqzPQHkkUA,352
7
- git_commit_msg_ai/git_ops.py,sha256=qZae4w3IEf3Lmf2T5oWItkgM0PrEYHCWr3TgBJLeecY,1387
8
- git_commit_msg_ai-2.1.1.dist-info/licenses/LICENSE,sha256=BBR2CbV1SRenzTi4vvRx5wTIg32q898vdAyaLDN5wDc,1068
9
- git_commit_msg_ai-2.1.1.dist-info/METADATA,sha256=5Js_pF6lAvg16HqPyssL99hCB-Z8VyQ_JdPR35S2oCs,7448
10
- git_commit_msg_ai-2.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
- git_commit_msg_ai-2.1.1.dist-info/entry_points.txt,sha256=KTu6wUhl0p3nf27k8L4vpSH_hpeRQpwzMPSmKv7K5Cs,65
12
- git_commit_msg_ai-2.1.1.dist-info/top_level.txt,sha256=XYQC2BXvrcREGKEG7sm9nbwO7ifqcUSVgU7SW8BTURs,18
13
- git_commit_msg_ai-2.1.1.dist-info/RECORD,,