git-cai-cli 0.15.1__tar.gz → 0.15.3__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.
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.pylintrc +2 -2
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.mega-linter.yml +1 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/PKG-INFO +1 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/docs/git-cai.txt +12 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/docs/man/git-cai.1 +24 -3
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/_version.py +3 -3
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/config.py +38 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/gitutils.py +19 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/llm.py +99 -29
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/pr.py +6 -4
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/secrets.py +13 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/squash.py +25 -7
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/validate.py +2 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/main.py +53 -4
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli.egg-info/PKG-INFO +1 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli.egg-info/SOURCES.txt +2 -0
- git_cai_cli-0.15.3/src/git_cai_cli.egg-info/scm_file_list.json +96 -0
- git_cai_cli-0.15.3/src/git_cai_cli.egg-info/scm_version.json +8 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_pr_integration.py +4 -4
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_gitutils.py +27 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_main.py +75 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_pr.py +8 -6
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_secrets.py +67 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_set_config.py +78 -1
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_squash.py +55 -3
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.caiignore +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.gitattributes +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/cd/.SRCINFO +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/cd/PKGBUILD +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/ci/_version.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/ci/cai_config.ci.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/ci/tokens.ci.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/workflows/python-tests.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/workflows/release.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.github/workflows/release_aur.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.gitignore +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.bandit.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.checkov.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.flake8 +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.ls-lint.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.markdown-link-check.json +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.markdownlint.json +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.proselintrc +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/.yamllint.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/check_git_branch_name.sh +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/lychee.toml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.linters/pyrightconfig.json +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.markdownlintignore +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.semgrepignore +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/.trivyignore +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/CLAUDE.md +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/LICENSE +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/Makefile +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/README.md +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/cai_config.yml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/pyproject.toml +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/setup.cfg +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/__init__.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/cli/__init__.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/cli/cli.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/cli/helptext.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/cli/modes.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/__init__.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/completion.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/doctor.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/editors.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/init.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/languages.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/options.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/prompts_fallback.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/spinner.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli/core/stats.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli.egg-info/requires.txt +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/src/git_cai_cli.egg-info/top_level.txt +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/conftest.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_cli_integration.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_config_integration.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_gitutils_integration.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_modes_integration.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_options_integration.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/integration/test_squash_integration.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_amend.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_branch_context.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_classification.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_cli.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_completion.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_config.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_conventional.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_doctor.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_helptext.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_init.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_llm.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_modes.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_options.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_print.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_prompt_loading.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_signoff.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_spinner.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_stats.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/tests/unit/test_validate.py +0 -0
- {git_cai_cli-0.15.1 → git_cai_cli-0.15.3}/uv.lock +0 -0
|
@@ -68,10 +68,10 @@ check-protected-access-in-special-methods=no
|
|
|
68
68
|
[DESIGN]
|
|
69
69
|
max-args=30 # Max arguments per function
|
|
70
70
|
max-attributes=12 # Max attributes per class
|
|
71
|
-
max-branches=
|
|
71
|
+
max-branches=35 # Max branches per function
|
|
72
72
|
max-locals=80 # Max locals per function
|
|
73
73
|
max-returns=10 # Max return statements per function
|
|
74
|
-
max-statements=
|
|
74
|
+
max-statements=140 # Max total statements per function
|
|
75
75
|
max-public-methods=20 # Max public methods per class
|
|
76
76
|
min-public-methods=1 # Min public methods per class
|
|
77
77
|
max-nested-blocks=15 # Max nesting depth
|
|
@@ -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-
|
|
5
|
+
.\" Date: 2026-06-29
|
|
6
6
|
.\" Manual: \ \&
|
|
7
7
|
.\" Source: \ \&
|
|
8
8
|
.\" Language: English
|
|
9
9
|
.\"
|
|
10
|
-
.TH "GIT\-CAI" "1" "2026-06-
|
|
10
|
+
.TH "GIT\-CAI" "1" "2026-06-29" "\ \&" "\ \&"
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 15,
|
|
21
|
+
__version__ = version = '0.15.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 15, 3)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g0517f2f82'
|
|
@@ -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
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
315
|
-
|
|
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.
|
|
402
|
-
|
|
403
|
-
|
|
406
|
+
generator.send,
|
|
407
|
+
content,
|
|
408
|
+
system_prompt,
|
|
404
409
|
token=token,
|
|
405
410
|
requires_token=provider not in TOKENLESS_PROVIDERS,
|
|
406
411
|
)
|
|
@@ -479,8 +484,21 @@ def squash_branch(
|
|
|
479
484
|
)
|
|
480
485
|
choice = input("Shall I execute it for you now? [yes/no]: ").strip().lower()
|
|
481
486
|
if choice in ("y", "yes"):
|
|
482
|
-
subprocess.run(
|
|
483
|
-
|
|
487
|
+
push_result = subprocess.run(
|
|
488
|
+
["git", "push", "--force-with-lease"],
|
|
489
|
+
capture_output=True,
|
|
490
|
+
text=True,
|
|
491
|
+
check=False,
|
|
492
|
+
)
|
|
493
|
+
if push_result.returncode == 0:
|
|
494
|
+
log.info("✅ Successfully pushed the squashed branch to remote.")
|
|
495
|
+
else:
|
|
496
|
+
log.debug("git push failed:\n%s", push_result.stderr.strip())
|
|
497
|
+
log.error(
|
|
498
|
+
"❌ Push failed. The remote branch may have been merged and "
|
|
499
|
+
"deleted.\nCheck whether the remote branch still exists "
|
|
500
|
+
"(e.g. `git branch -r`), then push manually if needed."
|
|
501
|
+
)
|
|
484
502
|
else:
|
|
485
503
|
log.info(
|
|
486
504
|
"No upstream branch detected. Normal `git push` will work as expected."
|
|
@@ -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.
|
|
281
|
-
|
|
282
|
-
|
|
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)
|
|
@@ -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
|