git-cai-cli 0.11.0__tar.gz → 0.11.2__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 (93) hide show
  1. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.proselintrc +1 -1
  2. {git_cai_cli-0.11.0/src/git_cai_cli.egg-info → git_cai_cli-0.11.2}/PKG-INFO +1 -1
  3. git_cai_cli-0.11.2/cai_config.yml +35 -0
  4. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/_version.py +3 -3
  5. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/config.py +9 -1
  6. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/llm.py +53 -71
  7. git_cai_cli-0.11.2/src/git_cai_cli/core/prompts_fallback.py +36 -0
  8. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/squash.py +29 -26
  9. git_cai_cli-0.11.2/src/git_cai_cli/defaults/commit_prompt.md +12 -0
  10. git_cai_cli-0.11.2/src/git_cai_cli/defaults/squash_prompt.md +10 -0
  11. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2/src/git_cai_cli.egg-info}/PKG-INFO +1 -1
  12. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli.egg-info/SOURCES.txt +1 -0
  13. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_gitutils.py +1 -1
  14. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_llm.py +3 -2
  15. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_squash.py +4 -0
  16. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/uv.lock +247 -231
  17. git_cai_cli-0.11.0/src/git_cai_cli/core/prompts_fallback.py +0 -26
  18. git_cai_cli-0.11.0/src/git_cai_cli/defaults/commit_prompt.md +0 -6
  19. git_cai_cli-0.11.0/src/git_cai_cli/defaults/squash_prompt.md +0 -9
  20. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.caiignore +0 -0
  21. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.gitattributes +0 -0
  22. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/cd/.SRCINFO +0 -0
  23. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/cd/PKGBUILD +0 -0
  24. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/ci/_version.py +0 -0
  25. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/ci/cai_config.ci.yml +0 -0
  26. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/ci/tokens.ci.yml +0 -0
  27. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/workflows/python-tests.yml +0 -0
  28. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/workflows/release.yml +0 -0
  29. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.github/workflows/release_aur.yml +0 -0
  30. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.gitignore +0 -0
  31. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.bandit.yml +0 -0
  32. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.checkov.yml +0 -0
  33. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.flake8 +0 -0
  34. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.ls-lint.yml +0 -0
  35. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.markdown-link-check.json +0 -0
  36. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.markdownlint.json +0 -0
  37. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.pylintrc +0 -0
  38. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/.yamllint.yml +0 -0
  39. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/check_git_branch_name.sh +0 -0
  40. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/lychee.toml +0 -0
  41. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.linters/pyrightconfig.json +0 -0
  42. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.markdownlintignore +0 -0
  43. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.mega-linter.yml +0 -0
  44. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.semgrepignore +0 -0
  45. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/.trivyignore +0 -0
  46. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/CLAUDE.md +0 -0
  47. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/LICENSE +0 -0
  48. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/Makefile +0 -0
  49. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/README.md +0 -0
  50. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/docs/git-cai.txt +0 -0
  51. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/docs/man/git-cai.1 +0 -0
  52. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/pyproject.toml +0 -0
  53. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/setup.cfg +0 -0
  54. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/__init__.py +0 -0
  55. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/cli/__init__.py +0 -0
  56. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/cli/cli.py +0 -0
  57. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/cli/helptext.py +0 -0
  58. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/cli/modes.py +0 -0
  59. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/__init__.py +0 -0
  60. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/completion.py +0 -0
  61. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/editors.py +0 -0
  62. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/gitutils.py +0 -0
  63. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/languages.py +0 -0
  64. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/options.py +0 -0
  65. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/spinner.py +0 -0
  66. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/core/validate.py +0 -0
  67. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/defaults/__init__.py +0 -0
  68. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli/main.py +0 -0
  69. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
  70. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
  71. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli.egg-info/requires.txt +0 -0
  72. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/src/git_cai_cli.egg-info/top_level.txt +0 -0
  73. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/conftest.py +0 -0
  74. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/integration/test_cli_integration.py +0 -0
  75. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/integration/test_config_integration.py +0 -0
  76. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/integration/test_gitutils_integration.py +0 -0
  77. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/integration/test_modes_integration.py +0 -0
  78. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/integration/test_options_integration.py +0 -0
  79. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/integration/test_squash_integration.py +0 -0
  80. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_amend.py +0 -0
  81. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_branch_context.py +0 -0
  82. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_cli.py +0 -0
  83. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_completion.py +0 -0
  84. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_config.py +0 -0
  85. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_conventional.py +0 -0
  86. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_helptext.py +0 -0
  87. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_main.py +0 -0
  88. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_modes.py +0 -0
  89. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_options.py +0 -0
  90. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_prompt_loading.py +0 -0
  91. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_set_config.py +0 -0
  92. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_spinner.py +0 -0
  93. {git_cai_cli-0.11.0 → git_cai_cli-0.11.2}/tests/unit/test_validate.py +0 -0
