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.
Files changed (44) hide show
  1. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.pylintrc +4 -4
  2. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/PKG-INFO +12 -6
  3. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/README.md +10 -5
  4. git_cai_cli-0.3.0/archlinux/.SRCINFO +21 -0
  5. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/archlinux/PKGBUILD +4 -4
  6. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/pyproject.toml +1 -0
  7. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/cli.py +17 -4
  8. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/config.py +1 -1
  9. git_cai_cli-0.3.0/src/git_cai_cli/core/gitutils.py +156 -0
  10. git_cai_cli-0.3.0/src/git_cai_cli/core/llm.py +152 -0
  11. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/options.py +15 -4
  12. git_cai_cli-0.3.0/src/git_cai_cli/core/squash.py +181 -0
  13. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/PKG-INFO +12 -6
  14. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/SOURCES.txt +3 -1
  15. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/top_level.txt +0 -1
  16. git_cai_cli-0.2.0/src/git_cai_cli/core/gitutils.py +0 -57
  17. git_cai_cli-0.2.0/src/git_cai_cli/core/llm.py +0 -112
  18. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.caiignore +0 -0
  19. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.gitignore +0 -0
  20. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.bandit.yml +0 -0
  21. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.checkov.yml +0 -0
  22. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.flake8 +0 -0
  23. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.ls-lint.yml +0 -0
  24. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.markdown-link-check.json +0 -0
  25. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.markdownlint.json +0 -0
  26. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.proselintrc +0 -0
  27. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/.yamllint.yml +0 -0
  28. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/check_git_branch_name.sh +0 -0
  29. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/lychee.toml +0 -0
  30. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.linters/pyrightconfig.json +0 -0
  31. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.mega-linter.yml +0 -0
  32. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.semgrepignore +0 -0
  33. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/.trivyignore +0 -0
  34. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/LICENSE +0 -0
  35. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/Makefile +0 -0
  36. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/setup.cfg +0 -0
  37. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/__init__.py +0 -0
  38. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/__init__.py +0 -0
  39. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli/core/languages.py +0 -0
  40. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
  41. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
  42. {git_cai_cli-0.2.0 → git_cai_cli-0.3.0}/src/git_cai_cli.egg-info/requires.txt +0 -0
  43. {git_cai_cli-0.2.0/src → git_cai_cli-0.3.0}/tests/test_core/test_config.py +0 -0
  44. {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=12 # Max branches per function
71
+ max-branches=20 # Max branches per function
72
72
  max-locals=30 # Max locals per function
73
- max-returns=6 # Max return statements per function
74
- max-statements=50 # Max total statements per function
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 # Max positional arguments per function
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.2.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 as input for generating commit messages.
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
- - `d`, `--debug` enables debug logging to help troubleshoot issues
137
- - `l`, `--languages` list available languages
138
- - `u`, `--update` checks for updates the `cai` tool
139
- - `v`, `--version` displays the currently installed version
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 as input for generating commit messages.
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
- - `d`, `--debug` enables debug logging to help troubleshoot issues
108
- - `l`, `--languages` list available languages
109
- - `u`, `--update` checks for updates the `cai` tool
110
- - `v`, `--version` displays the currently installed version
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.1.1
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/e6/c3/99bb2d87c16628017468c9ae6dd18ee2e63644109150bee0450a9f22b83f/git_cai_cli-0.1.1.tar.gz")
12
- sha256sums=('36fb55223ad430b7d3dc6bf60fd4e04f6ac567acee5546d5aa8c2df781061461')
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 find_git_root, git_diff_excluding
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
- subprocess.run(["git", "commit", "--edit", "-m", commit_message], check=True)
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
- typer.echo(manager.enable_debug())
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("Default config value: %s", default_value)
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 Generates commit message
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
- for code, name in sorted(self.allowed_languages.items()):
177
- lines.append(f" - {code} {name}")
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.2.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 as input for generating commit messages.
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
- - `d`, `--debug` enables debug logging to help troubleshoot issues
137
- - `l`, `--languages` list available languages
138
- - `u`, `--update` checks for updates the `cai` tool
139
- - `v`, `--version` displays the currently installed version
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/tests/test_core/test_config.py
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