git-commit-msg-ai 2.2.0__tar.gz → 2.3.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 (25) hide show
  1. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/PKG-INFO +6 -5
  2. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/README.md +5 -4
  3. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/ai_client.py +7 -3
  4. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/cli.py +31 -13
  5. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai.egg-info/PKG-INFO +6 -5
  6. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/pyproject.toml +1 -1
  7. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_ai_client.py +61 -0
  8. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_cli.py +86 -0
  9. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/LICENSE +0 -0
  10. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/__init__.py +0 -0
  11. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/config.py +0 -0
  12. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/editor.py +0 -0
  13. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/exceptions.py +0 -0
  14. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai/git_ops.py +0 -0
  15. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai.egg-info/SOURCES.txt +0 -0
  16. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai.egg-info/dependency_links.txt +0 -0
  17. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai.egg-info/entry_points.txt +0 -0
  18. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai.egg-info/requires.txt +0 -0
  19. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/git_commit_msg_ai.egg-info/top_level.txt +0 -0
  20. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/setup.cfg +0 -0
  21. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_config.py +0 -0
  22. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_editor.py +0 -0
  23. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_exceptions.py +0 -0
  24. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_generate_release_notes.py +0 -0
  25. {git_commit_msg_ai-2.2.0 → git_commit_msg_ai-2.3.0}/tests/test_git_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-msg-ai
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License: MIT License
6
6
 
@@ -118,12 +118,13 @@ The tool will:
118
118
  3. Print the message and prompt you to choose:
119
119
 
120
120
  ```
121
- [a]ccept / [e]dit / [r]eject:
121
+ [a]ccept / [e]dit / [r]eject / [f]eedback:
122
122
  ```
123
123
 
124
124
  - **a** - commits immediately with the generated message
125
125
  - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
126
126
  - **r** - exits without committing
127
+ - **f** - prompts you for natural-language feedback, regenerates the message incorporating that feedback, and loops back so you can keep refining until satisfied
127
128
 
