git-commit-msg-ai 1.4.0__tar.gz → 1.4.1__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 (22) hide show
  1. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/PKG-INFO +4 -4
  2. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/README.md +3 -3
  3. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai/git_ops.py +10 -2
  4. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai.egg-info/PKG-INFO +4 -4
  5. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/pyproject.toml +1 -1
  6. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/tests/test_ai_client.py +11 -5
  7. git_commit_msg_ai-1.4.1/tests/test_cli.py +149 -0
  8. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/tests/test_editor.py +25 -18
  9. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/tests/test_git_ops.py +25 -14
  10. git_commit_msg_ai-1.4.0/tests/test_cli.py +0 -139
  11. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai/__init__.py +0 -0
  12. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai/ai_client.py +0 -0
  13. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai/cli.py +0 -0
  14. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai/editor.py +0 -0
  15. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai/exceptions.py +0 -0
  16. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai.egg-info/SOURCES.txt +0 -0
  17. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai.egg-info/dependency_links.txt +0 -0
  18. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai.egg-info/entry_points.txt +0 -0
  19. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai.egg-info/requires.txt +0 -0
  20. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/git_commit_msg_ai.egg-info/top_level.txt +0 -0
  21. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/setup.cfg +0 -0
  22. {git_commit_msg_ai-1.4.0 → git_commit_msg_ai-1.4.1}/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.0
3
+ Version: 1.4.1
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -53,9 +53,9 @@ The tool will:
53
53
  [a]ccept / [e]dit / [r]eject:
54
54
  ```
55
55
 
56
- - **a** commits immediately with the generated message
57
- - **e** opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
58
- - **r** exits without committing
56
+ - **a** - commits immediately with the generated message
57
+ - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
58
+ - **r** - exits without committing
59
59
 
60
60
  ## Commit message format
61
61
 
@@ -39,9 +39,9 @@ The tool will:
39
39
  [a]ccept / [e]dit / [r]eject:
40
40
  ```
41
41
 
42
- - **a** commits immediately with the generated message
43
- - **e** opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
44
- - **r** exits without committing
42
+ - **a** - commits immediately with the generated message
43
+ - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
44
+ - **r** - exits without committing
45
45
 
46
46
  ## Commit message format
47
47
 
@@ -1,11 +1,19 @@
1
1
  import subprocess
2
+ from typing import Final
2
3
 
3
4
  from git_commit_msg_ai.exceptions import GitError
4
5
 
6
+ GIT_COMMAND: Final[str] = 'git'
7
+ DIFF_SUBCOMMAND: Final[str] = 'diff'
8
+ CACHED_FLAG: Final[str] = '--cached'
9
+ COMMIT_SUBCOMMAND: Final[str] = 'commit'
10
+ MESSAGE_FLAG: Final[str] = '-m'
11
+ UTF8_ENCODING: Final[str] = 'utf-8'
12
+
5
13
 
6
14
  def get_staged_diff() -> str:
7
15
  try:
8
- staged_diff = subprocess.check_output(['git', 'diff', '--cached']).decode('utf-8')
16
+ staged_diff = subprocess.check_output([GIT_COMMAND, DIFF_SUBCOMMAND, CACHED_FLAG]).decode(UTF8_ENCODING)
9
17
  except FileNotFoundError:
10
18
  raise GitError('git is not installed or not on PATH.')
11
19
  except subprocess.CalledProcessError:
@@ -21,7 +29,7 @@ def get_staged_diff() -> str:
21
29
 
22
30
  def commit(message: str) -> None:
23
31
  try:
24
- subprocess.run(['git', 'commit', '-m', message], check=True)
32
+ subprocess.run([GIT_COMMAND, COMMIT_SUBCOMMAND, MESSAGE_FLAG, message], check=True)
25
33
  except FileNotFoundError:
26
34
  raise GitError('git is not installed or not on PATH.')
27
35
  except subprocess.CalledProcessError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-msg-ai
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -53,9 +53,9 @@ The tool will:
53
53
  [a]ccept / [e]dit / [r]eject:
