git-commit-msg-ai 2.0.0__py3-none-any.whl → 2.1.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.
- git_commit_msg_ai/ai_client.py +18 -11
- git_commit_msg_ai/cli.py +16 -17
- git_commit_msg_ai/config.py +80 -0
- {git_commit_msg_ai-2.0.0.dist-info → git_commit_msg_ai-2.1.0.dist-info}/METADATA +47 -4
- git_commit_msg_ai-2.1.0.dist-info/RECORD +12 -0
- git_commit_msg_ai-2.0.0.dist-info/RECORD +0 -11
- {git_commit_msg_ai-2.0.0.dist-info → git_commit_msg_ai-2.1.0.dist-info}/WHEEL +0 -0
- {git_commit_msg_ai-2.0.0.dist-info → git_commit_msg_ai-2.1.0.dist-info}/entry_points.txt +0 -0
- {git_commit_msg_ai-2.0.0.dist-info → git_commit_msg_ai-2.1.0.dist-info}/top_level.txt +0 -0
git_commit_msg_ai/ai_client.py
CHANGED
|
@@ -3,24 +3,31 @@ import textwrap
|
|
|
3
3
|
from typing import Final
|
|
4
4
|
|
|
5
5
|
import anthropic
|
|
6
|
+
from anthropic.types import CacheControlEphemeralParam, TextBlockParam
|
|
6
7
|
|
|
7
8
|
from git_commit_msg_ai.exceptions import AIError
|
|
8
9
|
|
|
9
10
|
logger = logging.getLogger(__name__)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- First line: <type>(<optional scope>)<!>: <short subject> (50 chars max); append ! before the colon if the commit introduces a breaking change
|
|
14
|
-
- Blank line
|
|
15
|
-
- Bullet-point body explaining WHY the changes were made, not what
|
|
16
|
-
- If there is a breaking change, add a blank line after the body followed by "BREAKING CHANGE: <description of what breaks and why>
|
|
17
|
-
Types: feat, fix, docs, style, refactor, test, chore""")
|
|
12
|
+
MODEL: Final[str] = "claude-sonnet-4-6"
|
|
13
|
+
MAX_TOKENS: Final[int] = 1024 # generous ceiling for any Conventional Commit message; caps API cost and output length
|
|
18
14
|
|
|
19
|
-
MODEL: Final[str] = "claude-haiku-4-5-20251001"
|
|
20
|
-
MAX_TOKENS: Final[int] = 1024
|
|
21
15
|
|
|
16
|
+
def _build_system_prompt(types: tuple[str, ...]) -> list[TextBlockParam]:
|
|
17
|
+
types_str = ", ".join(types)
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
text = textwrap.dedent(f"""\
|
|
20
|
+
You are a Git commit message generator. Output only the commit message, nothing else. Follow the Conventional Commits specification:
|
|
21
|
+
- First line: <type>(<optional scope>)<!>: <short subject> (50 chars max); append ! before the colon if the commit introduces a breaking change
|
|
22
|
+
- Blank line
|
|
23
|
+
- Bullet-point body explaining WHY the changes were made, not what
|
|
24
|
+
- If there is a breaking change, add a blank line after the body followed by "BREAKING CHANGE: <description of what breaks and why>
|
|
25
|
+
- For revert commits: begin the first line with "revert: " followed by the header of the reverted commit; the body must contain "This reverts commit <hash>." where <hash> is the SHA of the reverted commit
|
|
26
|
+
Types: {types_str}""")
|
|
27
|
+
return [TextBlockParam(type="text", text=text, cache_control=CacheControlEphemeralParam(type="ephemeral"))]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_commit_message(diff: str, types: tuple[str, ...]) -> str:
|
|
24
31
|
try:
|
|
25
32
|
anthropic_client = anthropic.Anthropic()
|
|
26
33
|
|
|
@@ -28,7 +35,7 @@ def generate_commit_message(diff: str) -> str:
|
|
|
28
35
|
anthropic_api_response = anthropic_client.messages.create(
|
|
29
36
|
model=MODEL,
|
|
30
37
|
max_tokens=MAX_TOKENS,
|
|
31
|
-
system=
|
|
38
|
+
system=_build_system_prompt(types),
|
|
32
39
|
messages=[{"role": "user", "content": diff}],
|
|
33
40
|
)
|
|
34
41
|
except anthropic.AuthenticationError:
|
git_commit_msg_ai/cli.py
CHANGED
|
@@ -3,19 +3,14 @@ import os
|
|
|
3
3
|
import sys
|
|
4
4
|
from typing import Final
|
|
5
5
|
|
|
6
|
-
from git_commit_msg_ai import ai_client, editor, git_ops
|
|
6
|
+
from git_commit_msg_ai import ai_client, config, editor, git_ops
|
|
7
7
|
from git_commit_msg_ai.exceptions import GitCommitAIError
|
|
8
8
|
|
|
9
9
|
LOG_LEVEL_ENVIRONMENT_VARIABLE: Final[str] = "GIT_COMMIT_AI_LOG_LEVEL"
|
|
10
10
|
DEFAULT_LOG_LEVEL: Final[str] = "WARNING"
|
|
11
11
|
LOG_FORMAT: Final[str] = "%(levelname)s:%(name)s:%(message)s"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
SELECTION_EDIT: Final[str] = "e"
|
|
15
|
-
SELECTION_REJECT: Final[str] = "r"
|
|
16
|
-
REJECTED_MESSAGE: Final[str] = "User rejected the generated commit message. No commit made."
|
|
17
|
-
INVALID_SELECTION_MESSAGE: Final[str] = "Invalid selection."
|
|
18
|
-
ABORTED_MESSAGE: Final[str] = "Aborted."
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
19
14
|
|
|
20
15
|
|
|
21
16
|
def main() -> None:
|
|
@@ -29,29 +24,33 @@ def main() -> None:
|
|
|
29
24
|
|
|
30
25
|
logging.basicConfig(level=effective_log_level, format=LOG_FORMAT, stream=sys.stderr)
|
|
31
26
|
|
|
27
|
+
optional_types = config.load_optional_types()
|
|
28
|
+
all_types = config.get_all_types(optional_types)
|
|
29
|
+
|
|
32
30
|
try:
|
|
33
31
|
diff = git_ops.get_staged_diff()
|
|
34
|
-
commit_message = ai_client.generate_commit_message(diff)
|
|
32
|
+
commit_message = ai_client.generate_commit_message(diff, all_types)
|
|
35
33
|
print(commit_message)
|
|
36
34
|
|
|
37
35
|
print()
|
|
38
|
-
user_selection = input(
|
|
36
|
+
user_selection = input("[a]ccept / [e]dit / [r]eject: ").strip().lower()
|
|
39
37
|
|
|
40
|
-
if user_selection ==
|
|
38
|
+
if user_selection == "a":
|
|
41
39
|
git_ops.commit(commit_message)
|
|
42
|
-
elif user_selection ==
|
|
40
|
+
elif user_selection == "e":
|
|
43
41
|
updated_commit_message = editor.open_in_editor(commit_message)
|
|
44
42
|
git_ops.commit(updated_commit_message)
|
|
45
|
-
elif user_selection ==
|
|
46
|
-
print(
|
|
43
|
+
elif user_selection == "r":
|
|
44
|
+
print("User rejected the generated commit message. No commit made.")
|
|
47
45
|
else:
|
|
48
|
-
print(
|
|
46
|
+
print("Invalid selection.")
|
|
49
47
|
sys.exit(1)
|
|
50
48
|
except GitCommitAIError as error:
|
|
51
|
-
|
|
49
|
+
logger.error(str(error))
|
|
50
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
52
51
|
sys.exit(1)
|
|
53
52
|
except KeyboardInterrupt, EOFError:
|
|
54
|
-
print(
|
|
53
|
+
print("Aborted.")
|
|
55
54
|
sys.exit(1)
|
|
56
55
|
|
|
57
56
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
MANDATORY_TYPES: Final[tuple[str, ...]] = ("feat", "fix", "revert")
|
|
9
|
+
DEFAULT_OPTIONAL_TYPES: Final[tuple[str, ...]] = ("build", "ci", "docs", "perf", "refactor", "style", "test")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _find_pyproject_toml() -> Path | None:
|
|
13
|
+
for directory in [Path.cwd(), *Path.cwd().parents]:
|
|
14
|
+
candidate = directory / "pyproject.toml"
|
|
15
|
+
if candidate.is_file():
|
|
16
|
+
return candidate
|
|
17
|
+
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_types_list(types_value: object, source: str) -> tuple[str, ...] | None:
|
|
22
|
+
if types_value is None:
|
|
23
|
+
return None
|
|
24
|
+
if not isinstance(types_value, list):
|
|
25
|
+
logger.warning(f"'types' in {source} must be a TOML array, skipping")
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
return tuple(entry.strip().lower() for entry in types_value if isinstance(entry, str) and entry.strip())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_from_pyproject_toml(path: Path) -> tuple[str, ...] | None:
|
|
32
|
+
try:
|
|
33
|
+
with open(path, "rb") as toml_file:
|
|
34
|
+
data = tomllib.load(toml_file)
|
|
35
|
+
except tomllib.TOMLDecodeError:
|
|
36
|
+
logger.warning(f"Could not parse {path}, trying next config source")
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
tool_section = data.get("tool")
|
|
40
|
+
if not isinstance(tool_section, dict):
|
|
41
|
+
return None
|
|
42
|
+
|
|
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}")
|
|
48
|
+
|
|
49
|
+
|
|
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():
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
with open(global_config, "rb") as toml_file:
|
|
57
|
+
data = tomllib.load(toml_file)
|
|
58
|
+
except tomllib.TOMLDecodeError:
|
|
59
|
+
logger.warning(f"Could not parse {global_config}, using default commit types")
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
return _parse_types_list(data.get("types"), str(global_config))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_optional_types() -> tuple[str, ...]:
|
|
66
|
+
pyproject_path = _find_pyproject_toml()
|
|
67
|
+
if pyproject_path is not None:
|
|
68
|
+
result = _load_from_pyproject_toml(pyproject_path)
|
|
69
|
+
if result is not None:
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
result = _load_from_global_config()
|
|
73
|
+
if result is not None:
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
return DEFAULT_OPTIONAL_TYPES
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_all_types(optional_types: tuple[str, ...]) -> tuple[str, ...]:
|
|
80
|
+
return tuple(dict.fromkeys(MANDATORY_TYPES + optional_types))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-msg-ai
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: AI-powered git commit message generator following Conventional Commits
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.14
|
|
@@ -112,7 +112,50 @@ feat(api)!: remove deprecated endpoint
|
|
|
112
112
|
BREAKING CHANGE: /v1/legacy is no longer available
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
Supported types: `feat`, `fix`, `docs`, `
|
|
115
|
+
Supported types: `feat`, `fix`, `revert`, `build`, `ci`, `docs`, `perf`, `refactor`, `style`, `test` (configurable — see [Configuration](#configuration))
|
|
116
|
+
|
|
117
|
+
`revert` commits always follow this format: the subject line begins with `revert: ` followed by the header of the reverted commit, and the body must contain `This reverts commit <hash>.`
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
The set of optional commit types the AI may use can be customised through a config file. `feat`, `fix`, and `revert` are always included regardless of any configuration.
|
|
122
|
+
|
|
123
|
+
### Config file locations and precedence
|
|
124
|
+
|
|
125
|
+
The tool checks the following locations in order, using the first one that defines a `types` list:
|
|
126
|
+
|
|
127
|
+
1. **Project-level** — `pyproject.toml` walked up from the current working directory, under `[tool.git-commit-msg-ai]`
|
|
128
|
+
2. **User-level** — `~/.git-commit-msg-ai.toml` in your home directory
|
|
129
|
+
3. **Built-in defaults** — used when neither config file is present or neither defines `types`
|
|
130
|
+
|
|
131
|
+
### Config file formats
|
|
132
|
+
|
|
133
|
+
Project-level (`pyproject.toml`):
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
[tool.git-commit-msg-ai]
|
|
137
|
+
types = ["build", "ci", "docs", "perf", "refactor", "style", "test"]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
User-level (`~/.git-commit-msg-ai.toml`):
|
|
141
|
+
|
|
142
|
+
```toml
|
|
143
|
+
types = ["build", "ci", "docs", "chore"]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Setting `types = []` restricts the AI to only the three mandatory types (`feat`, `fix`, `revert`).
|
|
147
|
+
|
|
148
|
+
### Default optional types
|
|
149
|
+
|
|
150
|
+
| Type | Purpose |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `build` | Changes to the build system or external dependencies |
|
|
153
|
+
| `ci` | Changes to CI configuration files and scripts |
|
|
154
|
+
| `docs` | Documentation-only changes |
|
|
155
|
+
| `perf` | A code change that improves performance |
|
|
156
|
+
| `refactor` | A code change that neither fixes a bug nor adds a feature |
|
|
157
|
+
| `style` | Changes that do not affect the meaning of the code (whitespace, formatting) |
|
|
158
|
+
| `test` | Adding missing tests or correcting existing tests |
|
|
116
159
|
|
|
117
160
|
## CI/CD
|
|
118
161
|
|
|
@@ -123,7 +166,7 @@ Pushing a version tag (e.g. `v1.5.2`) triggers the CD pipeline:
|
|
|
123
166
|
1. Tests are re-run as a gate
|
|
124
167
|
2. The tag version is verified to match the version in `pyproject.toml`
|
|
125
168
|
3. The package is built and published to [PyPI](https://pypi.org/project/git-commit-msg-ai/)
|
|
126
|
-
4. A GitHub Release is created with release notes generated by Claude
|
|
169
|
+
4. A GitHub Release is created with release notes generated by Claude Sonnet from the commit log
|
|
127
170
|
|
|
128
171
|
**Releasing a new version:**
|
|
129
172
|
|
|
@@ -140,7 +183,7 @@ git push origin v1.5.2
|
|
|
140
183
|
|
|
141
184
|
## Debugging
|
|
142
185
|
|
|
143
|
-
|
|
186
|
+
When a failure occurs (git error, API error, editor error), the tool always prints an error message to **stderr**. Diagnostic logs are disabled by default; to enable them, set the `GIT_COMMIT_AI_LOG_LEVEL` environment variable before running the command. Both error messages and logs are written to **stderr** and do not interfere with the generated commit message on **stdout**.
|
|
144
187
|
|
|
145
188
|
Valid values: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
|
|
146
189
|
|
|
@@ -0,0 +1,12 @@
|
|
|
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.0.dist-info/METADATA,sha256=tv8CnvgRuFz0UqVpjx-raiMG6LOFWt05kPNnHAUIURs,6110
|
|
9
|
+
git_commit_msg_ai-2.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
git_commit_msg_ai-2.1.0.dist-info/entry_points.txt,sha256=KTu6wUhl0p3nf27k8L4vpSH_hpeRQpwzMPSmKv7K5Cs,65
|
|
11
|
+
git_commit_msg_ai-2.1.0.dist-info/top_level.txt,sha256=XYQC2BXvrcREGKEG7sm9nbwO7ifqcUSVgU7SW8BTURs,18
|
|
12
|
+
git_commit_msg_ai-2.1.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
git_commit_msg_ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
git_commit_msg_ai/ai_client.py,sha256=DktS01lI62kaIRW4SccdelpwFn3VSJ_YRjlO5_IQE6I,2238
|
|
3
|
-
git_commit_msg_ai/cli.py,sha256=33N5IFgD_ENAfNTf3X4w4fsE9WyiV-qLneN5Lay7IdQ,2187
|
|
4
|
-
git_commit_msg_ai/editor.py,sha256=wW7TJ4zVvvN7ThLtrJthAf9FjdmIDTbBS7V5pIW490s,2143
|
|
5
|
-
git_commit_msg_ai/exceptions.py,sha256=7Hwluf3zHMjs4lpGktWS-Lwgo8y_4Xbb1WqzPQHkkUA,352
|
|
6
|
-
git_commit_msg_ai/git_ops.py,sha256=qZae4w3IEf3Lmf2T5oWItkgM0PrEYHCWr3TgBJLeecY,1387
|
|
7
|
-
git_commit_msg_ai-2.0.0.dist-info/METADATA,sha256=il4_uh_01x9ON1Dfz69-ExOB2a3z1gYRG4_z0DQy-MI,4226
|
|
8
|
-
git_commit_msg_ai-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
-
git_commit_msg_ai-2.0.0.dist-info/entry_points.txt,sha256=KTu6wUhl0p3nf27k8L4vpSH_hpeRQpwzMPSmKv7K5Cs,65
|
|
10
|
-
git_commit_msg_ai-2.0.0.dist-info/top_level.txt,sha256=XYQC2BXvrcREGKEG7sm9nbwO7ifqcUSVgU7SW8BTURs,18
|
|
11
|
-
git_commit_msg_ai-2.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|