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.
Files changed (24) hide show
  1. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/PKG-INFO +46 -3
  2. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/README.md +45 -2
  3. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/ai_client.py +18 -11
  4. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/cli.py +16 -17
  5. git_commit_msg_ai-2.1.0/git_commit_msg_ai/config.py +80 -0
  6. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/PKG-INFO +46 -3
  7. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/SOURCES.txt +2 -0
  8. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/pyproject.toml +1 -1
  9. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_ai_client.py +26 -6
  10. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_cli.py +111 -0
  11. git_commit_msg_ai-2.1.0/tests/test_config.py +308 -0
  12. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/__init__.py +0 -0
  13. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/editor.py +0 -0
  14. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/exceptions.py +0 -0
  15. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai/git_ops.py +0 -0
  16. {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
  17. {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
  18. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/git_commit_msg_ai.egg-info/requires.txt +0 -0
  19. {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
  20. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/setup.cfg +0 -0
  21. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_editor.py +0 -0
  22. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_exceptions.py +0 -0
  23. {git_commit_msg_ai-2.0.1 → git_commit_msg_ai-2.1.0}/tests/test_generate_release_notes.py +0 -0
  24. {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.1
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`, `style`, `refactor`, `test`, `chore`
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
- By default the tool produces no diagnostic output. To enable logging, set the `GIT_COMMIT_AI_LOG_LEVEL` environment variable before running the command. Logs are written to **stderr** and do not interfere with the generated commit message on **stdout**.
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`, `style`, `refactor`, `test`, `chore`
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
- By default the tool produces no diagnostic output. To enable logging, set the `GIT_COMMIT_AI_LOG_LEVEL` environment variable before running the command. Logs are written to **stderr** and do not interfere with the generated commit message on **stdout**.
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=SYSTEM_PROMPT,
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
- SELECTION_PROMPT: Final[str] = "[a]ccept / [e]dit / [r]eject: "
13
- SELECTION_ACCEPT: Final[str] = "a"
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(SELECTION_PROMPT).strip().lower()
36
+ user_selection = input("[a]ccept / [e]dit / [r]eject: ").strip().lower()
39
37
 
40
- if user_selection == SELECTION_ACCEPT:
38
+ if user_selection == "a":
41
39
  git_ops.commit(commit_message)
42
- elif user_selection == SELECTION_EDIT:
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 == SELECTION_REJECT:
46
- print(REJECTED_MESSAGE)
43
+ elif user_selection == "r":
44
+ print("User rejected the generated commit message. No commit made.")
47
45
  else:
48
- print(INVALID_SELECTION_MESSAGE)
46
+ print("Invalid selection.")
49
47
  sys.exit(1)
50
48
  except GitCommitAIError as error:
51
- logging.error(str(error))
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(ABORTED_MESSAGE)
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.1
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`, `style`, `refactor`, `test`, `chore`
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
- By default the tool produces no diagnostic output. To enable logging, set the `GIT_COMMIT_AI_LOG_LEVEL` environment variable before running the command. Logs are written to **stderr** and do not interfere with the generated commit message on **stdout**.
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-commit-msg-ai"
7
- version = "2.0.1"
7
+ version = "2.1.0"
8
8
  description = "AI-powered git commit message generator following Conventional Commits"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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