54
54
  ```
55
55
 
56
- - **a** commits immediately with the generated message
57
- - **e** opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
58
- - **r** exits without committing
56
+ - **a** - commits immediately with the generated message
57
+ - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
58
+ - **r** - exits without committing
59
59
 
60
60
  ## Commit message format
61
61
 
@@ -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.0"
7
+ version = "1.4.1"
8
8
  description = "AI-powered git commit message generator following Conventional Commits"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,4 @@
1
+ from contextlib import ExitStack
1
2
  from unittest.mock import MagicMock, patch
2
3
 
3
4
  import anthropic
@@ -26,7 +27,8 @@ def _make_status_error(exception_class: type[anthropic.APIStatusError], status_c
26
27
 
27
28
  class TestGenerateCommitMessage:
28
29
  def test_returns_stripped_commit_message(self) -> None:
29
- with patch('git_commit_msg_ai.ai_client.anthropic.Anthropic') as mock_anthropic_class:
30
+ with ExitStack() as stack:
31
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
30
32
  mock_client = MagicMock()
31
33
  mock_client.messages.create.return_value = _make_api_response(' feat: add feature ')
32
34
  mock_anthropic_class.return_value = mock_client
@@ -36,7 +38,8 @@ class TestGenerateCommitMessage:
36
38
  assert result == 'feat: add feature'
37
39
 
38
40
  def test_raises_ai_error_on_authentication_error(self) -> None:
39
- with patch('git_commit_msg_ai.ai_client.anthropic.Anthropic') as mock_anthropic_class:
41
+ with ExitStack() as stack:
42
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
40
43
  mock_client = MagicMock()
41
44
  mock_client.messages.create.side_effect = _make_status_error(anthropic.AuthenticationError, 401)
42
45
  mock_anthropic_class.return_value = mock_client
@@ -45,7 +48,8 @@ class TestGenerateCommitMessage:
45
48
  generate_commit_message('diff content')
46
49
 
47
50
  def test_raises_ai_error_on_rate_limit_error(self) -> None:
48
- with patch('git_commit_msg_ai.ai_client.anthropic.Anthropic') as mock_anthropic_class:
51
+ with ExitStack() as stack:
52
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
49
53
  mock_client = MagicMock()
50
54
  mock_client.messages.create.side_effect = _make_status_error(anthropic.RateLimitError, 429)
51
55
  mock_anthropic_class.return_value = mock_client
@@ -56,7 +60,8 @@ class TestGenerateCommitMessage:
56
60
  def test_raises_ai_error_on_api_connection_error(self) -> None:
57
61
  mock_request = MagicMock()
58
62
 
59
- with patch('git_commit_msg_ai.ai_client.anthropic.Anthropic') as mock_anthropic_class:
63
+ with ExitStack() as stack:
64
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
60
65
  mock_client = MagicMock()
61
66
  mock_client.messages.create.side_effect = anthropic.APIConnectionError(request=mock_request)
62
67
  mock_anthropic_class.return_value = mock_client
@@ -65,7 +70,8 @@ class TestGenerateCommitMessage:
65
70
  generate_commit_message('diff content')
66
71
 
67
72
  def test_raises_ai_error_on_api_status_error_with_status_code(self) -> None:
68
- with patch('git_commit_msg_ai.ai_client.anthropic.Anthropic') as mock_anthropic_class:
73
+ with ExitStack() as stack:
74
+ mock_anthropic_class = stack.enter_context(patch('git_commit_msg_ai.ai_client.anthropic.Anthropic'))
69
75
  mock_client = MagicMock()
70
76
  mock_client.messages.create.side_effect = _make_status_error(anthropic.APIStatusError, 500)
71
77
  mock_anthropic_class.return_value = mock_client
@@ -0,0 +1,149 @@
1
+ from contextlib import ExitStack
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+
6
+ from git_commit_msg_ai.cli import main
7
+ from git_commit_msg_ai.exceptions import AIError, GitError
8
+
9
+ COMMIT_MESSAGE = 'feat: add feature'
10
+ EDITED_COMMIT_MESSAGE = 'edited commit message'
11
+ STAGED_DIFF = 'diff content'
12
+ GENERIC_COMMIT_MESSAGE = 'generic commit message'
13
+
14
+
15
+ class TestMain:
16
+ def _run_with_input(self, user_input: str, commit_message: str = COMMIT_MESSAGE) -> tuple[MagicMock, MagicMock, MagicMock]:
17
+ mock_git_ops = MagicMock()
18
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
19
+
20
+ mock_ai_client = MagicMock()
21
+ mock_ai_client.generate_commit_message.return_value = commit_message
22
+
23
+ mock_editor = MagicMock()
24
+ mock_editor.open_in_editor.return_value = EDITED_COMMIT_MESSAGE
25
+
26
+ with ExitStack() as stack:
27
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
28
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
29
+ stack.enter_context(patch('git_commit_msg_ai.cli.editor', mock_editor))
30
+ stack.enter_context(patch('builtins.input', return_value=user_input))
31
+ main()
32
+
33
+ return mock_git_ops, mock_ai_client, mock_editor
34
+
35
+ def test_accept_calls_commit_with_generated_message(self) -> None:
36
+ mock_git_ops, _, _ = self._run_with_input('a', COMMIT_MESSAGE)
37
+
38
+ mock_git_ops.commit.assert_called_once_with(COMMIT_MESSAGE)
39
+
40
+ def test_edit_opens_editor_then_commits_edited_message(self) -> None:
41
+ mock_git_ops, _, mock_editor = self._run_with_input('e', COMMIT_MESSAGE)
42
+
43
+ mock_editor.open_in_editor.assert_called_once_with(COMMIT_MESSAGE)
44
+ mock_git_ops.commit.assert_called_once_with(EDITED_COMMIT_MESSAGE)
45
+
46
+ def test_reject_does_not_commit(self) -> None:
47
+ mock_git_ops, _, _ = self._run_with_input('r')
48
+
49
+ mock_git_ops.commit.assert_not_called()
50
+
51
+ def test_invalid_input_exits_with_code_1(self) -> None:
52
+ mock_git_ops = MagicMock()
53
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
54
+
55
+ mock_ai_client = MagicMock()
56
+ mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
57
+
58
+ with ExitStack() as stack:
59
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
60
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
61
+ stack.enter_context(patch('git_commit_msg_ai.cli.editor'))
62
+ stack.enter_context(patch('builtins.input', return_value='x'))
63
+ with pytest.raises(SystemExit) as exc_info:
64
+ main()
65
+
66
+ assert exc_info.value.code == 1
67
+
68
+ def test_git_error_from_get_staged_diff_exits_with_message(self, capsys: pytest.CaptureFixture[str]) -> None:
69
+ mock_git_ops = MagicMock()
70
+ mock_git_ops.get_staged_diff.side_effect = GitError('staged error')
71
+
72
+ with ExitStack() as stack:
73
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
74
+ with pytest.raises(SystemExit) as exc_info:
75
+ main()
76
+
77
+ assert exc_info.value.code == 1
78
+ assert 'staged error' in capsys.readouterr().out
79
+
80
+ def test_ai_error_from_generate_commit_message_exits_with_message(self, capsys: pytest.CaptureFixture[str]) -> None:
81
+ mock_git_ops = MagicMock()
82
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
83
+
84
+ mock_ai_client = MagicMock()
85
+ mock_ai_client.generate_commit_message.side_effect = AIError('ai error')
86
+
87
+ with ExitStack() as stack:
88
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
89
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
90
+ with pytest.raises(SystemExit) as exc_info:
91
+ main()
92
+
93
+ assert exc_info.value.code == 1
94
+ assert 'ai error' in capsys.readouterr().out
95
+
96
+ def test_git_error_from_commit_exits_with_message(self, capsys: pytest.CaptureFixture[str]) -> None:
97
+ mock_git_ops = MagicMock()
98
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
99
+ mock_git_ops.commit.side_effect = GitError('commit error')
100
+
101
+ mock_ai_client = MagicMock()
102
+ mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
103
+
104
+ with ExitStack() as stack:
105
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
106
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
107
+ stack.enter_context(patch('git_commit_msg_ai.cli.editor'))
108
+ stack.enter_context(patch('builtins.input', return_value='a'))
109
+ with pytest.raises(SystemExit) as exc_info:
110
+ main()
111
+
112
+ assert exc_info.value.code == 1
113
+ assert 'commit error' in capsys.readouterr().out
114
+
115
+ def test_keyboard_interrupt_exits_with_aborted_message(self, capsys: pytest.CaptureFixture[str]) -> None:
116
+ mock_git_ops = MagicMock()
117
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
118
+
119
+ mock_ai_client = MagicMock()
120
+ mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
121
+
122
+ with ExitStack() as stack:
123
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
124
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
125
+ stack.enter_context(patch('git_commit_msg_ai.cli.editor'))
126
+ stack.enter_context(patch('builtins.input', side_effect=KeyboardInterrupt))
127
+ with pytest.raises(SystemExit) as exc_info:
128
+ main()
129
+
130
+ assert exc_info.value.code == 1
131
+ assert 'Aborted.' in capsys.readouterr().out
132
+
133
+ def test_eof_error_exits_with_aborted_message(self, capsys: pytest.CaptureFixture[str]) -> None:
134
+ mock_git_ops = MagicMock()
135
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
136
+
137
+ mock_ai_client = MagicMock()
138
+ mock_ai_client.generate_commit_message.return_value = GENERIC_COMMIT_MESSAGE
139
+
140
+ with ExitStack() as stack:
141
+ stack.enter_context(patch('git_commit_msg_ai.cli.git_ops', mock_git_ops))
142
+ stack.enter_context(patch('git_commit_msg_ai.cli.ai_client', mock_ai_client))
143
+ stack.enter_context(patch('git_commit_msg_ai.cli.editor'))
144
+ stack.enter_context(patch('builtins.input', side_effect=EOFError))
145
+ with pytest.raises(SystemExit) as exc_info:
146
+ main()
147
+
148
+ assert exc_info.value.code == 1
149
+ assert 'Aborted.' in capsys.readouterr().out
@@ -10,6 +10,11 @@ from git_commit_msg_ai.editor import get_default_editor, open_in_editor
10
10
  from git_commit_msg_ai.exceptions import EditorError
11
11
 
12
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'
13
18
 
14
19
 
15
20
  def _make_named_temporary_file_mock() -> MagicMock:
@@ -35,24 +40,24 @@ class TestOpenInEditor:
35
40
  with ExitStack() as stack:
36
41
  stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
37
42
  stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
38
- stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(' edited text ')))
43
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(EDITED_TEXT_WITH_WHITESPACE)))
39
44
  stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
40
45
 
41
- result = open_in_editor('initial text')
46
+ result = open_in_editor(INITIAL_TEXT)
42
47
 
43
- assert result == 'edited text'
48
+ assert result == EDITED_TEXT
44
49
 
45
50
  def test_uses_editor_env_var(self) -> None:
46
51
  with ExitStack() as stack:
47
52
  stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
48
53
  mock_run = stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
49
- stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock('text')))
54
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(GENERIC_TEXT)))
50
55
  stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
51
- stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value='vim'))
56
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
52
57
 
53
- open_in_editor('initial text')
58
+ open_in_editor(INITIAL_TEXT)
54
59
 
55
- mock_run.assert_called_once_with(['vim', TEMP_FILE_PATH], check=True)
60
+ mock_run.assert_called_once_with([VIM_EDITOR, TEMP_FILE_PATH], check=True)
56
61
 
57
62
  def test_falls_back_to_platform_default_when_editor_not_set(self) -> None:
58
63
  sentinel_editor = 'test-sentinel-editor'
@@ -60,12 +65,12 @@ class TestOpenInEditor:
60
65
  with ExitStack() as stack:
61
66
  stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
62
67
  mock_run = stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run'))
63
- stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock('text')))
68
+ stack.enter_context(patch('git_commit_msg_ai.editor.open', return_value=_make_open_mock(GENERIC_TEXT)))
64
69
  stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
65
70
  stack.enter_context(patch('git_commit_msg_ai.editor.get_default_editor', return_value=sentinel_editor))
66
71
  stack.enter_context(patch.dict('os.environ', {}, clear=True))
67
72
 
68
- open_in_editor('initial text')
73
+ open_in_editor(INITIAL_TEXT)
69
74
 
70
75
  mock_run.assert_called_once_with([sentinel_editor, TEMP_FILE_PATH], check=True)
71
76
 
@@ -75,7 +80,7 @@ class TestOpenInEditor:
75
80
  mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
76
81
 
77
82
  with pytest.raises(EditorError, match='temporary file'):
78
- open_in_editor('initial text')
83
+ open_in_editor(INITIAL_TEXT)
79
84
 
80
85
  mock_unlink.assert_not_called()
81
86
 
@@ -84,22 +89,22 @@ class TestOpenInEditor:
84
89
  stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
85
90
  stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=FileNotFoundError))
86
91
  mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
87
- stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value='vim'))
92
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
88
93
 
89
94
  with pytest.raises(EditorError, match='was not found'):
90
- open_in_editor('initial text')
95
+ open_in_editor(INITIAL_TEXT)
91
96
 
92
97
  mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
93
98
 
94
99
  def test_raises_editor_error_when_editor_exits_with_error(self) -> None:
95
100
  with ExitStack() as stack:
96
101
  stack.enter_context(patch('git_commit_msg_ai.editor.tempfile.NamedTemporaryFile', return_value=_make_named_temporary_file_mock()))
97
- stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=subprocess.CalledProcessError(1, 'vim')))
102
+ stack.enter_context(patch('git_commit_msg_ai.editor.subprocess.run', side_effect=subprocess.CalledProcessError(1, VIM_EDITOR)))
98
103
  mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
99
- stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value='vim'))
104
+ stack.enter_context(patch('git_commit_msg_ai.editor.os.environ.get', return_value=VIM_EDITOR))
100
105
 
101
106
  with pytest.raises(EditorError, match='exited with an error'):
102
- open_in_editor('initial text')
107
+ open_in_editor(INITIAL_TEXT)
103
108
 
104
109
  mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
105
110
 
@@ -111,20 +116,22 @@ class TestOpenInEditor:
111
116
  mock_unlink = stack.enter_context(patch('git_commit_msg_ai.editor.os.unlink'))
112
117
 
113
118
  with pytest.raises(EditorError, match='Could not read'):
114
- open_in_editor('initial text')
119
+ open_in_editor(INITIAL_TEXT)
115
120
 
116
121
  mock_unlink.assert_called_once_with(TEMP_FILE_PATH)
117
122
 
118
123
 
119
124
  class TestGetDefaultEditor:
120
125
  def test_returns_notepad_on_windows(self) -> None:
121
- with patch('git_commit_msg_ai.editor.platform.system', return_value='Windows'):
126
+ with ExitStack() as stack:
127
+ stack.enter_context(patch('git_commit_msg_ai.editor.platform.system', return_value='Windows'))
122
128
  result = get_default_editor()
123
129
 
124
130
  assert result == 'notepad'
125
131
 
126
132
  def test_returns_vi_on_non_windows(self) -> None:
127
- with patch('git_commit_msg_ai.editor.platform.system', return_value='Linux'):
133
+ with ExitStack() as stack:
134
+ stack.enter_context(patch('git_commit_msg_ai.editor.platform.system', return_value='Linux'))
128
135
  result = get_default_editor()
129
136
 
130
137
  assert result == 'vi'
@@ -1,15 +1,19 @@
1
1
  import subprocess
2
+ from contextlib import ExitStack
2
3
  from unittest.mock import MagicMock, patch
3
4
 
4
5
  import pytest
5
6
 
6
7
  from git_commit_msg_ai.exceptions import GitError
7
- from git_commit_msg_ai.git_ops import commit, get_staged_diff
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'
8
11
 
9
12
 
10
13
  class TestGetStagedDiff:
11
14
  def test_returns_decoded_diff(self) -> None:
12
- with patch('git_commit_msg_ai.git_ops.subprocess.check_output') as mock_check_output:
15
+ with ExitStack() as stack:
16
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
13
17
  mock_check_output.return_value = b'diff content'
14
18
 
15
19
  result = get_staged_diff()
@@ -17,14 +21,16 @@ class TestGetStagedDiff:
17
21
  assert result == 'diff content'
18
22
 
19
23
  def test_raises_git_error_when_git_not_found(self) -> None:
20
- with patch('git_commit_msg_ai.git_ops.subprocess.check_output') as mock_check_output:
24
+ with ExitStack() as stack:
25
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
21
26
  mock_check_output.side_effect = FileNotFoundError
22
27
 
23
28
  with pytest.raises(GitError, match='not installed'):
24
29
  get_staged_diff()
25
30
 
26
31
  def test_raises_git_error_on_called_process_error(self) -> None:
27
- with patch('git_commit_msg_ai.git_ops.subprocess.check_output') as mock_check_output:
32
+ with ExitStack() as stack:
33
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
28
34
  mock_check_output.side_effect = subprocess.CalledProcessError(1, 'git')
29
35
 
30
36
  with pytest.raises(GitError, match='git repository'):
@@ -34,14 +40,16 @@ class TestGetStagedDiff:
34
40
  mock_bytes = MagicMock()
35
41
  mock_bytes.decode.side_effect = UnicodeDecodeError('utf-8', b'', 0, 1, 'reason')
36
42
 
37
- with patch('git_commit_msg_ai.git_ops.subprocess.check_output') as mock_check_output:
43
+ with ExitStack() as stack:
44
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
38
45
  mock_check_output.return_value = mock_bytes
39
46
 
40
47
  with pytest.raises(GitError, match='UTF-8'):
41
48
  get_staged_diff()
42
49
 
43
50
  def test_raises_git_error_when_diff_is_empty(self) -> None:
44
- with patch('git_commit_msg_ai.git_ops.subprocess.check_output') as mock_check_output:
51
+ with ExitStack() as stack:
52
+ mock_check_output = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.check_output'))
45
53
  mock_check_output.return_value = b''
46
54
 
47
55
  with pytest.raises(GitError, match='No staged changes'):
@@ -50,21 +58,24 @@ class TestGetStagedDiff:
50
58
 
51
59
  class TestCommit:
52
60
  def test_calls_subprocess_run_with_correct_args(self) -> None:
53
- with patch('git_commit_msg_ai.git_ops.subprocess.run') as mock_run:
54
- commit('my message')
61
+ with ExitStack() as stack:
62
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
63
+ commit(COMMIT_MESSAGE)
55
64
 
56
- mock_run.assert_called_once_with(['git', 'commit', '-m', 'my message'], check=True)
65
+ mock_run.assert_called_once_with([GIT_COMMAND, COMMIT_SUBCOMMAND, MESSAGE_FLAG, COMMIT_MESSAGE], check=True)
57
66
 
58
67
  def test_raises_git_error_when_git_not_found(self) -> None:
59
- with patch('git_commit_msg_ai.git_ops.subprocess.run') as mock_run:
68
+ with ExitStack() as stack:
69
+ mock_run = stack.enter_context(patch('git_commit_msg_ai.git_ops.subprocess.run'))
60
70
  mock_run.side_effect = FileNotFoundError
61
71
 
62
72
  with pytest.raises(GitError, match='not installed'):
63
- commit('my message')
73
+ commit(COMMIT_MESSAGE)
64
74
 
65
75
  def test_raises_git_error_on_called_process_error(self) -> None:
66
- with patch('git_commit_msg_ai.git_ops.subprocess.run') as mock_run:
67
- mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
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)
68
79
 
69
80
  with pytest.raises(GitError, match='git commit failed'):
70
- commit('my message')
81
+ commit(COMMIT_MESSAGE)
@@ -1,139 +0,0 @@
1
- from unittest.mock import MagicMock, patch
2
-
3
- import pytest
4
-
5
- from git_commit_msg_ai.cli import main
6
- from git_commit_msg_ai.exceptions import AIError, GitError
7
-
8
- COMMIT_MESSAGE = 'feat: add feature'
9
- EDITED_COMMIT_MESSAGE = 'edited commit message'
10
-
11
-
12
- class TestMain:
13
- def _run_with_input(self, user_input: str, commit_message: str = COMMIT_MESSAGE) -> tuple[MagicMock, MagicMock, MagicMock]:
14
- mock_git_ops = MagicMock()
15
- mock_git_ops.get_staged_diff.return_value = 'diff content'
16
-
17
- mock_ai_client = MagicMock()
18
- mock_ai_client.generate_commit_message.return_value = commit_message
19
-
20
- mock_editor = MagicMock()
21
- mock_editor.open_in_editor.return_value = EDITED_COMMIT_MESSAGE
22
-
23
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
24
- with patch('git_commit_msg_ai.cli.ai_client', mock_ai_client):
25
- with patch('git_commit_msg_ai.cli.editor', mock_editor):
26
- with patch('builtins.input', return_value=user_input):
27
- main()
28
-
29
- return mock_git_ops, mock_ai_client, mock_editor
30
-
31
- def test_accept_calls_commit_with_generated_message(self) -> None:
32
- mock_git_ops, _, _ = self._run_with_input('a', COMMIT_MESSAGE)
33
-
34
- mock_git_ops.commit.assert_called_once_with(COMMIT_MESSAGE)
35
-
36
- def test_edit_opens_editor_then_commits_edited_message(self) -> None:
37
- mock_git_ops, _, mock_editor = self._run_with_input('e', COMMIT_MESSAGE)
38
-
39
- mock_editor.open_in_editor.assert_called_once_with(COMMIT_MESSAGE)
40
- mock_git_ops.commit.assert_called_once_with(EDITED_COMMIT_MESSAGE)
41
-
42
- def test_reject_does_not_commit(self) -> None:
43
- mock_git_ops, _, _ = self._run_with_input('r')
44
-
45
- mock_git_ops.commit.assert_not_called()
46
-
47
- def test_invalid_input_exits_with_code_1(self) -> None:
48
- mock_git_ops = MagicMock()
49
- mock_git_ops.get_staged_diff.return_value = 'diff content'
50
-
51
- mock_ai_client = MagicMock()
52
- mock_ai_client.generate_commit_message.return_value = 'msg'
53
-
54
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
55
- with patch('git_commit_msg_ai.cli.ai_client', mock_ai_client):
56
- with patch('git_commit_msg_ai.cli.editor'):
57
- with patch('builtins.input', return_value='x'):
58
- with pytest.raises(SystemExit) as exc_info:
59
- main()
60
-
61
- assert exc_info.value.code == 1
62
-
63
- def test_git_error_from_get_staged_diff_exits_with_message(self, capsys: pytest.CaptureFixture[str]) -> None:
64
- mock_git_ops = MagicMock()
65
- mock_git_ops.get_staged_diff.side_effect = GitError('staged error')
66
-
67
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
68
- with pytest.raises(SystemExit) as exc_info:
69
- main()
70
-
71
- assert exc_info.value.code == 1
72
- assert 'staged error' in capsys.readouterr().out
73
-
74
- def test_ai_error_from_generate_commit_message_exits_with_message(self, capsys: pytest.CaptureFixture[str]) -> None:
75
- mock_git_ops = MagicMock()
76
- mock_git_ops.get_staged_diff.return_value = 'diff content'
77
-
78
- mock_ai_client = MagicMock()
79
- mock_ai_client.generate_commit_message.side_effect = AIError('ai error')
80
-
81
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
82
- with patch('git_commit_msg_ai.cli.ai_client', mock_ai_client):
83
- with pytest.raises(SystemExit) as exc_info:
84
- main()
85
-
86
- assert exc_info.value.code == 1
87
- assert 'ai error' in capsys.readouterr().out
88
-
89
- def test_git_error_from_commit_exits_with_message(self, capsys: pytest.CaptureFixture[str]) -> None:
90
- mock_git_ops = MagicMock()
91
- mock_git_ops.get_staged_diff.return_value = 'diff content'
92
- mock_git_ops.commit.side_effect = GitError('commit error')
93
-
94
- mock_ai_client = MagicMock()
95
- mock_ai_client.generate_commit_message.return_value = 'msg'
96
-
97
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
98
- with patch('git_commit_msg_ai.cli.ai_client', mock_ai_client):
99
- with patch('git_commit_msg_ai.cli.editor'):
100
- with patch('builtins.input', return_value='a'):
101
- with pytest.raises(SystemExit) as exc_info:
102
- main()
103
-
104
- assert exc_info.value.code == 1
105
- assert 'commit error' in capsys.readouterr().out
106
-
107
- def test_keyboard_interrupt_exits_with_aborted_message(self, capsys: pytest.CaptureFixture[str]) -> None:
108
- mock_git_ops = MagicMock()
109
- mock_git_ops.get_staged_diff.return_value = 'diff content'
110
-
111
- mock_ai_client = MagicMock()
112
- mock_ai_client.generate_commit_message.return_value = 'msg'
113
-
114
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
115
- with patch('git_commit_msg_ai.cli.ai_client', mock_ai_client):
116
- with patch('git_commit_msg_ai.cli.editor'):
117
- with patch('builtins.input', side_effect=KeyboardInterrupt):
118
- with pytest.raises(SystemExit) as exc_info:
119
- main()
120
-
121
- assert exc_info.value.code == 1
122
- assert 'Aborted.' in capsys.readouterr().out
123
-
124
- def test_eof_error_exits_with_aborted_message(self, capsys: pytest.CaptureFixture[str]) -> None:
125
- mock_git_ops = MagicMock()
126
- mock_git_ops.get_staged_diff.return_value = 'diff content'
127
-
128
- mock_ai_client = MagicMock()
129
- mock_ai_client.generate_commit_message.return_value = 'msg'
130
-
131
- with patch('git_commit_msg_ai.cli.git_ops', mock_git_ops):
132
- with patch('git_commit_msg_ai.cli.ai_client', mock_ai_client):
133
- with patch('git_commit_msg_ai.cli.editor'):
134
- with patch('builtins.input', side_effect=EOFError):
135
- with pytest.raises(SystemExit) as exc_info:
136
- main()
137
-
138
- assert exc_info.value.code == 1
139
- assert 'Aborted.' in capsys.readouterr().out