git-commit-msg-ai 2.0.1__tar.gz → 2.1.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.0.1 → git_commit_msg_ai-2.1.0}/PKG-INFO +46 -3
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/README.md +45 -2
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/ai_client.py +18 -11
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/cli.py +16 -17
- git_commit_msg_ai-2.1.0/git_commit_msg_ai/config.py +80 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/PKG-INFO +46 -3
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/SOURCES.txt +2 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/pyproject.toml +1 -1
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_ai_client.py +26 -6
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_cli.py +111 -0
- git_commit_msg_ai-2.1.0/tests/test_config.py +308 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/__init__.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/editor.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/exceptions.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/git_ops.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/dependency_links.txt +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/entry_points.txt +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/requires.txt +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/top_level.txt +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/setup.cfg +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_editor.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_exceptions.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_generate_release_notes.py +0 -0
- {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_git_ops.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-msg-ai
|
|
3
|
-
Version: 2.0
|
|
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
|
|
|
@@ -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
|
|
|
@@ -96,7 +96,50 @@ feat(api)!: remove deprecated endpoint
|
|
|
96
96
|
BREAKING CHANGE: /v1/legacy is no longer available
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
Supported types: `feat`, `fix`, `docs`, `
|
|
99
|
+
Supported types: `feat`, `fix`, `revert`, `build`, `ci`, `docs`, `perf`, `refactor`, `style`, `test` (configurable — see [Configuration](#configuration))
|
|
100
|
+
|
|
101
|
+
`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>.`
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
### Config file locations and precedence
|
|
108
|
+
|
|
109
|
+
The tool checks the following locations in order, using the first one that defines a `types` list:
|
|
110
|
+
|
|
111
|
+
1. **Project-level** — `pyproject.toml` walked up from the current working directory, under `[tool.git-commit-msg-ai]`
|
|
112
|
+
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 `types`
|
|
114
|
+
|
|
115
|
+
### Config file formats
|
|
116
|
+
|
|
117
|
+
Project-level (`pyproject.toml`):
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
[tool.git-commit-msg-ai]
|
|
121
|
+
types = ["build", "ci", "docs", "perf", "refactor", "style", "test"]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
User-level (`~/.git-commit-msg-ai.toml`):
|
|
125
|
+
|
|
126
|
+
```toml
|
|
127
|
+
types = ["build", "ci", "docs", "chore"]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Setting `types = []` restricts the AI to only the three mandatory types (`feat`, `fix`, `revert`).
|
|
131
|
+
|
|
132
|
+
### Default optional types
|
|
133
|
+
|
|
134
|
+
| Type | Purpose |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `build` | Changes to the build system or external dependencies |
|
|
137
|
+
| `ci` | Changes to CI configuration files and scripts |
|
|
138
|
+
| `docs` | Documentation-only changes |
|
|
139
|
+
| `perf` | A code change that improves performance |
|
|
140
|
+
| `refactor` | A code change that neither fixes a bug nor adds a feature |
|
|
141
|
+
| `style` | Changes that do not affect the meaning of the code (whitespace, formatting) |
|
|
142
|
+
| `test` | Adding missing tests or correcting existing tests |
|
|
100
143
|
|
|
101
144
|
## CI/CD
|
|
102
145
|
|
|
@@ -124,7 +167,7 @@ git push origin v1.5.2
|
|
|
124
167
|
|
|
125
168
|
## Debugging
|
|
126
169
|
|
|
127
|
-
|
|
170
|
+
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**.
|
|
128
171
|
|
|
129
172
|
Valid values: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
|
|
130
173
|
|
|
@@ -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
|
-
SYSTEM_PROMPT: Final[str] = textwrap.dedent("""\
|
|
12
|
-
You are a Git commit message generator. Output only the commit message, nothing else. Follow the Conventional Commits specification:
|
|
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""")
|
|
18
|
-
|
|
19
12
|
MODEL: Final[str] = "claude-sonnet-4-6"
|
|
20
|
-
MAX_TOKENS: Final[int] = 1024
|
|
13
|
+
MAX_TOKENS: Final[int] = 1024 # generous ceiling for any Conventional Commit message; caps API cost and output length
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_system_prompt(types: tuple[str, ...]) -> list[TextBlockParam]:
|
|
17
|
+
types_str = ", ".join(types)
|
|
18
|
+
|
|
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"))]
|
|
21
28
|
|
|
22
29
|
|
|
23
|
-
def generate_commit_message(diff: str) -> str:
|
|
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:
|
|
@@ -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.0
|
|
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
|
|
|
@@ -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
|
|
|
@@ -3,6 +3,7 @@ pyproject.toml
|
|
|
3
3
|
git_commit_msg_ai/__init__.py
|
|
4
4
|
git_commit_msg_ai/ai_client.py
|
|
5
5
|
git_commit_msg_ai/cli.py
|
|
6
|
+
git_commit_msg_ai/config.py
|
|
6
7
|
git_commit_msg_ai/editor.py
|
|
7
8
|
git_commit_msg_ai/exceptions.py
|
|
8
9
|
git_commit_msg_ai/git_ops.py
|
|
@@ -14,6 +15,7 @@ git_commit_msg_ai.egg-info/requires.txt
|
|
|
14
15
|
git_commit_msg_ai.egg-info/top_level.txt
|
|
15
16
|
tests/test_ai_client.py
|
|
16
17
|
tests/test_cli.py
|
|
18
|
+
tests/test_config.py
|
|
17
19
|
tests/test_editor.py
|
|
18
20
|
tests/test_exceptions.py
|
|
19
21
|
tests/test_generate_release_notes.py
|
|
@@ -7,6 +7,8 @@ import pytest
|
|
|
7
7
|
from git_commit_msg_ai.ai_client import generate_commit_message
|
|
8
8
|
from git_commit_msg_ai.exceptions import AIError
|
|
9
9
|
|
|
10
|
+
TYPES: tuple[str, ...] = ("feat", "fix", "docs")
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
def _make_api_response(text: str) -> MagicMock:
|
|
12
14
|
mock_text_block = MagicMock(spec=anthropic.types.TextBlock)
|
|
@@ -33,7 +35,7 @@ class TestGenerateCommitMessage:
|
|
|
33
35
|
mock_client.messages.create.return_value = _make_api_response(" feat: add feature ")
|
|
34
36
|
mock_anthropic_class.return_value = mock_client
|
|
35
37
|
|
|
36
|
-
result = generate_commit_message("diff content")
|
|
38
|
+
result = generate_commit_message("diff content", TYPES)
|
|
37
39
|
|
|
38
40
|
assert result == "feat: add feature"
|
|
39
41
|
|
|
@@ -45,7 +47,7 @@ class TestGenerateCommitMessage:
|
|
|
45
47
|
mock_anthropic_class.return_value = mock_client
|
|
46
48
|
|
|
47
49
|
with pytest.raises(AIError, match="ANTHROPIC_API_KEY"):
|
|
48
|
-
generate_commit_message("diff content")
|
|
50
|
+
generate_commit_message("diff content", TYPES)
|
|
49
51
|
|
|
50
52
|
def test_generate_commit_message_raises_ai_error_on_rate_limit_error(self) -> None:
|
|
51
53
|
with ExitStack() as stack:
|
|
@@ -55,7 +57,7 @@ class TestGenerateCommitMessage:
|
|
|
55
57
|
mock_anthropic_class.return_value = mock_client
|
|
56
58
|
|
|
57
59
|
with pytest.raises(AIError, match="rate limit"):
|
|
58
|
-
generate_commit_message("diff content")
|
|
60
|
+
generate_commit_message("diff content", TYPES)
|
|
59
61
|
|
|
60
62
|
def test_generate_commit_message_raises_ai_error_on_api_connection_error(self) -> None:
|
|
61
63
|
mock_request = MagicMock()
|
|
@@ -67,7 +69,7 @@ class TestGenerateCommitMessage:
|
|
|
67
69
|
mock_anthropic_class.return_value = mock_client
|
|
68
70
|
|
|
69
71
|
with pytest.raises(AIError, match="network"):
|
|
70
|
-
generate_commit_message("diff content")
|
|
72
|
+
generate_commit_message("diff content", TYPES)
|
|
71
73
|
|
|
72
74
|
def test_generate_commit_message_raises_ai_error_on_api_status_error_with_status_code(self) -> None:
|
|
73
75
|
with ExitStack() as stack:
|
|
@@ -77,7 +79,7 @@ class TestGenerateCommitMessage:
|
|
|
77
79
|
mock_anthropic_class.return_value = mock_client
|
|
78
80
|
|
|
79
81
|
with pytest.raises(AIError, match="500"):
|
|
80
|
-
generate_commit_message("diff content")
|
|
82
|
+
generate_commit_message("diff content", TYPES)
|
|
81
83
|
|
|
82
84
|
def test_generate_commit_message_raises_ai_error_on_non_text_block_response(self) -> None:
|
|
83
85
|
mock_non_text_block = MagicMock() # no spec=TextBlock → isinstance check fails
|
|
@@ -92,4 +94,22 @@ class TestGenerateCommitMessage:
|
|
|
92
94
|
mock_anthropic_class.return_value = mock_client
|
|
93
95
|
|
|
94
96
|
with pytest.raises(AIError, match="Unexpected response format"):
|
|
95
|
-
generate_commit_message("diff content")
|
|
97
|
+
generate_commit_message("diff content", TYPES)
|
|
98
|
+
|
|
99
|
+
def test_generate_commit_message_includes_types_in_system_prompt(self) -> None:
|
|
100
|
+
with ExitStack() as stack:
|
|
101
|
+
mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
|
|
102
|
+
mock_client = MagicMock()
|
|
103
|
+
mock_client.messages.create.return_value = _make_api_response("feat: add feature")
|
|
104
|
+
mock_anthropic_class.return_value = mock_client
|
|
105
|
+
|
|
106
|
+
generate_commit_message("diff", ("feat", "fix", "chore"))
|
|
107
|
+
|
|
108
|
+
system_param = mock_client.messages.create.call_args.kwargs["system"]
|
|
109
|
+
|
|
110
|
+
assert isinstance(system_param, list)
|
|
111
|
+
assert len(system_param) == 1
|
|
112
|
+
block = system_param[0]
|
|
113
|
+
assert block["type"] == "text"
|
|
114
|
+
assert block["cache_control"] == {"type": "ephemeral"}
|
|
115
|
+
assert "feat, fix, chore" in block["text"]
|
|
@@ -6,12 +6,14 @@ from unittest.mock import MagicMock, patch
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
from git_commit_msg_ai.cli import LOG_FORMAT, LOG_LEVEL_ENVIRONMENT_VARIABLE, main
|
|
9
|
+
from git_commit_msg_ai.config import DEFAULT_OPTIONAL_TYPES, MANDATORY_TYPES
|
|
9
10
|
from git_commit_msg_ai.exceptions import AIError, GitError
|
|
10
11
|
|
|
11
12
|
COMMIT_MESSAGE = "feat: add feature"
|
|
12
13
|
EDITED_COMMIT_MESSAGE = "edited commit message"
|
|
13
14
|
STAGED_DIFF = "diff content"
|
|
14
15
|
GENERIC_COMMIT_MESSAGE = "generic commit message"
|
|
16
|
+
ALL_TYPES = MANDATORY_TYPES + DEFAULT_OPTIONAL_TYPES
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class TestMain:
|
|
@@ -26,9 +28,13 @@ class TestMain:
|
|
|
26
28
|
mock_editor.open_in_editor.return_value = EDITED_COMMIT_MESSAGE
|
|
27
29
|
|
|
28
30
|
with ExitStack() as stack:
|
|
31
|
+
mock_config = MagicMock()
|
|
32
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
33
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
29
34
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
30
35
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
31
36
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor", mock_editor))
|
|
37
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
32
38
|
stack.enter_context(patch("builtins.input", return_value=user_input))
|
|
33
39
|
main()
|
|
34
40
|
|
|
@@ -68,12 +74,16 @@ class TestMain:
|
|
|
68
74
|
mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
|
|
69
75
|
mock_ai_client = MagicMock()
|
|
70
76
|
mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
|
|
77
|
+
mock_config = MagicMock()
|
|
78
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
79
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
71
80
|
|
|
72
81
|
# Act
|
|
73
82
|
with ExitStack() as stack:
|
|
74
83
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
75
84
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
76
85
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
86
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
77
87
|
stack.enter_context(patch("builtins.input", return_value="x"))
|
|
78
88
|
with pytest.raises(SystemExit) as exc_info:
|
|
79
89
|
main()
|
|
@@ -85,10 +95,14 @@ class TestMain:
|
|
|
85
95
|
# Arrange
|
|
86
96
|
mock_git_ops = MagicMock()
|
|
87
97
|
mock_git_ops.get_staged_diff.side_effect = GitError("staged error")
|
|
98
|
+
mock_config = MagicMock()
|
|
99
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
100
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
88
101
|
|
|
89
102
|
# Act
|
|
90
103
|
with ExitStack() as stack:
|
|
91
104
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
105
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
92
106
|
with pytest.raises(SystemExit) as exc_info:
|
|
93
107
|
main()
|
|
94
108
|
|
|
@@ -99,27 +113,53 @@ class TestMain:
|
|
|
99
113
|
# Arrange
|
|
100
114
|
mock_git_ops = MagicMock()
|
|
101
115
|
mock_git_ops.get_staged_diff.side_effect = GitError("staged error")
|
|
116
|
+
mock_config = MagicMock()
|
|
117
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
118
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
102
119
|
|
|
103
120
|
# Act
|
|
104
121
|
with ExitStack() as stack:
|
|
105
122
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
123
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
106
124
|
with pytest.raises(SystemExit):
|
|
107
125
|
main()
|
|
108
126
|
|
|
109
127
|
# Assert
|
|
110
128
|
assert "staged error" in caplog.text
|
|
111
129
|
|
|
130
|
+
def test_main_git_error_from_get_staged_diff_prints_error_to_stderr(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
131
|
+
# Arrange
|
|
132
|
+
mock_git_ops = MagicMock()
|
|
133
|
+
mock_git_ops.get_staged_diff.side_effect = GitError("staged error")
|
|
134
|
+
mock_config = MagicMock()
|
|
135
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
136
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
137
|
+
|
|
138
|
+
# Act
|
|
139
|
+
with ExitStack() as stack:
|
|
140
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
141
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
142
|
+
with pytest.raises(SystemExit):
|
|
143
|
+
main()
|
|
144
|
+
|
|
145
|
+
# Assert
|
|
146
|
+
assert "staged error" in capsys.readouterr().err
|
|
147
|
+
|
|
112
148
|
def test_main_ai_error_exits_with_code_1(self) -> None:
|
|
113
149
|
# Arrange
|
|
114
150
|
mock_git_ops = MagicMock()
|
|
115
151
|
mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
|
|
116
152
|
mock_ai_client = MagicMock()
|
|
117
153
|
mock_ai_client.generate_commit_message.side_effect = AIError("ai error")
|
|
154
|
+
mock_config = MagicMock()
|
|
155
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
156
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
118
157
|
|
|
119
158
|
# Act
|
|
120
159
|
with ExitStack() as stack:
|
|
121
160
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
122
161
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
162
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
123
163
|
with pytest.raises(SystemExit) as exc_info:
|
|
124
164
|
main()
|
|
125
165
|
|
|
@@ -132,17 +172,42 @@ class TestMain:
|
|
|
132
172
|
mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
|
|
133
173
|
mock_ai_client = MagicMock()
|
|
134
174
|
mock_ai_client.generate_commit_message.side_effect = AIError("ai error")
|
|
175
|
+
mock_config = MagicMock()
|
|
176
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
177
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
135
178
|
|
|
136
179
|
# Act
|
|
137
180
|
with ExitStack() as stack:
|
|
138
181
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
139
182
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
183
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
140
184
|
with pytest.raises(SystemExit):
|
|
141
185
|
main()
|
|
142
186
|
|
|
143
187
|
# Assert
|
|
144
188
|
assert "ai error" in caplog.text
|
|
145
189
|
|
|
190
|
+
def test_main_ai_error_prints_error_to_stderr(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
191
|
+
# Arrange
|
|
192
|
+
mock_git_ops = MagicMock()
|
|
193
|
+
mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
|
|
194
|
+
mock_ai_client = MagicMock()
|
|
195
|
+
mock_ai_client.generate_commit_message.side_effect = AIError("ai error")
|
|
196
|
+
mock_config = MagicMock()
|
|
197
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
198
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
199
|
+
|
|
200
|
+
# Act
|
|
201
|
+
with ExitStack() as stack:
|
|
202
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
203
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
204
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
205
|
+
with pytest.raises(SystemExit):
|
|
206
|
+
main()
|
|
207
|
+
|
|
208
|
+
# Assert
|
|
209
|
+
assert "ai error" in capsys.readouterr().err
|
|
210
|
+
|
|
146
211
|
def test_main_git_error_from_commit_exits_with_code_1(self) -> None:
|
|
147
212
|
# Arrange
|
|
148
213
|
mock_git_ops = MagicMock()
|
|
@@ -150,12 +215,16 @@ class TestMain:
|
|
|
150
215
|
mock_git_ops.commit.side_effect = GitError("commit error")
|
|
151
216
|
mock_ai_client = MagicMock()
|
|
152
217
|
mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
|
|
218
|
+
mock_config = MagicMock()
|
|
219
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
220
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
153
221
|
|
|
154
222
|
# Act
|
|
155
223
|
with ExitStack() as stack:
|
|
156
224
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
157
225
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
158
226
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
227
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
159
228
|
stack.enter_context(patch("builtins.input", return_value="a"))
|
|
160
229
|
with pytest.raises(SystemExit) as exc_info:
|
|
161
230
|
main()
|
|
@@ -170,12 +239,16 @@ class TestMain:
|
|
|
170
239
|
mock_git_ops.commit.side_effect = GitError("commit error")
|
|
171
240
|
mock_ai_client = MagicMock()
|
|
172
241
|
mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
|
|
242
|
+
mock_config = MagicMock()
|
|
243
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
244
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
173
245
|
|
|
174
246
|
# Act
|
|
175
247
|
with ExitStack() as stack:
|
|
176
248
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
177
249
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
178
250
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
251
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
179
252
|
stack.enter_context(patch("builtins.input", return_value="a"))
|
|
180
253
|
with pytest.raises(SystemExit):
|
|
181
254
|
main()
|
|
@@ -183,6 +256,30 @@ class TestMain:
|
|
|
183
256
|
# Assert
|
|
184
257
|
assert "commit error" in caplog.text
|
|
185
258
|
|
|
259
|
+
def test_main_git_error_from_commit_prints_error_to_stderr(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
260
|
+
# Arrange
|
|
261
|
+
mock_git_ops = MagicMock()
|
|
262
|
+
mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
|
|
263
|
+
mock_git_ops.commit.side_effect = GitError("commit error")
|
|
264
|
+
mock_ai_client = MagicMock()
|
|
265
|
+
mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
|
|
266
|
+
mock_config = MagicMock()
|
|
267
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
268
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
269
|
+
|
|
270
|
+
# Act
|
|
271
|
+
with ExitStack() as stack:
|
|
272
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
273
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
274
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
275
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
276
|
+
stack.enter_context(patch("builtins.input", return_value="a"))
|
|
277
|
+
with pytest.raises(SystemExit):
|
|
278
|
+
main()
|
|
279
|
+
|
|
280
|
+
# Assert
|
|
281
|
+
assert "commit error" in capsys.readouterr().err
|
|
282
|
+
|
|
186
283
|
@pytest.mark.parametrize("exception", [KeyboardInterrupt, EOFError])
|
|
187
284
|
def test_main_interrupt_exits_with_code_1(self, exception: type[BaseException]) -> None:
|
|
188
285
|
# Arrange
|
|
@@ -191,11 +288,16 @@ class TestMain:
|
|
|
191
288
|
mock_ai_client = MagicMock()
|
|
192
289
|
mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
|
|
193
290
|
|
|
291
|
+
mock_config = MagicMock()
|
|
292
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
293
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
294
|
+
|
|
194
295
|
# Act
|
|
195
296
|
with ExitStack() as stack:
|
|
196
297
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
197
298
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
198
299
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
300
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
199
301
|
stack.enter_context(patch("builtins.input", side_effect=exception))
|
|
200
302
|
with pytest.raises(SystemExit) as exc_info:
|
|
201
303
|
main()
|
|
@@ -211,11 +313,16 @@ class TestMain:
|
|
|
211
313
|
mock_ai_client = MagicMock()
|
|
212
314
|
mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
|
|
213
315
|
|
|
316
|
+
mock_config = MagicMock()
|
|
317
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
318
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
319
|
+
|
|
214
320
|
# Act
|
|
215
321
|
with ExitStack() as stack:
|
|
216
322
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
217
323
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
218
324
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
325
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
219
326
|
stack.enter_context(patch("builtins.input", side_effect=exception))
|
|
220
327
|
with pytest.raises(SystemExit):
|
|
221
328
|
main()
|
|
@@ -231,9 +338,13 @@ class TestMain:
|
|
|
231
338
|
mock_ai_client.generate_commit_message.return_value = "feat: x"
|
|
232
339
|
|
|
233
340
|
with ExitStack() as stack:
|
|
341
|
+
mock_config = MagicMock()
|
|
342
|
+
mock_config.load_optional_types.return_value = DEFAULT_OPTIONAL_TYPES
|
|
343
|
+
mock_config.get_all_types.return_value = ALL_TYPES
|
|
234
344
|
stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
|
|
235
345
|
stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
|
|
236
346
|
stack.enter_context(patch("git_commit_msg_ai.cli.editor"))
|
|
347
|
+
stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
|
|
237
348
|
stack.enter_context(patch("builtins.input", return_value="r"))
|
|
238
349
|
main()
|
|
239
350
|
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from contextlib import ExitStack
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from git_commit_msg_ai.config import (
|
|
6
|
+
DEFAULT_OPTIONAL_TYPES,
|
|
7
|
+
MANDATORY_TYPES,
|
|
8
|
+
get_all_types,
|
|
9
|
+
load_optional_types,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestLoadOptionalTypes:
|
|
14
|
+
def test_no_pyproject_toml_and_no_global_config_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
15
|
+
home_dir = tmp_path / "home"
|
|
16
|
+
home_dir.mkdir()
|
|
17
|
+
|
|
18
|
+
with ExitStack() as stack:
|
|
19
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
20
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
21
|
+
result = load_optional_types()
|
|
22
|
+
|
|
23
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
24
|
+
|
|
25
|
+
def test_pyproject_toml_without_tool_section_and_no_global_config_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
26
|
+
home_dir = tmp_path / "home"
|
|
27
|
+
home_dir.mkdir()
|
|
28
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
29
|
+
[project]
|
|
30
|
+
name = "foo"
|
|
31
|
+
""")
|
|
32
|
+
|
|
33
|
+
with ExitStack() as stack:
|
|
34
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
35
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
36
|
+
result = load_optional_types()
|
|
37
|
+
|
|
38
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
39
|
+
|
|
40
|
+
def test_pyproject_toml_with_other_tool_section_and_no_global_config_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
41
|
+
home_dir = tmp_path / "home"
|
|
42
|
+
home_dir.mkdir()
|
|
43
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
44
|
+
[tool.other-tool]
|
|
45
|
+
key = "value"
|
|
46
|
+
""")
|
|
47
|
+
|
|
48
|
+
with ExitStack() as stack:
|
|
49
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
50
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
51
|
+
result = load_optional_types()
|
|
52
|
+
|
|
53
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
54
|
+
|
|
55
|
+
def test_pyproject_toml_with_package_section_without_types_key_and_no_global_config_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
56
|
+
home_dir = tmp_path / "home"
|
|
57
|
+
home_dir.mkdir()
|
|
58
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
59
|
+
[tool.git-commit-msg-ai]
|
|
60
|
+
other_key = "value"
|
|
61
|
+
""")
|
|
62
|
+
|
|
63
|
+
with ExitStack() as stack:
|
|
64
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
65
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
66
|
+
result = load_optional_types()
|
|
67
|
+
|
|
68
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
69
|
+
|
|
70
|
+
def test_pyproject_toml_with_valid_types_returns_configured_types(self, tmp_path: Path) -> None:
|
|
71
|
+
home_dir = tmp_path / "home"
|
|
72
|
+
home_dir.mkdir()
|
|
73
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
74
|
+
[tool.git-commit-msg-ai]
|
|
75
|
+
types = ["build", "ci", "docs"]
|
|
76
|
+
""")
|
|
77
|
+
|
|
78
|
+
with ExitStack() as stack:
|
|
79
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
80
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
81
|
+
result = load_optional_types()
|
|
82
|
+
|
|
83
|
+
assert result == ("build", "ci", "docs")
|
|
84
|
+
|
|
85
|
+
def test_pyproject_toml_types_are_stripped_and_lowercased(self, tmp_path: Path) -> None:
|
|
86
|
+
home_dir = tmp_path / "home"
|
|
87
|
+
home_dir.mkdir()
|
|
88
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
89
|
+
[tool.git-commit-msg-ai]
|
|
90
|
+
types = [" Build ", "CI"]
|
|
91
|
+
""")
|
|
92
|
+
|
|
93
|
+
with ExitStack() as stack:
|
|
94
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
95
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
96
|
+
result = load_optional_types()
|
|
97
|
+
|
|
98
|
+
assert result == ("build", "ci")
|
|
99
|
+
|
|
100
|
+
def test_pyproject_toml_empty_string_entries_are_filtered(self, tmp_path: Path) -> None:
|
|
101
|
+
home_dir = tmp_path / "home"
|
|
102
|
+
home_dir.mkdir()
|
|
103
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
104
|
+
[tool.git-commit-msg-ai]
|
|
105
|
+
types = ["docs", "", " ", "test"]
|
|
106
|
+
""")
|
|
107
|
+
|
|
108
|
+
with ExitStack() as stack:
|
|
109
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
110
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
111
|
+
result = load_optional_types()
|
|
112
|
+
|
|
113
|
+
assert result == ("docs", "test")
|
|
114
|
+
|
|
115
|
+
def test_pyproject_toml_non_string_entries_are_filtered(self, tmp_path: Path) -> None:
|
|
116
|
+
home_dir = tmp_path / "home"
|
|
117
|
+
home_dir.mkdir()
|
|
118
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
119
|
+
[tool.git-commit-msg-ai]
|
|
120
|
+
types = [1, "docs", true, "test"]
|
|
121
|
+
""")
|
|
122
|
+
|
|
123
|
+
with ExitStack() as stack:
|
|
124
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
125
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
126
|
+
result = load_optional_types()
|
|
127
|
+
|
|
128
|
+
assert result == ("docs", "test")
|
|
129
|
+
|
|
130
|
+
def test_pyproject_toml_empty_types_list_returns_empty_tuple(self, tmp_path: Path) -> None:
|
|
131
|
+
home_dir = tmp_path / "home"
|
|
132
|
+
home_dir.mkdir()
|
|
133
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
134
|
+
[tool.git-commit-msg-ai]
|
|
135
|
+
types = []
|
|
136
|
+
""")
|
|
137
|
+
|
|
138
|
+
with ExitStack() as stack:
|
|
139
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
140
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
141
|
+
result = load_optional_types()
|
|
142
|
+
|
|
143
|
+
assert result == ()
|
|
144
|
+
|
|
145
|
+
def test_pyproject_toml_invalid_toml_falls_through_and_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
146
|
+
home_dir = tmp_path / "home"
|
|
147
|
+
home_dir.mkdir()
|
|
148
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
149
|
+
this is not valid toml }{}
|
|
150
|
+
""")
|
|
151
|
+
|
|
152
|
+
with ExitStack() as stack:
|
|
153
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
154
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
155
|
+
result = load_optional_types()
|
|
156
|
+
|
|
157
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
158
|
+
|
|
159
|
+
def test_pyproject_toml_types_is_string_not_array_falls_through_and_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
160
|
+
home_dir = tmp_path / "home"
|
|
161
|
+
home_dir.mkdir()
|
|
162
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
163
|
+
[tool.git-commit-msg-ai]
|
|
164
|
+
types = "docs"
|
|
165
|
+
""")
|
|
166
|
+
|
|
167
|
+
with ExitStack() as stack:
|
|
168
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
169
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
170
|
+
result = load_optional_types()
|
|
171
|
+
|
|
172
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
173
|
+
|
|
174
|
+
def test_pyproject_toml_discovered_by_walking_up_from_subdirectory(self, tmp_path: Path) -> None:
|
|
175
|
+
home_dir = tmp_path / "home"
|
|
176
|
+
home_dir.mkdir()
|
|
177
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
178
|
+
[tool.git-commit-msg-ai]
|
|
179
|
+
types = ["build", "ci"]
|
|
180
|
+
""")
|
|
181
|
+
subdir = tmp_path / "subdir"
|
|
182
|
+
subdir.mkdir()
|
|
183
|
+
|
|
184
|
+
with ExitStack() as stack:
|
|
185
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=subdir))
|
|
186
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
187
|
+
result = load_optional_types()
|
|
188
|
+
|
|
189
|
+
assert result == ("build", "ci")
|
|
190
|
+
|
|
191
|
+
def test_global_config_used_when_no_pyproject_toml_section(self, tmp_path: Path) -> None:
|
|
192
|
+
cwd_dir = tmp_path / "project"
|
|
193
|
+
cwd_dir.mkdir()
|
|
194
|
+
home_dir = tmp_path / "home"
|
|
195
|
+
home_dir.mkdir()
|
|
196
|
+
(home_dir / ".git-commit-msg-ai.toml").write_text("""\
|
|
197
|
+
types = ["chore"]
|
|
198
|
+
""")
|
|
199
|
+
|
|
200
|
+
with ExitStack() as stack:
|
|
201
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=cwd_dir))
|
|
202
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
203
|
+
result = load_optional_types()
|
|
204
|
+
|
|
205
|
+
assert result == ("chore",)
|
|
206
|
+
|
|
207
|
+
def test_global_config_empty_types_returns_empty_tuple(self, tmp_path: Path) -> None:
|
|
208
|
+
cwd_dir = tmp_path / "project"
|
|
209
|
+
cwd_dir.mkdir()
|
|
210
|
+
home_dir = tmp_path / "home"
|
|
211
|
+
home_dir.mkdir()
|
|
212
|
+
(home_dir / ".git-commit-msg-ai.toml").write_text("""\
|
|
213
|
+
types = []
|
|
214
|
+
""")
|
|
215
|
+
|
|
216
|
+
with ExitStack() as stack:
|
|
217
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=cwd_dir))
|
|
218
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
219
|
+
result = load_optional_types()
|
|
220
|
+
|
|
221
|
+
assert result == ()
|
|
222
|
+
|
|
223
|
+
def test_global_config_invalid_toml_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
224
|
+
cwd_dir = tmp_path / "project"
|
|
225
|
+
cwd_dir.mkdir()
|
|
226
|
+
home_dir = tmp_path / "home"
|
|
227
|
+
home_dir.mkdir()
|
|
228
|
+
(home_dir / ".git-commit-msg-ai.toml").write_text("""\
|
|
229
|
+
this is not valid toml }{}
|
|
230
|
+
""")
|
|
231
|
+
|
|
232
|
+
with ExitStack() as stack:
|
|
233
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=cwd_dir))
|
|
234
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
235
|
+
result = load_optional_types()
|
|
236
|
+
|
|
237
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
238
|
+
|
|
239
|
+
def test_global_config_without_types_key_returns_default_optional_types(self, tmp_path: Path) -> None:
|
|
240
|
+
cwd_dir = tmp_path / "project"
|
|
241
|
+
cwd_dir.mkdir()
|
|
242
|
+
home_dir = tmp_path / "home"
|
|
243
|
+
home_dir.mkdir()
|
|
244
|
+
(home_dir / ".git-commit-msg-ai.toml").write_text("""\
|
|
245
|
+
other_key = "value"
|
|
246
|
+
""")
|
|
247
|
+
|
|
248
|
+
with ExitStack() as stack:
|
|
249
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=cwd_dir))
|
|
250
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
251
|
+
result = load_optional_types()
|
|
252
|
+
|
|
253
|
+
assert result == DEFAULT_OPTIONAL_TYPES
|
|
254
|
+
|
|
255
|
+
def test_pyproject_toml_takes_precedence_over_global_config(self, tmp_path: Path) -> None:
|
|
256
|
+
home_dir = tmp_path / "home"
|
|
257
|
+
home_dir.mkdir()
|
|
258
|
+
(tmp_path / "pyproject.toml").write_text("""\
|
|
259
|
+
[tool.git-commit-msg-ai]
|
|
260
|
+
types = ["build"]
|
|
261
|
+
""")
|
|
262
|
+
(home_dir / ".git-commit-msg-ai.toml").write_text("""\
|
|
263
|
+
types = ["chore"]
|
|
264
|
+
""")
|
|
265
|
+
|
|
266
|
+
with ExitStack() as stack:
|
|
267
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.cwd", return_value=tmp_path))
|
|
268
|
+
stack.enter_context(patch("git_commit_msg_ai.config.Path.home", return_value=home_dir))
|
|
269
|
+
result = load_optional_types()
|
|
270
|
+
|
|
271
|
+
assert result == ("build",)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class TestGetAllTypes:
|
|
275
|
+
def test_get_all_types_with_default_optional_types(self) -> None:
|
|
276
|
+
result = get_all_types(DEFAULT_OPTIONAL_TYPES)
|
|
277
|
+
|
|
278
|
+
assert result == MANDATORY_TYPES + DEFAULT_OPTIONAL_TYPES
|
|
279
|
+
|
|
280
|
+
def test_get_all_types_with_empty_optional_types_returns_only_mandatory_types(self) -> None:
|
|
281
|
+
result = get_all_types(())
|
|
282
|
+
|
|
283
|
+
assert result == MANDATORY_TYPES
|
|
284
|
+
|
|
285
|
+
def test_get_all_types_deduplicates_feat_in_optional_types(self) -> None:
|
|
286
|
+
result = get_all_types(("feat", "docs"))
|
|
287
|
+
|
|
288
|
+
assert result.count("feat") == 1
|
|
289
|
+
|
|
290
|
+
def test_get_all_types_deduplicates_fix_in_optional_types(self) -> None:
|
|
291
|
+
result = get_all_types(("fix", "docs"))
|
|
292
|
+
|
|
293
|
+
assert result.count("fix") == 1
|
|
294
|
+
|
|
295
|
+
def test_get_all_types_deduplicates_revert_in_optional_types(self) -> None:
|
|
296
|
+
result = get_all_types(("revert", "docs"))
|
|
297
|
+
|
|
298
|
+
assert result.count("revert") == 1
|
|
299
|
+
|
|
300
|
+
def test_get_all_types_deduplicates_all_mandatory_types_in_optional_types(self) -> None:
|
|
301
|
+
result = get_all_types(("feat", "fix", "revert", "docs"))
|
|
302
|
+
|
|
303
|
+
assert result == ("feat", "fix", "revert", "docs")
|
|
304
|
+
|
|
305
|
+
def test_get_all_types_mandatory_types_always_come_first(self) -> None:
|
|
306
|
+
result = get_all_types(("docs", "build"))
|
|
307
|
+
|
|
308
|
+
assert result[:3] == MANDATORY_TYPES
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|