git-cai-cli 0.15.1__tar.gz → 0.15.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 (103) hide show
  1. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.mega-linter.yml +1 -0
  2. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/PKG-INFO +1 -1
  3. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/docs/git-cai.txt +12 -1
  4. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/docs/man/git-cai.1 +24 -3
  5. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/_version.py +3 -3
  6. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/config.py +38 -0
  7. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/gitutils.py +19 -0
  8. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/llm.py +99 -29
  9. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/pr.py +6 -4
  10. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/secrets.py +13 -1
  11. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/squash.py +10 -5
  12. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/validate.py +2 -1
  13. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/main.py +53 -4
  14. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli.egg-info/PKG-INFO +1 -1
  15. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli.egg-info/SOURCES.txt +2 -0
  16. git_cai_cli-0.15.2/src/git_cai_cli.egg-info/scm_file_list.json +96 -0
  17. git_cai_cli-0.15.2/src/git_cai_cli.egg-info/scm_version.json +8 -0
  18. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_pr_integration.py +4 -4
  19. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_gitutils.py +27 -0
  20. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_main.py +75 -1
  21. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_pr.py +8 -6
  22. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_secrets.py +67 -0
  23. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_set_config.py +78 -1
  24. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_squash.py +13 -2
  25. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.caiignore +0 -0
  26. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.gitattributes +0 -0
  27. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/cd/.SRCINFO +0 -0
  28. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/cd/PKGBUILD +0 -0
  29. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/ci/_version.py +0 -0
  30. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/ci/cai_config.ci.yml +0 -0
  31. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/ci/tokens.ci.yml +0 -0
  32. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/workflows/python-tests.yml +0 -0
  33. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/workflows/release.yml +0 -0
  34. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.github/workflows/release_aur.yml +0 -0
  35. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.gitignore +0 -0
  36. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.bandit.yml +0 -0
  37. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.checkov.yml +0 -0
  38. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.flake8 +0 -0
  39. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.ls-lint.yml +0 -0
  40. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.markdown-link-check.json +0 -0
  41. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.markdownlint.json +0 -0
  42. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.proselintrc +0 -0
  43. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.pylintrc +0 -0
  44. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/.yamllint.yml +0 -0
  45. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/check_git_branch_name.sh +0 -0
  46. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/lychee.toml +0 -0
  47. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.linters/pyrightconfig.json +0 -0
  48. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.markdownlintignore +0 -0
  49. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.semgrepignore +0 -0
  50. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/.trivyignore +0 -0
  51. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/CLAUDE.md +0 -0
  52. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/LICENSE +0 -0
  53. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/Makefile +0 -0
  54. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/README.md +0 -0
  55. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/cai_config.yml +0 -0
  56. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/pyproject.toml +0 -0
  57. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/setup.cfg +0 -0
  58. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/__init__.py +0 -0
  59. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/cli/__init__.py +0 -0
  60. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/cli/cli.py +0 -0
  61. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/cli/helptext.py +0 -0
  62. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/cli/modes.py +0 -0
  63. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/__init__.py +0 -0
  64. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/completion.py +0 -0
  65. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/doctor.py +0 -0
  66. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/editors.py +0 -0
  67. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/init.py +0 -0
  68. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/languages.py +0 -0
  69. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/options.py +0 -0
  70. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/prompts_fallback.py +0 -0
  71. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/spinner.py +0 -0
  72. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli/core/stats.py +0 -0
  73. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
  74. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
  75. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli.egg-info/requires.txt +0 -0
  76. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/src/git_cai_cli.egg-info/top_level.txt +0 -0
  77. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/conftest.py +0 -0
  78. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_cli_integration.py +0 -0
  79. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_config_integration.py +0 -0
  80. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_gitutils_integration.py +0 -0
  81. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_modes_integration.py +0 -0
  82. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_options_integration.py +0 -0
  83. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/integration/test_squash_integration.py +0 -0
  84. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_amend.py +0 -0
  85. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_branch_context.py +0 -0
  86. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_classification.py +0 -0
  87. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_cli.py +0 -0
  88. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_completion.py +0 -0
  89. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_config.py +0 -0
  90. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_conventional.py +0 -0
  91. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_doctor.py +0 -0
  92. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_helptext.py +0 -0
  93. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_init.py +0 -0
  94. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_llm.py +0 -0
  95. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_modes.py +0 -0
  96. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_options.py +0 -0
  97. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_print.py +0 -0
  98. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_prompt_loading.py +0 -0
  99. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_signoff.py +0 -0
  100. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_spinner.py +0 -0
  101. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_stats.py +0 -0
  102. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/tests/unit/test_validate.py +0 -0
  103. {git_cai_cli-0.15.1 → git_cai_cli-0.15.2}/uv.lock +0 -0