@@ -73,6 +73,6 @@
73
73
  , "typography.symbols": false
74
74
  , "uncomparables.misc": true
75
75
  , "weasel_words.misc": true
76
- , "weasel_words.very": true
76
+ , "weasel_words.very": false
77
77
  }
78
78
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-cai-cli
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Use LLM to create git commit messages
5
5
  Author-email: Thorsten Foltz <thorsten.foltz@live.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,35 @@
1
+ default: anthropic
2
+ language: en
3
+ style: professional
4
+ emoji: true
5
+ conventional: false
6
+ branch_context: false
7
+ load_tokens_from: /home/thorsten/.config/cai/tokens.yml
8
+ prompt_file: ""
9
+ squash_prompt_file: ""
10
+ token_logging: true
11
+ measure_time: true
12
+ anthropic:
13
+ model: claude-opus-4-6
14
+ temperature: 0
15
+ deepseek:
16
+ model: deepseek-chat
17
+ temperature: 0
18
+ gemini:
19
+ model: gemini-2.5-flash
20
+ temperature: 0
21
+ groq:
22
+ model: moonshotai/kimi-k2-instruct
23
+ temperature: 0
24
+ mistral:
25
+ model: codestral-2508
26
+ temperature: 0
27
+ ollama:
28
+ model: llama3.1
29
+ temperature: 0
30
+ openai:
31
+ model: gpt-5.2
32
+ temperature: 0
33
+ xai:
34
+ model: grok-4-1-fast-reasoning
35
+ temperature: 0
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.11.0'
22
- __version_tuple__ = version_tuple = (0, 11, 0)
21
+ __version__ = version = '0.11.2'
22
+ __version_tuple__ = version_tuple = (0, 11, 2)
23
23
 
