git-commit-msg-ai 1.4.2__tar.gz → 1.5.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-1.4.2 → git_commit_msg_ai-1.5.0}/PKG-INFO +23 -1
  2. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/README.md +22 -0
  3. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai/ai_client.py +10 -0
  4. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai/cli.py +16 -0
  5. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai/editor.py +16 -0
  6. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai/git_ops.py +17 -1
  7. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai.egg-info/PKG-INFO +23 -1
  8. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/pyproject.toml +1 -1
  9. git_commit_msg_ai-1.5.0/tests/test_ai_client.py +158 -0
  10. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/tests/test_cli.py +71 -1
  11. git_commit_msg_ai-1.5.0/tests/test_editor.py +245 -0
  12. git_commit_msg_ai-1.5.0/tests/test_git_ops.py +189 -0
  13. git_commit_msg_ai-1.4.2/tests/test_ai_client.py +0 -80
  14. git_commit_msg_ai-1.4.2/tests/test_editor.py +0 -137
  15. git_commit_msg_ai-1.4.2/tests/test_git_ops.py +0 -81
  16. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai/__init__.py +0 -0
  17. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai/exceptions.py +0 -0
  18. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai.egg-info/SOURCES.txt +0 -0
  19. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai.egg-info/dependency_links.txt +0 -0
  20. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai.egg-info/entry_points.txt +0 -0
  21. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai.egg-info/requires.txt +0 -0
  22. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/git_commit_msg_ai.egg-info/top_level.txt +0 -0
  23. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/setup.cfg +0 -0
  24. {git_commit_msg_ai-1.4.2 → git_commit_msg_ai-1.5.0}/tests/test_exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-msg-ai
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -113,3 +113,25 @@ BREAKING CHANGE: /v1/legacy is no longer available
113
113
  ```
114
114
 
115
115
  Supported types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
116
+
117
+ ## Debugging
118
+
119
+ 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**.
120
+
121
+ Valid values: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
122
+
123
+ ```sh
124
+ # macOS/Linux - show all internal diagnostic messages
125
+ GIT_COMMIT_AI_LOG_LEVEL=DEBUG git-commit-msg-ai
126
+
127
+ # Windows PowerShell
128
+ $env:GIT_COMMIT_AI_LOG_LEVEL = 'DEBUG'
129
+ git-commit-msg-ai
130
+ ```
131
+
132
+ | Level | What you see |
133
+ |---|---|
134
+ | `DEBUG` | git commands run, API model/token params, temp file paths, char counts |
135
+ | `INFO` | commit message generated, commit created |
136
+ | `WARNING` | no staged changes found |
137
+ | `ERROR` | git not found, API failures, editor errors |
@@ -97,3 +97,25 @@ BREAKING CHANGE: /v1/legacy is no longer available
97
97
  ```
98
98
 
99
99
  Supported types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
100
+
101
+ ## Debugging
102
+
103
+ 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**.
104
+
105
+ Valid values: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
106
+
107
+ ```sh
108
+ # macOS/Linux - show all internal diagnostic messages
109
+ GIT_COMMIT_AI_LOG_LEVEL=DEBUG git-commit-msg-ai
110
+
111
+ # Windows PowerShell
112
+ $env:GIT_COMMIT_AI_LOG_LEVEL = 'DEBUG'
113
+ git-commit-msg-ai
114
+ ```
115
+
116
+ | Level | What you see |
117
+ |---|---|
118
+ | `DEBUG` | git commands run, API model/token params, temp file paths, char counts |
119
+ | `INFO` | commit message generated, commit created |
120
+ | `WARNING` | no staged changes found |
121
+ | `ERROR` | git not found, API failures, editor errors |
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import textwrap
2
3
  from typing import Final, cast
3
4
 
@@ -5,6 +6,8 @@ import anthropic
5
6
 
6
7
  from git_commit_msg_ai.exceptions import AIError
7
8
 