@@ -67,6 +67,7 @@ REPOSITORY_SEMGREP_RULESETS:
67
67
  "p/r2c-security-audit",
68
68
  "p/secure-defaults",
69
69
  ]
70
+ SECURITY_SUGGESTIONS: false
70
71
  SHOW_ELAPSED_TIME: true
71
72
  SHOW_SKIPPED_LINTERS: true
72
73
  SPELL_PROSELINT_RULES_PATH: .linters
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-cai-cli
3
- Version: 0.15.1
3
+ Version: 0.15.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
@@ -914,7 +914,18 @@ Available configuration keys:
914
914
  credential formats are flagged (private keys, AWS/GitHub/Slack/Google/OpenAI
915
915
  keys); obvious placeholders (values containing `example`, `sample`, `dummy`,
916
916
  and repeated-character filler) are ignored. Local providers (Ollama) skip the
917
- scan, since nothing leaves the machine.
917
+ scan, since nothing leaves the machine. When you confirm a flagged send in an
918
+ interactive commit (a false alarm), git-cai offers, per flagged file, to
919
+ remember the decision: add the file to `.caiignore` (drop it from git-cai
920
+ entirely), add it to `secret_scan_exclude` (keep sending it but skip its
921
+ scan), or skip (remember nothing).
922
+ - `secret_scan_exclude` -- list of repo-relative paths whose contents are
923
+ exempt from the secret scan while still being sent to the LLM (default empty).
924
+ Use this for files that keep tripping a false positive (such as test fixtures)
925
+ but that you still want git-cai to see. Paths are matched with gitignore
926
+ semantics, like `.caiignore`, so directory patterns work too. The
927
+ false-alarm prompt writes here when you choose "keep, skip scan", targeting
928
+ the repository `cai_config.yml` if one exists, otherwise the home config.
918
929
  - `load_tokens_from` -- path to the tokens file
919
930
  - `prompt_file` -- path to the commit prompt Markdown file
920
931
  - `squash_prompt_file` -- path to the squash prompt Markdown file
@@ -2,12 +2,12 @@
2
2
  .\" Title: git-cai
3
3
  .\" Author: Thorsten Foltz
4
4
  .\" Generator: Asciidoctor 2.0.26
5
- .\" Date: 2026-06-19
5
+ .\" Date: 2026-06-24
6
6
  .\" Manual: \ \&
7
7
  .\" Source: \ \&
8
8
  .\" Language: English
9
9
  .\"
