git-commit-msg-ai 2.1.1__tar.gz → 2.2.0__tar.gz
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-2.1.1 → git_commit_msg_ai-2.2.0}/PKG-INFO +26 -9
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/README.md +21 -8
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai/ai_client.py +22 -2
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai/cli.py +4 -3
- git_commit_msg_ai-2.2.0/git_commit_msg_ai/config.py +142 -0
- git_commit_msg_ai-2.2.0/git_commit_msg_ai/git_ops.py +62 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai.egg-info/PKG-INFO +26 -9
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/pyproject.toml +7 -1
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/tests/test_ai_client.py +62 -1
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/tests/test_cli.py +60 -83
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/tests/test_config.py +193 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/tests/test_generate_release_notes.py +16 -4
- git_commit_msg_ai-2.2.0/tests/test_git_ops.py +183 -0
- git_commit_msg_ai-2.1.1/git_commit_msg_ai/config.py +0 -80
- git_commit_msg_ai-2.1.1/git_commit_msg_ai/git_ops.py +0 -41
- git_commit_msg_ai-2.1.1/tests/test_git_ops.py +0 -82
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/LICENSE +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai/__init__.py +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai/editor.py +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai/exceptions.py +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai.egg-info/SOURCES.txt +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai.egg-info/dependency_links.txt +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai.egg-info/entry_points.txt +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai.egg-info/requires.txt +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/git_commit_msg_ai.egg-info/top_level.txt +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/setup.cfg +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/tests/test_editor.py +0 -0
- {git_commit_msg_ai-2.1.1 → git_commit_msg_ai-2.2.0}/tests/test_exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-msg-ai
|
|
3
|
-
Version: 2.
|
|
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
|
+
[](https://github.com/ankit-d-joshi/git-commit-msg-ai/actions/workflows/ci.yml)
|
|
49
|
+
[](https://pypi.org/project/git-commit-msg-ai/)
|
|
50
|
+
[](https://pypi.org/project/git-commit-msg-ai/)
|
|
51
|
+
[](https://opensource.org/licenses/MIT)
|
|
52
|
+
[](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
|
|
85
|
-
ruff check .
|
|
86
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
204
|
-
git push origin
|
|
220
|
+
git tag v2.2.0
|
|
221
|
+
git push origin v2.2.0
|
|
205
222
|
```
|
|
206
223
|
|
|
207
224
|
## Debugging
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered git commit message generator that follows the [Conventional Commits](https://www.conventionalcommits.org/) specification.
|
|
4
4
|
|
|
5
|
+
[](https://github.com/ankit-d-joshi/git-commit-msg-ai/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/git-commit-msg-ai/)
|
|
7
|
+
[](https://pypi.org/project/git-commit-msg-ai/)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://pepy.tech/project/git-commit-msg-ai)
|
|
10
|
+
|
|
5
11
|
## Prerequisites
|
|
6
12
|
|
|
7
13
|
- Python 3.14+
|
|
@@ -42,9 +48,10 @@ pip install -e ".[dev]"
|
|
|
42
48
|
After activation the `git-commit-msg-ai` entry-point is on your PATH. You can also run the dev toolchain:
|
|
43
49
|
|
|
44
50
|
```sh
|
|
45
|
-
pytest
|
|
46
|
-
ruff check .
|
|
47
|
-
|
|
51
|
+
pytest # run tests with coverage
|
|
52
|
+
ruff check . # lint
|
|
53
|
+
ruff format --check . # format check
|
|
54
|
+
mypy . # type-check
|
|
48
55
|
```
|
|
49
56
|
|
|
50
57
|
## Installation
|
|
@@ -75,6 +82,8 @@ The tool will:
|
|
|
75
82
|
- **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
|
|
76
83
|
- **r** - exits without committing
|
|
77
84
|
|
|
85
|
+
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.
|
|
86
|
+
|
|
78
87
|
## Commit message format
|
|
79
88
|
|
|
80
89
|
Generated messages follow the Conventional Commits specification:
|
|
@@ -106,11 +115,11 @@ The set of optional commit types the AI may use can be customised through a conf
|
|
|
106
115
|
|
|
107
116
|
### Config file locations and precedence
|
|
108
117
|
|
|
109
|
-
The tool checks the following locations in order, using the first one that defines a
|
|
118
|
+
The tool checks the following locations in order, using the first one that defines a given setting:
|
|
110
119
|
|
|
111
120
|
1. **Project-level** — `pyproject.toml` walked up from the current working directory, under `[tool.git-commit-msg-ai]`
|
|
112
121
|
2. **User-level** — `~/.git-commit-msg-ai.toml` in your home directory
|
|
113
|
-
3. **Built-in defaults** — used when neither config file is present or neither defines
|
|
122
|
+
3. **Built-in defaults** — used when neither config file is present or neither defines the setting
|
|
114
123
|
|
|
115
124
|
### Config file formats
|
|
116
125
|
|
|
@@ -119,16 +128,20 @@ Project-level (`pyproject.toml`):
|
|
|
119
128
|
```toml
|
|
120
129
|
[tool.git-commit-msg-ai]
|
|
121
130
|
types = ["build", "ci", "docs", "perf", "refactor", "style", "test"]
|
|
131
|
+
context_commits = 10 # include last 10 commits for context (default: 5)
|
|
122
132
|
```
|
|
123
133
|
|
|
124
134
|
User-level (`~/.git-commit-msg-ai.toml`):
|
|
125
135
|
|
|
126
136
|
```toml
|
|
127
137
|
types = ["build", "ci", "docs", "chore"]
|
|
138
|
+
context_commits = 0 # disable commit context
|
|
128
139
|
```
|
|
129
140
|
|
|
130
141
|
Setting `types = []` restricts the AI to only the three mandatory types (`feat`, `fix`, `revert`).
|
|
131
142
|
|
|
143
|
+
`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.
|
|
144
|
+
|
|
132
145
|
### Default optional types
|
|
133
146
|
|
|
134
147
|
| Type | Purpose |
|
|
@@ -158,11 +171,11 @@ Pushing a version tag (e.g. `v1.5.2`) triggers the CD pipeline:
|
|
|
158
171
|
# 1. Bump the version in pyproject.toml
|
|
159
172
|
# 2. Commit and push
|
|
160
173
|
git add pyproject.toml
|
|
161
|
-
git commit -m "chore: bump version to
|
|
174
|
+
git commit -m "chore: bump version to 2.2.0"
|
|
162
175
|
git push origin main
|
|
163
176
|
# 3. Tag and push - this triggers the CD pipeline
|
|
164
|
-
git tag
|
|
165
|
-
git push origin
|
|
177
|
+
git tag v2.2.0
|
|
178
|
+
git push origin v2.2.0
|
|
166
179
|
```
|
|
167
180
|
|
|
168
181
|
## Debugging
|
|
@@ -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
|
|
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":
|
|
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.")
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Final, cast
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
MANDATORY_TYPES: Final[tuple[str, ...]] = ("feat", "fix", "revert")
|
|
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
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_pyproject_toml() -> Path | None:
|
|
21
|
+
for directory in [Path.cwd(), *Path.cwd().parents]:
|
|
22
|
+
candidate = directory / "pyproject.toml"
|
|
23
|
+
if candidate.is_file():
|
|
24
|
+
return candidate
|
|
25
|
+
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_types_list(types_value: object, source: str) -> tuple[str, ...] | None:
|
|
30
|
+
if types_value is None:
|
|
31
|
+
return None
|
|
32
|
+
if not isinstance(types_value, list):
|
|
33
|
+
logger.warning(f"'types' in {source} must be a TOML array, skipping")
|
|
34
|
+
return None
|
|
35
|
+
|
|
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
|
+
|
|
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)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_pkg_section_from_pyproject_toml(path: Path) -> dict[str, object] | None:
|
|
50
|
+
try:
|
|
51
|
+
with open(path, "rb") as toml_file:
|
|
52
|
+
data = tomllib.load(toml_file)
|
|
53
|
+
except tomllib.TOMLDecodeError:
|
|
54
|
+
logger.warning(f"Could not parse {path}, trying next config source")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
tool_section = data.get("tool")
|
|
58
|
+
if not isinstance(tool_section, dict):
|
|
59
|
+
return None
|
|
60
|
+
|
|
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
|
|
63
|
+
|
|
64
|
+
|
|
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():
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with open(path, "rb") as toml_file:
|
|
72
|
+
return tomllib.load(toml_file)
|
|
73
|
+
except tomllib.TOMLDecodeError:
|
|
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:
|
|
81
|
+
return None
|
|
82
|
+
return _parse_types_list(section.get("types"), f"[tool.git-commit-msg-ai] in {path}")
|
|
83
|
+
|
|
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"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_optional_types() -> tuple[str, ...]:
|
|
107
|
+
pyproject_path = _find_pyproject_toml()
|
|
108
|
+
if pyproject_path is not None:
|
|
109
|
+
result = _load_types_from_pyproject_toml(pyproject_path)
|
|
110
|
+
if result is not None:
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
result = _load_types_from_global_config()
|
|
114
|
+
if result is not None:
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
return DEFAULT_OPTIONAL_TYPES
|
|
118
|
+
|
|
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
|
+
|
|
141
|
+
def get_all_types(optional_types: tuple[str, ...]) -> tuple[str, ...]:
|
|
142
|
+
return tuple(dict.fromkeys(MANDATORY_TYPES + optional_types))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from git_commit_msg_ai.exceptions import GitError
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_staged_diff() -> str:
|
|
10
|
+
try:
|
|
11
|
+
logger.debug("Running: git diff --cached")
|
|
12
|
+
raw_diff_bytes = subprocess.check_output(["git", "diff", "--cached"])
|
|
13
|
+
staged_diff = raw_diff_bytes.decode("utf-8")
|
|
14
|
+
except FileNotFoundError:
|
|
15
|
+
raise GitError("git is not installed or not on PATH.")
|
|
16
|
+
except subprocess.CalledProcessError:
|
|
17
|
+
raise GitError("Failed to get staged diff. Are you inside a git repository?")
|
|
18
|
+
except UnicodeDecodeError:
|
|
19
|
+
raise GitError("Staged diff contains bytes that could not be decoded as UTF-8.")
|
|
20
|
+
|
|
21
|
+
logger.debug(f"Staged diff received: {len(staged_diff)} chars")
|
|
22
|
+
|
|
23
|
+
if not staged_diff:
|
|
24
|
+
raise GitError("No staged changes found. Stage files with git add before running.")
|
|
25
|
+
|
|
26
|
+
return staged_diff
|
|
27
|
+
|
|
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
|
+
|
|
53
|
+
def commit(message: str) -> None:
|
|
54
|
+
try:
|
|
55
|
+
logger.debug("Running: git commit -m <message>")
|
|
56
|
+
subprocess.run(["git", "commit", "-m", message], check=True)
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
raise GitError("git is not installed or not on PATH.")
|
|
59
|
+
except subprocess.CalledProcessError:
|
|
60
|
+
raise GitError("git commit failed.")
|
|
61
|
+
|
|
62
|
+
logger.info("Commit created successfully")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-msg-ai
|
|
3
|
-
Version: 2.
|
|
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
|
+
[](https://github.com/ankit-d-joshi/git-commit-msg-ai/actions/workflows/ci.yml)
|
|
49
|
+
[](https://pypi.org/project/git-commit-msg-ai/)
|
|
50
|
+
[](https://pypi.org/project/git-commit-msg-ai/)
|
|
51
|
+
[](https://opensource.org/licenses/MIT)
|
|
52
|
+
[](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
|
|
85
|
-
ruff check .
|
|
86
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
204
|
-
git push origin
|
|
220
|
+
git tag v2.2.0
|
|
221
|
+
git push origin v2.2.0
|
|
205
222
|
```
|
|
206
223
|
|
|
207
224
|
## Debugging
|
|
@@ -4,11 +4,17 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "git-commit-msg-ai"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "AI-powered git commit message generator following Conventional Commits"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {file = "LICENSE"}
|
|
11
11
|
requires-python = ">=3.14"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
12
18
|
dependencies = ["anthropic"]
|
|
13
19
|
|
|
14
20
|
[project.scripts]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from contextlib import ExitStack
|
|
2
|
+
from typing import Any
|
|
2
3
|
from unittest.mock import MagicMock, patch
|
|
3
4
|
|
|
4
5
|
import anthropic
|
|
@@ -105,7 +106,7 @@ class TestGenerateCommitMessage:
|
|
|
105
106
|
|
|
106
107
|
generate_commit_message("diff", ("feat", "fix", "chore"))
|
|
107
108
|
|
|
108
|
-
system_param = mock_client.messages.create.call_args.kwargs["system"]
|
|
109
|
+
system_param: list[Any] = mock_client.messages.create.call_args.kwargs["system"]
|
|
109
110
|
|
|
110
111
|
assert isinstance(system_param, list)
|
|
111
112
|
assert len(system_param) == 1
|
|
@@ -113,3 +114,63 @@ class TestGenerateCommitMessage:
|
|
|
113
114
|
assert block["type"] == "text"
|
|
114
115
|
assert block["cache_control"] == {"type": "ephemeral"}
|
|
115
116
|
assert "feat, fix, chore" in block["text"]
|
|
117
|
+
|
|
118
|
+
def test_generate_commit_message_without_context_sends_raw_diff(self) -> None:
|
|
119
|
+
with ExitStack() as stack:
|
|
120
|
+
mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
|
|
121
|
+
mock_client = MagicMock()
|
|
122
|
+
mock_client.messages.create.return_value = _make_api_response("feat: add feature")
|
|
123
|
+
mock_anthropic_class.return_value = mock_client
|
|
124
|
+
|
|
125
|
+
generate_commit_message("diff content", TYPES)
|
|
126
|
+
|
|
127
|
+
user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"]
|
|
128
|
+
|
|
129
|
+
assert user_content == "diff content"
|
|
130
|
+
|
|
131
|
+
def test_generate_commit_message_with_recent_commits_includes_them_in_user_message(self) -> None:
|
|
132
|
+
with ExitStack() as stack:
|
|
133
|
+
mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
|
|
134
|
+
mock_client = MagicMock()
|
|
135
|
+
mock_client.messages.create.return_value = _make_api_response("feat: add feature")
|
|
136
|
+
mock_anthropic_class.return_value = mock_client
|
|
137
|
+
|
|
138
|
+
generate_commit_message(
|
|
139
|
+
"diff content",
|
|
140
|
+
TYPES,
|
|
141
|
+
recent_commits=["feat: first commit", "fix: second commit"],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"]
|
|
145
|
+
|
|
146
|
+
assert "feat: first commit" in user_content
|
|
147
|
+
assert "fix: second commit" in user_content
|
|
148
|
+
assert "Recent commits" in user_content
|
|
149
|
+
assert "Staged diff:" in user_content
|
|
150
|
+
|
|
151
|
+
def test_generate_commit_message_with_branch_name_includes_it_in_user_message(self) -> None:
|
|
152
|
+
with ExitStack() as stack:
|
|
153
|
+
mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
|
|
154
|
+
mock_client = MagicMock()
|
|
155
|
+
mock_client.messages.create.return_value = _make_api_response("feat: add feature")
|
|
156
|
+
mock_anthropic_class.return_value = mock_client
|
|
157
|
+
|
|
158
|
+
generate_commit_message("diff content", TYPES, branch_name="feature/oauth-login")
|
|
159
|
+
|
|
160
|
+
user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"]
|
|
161
|
+
|
|
162
|
+
assert "Branch: feature/oauth-login" in user_content
|
|
163
|
+
assert "Staged diff:" in user_content
|
|
164
|
+
|
|
165
|
+
def test_generate_commit_message_with_empty_recent_commits_sends_raw_diff(self) -> None:
|
|
166
|
+
with ExitStack() as stack:
|
|
167
|
+
mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
|
|
168
|
+
mock_client = MagicMock()
|
|
169
|
+
mock_client.messages.create.return_value = _make_api_response("feat: add feature")
|
|
170
|
+
mock_anthropic_class.return_value = mock_client
|
|
171
|
+
|
|
172
|
+
generate_commit_message("diff content", TYPES, recent_commits=[])
|
|
173
|
+
|
|
174
|
+
user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"]
|
|
175
|
+
|
|
176
|
+
assert user_content == "diff content"
|