24
- __commit_id__ = commit_id = 'gb38a05ada'
24
+ __commit_id__ = commit_id = 'g24b24b5ca'
@@ -268,7 +268,15 @@ def load_config(
268
268
 
269
269
  try:
270
270
  with fallback_config_file.open("r", encoding="utf-8") as f:
271
- config = cast(dict[str, Any], yaml.safe_load(f) or default_config)
271
+ raw = yaml.safe_load(f)
272
+ if not isinstance(raw, dict):
273
+ log.warning(
274
+ "Home config %s is not a YAML mapping, using defaults",
275
+ fallback_config_file,
276
+ )
277
+ config = default_config
278
+ else:
279
+ config = raw or default_config
272
280
  log.debug("Home config loaded successfully")
273
281
  except yaml.YAMLError:
274
282
  log.error("Failed to parse home config %s", fallback_config_file)
@@ -164,7 +164,10 @@ class CommitMessageGenerator:
164
164
 
165
165
  content = git_diff
166
166
  if context:
167
- content = f"{git_diff}\n\nAdditional context:\n{context}"
167
+ content = (
168
+ f"{git_diff}\n\n"
169
+ f"--- Additional context from the author ---\n{context}"
170
+ )
168
171
 
169
172
  return self._dispatch_generate(content=content, system_prompt=prompt)
170
173
 
@@ -192,8 +195,8 @@ class CommitMessageGenerator:
192
195
 
193
196
  if emoji_value:
194
197
  emoji_instruction = (
195
- "Use relevant emojis in the commit message where appropriate. "
196
- "Emojis should enhance the clarity and tone of the message."
198
+ "Use relevant emojis at the start of the headline and in bullet points "
199
+ "where they add clarity. Keep emojis purposeful one per bullet at most."
197
200
  )
198
201
  log.info("Emojis are enabled for commit messages.")
199
202
  else:
@@ -236,7 +239,7 @@ class CommitMessageGenerator:
236
239
  log.info("Style setting is 'none' — no style instruction added to prompt.")
237
240
  return ""
238
241
 
239
- return f"Write the commit message in the following tone style: {style}."
242
+ return f"Write the commit message in the following tone style: {style}. Apply this tone to both the headline and the bullet points."
240
243
 
241
244
  def _conventional_instruction(self) -> str:
242
245
  """
@@ -248,12 +251,12 @@ class CommitMessageGenerator:
248
251
  log.info("Conventional Commits format enabled.")
249
252
  return (
250
253
  "Follow the Conventional Commits specification. "
251
- "The commit message MUST be structured as: <type>(<optional scope>): <description>. "
254
+ "The headline MUST be structured as: <type>(<optional scope>): <description>. "
252
255
  "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. "
253
256
  "The scope is optional and describes the section of the codebase affected. "
254
257
  "Use a '!' after the type/scope for breaking changes (e.g., 'feat!: ...' or 'feat(api)!: ...'). "
255
- "The description must be a concise summary in imperative mood. "
256
- "Additional details can follow as bullet points in the body."
258
+ "The description must be a concise summary in imperative mood, lowercase, no trailing period. "
259
+ "Additional details follow as bullet points in the body after a blank line."
257
260
  )
258
261
 
259
262
  def _branch_instruction(self) -> str:
@@ -270,8 +273,8 @@ class CommitMessageGenerator:
270
273
  log.info("Branch context enabled: '%s'.", branch_name)
271
274
  return (
272
275
  f"The current Git branch is '{branch_name}'. "
273
- "Use the branch name as additional context to better understand "
274
- "the intent and scope of the changes."
276
+ "Use the branch name as additional context to infer the intent "
277
+ "and scope of the changes — but do not include the branch name in the message."
275
278
  )
276
279
 
277
280
  def _config_instructions(self) -> str:
@@ -422,15 +425,12 @@ class CommitMessageGenerator:
422
425
 
423
426
  data = response.json()
424
427
 
425
- try:
426
- usage = data.get("usage", {})
427
- self._log_token_usage(
428
- "anthropic",
429
- usage.get("input_tokens"),
430
- usage.get("output_tokens"),
431
- )
432
- except (KeyError, TypeError, AttributeError):
433
- self._log_token_usage("anthropic", None, None)
428
+ usage = data.get("usage") or {}
429
+ self._log_token_usage(
430
+ "anthropic",
431
+ usage.get("input_tokens"),
432
+ usage.get("output_tokens"),
433
+ )
434
434
 
435
435
  return data["content"][0]["text"].strip()
436
436
 
@@ -492,15 +492,12 @@ class CommitMessageGenerator:
492
492
 
493
493
  data = response.json()
494
494
 
495
- try:
496
- usage = data.get("usageMetadata", {})
497
- self._log_token_usage(
498
- "gemini",
499
- usage.get("promptTokenCount"),
500
- usage.get("candidatesTokenCount"),
501
- )
502
- except (KeyError, TypeError, AttributeError):
503
- self._log_token_usage("gemini", None, None)
495
+ usage = data.get("usageMetadata") or {}
496
+ self._log_token_usage(
497
+ "gemini",
498
+ usage.get("promptTokenCount"),
499
+ usage.get("candidatesTokenCount"),
500
+ )
504
501
 
505
502
  return data["candidates"][0]["content"]["parts"][0]["text"].strip()
506
503
 
@@ -553,15 +550,12 @@ class CommitMessageGenerator:
553
550
 
554
551
  data = response.json()
555
552
 
556
- try:
557
- usage = data.get("usage", {})
558
- self._log_token_usage(
559
- "groq",
560
- usage.get("prompt_tokens"),
561
- usage.get("completion_tokens"),
562
- )
563
- except (KeyError, TypeError, AttributeError):
564
- self._log_token_usage("groq", None, None)
553
+ usage = data.get("usage") or {}
554
+ self._log_token_usage(
555
+ "groq",
556
+ usage.get("prompt_tokens"),
557
+ usage.get("completion_tokens"),
558
+ )
565
559
 
566
560
  return data["choices"][0]["message"]["content"].strip()
567
561
 
@@ -612,15 +606,12 @@ class CommitMessageGenerator:
612
606
 
613
607
  data = response.json()
614
608
 
615
- try:
616
- usage = data.get("usage", {})
617
- self._log_token_usage(
618
- "mistral",
619
- usage.get("prompt_tokens"),
620
- usage.get("completion_tokens"),
621
- )
622
- except (KeyError, TypeError, AttributeError):
623
- self._log_token_usage("mistral", None, None)
609
+ usage = data.get("usage") or {}
610
+ self._log_token_usage(
611
+ "mistral",
612
+ usage.get("prompt_tokens"),
613
+ usage.get("completion_tokens"),
614
+ )
624
615
 
625
616
  return data["choices"][0]["message"]["content"].strip()
626
617
 
@@ -780,14 +771,11 @@ class CommitMessageGenerator:
780
771
  data = response.json()
781
772
 
782
773
  # Extract token usage from Ollama response
783
- try:
784
- self._log_token_usage(
785
- "ollama",
786
- data.get("prompt_eval_count") if isinstance(data, dict) else None,
787
- data.get("eval_count") if isinstance(data, dict) else None,
788
- )
789
- except (KeyError, TypeError, AttributeError):
790
- self._log_token_usage("ollama", None, None)
774
+ self._log_token_usage(
775
+ "ollama",
776
+ data.get("prompt_eval_count") if isinstance(data, dict) else None,
777
+ data.get("eval_count") if isinstance(data, dict) else None,
778
+ )
791
779
 
792
780
  # /api/chat format
793
781
  if isinstance(data, dict) and isinstance(data.get("message"), dict):
@@ -847,15 +835,12 @@ class CommitMessageGenerator:
847
835
  stream=False,
848
836
  )
849
837
 
850
- try:
851
- usage = completion.usage
852
- self._log_token_usage(
853
- provider_name,
854
- getattr(usage, "prompt_tokens", None),
855
- getattr(usage, "completion_tokens", None),
856
- )
857
- except (KeyError, TypeError, AttributeError):
858
- self._log_token_usage(provider_name, None, None)
838
+ usage = completion.usage
839
+ self._log_token_usage(
840
+ provider_name,
841
+ getattr(usage, "prompt_tokens", None),
842
+ getattr(usage, "completion_tokens", None),
843
+ )
859
844
 
860
845
  return completion.choices[0].message.content.strip()
861
846
 
@@ -905,15 +890,12 @@ class CommitMessageGenerator:
905
890
 
906
891
  data = response.json()
907
892
 
908
- try:
909
- usage = data.get("usage", {})
910
- self._log_token_usage(
911
- "xai",
912
- usage.get("prompt_tokens"),
913
- usage.get("completion_tokens"),
914
- )
915
- except (KeyError, TypeError, AttributeError):
916
- self._log_token_usage("xai", None, None)
893
+ usage = data.get("usage") or {}
894
+ self._log_token_usage(
895
+ "xai",
896
+ usage.get("prompt_tokens"),
897
+ usage.get("completion_tokens"),
898
+ )
917
899
 
918
900
  return data["choices"][0]["message"]["content"].strip()
919
901
 
@@ -0,0 +1,36 @@
1
+ """
2
+ Hardcoded fallback prompts used as a last resort when no prompt file is found.
3
+
4
+ This module exists to break the circular dependency between config.py and llm.py.
5
+ Both modules can safely import from here without creating import cycles.
6
+ """
7
+
8
+ HARDCODED_COMMIT_PROMPT = (
9
+ "You are an expert software engineer assistant. "
10
+ "Your task is to generate a concise, professional git commit message "
11
+ "from the provided git diff.\n"
12
+ "\n"
13
+ "Rules:\n"
14
+ '- Write a single headline in imperative mood (e.g. "Add", "Fix", "Refactor"), max 50 characters.\n'
15
+ "- Below the headline, leave one blank line, then add a bullet-point list of the most important changes.\n"
16
+ "- Each bullet should explain *what* changed and *why*, not repeat filenames or obvious details.\n"
17
+ "- Group related changes into one bullet instead of listing every file separately.\n"
18
+ "- Keep the total message short — aim for clarity over completeness.\n"
19
+ "- Output only the raw commit message. No markdown fences, no quotes, no extra commentary.\n"
20
+ "- If you detect sensitive information (keys, tokens, passwords), "
21
+ "warn about it at the very top before the headline."
22
+ )
23
+
24
+ HARDCODED_SQUASH_PROMPT = (
25
+ "You are an expert software engineer assistant. "
26
+ "Your task is to summarize multiple existing commit messages "
27
+ "into a single, coherent git commit message that captures the overall intent.\n"
28
+ "\n"
29
+ "Rules:\n"
30
+ "- Do not list or echo each original commit. Synthesize them into a unified narrative.\n"
31
+ "- Write one clear headline in imperative mood (max 50 characters) that captures the main purpose.\n"
32
+ "- Below the headline, leave one blank line, then add a concise bullet list "
33
+ "describing the key themes of the work.\n"
34
+ "- Focus on *why* the changes were made, not just *what* was touched.\n"
35
+ "- Output only the raw commit message. No markdown fences, no quotes, no extra commentary."
36
+ )
@@ -251,7 +251,10 @@ def squash_branch(
251
251
 
252
252
  result = commit_with_edit_template(msg)
253
253
  if result != 0:
254
- log.info("Commit aborted — squash cancelled.")
254
+ log.info(
255
+ "Commit aborted — squash cancelled. "
256
+ "Note: staged changes were already committed."
257
+ )
255
258
  return
256
259
 
257
260
  else:
@@ -314,35 +317,35 @@ def squash_branch(
314
317
  tf.flush()
315
318
  tf_name = Path(tf.name)
316
319
 
317
- original_hash = sha256_of_file(tf_name)
318
-
319
- editor = get_git_editor()
320
- parts = shlex.split(editor)
321
- if not shutil.which(parts[0]):
322
- # This editor command requires shell interpretation
323
- rc = subprocess.run(
324
- f'{editor} "{tf_name}"',
325
- shell=True,
326
- check=False, # nosemgrep
327
- ).returncode # nosec # nosemgrep
328
- else:
329
- rc = subprocess.run(parts + [str(tf_name)], check=False).returncode
320
+ try:
321
+ original_hash = sha256_of_file(tf_name)
322
+
323
+ editor = get_git_editor()
324
+ parts = shlex.split(editor)
325
+ if not shutil.which(parts[0]):
326
+ # This editor command requires shell interpretation
327
+ rc = subprocess.run(
328
+ f'{editor} "{tf_name}"',
329
+ shell=True,
330
+ check=False, # nosemgrep
331
+ ).returncode # nosec # nosemgrep
332
+ else:
333
+ rc = subprocess.run(parts + [str(tf_name)], check=False).returncode
334
+
335
+ if rc != 0:
336
+ log.info("Editor exited non-zero — squash cancelled.")
337
+ return
330
338
 
331
- if rc != 0:
332
- log.info("Editor exited non-zero — squash cancelled.")
333
- tf_name.unlink(missing_ok=True)
334
- return
339
+ new_hash = sha256_of_file(tf_name)
335
340
 
336
- new_hash = sha256_of_file(tf_name)
341
+ if new_hash == original_hash:
342
+ log.info("Squash cancelled (user did not save message).")
343
+ return
337
344
 
338
- if new_hash == original_hash:
339
- log.info("Squash cancelled (user did not save message).")
345
+ # User saved → read message into final_message
346
+ final_message = tf_name.read_text(encoding="utf-8").strip()
347
+ finally:
340
348
  tf_name.unlink(missing_ok=True)
341
- return
342
-
343
- # User saved → read message into final_message
344
- final_message = tf_name.read_text(encoding="utf-8").strip()
345
- tf_name.unlink(missing_ok=True)
346
349
 
347
350
  # 5) Perform squash
348
351
  subprocess.run(["git", "reset", "--soft", merge_base], check=True)
@@ -0,0 +1,12 @@
1
+ You are an expert software engineer assistant.
2
+ Your task is to generate a concise, professional git commit message
3
+ from the provided git diff.
4
+
5
+ Rules:
6
+ - Write a single headline in imperative mood (e.g. "Add", "Fix", "Refactor"), max 50 characters.
7
+ - Below the headline, leave one blank line, then add a bullet-point list of the most important changes.
8
+ - Each bullet should explain *what* changed and *why*, not repeat filenames or obvious details.
9
+ - Group related changes into one bullet instead of listing every file separately.
10
+ - Keep the total message short — aim for clarity over completeness.
11
+ - Output only the raw commit message. No markdown fences, no quotes, no extra commentary.
12
+ - If you detect sensitive information (keys, tokens, passwords), warn about it at the very top before the headline.
@@ -0,0 +1,10 @@
1
+ You are an expert software engineer assistant.
2
+ Your task is to summarize multiple existing commit messages
3
+ into a single, coherent git commit message that captures the overall intent.
4
+
5
+ Rules:
6
+ - Do not list or echo each original commit. Synthesize them into a unified narrative.
7
+ - Write one clear headline in imperative mood (max 50 characters) that captures the main purpose.
8
+ - Below the headline, leave one blank line, then add a concise bullet list describing the key themes of the work.
9
+ - Focus on *why* the changes were made, not just *what* was touched.
10
+ - Output only the raw commit message. No markdown fences, no quotes, no extra commentary.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-cai-cli
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Use LLM to create git commit messages
5
5
  Author-email: Thorsten Foltz <thorsten.foltz@live.com>
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ CLAUDE.md
9
9
  LICENSE
10
10
  Makefile
11
11
  README.md
12
+ cai_config.yml
12
13
  pyproject.toml
13
14
  uv.lock
14
15
  .github/cd/.SRCINFO
@@ -219,7 +219,7 @@ def test_get_git_editor_falls_back_to_env():
219
219
  get_git_editor() should fall back to environment variables if git var fails.
220
220
  """
221
221
  with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "cmd")):
