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 ADDED
@@ -0,0 +1,3 @@
1
+ """CLI Commitar."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commitar = commitar.cli:main
@@ -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