128
129
  The tool automatically reads the current branch name and recent commit history and includes them in the request to the AI. This helps the AI match the commit style already established in the project. No configuration is required to enable this; see [Configuration](#configuration) to control how many recent commits are included.
129
130
 
@@ -214,11 +215,11 @@ Pushing a version tag (e.g. `v1.5.2`) triggers the CD pipeline:
214
215
  # 1. Bump the version in pyproject.toml
215
216
  # 2. Commit and push
216
217
  git add pyproject.toml
217
- git commit -m "chore: bump version to 2.2.0"
218
+ git commit -m "chore: bump version to 2.3.0"
218
219
  git push origin main
219
220
  # 3. Tag and push - this triggers the CD pipeline
220
- git tag v2.2.0
221
- git push origin v2.2.0
221
+ git tag v2.3.0
222
+ git push origin v2.3.0
222
223
  ```
223
224
 
224
225
  ## Debugging
@@ -75,12 +75,13 @@ The tool will:
75
75
  3. Print the message and prompt you to choose:
76
76
 
77
77
  ```
78
- [a]ccept / [e]dit / [r]eject:
78
+ [a]ccept / [e]dit / [r]eject / [f]eedback:
79
79
  ```
80
80
 
81
81
  - **a** - commits immediately with the generated message
82
82
  - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
83
83
  - **r** - exits without committing
84
+ - **f** - prompts you for natural-language feedback, regenerates the message incorporating that feedback, and loops back so you can keep refining until satisfied
84
85
 
85
86
  The tool automatically reads the current branch name and recent commit history and includes them in the request to the AI. This helps the AI match the commit style already established in the project. No configuration is required to enable this; see [Configuration](#configuration) to control how many recent commits are included.
86
87
 
@@ -171,11 +172,11 @@ Pushing a version tag (e.g. `v1.5.2`) triggers the CD pipeline:
171
172
  # 1. Bump the version in pyproject.toml
172
173
  # 2. Commit and push
173
174
  git add pyproject.toml
174
- git commit -m "chore: bump version to 2.2.0"
175
+ git commit -m "chore: bump version to 2.3.0"
175
176
  git push origin main
176
177
  # 3. Tag and push - this triggers the CD pipeline
177
- git tag v2.2.0
178
- git push origin v2.2.0
178
+ git tag v2.3.0
179
+ git push origin v2.3.0
179
180
  ```
180
181
 
181
182
  ## Debugging
@@ -1,9 +1,9 @@
1
1
  import logging
2
2
  import textwrap
3
- from typing import Final
3
+ from typing import Final, cast
4
4
 
5
5
  import anthropic
6
- from anthropic.types import CacheControlEphemeralParam, TextBlockParam
6
+ from anthropic.types import CacheControlEphemeralParam, MessageParam, TextBlockParam
7
7
 
8
8
  from git_commit_msg_ai.exceptions import AIError
9
9
 
@@ -46,17 +46,21 @@ def generate_commit_message(
46
46
  *,
47
47
  recent_commits: list[str] | None = None,
48
48
  branch_name: str | None = None,
49
+ conversation_history: list[dict[str, str]] | None = None,
49
50
  ) -> str:
50
51
  try:
51
52
  anthropic_client = anthropic.Anthropic()
52
53
 
53
54
  user_message = _build_user_message(diff, recent_commits, branch_name)
55
+ messages: list[MessageParam] = [{"role": "user", "content": user_message}]
56
+ if conversation_history:
57
+ messages.extend(cast(list[MessageParam], conversation_history))
54
58
  logger.debug(f"Calling Anthropic API: model={MODEL} max_tokens={MAX_TOKENS}")
55
59
  anthropic_api_response = anthropic_client.messages.create(
56
60
  model=MODEL,
57
61
  max_tokens=MAX_TOKENS,
58
62
  system=_build_system_prompt(types),
59
- messages=[{"role": "user", "content": user_message}],
63
+ messages=messages,
60
64
  )
61
65
  except anthropic.AuthenticationError:
62
66
  raise AIError("Anthropic API key is missing or invalid. Set the ANTHROPIC_API_KEY environment variable.")
@@ -33,19 +33,37 @@ def main() -> None:
33
33
  commit_message = ai_client.generate_commit_message(diff, app_config.types, recent_commits=recent_commits, branch_name=branch_name)
34
34
  print(commit_message)
35
35
 
36
- print()
37
- user_selection = input("[a]ccept / [e]dit / [r]eject: ").strip().lower()
38
-
39
- if user_selection == "a":
40
- git_ops.commit(commit_message)
41
- elif user_selection == "e":
42
- updated_commit_message = editor.open_in_editor(commit_message)
43
- git_ops.commit(updated_commit_message)
44
- elif user_selection == "r":
45
- print("User rejected the generated commit message. No commit made.")
46
- else:
47
- print("Invalid selection.")
48
- sys.exit(1)
36
+ conversation_history: list[dict[str, str]] = [{"role": "assistant", "content": commit_message}]
37
+
38
+ while True:
39
+ print()
40
+ user_selection = input("[a]ccept / [e]dit / [r]eject / [f]eedback: ").strip().lower()
41
+
42
+ if user_selection == "a":
43
+ git_ops.commit(commit_message)
44
+ break
45
+ elif user_selection == "e":
46
+ updated_commit_message = editor.open_in_editor(commit_message)
47
+ git_ops.commit(updated_commit_message)
48
+ break
49
+ elif user_selection == "r":
50
+ print("User rejected the generated commit message. No commit made.")
51
+ break
52
+ elif user_selection == "f":
53
+ feedback = input("Feedback: ").strip()
54
+ conversation_history.append({"role": "user", "content": feedback})
55
+ commit_message = ai_client.generate_commit_message(
56
+ diff,
57
+ app_config.types,
58
+ recent_commits=recent_commits,
59
+ branch_name=branch_name,
60
+ conversation_history=list(conversation_history),
61
+ )
62
+ print(commit_message)
63
+ conversation_history.append({"role": "assistant", "content": commit_message})
64
+ else:
65
+ print("Invalid selection.")
66
+ sys.exit(1)
49
67
  except GitCommitAIError as error:
50
68
  logger.error(str(error))
51
69
  print(f"Error: {error}", file=sys.stderr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-msg-ai
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: AI-powered git commit message generator following Conventional Commits
5
5
  License: MIT License
6
6
 
@@ -118,12 +118,13 @@ The tool will:
118
118
  3. Print the message and prompt you to choose:
119
119
 
120
120
  ```
121
- [a]ccept / [e]dit / [r]eject:
121
+ [a]ccept / [e]dit / [r]eject / [f]eedback:
122
122
  ```
123
123
 
124
124
  - **a** - commits immediately with the generated message
125
125
  - **e** - opens the message in your `$EDITOR` (defaults to `notepad` on Windows, `vi` on Linux/macOS), lets you modify it, then commits
126
126
  - **r** - exits without committing
127
+ - **f** - prompts you for natural-language feedback, regenerates the message incorporating that feedback, and loops back so you can keep refining until satisfied
127
128
 
128
129
  The tool automatically reads the current branch name and recent commit history and includes them in the request to the AI. This helps the AI match the commit style already established in the project. No configuration is required to enable this; see [Configuration](#configuration) to control how many recent commits are included.
129
130
 
@@ -214,11 +215,11 @@ Pushing a version tag (e.g. `v1.5.2`) triggers the CD pipeline:
214
215
  # 1. Bump the version in pyproject.toml
215
216
  # 2. Commit and push
216
217
  git add pyproject.toml
217
- git commit -m "chore: bump version to 2.2.0"
218
+ git commit -m "chore: bump version to 2.3.0"
218
219
  git push origin main
219
220
  # 3. Tag and push - this triggers the CD pipeline
220
- git tag v2.2.0
221
- git push origin v2.2.0
221
+ git tag v2.3.0
222
+ git push origin v2.3.0
222
223
  ```
223
224
 
224
225
  ## Debugging
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-commit-msg-ai"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "AI-powered git commit message generator following Conventional Commits"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -174,3 +174,64 @@ class TestGenerateCommitMessage:
174
174
  user_content = mock_client.messages.create.call_args.kwargs["messages"][0]["content"]
175
175
 
176
176
  assert user_content == "diff content"
177
+
178
+ def test_generate_commit_message_without_history_sends_single_message(self) -> None:
179
+ with ExitStack() as stack:
180
+ mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
181
+ mock_client = MagicMock()
182
+ mock_client.messages.create.return_value = _make_api_response("feat: add feature")
183
+ mock_anthropic_class.return_value = mock_client
184
+
185
+ generate_commit_message("diff content", TYPES)
186
+
187
+ messages = mock_client.messages.create.call_args.kwargs["messages"]
188
+
189
+ assert len(messages) == 1
190
+ assert messages[0]["role"] == "user"
191
+
192
+ def test_generate_commit_message_with_none_history_sends_single_message(self) -> None:
193
+ with ExitStack() as stack:
194
+ mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
195
+ mock_client = MagicMock()
196
+ mock_client.messages.create.return_value = _make_api_response("feat: add feature")
197
+ mock_anthropic_class.return_value = mock_client
198
+
199
+ generate_commit_message("diff content", TYPES, conversation_history=None)
200
+
201
+ messages = mock_client.messages.create.call_args.kwargs["messages"]
202
+
203
+ assert len(messages) == 1
204
+
205
+ def test_generate_commit_message_with_empty_history_sends_single_message(self) -> None:
206
+ with ExitStack() as stack:
207
+ mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
208
+ mock_client = MagicMock()
209
+ mock_client.messages.create.return_value = _make_api_response("feat: add feature")
210
+ mock_anthropic_class.return_value = mock_client
211
+
212
+ generate_commit_message("diff content", TYPES, conversation_history=[])
213
+
214
+ messages = mock_client.messages.create.call_args.kwargs["messages"]
215
+
216
+ assert len(messages) == 1
217
+
218
+ def test_generate_commit_message_with_conversation_history_appends_to_messages(self) -> None:
219
+ history = [
220
+ {"role": "assistant", "content": "feat: first draft"},
221
+ {"role": "user", "content": "make it shorter"},
222
+ ]
223
+
224
+ with ExitStack() as stack:
225
+ mock_anthropic_class = stack.enter_context(patch("git_commit_msg_ai.ai_client.anthropic.Anthropic"))
226
+ mock_client = MagicMock()
227
+ mock_client.messages.create.return_value = _make_api_response("feat: shorter")
228
+ mock_anthropic_class.return_value = mock_client
229
+
230
+ generate_commit_message("diff content", TYPES, conversation_history=history)
231
+
232
+ messages = mock_client.messages.create.call_args.kwargs["messages"]
233
+
234
+ assert len(messages) == 3
235
+ assert messages[0]["role"] == "user"
236
+ assert messages[1] == {"role": "assistant", "content": "feat: first draft"}
237
+ assert messages[2] == {"role": "user", "content": "make it shorter"}
@@ -10,6 +10,7 @@ from git_commit_msg_ai.config import CONTEXT_COMMITS_DEFAULT, DEFAULT_OPTIONAL_T
10
10
  from git_commit_msg_ai.exceptions import AIError, GitError
11
11
 
12
12
  COMMIT_MESSAGE = "feat: add feature"
13
+ REFINED_COMMIT_MESSAGE = "feat: add concise feature"
13
14
  EDITED_COMMIT_MESSAGE = "edited commit message"
14
15
  STAGED_DIFF = "diff content"
15
16
  GENERIC_COMMIT_MESSAGE = "generic commit message"
@@ -316,6 +317,91 @@ class TestMain:
316
317
 
317
318
  assert "Aborted." in capsys.readouterr().out
318
319
 
320
+ def _run_with_inputs(
321
+ self,
322
+ user_inputs: list[str],
323
+ commit_messages: list[str] | None = None,
324
+ ) -> tuple[MagicMock, MagicMock, MagicMock]:
325
+ if commit_messages is None:
326
+ commit_messages = [COMMIT_MESSAGE, REFINED_COMMIT_MESSAGE]
327
+
328
+ mock_git_ops = MagicMock()
329
+ mock_git_ops.get_staged_diff.return_value = STAGED_DIFF
330
+ mock_git_ops.get_recent_commit_messages.return_value = RECENT_COMMITS
331
+ mock_git_ops.get_branch_name.return_value = BRANCH_NAME
332
+
333
+ mock_ai_client = MagicMock()
334
+ mock_ai_client.generate_commit_message.side_effect = commit_messages
335
+
336
+ mock_editor = MagicMock()
337
+ mock_editor.open_in_editor.return_value = EDITED_COMMIT_MESSAGE
338
+
339
+ with ExitStack() as stack:
340
+ mock_config = MagicMock()
341
+ mock_config.load_config.return_value = APP_CONFIG
342
+ stack.enter_context(patch("git_commit_msg_ai.cli.git_ops", mock_git_ops))
343
+ stack.enter_context(patch("git_commit_msg_ai.cli.ai_client", mock_ai_client))
344
+ stack.enter_context(patch("git_commit_msg_ai.cli.editor", mock_editor))
345
+ stack.enter_context(patch("git_commit_msg_ai.cli.config", mock_config))
346
+ stack.enter_context(patch("builtins.input", side_effect=user_inputs))
347
+ main()
348
+
349
+ return mock_git_ops, mock_ai_client, mock_editor
350
+
351
+ def test_main_feedback_then_accept_commits_refined_message(self) -> None:
352
+ mock_git_ops, _, _ = self._run_with_inputs(["f", "make it shorter", "a"])
353
+
354
+ mock_git_ops.commit.assert_called_once_with(REFINED_COMMIT_MESSAGE)
355
+
356
+ def test_main_feedback_then_reject_does_not_commit(self) -> None:
357
+ mock_git_ops, _, _ = self._run_with_inputs(["f", "make it shorter", "r"])
358
+
359
+ mock_git_ops.commit.assert_not_called()
360
+
361
+ def test_main_feedback_then_edit_opens_editor_with_refined_message(self) -> None:
362
+ _, _, mock_editor = self._run_with_inputs(["f", "make it shorter", "e"])
363
+
364
+ mock_editor.open_in_editor.assert_called_once_with(REFINED_COMMIT_MESSAGE)
365
+
366
+ def test_main_feedback_passes_conversation_history_to_refined_call(self) -> None:
367
+ _, mock_ai_client, _ = self._run_with_inputs(["f", "make it shorter", "a"])
368
+
369
+ calls = mock_ai_client.generate_commit_message.call_args_list
370
+ assert calls[1].kwargs["conversation_history"] == [
371
+ {"role": "assistant", "content": COMMIT_MESSAGE},
372
+ {"role": "user", "content": "make it shorter"},
373
+ ]
374
+
375
+ def test_main_first_call_has_no_conversation_history(self) -> None:
376
+ _, mock_ai_client, _ = self._run_with_inputs(["f", "make it shorter", "a"])
377
+
378
+ first_call_kwargs = mock_ai_client.generate_commit_message.call_args_list[0].kwargs
379
+ assert "conversation_history" not in first_call_kwargs
380
+
381
+ def test_main_multiple_feedback_rounds_grows_conversation_history(self) -> None:
382
+ second_refined = "feat: even shorter"
383
+ _, mock_ai_client, _ = self._run_with_inputs(
384
+ ["f", "feedback1", "f", "feedback2", "a"],
385
+ commit_messages=[COMMIT_MESSAGE, REFINED_COMMIT_MESSAGE, second_refined],
386
+ )
387
+
388
+ calls = mock_ai_client.generate_commit_message.call_args_list
389
+ assert calls[2].kwargs["conversation_history"] == [
390
+ {"role": "assistant", "content": COMMIT_MESSAGE},
391
+ {"role": "user", "content": "feedback1"},
392
+ {"role": "assistant", "content": REFINED_COMMIT_MESSAGE},
393
+ {"role": "user", "content": "feedback2"},
394
+ ]
395
+
396
+ def test_main_multiple_feedback_rounds_commits_last_refined_message(self) -> None:
397
+ second_refined = "feat: even shorter"
398
+ mock_git_ops, _, _ = self._run_with_inputs(
399
+ ["f", "feedback1", "f", "feedback2", "a"],
400
+ commit_messages=[COMMIT_MESSAGE, REFINED_COMMIT_MESSAGE, second_refined],
401
+ )
402
+
403
+ mock_git_ops.commit.assert_called_once_with(second_refined)
404
+
319
405
  def _run_main_rejecting(self) -> None:
320
406
  mock_git_ops = MagicMock()
321
407
  mock_git_ops.get_staged_diff.return_value = "diff"