222
- with patch.dict(os.environ, {"EDITOR": "nano"}):
222
+ with patch.dict(os.environ, {"EDITOR": "nano"}, clear=True):
223
223
  assert get_git_editor() == "nano"
224
224
 
225
225
 
@@ -60,7 +60,8 @@ def test_emoji_enabled(generator):
60
60
  Test that the _emoji_instruction method returns the correct string when emojis are enabled
61
61
  """
62
62
  assert (
63
- "Use relevant emojis in the commit message where appropriate. Emojis should enhance the clarity and tone of the message."
63
+ "Use relevant emojis at the start of the headline and in bullet points "
64
+ "where they add clarity. Keep emojis purposeful — one per bullet at most."
64
65
  in generator._emoji_instruction()
65
66
  )
66
67
 
@@ -937,7 +938,7 @@ def test_generate_appends_context_to_diff(generator):
937
938
  call_args = mock.call_args
938
939
  content = call_args[1]["content"] if "content" in call_args[1] else call_args[0][0]
939
940
  assert "diff output" in content
940
- assert "Additional context:" in content
941
+ assert "Additional context from the author" in content
941
942
  assert "Fixes JIRA-1234" in content
942
943
 
943
944
 
@@ -84,6 +84,10 @@ def test_aborts_on_unstaged_changes(mock_repo_root) -> None:
84
84
  "file.py", # unstaged
85
85
  ],
86
86
  ),
87
+ patch(
88
+ "git_cai_cli.core.squash.load_config", return_value={"default": "openai"}
89
+ ),
90
+ patch("git_cai_cli.core.squash.load_token", return_value="token"),
87
91
  ):
88
92
  squash_branch()
89
93