git-cai-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.pylintrc +4 -4
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/PKG-INFO +12 -6
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/README.md +10 -5
- git_cai_cli-0.3.0/archlinux/.SRCINFO +21 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/archlinux/PKGBUILD +4 -4
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/pyproject.toml +1 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/cli.py +17 -4
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/config.py +1 -1
- git_cai_cli-0.3.0/src/git_cai_cli/core/gitutils.py +156 -0
- git_cai_cli-0.3.0/src/git_cai_cli/core/llm.py +152 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/options.py +15 -4
- git_cai_cli-0.3.0/src/git_cai_cli/core/squash.py +181 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/PKG-INFO +12 -6
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/SOURCES.txt +3 -1
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/top_level.txt +0 -1
- git_cai_cli-0.2.0/src/git_cai_cli/core/gitutils.py +0 -57
- git_cai_cli-0.2.0/src/git_cai_cli/core/llm.py +0 -112
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.caiignore +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.gitignore +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.bandit.yml +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.checkov.yml +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.flake8 +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.ls-lint.yml +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.markdown-link-check.json +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.markdownlint.json +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.proselintrc +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.yamllint.yml +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/check_git_branch_name.sh +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/lychee.toml +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/pyrightconfig.json +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.mega-linter.yml +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.semgrepignore +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.trivyignore +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/LICENSE +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/Makefile +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/setup.cfg +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/__init__.py +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/__init__.py +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/languages.py +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/requires.txt +0 -0
- {git_cai_cli-0.2.0/src → git_cai_cli-0.3.0}/tests/test_core/test_config.py +0 -0
- {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/uv.lock +0 -0
|
@@ -68,16 +68,16 @@ check-protected-access-in-special-methods=no
|
|
|
68
68
|
[DESIGN]
|
|
69
69
|
max-args=10 # Max arguments per function
|
|
70
70
|
max-attributes=7 # Max attributes per class
|
|
71
|
-
max-branches=
|
|
71
|
+
max-branches=20 # Max branches per function
|
|
72
72
|
max-locals=30 # Max locals per function
|
|
73
|
-
max-returns=
|
|
74
|
-
max-statements=
|
|
73
|
+
max-returns=10 # Max return statements per function
|
|
74
|
+
max-statements=60 # 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
|
|
78
78
|
max-bool-expr=15 # Max boolean subexpressions in a condition
|
|
79
79
|
max-parents=7 # Max base classes per class
|
|
80
|
-
max-positional-arguments=10
|
|
80
|
+
max-positional-arguments=10 # Max positional arguments per function
|
|
81
81
|
|
|
82
82
|
[EXCEPTIONS]
|
|
83
83
|
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-cai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Use LLM to create git commit messages
|
|
5
5
|
Author-email: Thorsten Foltz <thorsten.foltz@live.com>
|
|
6
6
|
License: MIT
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
19
|
Classifier: Operating System :: OS Independent
|
|
19
20
|
Classifier: Topic :: Software Development :: Version Control
|
|
20
21
|
Requires-Python: >=3.10
|
|
@@ -64,6 +65,7 @@ Currently, the only supported backends are the OpenAI and Gemini APIs, but addit
|
|
|
64
65
|
- Generates meaningful, context-aware commit messages using an LLM
|
|
65
66
|
- Seamless integration with Git as a plugin/extension
|
|
66
67
|
- Supports different LLM models and languages for each repository, as well as global configuration
|
|
68
|
+
- Allows to squash all commits in a branch and summarizes the commit messages
|
|
67
69
|
|
|
68
70
|
<h2 id="installation-section">Installation</h2>
|
|
69
71
|
|
|
@@ -89,7 +91,10 @@ Once installed, cai works like a standard Git command:
|
|
|
89
91
|
git cai
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
`cai` uses Git’s `diff` output
|
|
94
|
+
`cai` uses Git’s `diff` output to generate commit messages. The generated message is then opened in your editor, allowing you to review and edit it before confirming the commit.
|
|
95
|
+
|
|
96
|
+
In short: it behaves like `git commit`, but the commit message is pre-filled for you.
|
|
97
|
+
|
|
93
98
|
To exclude specific files or directories from being included in the generated commit message, create a `.caiignore` file in the root of your repository. This file works like a `.gitignore`.
|
|
94
99
|
|
|
95
100
|
- Files listed in `.gitignore` are **always excluded**.
|
|
@@ -133,10 +138,11 @@ Currently, the following options can be customized:
|
|
|
133
138
|
Besides running `git cai` to generate commit messages, you can use the following options:
|
|
134
139
|
|
|
135
140
|
- `-h` shows a brief help message with available commands
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
141
|
+
- `-d`, `--debug` enables debug logging to help troubleshoot issues
|
|
142
|
+
- `-l`, `--languages` list available languages
|
|
143
|
+
- `-s`, `--squash` Squash commits on this branch and summarize them
|
|
144
|
+
- `-u`, `--update` checks for updates the `cai` tool
|
|
145
|
+
- `-v`, `--version` displays the currently installed version
|
|
140
146
|
|
|
141
147
|
<h2 id="license-section">License</h2>
|
|
142
148
|
This project is licensed under the MIT License.
|
|
@@ -35,6 +35,7 @@ Currently, the only supported backends are the OpenAI and Gemini APIs, but addit
|
|
|
35
35
|
- Generates meaningful, context-aware commit messages using an LLM
|
|
36
36
|
- Seamless integration with Git as a plugin/extension
|
|
37
37
|
- Supports different LLM models and languages for each repository, as well as global configuration
|
|
38
|
+
- Allows to squash all commits in a branch and summarizes the commit messages
|
|
38
39
|
|
|
39
40
|
<h2 id="installation-section">Installation</h2>
|
|
40
41
|
|
|
@@ -60,7 +61,10 @@ Once installed, cai works like a standard Git command:
|
|
|
60
61
|
git cai
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
`cai` uses Git’s `diff` output
|
|
64
|
+
`cai` uses Git’s `diff` output to generate commit messages. The generated message is then opened in your editor, allowing you to review and edit it before confirming the commit.
|
|
65
|
+
|
|
66
|
+
In short: it behaves like `git commit`, but the commit message is pre-filled for you.
|
|
67
|
+
|
|
64
68
|
To exclude specific files or directories from being included in the generated commit message, create a `.caiignore` file in the root of your repository. This file works like a `.gitignore`.
|
|
65
69
|
|
|
66
70
|
- Files listed in `.gitignore` are **always excluded**.
|
|
@@ -104,10 +108,11 @@ Currently, the following options can be customized:
|
|
|
104
108
|
Besides running `git cai` to generate commit messages, you can use the following options:
|
|
105
109
|
|
|
106
110
|
- `-h` shows a brief help message with available commands
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
+
- `-d`, `--debug` enables debug logging to help troubleshoot issues
|
|
112
|
+
- `-l`, `--languages` list available languages
|
|
113
|
+
- `-s`, `--squash` Squash commits on this branch and summarize them
|
|
114
|
+
- `-u`, `--update` checks for updates the `cai` tool
|
|
115
|
+
- `-v`, `--version` displays the currently installed version
|
|
111
116
|
|
|
112
117
|
<h2 id="license-section">License</h2>
|
|
113
118
|
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
pkgbase = git-cai-cli
|
|
2
|
+
pkgdesc = Use LLM to create git commit messages.
|
|
3
|
+
pkgver = 0.2.0
|
|
4
|
+
pkgrel = 1
|
|
5
|
+
url = https://github.com/thorstenfoltz/cai
|
|
6
|
+
arch = any
|
|
7
|
+
license = MIT
|
|
8
|
+
makedepends = python-build
|
|
9
|
+
makedepends = python-installer
|
|
10
|
+
makedepends = python-setuptools
|
|
11
|
+
makedepends = python-wheel
|
|
12
|
+
depends = python
|
|
13
|
+
depends = python-yaml
|
|
14
|
+
depends = python-openai
|
|
15
|
+
depends = python-google-genai
|
|
16
|
+
depends = python-requests
|
|
17
|
+
depends = python-typer
|
|
18
|
+
source = https://files.pythonhosted.org/packages/bf/df/cc0dce84bc5aea242012f15782abcc58e60e99113e91602011c33d3a8d91/git_cai_cli-0.2.0.tar.gz
|
|
19
|
+
sha256sums = 83a6fba3f0ac729c8cf2453abba96b37c60ff7ea8d309704c613258544687635
|
|
20
|
+
|
|
21
|
+
pkgname = git-cai-cli
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# Maintainer: Thorsten Foltz <thorsten.foltz@live.com>
|
|
2
2
|
pkgname=git-cai-cli
|
|
3
|
-
pkgver=0.
|
|
3
|
+
pkgver=0.2.1
|
|
4
4
|
pkgrel=1
|
|
5
5
|
pkgdesc="Use LLM to create git commit messages."
|
|
6
6
|
arch=('any')
|
|
7
7
|
url="https://github.com/thorstenfoltz/cai"
|
|
8
8
|
license=('MIT')
|
|
9
|
-
depends=('python' 'python-yaml' 'python-openai' 'python-google-genai')
|
|
9
|
+
depends=('python' 'python-yaml' 'python-openai' 'python-google-genai' 'python-requests' 'python-typer')
|
|
10
10
|
makedepends=(python-build python-installer python-setuptools python-wheel)
|
|
11
|
-
source=("https://files.pythonhosted.org/packages/
|
|
12
|
-
sha256sums=('
|
|
11
|
+
source=("https://files.pythonhosted.org/packages/2d/f4/90663c50a4fe47d5b5b17c7d9bda3bdf6ac620d8bcae390b9eb449dc962c/git_cai_cli-0.2.1.tar.gz")
|
|
12
|
+
sha256sums=('3803b4eff03763cd41deddf59d3d039a540c6492aaaf38aa1cd3959254609d3e')
|
|
13
13
|
|
|
14
14
|
build() {
|
|
15
15
|
cd "${srcdir}/git_cai_cli-${pkgver}"
|
|
@@ -23,6 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Programming Language :: Python :: 3.11",
|
|
24
24
|
"Programming Language :: Python :: 3.12",
|
|
25
25
|
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
26
27
|
"Operating System :: OS Independent",
|
|
27
28
|
"Topic :: Software Development :: Version Control",
|
|
28
29
|
]
|
|
@@ -3,13 +3,16 @@ Main function
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
import subprocess
|
|
7
6
|
import sys
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
10
9
|
import typer
|
|
11
10
|
from git_cai_cli.core.config import get_default_config, load_config, load_token
|
|
12
|
-
from git_cai_cli.core.gitutils import
|
|
11
|
+
from git_cai_cli.core.gitutils import (
|
|
12
|
+
commit_with_edit_template,
|
|
13
|
+
find_git_root,
|
|
14
|
+
git_diff_excluding,
|
|
15
|
+
)
|
|
13
16
|
from git_cai_cli.core.llm import CommitMessageGenerator
|
|
14
17
|
from git_cai_cli.core.options import CliManager
|
|
15
18
|
|
|
@@ -63,7 +66,7 @@ def main() -> None:
|
|
|
63
66
|
commit_message = generator.generate(diff)
|
|
64
67
|
|
|
65
68
|
# Open git commit editor with the generated message
|
|
66
|
-
|
|
69
|
+
commit_with_edit_template(commit_message)
|
|
67
70
|
|
|
68
71
|
|
|
69
72
|
@app.command()
|
|
@@ -75,6 +78,13 @@ def run(
|
|
|
75
78
|
language: bool = typer.Option(
|
|
76
79
|
False, "--languages", "-l", help="List supported languages", is_eager=True
|
|
77
80
|
),
|
|
81
|
+
squash: bool = typer.Option(
|
|
82
|
+
False,
|
|
83
|
+
"--squash",
|
|
84
|
+
"-s",
|
|
85
|
+
help="Squash commits on this branch and summarize them",
|
|
86
|
+
is_eager=True,
|
|
87
|
+
),
|
|
78
88
|
update: bool = typer.Option(
|
|
79
89
|
False, "--update", "-u", help="Check for updates", is_eager=True
|
|
80
90
|
),
|
|
@@ -90,10 +100,13 @@ def run(
|
|
|
90
100
|
raise typer.Exit()
|
|
91
101
|
|
|
92
102
|
if enable_debug:
|
|
93
|
-
|
|
103
|
+
manager.enable_debug()
|
|
94
104
|
|
|
95
105
|
if language:
|
|
96
106
|
typer.echo(manager.print_available_languages())
|
|
107
|
+
|
|
108
|
+
if squash:
|
|
109
|
+
manager.squash_branch()
|
|
97
110
|
raise typer.Exit()
|
|
98
111
|
|
|
99
112
|
if update:
|
|
@@ -157,7 +157,7 @@ def get_default_config() -> str:
|
|
|
157
157
|
raise KeyError(f"'default' key not found in {config_path}")
|
|
158
158
|
|
|
159
159
|
default_value = config["default"]
|
|
160
|
-
log.info("
|
|
160
|
+
log.info("Using provider: %s", default_value)
|
|
161
161
|
return default_value
|
|
162
162
|
|
|
163
163
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Check git repo and run git diff
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_git_root(
|
|
20
|
+
run_cmd: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
21
|
+
) -> Path | None:
|
|
22
|
+
"""Returns the root directory of the current Git repository, or None if not in a Git repo."""
|
|
23
|
+
try:
|
|
24
|
+
result = run_cmd(
|
|
25
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
check=True,
|
|
29
|
+
)
|
|
30
|
+
return Path(result.stdout.strip())
|
|
31
|
+
except subprocess.CalledProcessError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def git_diff_excluding(
|
|
36
|
+
repo_root: Path,
|
|
37
|
+
run_cmd: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
38
|
+
exit_func: Callable[[int], None] = sys.exit,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Run `git diff` excluding files listed in .caiignore."""
|
|
41
|
+
ignore_file = repo_root / ".caiignore"
|
|
42
|
+
|
|
43
|
+
exclude_files: list[str] = []
|
|
44
|
+
if ignore_file.exists():
|
|
45
|
+
with open(ignore_file, "r", encoding="utf-8") as f:
|
|
46
|
+
exclude_files = [
|
|
47
|
+
line.strip()
|
|
48
|
+
for line in f
|
|
49
|
+
if line.strip() and not line.strip().startswith("#")
|
|
50
|
+
]
|
|
51
|
+
if not exclude_files:
|
|
52
|
+
log.info("%s is empty. No files excluded.", ignore_file)
|
|
53
|
+
|
|
54
|
+
cmd = ["git", "diff", "--cached", "--", "."]
|
|
55
|
+
cmd.extend(f":!{pattern}" for pattern in exclude_files)
|
|
56
|
+
|
|
57
|
+
result = run_cmd(cmd, capture_output=True, text=True, check=True)
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
log.error("git diff failed: %s", result.stderr.strip())
|
|
60
|
+
exit_func(1)
|
|
61
|
+
|
|
62
|
+
return result.stdout
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _has_upstream() -> bool:
|
|
66
|
+
try:
|
|
67
|
+
subprocess.check_output(
|
|
68
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
69
|
+
text=True,
|
|
70
|
+
stderr=subprocess.DEVNULL,
|
|
71
|
+
)
|
|
72
|
+
return True
|
|
73
|
+
except subprocess.CalledProcessError:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_git_editor() -> str:
|
|
78
|
+
"""Return the editor git would use (GIT_EDITOR, core.editor, VISUAL, EDITOR, fallback)."""
|
|
79
|
+
# Ask git for its editor; falls back if git returns nothing or error
|
|
80
|
+
try:
|
|
81
|
+
p = subprocess.run(
|
|
82
|
+
["git", "var", "GIT_EDITOR"], capture_output=True, text=True, check=True
|
|
83
|
+
)
|
|
84
|
+
editor = p.stdout.strip()
|
|
85
|
+
if editor:
|
|
86
|
+
return editor
|
|
87
|
+
except subprocess.CalledProcessError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# fallback lookup similar to git's precedence
|
|
91
|
+
for env in ("GIT_EDITOR", "VISUAL", "EDITOR"):
|
|
92
|
+
val = os.environ.get(env)
|
|
93
|
+
if val:
|
|
94
|
+
return val
|
|
95
|
+
|
|
96
|
+
return shutil.which("vi") or shutil.which("nano") or "vi"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sha256_of_file(path: Path) -> str:
|
|
100
|
+
"""Compute SHA256 hash of a file."""
|
|
101
|
+
h = hashlib.sha256()
|
|
102
|
+
with path.open("rb") as f:
|
|
103
|
+
while True:
|
|
104
|
+
chunk = f.read(8192)
|
|
105
|
+
if not chunk:
|
|
106
|
+
break
|
|
107
|
+
h.update(chunk)
|
|
108
|
+
return h.hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def commit_with_edit_template(commit_message: str) -> int:
|
|
112
|
+
"""Open git commit editor with a pre-filled commit message template."""
|
|
113
|
+
# create temp file with initial message
|
|
114
|
+
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tf:
|
|
115
|
+
tf.write(commit_message)
|
|
116
|
+
tf.flush()
|
|
117
|
+
tf_name = Path(tf.name)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
original_hash = sha256_of_file(tf_name)
|
|
121
|
+
|
|
122
|
+
editor = get_git_editor()
|
|
123
|
+
parts = shlex.split(editor)
|
|
124
|
+
if not shutil.which(parts[0]):
|
|
125
|
+
log.error(
|
|
126
|
+
"Editor %r not found in PATH; please set GIT_EDITOR properly.", parts[0]
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
rc = subprocess.run(parts + [str(tf_name)], check=False).returncode
|
|
130
|
+
|
|
131
|
+
if rc != 0:
|
|
132
|
+
log.error("Editor exited with non-zero status -> aborting commit.")
|
|
133
|
+
return rc or 1
|
|
134
|
+
|
|
135
|
+
new_hash = sha256_of_file(tf_name)
|
|
136
|
+
|
|
137
|
+
# If the file was not changed, treat as "user didn't save" and abort.
|
|
138
|
+
if new_hash == original_hash:
|
|
139
|
+
log.warning("Aborting commit: commit message not saved.")
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
# file changed (or saved). Run git commit using the file as message.
|
|
143
|
+
try:
|
|
144
|
+
subprocess.run(["git", "commit", "-F", str(tf_name)], check=True)
|
|
145
|
+
return 0
|
|
146
|
+
except subprocess.CalledProcessError as e:
|
|
147
|
+
log.error("git commit failed with exit code %d", e.returncode)
|
|
148
|
+
return e.returncode or 1
|
|
149
|
+
|
|
150
|
+
finally:
|
|
151
|
+
try:
|
|
152
|
+
os.remove(tf_name)
|
|
153
|
+
except OSError as e:
|
|
154
|
+
log.warning(
|
|
155
|
+
"Failed to remove temporary commit message file %s: %r", tf_name, e
|
|
156
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Use LLMs to generate git commit messages from diffs or multiple commits.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, Optional, Type
|
|
7
|
+
|
|
8
|
+
from git_cai_cli.core.languages import LANGUAGE_MAP
|
|
9
|
+
from google import genai # type: ignore[reportUnknownImport]
|
|
10
|
+
from google.genai import types # type: ignore[reportUnknownImport]
|
|
11
|
+
from openai import OpenAI
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommitMessageGenerator:
|
|
17
|
+
"""
|
|
18
|
+
Generates git commit messages from diffs or from multiple commit messages.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, token: str, config: Dict[str, Any], default_model: str):
|
|
22
|
+
self.token = token
|
|
23
|
+
self.config = config
|
|
24
|
+
self.default_model = default_model
|
|
25
|
+
|
|
26
|
+
def generate(self, git_diff: str) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Generate a commit message from a diff.
|
|
29
|
+
"""
|
|
30
|
+
language_name = self._language_name(self.config["language"], LANGUAGE_MAP)
|
|
31
|
+
prompt = self._system_prompt(language_name=language_name)
|
|
32
|
+
return self._dispatch_generate(content=git_diff, system_prompt=prompt)
|
|
33
|
+
|
|
34
|
+
def summarize_commit_history(self, commit_messages: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Summarize multiple commit messages into one high-level commit message.
|
|
37
|
+
"""
|
|
38
|
+
language_name = self._language_name(self.config["language"], LANGUAGE_MAP)
|
|
39
|
+
prompt = self._summary_prompt(language_name=language_name)
|
|
40
|
+
return self._dispatch_generate(content=commit_messages, system_prompt=prompt)
|
|
41
|
+
|
|
42
|
+
# ---------------------------
|
|
43
|
+
# PROMPTS
|
|
44
|
+
# ---------------------------
|
|
45
|
+
|
|
46
|
+
def _system_prompt(self, language_name: str) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Prompt used when generating commit messages from diffs.
|
|
49
|
+
"""
|
|
50
|
+
return (
|
|
51
|
+
"You are an expert software engineer assistant. "
|
|
52
|
+
"Your task is to generate a concise, professional git commit message. "
|
|
53
|
+
f"summarizing the provided git diff changes in {language_name}. "
|
|
54
|
+
"Keep the message clear and focused on what was changed and why. "
|
|
55
|
+
"Always include a headline, followed by a bullet-point list of changes. "
|
|
56
|
+
"If you detect sensitive information, mention it at the top, but still generate the message."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _summary_prompt(self, language_name: str) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Prompt used when summarizing multiple commit messages into a single commit.
|
|
62
|
+
"""
|
|
63
|
+
return (
|
|
64
|
+
"You are an expert software engineer assistant. "
|
|
65
|
+
"Your task is to summarize multiple existing commit messages* "
|
|
66
|
+
"into a single clean git commit message. "
|
|
67
|
+
f"Write the final message in {language_name}. "
|
|
68
|
+
"Do not list each commit individually. "
|
|
69
|
+
"Instead, infer the main purpose and overall change. "
|
|
70
|
+
"Format:\n"
|
|
71
|
+
"1. One short, clear headline.\n"
|
|
72
|
+
"2. A concise bullet list describing the main themes of the work."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# ---------------------------
|
|
76
|
+
# DISPATCH
|
|
77
|
+
# ---------------------------
|
|
78
|
+
|
|
79
|
+
def _dispatch_generate(self, content: str, system_prompt: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Route to correct model (openai or gemini) with the right prompt.
|
|
82
|
+
"""
|
|
83
|
+
model_dispatch = {
|
|
84
|
+
"openai": self.generate_openai,
|
|
85
|
+
"gemini": self.generate_gemini,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if self.default_model not in model_dispatch:
|
|
89
|
+
raise ValueError(f"Unknown model type: '{self.default_model}'")
|
|
90
|
+
|
|
91
|
+
return model_dispatch[self.default_model](
|
|
92
|
+
content, system_prompt_override=system_prompt
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# ---------------------------
|
|
96
|
+
# MODEL CALLS
|
|
97
|
+
# ---------------------------
|
|
98
|
+
|
|
99
|
+
def generate_openai(
|
|
100
|
+
self,
|
|
101
|
+
content: str,
|
|
102
|
+
openai_cls: Type[Any] = OpenAI,
|
|
103
|
+
system_prompt_override: Optional[str] = None,
|
|
104
|
+
) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Shared OpenAI call for commit generation or commit history summarization.
|
|
107
|
+
"""
|
|
108
|
+
client = openai_cls(api_key=self.token)
|
|
109
|
+
model = self.config["openai"]["model"]
|
|
110
|
+
temperature = self.config["openai"]["temperature"]
|
|
111
|
+
|
|
112
|
+
messages = [
|
|
113
|
+
{"role": "system", "content": system_prompt_override},
|
|
114
|
+
{"role": "user", "content": content},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
completion = client.chat.completions.create(
|
|
118
|
+
model=model,
|
|
119
|
+
messages=messages,
|
|
120
|
+
temperature=temperature,
|
|
121
|
+
)
|
|
122
|
+
return completion.choices[0].message.content.strip()
|
|
123
|
+
|
|
124
|
+
def generate_gemini(
|
|
125
|
+
self,
|
|
126
|
+
content: str,
|
|
127
|
+
genai_cls: Type[Any] = genai.Client,
|
|
128
|
+
system_prompt_override: Optional[str] = None,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Shared Gemini call for commit generation or commit history summarization.
|
|
132
|
+
"""
|
|
133
|
+
client = genai_cls(api_key=self.token)
|
|
134
|
+
model = self.config["gemini"]["model"]
|
|
135
|
+
temperature = self.config["gemini"]["temperature"]
|
|
136
|
+
|
|
137
|
+
response = client.models.generate_content(
|
|
138
|
+
model=model,
|
|
139
|
+
contents=content,
|
|
140
|
+
config=types.GenerateContentConfig(
|
|
141
|
+
system_instruction=system_prompt_override,
|
|
142
|
+
temperature=temperature,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
return response.text
|
|
146
|
+
|
|
147
|
+
# ---------------------------
|
|
148
|
+
# LANGUAGE HELPER
|
|
149
|
+
# ---------------------------
|
|
150
|
+
|
|
151
|
+
def _language_name(self, lang_code: str, allowed_languages: dict[str, str]) -> str:
|
|
152
|
+
return allowed_languages.get(lang_code, "English")
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
import requests
|
|
12
12
|
from git_cai_cli.core.languages import LANGUAGE_MAP
|
|
13
|
+
from git_cai_cli.core.squash import squash_branch
|
|
13
14
|
|
|
14
15
|
log = logging.getLogger(__name__)
|
|
15
16
|
|
|
@@ -62,6 +63,7 @@ Flags:
|
|
|
62
63
|
-h Show this help message
|
|
63
64
|
-d, --debug Enable debug logging
|
|
64
65
|
-l, --languages List supported languages
|
|
66
|
+
-s, --squash Squash commits on this branch and summarize them
|
|
65
67
|
-u, --update Check for updates
|
|
66
68
|
-v, --version Show installed version
|
|
67
69
|
|
|
@@ -70,7 +72,7 @@ Configuration:
|
|
|
70
72
|
|
|
71
73
|
Examples:
|
|
72
74
|
git add .
|
|
73
|
-
git cai
|
|
75
|
+
git cai Generates commit message
|
|
74
76
|
|
|
75
77
|
"""
|
|
76
78
|
|
|
@@ -159,7 +161,7 @@ Examples:
|
|
|
159
161
|
log.error("Error during update: %s", update_error)
|
|
160
162
|
print("❌ An error occurred while updating. Check logs for details.")
|
|
161
163
|
|
|
162
|
-
def enable_debug(self):
|
|
164
|
+
def enable_debug(self) -> None:
|
|
163
165
|
"""
|
|
164
166
|
Enable verbose/debug logging.
|
|
165
167
|
"""
|
|
@@ -173,6 +175,15 @@ Examples:
|
|
|
173
175
|
Intended to be used in CLI commands.
|
|
174
176
|
"""
|
|
175
177
|
lines = ["\nAvailable languages:"]
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
# Sort by the name (value)
|
|
179
|
+
for code, name in sorted(
|
|
180
|
+
self.allowed_languages.items(), key=lambda item: item[1]
|
|
181
|
+
):
|
|
182
|
+
lines.append(f" - {name} → {code}")
|
|
178
183
|
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
def squash_branch(self) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Squash commits on the current branch and summarize them.
|
|
188
|
+
"""
|
|
189
|
+
return squash_branch()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Squash all commits in the current branch into a single commit with an LLM-generated message.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from git_cai_cli.core.config import get_default_config, load_config, load_token
|
|
14
|
+
from git_cai_cli.core.gitutils import (
|
|
15
|
+
_has_upstream,
|
|
16
|
+
commit_with_edit_template,
|
|
17
|
+
find_git_root,
|
|
18
|
+
get_git_editor,
|
|
19
|
+
git_diff_excluding,
|
|
20
|
+
sha256_of_file,
|
|
21
|
+
)
|
|
22
|
+
from git_cai_cli.core.llm import CommitMessageGenerator
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_branch_base() -> str:
|
|
28
|
+
"""
|
|
29
|
+
Determine commit where the branch diverged
|
|
30
|
+
"""
|
|
31
|
+
# Try remote default branch (origin/main, origin/master, etc.)
|
|
32
|
+
try:
|
|
33
|
+
ref = subprocess.check_output(
|
|
34
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"], text=True
|
|
35
|
+
).strip()
|
|
36
|
+
default_branch = ref.replace("refs/remotes/", "")
|
|
37
|
+
log.info("Using default branch for base detection: %s", default_branch)
|
|
38
|
+
base = subprocess.check_output(
|
|
39
|
+
["git", "merge-base", "--fork-point", default_branch, "HEAD"], text=True
|
|
40
|
+
).strip()
|
|
41
|
+
return base
|
|
42
|
+
except subprocess.CalledProcessError as e:
|
|
43
|
+
log.info(
|
|
44
|
+
"Unable to determine default branch. Falling back to initial commit..."
|
|
45
|
+
)
|
|
46
|
+
log.debug("Default branch detection failed: %r", e)
|
|
47
|
+
|
|
48
|
+
# Fallback to repo root commit
|
|
49
|
+
try:
|
|
50
|
+
base = subprocess.check_output(
|
|
51
|
+
["git", "rev-list", "--max-parents=0", "HEAD"], text=True
|
|
52
|
+
).strip()
|
|
53
|
+
log.info("Using repository root commit as base: %s", base)
|
|
54
|
+
return base
|
|
55
|
+
|
|
56
|
+
except subprocess.CalledProcessError as e:
|
|
57
|
+
# This case is extremely rare and would be truly unexpected.
|
|
58
|
+
log.debug("Failed to determine repository root commit: %r", e)
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def squash_branch() -> None:
|
|
63
|
+
"""
|
|
64
|
+
Squash all commits in the current branch into a single commit with an LLM-generated message.
|
|
65
|
+
"""
|
|
66
|
+
repo_root = find_git_root()
|
|
67
|
+
if not repo_root:
|
|
68
|
+
log.error("Not inside a Git repository.")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
staged = subprocess.check_output(
|
|
72
|
+
["git", "diff", "--cached", "--name-only"], text=True
|
|
73
|
+
).strip()
|
|
74
|
+
unstaged = subprocess.check_output(
|
|
75
|
+
["git", "diff", "--name-only"], text=True
|
|
76
|
+
).strip()
|
|
77
|
+
|
|
78
|
+
config = load_config()
|
|
79
|
+
default_model = get_default_config()
|
|
80
|
+
token = load_token(default_model)
|
|
81
|
+
if not token:
|
|
82
|
+
log.error("Missing %s token in ~/.config/cai/tokens.yml", default_model)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
generator = CommitMessageGenerator(token, config, default_model)
|
|
85
|
+
|
|
86
|
+
# 1) Working tree handling
|
|
87
|
+
if staged:
|
|
88
|
+
log.info("Staged changes detected — committing them first before squashing...")
|
|
89
|
+
diff = git_diff_excluding(repo_root)
|
|
90
|
+
|
|
91
|
+
if not diff.strip():
|
|
92
|
+
log.error("Staged changes detected, but diff is empty. Aborting.")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
msg = generator.generate(diff)
|
|
96
|
+
|
|
97
|
+
result = commit_with_edit_template(msg)
|
|
98
|
+
if result != 0:
|
|
99
|
+
log.info("Commit aborted — squash cancelled.")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
else:
|
|
103
|
+
if unstaged:
|
|
104
|
+
log.error(
|
|
105
|
+
"Unstaged changes present. Please stage or discard them before squashing."
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
log.info("Working tree clean — proceeding to squash history.")
|
|
109
|
+
|
|
110
|
+
# 2) Determine branch base
|
|
111
|
+
merge_base = _get_branch_base()
|
|
112
|
+
|
|
113
|
+
# 3) Summarize commit history
|
|
114
|
+
commit_log = subprocess.check_output(
|
|
115
|
+
["git", "--no-pager", "log", f"{merge_base}..HEAD", "--pretty=format:%B"],
|
|
116
|
+
text=True,
|
|
117
|
+
).strip()
|
|
118
|
+
|
|
119
|
+
if not commit_log:
|
|
120
|
+
log.info("Nothing to squash — branch contains only one commit.")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
log.info("Summarizing commit history using LLM...")
|
|
124
|
+
summary_message = generator.summarize_commit_history(commit_log)
|
|
125
|
+
|
|
126
|
+
# 4) Let user edit the summary without making a commit yet
|
|
127
|
+
log.info(
|
|
128
|
+
"Opening editor for final squash commit message. Save = continue, exit w/o save = cancel..."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# write to temp file
|
|
132
|
+
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tf:
|
|
133
|
+
tf.write(summary_message)
|
|
134
|
+
tf.flush()
|
|
135
|
+
tf_name = Path(tf.name)
|
|
136
|
+
|
|
137
|
+
original_hash = sha256_of_file(tf_name)
|
|
138
|
+
|
|
139
|
+
editor = get_git_editor()
|
|
140
|
+
parts = shlex.split(editor)
|
|
141
|
+
if not shutil.which(parts[0]):
|
|
142
|
+
# This editor command requires shell interpretation
|
|
143
|
+
rc = subprocess.run(
|
|
144
|
+
f'{editor} "{tf_name}"', shell=True, check=False # nosemgrep
|
|
145
|
+
).returncode # nosec # nosemgrep
|
|
146
|
+
else:
|
|
147
|
+
rc = subprocess.run(parts + [str(tf_name)], check=False).returncode
|
|
148
|
+
|
|
149
|
+
if rc != 0:
|
|
150
|
+
log.info("Editor exited non-zero — squash cancelled.")
|
|
151
|
+
tf_name.unlink(missing_ok=True)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
new_hash = sha256_of_file(tf_name)
|
|
155
|
+
|
|
156
|
+
if new_hash == original_hash:
|
|
157
|
+
log.info("Squash cancelled (user did not save message).")
|
|
158
|
+
tf_name.unlink(missing_ok=True)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# User saved → read message into final_message
|
|
162
|
+
final_message = tf_name.read_text(encoding="utf-8").strip()
|
|
163
|
+
tf_name.unlink(missing_ok=True)
|
|
164
|
+
|
|
165
|
+
# 5) Perform squash
|
|
166
|
+
subprocess.run(["git", "reset", "--soft", merge_base], check=True)
|
|
167
|
+
subprocess.run(["git", "commit", "-m", final_message], check=True)
|
|
168
|
+
|
|
169
|
+
log.info("🎉 Branch successfully squashed into one commit. ✅")
|
|
170
|
+
|
|
171
|
+
if _has_upstream():
|
|
172
|
+
log.warning(
|
|
173
|
+
"Your branch has a remote upstream.\n"
|
|
174
|
+
"Since squashing rewrites commit history, your next push will require:\n\n"
|
|
175
|
+
" git push --force-with-lease\n\n"
|
|
176
|
+
"This is a safe force-push that prevents overwriting others' commits."
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
log.info(
|
|
180
|
+
"No upstream branch detected. Normal `git push` will work as expected."
|
|
181
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-cai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Use LLM to create git commit messages
|
|
5
5
|
Author-email: Thorsten Foltz <thorsten.foltz@live.com>
|
|
6
6
|
License: MIT
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
19
|
Classifier: Operating System :: OS Independent
|
|
19
20
|
Classifier: Topic :: Software Development :: Version Control
|
|
20
21
|
Requires-Python: >=3.10
|
|
@@ -64,6 +65,7 @@ Currently, the only supported backends are the OpenAI and Gemini APIs, but addit
|
|
|
64
65
|
- Generates meaningful, context-aware commit messages using an LLM
|
|
65
66
|
- Seamless integration with Git as a plugin/extension
|
|
66
67
|
- Supports different LLM models and languages for each repository, as well as global configuration
|
|
68
|
+
- Allows to squash all commits in a branch and summarizes the commit messages
|
|
67
69
|
|
|
68
70
|
<h2 id="installation-section">Installation</h2>
|
|
69
71
|
|
|
@@ -89,7 +91,10 @@ Once installed, cai works like a standard Git command:
|
|
|
89
91
|
git cai
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
`cai` uses Git’s `diff` output
|
|
94
|
+
`cai` uses Git’s `diff` output to generate commit messages. The generated message is then opened in your editor, allowing you to review and edit it before confirming the commit.
|
|
95
|
+
|
|
96
|
+
In short: it behaves like `git commit`, but the commit message is pre-filled for you.
|
|
97
|
+
|
|
93
98
|
To exclude specific files or directories from being included in the generated commit message, create a `.caiignore` file in the root of your repository. This file works like a `.gitignore`.
|
|
94
99
|
|
|
95
100
|
- Files listed in `.gitignore` are **always excluded**.
|
|
@@ -133,10 +138,11 @@ Currently, the following options can be customized:
|
|
|
133
138
|
Besides running `git cai` to generate commit messages, you can use the following options:
|
|
134
139
|
|
|
135
140
|
- `-h` shows a brief help message with available commands
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
141
|
+
- `-d`, `--debug` enables debug logging to help troubleshoot issues
|
|
142
|
+
- `-l`, `--languages` list available languages
|
|
143
|
+
- `-s`, `--squash` Squash commits on this branch and summarize them
|
|
144
|
+
- `-u`, `--update` checks for updates the `cai` tool
|
|
145
|
+
- `-v`, `--version` displays the currently installed version
|
|
140
146
|
|
|
141
147
|
<h2 id="license-section">License</h2>
|
|
142
148
|
This project is licensed under the MIT License.
|
|
@@ -20,6 +20,7 @@ uv.lock
|
|
|
20
20
|
.linters/check_git_branch_name.sh
|
|
21
21
|
.linters/lychee.toml
|
|
22
22
|
.linters/pyrightconfig.json
|
|
23
|
+
archlinux/.SRCINFO
|
|
23
24
|
archlinux/PKGBUILD
|
|
24
25
|
src/git_cai_cli/__init__.py
|
|
25
26
|
src/git_cai_cli/cli.py
|
|
@@ -35,4 +36,5 @@ src/git_cai_cli/core/gitutils.py
|
|
|
35
36
|
src/git_cai_cli/core/languages.py
|
|
36
37
|
src/git_cai_cli/core/llm.py
|
|
37
38
|
src/git_cai_cli/core/options.py
|
|
38
|
-
src/
|
|
39
|
+
src/git_cai_cli/core/squash.py
|
|
40
|
+
tests/test_core/test_config.py
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Check git repo and run git diff
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Callable
|
|
10
|
-
|
|
11
|
-
log = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def find_git_root(
|
|
15
|
-
run_cmd: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
16
|
-
) -> Path | None:
|
|
17
|
-
"""Returns the root directory of the current Git repository, or None if not in a Git repo."""
|
|
18
|
-
try:
|
|
19
|
-
result = run_cmd(
|
|
20
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
21
|
-
capture_output=True,
|
|
22
|
-
text=True,
|
|
23
|
-
check=True,
|
|
24
|
-
)
|
|
25
|
-
return Path(result.stdout.strip())
|
|
26
|
-
except subprocess.CalledProcessError:
|
|
27
|
-
return None
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def git_diff_excluding(
|
|
31
|
-
repo_root: Path,
|
|
32
|
-
run_cmd: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
33
|
-
exit_func: Callable[[int], None] = sys.exit,
|
|
34
|
-
) -> str:
|
|
35
|
-
"""Run `git diff` excluding files listed in .caiignore."""
|
|
36
|
-
ignore_file = repo_root / ".caiignore"
|
|
37
|
-
|
|
38
|
-
exclude_files: list[str] = []
|
|
39
|
-
if ignore_file.exists():
|
|
40
|
-
with open(ignore_file, "r", encoding="utf-8") as f:
|
|
41
|
-
exclude_files = [
|
|
42
|
-
line.strip()
|
|
43
|
-
for line in f
|
|
44
|
-
if line.strip() and not line.strip().startswith("#")
|
|
45
|
-
]
|
|
46
|
-
if not exclude_files:
|
|
47
|
-
log.info("%s is empty. No files excluded.", ignore_file)
|
|
48
|
-
|
|
49
|
-
cmd = ["git", "diff", "--cached", "--", "."]
|
|
50
|
-
cmd.extend(f":!{pattern}" for pattern in exclude_files)
|
|
51
|
-
|
|
52
|
-
result = run_cmd(cmd, capture_output=True, text=True, check=True)
|
|
53
|
-
if result.returncode != 0:
|
|
54
|
-
log.error("git diff failed: %s", result.stderr.strip())
|
|
55
|
-
exit_func(1)
|
|
56
|
-
|
|
57
|
-
return result.stdout
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Settings and connection of LLM
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
from typing import Any, Dict, Type
|
|
7
|
-
|
|
8
|
-
from git_cai_cli.core.languages import LANGUAGE_MAP
|
|
9
|
-
from google import genai # type: ignore[reportUnknownImport]
|
|
10
|
-
from google.genai import types # type: ignore[reportUnknownImport]
|
|
11
|
-
from openai import OpenAI
|
|
12
|
-
|
|
13
|
-
log = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class CommitMessageGenerator:
|
|
17
|
-
"""
|
|
18
|
-
Generates git commit messages from a git diff using LLMs.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
def __init__(self, token: str, config: Dict[str, Any], default_model: str):
|
|
22
|
-
self.token = token
|
|
23
|
-
self.config = config
|
|
24
|
-
self.default_model = default_model
|
|
25
|
-
|
|
26
|
-
def generate(self, git_diff: str) -> str:
|
|
27
|
-
"""
|
|
28
|
-
Generate a commit message using the default model.
|
|
29
|
-
"""
|
|
30
|
-
model_dispatch = {
|
|
31
|
-
"openai": self.generate_openai,
|
|
32
|
-
"gemini": self.generate_gemini,
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
try:
|
|
36
|
-
log.debug("Generating commit message using model '%s'", self.default_model)
|
|
37
|
-
return model_dispatch[self.default_model](git_diff)
|
|
38
|
-
except KeyError as e:
|
|
39
|
-
log.error("Unknown default model: '%s'", self.default_model)
|
|
40
|
-
raise ValueError(f"Unknown default model: '{self.default_model}'") from e
|
|
41
|
-
|
|
42
|
-
def _system_prompt(self, language_name: str) -> str:
|
|
43
|
-
"""
|
|
44
|
-
Shared system prompt for both OpenAI and Gemini.
|
|
45
|
-
"""
|
|
46
|
-
return (
|
|
47
|
-
"You are an expert software engineer assistant. "
|
|
48
|
-
"Your task is to generate a concise, professional git commit message. "
|
|
49
|
-
f"Summarizing the provided git diff changes in {language_name}. "
|
|
50
|
-
"Keep the message clear and focused on what was changed and why. "
|
|
51
|
-
"Always include a headline, followed by a bullet-point list of changes. "
|
|
52
|
-
"Should you observe any sensitive information in the diff, print 'SENSITIVE INFORMATION DETECTED' "
|
|
53
|
-
"and show what was detected, the file where and the line number. "
|
|
54
|
-
"But even if you see sensitive information, still generate the commit message. "
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
def generate_openai(self, git_diff: str, openai_cls: Type[Any] = OpenAI) -> str:
|
|
58
|
-
"""
|
|
59
|
-
Generate a commit message using OpenAI's API.
|
|
60
|
-
"""
|
|
61
|
-
client = openai_cls(api_key=self.token)
|
|
62
|
-
model = self.config["openai"]["model"]
|
|
63
|
-
temperature = self.config["openai"]["temperature"]
|
|
64
|
-
language = self.config["language"]
|
|
65
|
-
language_name = self._language_name(language, LANGUAGE_MAP)
|
|
66
|
-
|
|
67
|
-
system_prompt = self._system_prompt(language_name)
|
|
68
|
-
|
|
69
|
-
messages = [
|
|
70
|
-
{"role": "system", "content": system_prompt},
|
|
71
|
-
{
|
|
72
|
-
"role": "user",
|
|
73
|
-
"content": f"Generate a commit message for:\n\n{git_diff}",
|
|
74
|
-
},
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
completion = client.chat.completions.create(
|
|
78
|
-
model=model,
|
|
79
|
-
messages=messages,
|
|
80
|
-
temperature=temperature,
|
|
81
|
-
)
|
|
82
|
-
return completion.choices[0].message.content.strip()
|
|
83
|
-
|
|
84
|
-
def generate_gemini(
|
|
85
|
-
self, git_diff: str, genai_cls: Type[Any] = genai.Client
|
|
86
|
-
) -> str:
|
|
87
|
-
"""
|
|
88
|
-
Generate a commit message using Gemini's API.
|
|
89
|
-
"""
|
|
90
|
-
client = genai_cls(api_key=self.token)
|
|
91
|
-
model = self.config["gemini"]["model"]
|
|
92
|
-
temperature = self.config["gemini"]["temperature"]
|
|
93
|
-
language = self.config["language"]
|
|
94
|
-
language_name = self._language_name(language, LANGUAGE_MAP)
|
|
95
|
-
system_prompt = self._system_prompt(language_name)
|
|
96
|
-
messages = git_diff
|
|
97
|
-
response = client.models.generate_content(
|
|
98
|
-
model=model,
|
|
99
|
-
contents=messages,
|
|
100
|
-
config=types.GenerateContentConfig(
|
|
101
|
-
system_instruction=system_prompt,
|
|
102
|
-
temperature=temperature,
|
|
103
|
-
),
|
|
104
|
-
)
|
|
105
|
-
return response.text
|
|
106
|
-
|
|
107
|
-
def _language_name(self, lang_code: str, allowed_languages: dict[str, str]) -> str:
|
|
108
|
-
"""
|
|
109
|
-
Convert ISO 639-1 code to human-readable language name.
|
|
110
|
-
Defaults to English if not found.
|
|
111
|
-
"""
|
|
112
|
-
return allowed_languages.get(lang_code, "English")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|