git-cai-cli 0.16.1__tar.gz → 0.16.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.
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/workflows/python-tests.yml +1 -1
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/workflows/release_aur.yml +5 -2
- git_cai_cli-0.16.2/.linters/.checkov.yml +7 -0
- git_cai_cli-0.16.2/.linters/.proselintrc.json +32 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.mega-linter.yml +14 -6
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/Makefile +2 -2
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/PKG-INFO +1 -1
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/cai_config.yml +2 -1
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/docs/man/git-cai.1 +2 -2
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/_version.py +3 -3
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/config.py +1 -1
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/gitutils.py +9 -2
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/llm.py +36 -2
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/squash.py +11 -8
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/PKG-INFO +1 -1
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/SOURCES.txt +1 -2
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/scm_file_list.json +82 -83
- git_cai_cli-0.16.2/src/git_cai_cli.egg-info/scm_version.json +8 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_llm.py +61 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_signoff.py +14 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_squash.py +48 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/uv.lock +159 -83
- git_cai_cli-0.16.1/.linters/.checkov.yml +0 -2
- git_cai_cli-0.16.1/.linters/.markdown-link-check.json +0 -10
- git_cai_cli-0.16.1/.linters/.proselintrc +0 -78
- git_cai_cli-0.16.1/src/git_cai_cli.egg-info/scm_version.json +0 -8
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.caiignore +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.gitattributes +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/cd/.SRCINFO +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/cd/PKGBUILD +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/ci/_version.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/ci/cai_config.ci.yml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/ci/tokens.ci.yml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.github/workflows/release.yml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.gitignore +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/.bandit.yml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/.flake8 +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/.ls-lint.yml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/.markdownlint.json +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/.pylintrc +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/.yamllint.yml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/check_git_branch_name.sh +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/lychee.toml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.linters/pyrightconfig.json +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.markdownlintignore +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.semgrepignore +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/.trivyignore +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/CLAUDE.md +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/LICENSE +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/README.md +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/docs/git-cai.txt +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/pyproject.toml +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/setup.cfg +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/__init__.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/cli/__init__.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/cli/cli.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/cli/helptext.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/cli/modes.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/__init__.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/changelog.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/completion.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/doctor.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/explain.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/generation.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/init.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/languages.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/options.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/pr.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/prompts_fallback.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/release.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/secrets.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/spinner.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/split.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/stats.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/core/validate.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli/main.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/requires.txt +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/src/git_cai_cli.egg-info/top_level.txt +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/conftest.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_cli_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_config_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_gitutils_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_modes_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_options_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_pr_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/integration/test_squash_integration.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_amend.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_branch_context.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_changelog.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_classification.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_cli.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_completion.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_config.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_conventional.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_doctor.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_explain.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_generation.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_gitutils.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_helptext.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_init.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_main.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_modes.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_options.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_pr.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_print.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_prompt_loading.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_release.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_secrets.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_set_config.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_spinner.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_split.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_stats.py +0 -0
- {git_cai_cli-0.16.1 → git_cai_cli-0.16.2}/tests/unit/test_validate.py +0 -0
|
@@ -99,7 +99,7 @@ jobs:
|
|
|
99
99
|
|
|
100
100
|
# MegaLinter
|
|
101
101
|
- name: MegaLinter
|
|
102
|
-
uses: oxsecurity/megalinter@
|
|
102
|
+
uses: oxsecurity/megalinter@15e5b45552097e318c93de385779ce3b1084052c # v10.0.0
|
|
103
103
|
id: ml
|
|
104
104
|
env:
|
|
105
105
|
VALIDATE_ALL_CODEBASE: false
|
|
@@ -9,6 +9,9 @@ on:
|
|
|
9
9
|
description: "Release version (e.g. 0.6.5)"
|
|
10
10
|
required: true
|
|
11
11
|
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
12
15
|
jobs:
|
|
13
16
|
aur:
|
|
14
17
|
runs-on: ubuntu-slim
|
|
@@ -48,8 +51,8 @@ jobs:
|
|
|
48
51
|
echo "Downloading tag: ${TAG}"
|
|
49
52
|
|
|
50
53
|
curl -fL \
|
|
51
|
-
-o cai-${VERSION}.tar.gz \
|
|
52
|
-
https://github.com/thorstenfoltz/cai/archive/refs/tags/${TAG}.tar.gz
|
|
54
|
+
-o "cai-${VERSION}.tar.gz" \
|
|
55
|
+
"https://github.com/thorstenfoltz/cai/archive/refs/tags/${TAG}.tar.gz"
|
|
53
56
|
|
|
54
57
|
- name: Compute sha256sum
|
|
55
58
|
id: sha
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# skip-path only filters directory scans. On pull requests MegaLinter passes
|
|
2
|
+
# the changed files to checkov with --file, which ignores skip-path, so
|
|
3
|
+
# workflow findings must be suppressed per check instead.
|
|
4
|
+
# CKV_GHA_7: workflow_dispatch inputs must be empty; release_aur.yml needs a
|
|
5
|
+
# manual version input to republish a release.
|
|
6
|
+
skip-check:
|
|
7
|
+
- CKV_GHA_7
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"checks": {
|
|
3
|
+
"annotations": false,
|
|
4
|
+
"archaism": false,
|
|
5
|
+
"cliches": true,
|
|
6
|
+
"dates_times": true,
|
|
7
|
+
"hedging": true,
|
|
8
|
+
"industrial_language": false,
|
|
9
|
+
"lexical_illusions": false,
|
|
10
|
+
"malapropisms": true,
|
|
11
|
+
"misc": true,
|
|
12
|
+
"misc.phrasal_adjectives": false,
|
|
13
|
+
"mixed_metaphors": true,
|
|
14
|
+
"mondegreens": true,
|
|
15
|
+
"needless_variants": false,
|
|
16
|
+
"nonwords": true,
|
|
17
|
+
"oxymorons": true,
|
|
18
|
+
"psychology": true,
|
|
19
|
+
"redundancy": true,
|
|
20
|
+
"restricted": false,
|
|
21
|
+
"skunked_terms": true,
|
|
22
|
+
"social_awareness": true,
|
|
23
|
+
"spelling": true,
|
|
24
|
+
"spelling.athletes": false,
|
|
25
|
+
"terms": true,
|
|
26
|
+
"typography": true,
|
|
27
|
+
"typography.symbols": false,
|
|
28
|
+
"uncomparables": true,
|
|
29
|
+
"weasel_words": true,
|
|
30
|
+
"weasel_words.very": false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -2,20 +2,26 @@
|
|
|
2
2
|
# See all available variables at https://megalinter.io/configuration/
|
|
3
3
|
# and in linters documentation
|
|
4
4
|
|
|
5
|
+
ADDITIONAL_EXCLUDED_DIRECTORIES:
|
|
6
|
+
# Build/test caches: gitignored, but project-mode linters scan them anyway.
|
|
7
|
+
- __pycache__
|
|
8
|
+
- .mypy_cache
|
|
9
|
+
- .pytest_cache
|
|
10
|
+
- .ruff_cache
|
|
11
|
+
- .benchmarks
|
|
5
12
|
APPLY_FIXES: none # all, none, or list of linter keys
|
|
6
13
|
BASH_SHELLCHECK_ARGUMENTS: -e "SC2162"
|
|
7
14
|
CLEAR_REPORT_FOLDER: true
|
|
8
15
|
DISABLE_ERRORS: false
|
|
9
16
|
ENABLE_LINTERS:
|
|
17
|
+
- ACTION_ACTIONLINT
|
|
10
18
|
- BASH_EXEC
|
|
11
19
|
- BASH_SHELLCHECK
|
|
12
20
|
- BASH_SHFMT
|
|
13
21
|
- JSON_JSONLINT
|
|
14
22
|
- JSON_PRETTIER
|
|
15
23
|
- JSON_V8R
|
|
16
|
-
- MAKEFILE_CHECKMAKE
|
|
17
24
|
- MARKDOWN_MARKDOWNLINT
|
|
18
|
-
- MARKDOWN_MARKDOWN_LINK_CHECK
|
|
19
25
|
- MARKDOWN_MARKDOWN_TABLE_FORMATTER
|
|
20
26
|
- PYTHON_BLACK
|
|
21
27
|
- PYTHON_BANDIT
|
|
@@ -26,9 +32,8 @@ ENABLE_LINTERS:
|
|
|
26
32
|
- PYTHON_PYRIGHT
|
|
27
33
|
- PYTHON_RUFF
|
|
28
34
|
- PYTHON_RUFF_FORMAT
|
|
35
|
+
- REPOSITORY_BETTERLEAKS
|
|
29
36
|
- REPOSITORY_CHECKOV
|
|
30
|
-
- REPOSITORY_GITLEAKS
|
|
31
|
-
- REPOSITORY_KICS
|
|
32
37
|
- REPOSITORY_LS_LINT
|
|
33
38
|
- REPOSITORY_SECRETLINT
|
|
34
39
|
- REPOSITORY_SEMGREP
|
|
@@ -48,7 +53,8 @@ IGNORE_GITIGNORED_FILES: true
|
|
|
48
53
|
LINTER_RULES_PATH: .linters
|
|
49
54
|
LOG_LEVEL: INFO
|
|
50
55
|
MARKDOWN_DEFAULT_STYLE: markdownlint
|
|
51
|
-
|
|
56
|
+
# Pins the Docker image for local runs to the version the CI workflow pins.
|
|
57
|
+
MEGALINTER_VERSION: v10.0.0
|
|
52
58
|
PARALLEL: true
|
|
53
59
|
PRINT_ALPACA: false
|
|
54
60
|
PYTHON_BANDIT_RULES_PATH: .linters
|
|
@@ -68,8 +74,10 @@ REPOSITORY_SEMGREP_RULESETS:
|
|
|
68
74
|
"p/secure-defaults",
|
|
69
75
|
]
|
|
70
76
|
SECURITY_SUGGESTIONS: false
|
|
77
|
+
# Lob API keys look like `test_<alnum>`, so every pytest function name is
|
|
78
|
+
# reported as a verified secret. No other detector is affected.
|
|
79
|
+
REPOSITORY_TRUFFLEHOG_ARGUMENTS: --exclude-detectors=Lob
|
|
71
80
|
SHOW_ELAPSED_TIME: true
|
|
72
81
|
SHOW_SKIPPED_LINTERS: true
|
|
73
82
|
SPELL_PROSELINT_RULES_PATH: .linters
|
|
74
|
-
SPELL_VALE_RULES_PATH: .linters
|
|
75
83
|
VALIDATE_ALL_CODEBASE: false
|
|
@@ -42,10 +42,10 @@ clean: ## Clean cache of uv and delete virtual environment
|
|
|
42
42
|
|
|
43
43
|
lint:
|
|
44
44
|
@sh ./.linters/check_git_branch_name.sh
|
|
45
|
-
@npx mega-linter-runner
|
|
45
|
+
@npx mega-linter-runner@v10
|
|
46
46
|
|
|
47
47
|
lint-fix: ## Lints the code using sqlfluff and fixes the issues
|
|
48
|
-
@npx mega-linter-runner --fix
|
|
48
|
+
@npx mega-linter-runner@v10 --fix
|
|
49
49
|
|
|
50
50
|
test: ## Runs tests
|
|
51
51
|
@$(UV) run pytest
|
|
@@ -16,6 +16,7 @@ pr_to_file: false
|
|
|
16
16
|
pr_file_name: PR_DESCRIPTION.md
|
|
17
17
|
pr_prompt_file: "/home/thorsten/.config/cai/pr_prompt.md"
|
|
18
18
|
stats: true
|
|
19
|
+
signoff: true
|
|
19
20
|
anthropic:
|
|
20
21
|
model: claude-haiku-4-5
|
|
21
22
|
temperature: 0
|
|
@@ -37,7 +38,7 @@ ollama:
|
|
|
37
38
|
temperature: 0
|
|
38
39
|
timeout: 300
|
|
39
40
|
openai:
|
|
40
|
-
model: gpt-5.
|
|
41
|
+
model: gpt-5.6-luna
|
|
41
42
|
xai:
|
|
42
43
|
model: grok-4.3
|
|
43
44
|
temperature: 0
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
.\" Title: git-cai
|
|
3
3
|
.\" Author: Thorsten Foltz
|
|
4
4
|
.\" Generator: Asciidoctor 2.0.26
|
|
5
|
-
.\" Date: 2026-
|
|
5
|
+
.\" Date: 2026-08-06
|
|
6
6
|
.\" Manual: \ \&
|
|
7
7
|
.\" Source: \ \&
|
|
8
8
|
.\" Language: English
|
|
9
9
|
.\"
|
|
10
|
-
.TH "GIT\-CAI" "1" "2026-
|
|
10
|
+
.TH "GIT\-CAI" "1" "2026-08-06" "\ \&" "\ \&"
|
|
11
11
|
.ie \n(.g .ds Aq \(aq
|
|
12
12
|
.el .ds Aq '
|
|
13
13
|
.ss \n[.ss] 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.16.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 16,
|
|
21
|
+
__version__ = version = '0.16.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 16, 2)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g2674674a6'
|
|
@@ -40,7 +40,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
40
40
|
# ``max_tokens`` is still read as a legacy alias (see llm.py)
|
|
41
41
|
"max_output_tokens": 32768,
|
|
42
42
|
},
|
|
43
|
-
"openai": {"model": "gpt-5.
|
|
43
|
+
"openai": {"model": "gpt-5.6-luna"},
|
|
44
44
|
"deepseek": {"model": "deepseek-chat", "temperature": 0},
|
|
45
45
|
"gemini": {"model": "gemini-3.1-flash-lite", "temperature": 0},
|
|
46
46
|
"groq": {"model": "openai/gpt-oss-20b", "temperature": 0},
|
|
@@ -259,8 +259,15 @@ def append_signoff(message: str, identity: tuple[str, str] | None = None) -> str
|
|
|
259
259
|
if not stripped:
|
|
260
260
|
return trailer
|
|
261
261
|
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
# Only join an existing trailer block directly: a block is the final
|
|
263
|
+
# paragraph and must consist solely of ``Key: value`` lines. A message
|
|
264
|
+
# with a single paragraph never qualifies — a lone conventional subject
|
|
265
|
+
# ("feat: add x") looks like a trailer line, and gluing the sign-off to
|
|
266
|
+
# it makes git stop recognizing the trailer at all.
|
|
267
|
+
paragraphs = stripped.split("\n\n")
|
|
268
|
+
if len(paragraphs) > 1 and all(
|
|
269
|
+
_TRAILER_LINE_RE.match(line) for line in paragraphs[-1].splitlines()
|
|
270
|
+
):
|
|
264
271
|
return f"{stripped}\n{trailer}"
|
|
265
272
|
|
|
266
273
|
return f"{stripped}\n\n{trailer}"
|
|
@@ -37,7 +37,9 @@ log = logging.getLogger(__name__)
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
# Models that reject any non-default ``temperature`` (return HTTP 400).
|
|
40
|
-
#
|
|
40
|
+
# Pure fast path: it saves the rejected request. Models missing here are
|
|
41
|
+
# still handled — ``_http_post`` retries once without the field — so add
|
|
42
|
+
# an entry only when a warning shows up in practice.
|
|
41
43
|
_TEMPERATURE_UNSUPPORTED_PATTERNS = [
|
|
42
44
|
re.compile(r"^gpt-5", re.IGNORECASE), # OpenAI GPT-5 family (incl. mini/nano)
|
|
43
45
|
re.compile(r"^o[134](-|$)", re.IGNORECASE), # OpenAI o1/o3/o4 reasoning series
|
|
@@ -123,14 +125,46 @@ def _get_http_session() -> requests.Session:
|
|
|
123
125
|
return _build_retrying_session()
|
|
124
126
|
|
|
125
127
|
|
|
128
|
+
def _strip_temperature(payload: Any) -> bool:
|
|
129
|
+
"""Remove every ``temperature`` key from a request body, in place.
|
|
130
|
+
|
|
131
|
+
Providers nest it differently — top level for the OpenAI-compatible
|
|
132
|
+
APIs and Anthropic, ``generationConfig`` for Gemini, ``options`` for
|
|
133
|
+
Ollama — so recurse into nested dicts. Returns True if anything was
|
|
134
|
+
removed.
|
|
135
|
+
"""
|
|
136
|
+
if not isinstance(payload, dict):
|
|
137
|
+
return False
|
|
138
|
+
removed = payload.pop("temperature", None) is not None
|
|
139
|
+
for value in payload.values():
|
|
140
|
+
if _strip_temperature(value):
|
|
141
|
+
removed = True
|
|
142
|
+
return removed
|
|
143
|
+
|
|
144
|
+
|
|
126
145
|
def _http_post(*args, **kwargs):
|
|
127
146
|
"""POST via the module-level retrying session.
|
|
128
147
|
|
|
129
148
|
Single patch-point for tests; never uses ``requests.post`` directly so
|
|
130
149
|
every provider call benefits from urllib3 retry/backoff on transient
|
|
131
150
|
failures (429 / 5xx).
|
|
151
|
+
|
|
152
|
+
Models that reject ``temperature`` answer 400 instead of ignoring the
|
|
153
|
+
field, and new ones keep appearing, so a hardcoded model list can only
|
|
154
|
+
ever be a fast path (see ``_TEMPERATURE_UNSUPPORTED_PATTERNS``). When
|
|
155
|
+
the provider's own error names the field, drop it and retry once
|
|
156
|
+
rather than failing the run.
|
|
132
157
|
"""
|
|
133
|
-
|
|
158
|
+
response = _get_http_session().post(*args, **kwargs)
|
|
159
|
+
if response.status_code == 400 and "temperature" in response.text.lower():
|
|
160
|
+
if _strip_temperature(kwargs.get("json")):
|
|
161
|
+
log.warning(
|
|
162
|
+
"Provider rejected the configured temperature (HTTP 400); "
|
|
163
|
+
"retrying without it. Remove 'temperature' from this "
|
|
164
|
+
"model's config block to skip the extra request."
|
|
165
|
+
)
|
|
166
|
+
response = _get_http_session().post(*args, **kwargs)
|
|
167
|
+
return response
|
|
134
168
|
|
|
135
169
|
|
|
136
170
|
def load_prompt_file(
|
|
@@ -409,7 +409,17 @@ def squash_branch(
|
|
|
409
409
|
log.info("Squash summary generated in %.2fs", elapsed)
|
|
410
410
|
generator.record_elapsed(int(elapsed * 1000))
|
|
411
411
|
|
|
412
|
-
# 4) Let user edit the summary without making a commit yet
|
|
412
|
+
# 4) Let user edit the summary without making a commit yet.
|
|
413
|
+
# The trailer is added before the editor opens — same as commit mode,
|
|
414
|
+
# so the user sees (and can drop) it instead of it appearing only
|
|
415
|
+
# after the squash commit was already written.
|
|
416
|
+
if apply_signoff:
|
|
417
|
+
try:
|
|
418
|
+
summary_message = append_signoff(summary_message)
|
|
419
|
+
except RuntimeError as e:
|
|
420
|
+
log.error("%s", e)
|
|
421
|
+
sys.exit(1)
|
|
422
|
+
|
|
413
423
|
log.info(
|
|
414
424
|
"Opening editor for final squash commit message. Save = continue, exit w/o save = cancel."
|
|
415
425
|
)
|
|
@@ -449,13 +459,6 @@ def squash_branch(
|
|
|
449
459
|
finally:
|
|
450
460
|
tf_name.unlink(missing_ok=True)
|
|
451
461
|
|
|
452
|
-
if apply_signoff:
|
|
453
|
-
try:
|
|
454
|
-
final_message = append_signoff(final_message)
|
|
455
|
-
except RuntimeError as e:
|
|
456
|
-
log.error("%s", e)
|
|
457
|
-
sys.exit(1)
|
|
458
|
-
|
|
459
462
|
# 5) Perform squash
|
|
460
463
|
subprocess.run(["git", "reset", "--soft", merge_base], check=True)
|
|
461
464
|
subprocess.run(["git", "commit", "-m", final_message], check=True)
|
|
@@ -24,9 +24,8 @@ uv.lock
|
|
|
24
24
|
.linters/.checkov.yml
|
|
25
25
|
.linters/.flake8
|
|
26
26
|
.linters/.ls-lint.yml
|
|
27
|
-
.linters/.markdown-link-check.json
|
|
28
27
|
.linters/.markdownlint.json
|
|
29
|
-
.linters/.proselintrc
|
|
28
|
+
.linters/.proselintrc.json
|
|
30
29
|
.linters/.pylintrc
|
|
31
30
|
.linters/.yamllint.yml
|
|
32
31
|
.linters/check_git_branch_name.sh
|
|
@@ -1,105 +1,104 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
|
-
".gitattributes",
|
|
4
|
-
".semgrepignore",
|
|
5
|
-
".mega-linter.yml",
|
|
6
|
-
"Makefile",
|
|
7
|
-
".trivyignore",
|
|
8
|
-
"pyproject.toml",
|
|
9
|
-
"cai_config.yml",
|
|
10
|
-
".markdownlintignore",
|
|
11
|
-
"LICENSE",
|
|
12
|
-
"CLAUDE.md",
|
|
13
|
-
"README.md",
|
|
14
|
-
"uv.lock",
|
|
15
|
-
".gitignore",
|
|
16
3
|
".caiignore",
|
|
17
|
-
".
|
|
18
|
-
".github/workflows/release_aur.yml",
|
|
19
|
-
".github/workflows/release.yml",
|
|
20
|
-
".github/cd/PKGBUILD",
|
|
4
|
+
".gitattributes",
|
|
21
5
|
".github/cd/.SRCINFO",
|
|
6
|
+
".github/cd/PKGBUILD",
|
|
22
7
|
".github/ci/_version.py",
|
|
23
8
|
".github/ci/cai_config.ci.yml",
|
|
24
9
|
".github/ci/tokens.ci.yml",
|
|
10
|
+
".github/workflows/python-tests.yml",
|
|
11
|
+
".github/workflows/release.yml",
|
|
12
|
+
".github/workflows/release_aur.yml",
|
|
13
|
+
".gitignore",
|
|
14
|
+
".linters/.bandit.yml",
|
|
15
|
+
".linters/.checkov.yml",
|
|
16
|
+
".linters/.flake8",
|
|
17
|
+
".linters/.ls-lint.yml",
|
|
18
|
+
".linters/.markdownlint.json",
|
|
19
|
+
".linters/.proselintrc.json",
|
|
20
|
+
".linters/.pylintrc",
|
|
21
|
+
".linters/.yamllint.yml",
|
|
22
|
+
".linters/check_git_branch_name.sh",
|
|
23
|
+
".linters/lychee.toml",
|
|
24
|
+
".linters/pyrightconfig.json",
|
|
25
|
+
".markdownlintignore",
|
|
26
|
+
".mega-linter.yml",
|
|
27
|
+
".semgrepignore",
|
|
28
|
+
".trivyignore",
|
|
29
|
+
"CLAUDE.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"Makefile",
|
|
32
|
+
"README.md",
|
|
33
|
+
"cai_config.yml",
|
|
34
|
+
"docs/git-cai.txt",
|
|
35
|
+
"docs/man/git-cai.1",
|
|
36
|
+
"pyproject.toml",
|
|
37
|
+
"src/git_cai_cli/__init__.py",
|
|
38
|
+
"src/git_cai_cli/cli/__init__.py",
|
|
39
|
+
"src/git_cai_cli/cli/cli.py",
|
|
40
|
+
"src/git_cai_cli/cli/helptext.py",
|
|
41
|
+
"src/git_cai_cli/cli/modes.py",
|
|
42
|
+
"src/git_cai_cli/core/__init__.py",
|
|
43
|
+
"src/git_cai_cli/core/changelog.py",
|
|
44
|
+
"src/git_cai_cli/core/completion.py",
|
|
45
|
+
"src/git_cai_cli/core/config.py",
|
|
46
|
+
"src/git_cai_cli/core/doctor.py",
|
|
47
|
+
"src/git_cai_cli/core/explain.py",
|
|
48
|
+
"src/git_cai_cli/core/generation.py",
|
|
49
|
+
"src/git_cai_cli/core/gitutils.py",
|
|
50
|
+
"src/git_cai_cli/core/init.py",
|
|
51
|
+
"src/git_cai_cli/core/languages.py",
|
|
52
|
+
"src/git_cai_cli/core/llm.py",
|
|
53
|
+
"src/git_cai_cli/core/options.py",
|
|
54
|
+
"src/git_cai_cli/core/pr.py",
|
|
55
|
+
"src/git_cai_cli/core/prompts_fallback.py",
|
|
56
|
+
"src/git_cai_cli/core/release.py",
|
|
57
|
+
"src/git_cai_cli/core/secrets.py",
|
|
58
|
+
"src/git_cai_cli/core/spinner.py",
|
|
59
|
+
"src/git_cai_cli/core/split.py",
|
|
60
|
+
"src/git_cai_cli/core/squash.py",
|
|
61
|
+
"src/git_cai_cli/core/stats.py",
|
|
62
|
+
"src/git_cai_cli/core/validate.py",
|
|
63
|
+
"src/git_cai_cli/main.py",
|
|
25
64
|
"tests/conftest.py",
|
|
26
|
-
"tests/integration/
|
|
27
|
-
"tests/integration/
|
|
65
|
+
"tests/integration/test_cli_integration.py",
|
|
66
|
+
"tests/integration/test_config_integration.py",
|
|
28
67
|
"tests/integration/test_gitutils_integration.py",
|
|
68
|
+
"tests/integration/test_modes_integration.py",
|
|
69
|
+
"tests/integration/test_options_integration.py",
|
|
29
70
|
"tests/integration/test_pr_integration.py",
|
|
30
|
-
"tests/integration/test_config_integration.py",
|
|
31
71
|
"tests/integration/test_squash_integration.py",
|
|
32
|
-
"tests/
|
|
72
|
+
"tests/unit/test_amend.py",
|
|
73
|
+
"tests/unit/test_branch_context.py",
|
|
74
|
+
"tests/unit/test_changelog.py",
|
|
33
75
|
"tests/unit/test_classification.py",
|
|
34
|
-
"tests/unit/
|
|
35
|
-
"tests/unit/
|
|
76
|
+
"tests/unit/test_cli.py",
|
|
77
|
+
"tests/unit/test_completion.py",
|
|
78
|
+
"tests/unit/test_config.py",
|
|
36
79
|
"tests/unit/test_conventional.py",
|
|
37
|
-
"tests/unit/test_branch_context.py",
|
|
38
|
-
"tests/unit/test_signoff.py",
|
|
39
80
|
"tests/unit/test_doctor.py",
|
|
81
|
+
"tests/unit/test_explain.py",
|
|
40
82
|
"tests/unit/test_generation.py",
|
|
41
|
-
"tests/unit/
|
|
42
|
-
"tests/unit/test_print.py",
|
|
43
|
-
"tests/unit/test_options.py",
|
|
44
|
-
"tests/unit/test_llm.py",
|
|
45
|
-
"tests/unit/test_changelog.py",
|
|
83
|
+
"tests/unit/test_gitutils.py",
|
|
46
84
|
"tests/unit/test_helptext.py",
|
|
85
|
+
"tests/unit/test_init.py",
|
|
86
|
+
"tests/unit/test_llm.py",
|
|
87
|
+
"tests/unit/test_main.py",
|
|
88
|
+
"tests/unit/test_modes.py",
|
|
89
|
+
"tests/unit/test_options.py",
|
|
47
90
|
"tests/unit/test_pr.py",
|
|
48
|
-
"tests/unit/
|
|
49
|
-
"tests/unit/
|
|
50
|
-
"tests/unit/test_explain.py",
|
|
91
|
+
"tests/unit/test_print.py",
|
|
92
|
+
"tests/unit/test_prompt_loading.py",
|
|
51
93
|
"tests/unit/test_release.py",
|
|
52
|
-
"tests/unit/
|
|
53
|
-
"tests/unit/test_gitutils.py",
|
|
94
|
+
"tests/unit/test_secrets.py",
|
|
54
95
|
"tests/unit/test_set_config.py",
|
|
55
|
-
"tests/unit/
|
|
56
|
-
"tests/unit/test_main.py",
|
|
57
|
-
"tests/unit/test_modes.py",
|
|
58
|
-
"tests/unit/test_validate.py",
|
|
59
|
-
"tests/unit/test_cli.py",
|
|
96
|
+
"tests/unit/test_signoff.py",
|
|
60
97
|
"tests/unit/test_spinner.py",
|
|
61
|
-
"tests/unit/
|
|
62
|
-
"tests/unit/
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"src/git_cai_cli/cli/__init__.py",
|
|
67
|
-
"src/git_cai_cli/cli/cli.py",
|
|
68
|
-
"src/git_cai_cli/cli/helptext.py",
|
|
69
|
-
"src/git_cai_cli/core/languages.py",
|
|
70
|
-
"src/git_cai_cli/core/prompts_fallback.py",
|
|
71
|
-
"src/git_cai_cli/core/split.py",
|
|
72
|
-
"src/git_cai_cli/core/pr.py",
|
|
73
|
-
"src/git_cai_cli/core/completion.py",
|
|
74
|
-
"src/git_cai_cli/core/llm.py",
|
|
75
|
-
"src/git_cai_cli/core/stats.py",
|
|
76
|
-
"src/git_cai_cli/core/config.py",
|
|
77
|
-
"src/git_cai_cli/core/release.py",
|
|
78
|
-
"src/git_cai_cli/core/explain.py",
|
|
79
|
-
"src/git_cai_cli/core/__init__.py",
|
|
80
|
-
"src/git_cai_cli/core/spinner.py",
|
|
81
|
-
"src/git_cai_cli/core/options.py",
|
|
82
|
-
"src/git_cai_cli/core/changelog.py",
|
|
83
|
-
"src/git_cai_cli/core/validate.py",
|
|
84
|
-
"src/git_cai_cli/core/secrets.py",
|
|
85
|
-
"src/git_cai_cli/core/init.py",
|
|
86
|
-
"src/git_cai_cli/core/generation.py",
|
|
87
|
-
"src/git_cai_cli/core/squash.py",
|
|
88
|
-
"src/git_cai_cli/core/doctor.py",
|
|
89
|
-
"src/git_cai_cli/core/gitutils.py",
|
|
90
|
-
"docs/git-cai.txt",
|
|
91
|
-
"docs/man/git-cai.1",
|
|
92
|
-
".linters/.checkov.yml",
|
|
93
|
-
".linters/.pylintrc",
|
|
94
|
-
".linters/.ls-lint.yml",
|
|
95
|
-
".linters/pyrightconfig.json",
|
|
96
|
-
".linters/.markdown-link-check.json",
|
|
97
|
-
".linters/check_git_branch_name.sh",
|
|
98
|
-
".linters/lychee.toml",
|
|
99
|
-
".linters/.markdownlint.json",
|
|
100
|
-
".linters/.yamllint.yml",
|
|
101
|
-
".linters/.proselintrc",
|
|
102
|
-
".linters/.flake8",
|
|
103
|
-
".linters/.bandit.yml"
|
|
98
|
+
"tests/unit/test_split.py",
|
|
99
|
+
"tests/unit/test_squash.py",
|
|
100
|
+
"tests/unit/test_stats.py",
|
|
101
|
+
"tests/unit/test_validate.py",
|
|
102
|
+
"uv.lock"
|
|
104
103
|
]
|
|
105
104
|
}
|
|
@@ -8,8 +8,10 @@ from unittest.mock import MagicMock, patch
|
|
|
8
8
|
import pytest
|
|
9
9
|
from git_cai_cli.core.llm import (
|
|
10
10
|
CommitMessageGenerator,
|
|
11
|
+
_http_post,
|
|
11
12
|
_model_rejects_temperature,
|
|
12
13
|
_resolve_temperature,
|
|
14
|
+
_strip_temperature,
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
|
|
@@ -1383,6 +1385,65 @@ def test_ollama_start_skips_start_new_session_on_windows(monkeypatch, generator)
|
|
|
1383
1385
|
# ---------------------------------------------------------------------------
|
|
1384
1386
|
|
|
1385
1387
|
|
|
1388
|
+
def test_strip_temperature_removes_nested_occurrences():
|
|
1389
|
+
payload = {
|
|
1390
|
+
"model": "m",
|
|
1391
|
+
"temperature": 0,
|
|
1392
|
+
"generationConfig": {"temperature": 0.7, "topK": 1},
|
|
1393
|
+
"options": {"temperature": 0},
|
|
1394
|
+
}
|
|
1395
|
+
assert _strip_temperature(payload) is True
|
|
1396
|
+
assert payload == {"model": "m", "generationConfig": {"topK": 1}, "options": {}}
|
|
1397
|
+
# nothing left to remove -> no pointless retry
|
|
1398
|
+
assert _strip_temperature(payload) is False
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def test_http_post_retries_without_temperature_on_400(caplog):
|
|
1402
|
+
"""A model not covered by the known-list rejects the field with 400;
|
|
1403
|
+
the request is replayed once without it instead of failing the run."""
|
|
1404
|
+
rejected = MagicMock(
|
|
1405
|
+
status_code=400,
|
|
1406
|
+
text=(
|
|
1407
|
+
'{"error":{"message":"Unsupported value: \'temperature\'",'
|
|
1408
|
+
'"param":"temperature"}}'
|
|
1409
|
+
),
|
|
1410
|
+
)
|
|
1411
|
+
accepted = MagicMock(status_code=200, text="{}")
|
|
1412
|
+
session = MagicMock()
|
|
1413
|
+
session.post.side_effect = [rejected, accepted]
|
|
1414
|
+
payload = {"model": "future-model", "temperature": 0}
|
|
1415
|
+
|
|
1416
|
+
with (
|
|
1417
|
+
patch(
|
|
1418
|
+
f"{CommitMessageGenerator.__module__}._get_http_session",
|
|
1419
|
+
return_value=session,
|
|
1420
|
+
),
|
|
1421
|
+
caplog.at_level(logging.WARNING),
|
|
1422
|
+
):
|
|
1423
|
+
result = _http_post("https://api.example.com/v1", json=payload)
|
|
1424
|
+
|
|
1425
|
+
assert result is accepted
|
|
1426
|
+
assert session.post.call_count == 2
|
|
1427
|
+
assert session.post.call_args_list[1].kwargs["json"] == {"model": "future-model"}
|
|
1428
|
+
assert "retrying without it" in caplog.text
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def test_http_post_does_not_retry_on_unrelated_400():
|
|
1432
|
+
rejected = MagicMock(
|
|
1433
|
+
status_code=400, text='{"error":{"message":"model not found"}}'
|
|
1434
|
+
)
|
|
1435
|
+
session = MagicMock()
|
|
1436
|
+
session.post.return_value = rejected
|
|
1437
|
+
|
|
1438
|
+
with patch(
|
|
1439
|
+
f"{CommitMessageGenerator.__module__}._get_http_session", return_value=session
|
|
1440
|
+
):
|
|
1441
|
+
result = _http_post("https://api.example.com/v1", json={"temperature": 0})
|
|
1442
|
+
|
|
1443
|
+
assert result is rejected
|
|
1444
|
+
assert session.post.call_count == 1
|
|
1445
|
+
|
|
1446
|
+
|
|
1386
1447
|
def test_model_rejects_temperature_openai_gpt5():
|
|
1387
1448
|
assert _model_rejects_temperature("gpt-5.4-mini") is True
|
|
1388
1449
|
assert _model_rejects_temperature("gpt-5.2") is True
|
|
@@ -116,6 +116,20 @@ def test_append_signoff_when_other_signoff_already_present():
|
|
|
116
116
|
)
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def test_append_signoff_separates_single_line_conventional_subject():
|
|
120
|
+
"""A lone subject like ``feat: x`` matches the trailer pattern but is
|
|
121
|
+
not a trailer block — gluing the sign-off to it makes git drop the
|
|
122
|
+
trailer entirely (``git interpret-trailers --parse`` returns nothing)."""
|
|
123
|
+
out = append_signoff("feat: add signoff", identity=("Alice", "alice@example.com"))
|
|
124
|
+
assert out == "feat: add signoff\n\nSigned-off-by: Alice <alice@example.com>"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_append_signoff_ignores_trailer_lookalike_at_end_of_body():
|
|
128
|
+
msg = "Add thing\n\nBody line\nNote: this is prose, not a trailer block"
|
|
129
|
+
out = append_signoff(msg, identity=("Alice", "alice@example.com"))
|
|
130
|
+
assert out == f"{msg}\n\nSigned-off-by: Alice <alice@example.com>"
|
|
131
|
+
|
|
132
|
+
|
|
119
133
|
def test_append_signoff_on_empty_message_just_returns_trailer():
|
|
120
134
|
out = append_signoff("", identity=("Alice", "alice@example.com"))
|
|
121
135
|
assert out == "Signed-off-by: Alice <alice@example.com>"
|