9
+ logger = logging.getLogger(__name__)
10
+
8
11
  SYSTEM_PROMPT: Final[str] = textwrap.dedent("""\
9
12
  You are a Git commit message generator. Output only the commit message, nothing else. Follow the Conventional Commits specification:
10
13
  - First line: <type>(<optional scope>)<!>: <short subject> (50 chars max); append ! before the colon if the commit introduces a breaking change
@@ -21,6 +24,7 @@ def generate_commit_message(diff: str) -> str:
21
24
  try:
22
25
  anthropic_client = anthropic.Anthropic()
23
26
 
27
+ logger.debug('Calling Anthropic API: model=%s max_tokens=%d', MODEL, MAX_TOKENS)
24
28
  anthropic_api_response = anthropic_client.messages.create(
25
29
  model=MODEL,
26
30
  max_tokens=MAX_TOKENS,
@@ -28,15 +32,21 @@ def generate_commit_message(diff: str) -> str:
28
32
  messages=[{'role': 'user', 'content': diff}],
29
33
  )
30
34
  except anthropic.AuthenticationError:
35
+ logger.error('Anthropic authentication error')
31
36
  raise AIError('Anthropic API key is missing or invalid. Set the ANTHROPIC_API_KEY environment variable.')
32
37
  except anthropic.RateLimitError:
38
+ logger.error('Anthropic rate limit error')
33
39
  raise AIError('Anthropic API rate limit reached. Wait a moment and try again.')
34
40
  except anthropic.APIConnectionError:
41
+ logger.error('Anthropic API connection error')
35
42
  raise AIError('Could not reach the Anthropic API. Check your network connection.')
36
43
  except anthropic.APIStatusError as error:
44
+ logger.error('Anthropic API status error: %s', error.status_code)
37
45
  raise AIError(f'Anthropic API returned an error: {error.status_code}.')
38
46
 
39
47
  anthropic_api_response_message = anthropic_api_response.content[0]
40
48
  commit_message = cast(anthropic.types.TextBlock, anthropic_api_response_message).text.strip()
41
49
 
50
+ logger.info('Commit message generated: %d chars', len(commit_message))
51
+
42
52
  return commit_message
@@ -1,10 +1,26 @@
1
+ import logging
2
+ import os
1
3
  import sys
4
+ from typing import Final
2
5
 
3
6
  from git_commit_msg_ai import ai_client, editor, git_ops
4
7
  from git_commit_msg_ai.exceptions import GitCommitAIError
5
8
 
9
+ LOG_LEVEL_ENV_VAR: Final[str] = 'GIT_COMMIT_AI_LOG_LEVEL'
10
+
6
11
 
7
12
  def main() -> None:
13
+ log_level_name = os.environ.get(LOG_LEVEL_ENV_VAR, 'WARNING').upper()
14
+ numeric_log_level = getattr(logging, log_level_name, None)
15
+
16
+ if not isinstance(numeric_log_level, int):
17
+ numeric_log_level = logging.WARNING
18
+
19
+ logging.basicConfig(level=numeric_log_level, format='%(levelname)s:%(name)s:%(message)s', stream=sys.stderr)
20
+
21
+ if not isinstance(getattr(logging, log_level_name, None), int):
22
+ logging.warning('Invalid %s value %r, defaulting to WARNING', LOG_LEVEL_ENV_VAR, log_level_name)
23
+
8
24
  try:
9
25
  diff = git_ops.get_staged_diff()
10
26
  commit_message = ai_client.generate_commit_message(diff)
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import os
2
3
  import platform
3
4
  import subprocess
@@ -5,6 +6,8 @@ import tempfile
5
6
 
6
7
  from git_commit_msg_ai.exceptions import EditorError
7
8
 
9
+ logger = logging.getLogger(__name__)
10
+
8
11
 
9
12
  def get_default_editor() -> str:
10
13
  current_platform = platform.system()
@@ -19,25 +22,38 @@ def open_in_editor(initial_text: str) -> str:
19
22
  temp_file.write(initial_text)
20
23
  temp_file_path = temp_file.name
21
24
  except OSError:
25
+ logger.error('Could not create temporary file')
22
26
  raise EditorError('Could not create a temporary file.')
23
27
 
28
+ logger.debug('Temporary file created: %s', temp_file_path)
29
+
24
30
  platform_default_editor = get_default_editor()
25
31
  editor_command = os.environ.get('EDITOR', platform_default_editor)
26
32
 
33
+ logger.debug('Selected editor: %s', editor_command)
34
+
27
35
  try:
28
36
  try:
37
+ logger.debug('Opening editor: %s %s', editor_command, temp_file_path)
29
38
  subprocess.run([editor_command, temp_file_path], check=True)
30
39
  except FileNotFoundError:
40
+ logger.error('Editor not found: %s', editor_command)
31
41
  raise EditorError(f'Editor "{editor_command}" was not found. Set the EDITOR environment variable to a valid editor.')
32
42
  except subprocess.CalledProcessError:
43
+ logger.error('Editor exited with error: %s', editor_command)
33
44
  raise EditorError(f'Editor "{editor_command}" exited with an error.')
34
45
 
46
+ logger.debug('Editor closed, reading edited content')
47
+
35
48
  try:
36
49
  with open(temp_file_path) as temp_file:
37
50
  edited_text = temp_file.read().strip()
38
51
  except OSError:
52
+ logger.error('Could not read temporary file: %s', temp_file_path)
39
53
  raise EditorError('Could not read the edited commit message from the temporary file.')
40
54
  finally:
41
55
  os.unlink(temp_file_path)
42
56
 
57
+ logger.debug('Edited text read: %d chars', len(edited_text))
58
+
43
59
  return edited_text
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import subprocess
2
3
  from typing import Final
3
4
 
@@ -10,18 +11,28 @@ COMMIT_SUBCOMMAND: Final[str] = 'commit'
10
11
  MESSAGE_FLAG: Final[str] = '-m'
11
12
  UTF8_ENCODING: Final[str] = 'utf-8'
12
13
 
14
+ logger = logging.getLogger(__name__)
15
+
13
16
 
14
17
  def get_staged_diff() -> str:
15
18
  try:
16
- staged_diff = subprocess.check_output([GIT_COMMAND, DIFF_SUBCOMMAND, CACHED_FLAG]).decode(UTF8_ENCODING)
19
+ logger.debug('Running: %s %s %s', GIT_COMMAND, DIFF_SUBCOMMAND, CACHED_FLAG)
20
+ raw_diff_bytes = subprocess.check_output([GIT_COMMAND, DIFF_SUBCOMMAND, CACHED_FLAG])
21
+ staged_diff = raw_diff_bytes.decode(UTF8_ENCODING)
17
22
  except FileNotFoundError:
23
+ logger.error('git not found on PATH')
18
24
  raise GitError('git is not installed or not on PATH.')
19
25
  except subprocess.CalledProcessError:
26
+ logger.error('git diff --cached exited non-zero')
20
27
  raise GitError('Failed to get staged diff. Are you inside a git repository?')
21
28
  except UnicodeDecodeError:
29
+ logger.error('Staged diff could not be decoded as UTF-8')
22
30
  raise GitError('Staged diff contains bytes that could not be decoded as UTF-8.')
23
31
 
32
+ logger.debug('Staged diff received: %d chars', len(staged_diff))
33
+
24
34
  if not staged_diff:
35
+ logger.warning('No staged changes found')
25
36
  raise GitError('No staged changes found. Stage files with git add before running.')
26
37
 
27
38
  return staged_diff
@@ -29,8 +40,13 @@ def get_staged_diff() -> str:
29
40
 
30
41
  def commit(message: str) -> None:
31
42
  try:
43
+ logger.debug('Running: %s %s %s <message>', GIT_COMMAND, COMMIT_SUBCOMMAND, MESSAGE_FLAG)
32
44
  subprocess.run([GIT_COMMAND, COMMIT_SUBCOMMAND, MESSAGE_FLAG, message], check=True)
33
45
  except FileNotFoundError:
46
+ logger.error('git not found on PATH')
34
47
  raise GitError('git is not installed or not on PATH.')
35
48
  except subprocess.CalledProcessError:
49
+ logger.error('git commit exited non-zero')
36
50
  raise GitError('git commit failed.')
51
+
52
+ logger.info('Commit created successfully')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-msg-ai
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -113,3 +113,25 @@ BREAKING CHANGE: /v1/legacy is no longer available
113
113
  ```
114
114
 
115
115
  Supported types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
116
+
117
+ ## Debugging
118
+
119
+ 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**.
120
+
121
+ Valid values: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
122
+
123
+ ```sh
124
+ # macOS/Linux - show all internal diagnostic messages
125
+ GIT_COMMIT_AI_LOG_LEVEL=DEBUG git-commit-msg-ai
126
+
127
+ # Windows PowerShell
128
+ $env:GIT_COMMIT_AI_LOG_LEVEL = 'DEBUG'
129
+ git-commit-msg-ai
130
+ ```
131
+
132
+ | Level | What you see |
133
+ |---|---|
134
+ | `DEBUG` | git commands run, API model/token params, temp file paths, char counts |
135
+ | `INFO` | commit message generated, commit created |
136
+ | `WARNING` | no staged changes found |
137
+ | `ERROR` | git not found, API failures, editor errors |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-commit-msg-ai"
7
- version = "1.4.2"
7
+ version = "1.5.0"
8
8
  description = "AI-powered git commit message generator following Conventional Commits"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,158 @@
1
+ import logging
2
+ from contextlib import ExitStack
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import anthropic
6
+ import pytest
7
+
8
+ from git_commit_msg_ai.ai_client import MAX_TOKENS, MODEL, generate_commit_message
9
+ from git_commit_msg_ai.exceptions import AIError
10
+
11
+
12
+ def _make_api_response(text: str) -> MagicMock:
13
+ mock_text_block = MagicMock(spec=anthropic.types.TextBlock)
14
+ mock_text_block.text = text
15
+
16
+ mock_response = MagicMock(spec=anthropic.types.Message)
17
+ mock_response.content = [mock_text_block]
18
+
19
+ return mock_response
20
+
21
+
22
+ def _make_status_error(exception_class: type[anthropic.APIStatusError], status_code: int) -> anthropic.APIStatusError:
23
+ mock_response = MagicMock()
24
+ mock_response.status_code = status_code
25
+
26
+ return exception_class('error', response=mock_response, body={})
27
+
28
+
29
+ class TestGenerateCommitMessage:
30
+ def test_returns_stripped_commit_message(self) -> None:
31
+ with ExitStack() as stack:
32
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
33
+ mock_client = MagicMock()
34
+ mock_client.messages.create.return_value = _make_api_response(' feat: add feature ')
35
+ mock_anthropic_class.return_value = mock_client
36
+
37
+ result = generate_commit_message('diff content')
38
+
39
+ assert result == 'feat: add feature'
40
+
41
+ def test_raises_ai_error_on_authentication_error(self) -> None:
42
+ with ExitStack() as stack:
43
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
44
+ mock_client = MagicMock()
45
+ mock_client.messages.create.side_effect = _make_status_error(anthropic.AuthenticationError, 401)
46
+ mock_anthropic_class.return_value = mock_client
47
+
48
+ with pytest.raises(AIError, match='ANTHROPIC_API_KEY'):
49
+ generate_commit_message('diff content')
50
+
51
+ def test_raises_ai_error_on_rate_limit_error(self) -> None:
52
+ with ExitStack() as stack:
53
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
54
+ mock_client = MagicMock()
55
+ mock_client.messages.create.side_effect = _make_status_error(anthropic.RateLimitError, 429)
56
+ mock_anthropic_class.return_value = mock_client
57
+
58
+ with pytest.raises(AIError, match='rate limit'):
59
+ generate_commit_message('diff content')
60
+
61
+ def test_raises_ai_error_on_api_connection_error(self) -> None:
62
+ mock_request = MagicMock()
63
+
64
+ with ExitStack() as stack:
65
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
66
+ mock_client = MagicMock()
67
+ mock_client.messages.create.side_effect = anthropic.APIConnectionError(request=mock_request)
68
+ mock_anthropic_class.return_value = mock_client
69
+
70
+ with pytest.raises(AIError, match='network'):
71
+ generate_commit_message('diff content')
72
+
73
+ def test_raises_ai_error_on_api_status_error_with_status_code(self) -> None:
74
+ with ExitStack() as stack:
75
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
76
+ mock_client = MagicMock()
77
+ mock_client.messages.create.side_effect = _make_status_error(anthropic.APIStatusError, 500)
78
+ mock_anthropic_class.return_value = mock_client
79
+
80
+ with pytest.raises(AIError, match='500'):
81
+ generate_commit_message('diff content')
82
+
83
+ def test_logs_debug_before_api_call(self, caplog: pytest.LogCaptureFixture) -> None:
84
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.ai_client'):
85
+ with ExitStack() as stack:
86
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
87
+ mock_client = MagicMock()
88
+ mock_client.messages.create.return_value = _make_api_response('feat: add feature')
89
+ mock_anthropic_class.return_value = mock_client
90
+ generate_commit_message('diff content')
91
+
92
+ messages = [r.message for r in caplog.records]
93
+ assert any(MODEL in m and str(MAX_TOKENS) in m for m in messages)
94
+
95
+ def test_logs_info_after_message_generated(self, caplog: pytest.LogCaptureFixture) -> None:
96
+ with caplog.at_level(logging.INFO, logger='git_commit_msg_ai.ai_client'):
97
+ with ExitStack() as stack:
98
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
99
+ mock_client = MagicMock()
100
+ mock_client.messages.create.return_value = _make_api_response('feat: add feature')
101
+ mock_anthropic_class.return_value = mock_client
102
+ generate_commit_message('diff content')
103
+
104
+ assert any(r.levelno == logging.INFO and 'Commit message generated' in r.message for r in caplog.records)
105
+
106
+ def test_logs_error_on_authentication_error(self, caplog: pytest.LogCaptureFixture) -> None:
107
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.ai_client'):
108
+ with ExitStack() as stack:
109
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
110
+ mock_client = MagicMock()
111
+ mock_client.messages.create.side_effect = _make_status_error(anthropic.AuthenticationError, 401)
112
+ mock_anthropic_class.return_value = mock_client
113
+
114
+ with pytest.raises(AIError):
115
+ generate_commit_message('diff content')
116
+
117
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
118
+
119
+ def test_logs_error_on_rate_limit_error(self, caplog: pytest.LogCaptureFixture) -> None:
120
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.ai_client'):
121
+ with ExitStack() as stack:
122
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
123
+ mock_client = MagicMock()
124
+ mock_client.messages.create.side_effect = _make_status_error(anthropic.RateLimitError, 429)
125
+ mock_anthropic_class.return_value = mock_client
126
+
127
+ with pytest.raises(AIError):
128
+ generate_commit_message('diff content')
129
+
130
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
131
+
132
+ def test_logs_error_on_api_connection_error(self, caplog: pytest.LogCaptureFixture) -> None:
133
+ mock_request = MagicMock()
134
+
135
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.ai_client'):
136
+ with ExitStack() as stack:
137
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
138
+ mock_client = MagicMock()
139
+ mock_client.messages.create.side_effect = anthropic.APIConnectionError(request=mock_request)
140
+ mock_anthropic_class.return_value = mock_client
141
+
142
+ with pytest.raises(AIError):
143
+ generate_commit_message('diff content')
144
+
145
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
146
+
147
+ def test_logs_error_on_api_status_error(self, caplog: pytest.LogCaptureFixture) -> None:
148
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.ai_client'):
149
+ with ExitStack() as stack:
150
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
151
+ mock_client = MagicMock()
152
+ mock_client.messages.create.side_effect = _make_status_error(anthropic.APIStatusError, 500)
153
+ mock_anthropic_class.return_value = mock_client
154
+
155
+ with pytest.raises(AIError):
156
+ generate_commit_message('diff content')
157
+
158
+ assert any(r.levelno == logging.ERROR and '500' in r.message for r in caplog.records)
@@ -1,9 +1,11 @@
1
+ import logging
2
+ import sys
1
3
  from contextlib import ExitStack
2
4
  from unittest.mock import MagicMock, patch
3
5
 
4
6
  import pytest
5
7
 
6
- from git_commit_msg_ai.cli import main
8
+ from git_commit_msg_ai.cli import LOG_LEVEL_ENV_VAR, main
7
9
  from git_commit_msg_ai.exceptions import AIError, GitError
8
10
 
9
11
  COMMIT_MESSAGE = 'feat: add feature'
@@ -147,3 +149,71 @@ class TestMain:
147
149
 
148
150
  assert exc_info.value.code == 1
149
151
  assert 'Aborted.' in capsys.readouterr().out
152
+
153
+
154
+ class TestLoggingConfiguration:
155
+ def _run_main_rejecting(self) -> None:
156
+ mock_git_ops = MagicMock()
157
+ mock_git_ops.get_staged_diff.return_value = 'diff'
158
+
159
+ mock_ai_client = MagicMock()
160
+ mock_ai_client.generate_commit_message.return_value = 'feat: x'
161
+
162
+ with ExitStack() as stack:
163
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
164
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
165
+ stack.enter_context(patch('git_commit_msg_ai.cli.editor'))
166
+ stack.enter_context(patch('builtins.input', return_value='r'))
167
+ main()
168
+
169
+ def test_uses_warning_level_when_env_var_absent(self) -> None:
170
+ with ExitStack() as stack:
171
+ mock_basic_config = stack.enter_context(patch('git_commit_msg_ai.cli.logging.basicConfig'))
172
+ stack.enter_context(patch.dict('os.environ', {}, clear=True))
173
+ self._run_main_rejecting()
174
+
175
+ mock_basic_config.assert_called_once_with(level=logging.WARNING, format='%(levelname)s:%(name)s:%(message)s', stream=sys.stderr)
176
+
177
+ def test_uses_debug_level_from_env_var(self) -> None:
178
+ with ExitStack() as stack:
179
+ mock_basic_config = stack.enter_context(patch('git_commit_msg_ai.cli.logging.basicConfig'))
180
+ stack.enter_context(patch.dict('os.environ', {LOG_LEVEL_ENV_VAR: 'DEBUG'}))
181
+ self._run_main_rejecting()
182
+
183
+ mock_basic_config.assert_called_once_with(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s', stream=sys.stderr)
184
+
185
+ def test_uses_info_level_from_env_var(self) -> None:
186
+ with ExitStack() as stack:
187
+ mock_basic_config = stack.enter_context(patch('git_commit_msg_ai.cli.logging.basicConfig'))
188
+ stack.enter_context(patch.dict('os.environ', {LOG_LEVEL_ENV_VAR: 'INFO'}))
189
+ self._run_main_rejecting()
190
+
191
+ mock_basic_config.assert_called_once_with(level=logging.INFO, format='%(levelname)s:%(name)s:%(message)s', stream=sys.stderr)
192
+
193
+ def test_invalid_env_var_falls_back_to_warning(self) -> None:
194
+ with ExitStack() as stack:
195
+ mock_basic_config = stack.enter_context(patch('git_commit_msg_ai.cli.logging.basicConfig'))
196
+ mock_log_warning = stack.enter_context(patch('git_commit_msg_ai.cli.logging.warning'))
197
+ stack.enter_context(patch.dict('os.environ', {LOG_LEVEL_ENV_VAR: 'BANANA'}))
198
+ self._run_main_rejecting()
199
+
200
+ mock_basic_config.assert_called_once_with(level=logging.WARNING, format='%(levelname)s:%(name)s:%(message)s', stream=sys.stderr)
201
+ assert 'BANANA' in str(mock_log_warning.call_args)
202
+
203
+ def test_logs_to_stderr(self) -> None:
204
+ with ExitStack() as stack:
205
+ mock_basic_config = stack.enter_context(patch('git_commit_msg_ai.cli.logging.basicConfig'))
206
+ stack.enter_context(patch.dict('os.environ', {}, clear=True))
207
+ self._run_main_rejecting()
208
+
209
+ _, kwargs = mock_basic_config.call_args
210
+ assert kwargs['stream'] is sys.stderr
211
+
212
+ def test_uses_expected_log_format(self) -> None:
213
+ with ExitStack() as stack:
214
+ mock_basic_config = stack.enter_context(patch('git_commit_msg_ai.cli.logging.basicConfig'))
215
+ stack.enter_context(patch.dict('os.environ', {}, clear=True))
216
+ self._run_main_rejecting()
217
+
218
+ _, kwargs = mock_basic_config.call_args
219
+ assert kwargs['format'] == '%(levelname)s:%(name)s:%(message)s'
@@ -0,0 +1,245 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from contextlib import ExitStack
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from git_commit_msg_ai.editor import get_default_editor, open_in_editor
11
+ from git_commit_msg_ai.exceptions import EditorError
12
+
13
+ TEMP_FILE_PATH = os.path.join(tempfile.gettempdir(), 'tmpfile.txt')
14
+ INITIAL_TEXT = 'initial text'
15
+ EDITED_TEXT_WITH_WHITESPACE = ' edited text '
16
+ EDITED_TEXT = 'edited text'
17
+ GENERIC_TEXT = 'text'
18
+ VIM_EDITOR = 'vim'
19
+
20
+
21
+ def _make_named_temporary_file_mock() -> MagicMock:
22
+ mock_temp_file = MagicMock()
23
+ mock_temp_file.name = TEMP_FILE_PATH
24
+ mock_temp_file.__enter__ = MagicMock(return_value=mock_temp_file)
25
+ mock_temp_file.__exit__ = MagicMock(return_value=False)
26
+
27
+ return mock_temp_file
28
+
29
+
30
+ def _make_open_mock(text: str) -> MagicMock:
31
+ mock_file = MagicMock()
32
+ mock_file.read.return_value = text
33
+ mock_file.__enter__ = MagicMock(return_value=mock_file)
34
+ mock_file.__exit__ = MagicMock(return_value=False)
35
+
36
+ return mock_file
37
+
38
+
39
+ class TestOpenInEditor:
40
+ def test_returns_stripped_edited_text(self) -> None:
41
+ with ExitStack() as stack:
42
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
43
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
44
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT_WITH_WHITESPACE)))
45
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
46
+
47
+ result = open_in_editor(INITIAL_TEXT)
48
+
49
+ assert result == EDITED_TEXT
50
+
51
+ def test_uses_editor_env_var(self) -> None:
52
+ with ExitStack() as stack:
53
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
54
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
55
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(GENERIC_TEXT)))
56
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
57
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
58
+
59
+ open_in_editor(INITIAL_TEXT)
60
+
61
+ mock_run.assert_called_once_with([VIM_EDITOR, TEMP_FILE_PATH], check=True)
62
+
63
+ def test_falls_back_to_platform_default_when_editor_not_set(self) -> None:
64
+ sentinel_editor = 'test-sentinel-editor'
65
+
66
+ with ExitStack() as stack:
67
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
68
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
69
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(GENERIC_TEXT)))
70
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
71
+ stack.enter_context(patch('git_commit_msg_ai.editor.get_default_editor', return_value=sentinel_editor))
72
+ stack.enter_context(patch.dict('os.environ', {}, clear=True))
73
+
74
+ open_in_editor(INITIAL_TEXT)
75
+
76
+ mock_run.assert_called_once_with([sentinel_editor, TEMP_FILE_PATH], check=True)
77
+
78
+ def test_raises_editor_error_on_temp_file_creation_failure(self) -> None:
79
+ with ExitStack() as stack:
80
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', side_effect=OSError))
81
+ mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
82
+
83
+ with pytest.raises(EditorError, match='temporary file'):
84
+ open_in_editor(INITIAL_TEXT)
85
+
86
+ mock_unlink.assert_not_called()
87
+
88
+ def test_raises_editor_error_when_editor_not_found(self) -> None:
89
+ with ExitStack() as stack:
90
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
91
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=FileNotFoundError))
92
+ mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
93
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
94
+
95
+ with pytest.raises(EditorError, match='was not found'):
96
+ open_in_editor(INITIAL_TEXT)
97
+
98
+ mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
99
+
100
+ def test_raises_editor_error_when_editor_exits_with_error(self) -> None:
101
+ with ExitStack() as stack:
102
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
103
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=subprocess.CalledProcessError(1, VIM_EDITOR)))
104
+ mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
105
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
106
+
107
+ with pytest.raises(EditorError, match='exited with an error'):
108
+ open_in_editor(INITIAL_TEXT)
109
+
110
+ mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
111
+
112
+ def test_raises_editor_error_when_file_read_fails(self) -> None:
113
+ with ExitStack() as stack:
114
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
115
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
116
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', side_effect=OSError))
117
+ mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
118
+
119
+ with pytest.raises(EditorError, match='Could not read'):
120
+ open_in_editor(INITIAL_TEXT)
121
+
122
+ mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
123
+
124
+ def test_logs_debug_temp_file_path(self, caplog: pytest.LogCaptureFixture) -> None:
125
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.editor'):
126
+ with ExitStack() as stack:
127
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
128
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
129
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT)))
130
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
131
+ open_in_editor(INITIAL_TEXT)
132
+
133
+ assert any(TEMP_FILE_PATH in r.message for r in caplog.records)
134
+
135
+ def test_logs_debug_selected_editor(self, caplog: pytest.LogCaptureFixture) -> None:
136
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.editor'):
137
+ with ExitStack() as stack:
138
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
139
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
140
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT)))
141
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
142
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
143
+ open_in_editor(INITIAL_TEXT)
144
+
145
+ assert any(VIM_EDITOR in r.message for r in caplog.records)
146
+
147
+ def test_logs_debug_opening_editor(self, caplog: pytest.LogCaptureFixture) -> None:
148
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.editor'):
149
+ with ExitStack() as stack:
150
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
151
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
152
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT)))
153
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
154
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
155
+ open_in_editor(INITIAL_TEXT)
156
+
157
+ assert any(VIM_EDITOR in r.message and TEMP_FILE_PATH in r.message for r in caplog.records)
158
+
159
+ def test_logs_debug_editor_closed(self, caplog: pytest.LogCaptureFixture) -> None:
160
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.editor'):
161
+ with ExitStack() as stack:
162
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
163
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
164
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT)))
165
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
166
+ open_in_editor(INITIAL_TEXT)
167
+
168
+ assert any('Editor closed' in r.message for r in caplog.records)
169
+
170
+ def test_logs_debug_edited_text_length(self, caplog: pytest.LogCaptureFixture) -> None:
171
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.editor'):
172
+ with ExitStack() as stack:
173
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
174
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
175
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT)))
176
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
177
+ open_in_editor(INITIAL_TEXT)
178
+
179
+ assert any(str(len(EDITED_TEXT)) in r.message for r in caplog.records)
180
+
181
+ def test_logs_error_on_temp_file_create_failure(self, caplog: pytest.LogCaptureFixture) -> None:
182
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.editor'):
183
+ with ExitStack() as stack:
184
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', side_effect=OSError))
185
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
186
+
187
+ with pytest.raises(EditorError):
188
+ open_in_editor(INITIAL_TEXT)
189
+
190
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
191
+
192
+ def test_logs_error_when_editor_not_found(self, caplog: pytest.LogCaptureFixture) -> None:
193
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.editor'):
194
+ with ExitStack() as stack:
195
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
196
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=FileNotFoundError))
197
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
198
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
199
+
200
+ with pytest.raises(EditorError):
201
+ open_in_editor(INITIAL_TEXT)
202
+
203
+ assert any(r.levelno == logging.ERROR and VIM_EDITOR in r.message for r in caplog.records)
204
+
205
+ def test_logs_error_when_editor_exits_with_error(self, caplog: pytest.LogCaptureFixture) -> None:
206
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.editor'):
207
+ with ExitStack() as stack:
208
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
209
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=subprocess.CalledProcessError(1, VIM_EDITOR)))
210
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
211
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
212
+
213
+ with pytest.raises(EditorError):
214
+ open_in_editor(INITIAL_TEXT)
215
+
216
+ assert any(r.levelno == logging.ERROR and VIM_EDITOR in r.message for r in caplog.records)
217
+
218
+ def test_logs_error_when_temp_file_read_fails(self, caplog: pytest.LogCaptureFixture) -> None:
219
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.editor'):
220
+ with ExitStack() as stack:
221
+ stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
222
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
223
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', side_effect=OSError))
224
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
225
+
226
+ with pytest.raises(EditorError):
227
+ open_in_editor(INITIAL_TEXT)
228
+
229
+ assert any(r.levelno == logging.ERROR and TEMP_FILE_PATH in r.message for r in caplog.records)
230
+
231
+
232
+ class TestGetDefaultEditor:
233
+ def test_returns_notepad_on_windows(self) -> None:
234
+ with ExitStack() as stack:
235
+ stack.enter_context(patch('git_commit_msg_ai.editor.platform.system', return_value='Windows'))
236
+ result = get_default_editor()
237
+
238
+ assert result == 'notepad'
239
+
240
+ def test_returns_vi_on_non_windows(self) -> None:
241
+ with ExitStack() as stack:
242
+ stack.enter_context(patch('git_commit_msg_ai.editor.platform.system', return_value='Linux'))
243
+ result = get_default_editor()
244
+
245
+ assert result == 'vi'
@@ -0,0 +1,189 @@
1
+ import logging
2
+ import subprocess
3
+ from contextlib import ExitStack
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from git_commit_msg_ai.exceptions import GitError
9
+ from git_commit_msg_ai.git_ops import CACHED_FLAG, COMMIT_SUBCOMMAND, DIFF_SUBCOMMAND, GIT_COMMAND, MESSAGE_FLAG, commit, get_staged_diff
10
+
11
+ COMMIT_MESSAGE = 'my message'
12
+
13
+
14
+ class TestGetStagedDiff:
15
+ def test_returns_decoded_diff(self) -> None:
16
+ with ExitStack() as stack:
17
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
18
+ mock_check_output.return_value = b'diff content'
19
+
20
+ result = get_staged_diff()
21
+
22
+ assert result == 'diff content'
23
+
24
+ def test_raises_git_error_when_git_not_found(self) -> None:
25
+ with ExitStack() as stack:
26
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
27
+ mock_check_output.side_effect = FileNotFoundError
28
+
29
+ with pytest.raises(GitError, match='not installed'):
30
+ get_staged_diff()
31
+
32
+ def test_raises_git_error_on_called_process_error(self) -> None:
33
+ with ExitStack() as stack:
34
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
35
+ mock_check_output.side_effect = subprocess.CalledProcessError(1, 'git')
36
+
37
+ with pytest.raises(GitError, match='git repository'):
38
+ get_staged_diff()
39
+
40
+ def test_raises_git_error_on_unicode_decode_error(self) -> None:
41
+ mock_bytes = MagicMock()
42
+ mock_bytes.decode.side_effect = UnicodeDecodeError('utf-8', b'', 0, 1, 'reason')
43
+
44
+ with ExitStack() as stack:
45
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
46
+ mock_check_output.return_value = mock_bytes
47
+
48
+ with pytest.raises(GitError, match='UTF-8'):
49
+ get_staged_diff()
50
+
51
+ def test_raises_git_error_when_diff_is_empty(self) -> None:
52
+ with ExitStack() as stack:
53
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
54
+ mock_check_output.return_value = b''
55
+
56
+ with pytest.raises(GitError, match='No staged changes'):
57
+ get_staged_diff()
58
+
59
+ def test_logs_debug_before_running_diff(self, caplog: pytest.LogCaptureFixture) -> None:
60
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.git_ops'):
61
+ with ExitStack() as stack:
62
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
63
+ mock_check_output.return_value = b'diff content'
64
+ get_staged_diff()
65
+
66
+ messages = [r.message for r in caplog.records]
67
+ assert any(GIT_COMMAND in m and DIFF_SUBCOMMAND in m and CACHED_FLAG in m for m in messages)
68
+
69
+ def test_logs_debug_diff_size(self, caplog: pytest.LogCaptureFixture) -> None:
70
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.git_ops'):
71
+ with ExitStack() as stack:
72
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
73
+ mock_check_output.return_value = b'diff content'
74
+ get_staged_diff()
75
+
76
+ messages = [r.message for r in caplog.records]
77
+ assert any('12' in m for m in messages)
78
+
79
+ def test_logs_warning_when_no_staged_changes(self, caplog: pytest.LogCaptureFixture) -> None:
80
+ with caplog.at_level(logging.WARNING, logger='git_commit_msg_ai.git_ops'):
81
+ with ExitStack() as stack:
82
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
83
+ mock_check_output.return_value = b''
84
+
85
+ with pytest.raises(GitError):
86
+ get_staged_diff()
87
+
88
+ assert any(r.levelno == logging.WARNING and 'No staged changes' in r.message for r in caplog.records)
89
+
90
+ def test_logs_error_when_git_not_found_in_diff(self, caplog: pytest.LogCaptureFixture) -> None:
91
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.git_ops'):
92
+ with ExitStack() as stack:
93
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
94
+ mock_check_output.side_effect = FileNotFoundError
95
+
96
+ with pytest.raises(GitError):
97
+ get_staged_diff()
98
+
99
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
100
+
101
+ def test_logs_error_on_diff_called_process_error(self, caplog: pytest.LogCaptureFixture) -> None:
102
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.git_ops'):
103
+ with ExitStack() as stack:
104
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
105
+ mock_check_output.side_effect = subprocess.CalledProcessError(1, 'git')
106
+
107
+ with pytest.raises(GitError):
108
+ get_staged_diff()
109
+
110
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
111
+
112
+ def test_logs_error_on_diff_unicode_decode_error(self, caplog: pytest.LogCaptureFixture) -> None:
113
+ mock_bytes = MagicMock()
114
+ mock_bytes.decode.side_effect = UnicodeDecodeError('utf-8', b'', 0, 1, 'reason')
115
+
116
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.git_ops'):
117
+ with ExitStack() as stack:
118
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
119
+ mock_check_output.return_value = mock_bytes
120
+
121
+ with pytest.raises(GitError):
122
+ get_staged_diff()
123
+
124
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
125
+
126
+
127
+ class TestCommit:
128
+ def test_calls_subprocess_run_with_correct_args(self) -> None:
129
+ with ExitStack() as stack:
130
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
131
+ commit(COMMIT_MESSAGE)
132
+
133
+ mock_run.assert_called_once_with([GIT_COMMAND, COMMIT_SUBCOMMAND, MESSAGE_FLAG, COMMIT_MESSAGE], check=True)
134
+
135
+ def test_raises_git_error_when_git_not_found(self) -> None:
136
+ with ExitStack() as stack:
137
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
138
+ mock_run.side_effect = FileNotFoundError
139
+
140
+ with pytest.raises(GitError, match='not installed'):
141
+ commit(COMMIT_MESSAGE)
142
+
143
+ def test_raises_git_error_on_called_process_error(self) -> None:
144
+ with ExitStack() as stack:
145
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
146
+ mock_run.side_effect = subprocess.CalledProcessError(1, GIT_COMMAND)
147
+
148
+ with pytest.raises(GitError, match='git commit failed'):
149
+ commit(COMMIT_MESSAGE)
150
+
151
+ def test_logs_debug_before_running_commit(self, caplog: pytest.LogCaptureFixture) -> None:
152
+ with caplog.at_level(logging.DEBUG, logger='git_commit_msg_ai.git_ops'):
153
+ with ExitStack() as stack:
154
+ stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
155
+ commit(COMMIT_MESSAGE)
156
+
157
+ messages = [r.message for r in caplog.records]
158
+ assert any(GIT_COMMAND in m and COMMIT_SUBCOMMAND in m and MESSAGE_FLAG in m for m in messages)
159
+ assert not any(COMMIT_MESSAGE in m for m in messages)
160
+
161
+ def test_logs_info_after_successful_commit(self, caplog: pytest.LogCaptureFixture) -> None:
162
+ with caplog.at_level(logging.INFO, logger='git_commit_msg_ai.git_ops'):
163
+ with ExitStack() as stack:
164
+ stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
165
+ commit(COMMIT_MESSAGE)
166
+
167
+ assert any(r.levelno == logging.INFO and 'Commit created successfully' in r.message for r in caplog.records)
168
+
169
+ def test_logs_error_when_git_not_found_in_commit(self, caplog: pytest.LogCaptureFixture) -> None:
170
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.git_ops'):
171
+ with ExitStack() as stack:
172
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
173
+ mock_run.side_effect = FileNotFoundError
174
+
175
+ with pytest.raises(GitError):
176
+ commit(COMMIT_MESSAGE)
177
+
178
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
179
+
180
+ def test_logs_error_on_commit_called_process_error(self, caplog: pytest.LogCaptureFixture) -> None:
181
+ with caplog.at_level(logging.ERROR, logger='git_commit_msg_ai.git_ops'):
182
+ with ExitStack() as stack:
183
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
184
+ mock_run.side_effect = subprocess.CalledProcessError(1, GIT_COMMAND)
185
+
186
+ with pytest.raises(GitError):
187
+ commit(COMMIT_MESSAGE)
188
+
189
+ assert any(r.levelno == logging.ERROR for r in caplog.records)
@@ -1,80 +0,0 @@
1
- from contextlib import ExitStack
2
- from unittest.mock import MagicMock, patch
3
-
4
- import anthropic
5
- import pytest
6
-
7
- from git_commit_msg_ai.ai_client import generate_commit_message
8
- from git_commit_msg_ai.exceptions import AIError
9
-
10
-
11
- def _make_api_response(text: str) -> MagicMock:
12
- mock_text_block = MagicMock(spec=anthropic.types.TextBlock)
13
- mock_text_block.text = text
14
-
15
- mock_response = MagicMock(spec=anthropic.types.Message)
16
- mock_response.content = [mock_text_block]
17
-
18
- return mock_response
19
-
20
-
21
- def _make_status_error(exception_class: type[anthropic.APIStatusError], status_code: int) -> anthropic.APIStatusError:
22
- mock_response = MagicMock()
23
- mock_response.status_code = status_code
24
-
25
- return exception_class('error', response=mock_response, body={})
26
-
27
-
28
- class TestGenerateCommitMessage:
29
- def test_returns_stripped_commit_message(self) -> None:
30
- with ExitStack() as stack:
31
- mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
32
- mock_client = MagicMock()
33
- mock_client.messages.create.return_value = _make_api_response(' feat: add feature ')
34
- mock_anthropic_class.return_value = mock_client
35
-
36
- result = generate_commit_message('diff content')
37
-
38
- assert result == 'feat: add feature'
39
-
40
- def test_raises_ai_error_on_authentication_error(self) -> None:
41
- with ExitStack() as stack:
42
- mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
43
- mock_client = MagicMock()
44
- mock_client.messages.create.side_effect = _make_status_error(anthropic.AuthenticationError, 401)
45
- mock_anthropic_class.return_value = mock_client
46
-
47
- with pytest.raises(AIError, match='ANTHROPIC_API_KEY'):
48
- generate_commit_message('diff content')
49
-
50
- def test_raises_ai_error_on_rate_limit_error(self) -> None:
51
- with ExitStack() as stack:
52
- mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
53
- mock_client = MagicMock()
54
- mock_client.messages.create.side_effect = _make_status_error(anthropic.RateLimitError, 429)
55
- mock_anthropic_class.return_value = mock_client
56
-
57
- with pytest.raises(AIError, match='rate limit'):
58
- generate_commit_message('diff content')
59
-
60
- def test_raises_ai_error_on_api_connection_error(self) -> None:
61
- mock_request = MagicMock()
62
-
63
- with ExitStack() as stack:
64
- mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
65
- mock_client = MagicMock()
66
- mock_client.messages.create.side_effect = anthropic.APIConnectionError(request=mock_request)
67
- mock_anthropic_class.return_value = mock_client
68
-
69
- with pytest.raises(AIError, match='network'):
70
- generate_commit_message('diff content')
71
-
72
- def test_raises_ai_error_on_api_status_error_with_status_code(self) -> None:
73
- with ExitStack() as stack:
74
- mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
75
- mock_client = MagicMock()
76
- mock_client.messages.create.side_effect = _make_status_error(anthropic.APIStatusError, 500)
77
- mock_anthropic_class.return_value = mock_client
78
-
79
- with pytest.raises(AIError, match='500'):
80
- generate_commit_message('diff content')
@@ -1,137 +0,0 @@
1
- import os
2
- import subprocess
3
- import tempfile
4
- from contextlib import ExitStack
5
- from unittest.mock import MagicMock, patch
6
-
7
- import pytest
8
-
9
- from git_commit_msg_ai.editor import get_default_editor, open_in_editor
10
- from git_commit_msg_ai.exceptions import EditorError
11
-
12
- TEMP_FILE_PATH = os.path.join(tempfile.gettempdir(), 'tmpfile.txt')
13
- INITIAL_TEXT = 'initial text'
14
- EDITED_TEXT_WITH_WHITESPACE = ' edited text '
15
- EDITED_TEXT = 'edited text'
16
- GENERIC_TEXT = 'text'
17
- VIM_EDITOR = 'vim'
18
-
19
-
20
- def _make_named_temporary_file_mock() -> MagicMock:
21
- mock_temp_file = MagicMock()
22
- mock_temp_file.name = TEMP_FILE_PATH
23
- mock_temp_file.__enter__ = MagicMock(return_value=mock_temp_file)
24
- mock_temp_file.__exit__ = MagicMock(return_value=False)
25
-
26
- return mock_temp_file
27
-
28
-
29
- def _make_open_mock(text: str) -> MagicMock:
30
- mock_file = MagicMock()
31
- mock_file.read.return_value = text
32
- mock_file.__enter__ = MagicMock(return_value=mock_file)
33
- mock_file.__exit__ = MagicMock(return_value=False)
34
-
35
- return mock_file
36
-
37
-
38
- class TestOpenInEditor:
39
- def test_returns_stripped_edited_text(self) -> None:
40
- with ExitStack() as stack:
41
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
42
- stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
43
- stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT_WITH_WHITESPACE)))
44
- stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
45
-
46
- result = open_in_editor(INITIAL_TEXT)
47
-
48
- assert result == EDITED_TEXT
49
-
50
- def test_uses_editor_env_var(self) -> None:
51
- with ExitStack() as stack:
52
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
53
- mock_run = stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
54
- stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(GENERIC_TEXT)))
55
- stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
56
- stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
57
-
58
- open_in_editor(INITIAL_TEXT)
59
-
60
- mock_run.assert_called_once_with([VIM_EDITOR, TEMP_FILE_PATH], check=True)
61
-
62
- def test_falls_back_to_platform_default_when_editor_not_set(self) -> None:
63
- sentinel_editor = 'test-sentinel-editor'
64
-
65
- with ExitStack() as stack:
66
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
67
- mock_run = stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
68
- stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(GENERIC_TEXT)))
69
- stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
70
- stack.enter_context(patch('git_commit_msg_ai.editor.get_default_editor', return_value=sentinel_editor))
71
- stack.enter_context(patch.dict('os.environ', {}, clear=True))
72
-
73
- open_in_editor(INITIAL_TEXT)
74
-
75
- mock_run.assert_called_once_with([sentinel_editor, TEMP_FILE_PATH], check=True)
76
-
77
- def test_raises_editor_error_on_temp_file_creation_failure(self) -> None:
78
- with ExitStack() as stack:
79
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', side_effect=OSError))
80
- mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
81
-
82
- with pytest.raises(EditorError, match='temporary file'):
83
- open_in_editor(INITIAL_TEXT)
84
-
85
- mock_unlink.assert_not_called()
86
-
87
- def test_raises_editor_error_when_editor_not_found(self) -> None:
88
- with ExitStack() as stack:
89
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
90
- stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=FileNotFoundError))
91
- mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
92
- stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
93
-
94
- with pytest.raises(EditorError, match='was not found'):
95
- open_in_editor(INITIAL_TEXT)
96
-
97
- mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
98
-
99
- def test_raises_editor_error_when_editor_exits_with_error(self) -> None:
100
- with ExitStack() as stack:
101
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
102
- stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=subprocess.CalledProcessError(1, VIM_EDITOR)))
103
- mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
104
- stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
105
-
106
- with pytest.raises(EditorError, match='exited with an error'):
107
- open_in_editor(INITIAL_TEXT)
108
-
109
- mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
110
-
111
- def test_raises_editor_error_when_file_read_fails(self) -> None:
112
- with ExitStack() as stack:
113
- stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
114
- stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
115
- stack.enter_context(patch('git_commit_msg_ai.editor.open', side_effect=OSError))
116
- mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
117
-
118
- with pytest.raises(EditorError, match='Could not read'):
119
- open_in_editor(INITIAL_TEXT)
120
-
121
- mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
122
-
123
-
124
- class TestGetDefaultEditor:
125
- def test_returns_notepad_on_windows(self) -> None:
126
- with ExitStack() as stack:
127
- stack.enter_context(patch('git_commit_msg_ai.editor.platform.system', return_value='Windows'))
128
- result = get_default_editor()
129
-
130
- assert result == 'notepad'
131
-
132
- def test_returns_vi_on_non_windows(self) -> None:
133
- with ExitStack() as stack:
134
- stack.enter_context(patch('git_commit_msg_ai.editor.platform.system', return_value='Linux'))
135
- result = get_default_editor()
136
-
137
- assert result == 'vi'
@@ -1,81 +0,0 @@
1
- import subprocess
2
- from contextlib import ExitStack
3
- from unittest.mock import MagicMock, patch
4
-
5
- import pytest
6
-
7
- from git_commit_msg_ai.exceptions import GitError
8
- from git_commit_msg_ai.git_ops import COMMIT_SUBCOMMAND, GIT_COMMAND, MESSAGE_FLAG, commit, get_staged_diff
9
-
10
- COMMIT_MESSAGE = 'my message'
11
-
12
-
13
- class TestGetStagedDiff:
14
- def test_returns_decoded_diff(self) -> None:
15
- with ExitStack() as stack:
16
- mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
17
- mock_check_output.return_value = b'diff content'
18
-
19
- result = get_staged_diff()
20
-
21
- assert result == 'diff content'
22
-
23
- def test_raises_git_error_when_git_not_found(self) -> None:
24
- with ExitStack() as stack:
25
- mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
26
- mock_check_output.side_effect = FileNotFoundError
27
-
28
- with pytest.raises(GitError, match='not installed'):
29
- get_staged_diff()
30
-
31
- def test_raises_git_error_on_called_process_error(self) -> None:
32
- with ExitStack() as stack:
33
- mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
34
- mock_check_output.side_effect = subprocess.CalledProcessError(1, 'git')
35
-
36
- with pytest.raises(GitError, match='git repository'):
37
- get_staged_diff()
38
-
39
- def test_raises_git_error_on_unicode_decode_error(self) -> None:
40
- mock_bytes = MagicMock()
41
- mock_bytes.decode.side_effect = UnicodeDecodeError('utf-8', b'', 0, 1, 'reason')
42
-
43
- with ExitStack() as stack:
44
- mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
45
- mock_check_output.return_value = mock_bytes
46
-
47
- with pytest.raises(GitError, match='UTF-8'):
48
- get_staged_diff()
49
-
50
- def test_raises_git_error_when_diff_is_empty(self) -> None:
51
- with ExitStack() as stack:
52
- mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
53
- mock_check_output.return_value = b''
54
-
55
- with pytest.raises(GitError, match='No staged changes'):
56
- get_staged_diff()
57
-
58
-
59
- class TestCommit:
60
- def test_calls_subprocess_run_with_correct_args(self) -> None:
61
- with ExitStack() as stack:
62
- mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
63
- commit(COMMIT_MESSAGE)
64
-
65
- mock_run.assert_called_once_with([GIT_COMMAND, COMMIT_SUBCOMMAND, MESSAGE_FLAG, COMMIT_MESSAGE], check=True)
66
-
67
- def test_raises_git_error_when_git_not_found(self) -> None:
68
- with ExitStack() as stack:
69
- mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
70
- mock_run.side_effect = FileNotFoundError
71
-
72
- with pytest.raises(GitError, match='not installed'):
73
- commit(COMMIT_MESSAGE)
74
-
75
- def test_raises_git_error_on_called_process_error(self) -> None:
76
- with ExitStack() as stack:
77
- mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
78
- mock_run.side_effect = subprocess.CalledProcessError(1, GIT_COMMAND)
79
-
80
- with pytest.raises(GitError, match='git commit failed'):
81
- commit(COMMIT_MESSAGE)