commitar 1.0.0__py3-none-any.whl
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.
- commitar/__init__.py +3 -0
- commitar/ai.py +150 -0
- commitar/cli.py +302 -0
- commitar/config.py +141 -0
- commitar/errors.py +14 -0
- commitar/git.py +90 -0
- commitar/scope.py +47 -0
- commitar-1.0.0.dist-info/METADATA +292 -0
- commitar-1.0.0.dist-info/RECORD +13 -0
- commitar-1.0.0.dist-info/WHEEL +5 -0
- commitar-1.0.0.dist-info/entry_points.txt +2 -0
- commitar-1.0.0.dist-info/licenses/LICENSE +21 -0
- commitar-1.0.0.dist-info/top_level.txt +1 -0
commitar/__init__.py
ADDED
commitar/ai.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from math import ceil
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from .config import Settings
|
|
11
|
+
from .errors import AIProviderError, AITimeoutError, CommitarError, InvalidAIMessageError
|
|
12
|
+
|
|
13
|
+
CONVENTIONAL = re.compile(
|
|
14
|
+
r"^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)\r\n]+\))?!?: .+\S$"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prompt(diff: str, language: str) -> str:
|
|
19
|
+
return (
|
|
20
|
+
"Generate only one valid Conventional Commit message, without quotation marks, "
|
|
21
|
+
f"in {language}. Use one line in the type(optional scope): description format.\n\nDIFF:\n{diff}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def estimate_tokens(text: str) -> int:
|
|
26
|
+
"""Conservatively estimate tokens without depending on a provider tokenizer."""
|
|
27
|
+
return ceil(len(text.encode("utf-8")) / 3)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_input(body: str, settings: Settings) -> None:
|
|
31
|
+
estimated_tokens = estimate_tokens(body)
|
|
32
|
+
if estimated_tokens > settings.max_input_tokens:
|
|
33
|
+
raise CommitarError(
|
|
34
|
+
"AI input exceeds max_input_tokens "
|
|
35
|
+
f"({estimated_tokens} estimated tokens > {settings.max_input_tokens}). "
|
|
36
|
+
"Increase the limit or narrow the scope."
|
|
37
|
+
)
|
|
38
|
+
if estimated_tokens + settings.max_output_tokens > settings.context_window_tokens:
|
|
39
|
+
raise CommitarError(
|
|
40
|
+
"AI input and reserved output exceed context_window_tokens "
|
|
41
|
+
f"({estimated_tokens} + {settings.max_output_tokens} > "
|
|
42
|
+
f"{settings.context_window_tokens}). Increase the context window or narrow the scope."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _post(url: str, payload: dict, headers: dict[str, str], timeout_seconds: int) -> dict:
|
|
47
|
+
request = Request(url, data=json.dumps(payload).encode(), headers=headers, method="POST")
|
|
48
|
+
try:
|
|
49
|
+
with urlopen(request, timeout=timeout_seconds) as response: # nosec B310 - configured endpoint
|
|
50
|
+
return json.loads(response.read())
|
|
51
|
+
except TimeoutError as exc:
|
|
52
|
+
raise AITimeoutError(f"Unable to contact the AI provider: {exc}") from exc
|
|
53
|
+
except URLError as exc:
|
|
54
|
+
if isinstance(exc.reason, TimeoutError):
|
|
55
|
+
raise AITimeoutError(f"Unable to contact the AI provider: {exc.reason}") from exc
|
|
56
|
+
raise AIProviderError(f"Unable to contact the AI provider: {exc}") from exc
|
|
57
|
+
except json.JSONDecodeError as exc:
|
|
58
|
+
raise AIProviderError(f"Unable to contact the AI provider: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def generate(diff: str, settings: Settings) -> str:
|
|
62
|
+
body = prompt(diff, settings.language)
|
|
63
|
+
_validate_input(body, settings)
|
|
64
|
+
if settings.provider == "openai":
|
|
65
|
+
key = os.getenv("OPENAI_API_KEY")
|
|
66
|
+
if not key:
|
|
67
|
+
raise CommitarError("OPENAI_API_KEY is not configured.")
|
|
68
|
+
data = _post(
|
|
69
|
+
settings.endpoint or "https://api.openai.com/v1/chat/completions",
|
|
70
|
+
{
|
|
71
|
+
"model": settings.model,
|
|
72
|
+
"messages": [{"role": "user", "content": body}],
|
|
73
|
+
"max_tokens": settings.max_output_tokens,
|
|
74
|
+
"temperature": settings.temperature,
|
|
75
|
+
},
|
|
76
|
+
{"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
77
|
+
settings.timeout_seconds,
|
|
78
|
+
)
|
|
79
|
+
answer = data["choices"][0]["message"]["content"]
|
|
80
|
+
elif settings.provider == "gemini":
|
|
81
|
+
key = os.getenv("GEMINI_API_KEY")
|
|
82
|
+
if not key:
|
|
83
|
+
raise CommitarError("GEMINI_API_KEY is not configured.")
|
|
84
|
+
url = (
|
|
85
|
+
settings.endpoint
|
|
86
|
+
or f"https://generativelanguage.googleapis.com/v1beta/models/{settings.model}:generateContent?key={key}"
|
|
87
|
+
)
|
|
88
|
+
data = _post(
|
|
89
|
+
url,
|
|
90
|
+
{
|
|
91
|
+
"contents": [{"parts": [{"text": body}]}],
|
|
92
|
+
"generationConfig": {
|
|
93
|
+
"maxOutputTokens": settings.max_output_tokens,
|
|
94
|
+
"temperature": settings.temperature,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{"Content-Type": "application/json"},
|
|
98
|
+
settings.timeout_seconds,
|
|
99
|
+
)
|
|
100
|
+
answer = data["candidates"][0]["content"]["parts"][0]["text"]
|
|
101
|
+
else:
|
|
102
|
+
data = _post(
|
|
103
|
+
settings.endpoint or "http://localhost:11434/api/generate",
|
|
104
|
+
{
|
|
105
|
+
"model": settings.model,
|
|
106
|
+
"prompt": body,
|
|
107
|
+
"stream": False,
|
|
108
|
+
"options": {
|
|
109
|
+
"temperature": settings.temperature,
|
|
110
|
+
"num_ctx": settings.context_window_tokens,
|
|
111
|
+
"num_predict": settings.max_output_tokens,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{"Content-Type": "application/json"},
|
|
115
|
+
settings.timeout_seconds,
|
|
116
|
+
)
|
|
117
|
+
answer = data["response"]
|
|
118
|
+
answer = answer.strip()
|
|
119
|
+
if not CONVENTIONAL.fullmatch(answer):
|
|
120
|
+
raise InvalidAIMessageError(
|
|
121
|
+
"The AI returned an invalid commit message; this commit was skipped."
|
|
122
|
+
)
|
|
123
|
+
return answer
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def message_for(diff: str, settings: Settings, supplied: str | None) -> str:
|
|
127
|
+
if supplied:
|
|
128
|
+
if not CONVENTIONAL.fullmatch(supplied):
|
|
129
|
+
raise CommitarError("`--message` must be a single-line Conventional Commit message.")
|
|
130
|
+
return supplied
|
|
131
|
+
encoded = diff.encode()
|
|
132
|
+
if (
|
|
133
|
+
len(encoded) <= settings.max_diff_bytes
|
|
134
|
+
and estimate_tokens(prompt(diff, settings.language)) <= settings.max_input_tokens
|
|
135
|
+
):
|
|
136
|
+
return generate(diff, settings)
|
|
137
|
+
# Deterministic summary; oversized input is never silently truncated.
|
|
138
|
+
summary = "\n".join(
|
|
139
|
+
line for line in diff.splitlines() if line.startswith(("diff --git", "--- ", "+++ ", "@@"))
|
|
140
|
+
)
|
|
141
|
+
context = "Deterministic summary due to the diff limit:\n" + summary
|
|
142
|
+
context_bytes = len(context.encode())
|
|
143
|
+
context_tokens = estimate_tokens(prompt(context, settings.language))
|
|
144
|
+
if context_bytes > settings.max_diff_bytes or context_tokens > settings.max_input_tokens:
|
|
145
|
+
raise CommitarError(
|
|
146
|
+
"The deterministic diff summary still exceeds the configured AI input limits "
|
|
147
|
+
f"({context_bytes} bytes, {context_tokens} estimated tokens). "
|
|
148
|
+
"Increase max_diff_bytes/max_input_tokens or narrow the scope."
|
|
149
|
+
)
|
|
150
|
+
return generate(context, settings)
|
commitar/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from threading import Event, Thread
|
|
9
|
+
from time import perf_counter, sleep
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from .ai import message_for
|
|
15
|
+
from .config import TEMPLATE, load
|
|
16
|
+
from .errors import AIProviderError, AITimeoutError, CommitarError, InvalidAIMessageError
|
|
17
|
+
from .git import GitRepo
|
|
18
|
+
from .scope import Group, resolve
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(add_completion=False, no_args_is_help=False)
|
|
21
|
+
config_app = typer.Typer(
|
|
22
|
+
help="Create and inspect Commitar configuration files.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
MAX_AI_ATTEMPTS = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _loading(message: str):
|
|
30
|
+
"""Show a spinner only when the CLI is attached to an interactive terminal."""
|
|
31
|
+
typer.echo(err=True)
|
|
32
|
+
if not sys.stderr.isatty():
|
|
33
|
+
typer.secho(message, fg=typer.colors.CYAN, err=True)
|
|
34
|
+
yield
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
stop = Event()
|
|
38
|
+
|
|
39
|
+
def spin() -> None:
|
|
40
|
+
for frame in "|/-\\":
|
|
41
|
+
if stop.is_set():
|
|
42
|
+
break
|
|
43
|
+
typer.secho(
|
|
44
|
+
f"\r{message} {frame}",
|
|
45
|
+
fg=typer.colors.CYAN,
|
|
46
|
+
nl=False,
|
|
47
|
+
err=True,
|
|
48
|
+
)
|
|
49
|
+
sleep(0.12)
|
|
50
|
+
|
|
51
|
+
def loop() -> None:
|
|
52
|
+
while not stop.is_set():
|
|
53
|
+
spin()
|
|
54
|
+
|
|
55
|
+
worker = Thread(target=loop, daemon=True)
|
|
56
|
+
worker.start()
|
|
57
|
+
try:
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
stop.set()
|
|
61
|
+
worker.join(timeout=1)
|
|
62
|
+
typer.echo("\r" + " " * (len(message) + 2) + "\r", nl=False, err=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _show(
|
|
66
|
+
group: Group,
|
|
67
|
+
message: str,
|
|
68
|
+
position: int,
|
|
69
|
+
total: int,
|
|
70
|
+
generation_seconds: float | None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Render a compact, readable preview for one commit group."""
|
|
73
|
+
typer.echo()
|
|
74
|
+
typer.secho(f"Commit preview {position}/{total}", fg=typer.colors.CYAN, bold=True)
|
|
75
|
+
typer.secho("─" * 52, fg=typer.colors.BRIGHT_BLACK)
|
|
76
|
+
typer.echo(f"Source: {group.source}")
|
|
77
|
+
typer.echo(f"Files ({len(group.files)}):")
|
|
78
|
+
for file in group.files:
|
|
79
|
+
typer.secho(f" • {file}", fg=typer.colors.WHITE)
|
|
80
|
+
typer.echo("Message:")
|
|
81
|
+
typer.secho(f" {message}", fg=typer.colors.GREEN, bold=True)
|
|
82
|
+
if generation_seconds is not None:
|
|
83
|
+
typer.secho(f"Generated in {generation_seconds:.2f}s", fg=typer.colors.BRIGHT_BLACK)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _show_skipped(group: Group, position: int, total: int, reason: str) -> None:
|
|
87
|
+
"""Render a recoverable generation failure without stopping later groups."""
|
|
88
|
+
typer.echo()
|
|
89
|
+
typer.secho(f"Skipped commit {position}/{total}", fg=typer.colors.YELLOW, bold=True)
|
|
90
|
+
typer.secho("─" * 52, fg=typer.colors.BRIGHT_BLACK)
|
|
91
|
+
typer.echo(f"Reason: {reason}")
|
|
92
|
+
typer.echo(f"Files ({len(group.files)}):")
|
|
93
|
+
for file in group.files:
|
|
94
|
+
typer.echo(f" • {file}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def commitar(
|
|
99
|
+
path: Optional[Path] = typer.Argument(
|
|
100
|
+
None,
|
|
101
|
+
metavar="[PATH]",
|
|
102
|
+
help="A file or directory inside the repository. Without PATH, creates one group per modified file.",
|
|
103
|
+
),
|
|
104
|
+
output: Optional[str] = typer.Option(
|
|
105
|
+
None, "--output", help="Operation mode: preview (no changes) or commit (creates commits)."
|
|
106
|
+
),
|
|
107
|
+
dry_run: bool = typer.Option(
|
|
108
|
+
False, "--dry-run", help="Alias for --output preview; never changes the repository."
|
|
109
|
+
),
|
|
110
|
+
yes: bool = typer.Option(
|
|
111
|
+
False, "--yes", "-y", help="Skip the confirmation prompt in commit mode."
|
|
112
|
+
),
|
|
113
|
+
message: Optional[str] = typer.Option(
|
|
114
|
+
None, "--message", help="Use this Conventional Commit message instead of calling the AI."
|
|
115
|
+
),
|
|
116
|
+
include_added: Optional[bool] = typer.Option(
|
|
117
|
+
None,
|
|
118
|
+
"--include-added/--no-include-added",
|
|
119
|
+
help="Include untracked files. Disabled by default.",
|
|
120
|
+
),
|
|
121
|
+
provider: Optional[str] = typer.Option(
|
|
122
|
+
None, "--provider", help="AI provider: openai, gemini, or ollama."
|
|
123
|
+
),
|
|
124
|
+
model: Optional[str] = typer.Option(
|
|
125
|
+
None, "--model", help="Model name for the selected AI provider."
|
|
126
|
+
),
|
|
127
|
+
timeout_seconds: Optional[int] = typer.Option(
|
|
128
|
+
None, "--timeout-seconds", min=1, help="AI request timeout in seconds; defaults to 60."
|
|
129
|
+
),
|
|
130
|
+
max_input_tokens: Optional[int] = typer.Option(
|
|
131
|
+
None,
|
|
132
|
+
"--max-input-tokens",
|
|
133
|
+
min=1,
|
|
134
|
+
help="Maximum estimated tokens sent to the AI, including prompt instructions.",
|
|
135
|
+
),
|
|
136
|
+
context_window_tokens: Optional[int] = typer.Option(
|
|
137
|
+
None,
|
|
138
|
+
"--context-window-tokens",
|
|
139
|
+
min=1,
|
|
140
|
+
help="Model context window shared by input and reserved output tokens.",
|
|
141
|
+
),
|
|
142
|
+
config: Optional[Path] = typer.Option(
|
|
143
|
+
None,
|
|
144
|
+
"--config",
|
|
145
|
+
help="Path to a TOML file that overrides repository and user configuration.",
|
|
146
|
+
),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Suggest or create Conventional Commits while keeping staging isolated.
|
|
149
|
+
|
|
150
|
+
When staging already contains changes, run without PATH: Commitar uses only the
|
|
151
|
+
index and creates one commit. A PATH together with staged changes fails safely.
|
|
152
|
+
Without staging, PATH groups a file or directory into one commit; no PATH creates
|
|
153
|
+
one group per modified file. Use `commitar config --help` for configuration commands.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
repo = GitRepo()
|
|
157
|
+
settings = load(
|
|
158
|
+
repo.root,
|
|
159
|
+
config,
|
|
160
|
+
output="preview" if dry_run else output,
|
|
161
|
+
include_added=include_added,
|
|
162
|
+
provider=provider,
|
|
163
|
+
model=model,
|
|
164
|
+
timeout_seconds=timeout_seconds,
|
|
165
|
+
max_input_tokens=max_input_tokens,
|
|
166
|
+
context_window_tokens=context_window_tokens,
|
|
167
|
+
)
|
|
168
|
+
scope = resolve(repo, path, settings.include_added)
|
|
169
|
+
if any(len(group.files) > settings.max_files_per_request for group in scope.groups):
|
|
170
|
+
raise CommitarError(
|
|
171
|
+
"The scope exceeds max_files_per_request; narrow the path or increase the limit."
|
|
172
|
+
)
|
|
173
|
+
completed: list[str] = []
|
|
174
|
+
skipped: list[list[str]] = []
|
|
175
|
+
total_groups = len(scope.groups)
|
|
176
|
+
for position, group in enumerate(scope.groups, start=1):
|
|
177
|
+
diff = repo.staged_diff() if scope.staged else repo.working_diff(group.files)
|
|
178
|
+
generation_seconds: float | None = None
|
|
179
|
+
files_in_progress = ", ".join(group.files)
|
|
180
|
+
try:
|
|
181
|
+
if message:
|
|
182
|
+
final_message = message_for(diff, settings, message)
|
|
183
|
+
else:
|
|
184
|
+
started_at = perf_counter()
|
|
185
|
+
model_candidates = settings.model_candidates
|
|
186
|
+
for attempt in range(1, MAX_AI_ATTEMPTS + 1):
|
|
187
|
+
model = model_candidates[min(attempt - 1, len(model_candidates) - 1)]
|
|
188
|
+
attempt_settings = replace(settings, model=model, models=())
|
|
189
|
+
try:
|
|
190
|
+
with _loading(
|
|
191
|
+
"Generating commit message for "
|
|
192
|
+
f"[{files_in_progress}] with model [{model}]... "
|
|
193
|
+
f"({attempt}/{MAX_AI_ATTEMPTS})"
|
|
194
|
+
):
|
|
195
|
+
final_message = message_for(diff, attempt_settings, None)
|
|
196
|
+
break
|
|
197
|
+
except (InvalidAIMessageError, AITimeoutError) as exc:
|
|
198
|
+
if attempt == MAX_AI_ATTEMPTS:
|
|
199
|
+
raise
|
|
200
|
+
next_model = model_candidates[min(attempt, len(model_candidates) - 1)]
|
|
201
|
+
reason = (
|
|
202
|
+
"AI request timed out"
|
|
203
|
+
if isinstance(exc, AITimeoutError)
|
|
204
|
+
else "Invalid AI message"
|
|
205
|
+
)
|
|
206
|
+
typer.secho(
|
|
207
|
+
f"{reason} with model [{model}]. "
|
|
208
|
+
f"Retrying with model [{next_model}] "
|
|
209
|
+
f"({attempt + 1}/{MAX_AI_ATTEMPTS})...",
|
|
210
|
+
fg=typer.colors.YELLOW,
|
|
211
|
+
)
|
|
212
|
+
generation_seconds = perf_counter() - started_at
|
|
213
|
+
except InvalidAIMessageError as exc:
|
|
214
|
+
skipped.append(group.files)
|
|
215
|
+
_show_skipped(group, position, total_groups, str(exc))
|
|
216
|
+
continue
|
|
217
|
+
except AIProviderError as exc:
|
|
218
|
+
raise AIProviderError(f"{exc}. Files in progress: [{files_in_progress}].") from exc
|
|
219
|
+
_show(group, final_message, position, total_groups, generation_seconds)
|
|
220
|
+
if settings.output == "preview":
|
|
221
|
+
continue
|
|
222
|
+
if not yes and settings.confirm:
|
|
223
|
+
if not sys.stdin.isatty():
|
|
224
|
+
raise CommitarError(
|
|
225
|
+
"Confirmation requires an interactive terminal; use --yes for automation."
|
|
226
|
+
)
|
|
227
|
+
if not typer.confirm("Create commit?", default=False):
|
|
228
|
+
typer.secho("Cancelled.", fg=typer.colors.YELLOW)
|
|
229
|
+
return
|
|
230
|
+
if not scope.staged and repo.staged_files():
|
|
231
|
+
raise CommitarError(
|
|
232
|
+
"The index changed during the operation; no new commit was created."
|
|
233
|
+
)
|
|
234
|
+
if scope.staged:
|
|
235
|
+
completed.append(repo.commit(final_message))
|
|
236
|
+
else:
|
|
237
|
+
repo.add(group.files)
|
|
238
|
+
try:
|
|
239
|
+
completed.append(repo.commit(final_message))
|
|
240
|
+
except CommitarError:
|
|
241
|
+
repo.unstage(group.files)
|
|
242
|
+
raise
|
|
243
|
+
if completed:
|
|
244
|
+
typer.echo()
|
|
245
|
+
typer.secho("Commit completed", fg=typer.colors.GREEN, bold=True)
|
|
246
|
+
typer.secho("─" * 52, fg=typer.colors.BRIGHT_BLACK)
|
|
247
|
+
typer.echo("Created commits: " + ", ".join(completed))
|
|
248
|
+
if skipped:
|
|
249
|
+
typer.echo()
|
|
250
|
+
typer.secho(
|
|
251
|
+
f"Skipped commits: {len(skipped)}",
|
|
252
|
+
fg=typer.colors.YELLOW,
|
|
253
|
+
bold=True,
|
|
254
|
+
)
|
|
255
|
+
except CommitarError as exc:
|
|
256
|
+
_print_error(str(exc))
|
|
257
|
+
raise typer.Exit(code=1) from exc
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _print_error(message: str) -> None:
|
|
261
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, bold=True, err=True)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@config_app.command("init")
|
|
265
|
+
def config_init(
|
|
266
|
+
path: Path = typer.Option(
|
|
267
|
+
Path(".commitar.toml"), "--path", help="Destination for the configuration template."
|
|
268
|
+
),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Create a configuration template."""
|
|
271
|
+
if path.exists():
|
|
272
|
+
typer.secho(f"Error: {path} already exists.", fg=typer.colors.RED, bold=True, err=True)
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
275
|
+
typer.secho(f"Configuration created at {path}.", fg=typer.colors.GREEN, bold=True)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@config_app.command("show")
|
|
279
|
+
def config_show(
|
|
280
|
+
config: Optional[Path] = typer.Option(
|
|
281
|
+
None, "--config", help="An additional TOML configuration file to apply."
|
|
282
|
+
),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Print the effective configuration without credentials."""
|
|
285
|
+
try:
|
|
286
|
+
repo = GitRepo()
|
|
287
|
+
typer.echo(json.dumps(load(repo.root, config).__dict__, ensure_ascii=False, indent=2))
|
|
288
|
+
except CommitarError as exc:
|
|
289
|
+
typer.secho(f"Error: {exc}", fg=typer.colors.RED, bold=True, err=True)
|
|
290
|
+
raise typer.Exit(1) from exc
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main() -> None:
|
|
294
|
+
"""Dispatch config without making PATH ambiguous for Click."""
|
|
295
|
+
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
296
|
+
config_app(args=sys.argv[2:], prog_name="commitar config")
|
|
297
|
+
else:
|
|
298
|
+
app()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|
commitar/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import CommitarError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
provider: str = "openai"
|
|
15
|
+
model: str = "gpt-5-mini"
|
|
16
|
+
models: tuple[str, ...] = ()
|
|
17
|
+
max_input_tokens: int = 12000
|
|
18
|
+
context_window_tokens: int = 32768
|
|
19
|
+
max_output_tokens: int = 80
|
|
20
|
+
temperature: float = 0.2
|
|
21
|
+
timeout_seconds: int = 60
|
|
22
|
+
endpoint: str | None = None
|
|
23
|
+
language: str = "pt-BR"
|
|
24
|
+
format: str = "conventional"
|
|
25
|
+
include_added: bool = False
|
|
26
|
+
output: str = "preview"
|
|
27
|
+
confirm: bool = True
|
|
28
|
+
max_files_per_request: int = 50
|
|
29
|
+
max_diff_bytes: int = 100000
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def model_candidates(self) -> tuple[str, ...]:
|
|
33
|
+
"""Return models in preference order, preserving legacy single-model config."""
|
|
34
|
+
return self.models or (self.model,)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
TEMPLATE = """[ai]
|
|
38
|
+
provider = "ollama"
|
|
39
|
+
models = ["gemma4:e4b", "qwen2.5-coder:14b"]
|
|
40
|
+
endpoint = "http://localhost:11434/api/generate"
|
|
41
|
+
max_input_tokens = 12000
|
|
42
|
+
context_window_tokens = 32768
|
|
43
|
+
max_output_tokens = 80
|
|
44
|
+
temperature = 0.2
|
|
45
|
+
timeout_seconds = 60
|
|
46
|
+
|
|
47
|
+
[commit]
|
|
48
|
+
language = "pt-BR"
|
|
49
|
+
format = "conventional"
|
|
50
|
+
include_added = true
|
|
51
|
+
output = "commit"
|
|
52
|
+
confirm = true
|
|
53
|
+
|
|
54
|
+
[limits]
|
|
55
|
+
max_files_per_request = 50
|
|
56
|
+
max_diff_bytes = 100000
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
return {}
|
|
63
|
+
try:
|
|
64
|
+
with path.open("rb") as file:
|
|
65
|
+
raw = tomllib.load(file)
|
|
66
|
+
except tomllib.TOMLDecodeError as exc:
|
|
67
|
+
raise CommitarError(f"Invalid TOML configuration in {path}: {exc}") from exc
|
|
68
|
+
return {**raw.get("ai", {}), **raw.get("commit", {}), **raw.get("limits", {})}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _env() -> dict[str, Any]:
|
|
72
|
+
mapping = {
|
|
73
|
+
"COMMITAR_PROVIDER": "provider",
|
|
74
|
+
"COMMITAR_MODEL": "model",
|
|
75
|
+
"COMMITAR_LANGUAGE": "language",
|
|
76
|
+
"COMMITAR_OUTPUT": "output",
|
|
77
|
+
"COMMITAR_ENDPOINT": "endpoint",
|
|
78
|
+
"COMMITAR_MAX_INPUT_TOKENS": "max_input_tokens",
|
|
79
|
+
"COMMITAR_CONTEXT_WINDOW_TOKENS": "context_window_tokens",
|
|
80
|
+
"COMMITAR_MAX_DIFF_BYTES": "max_diff_bytes",
|
|
81
|
+
"COMMITAR_TIMEOUT_SECONDS": "timeout_seconds",
|
|
82
|
+
}
|
|
83
|
+
return {key: os.environ[name] for name, key in mapping.items() if name in os.environ}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load(repo_root: Path, config_path: Path | None = None, **overrides: Any) -> Settings:
|
|
87
|
+
values: dict[str, Any] = asdict(Settings())
|
|
88
|
+
|
|
89
|
+
def apply_layer(layer: dict[str, Any]) -> None:
|
|
90
|
+
if "model" in layer and "models" not in layer:
|
|
91
|
+
values["models"] = ()
|
|
92
|
+
values.update(layer)
|
|
93
|
+
|
|
94
|
+
apply_layer(_read(Path.home() / ".config/commitar/config.toml"))
|
|
95
|
+
apply_layer(_read(repo_root / ".commitar.toml"))
|
|
96
|
+
apply_layer(_env())
|
|
97
|
+
if config_path:
|
|
98
|
+
apply_layer(_read(config_path))
|
|
99
|
+
apply_layer({key: value for key, value in overrides.items() if value is not None})
|
|
100
|
+
|
|
101
|
+
configured_models = values["models"]
|
|
102
|
+
if not isinstance(configured_models, (list, tuple)) or any(
|
|
103
|
+
not isinstance(candidate, str) or not candidate.strip() for candidate in configured_models
|
|
104
|
+
):
|
|
105
|
+
raise CommitarError("`models` must be a list of non-empty model names.")
|
|
106
|
+
if len(configured_models) > 3:
|
|
107
|
+
raise CommitarError("`models` accepts at most three models, matching the retry limit.")
|
|
108
|
+
values["models"] = tuple(configured_models)
|
|
109
|
+
for key in (
|
|
110
|
+
"max_input_tokens",
|
|
111
|
+
"context_window_tokens",
|
|
112
|
+
"max_output_tokens",
|
|
113
|
+
"max_files_per_request",
|
|
114
|
+
"max_diff_bytes",
|
|
115
|
+
"timeout_seconds",
|
|
116
|
+
):
|
|
117
|
+
if isinstance(values[key], str):
|
|
118
|
+
values[key] = int(values[key])
|
|
119
|
+
if isinstance(values["temperature"], str):
|
|
120
|
+
values["temperature"] = float(values["temperature"])
|
|
121
|
+
if values["output"] not in {"preview", "commit"}:
|
|
122
|
+
raise CommitarError("`output` must be `preview` or `commit`.")
|
|
123
|
+
if values["provider"] not in {"openai", "gemini", "ollama"}:
|
|
124
|
+
raise CommitarError("Provider must be openai, gemini, or ollama.")
|
|
125
|
+
if any(
|
|
126
|
+
values[key] <= 0
|
|
127
|
+
for key in (
|
|
128
|
+
"max_input_tokens",
|
|
129
|
+
"context_window_tokens",
|
|
130
|
+
"max_output_tokens",
|
|
131
|
+
"max_files_per_request",
|
|
132
|
+
"max_diff_bytes",
|
|
133
|
+
"timeout_seconds",
|
|
134
|
+
)
|
|
135
|
+
):
|
|
136
|
+
raise CommitarError("Numeric limits and timeout_seconds must be positive.")
|
|
137
|
+
if values["max_input_tokens"] + values["max_output_tokens"] > values["context_window_tokens"]:
|
|
138
|
+
raise CommitarError(
|
|
139
|
+
"`max_input_tokens` + `max_output_tokens` must not exceed `context_window_tokens`."
|
|
140
|
+
)
|
|
141
|
+
return Settings(**values)
|
commitar/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CommitarError(Exception):
|
|
2
|
+
"""Erro esperado, exibido ao usuário sem traceback."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InvalidAIMessageError(CommitarError):
|
|
6
|
+
"""The provider returned text that cannot be used as a commit message."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AIProviderError(CommitarError):
|
|
10
|
+
"""The CLI could not communicate with the configured AI provider."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AITimeoutError(AIProviderError):
|
|
14
|
+
"""The configured AI provider did not respond before the timeout."""
|
commitar/git.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import CommitarError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitRepo:
|
|
10
|
+
def __init__(self, start: Path | None = None) -> None:
|
|
11
|
+
base = str(start or Path.cwd())
|
|
12
|
+
result = self._run("rev-parse", "--show-toplevel", cwd=base)
|
|
13
|
+
self.root = Path(result.stdout.strip()).resolve()
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _run(
|
|
17
|
+
*args: str, cwd: str | None = None, check: bool = True
|
|
18
|
+
) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", *args], cwd=cwd, text=True, capture_output=True, check=False
|
|
21
|
+
)
|
|
22
|
+
if check and result.returncode:
|
|
23
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
24
|
+
raise CommitarError(f"Git command failed: {detail}")
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
def run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
28
|
+
return self._run(*args, cwd=str(self.root), check=check)
|
|
29
|
+
|
|
30
|
+
def staged_files(self) -> list[str]:
|
|
31
|
+
return [
|
|
32
|
+
part
|
|
33
|
+
for part in self.run("diff", "--cached", "--name-only", "-z").stdout.split("\0")
|
|
34
|
+
if part
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def staged_diff(self) -> str:
|
|
38
|
+
return self.run("diff", "--cached", "--binary").stdout
|
|
39
|
+
|
|
40
|
+
def relative_path(self, requested: Path) -> str:
|
|
41
|
+
try:
|
|
42
|
+
resolved = requested.resolve(strict=True)
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
raise CommitarError(f"Invalid path: {requested}") from exc
|
|
45
|
+
try:
|
|
46
|
+
return resolved.relative_to(self.root).as_posix()
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise CommitarError("The path must be inside the Git worktree.") from exc
|
|
49
|
+
|
|
50
|
+
def working_files(self, pathspec: str, include_added: bool) -> list[str]:
|
|
51
|
+
changed = self.run("diff", "--name-only", "-z", "--", pathspec).stdout.split("\0")
|
|
52
|
+
files = [name for name in changed if name and (self.root / name).exists()]
|
|
53
|
+
if include_added:
|
|
54
|
+
files.extend(self.untracked_files(pathspec))
|
|
55
|
+
return sorted(set(files))
|
|
56
|
+
|
|
57
|
+
def untracked_files(self, pathspec: str) -> list[str]:
|
|
58
|
+
"""Return non-ignored, untracked files inside a Git pathspec."""
|
|
59
|
+
output = self.run("ls-files", "--others", "--exclude-standard", "-z", "--", pathspec).stdout
|
|
60
|
+
return [name for name in output.split("\0") if name]
|
|
61
|
+
|
|
62
|
+
def working_diff(self, files: list[str]) -> str:
|
|
63
|
+
tracked = self.run("diff", "--binary", "--", *files).stdout if files else ""
|
|
64
|
+
untracked = []
|
|
65
|
+
tracked_set = (
|
|
66
|
+
set(self.run("ls-files", "-z", "--", *files).stdout.split("\0")) if files else set()
|
|
67
|
+
)
|
|
68
|
+
for name in files:
|
|
69
|
+
if name not in tracked_set:
|
|
70
|
+
file_path = self.root / name
|
|
71
|
+
if not file_path.is_file():
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
content = file_path.read_text(encoding="utf-8")
|
|
75
|
+
except UnicodeDecodeError:
|
|
76
|
+
content = "[binary file]"
|
|
77
|
+
except IsADirectoryError:
|
|
78
|
+
continue
|
|
79
|
+
untracked.append(f"--- /dev/null\n+++ b/{name}\n@@ new file @@\n{content}\n")
|
|
80
|
+
return tracked + "".join(untracked)
|
|
81
|
+
|
|
82
|
+
def add(self, files: list[str]) -> None:
|
|
83
|
+
self.run("add", "--", *files)
|
|
84
|
+
|
|
85
|
+
def unstage(self, files: list[str]) -> None:
|
|
86
|
+
self.run("reset", "--", *files)
|
|
87
|
+
|
|
88
|
+
def commit(self, message: str) -> str:
|
|
89
|
+
self.run("commit", "-m", message)
|
|
90
|
+
return self.run("rev-parse", "--short", "HEAD").stdout.strip()
|
commitar/scope.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import CommitarError
|
|
7
|
+
from .git import GitRepo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Group:
|
|
12
|
+
files: list[str]
|
|
13
|
+
source: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Scope:
|
|
18
|
+
staged: bool
|
|
19
|
+
groups: list[Group]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve(repo: GitRepo, requested: Path | None, include_added: bool) -> Scope:
|
|
23
|
+
staged = repo.staged_files()
|
|
24
|
+
if staged:
|
|
25
|
+
if requested is not None:
|
|
26
|
+
raise CommitarError(
|
|
27
|
+
"There are staged changes. Run `commitar` without a path to commit the current "
|
|
28
|
+
"staging, or commit/clear it before using a path."
|
|
29
|
+
)
|
|
30
|
+
return Scope(True, [Group(staged, "staged")])
|
|
31
|
+
path = requested or repo.root
|
|
32
|
+
relative = repo.relative_path(path)
|
|
33
|
+
files = repo.working_files(relative, include_added)
|
|
34
|
+
if not files:
|
|
35
|
+
untracked = repo.untracked_files(relative)
|
|
36
|
+
if untracked and not include_added:
|
|
37
|
+
preview = ", ".join(untracked[:3])
|
|
38
|
+
suffix = "..." if len(untracked) > 3 else ""
|
|
39
|
+
raise CommitarError(
|
|
40
|
+
f"No tracked file changes were found. Found {len(untracked)} untracked file(s) "
|
|
41
|
+
f"({preview}{suffix}), which are ignored by default. "
|
|
42
|
+
"Run `commitar --include-added` to include them."
|
|
43
|
+
)
|
|
44
|
+
raise CommitarError("There are no eligible changed files in this scope.")
|
|
45
|
+
if len(files) > 0 and requested is None:
|
|
46
|
+
return Scope(False, [Group([name], "working tree") for name in files])
|
|
47
|
+
return Scope(False, [Group(files, "working tree")])
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commitar
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Gerador seguro de mensagens e commits Git com IA
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/argolo/commitar
|
|
7
|
+
Project-URL: Homepage, https://argolo.dev
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: typer>=0.12
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
15
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Commitar
|
|
22
|
+
|
|
23
|
+
**Commitar** is a Python CLI that generates [Conventional Commits](https://www.conventionalcommits.org/) messages with AI and can optionally create Git commits. Its core principle is preserving the staging area: when the index already has changes, only those changes are considered.
|
|
24
|
+
|
|
25
|
+
Portuguese documentation is available in [README-PT.md](README-PT.md).
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Safe preview by default: no changes are made without `--output commit`.
|
|
30
|
+
- One-line Conventional Commit messages.
|
|
31
|
+
- OpenAI, Gemini, and Ollama support.
|
|
32
|
+
- File, directory, or whole-repository scope.
|
|
33
|
+
- Staging isolation, including partially staged files.
|
|
34
|
+
- TOML, environment-variable, and flag configuration.
|
|
35
|
+
- Colored feedback, a generation spinner, and elapsed time per message.
|
|
36
|
+
- Configurable AI timeout; the default is 60 seconds.
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Python 3.11 or later.
|
|
41
|
+
- Git installed and a Git repository initialized.
|
|
42
|
+
- A configured AI provider:
|
|
43
|
+
- OpenAI: `OPENAI_API_KEY`;
|
|
44
|
+
- Gemini: `GEMINI_API_KEY`;
|
|
45
|
+
- Ollama: a local service running with an installed model.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip3 install commitar
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For development, install the test dependencies too:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install -e '.[dev]'
|
|
57
|
+
commitar --help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
Configure your Git identity if necessary:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git config user.name "Your Name"
|
|
66
|
+
git config user.email "you@example.com"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Preview tracked changes:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
commitar
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When there is no staging, Commitar generates a preview for each changed file. The preview lists the source, files, suggested message, and generation time.
|
|
76
|
+
|
|
77
|
+
Untracked files are ignored for safety. Include them explicitly:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
commitar --include-added
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Create commits after a single confirmation:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
commitar src/ --output commit
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For automation, skip the interaction:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
commitar src/ --output commit --yes
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To create one commit from files explicitly selected with `git add`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git add src/moon_service.py tests/test_moon_service.py
|
|
99
|
+
commitar --output commit
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
With existing staging, Commitar exclusively uses `git diff --cached`, generates one message, and creates a commit containing exactly the files in the index.
|
|
103
|
+
|
|
104
|
+
Use `--message` to skip the AI provider call. The value must be a valid one-line Conventional Commit:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
commitar app.py --include-added \
|
|
108
|
+
--message "feat: add moon phase endpoint" \
|
|
109
|
+
--output commit --yes
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Staging safety
|
|
113
|
+
|
|
114
|
+
The behavior is deliberately conservative.
|
|
115
|
+
|
|
116
|
+
| Situation | Behavior |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| Staged files exist and `commitar` runs without a `PATH` | One message is generated from `git diff --cached`; the commit contains exactly the current index. |
|
|
119
|
+
| Staged files exist and a `PATH` is supplied | The command fails without changing the repository. |
|
|
120
|
+
| No staging and no `PATH` is supplied | One group is created for each changed file. |
|
|
121
|
+
| No staging and a file/directory is supplied | One group contains that file or all eligible files in the directory. |
|
|
122
|
+
| A file is partially staged | Only its index version is committed; remaining worktree changes stay intact. |
|
|
123
|
+
|
|
124
|
+
In path mode, Commitar verifies that the index remains empty before each commit. If another process changes staging, the operation stops to prevent changes from being mixed.
|
|
125
|
+
|
|
126
|
+
## Command reference
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
commitar [OPTIONS] [PATH]
|
|
130
|
+
commitar config init [OPTIONS]
|
|
131
|
+
commitar config show [OPTIONS]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| Option | Description |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `PATH` | A file or directory inside the worktree. With no value, one changed file is grouped at a time. |
|
|
137
|
+
| `--output preview` | Displays the preview; this is the default. |
|
|
138
|
+
| `--output commit` | Requests confirmation and creates commits. |
|
|
139
|
+
| `--dry-run` | Alias for `--output preview`. |
|
|
140
|
+
| `--yes`, `-y` | Does not request confirmation in `commit` mode. |
|
|
141
|
+
| `--message TEXT` | Uses a manual message without calling the AI. |
|
|
142
|
+
| `--include-added` | Includes untracked files. |
|
|
143
|
+
| `--provider` | Selects `openai`, `gemini`, or `ollama`. |
|
|
144
|
+
| `--model` | Sets the selected provider model. |
|
|
145
|
+
| `--timeout-seconds` | Temporarily overrides the AI timeout. |
|
|
146
|
+
| `--max-input-tokens` | Limits the estimated number of tokens in the full prompt sent to the AI. |
|
|
147
|
+
| `--context-window-tokens` | Sets the shared input and reserved-output context window. |
|
|
148
|
+
| `--config PATH` | Loads an additional TOML file, taking precedence over default files. |
|
|
149
|
+
|
|
150
|
+
Use `commitar --help` and `commitar config --help` for current CLI details.
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
Create a configuration template in the repository root:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
commitar config init
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Example `.commitar.toml`:
|
|
161
|
+
|
|
162
|
+
```toml
|
|
163
|
+
[ai]
|
|
164
|
+
provider = "ollama"
|
|
165
|
+
models = ["gemma4:e4b", "qwen2.5-coder:14b"]
|
|
166
|
+
endpoint = "http://localhost:11434/api/generate"
|
|
167
|
+
max_input_tokens = 12000
|
|
168
|
+
context_window_tokens = 32768
|
|
169
|
+
max_output_tokens = 80
|
|
170
|
+
temperature = 0.2
|
|
171
|
+
timeout_seconds = 60
|
|
172
|
+
|
|
173
|
+
[commit]
|
|
174
|
+
language = "pt-BR"
|
|
175
|
+
format = "conventional"
|
|
176
|
+
include_added = false
|
|
177
|
+
output = "preview"
|
|
178
|
+
confirm = true
|
|
179
|
+
|
|
180
|
+
[limits]
|
|
181
|
+
max_files_per_request = 50
|
|
182
|
+
max_diff_bytes = 100000
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`models` accepts up to three models in preference order. If the AI returns an invalid message, Commitar tries the next model, for up to three attempts. `model = "name"` remains supported for a single model; `--model` takes precedence for the current run.
|
|
186
|
+
|
|
187
|
+
The configuration precedence, from highest to lowest, is: CLI flags; the `--config` file; `COMMITAR_*` environment variables; `.commitar.toml` in the repository root; `~/.config/commitar/config.toml`; and built-in defaults.
|
|
188
|
+
|
|
189
|
+
Supported variables are `COMMITAR_PROVIDER`, `COMMITAR_MODEL`, `COMMITAR_LANGUAGE`, `COMMITAR_OUTPUT`, `COMMITAR_ENDPOINT`, `COMMITAR_MAX_DIFF_BYTES`, and `COMMITAR_TIMEOUT_SECONDS`.
|
|
190
|
+
|
|
191
|
+
Credentials are never read from TOML. Use `OPENAI_API_KEY` or `GEMINI_API_KEY`; Ollama normally does not need a key for local use.
|
|
192
|
+
|
|
193
|
+
## AI providers
|
|
194
|
+
|
|
195
|
+
### Ollama
|
|
196
|
+
|
|
197
|
+
| Model | Recommended use |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `gemma4:e4b` | A lighter option for general use and resource-constrained machines. |
|
|
200
|
+
| `qwen2.5-coder:14b` | A code-focused option for machines with more memory and processing capacity. |
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
ollama pull gemma4:e4b
|
|
204
|
+
ollama serve
|
|
205
|
+
commitar --provider ollama --model gemma4:e4b
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The default endpoint is `http://localhost:11434/api/generate`.
|
|
209
|
+
|
|
210
|
+
### OpenAI
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
export OPENAI_API_KEY="..."
|
|
214
|
+
commitar --provider openai --model gpt-5-mini
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Gemini
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
export GEMINI_API_KEY="..."
|
|
221
|
+
commitar --provider gemini --model gemini-2.5-flash
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Context limits
|
|
225
|
+
|
|
226
|
+
The context is controlled by four settings: `max_files_per_request` (maximum files per group), `max_diff_bytes` (maximum diff size), `max_input_tokens` (estimated full-prompt limit), and `context_window_tokens` (total input/output window).
|
|
227
|
+
|
|
228
|
+
`max_input_tokens + max_output_tokens` cannot exceed `context_window_tokens`. Input counting is a conservative estimate independent of the provider tokenizer. In Ollama, the context window is sent as `num_ctx` and the output limit as `num_predict`. OpenAI and Gemini validate the window locally before sending the output limit.
|
|
229
|
+
|
|
230
|
+
If the diff exceeds its byte or token limit, Commitar produces a deterministic summary of diff metadata. If that still exceeds a limit, the command fails explicitly; content is never silently truncated.
|
|
231
|
+
|
|
232
|
+
## FastAPI example
|
|
233
|
+
|
|
234
|
+
The [example/](example/) directory contains an asynchronous FastAPI API that returns the approximate Moon phase for an ISO date:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
python -m pip install fastapi uvicorn
|
|
238
|
+
uvicorn example.app:app --reload
|
|
239
|
+
curl 'http://127.0.0.1:8000/moon-phase?date=2026-07-28'
|
|
240
|
+
curl 'http://127.0.0.1:8000/next-moon-phase?phase=lua-cheia&from_date=2026-07-28'
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`/next-moon-phase` accepts `lua-nova`, `crescente`, `quarto-crescente`, `gibosa-crescente`, `lua-cheia`, `gibosa-minguante`, `quarto-minguante`, and `minguante`. The calculation is an approximation based on the average synodic month.
|
|
244
|
+
|
|
245
|
+
## Development and testing
|
|
246
|
+
|
|
247
|
+
Use the `Makefile` to standardize local tasks:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
make help
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Command | Purpose |
|
|
254
|
+
| --- | --- |
|
|
255
|
+
| `make venv` | Creates the local virtual environment in `.venv`. |
|
|
256
|
+
| `make shell` | Opens a shell with the virtual environment enabled; run `exit` to leave it. |
|
|
257
|
+
| `make install` | Installs Commitar in editable mode and all development dependencies. |
|
|
258
|
+
| `make clear` | Deletes Git-ignored files with `git clean -Xdf`, including `.venv`, `.env`, caches, and builds. |
|
|
259
|
+
| `make build` | Generates the wheel and source distribution in `dist/`. |
|
|
260
|
+
| `make test` | Runs the pytest suite. |
|
|
261
|
+
| `make coverage` | Runs tests and shows uncovered lines. |
|
|
262
|
+
| `make lint` | Checks style, imports, and common issues with Ruff. |
|
|
263
|
+
| `make format` | Formats code with Ruff. |
|
|
264
|
+
| `make typecheck` | Runs static analysis with mypy. |
|
|
265
|
+
| `make audit` | Checks dependencies for known vulnerabilities with pip-audit. |
|
|
266
|
+
| `make check` | Runs linting, type checking, tests, and the audit. |
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
make venv
|
|
270
|
+
make install
|
|
271
|
+
make check
|
|
272
|
+
make build
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
> **Warning:** `make clear` is destructive to ignored files. It also removes virtual environments and local files such as `.env`; back up needed local data.
|
|
276
|
+
|
|
277
|
+
## Troubleshooting
|
|
278
|
+
|
|
279
|
+
| Message / symptom | Recommended action |
|
|
280
|
+
| --- | --- |
|
|
281
|
+
| `No tracked file changes were found...` | Only new files exist. Run `commitar --include-added` to include them in the preview. |
|
|
282
|
+
| `There are no eligible changed files in this scope.` | There are no changes in the supplied scope; modify a tracked file or provide another path. |
|
|
283
|
+
| `There are staged changes...` | Run without a `PATH` to commit only the index, or clear/commit staging before supplying a path. |
|
|
284
|
+
| Provider timeout | Commitar retries up to three times across configured models. Check the service and increase `timeout_seconds`. |
|
|
285
|
+
| Credential error | Set `OPENAI_API_KEY` or `GEMINI_API_KEY` in the environment. |
|
|
286
|
+
| Invalid AI response | After three invalid attempts, the current group is skipped and execution continues with the next files. |
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
Distributed under the [MIT License](LICENSE). A project by André Argôlo ([argolo.dev](https://argolo.dev)).
|
|
291
|
+
|
|
292
|
+
Repository: [github.com/argolo/commitar](https://github.com/argolo/commitar).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
commitar/__init__.py,sha256=ZHFcxB3v8oJIY6wKYkxzzbMBkOY3xzwXDQil4mvr9lw,43
|
|
2
|
+
commitar/ai.py,sha256=9nSFPsuLxTE7dXlWyTgk5-NlkOHvodTYrvFKlj-2TxM,6221
|
|
3
|
+
commitar/cli.py,sha256=7mu2EgtPGxRCP01PKqTcAvERHCilLGOValDVMzukeyQ,11619
|
|
4
|
+
commitar/config.py,sha256=MRA-IoxOS3eJPODN7M2rgRoZ_XeR8giEvmBgzmJOvKU,4664
|
|
5
|
+
commitar/errors.py,sha256=bBkEkYF4kEmSCbDWkaQfEKJcyVxsyQUeojudvx35u-k,443
|
|
6
|
+
commitar/git.py,sha256=8IjQK-3QlsWrI3zQQtLX0DjPmvAK-eBwchSyQxOWBbI,3544
|
|
7
|
+
commitar/scope.py,sha256=4QaGiJdqsh1CNXfGLVm4BsKoVTz8sEpINf03avkwlTI,1632
|
|
8
|
+
commitar-1.0.0.dist-info/licenses/LICENSE,sha256=vmqyc5Yy-tKx7me3iWqd6-xTGx8D69JcCd9WJcqrMu4,1084
|
|
9
|
+
commitar-1.0.0.dist-info/METADATA,sha256=axNNNRXyuNA6nuFy9i5FShWywBMJ-aoO9VJh6ASIT_g,10704
|
|
10
|
+
commitar-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
commitar-1.0.0.dist-info/entry_points.txt,sha256=WpOhDdS1FoHwoBwDg-53LF-0MRByUpDQggi42NgUEDQ,47
|
|
12
|
+
commitar-1.0.0.dist-info/top_level.txt,sha256=ZliXiRp_sLVq71ZzgLshhvgIn1aEzJZkvUK8HESMiKc,9
|
|
13
|
+
commitar-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 André Argôlo (argolo.dev)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
commitar
|