10
- .TH "GIT\-CAI" "1" "2026-06-19" "\ \&" "\ \&"
10
+ .TH "GIT\-CAI" "1" "2026-06-24" "\ \&" "\ \&"
11
11
  .ie \n(.g .ds Aq \(aq
12
12
  .el .ds Aq '
13
13
  .ss \n[.ss] 0
@@ -1456,7 +1456,28 @@ match the send is blocked and you are asked to confirm (or pass
1456
1456
  credential formats are flagged (private keys, AWS/GitHub/Slack/Google/OpenAI
1457
1457
  keys); obvious placeholders (values containing \f(CRexample\fP, \f(CRsample\fP, \f(CRdummy\fP,
1458
1458
  and repeated\-character filler) are ignored. Local providers (Ollama) skip the
1459
- scan, since nothing leaves the machine.
1459
+ scan, since nothing leaves the machine. When you confirm a flagged send in an
1460
+ interactive commit (a false alarm), git\-cai offers, per flagged file, to
1461
+ remember the decision: add the file to \f(CR.caiignore\fP (drop it from git\-cai
1462
+ entirely), add it to \f(CRsecret_scan_exclude\fP (keep sending it but skip its
1463
+ scan), or skip (remember nothing).
1464
+ .RE
1465
+ .sp
1466
+ .RS 4
1467
+ .ie n \{\
1468
+ \h'-04'\(bu\h'+03'\c
1469
+ .\}
1470
+ .el \{\
1471
+ . sp -1
1472
+ . IP \(bu 2.3
1473
+ .\}
1474
+ \f(CRsecret_scan_exclude\fP \(em list of repo\-relative paths whose contents are
1475
+ exempt from the secret scan while still being sent to the LLM (default empty).
1476
+ Use this for files that keep tripping a false positive (such as test fixtures)
1477
+ but that you still want git\-cai to see. Paths are matched with gitignore
1478
+ semantics, like \f(CR.caiignore\fP, so directory patterns work too. The
1479
+ false\-alarm prompt writes here when you choose "keep, skip scan", targeting
1480
+ the repository \f(CRcai_config.yml\fP if one exists, otherwise the home config.
1460
1481
  .RE
1461
1482
  .sp
1462
1483
  .RS 4
@@ -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.15.1'
22
- __version_tuple__ = version_tuple = (0, 15, 1)
21
+ __version__ = version = '0.15.2'
22
+ __version_tuple__ = version_tuple = (0, 15, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g00cc1e8ad'
24
+ __commit_id__ = commit_id = 'g12ea7cd4d'
@@ -67,6 +67,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
67
67
  "stats": False,
68
68
  "signoff": False,
69
69
  "secret_scan": True,
70
+ "secret_scan_exclude": [],
70
71
  }
71
72
 
72
73
  # Providers that do not require an API token in tokens.yml
@@ -588,6 +589,43 @@ def set_config_value(key: str, raw_value: str, *, force_home: bool = False) -> P
588
589
  return target
589
590
 
590
591
 
592
+ def add_to_secret_scan_exclude(path: str, *, force_home: bool = False) -> Path:
593
+ """Append ``path`` to ``secret_scan_exclude`` in the active config file.
594
+
595
+ Targets the repo ``cai_config.yml`` if one exists, otherwise the home
596
+ config. Unlike :func:`set_config_value`, this never errors when no repo
597
+ config is present — it falls back to home, matching the "local if exists,
598
+ otherwise global" rule. The append is idempotent (dedup).
599
+
600
+ Returns the path to the config file that was written.
601
+ """
602
+ repo_config = None if force_home else _find_repo_config()
603
+ target = repo_config if repo_config else FALLBACK_CONFIG_FILE
604
+
605
+ target.parent.mkdir(parents=True, exist_ok=True)
606
+ if target.exists() and target.stat().st_size > 0:
607
+ try:
608
+ with target.open("r", encoding="utf-8") as f:
609
+ config = cast(dict[str, Any], yaml.safe_load(f) or {})
610
+ except yaml.YAMLError as e:
611
+ raise ValueError(f"Failed to parse config file {target}: {e}") from e
612
+ else:
613
+ config = {}
614
+
615
+ existing = config.get("secret_scan_exclude")
616
+ if not isinstance(existing, list):
617
+ existing = []
618
+ if path not in existing:
619
+ existing.append(path)
620
+ config["secret_scan_exclude"] = existing
621
+
622
+ with target.open("w", encoding="utf-8") as f:
623
+ yaml.safe_dump(_serialize_config(config), f, sort_keys=False)
624
+
625
+ log.info("Added %s to secret_scan_exclude in %s", path, target)
626
+ return target
627
+
628
+
591
629
  def apply_cli_overrides(
592
630
  config: dict,
593
631
  *,
@@ -222,6 +222,25 @@ def _load_caiignore_patterns(repo_root: Path) -> list[str]:
222
222
  return patterns
223
223
 
224
224
 
225
+ def append_to_caiignore(repo_root: Path, path: str) -> Path:
226
+ """Append ``path`` as a line to ``<repo>/.caiignore`` if not already present.
227
+
228
+ Idempotent; creates the file if missing. Returns the ignore-file path.
229
+ """
230
+ ignore_file = repo_root / ".caiignore"
231
+ lines: list[str] = []
232
+ if ignore_file.exists():
233
+ lines = ignore_file.read_text(encoding="utf-8").splitlines()
234
+
235
+ if path in (line.strip() for line in lines):
236
+ return ignore_file
237
+
238
+ lines.append(path)
239
+ ignore_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
240
+ log.info("Added %s to %s", path, ignore_file)
241
+ return ignore_file
242
+
243
+
225
244
  def git_diff_excluding(
226
245
  repo_root: Path,
227
246
  run_cmd: Callable[..., subprocess.CompletedProcess] = subprocess.run,
@@ -337,18 +337,39 @@ class CommitMessageGenerator:
337
337
  """
338
338
  self._classification_counts = classify_changed_paths(paths)
339
339
 
340
- def generate(
340
+ def _log_target(self) -> None:
341
+ """Log the active provider and model once, up front.
342
+
343
+ Call sites run this *before* starting the spinner so this routine
344
+ config noise does not interleave with the spinner's live frames —
345
+ interleaving would break the spinner line and make a single
346
+ generation look like two. The per-provider methods log the same
347
+ detail at DEBUG.
348
+ """
349
+ model = (self.config.get(self.default_model) or {}).get("model")
350
+ log.info("Using provider '%s' (model '%s').", self.default_model, model)
351
+
352
+ def send(self, content: str, system_prompt: str) -> str:
353
+ """Run the secret scan and dispatch a prebuilt request to the LLM.
354
+
355
+ This is the network-bound half of generation — the part worth
356
+ wrapping in a spinner. Prompt building (and its logging) happens
357
+ earlier in the matching ``build_*_request`` method.
358
+ """
359
+ return self._dispatch_generate(content=content, system_prompt=system_prompt)
360
+
361
+ def build_commit_request(
341
362
  self,
342
363
  git_diff: str,
343
364
  context: str | None = None,
344
365
  previous_message: str | None = None,
345
- ) -> str:
346
- """
347
- Generate a commit message from a diff.
366
+ ) -> tuple[str, str]:
367
+ """Build the ``(content, system_prompt)`` pair for a commit message.
348
368
 
349
- ``previous_message`` is used in amend mode so the model refines the
350
- existing message instead of regenerating from scratch.
369
+ Split out from :meth:`generate` so prompt building runs before the
370
+ spinner starts. ``previous_message`` is used in amend mode.
351
371
  """
372
+ self._log_target()
352
373
  self.set_changed_files(paths_from_diff(git_diff))
353
374
  prompt = self._build_commit_prompt(previous_message=previous_message)
354
375
  log.debug("Commit system prompt preview: %r", prompt[:400])
@@ -359,15 +380,30 @@ class CommitMessageGenerator:
359
380
  f"{git_diff}\n\n"
360
381
  f"--- Additional context from the author ---\n{context}"
361
382
  )
383
+ return content, prompt
362
384
 
363
- return self._dispatch_generate(content=content, system_prompt=prompt)
364
-
365
- def summarize_commit_history(
366
- self, commit_messages: str, context: str | None = None
385
+ def generate(
386
+ self,
387
+ git_diff: str,
388
+ context: str | None = None,
389
+ previous_message: str | None = None,
367
390
  ) -> str:
368
391
  """
369
- Summarize multiple commit messages into one high-level commit message.
392
+ Generate a commit message from a diff.
393
+
394
+ ``previous_message`` is used in amend mode so the model refines the
395
+ existing message instead of regenerating from scratch.
370
396
  """
397
+ content, prompt = self.build_commit_request(
398
+ git_diff, context=context, previous_message=previous_message
399
+ )
400
+ return self.send(content, prompt)
401
+
402
+ def build_squash_request(
403
+ self, commit_messages: str, context: str | None = None
404
+ ) -> tuple[str, str]:
405
+ """Build the ``(content, system_prompt)`` pair for a squash summary."""
406
+ self._log_target()
371
407
  prompt = self._build_squash_prompt()
372
408
  log.debug("Squash system prompt preview: %r", prompt[:400])
373
409
 
@@ -377,19 +413,25 @@ class CommitMessageGenerator:
377
413
  f"{commit_messages}\n\n"
378
414
  f"--- Additional context from the author ---\n{context}"
379
415
  )
416
+ return content, prompt
380
417
 
381
- return self._dispatch_generate(content=content, system_prompt=prompt)
418
+ def summarize_commit_history(
419
+ self, commit_messages: str, context: str | None = None
420
+ ) -> str:
421
+ """
422
+ Summarize multiple commit messages into one high-level commit message.
423
+ """
424
+ content, prompt = self.build_squash_request(commit_messages, context=context)
425
+ return self.send(content, prompt)
382
426
 
383
- def generate_pr_description(
427
+ def build_pr_request(
384
428
  self,
385
429
  commit_log: str,
386
430
  changed_files: str,
387
431
  context: str | None = None,
388
- ) -> str:
389
- """
390
- Generate a Markdown Pull Request description from the commit log and
391
- changed-files list of a feature branch.
392
- """
432
+ ) -> tuple[str, str]:
433
+ """Build the ``(content, system_prompt)`` pair for a PR description."""
434
+ self._log_target()
393
435
  self.set_changed_files(changed_files.splitlines())
394
436
  prompt = self._build_pr_prompt()
395
437
  log.debug("PR system prompt preview: %r", prompt[:400])
@@ -402,14 +444,27 @@ class CommitMessageGenerator:
402
444
  changed_files.strip() or "(no files)",
403
445
  ]
404
446
  content = "\n".join(sections)
405
-
406
447
  if context:
407
448
  content = (
408
449
  f"{content}\n\n"
409
450
  f"--- Additional context from the author ---\n{context}"
410
451
  )
452
+ return content, prompt
411
453
 
412
- return self._dispatch_generate(content=content, system_prompt=prompt)
454
+ def generate_pr_description(
455
+ self,
456
+ commit_log: str,
457
+ changed_files: str,
458
+ context: str | None = None,
459
+ ) -> str:
460
+ """
461
+ Generate a Markdown Pull Request description from the commit log and
462
+ changed-files list of a feature branch.
463
+ """
464
+ content, prompt = self.build_pr_request(
465
+ commit_log, changed_files, context=context
466
+ )
467
+ return self.send(content, prompt)
413
468
 
414
469
  def _emoji_instruction(self) -> str:
415
470
  """
@@ -686,9 +741,24 @@ class CommitMessageGenerator:
686
741
  if self.default_model in TOKENLESS_PROVIDERS:
687
742
  return
688
743
 
689
- from git_cai_cli.core.secrets import SecretLeakError, scan_for_secrets
744
+ from git_cai_cli.core.secrets import (
745
+ SecretLeakError,
746
+ drop_excluded,
747
+ scan_for_secrets,
748
+ )
690
749
 
691
750
  findings = scan_for_secrets(content)
751
+
752
+ # Files listed in `secret_scan_exclude` stay part of the payload but
753
+ # are exempt from the scan (false alarms the user already vetted).
754
+ # Matched with gitignore semantics for parity with `.caiignore`.
755
+ excludes = self.config.get("secret_scan_exclude") or []
756
+ if findings and excludes:
757
+ import pathspec
758
+
759
+ spec = pathspec.GitIgnoreSpec.from_lines(excludes)
760
+ findings = drop_excluded(findings, spec.match_file)
761
+
692
762
  if findings:
693
763
  raise SecretLeakError(findings)
694
764
 
@@ -714,7 +784,7 @@ class CommitMessageGenerator:
714
784
  if self.default_model not in model_dispatch:
715
785
  raise ValueError(f"Unknown model type: '{self.default_model}'")
716
786
 
717
- log.info("Using provider '%s' for generation.", self.default_model)
787
+ log.debug("Using provider '%s' for generation.", self.default_model)
718
788
 
719
789
  return model_dispatch[self.default_model](
720
790
  content, system_prompt_override=system_prompt
@@ -751,7 +821,7 @@ class CommitMessageGenerator:
751
821
  or anthropic_cfg.get("max_tokens", 32768)
752
822
  )
753
823
 
754
- log.info("Using anthropic model '%s'.", model)
824
+ log.debug("Using anthropic model '%s'.", model)
755
825
 
756
826
  # Anthropic Messages API expects system prompt via the top-level "system" field.
757
827
  messages = [{"role": "user", "content": content}]
@@ -817,7 +887,7 @@ class CommitMessageGenerator:
817
887
  model = self.config["gemini"]["model"]
818
888
  temperature = self.config["gemini"]["temperature"]
819
889
 
820
- log.info("Using gemini model '%s'.", model)
890
+ log.debug("Using gemini model '%s'.", model)
821
891
 
822
892
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
823
893
 
@@ -874,7 +944,7 @@ class CommitMessageGenerator:
874
944
  model = self.config["groq"]["model"]
875
945
  temperature = self.config["groq"]["temperature"]
876
946
 
877
- log.info("Using groq model '%s'.", model)
947
+ log.debug("Using groq model '%s'.", model)
878
948
 
879
949
  messages = []
880
950
 
@@ -934,7 +1004,7 @@ class CommitMessageGenerator:
934
1004
  model = self.config["mistral"]["model"]
935
1005
  temperature = self.config["mistral"]["temperature"]
936
1006
 
937
- log.info("Using mistral model '%s'.", model)
1007
+ log.debug("Using mistral model '%s'.", model)
938
1008
 
939
1009
  messages = []
940
1010
 
@@ -1119,7 +1189,7 @@ class CommitMessageGenerator:
1119
1189
  model = self.config["ollama"]["model"]
1120
1190
  temperature = self.config["ollama"]["temperature"]
1121
1191
 
1122
- log.info("Using ollama model '%s'.", model)
1192
+ log.debug("Using ollama model '%s'.", model)
1123
1193
 
1124
1194
  url = f"{self._ollama_base_url()}/api/chat"
1125
1195
 
@@ -1214,7 +1284,7 @@ class CommitMessageGenerator:
1214
1284
  else self.config["openai"]["temperature"]
1215
1285
  )
1216
1286
 
1217
- log.info("Using %s model '%s'.", provider_name, model)
1287
+ log.debug("Using %s model '%s'.", provider_name, model)
1218
1288
 
1219
1289
  messages = []
1220
1290
 
@@ -1265,7 +1335,7 @@ class CommitMessageGenerator:
1265
1335
  model = self.config["xai"]["model"]
1266
1336
  temperature = self.config["xai"]["temperature"]
1267
1337
 
1268
- log.info("Using xai model '%s'.", model)
1338
+ log.debug("Using xai model '%s'.", model)
1269
1339
 
1270
1340
  messages = []
1271
1341
 
@@ -150,14 +150,16 @@ def run_pr(
150
150
  generator.kind = "pr"
151
151
  generator.repo = repo_name_from_root(repo_root)
152
152
  generator.allow_secrets = allow_secrets
153
+ content, system_prompt = generator.build_pr_request(
154
+ commit_log, changed_files, context=context
155
+ )
153
156
  try:
154
157
  try:
155
158
  with Spinner("Generating PR description"):
156
159
  description = _validate_llm_call(
157
- generator.generate_pr_description,
158
- commit_log,
159
- changed_files,
160
- context=context,
160
+ generator.send,
161
+ content,
162
+ system_prompt,
161
163
  token=token,
162
164
  requires_token=provider not in TOKENLESS_PROVIDERS,
163
165
  )
@@ -9,7 +9,7 @@ ceiling. Swap in gitleaks/detect-secrets only if the FP rate becomes a problem.
9
9
  """
10
10
 
11
11
  import re
12
- from typing import NamedTuple
12
+ from typing import Callable, NamedTuple
13
13
 
14
14
 
15
15
  class Finding(NamedTuple):
@@ -144,6 +144,18 @@ def scan_for_secrets(text: str) -> list[Finding]:
144
144
  return findings
145
145
 
146
146
 
147
+ def drop_excluded(
148
+ findings: list[Finding], is_excluded: Callable[[str], bool]
149
+ ) -> list[Finding]:
150
+ """Drop findings whose file path is excluded from secret scanning.
151
+
152
+ ``is_excluded`` is a predicate over a path (the caller supplies the
153
+ matching semantics — see ``secret_scan_exclude``). Findings with no path
154
+ (``path is None``) are unattributable and never excluded.
155
+ """
156
+ return [f for f in findings if f.path is None or not is_excluded(f.path)]
157
+
158
+
147
159
  def format_findings(findings: list[Finding]) -> str:
148
160
  """Render findings as a human-readable, multi-line warning block."""
149
161
  lines = ["⚠️ Potential secret(s) detected in the content to be sent:"]
@@ -308,11 +308,13 @@ def squash_branch(
308
308
  start = time.perf_counter() if measure else None
309
309
 
310
310
  generator.kind = "commit"
311
+ content, system_prompt = generator.build_commit_request(diff)
311
312
  try:
312
313
  with Spinner("Generating commit message for staged changes"):
313
314
  msg = _validate_llm_call(
314
- generator.generate,
315
- diff,
315
+ generator.send,
316
+ content,
317
+ system_prompt,
316
318
  token=token,
317
319
  requires_token=provider not in TOKENLESS_PROVIDERS,
318
320
  )
@@ -395,12 +397,15 @@ def squash_branch(
395
397
  start = time.perf_counter() if measure else None
396
398
 
397
399
  generator.kind = "squash"
400
+ content, system_prompt = generator.build_squash_request(
401
+ commit_log, context=context
402
+ )
398
403
  try:
399
404
  with Spinner("Summarizing commit history"):
400
405
  summary_message = _validate_llm_call(
401
- generator.summarize_commit_history,
402
- commit_log,
403
- context=context,
406
+ generator.send,
407
+ content,
408
+ system_prompt,
404
409
  token=token,
405
410
  requires_token=provider not in TOKENLESS_PROVIDERS,
406
411
  )
@@ -73,10 +73,11 @@ def _validate_config_keys(config: dict[str, Any], reference: dict[str, Any]) ->
73
73
  "stats_db_path",
74
74
  "signoff",
75
75
  "secret_scan",
76
+ "secret_scan_exclude",
76
77
  }
77
78
  # Internal escape-hatch keys: accepted if a user sets them, but never
78
79
  # reported as "missing" — they aren't part of the documented surface.
79
- internal_only_keys = {"stats_db_path"}
80
+ internal_only_keys = {"stats_db_path", "secret_scan_exclude"}
80
81
  allowed_provider_keys = set(reference.keys()) - allowed_global_keys
81
82
 
82
83
  config_keys = set(config.keys())
@@ -63,6 +63,50 @@ def _log_stats_state(config: dict) -> None:
63
63
  stats_module.log_state(config)
64
64
 
65
65
 
66
+ def _route_false_alarm(findings: list, repo_root: Path | None) -> None:
67
+ """After a confirmed false alarm, offer to remember each flagged file.
68
+
69
+ For each distinct flagged path, prompt whether to add it to ``.caiignore``
70
+ (drop the file from git-cai), to ``secret_scan_exclude`` in the active
71
+ config (keep the file but skip its scan), or skip (remember nothing).
72
+ Findings without a path are unattributable and skipped.
73
+ """
74
+ from git_cai_cli.core.config import add_to_secret_scan_exclude
75
+ from git_cai_cli.core.gitutils import append_to_caiignore
76
+
77
+ seen: set[str] = set()
78
+ for finding in findings:
79
+ path = finding.path
80
+ if not path or path in seen:
81
+ continue
82
+ seen.add(path)
83
+
84
+ typer.echo(f"\nFalse alarm for: {path}", err=True)
85
+ typer.echo("How should git-cai remember this file?", err=True)
86
+ typer.echo(
87
+ " 1) .caiignore — drop the file from git-cai entirely "
88
+ "(it is never sent to the LLM again)",
89
+ err=True,
90
+ )
91
+ typer.echo(
92
+ " 2) config — keep sending the file, but skip its secret scan "
93
+ "from now on (added to secret_scan_exclude)",
94
+ err=True,
95
+ )
96
+ typer.echo(" 3) skip — send it this once; remember nothing", err=True)
97
+ choice = typer.prompt("Choose", default="3").strip()
98
+
99
+ if choice == "1":
100
+ if repo_root is None:
101
+ typer.echo("Not in a git repo; cannot write .caiignore.", err=True)
102
+ continue
103
+ target = append_to_caiignore(repo_root, path)
104
+ typer.echo(f"Added {path} to {target}", err=True)
105
+ elif choice == "2":
106
+ target = add_to_secret_scan_exclude(path)
107
+ typer.echo(f"Added {path} to secret_scan_exclude in {target}", err=True)
108
+
109
+
66
110
  def run(
67
111
  *,
68
112
  mode: Mode,
@@ -272,15 +316,19 @@ def run(
272
316
  spinner_text = (
273
317
  "Regenerating commit message" if is_amend else "Generating commit message"
274
318
  )
319
+ # Build the prompt (and emit its config logging) before the spinner starts,
320
+ # so that routine info does not interleave with the live spinner frames.
321
+ content, system_prompt = generator.build_commit_request(
322
+ diff, context=context, previous_message=previous_message
323
+ )
275
324
  try:
276
325
  while True:
277
326
  try:
278
327
  with Spinner(spinner_text):
279
328
  commit_message = _validate_llm_call(
280
- generator.generate,
281
- diff,
282
- context=context,
283
- previous_message=previous_message,
329
+ generator.send,
330
+ content,
331
+ system_prompt,
284
332
  token=token,
285
333
  requires_token=provider not in TOKENLESS_PROVIDERS,
286
334
  )
@@ -299,6 +347,7 @@ def run(
299
347
  ):
300
348
  typer.echo("Aborted. Nothing was sent.", err=True)
301
349
  raise typer.Exit(code=1)
350
+ _route_false_alarm(leak.findings, repo_root)
302
351
  generator.allow_secrets = True
303
352
  except ValueError as e:
304
353
  typer.echo(f"Error: {e}", err=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-cai-cli
3
- Version: 0.15.1
3
+ Version: 0.15.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
@@ -42,6 +42,8 @@ src/git_cai_cli.egg-info/SOURCES.txt
42
42
  src/git_cai_cli.egg-info/dependency_links.txt
43
43
  src/git_cai_cli.egg-info/entry_points.txt
44
44
  src/git_cai_cli.egg-info/requires.txt
45
+ src/git_cai_cli.egg-info/scm_file_list.json
46
+ src/git_cai_cli.egg-info/scm_version.json
45
47
  src/git_cai_cli.egg-info/top_level.txt
46
48
  src/git_cai_cli/cli/__init__.py
47
49
  src/git_cai_cli/cli/cli.py