diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 5da240287..23d9ab472 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -43,48 +43,6 @@ p.write_text( PY fi -# Expose repo scripts that follow the git- naming convention as -# proper git subcommands by symlinking them into /usr/local/bin. This makes -# `git ai-commit` work in any directory without requiring scripts/ on PATH. -ln -sf /workspaces/phlex/scripts/git-ai-commit /usr/local/bin/git-ai-commit - -# Adjust MANPATH to enable `git help ai-commit` to find the man page. -# The scripts/man/man1 directory contains git-subcommand man pages. -# Prepend the repo man directory and ensure the resulting MANPATH always has -# an empty field (which tells man to search the system default paths). When -# there is no pre-existing MANPATH, or the existing one lacks an empty field, -# a trailing colon is appended to provide one; if an empty field already -# exists it is preserved as-is. -cat >> /root/.bashrc <<'EOF' - -prepend_to_manpath() { - # Prepends one or more paths to MANPATH, preserving the position of any - # existing empty field (which tells man(1) where to insert the system - # default paths). Handles unset, empty, or degenerate MANPATH safely. - # If MANPATH has no empty field, a trailing : is added (system paths last). - local prefix - prefix=$(IFS=:; echo "$*") - # Unset or degenerate (only colons): start fresh, system defaults last. - if [[ -z ${MANPATH+set} || -z "${MANPATH//:}" ]]; then - export MANPATH="${prefix}:" - return - fi - case "$MANPATH" in - *::*|*:|:*) - # Already has an empty field somewhere; prepend and keep it in place. - # :foo -> newpath::foo (leading : becomes embedded :: after prepend) - # foo: -> newpath:foo: (trailing : stays trailing) - # f::b -> newpath:f::b (embedded :: stays embedded) - export MANPATH="${prefix}:${MANPATH}" ;; - *) - # No empty field: add trailing : so system paths are still searched. - export MANPATH="${prefix}:${MANPATH}:" ;; - esac -} - -prepend_to_manpath "/workspaces/phlex/scripts/man" -EOF - # Install pre-commit hooks if available. if command -v prek >/dev/null 2>&1; then prek install || true diff --git a/docs/dev/git-ai-commit-context/git-ai-commit-context-optimization.json b/docs/dev/git-ai-commit-context/git-ai-commit-context-optimization.json deleted file mode 100644 index 05290f799..000000000 --- a/docs/dev/git-ai-commit-context/git-ai-commit-context-optimization.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "goal": "Implement applicable recommendations from docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md to maximize commit-message quality while minimizing prompt/context bloat in scripts/git-ai-commit, per the resolved decisions in .kilo/plans/1782915491660-git-ai-commit-context-optimization.md (hunk-aware truncation, two-tier caps, raw-length escalation, binary/generated skipping, reduced history depth, prompt trims, tests, and docs).", - "idempotent": false, - "steps": [ - { - "id": "step-1", - "description": "[coder-qwen | default free/open-weight model] Read scripts/git-ai-commit in full to capture exact current structure. Report the precise line ranges and current code for: module constants block (_KILO_MODEL_PINNED_BY_ENV ~130, _MAX_DIFF_CHARS ~134, _ESCALATION_THRESHOLD_CHARS ~141, _ESCALATION_MODEL_KILO ~142), the _SYSTEM prompt (~144), _staged_diff (~212), _recent_log (~228), _build_messages signature and body (~656-704 including the inline truncation at 671-672), and the main() escalation block (~882-925 including the sum(len(...)) computation at ~919). Do NOT edit anything in this step; produce a faithful map of anchors to be used by subsequent edit steps.", - "model": "coder-qwen", - "input": null, - "output": "Verified anchor map: exact current text/line ranges of constants, _SYSTEM, _staged_diff, _recent_log, _build_messages, and main() escalation logic.", - "gate": true, - "parallel": false, - "retry": 0, - "max_retries": 2, - "on_error": "abort", - "self_check": "Confirm all named anchors (_MAX_DIFF_CHARS at ~134, _build_messages at ~656, main escalation sum at ~919) are located and quoted; abort if any is missing since later edits depend on them." - }, - { - "id": "step-2", - "description": "[coder-qwen | default free/open-weight model] Implement Task 1: add a hunk-aware diff-truncation helper `_truncate_diff(diff: str, max_chars: int) -> str` to scripts/git-ai-commit near _build_messages. Behavior: (a) return diff unchanged when len(diff) <= max_chars; (b) preserve the leading `git diff --cached --stat -p` diffstat block, then include whole `diff --git` file sections until the char budget is reached; (c) drop remaining file sections and append a clear marker naming counts, e.g. `\\n\\n[diff truncated: N of M files omitted to fit context budget]`; (d) if a single file section alone exceeds the budget, fall back to a line-boundary (never mid-line) cut within that section plus the marker. Split file sections on the `diff --git ` boundary. Add explanatory comments. Follow file-formatting rules: exactly one trailing newline, no trailing whitespace, ruff 99-char limit / double quotes. Do not yet wire it into _build_messages.", - "model": "coder-qwen", - "input": "step-1 anchor map", - "output": "New `_truncate_diff` helper added with hunk/file-boundary logic, stat-block preservation, single-oversized-file line-boundary fallback, and omitted-count marker.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "abort", - "self_check": "python3 -c \"from importlib.machinery import SourceFileLoader; m=SourceFileLoader('g','scripts/git-ai-commit').load_module(); assert callable(m._truncate_diff)\" — module imports and helper is callable." - }, - { - "id": "step-3", - "description": "[coder-qwen | default free/open-weight model] Implement Task 2: replace the single `_MAX_DIFF_CHARS = 60_000` constant with two module constants: `_MAX_DIFF_CHARS_DEFAULT = 60_000` (Haiku/non-escalated path) and `_MAX_DIFF_CHARS_ESCALATED` set to a large ceiling matched to Qwen's practical context (effectively a no-truncation safety net; choose a value like 400_000 and justify in a comment). Add explanatory comments describing the two-tier intent. Update any internal reference to the old name. Keep ruff/formatting rules.", - "model": "coder-qwen", - "input": "step-1 anchor map", - "output": "Two-tier cap constants (_MAX_DIFF_CHARS_DEFAULT, _MAX_DIFF_CHARS_ESCALATED) defined with comments; old single constant removed.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "abort", - "self_check": "grep confirms both new constants exist and the bare `_MAX_DIFF_CHARS =` (without _DEFAULT/_ESCALATED suffix) no longer appears; module still imports." - }, - { - "id": "step-4", - "description": "[coder-qwen | default free/open-weight model] Implement Task 4: add a `max_diff_chars: int = _MAX_DIFF_CHARS_DEFAULT` parameter to `_build_messages` (default preserves current behavior for direct/test callers). Replace the inline slice+marker at ~671-672 with a call to `_truncate_diff(diff, max_diff_chars)`. Ensure the rest of _build_messages (system/user message assembly at ~704) is otherwise unchanged. Keep formatting rules.", - "model": "coder-qwen", - "input": "step-2 helper; step-3 constants; step-1 anchor map", - "output": "_build_messages accepts max_diff_chars (defaulting to _MAX_DIFF_CHARS_DEFAULT) and delegates truncation to _truncate_diff; inline 60k slice removed.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "abort", - "self_check": "Inspect signature: `_build_messages` has max_diff_chars param; grep shows no remaining `diff[:_MAX_DIFF_CHARS]` inline slice; module imports." - }, - { - "id": "step-5", - "description": "[coder-qwen | default free/open-weight model] Implement Task 3 + Task 4 wiring in main() (~882-925). (a) Compute the escalation decision from the RAW pre-truncation length: build the sum of untruncated diff + status + log + context + instructions lengths BEFORE _build_messages truncates, instead of the current post-truncation `sum(len(m['content']) for m in msgs)`. Preserve existing guards: backend == 'kilo', not model_explicit, not _KILO_MODEL_PINNED_BY_ENV, threshold _ESCALATION_THRESHOLD_CHARS, and keep the existing stderr escalation notice. (b) After resolving the model, select `max_diff_chars = _MAX_DIFF_CHARS_ESCALATED` when the resolved model is the escalation model (_ESCALATION_MODEL_KILO) or a user-pinned/explicit high-context model; otherwise `_MAX_DIFF_CHARS_DEFAULT`. (c) Pass that value into the `_build_messages(...)` call at ~909, reordering so message construction happens after the model/cap is resolved. Do not double-count or regress the env-pin / explicit-model suppression logic. Keep formatting rules.", - "model": "coder-qwen", - "input": "step-4 _build_messages signature; step-3 constants; step-1 anchor map", - "output": "main() decides escalation from raw pre-truncation length, resolves model, then passes the model-appropriate max_diff_chars into _build_messages; guards and stderr notice preserved.", - "gate": false, - "parallel": false, - "retry": 1, - "max_retries": 3, - "on_error": "replan", - "self_check": "grep confirms the escalation condition no longer relies on `sum(len(m['content']) for m in msgs)` for the decision; module imports; a manual reasoning check that guards (kilo/model_explicit/env-pin/threshold) remain intact." - }, - { - "id": "step-6", - "description": "[coder-qwen | default free/open-weight model] Implement Task 5: skip binary/generated files in the diff. Add a small commented constant listing generated-file glob patterns (e.g. *.pb.cc, *.pb.h, *.gen.cpp and similar). Before prompting, filter `diff --git` sections that are either git binary sections (contain `Binary files ... differ`) or match a generated pattern, replacing each removed section with a one-line note so the model knows a file changed (e.g. `[skipped binary/generated file: ]`). Confirm no conflict with the `--stat` block (the stat line for skipped files may remain — that is acceptable). Integrate this filtering so it runs before/within truncation (apply filtering to the raw diff prior to _truncate_diff). Keep formatting rules.", - "model": "coder-qwen", - "input": "step-2 helper; step-4/step-5 wiring; step-1 anchor map", - "output": "Binary and generated-pattern diff sections are replaced with a one-line note; generated-pattern constant added; stat block left intact.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "continue", - "self_check": "Unit-level reasoning: a synthetic diff containing a `Binary files a/x b/x differ` section and a `*.pb.cc` section yields notes, not full hunks; module imports." - }, - { - "id": "step-7", - "description": "[coder-qwen | default free/open-weight model] Implement Task 6: reduce default commit-history depth in `_recent_log` (~228) from `-10` to `-5`. Update the in-code docstring reference to `git log --oneline -10` accordingly. OPTIONAL (only if low-risk and cleanly threadable): add a `--history-depth N` argparse flag threaded into `_recent_log(depth)`, defaulting to 5, and update argparse help text and the module docstring. If the flag is omitted, add a brief comment noting it as a deliberate simplification. Do not break existing callers. Keep formatting rules.", - "model": "coder-qwen", - "input": "step-1 anchor map", - "output": "_recent_log default depth reduced to 5 (with docstring updated); optional --history-depth flag added only if clean, otherwise documented as deliberately omitted.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "continue", - "self_check": "grep confirms `_recent_log` uses -5 (or a depth variable defaulting to 5); if flag added, argparse help and docstring updated; module imports." - }, - { - "id": "step-8", - "description": "[coder-qwen | default free/open-weight model] Implement Task 7: tighten the `_SYSTEM` prompt wording (~144) by removing only redundant/filler phrasing. MUST NOT weaken: Conventional Commits v1.0.0 rules, the 'respond with only the message' constraint, or subject/body/footer formatting requirements. Keep the change minimal (the prompt is already close to minimal-bloat form). Preserve textwrap.dedent structure and formatting rules.", - "model": "coder-qwen", - "input": "step-1 anchor map (_SYSTEM text)", - "output": "_SYSTEM prompt trimmed of filler while fully preserving Conventional Commits conformance, the message-only constraint, and formatting requirements.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "continue", - "self_check": "Diff review confirms only filler removed; the phrases enforcing Conventional Commits, 'only the message', and subject/body/footer rules remain present; module imports." - }, - { - "id": "step-9", - "description": "[coder-qwen | default free/open-weight model] Implement Task 8: update scripts/test/test_git_ai_commit.py (loaded via SourceFileLoader). Add tests: (a) _truncate_diff under budget returns unchanged; (b) over budget cuts on file/hunk boundary and keeps the --stat header; (c) a single oversized file falls back to a line-boundary cut; (d) the omitted-count marker text is present; (e) escalation ordering: a large RAW diff triggers escalation using pre-truncation length, and the escalated path receives the larger cap (assert via _build_messages output length / absence of truncation marker when max_diff_chars is escalated); (f) binary/generated-file skipping produces the one-line note. Update any existing tests that assumed the old single 60k cap or the old inline marker string `[diff truncated — too large to show in full]`. Preserve _build_messages default-arg behavior so existing signature-based tests still pass where feasible. Keep ruff/formatting rules.", - "model": "coder-qwen", - "input": "steps 2-8 implemented source", - "output": "Test suite extended with truncation, escalation-ordering, and binary/generated-skip tests; obsolete-cap/marker assertions updated; default-arg compatibility preserved.", - "gate": false, - "parallel": false, - "retry": 1, - "max_retries": 3, - "on_error": "replan", - "self_check": "pytest scripts/test/ -v — all tests (existing + new) pass; no reference to the removed inline marker string remains except in intentionally updated assertions." - }, - { - "id": "step-10", - "description": "[coder-qwen | default free/open-weight model] Implement Task 9: update documentation. (a) scripts/man/man1/git-ai-commit.1 — reflect new hunk-aware truncation behavior, reduced history depth (10->5), any new --history-depth flag, and binary/generated-file skipping. (b) The module docstring at the top of scripts/git-ai-commit — sync any option/default changes. (c) docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md — append a short 'Implementation status' section recording the disposition table from the plan (item 1 Implement-adapted, item 2 Partial, item 3 Reject, item 4 Implement, item 5 Reject, item 6 Partial, item 7 Reject-table/tune-threshold) with done/partial/rejected + rationale. Follow Markdown lint rules (MD012/MD022/MD031/MD032/MD034/MD040) and file-formatting rules (one trailing newline, no trailing whitespace).", - "model": "coder-qwen", - "input": "steps 2-9 implemented behavior; plan disposition table", - "output": "Man page, module docstring, and guidelines doc updated to match new behavior; disposition/Implementation-status section appended.", - "gate": false, - "parallel": false, - "retry": 0, - "max_retries": 3, - "on_error": "continue", - "self_check": "Man page mentions truncation/history-depth/binary skip; guidelines doc has an 'Implementation status' section covering all 7 report items; markdownlint-style spot check passes." - }, - { - "id": "step-11", - "description": "[coder-qwen | default free/open-weight model] Validation. Run: (1) `pytest scripts/test/ -v` — all tests must pass. (2) `ruff check scripts/git-ai-commit scripts/test/test_git_ai_commit.py` per pyproject.toml (99-char, double quotes) — fix any findings. (3) Manual dry-runs: `git ai-commit --dry-run` on a small diff (no truncation, default model, no escalation); a synthetic large diff over the escalation threshold (confirm escalation fires AND no truncation marker on the escalated path); and a diff containing a binary + a generated file (confirm both are skipped with a note). (4) `PREKCOMMAND=$(command -v prek || command -v pre-commit); $PREKCOMMAND run --all-files` and address hook findings. Report pass/fail per check with the exact commands and key output.", - "model": "coder-qwen", - "input": "all prior steps", - "output": "Validation report: pytest, ruff, three dry-run scenarios, and pre-commit hook results, with fixes applied for any failures.", - "gate": true, - "parallel": false, - "retry": 1, - "max_retries": 3, - "on_error": "replan", - "self_check": "All four checks pass; escalated-path dry-run shows no truncation marker; binary/generated dry-run shows skip notes; gate blocks completion until green." - } - ] -} diff --git a/docs/dev/git-ai-commit-context/git-ai-commit-context-optimization.md b/docs/dev/git-ai-commit-context/git-ai-commit-context-optimization.md deleted file mode 100644 index 851d43df5..000000000 --- a/docs/dev/git-ai-commit-context/git-ai-commit-context-optimization.md +++ /dev/null @@ -1,179 +0,0 @@ -# git-ai-commit context-optimization plan - -## Goal - -Implement the applicable recommendations from -`docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md` to maximize commit-message -quality while minimizing unnecessary prompt/context bloat in -`scripts/git-ai-commit`. Treat the report as an idea source, not a spec: the -tool already implements several recommendations and diverges from the report's -generic assumptions (character-based budgets, not tokens; single FNAL -deployment; no full-file inclusion; no doc cache; no issue/PR fetch). - -## Authoritative facts (verified) - -- Tool: `scripts/git-ai-commit` (Python, git-subcommand convention). This repo - copy is authoritative; it is symlinked onto PATH in - `.devcontainer/post-create.sh:57`. The separate personal copy at - `/home/greenc/scripts/git-ai-commit` is **out of scope**. -- Tests: `scripts/test/test_git_ai_commit.py`, loaded via `SourceFileLoader`. - Run in CI by `.github/workflows/python-check.yaml:162` and - `.github/workflows/coverage.yaml:272` (`pytest scripts/test/`). -- Man page: `scripts/man/man1/git-ai-commit.1` (266 lines) — must stay in sync - with CLI/behavior changes. -- Diff source: `_staged_diff` returns `git diff --cached --stat -p` output, so - the string begins with a diffstat block, then per-file `diff --git` hunks. -- Current truncation: `_build_messages` (line ~671) slices the raw diff at - `_MAX_DIFF_CHARS = 60_000` mid-line and appends - `[diff truncated — too large to show in full]`. -- Current escalation: in `main()` (line ~915), if `backend == "kilo"`, model - not explicit, not pinned by env, and summed message length > - `_ESCALATION_THRESHOLD_CHARS = 30_000`, switch model to - `_ESCALATION_MODEL_KILO = "qwen/qwen3-coder-next"`. Bug: this sums the - **already-truncated** message content, and truncation runs unconditionally at - 60k regardless of the resolved (escalated) model. -- No full-file content inclusion, no `.git-ai-commit-cache.json`, no GitHub - issue/PR fetch, no style-config reading exist today. - -## Resolved decisions - -1. **Scope:** selective/pragmatic. Implement report items that improve message - quality or reduce genuine bloat; explicitly reject the rest with rationale. -2. **Truncation:** escalate-first, truncate-rarely. When escalation triggers - (or a high-context model is otherwise in use), raise/disable the char cap so - the model sees the full diff; on the non-escalated (default Haiku) path keep - a cap and cut on **hunk boundaries** with a clear marker. -3. **Escalation ordering:** two-tier cap driven by the resolved model. - `main()` decides escalation from the **raw (pre-truncation)** prompt length, - then passes a model-appropriate cap into `_build_messages`. -4. **History window:** reduce the default `git log` depth (report item 4). -5. **Prompt wording:** only trims that lose no Conventional Commits conformance - (report item 6). - -## Report item disposition (record in the report doc) - -| # | Report recommendation | Disposition | Rationale | -| --- | --- | --- | --- | -| 1 | Diff-size gating, hunk truncation, `--full-diff` | Implement (adapted) | Char-based, no tokenizer dep; hunk-aware cut + escalation-aware cap | -| 2 | Conditional file content; skip binary/generated; whitelist | Partial | Tool never includes full files; add binary/generated-file diff skipping | -| 3 | Cache static docs in `.git-ai-commit-cache.json` | Reject | Per-invocation CLI, no session; cache adds staleness + bloat; AGENTS.md dedup already limits volume | -| 4 | Limit commit-history window (`--history-depth`, default 3) | Implement | Cheap, direct bloat reduction | -| 5 | Smart issue/PR pulling | Reject | Feature absent; adds network + bloat for marginal gain | -| 6 | Prompt-wording tweaks | Partial | Trim only where conformance preserved | -| 7 | Model-window table / `--max-tokens` | Reject (table); tune threshold only | Single FNAL deployment; full table is overkill and bloats config | - -## Implementation tasks (ordered) - -1. **Add a hunk-aware diff-truncation helper.** - - New function (e.g. `_truncate_diff(diff: str, max_chars: int) -> str`). - - Preserve the leading `--stat` block, then include whole `diff --git` - file sections until the budget is reached; drop remaining files and append - a clear marker naming how many files/sections were omitted, e.g. - `\n\n[diff truncated: N of M files omitted to fit context budget]`. - - If a single file section alone exceeds the budget, fall back to a - line-boundary cut within that section (never mid-line) plus the marker. - - Return the diff unchanged when under budget. - -2. **Introduce a two-tier character cap.** - - Replace the single `_MAX_DIFF_CHARS` with two constants, e.g. - `_MAX_DIFF_CHARS_DEFAULT` (keep ~60_000 for the Haiku path) and - `_MAX_DIFF_CHARS_ESCALATED` (large ceiling matched to Qwen's practical - context, or effectively "no truncation" safety net). - - Keep values as module constants with explanatory comments. - -3. **Make escalation decision use raw (pre-truncation) length.** - - In `main()`, compute the escalation decision from the untruncated diff + - instructions + context length **before** truncation is applied. - - Preserve existing guards: `backend == "kilo"`, `not model_explicit`, - `not _KILO_MODEL_PINNED_BY_ENV`, threshold `_ESCALATION_THRESHOLD_CHARS`. - - Keep the existing stderr escalation notice. - -4. **Pass the resolved cap into `_build_messages`.** - - Add a `max_diff_chars` parameter to `_build_messages` (default preserves - current behavior for direct/test callers). - - `main()` selects `_MAX_DIFF_CHARS_ESCALATED` when the resolved model is the - escalation model or a user-pinned/explicit high-context model; otherwise - `_MAX_DIFF_CHARS_DEFAULT`. - - `_build_messages` calls `_truncate_diff(diff, max_diff_chars)` instead of - the inline slice. - -5. **Skip binary/generated files in the diff (report item 2).** - - Filter or exclude binary-file sections (git marks these - `Binary files ... differ`) and known generated patterns - (`*.pb.cc`, `*.pb.h`, `*.gen.cpp`, etc.) from the diff before prompting, - replacing each with a one-line note so the model knows a file changed. - - Keep the exclusion list as a small, commented constant. Confirm no - conflict with `--stat` (the stat line for skipped files may remain). - -6. **Reduce default commit-history depth (report item 4).** - - Change `_recent_log` from `-10` to a smaller default (recommended `-5`; - report suggested 3). Update the man page and the docstring reference to - `git log --oneline -10`. - - Optional (only if low-risk): add a `--history-depth N` flag threaded into - `_recent_log`. If added, update argparse, help text, docstring, and man - page. If omitted, note it as a deliberate simplification. - -7. **Tighten `_SYSTEM` wording where conformance is preserved (report item 6).** - - Remove redundant/filler phrasing only. Do not weaken the Conventional - Commits v1.0.0 rules, the "respond with only the message" constraint, or - the subject/body/footer formatting requirements. - - Keep the change minimal; the existing prompt is already close to the - report's suggested minimal-bloat form. - -8. **Update tests (`scripts/test/test_git_ai_commit.py`).** - - New tests for `_truncate_diff`: under budget returns unchanged; over budget - cuts on file/hunk boundary and keeps the `--stat` header; single oversized - file falls back to line-boundary cut; marker text present. - - New tests for escalation ordering: a large raw diff triggers escalation - using pre-truncation length, and the escalated path receives the larger cap - (assert via `_build_messages` output length / marker absence). - - New tests for binary/generated-file skipping. - - Update any existing tests that assumed the old single 60k cap or the old - inline marker string. Keep `_build_messages` default-arg behavior so - existing signature-based tests still pass where feasible. - -9. **Update documentation.** - - `scripts/man/man1/git-ai-commit.1`: reflect new truncation behavior, - reduced history depth, any new flag, and binary/generated skipping. - - Script module docstring (top of `scripts/git-ai-commit`): sync any option - or default changes. - - `docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md`: - append a short "Implementation status" section recording the - disposition table above (done / partial / rejected + rationale) so - the report reflects reality. - -## Validation - -- `pytest scripts/test/ -v` (matches CI in `python-check.yaml` and - `coverage.yaml`). All existing tests must still pass. -- `ruff` per `pyproject.toml` (99-char limit, double quotes) on the edited - script and tests. -- Manual dry-run checks (`git ai-commit --dry-run`) on: - - a small diff (no truncation, default model, no escalation), - - a synthetic large diff (> escalation threshold) confirming escalation fires - **and** the full diff is sent (no truncation marker on the escalated path), - - a diff containing a binary and a generated file (confirm they are skipped - with a note). -- `PREKCOMMAND=$(command -v prek || command -v pre-commit)`; run - `$PREKCOMMAND run --all-files` before opening a PR. - -## Risks / notes - -- The `--stat -p` header interacts with hunk truncation: keep the stat block - intact so the model still sees the full file list even when hunks are cut. -- Escalation currently measures summed message content; moving the decision to - raw pre-truncation length must not double-count or regress the existing - env-pin / explicit-model suppression logic. -- File-formatting rules (global AGENTS.md): exactly one trailing newline, no - trailing whitespace. -- Implementation requires source edits and running tests — switch to an - implementation-capable agent to execute this plan. - -## Deliberately out of scope - -- Token-based budgeting / tokenizer dependency (kept character-based). -- `.git-ai-commit-cache.json` session cache. -- GitHub issue/PR fetching. -- Per-model context-window table / `--max-tokens`. -- Reading language style configs (`.clang-format`, `pyproject.toml`, etc.). -- The personal copy at `/home/greenc/scripts/git-ai-commit`. diff --git a/docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md b/docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md deleted file mode 100644 index ae075a227..000000000 --- a/docs/dev/git-ai-commit-context/git_ai_commit_context_guidelines.md +++ /dev/null @@ -1,141 +0,0 @@ -# Git‑AI‑Commit Context Guidelines - -## Original prompt - -Please evaluate the various sources of context to git-ai-commit for relevance/usefulness, and suggest adjustments to context inclusions or prompt wording to minimize context bloat while preserving or enhancing prompt effectiveness with various models and context window sizes. - -## Evaluation of current context sources - -| Context source | Typical size | Relevance to commit‑message generation | Typical usefulness | Comments (bloat risk) | -| --- | --- | --- | --- | --- | -| **Staged diff (`git diff --staged`)** | 0 – few KB | Highest – gives concrete code changes | essential | Can explode for large refactors; diff‑size‑based truncation is needed | -| **Full file contents of changed files** | 0 – several MB | Medium – helps model see surrounding code, definitions, imports | useful for non‑trivial changes (API tweaks, new types) | Heavy for binary files or huge generated sources | -| **Commit history (last N commits)** | 0 – few KB | Low‑medium – provides project‑wide style, recent terminology | useful for style consistency, detecting reused ticket IDs | Older history quickly becomes irrelevant; limit to “last 5” | -| **Branch name / PR number** | < 100 B | Low – can surface ticket ID, feature name | helpful for including ticket numbers, short context | negligible size | -| **Issue/PR description (GitHub API)** | 0 – few KB | Medium – clarifies intent, scope | valuable when present | Only fetch when `--issue` flag is used | -| **Project‑wide guidelines (README, CONTRIBUTING, `.gitmessage`, `kilo.json`)** | up to tens KB | Low‑medium – informs preferred tone, prefixes (e.g., “feat:”, “fix:”) | useful for enforcing conventions | Most of the file is static; cache and insert only once per session | -| **Language‑specific style configs (`.clang‑format`, `pyproject.toml`, `ruff.toml`)** | < 5 KB | Low – rarely needed for the message itself | seldom used | Can be omitted unless explicitly requested | -| **Recent CI status / test failures** | < 2 KB | Low – may affect urgency wording | occasional | Only useful for “WIP” or “hot‑fix” prompts | -| **User‑defined prompt template** | < 1 KB | High – drives tone, length, ticket format | critical | Fixed size; no bloat | - -### Key observations - -1. Diff is the single most valuable source. -2. Full file contents add marginal benefit and become a major source of bloat for large files. -3. Static project‑wide docs and style configs contribute almost no incremental information per commit; they can be cached once per session. -4. Branch/issue metadata is tiny but highly effective for ticket IDs. -5. Commit history beyond the last 3–5 entries rarely influences the current message. - ---- - -## Recommendations to shrink context while keeping effectiveness - -### 1. Diff‑size gating - -- If staged diff > `MAX_DIFF_TOKENS` (≈ 2 k tokens / ~4 KB), truncate to the first `N` hunks (default 10) and add a note: “*diff truncated – see remaining changes in file X*”. -- Provide a `--full-diff` flag for rare cases where the full diff is required. - -### 2. Conditional file‑content inclusion - -- Include only the *closest* surrounding lines (± 5–10 lines) around each changed hunk, not the whole file. -- Skip binary or generated files (`.pb.cc`, `.pb.h`, `.gen.cpp`, etc.) unless the user explicitly asks. -- Use a file‑type whitelist (`.cpp`, `.hpp`, `.py`, `.jsonnet`, `.cmake`). - -### 3. Cache static project context - -- Load once per session: `README.md`, `CONTRIBUTING.md`, `kilo.json`, `.gitmessage`. -- Pass a reference token (e.g., `<>`) that the model expands internally via a stored embedding or short summary. - -### 4. Limit commit‑history window - -- Default to **last 3 commits**; expose a `--history-depth=N` flag. -- Drop merges and revert commits unless they contain the same ticket ID. - -### 5. Smart issue/PR pulling - -- Only request issue/PR body when a ticket reference is detected in branch name or diff. -- Provide a short excerpt (first 200 chars) plus a “more” flag if the user wants the full description. - -### 6. Prompt wording tweaks to reduce token waste - -| Current wording (example) | Suggested wording | -| --- | --- | -| “Generate a conventional commit message for the following changes. Include a brief summary, a detailed body, and a footer with any relevant issue numbers.” | **“Write a conventional‑commit message for the diff below. Use a one‑line summary, optional body, and footer if an issue ID is present.”** | -| “Consider the project’s style guidelines, recent commit history, and the current branch name when forming the message.” | **“Follow the project’s conventional‑commit style. Use the branch name for any ticket ID.”** | -| “If the diff is large, you may truncate it but indicate that you omitted sections.” | **“When diff > 2 k tokens, keep the first 10 hunks and add ‘…truncated’.”** | - -- Remove filler words (e.g., “please”, “kindly”). -- Use concrete limits instead of vague adjectives. - -### 7. Model‑specific context windows - -| Model size | Max diff tokens + surrounding lines | History depth | -| --- | --- | --- | -| 4 k tokens (gpt‑3.5‑turbo) | ≤ 1 k diff tokens (≈ 2 KB) | last 2 commits | -| 8 k tokens (claude‑2.1) | ≤ 2 k diff tokens | last 3 commits | -| 16 k tokens (gpt‑4‑32k) | ≤ 4 k diff tokens | last 5 commits | -| 32 k tokens+ (Claude‑3‑Sonnet) | ≤ 6 k diff tokens | last 7 commits | - -- Expose a `--max‑tokens` option that defaults based on detected model. - -## Concrete adjustment plan for `git‑ai‑commit` - -1. Add a “context profile” enum (`tiny`, `standard`, `full`). -2. Implement diff‑truncation routine that keeps first `MAX_HUNKS` (default 10) or until token budget is hit, appending `…diff truncated…`. -3. Cache static docs in `.git‑ai‑commit‑cache.json` on first run; reuse on subsequent invocations. -4. Make issue/PR lookup lazy – only if a pattern like `([A-Z]+‑\d+)` appears. -5. Refine the default prompt template (see wording table) and allow user‑override via `--prompt‑template`. -6. Add CLI flags: `--diff-lines N`, `--history-depth N`, `--include-files PATTERN`, `--exclude‑binary`, `--max‑tokens N`. -7. Document model‑aware defaults in the README and `kilo.json` command config. - -### Minimal‑bloat default prompt (example) - -```text -Write a conventional‑commit message for the staged changes below. -Use a one‑line summary (≤ 50 chars). Add a body only if the diff -contains multiple logical parts. Include a footer with the ticket -ID if the branch name contains one (e.g. feat/1234‑...). - - -``` - -All other context (project guidelines, recent commits, full issue body) is omitted unless the corresponding flags are supplied. - ---- - -### Functional conformance check (summary) - -- The diff remains the primary source; truncation only adds a clear marker, preserving spec compliance. -- Ticket extraction, optional body, and footer handling continue to follow Conventional Commits 1.0. -- Style‑guide caching does not affect the required format. -- Prompt wording explicitly demands a **conventional‑commit** header, which is exactly what the spec requires. -- Therefore the suggested changes maintain full functional conformance while reducing token usage. - ---- - -### Implementation status - -The following table records the disposition of each recommendation from this -document as implemented in `scripts/git-ai-commit`. - -| # | Recommendation | Disposition | Notes | -| --- | --- | --- | --- | -| 1 | Hunk-aware diff truncation | Implemented | `_truncate_diff` preserves whole file sections and falls back to line-boundary cut; omission marker includes file counts. | -| 2 | Two-tier context caps | Implemented | `_MAX_DIFF_CHARS_DEFAULT = 60 000` (default); `_MAX_DIFF_CHARS_ESCALATED = 400 000` (escalated path). | -| 3 | Rewrite escalation to use raw length | Implemented | `raw_len` computed from untruncated inputs; replaces post-truncation `sum(len(m["content"]))` heuristic. | -| 4 | Binary and generated-file skipping | Implemented (partial) | Binary sections and files matching `_GENERATED_FILE_GLOBS` are replaced with a one-line note. Glob list is deliberately minimal; extend as needed. | -| 5 | Reduce commit-history depth | Implemented | `_recent_log` depth reduced from 10 to 5. No `--history-depth` flag added (kept simple). | -| 6 | Trim `_SYSTEM` prompt filler | Implemented (partial) | Removed one redundant filler sentence; essential Conventional Commits rules and the "message only" constraint preserved. | -| 7 | Reject table-driven model routing | Rejected | Auto-escalation threshold retained as-is; two-tier caps provide the necessary headroom without adding routing complexity. | - -### Implementation status (subsequent fixes) - -| # | Recommendation | Disposition | Notes | -| --- | --- | --- | --- | -| 8 | Model-aware provider selection | Implemented | `_determine_kilo_provider()` routes open-weight models (qwen/*, google/*, nvidia/*, BAAI/*) to fnal-ow provider (no cost) and Azure models to fnal-azure. Default model changed from azure/claude-haiku-4-5 to qwen/qwen3-coder-next. | - ---- - -*Generated by the Kilo `git‑ai‑commit` context‑optimization work.* - -After budget exhaustion fix (2026-07-10): updated default model and added model-aware provider selection to route open-weight models to the unlimited-budget fnal-ow provider, avoiding HTTP 400 budget exceeded errors. diff --git a/scripts/git-ai-commit b/scripts/git-ai-commit deleted file mode 100755 index 36da8d25f..000000000 --- a/scripts/git-ai-commit +++ /dev/null @@ -1,1573 +0,0 @@ -#!/usr/bin/env python3 -# pylint: disable=invalid-name # hyphens are required by the git-subcommand convention -"""git-ai-commit — commit staged changes with an AI-generated message. - -See `git-ai-commit --help` for full usage. -""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import subprocess -import sys -import tempfile -import textwrap -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any, TextIO - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -_GITHUB_MODELS_BASE = "https://models.inference.ai.azure.com" -_COPILOT_API_BASE = "https://api.githubcopilot.com" -_COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token" - -# Defaults for kilo backend - only apply when user specifies them via env vars. -# No defaults are used unless explicitly set by the user. -# Support deprecated GIT_AI_COMMIT_KILO_* vars as fallback to GIT_AI_COMMIT_* -_KILO_API_ENV = os.environ.get("GIT_AI_COMMIT_API", "").strip() -if not _KILO_API_ENV: - deprecated = os.environ.get("GIT_AI_COMMIT_KILO_API", "").strip() - if deprecated: - import warnings - - warnings.warn( - "GIT_AI_COMMIT_KILO_API is deprecated; use GIT_AI_COMMIT_API instead", - DeprecationWarning, - stacklevel=2, - ) - _KILO_API_ENV = deprecated -_DEFAULT_KILO_API = _KILO_API_ENV - -_KILO_MODEL_ENV = os.environ.get("GIT_AI_COMMIT_MODEL", "").strip() -if not _KILO_MODEL_ENV: - deprecated = os.environ.get("GIT_AI_COMMIT_KILO_MODEL", "").strip() - if deprecated: - import warnings - - warnings.warn( - "GIT_AI_COMMIT_KILO_MODEL is deprecated; use GIT_AI_COMMIT_MODEL instead", - DeprecationWarning, - stacklevel=2, - ) - _KILO_MODEL_ENV = deprecated -# Default model for kilo backend - uses qwen/qwen3-coder-next from fnal-ow provider -_KILO_DEFAULT_MODEL = "qwen/qwen3-coder-next" -_DEFAULT_MODEL_KILO = _KILO_MODEL_ENV or _KILO_DEFAULT_MODEL -_DEFAULT_MODEL_OTHER = "gpt-4o" - -# Path to kilo config file. Kilo's global config directory accepts either -# kilo.json or kilo.jsonc; check both, preferring kilo.json since that is -# what `kilo-sync-models` currently writes. -_KILO_CONFIG_DIR = Path.home() / ".config" / "kilo" -_KILO_CONFIG_CANDIDATES = ( - _KILO_CONFIG_DIR / "kilo.json", - _KILO_CONFIG_DIR / "kilo.jsonc", -) - - -def _kilo_config_path() -> Path | None: - """Return the first existing kilo config file, or None if neither exists.""" - for candidate in _KILO_CONFIG_CANDIDATES: - if candidate.is_file(): - return candidate - return None - - -_MAX_DIFF_CHARS_DEFAULT = 60_000 -_MAX_DIFF_CHARS_ESCALATED = 400_000 - -# Hunk-aware diff truncation helper - -# Binary and generated file skipping - -# Patterns for generated files to skip. Adjust as needed. -_GENERATED_FILE_GLOBS = [ - "*.pb.cc", - "*.pb.h", - "*.gen.cpp", - "*.gen.hpp", - "*.generated.cpp", - "*.generated.h", -] - - -# --------------------------------------------------------------------------- -# Kilo config functions -# --------------------------------------------------------------------------- - - -def _load_kilo_config() -> dict: - """Load and parse the kilo configuration file. - - Returns a dict with 'disabled_providers' and 'provider' keys. - 'provider' maps provider names to provider configs, each containing a 'models' dict. - """ - config_path = _kilo_config_path() - if config_path is None: - return {"disabled_providers": [], "provider": {}} - try: - # Use json.loads after removing comments (kilo.jsonc supports comments; - # harmless to strip from plain kilo.json too since "//" never appears - # outside of string values there). - content = config_path.read_text(encoding="utf-8") - # A simple state-based scanner to remove comments while preserving strings. - cleaned = [] - in_string = False - string_char = "" - escaped = False - i = 0 - while i < len(content): - char = content[i] - if escaped: - cleaned.append(char) - escaped = False - i += 1 - continue - if char == "\\": - cleaned.append(char) - escaped = True - i += 1 - continue - if in_string: - if char == string_char: - in_string = False - cleaned.append(char) - else: - if char in ('"', "'"): - in_string = True - string_char = char - cleaned.append(char) - elif content.startswith("//", i): - # Find end of line - eol = content.find("\n", i) - if eol != -1: - cleaned.append("\n") - i = eol - else: - i = len(content) - elif content.startswith("/*", i): - # Find end of block comment - end = content.find("*/", i + 2) - if end == -1: - i = len(content) - else: - i = end + 1 - else: - cleaned.append(char) - i += 1 - # Parse JSON after comment removal - data = json.loads("".join(cleaned)) - # Validate structure: top-level must be a dict with required keys. - if not isinstance(data, dict): - raise ValueError("Kilo config top-level is not a mapping") - disabled = data.get("disabled_providers", []) - provider = data.get("provider", {}) - if not isinstance(disabled, (list, tuple)): - raise ValueError("disabled_providers must be a list") - if not isinstance(provider, dict): - raise ValueError("provider must be a mapping") - # Return a sanitized dict ensuring expected types. - return {"disabled_providers": list(disabled), "provider": dict(provider)} - except (OSError, json.JSONDecodeError, ValueError): - return {"disabled_providers": [], "provider": {}} - - -def _load_kilo_config_with_models() -> dict[str, dict[str, dict[str, Any]]]: - """Load kilo config and return a mapping of provider names to model dicts. - - Returns a dict mapping Kilo config provider names to dicts of model - entries, keyed by the short config key. For example: - {"fnal-ow": {"qwen3-coder-next": {"id": "qwen/qwen3-coder-next", ...}}} - """ - config = _load_kilo_config() - disabled = set(config.get("disabled_providers", [])) - provider_config = config.get("provider", {}) - mapping: dict[str, dict[str, dict[str, Any]]] = {} - - for provider_name, provider_data in provider_config.items(): - if provider_name in disabled or not isinstance(provider_data, dict): - continue - models = provider_data.get("models", {}) - if not isinstance(models, dict): - continue - mapping[provider_name] = models - - return mapping - - -def _get_kilo_provider_names() -> list[str]: - """Return the list of known Kilo config provider names.""" - config = _load_kilo_config() - disabled = set(config.get("disabled_providers", [])) - provider_config = config.get("provider", {}) - return [name for name in provider_config.keys() if name not in disabled] - - -def _find_model_key(models: dict[str, dict[str, Any]], model: str) -> str | None: - """Return the config dict key within *models* that identifies *model*. - - Matches *model* against each entry's dict key first (the form the - `kilo` CLI's `-m` flag actually accepts), then falls back to the - entry's "id" field, or its "name" field if "id" is absent. This lets - callers pass either the short config key (e.g. "haiku-4-5") or the - provider-qualified id/name (e.g. "azure/claude-haiku-4-5") and still - resolve to the same entry. - """ - if model in models: - return model - for key, entry in models.items(): - # Defensive: kilo.json entries are untrusted external data, so a - # malformed file could put a non-dict value here despite the type hint. - if not isinstance(entry, dict): - continue # type: ignore[unreachable] - if entry.get("id") == model or entry.get("name") == model: - return key - return None - - -def _resolve_kilo_model(model: str) -> str: - """Resolve a kilo model name, adding provider prefix if needed. - - Logic: - 1. If model contains "/" (e.g., "qwen/"), check if the prefix is a known - provider in the Kilo config. - 2a. If the prefix IS a known provider, the remainder is matched against - that provider's models by config key, falling back to each entry's - "id" (or "name") field. - 2b. If the prefix is NOT a known provider, the full model (including - prefix) is treated as the model name and matched the same way - across all providers. - 3. If model has no prefix, it is matched directly across all providers, - by config key first, then by "id" (or "name"). - 4. If the lookup is ambiguous (multiple providers have the model), raise an error. - - The config key is always what gets returned for the matched provider, - since that is the only form `kilo run -m` accepts; the "id"/"name" - fields are only used to identify which entry the caller meant. - - Returns the fully-qualified model name in format "provider/key". - Raises _Error if the model lookup is ambiguous. - """ - provider_models = _load_kilo_config_with_models() - known_providers = set(_get_kilo_provider_names()) - - # If no model is provided, resolve the default the same way as any - # other input (it may itself be in "id" form, e.g. "qwen/qwen3-coder-next"). - if not model: - model = _DEFAULT_MODEL_KILO - - # If model has a prefix (contains "/"), check if it's a known provider - if "/" in model: - prefix, rest = model.split("/", 1) - if prefix in known_providers: - key = _find_model_key(provider_models.get(prefix, {}), rest) - if key is not None: - return f"{prefix}/{key}" - # Not found under this provider (e.g. a model not yet synced - # into the config); pass through as-is and let kilo report - # any error. - return model - - # The prefix is NOT a known provider - treat full model as model name - # and look it up across all providers - candidate_providers = [] - candidate_keys: dict[str, str] = {} # provider -> key - for provider_name, models in provider_models.items(): - key = _find_model_key(models, model) - if key is not None: - candidate_providers.append(provider_name) - candidate_keys[provider_name] = key - - if len(candidate_providers) == 0: - # Model not found in config; return as-is - return model - elif len(candidate_providers) == 1: - # Unambiguous lookup - return f"{candidate_providers[0]}/{candidate_keys[candidate_providers[0]]}" - else: - # Ambiguous lookup - raise error with available providers - raise _Error( - f"Model '{model}' is ambiguous: found under providers " - f"{', '.join(candidate_providers)}. " - "Please disambiguate by specifying one of these providers as a prefix." - ) - - # Model has no prefix - look it up directly across all providers - candidate_providers = [] - candidate_keys = {} # provider -> key - - for provider_name, models in provider_models.items(): - key = _find_model_key(models, model) - if key is not None: - candidate_providers.append(provider_name) - candidate_keys[provider_name] = key - - if len(candidate_providers) == 0: - # Model not found in config; return as-is - return model - elif len(candidate_providers) == 1: - # Unambiguous lookup - return f"{candidate_providers[0]}/{candidate_keys[candidate_providers[0]]}" - else: - # Ambiguous lookup - raise error with available providers - raise _Error( - f"Model '{model}' is ambiguous: found under providers " - f"{', '.join(candidate_providers)}. " - "Please disambiguate by specifying one of these providers as a prefix." - ) - - -def _skip_binary_and_generated(diff: str) -> str: - """Replace binary and generated file sections in *diff* with a placeholder note. - - Binary sections are lines containing "Binary files ... differ". - Generated files are detected via filename patterns in the "diff --git" header. - The placeholder format is ``"[skipped binary/generated file: ]"``. - """ - import fnmatch - - # Split diff into sections starting with "diff --git". - sections = re.split(r"(?=^diff --git )", diff, flags=re.MULTILINE) - kept_sections: list[str] = [] - for sec in sections: - if not sec.strip(): - continue - # Binary detection: apply only to sections that consist solely of the binary line - # (i.e., no diff header). This avoids replacing the entire preamble/stat block. - if "Binary files" in sec and "diff --git" not in sec: - # Replace each binary line with a placeholder, preserving other content. - sec = re.sub( - r"^Binary files ([^ \n]+) and ([^ \n]+) differ$", - lambda m: f"[skipped binary/generated file: {m.group(2)}]", - sec, - flags=re.MULTILINE, - ) - # Generated file detection via header line. - header_match = re.search(r"^diff --git a/([^ \n]+) b/([^ \n]+)", sec, flags=re.MULTILINE) - if header_match: - path = header_match.group(2) - if any(fnmatch.fnmatch(path, pat) for pat in _GENERATED_FILE_GLOBS): - # Replace the entire generated file section with a placeholder. - kept_sections.append(f"[skipped binary/generated file: {path}]\n") - continue - # Keep original (or possibly modified) section. - kept_sections.append(sec) - return "".join(kept_sections) - - -def _truncate_diff(diff: str, max_chars: int) -> str: - r"""Truncate *diff* to *max_chars* preserving the diffstat and whole file hunks. - - - If the diff length is within the budget, return unchanged. - - Preserve the leading "git diff --cached --stat -p" stat block (the lines before the first - "diff --git"). - - Include full ``diff --git`` file sections until adding the next section would exceed - *max_chars*. - - If a single file section alone exceeds *max_chars*, fall back to cutting at a line boundary - within that section. - - If there are no file sections (or only the stat block) and it exceeds *max_chars*, - truncate the stat block at a line boundary, reserving space for the omission marker. - - Omitted sections are replaced with a note: - ``"\n\n[diff truncated: N of M files omitted to fit context budget]"``. - """ - if len(diff) <= max_chars: - return diff - - # Split the diff into a leading stat block and per‑file sections. - raw_sections = re.split(r"(?=^diff --git )", diff, flags=re.MULTILINE) - stat_block = raw_sections[0] - file_sections = raw_sections[1:] - - total_files = len(file_sections) - kept_sections: list[str] = [] - current_len = len(stat_block) - - def _add_marker(base: str, omitted_cnt: int, total_cnt: int) -> str: - marker = ( - f"\n\n[diff truncated: {omitted_cnt} of {total_cnt} files omitted " - f"to fit context budget]" - ) - return base + marker - - for sec in file_sections: - if current_len + len(sec) <= max_chars: - kept_sections.append(sec) - current_len += len(sec) - else: - # Stop adding further sections; remaining are omitted. - break - - omitted = total_files - len(kept_sections) - # Optimization: if we have room for the stat block and at least one file, - # we can keep them. If not, we'll rely on the final truncation. - result = stat_block + "".join(kept_sections) - if omitted: - result = _add_marker(result, omitted, total_files) - # Ensure result does not exceed max_chars; if it does (e.g., stat_block alone too large), - # truncate at a line boundary preserving space for the marker. - if len(result) > max_chars: - # Determine how much space we need for the marker (if omitted>0, else none) - marker_len = 0 - if omitted: - marker = f"\n\n[diff truncated: {omitted} of {total_files} " - "files omitted to fit context budget]" - marker_len = len(marker) - # Truncate stat block (or result) to fit within max_chars, leaving room for marker. - truncate_len = max_chars - marker_len - truncated = result[:truncate_len] - # Ensure we cut at a line boundary. - truncated = truncated.rsplit("\n", 1)[0] - result = truncated - if omitted: - result = _add_marker(result, omitted, total_files) - return result - - -_SYSTEM = textwrap.dedent("""\ - You are an expert software developer writing Git commit messages. - - CRITICAL — respond with ONLY one single version of the commit message, nothing else. - - Do NOT provide multiple options or candidates. - - Do NOT repeat or duplicate the commit message. - - Do NOT return both a "long" and "short" version of the same message. - - If you write the commit message, write it ONCE and only once. - - No preamble, commentary, or markdown fences. - - Your very first character must be the first character of the subject line. - - ## Conventional Commits v1.0.0 Guidelines - Specification: https://www.conventionalcommits.org/en/v1.0.0/ - - Format: [optional scope][optional !]: - [optional body] - [optional footer(s)] - - - Common types (spec allows additional types): feat, fix, docs, style, refactor, - perf, test, build, ci, chore, revert - - Breaking changes: append '!' directly before ':' (e.g., `feat!:`) OR add - `BREAKING CHANGE: ` as a footer line - - Subject: imperative mood, no trailing period, keep it brief - - Body: Explain *what* changed and *why* (motivation, problem, tradeoffs). - Required only for non-trivial changes. Body and footer each separated from - subject/footer by a blank line. - - Footer format: `Token: value` or `Token# value` (tokens use hyphens instead - of spaces, except BREAKING CHANGE which may use spaces) - - Use bullet points for lists; avoid decorative formatting - - Do NOT reproduce the diff verbatim or enumerate every changed file -""") - -# --------------------------------------------------------------------------- -# Errors -# --------------------------------------------------------------------------- - - -class _Error(Exception): - """Base error for git-ai-commit.""" - - -class _APIError(_Error): - """An error returned by a remote API.""" - - def __init__(self, msg: str, status: int = 0) -> None: - super().__init__(msg) - self.status = status - - -# --------------------------------------------------------------------------- -# Git helpers -# --------------------------------------------------------------------------- - - -def _git(*args: str) -> str: - result = subprocess.run( - ["git", *args], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - raise _Error(f"`git {' '.join(args)}` failed:\n{result.stderr.strip()}") - return result.stdout - - -def _git_config(key: str) -> str: - try: - return _git("config", "--get", key).strip() - except _Error: - return "" - - -def _git_root() -> Path: - return Path(_git("rev-parse", "--show-toplevel").strip()) - - -def _staged_diff(amend: bool = False) -> str: - if amend: - try: - # Diff the index against HEAD^, showing what the amended commit - # will contain relative to its grandparent. - return _git("diff", "--cached", "--stat", "-p", "HEAD^") - except _Error: - # Fallback: first commit in the repo has no HEAD^. - return _git("diff", "--cached", "--stat", "-p") - return _git("diff", "--cached", "--stat", "-p") - - -def _status() -> str: - return _git("status", "--short") - - -def _recent_log() -> str: - # Depth deliberately capped at 5 to keep context concise. - # Increase via a future --history-depth flag if needed. - try: - return _git("log", "--oneline", "-5") - except _Error: - return "(no prior commits)" - - -def _last_commit_message() -> str: - try: - return _git("log", "-1", "--pretty=%B").strip() - except _Error: - return "" - - -def _read_dir_md(path: Path) -> str: - """Read all Markdown files in *path* and concatenate them.""" - if not path.is_dir(): - return "" - parts: list[str] = [] - try: - items = sorted(path.glob("*.md")) - except OSError: - return "" # directory listing failed; nothing to include - for item in items: - if item.is_file(): - try: - content = item.read_text().strip() - except OSError: - continue # file disappeared or is unreadable; skip it - if content: - parts.append(f"#### {item.name}\n\n{content}") - return "\n\n".join(parts) - - -def _get_instructions(root: Path) -> str: - """Read user-level and repository-level instructions from various AI tools. - - Only procedural/communications-oriented files are included — those that - tell the model *how* to write a commit message (conventions, style, PR - process). Technical reference files such as DEVELOPING.md and - CLANG_TIDY_CONFIGURATION.md are intentionally excluded; any technical - context the model needs should come from the diff itself or be supplied - via --context. - - When an AGENTS.md (or AGENT.md) file is present and non-empty it is - treated as the authoritative summary of all project instructions. In - that case the tool-specific rule directories (.kilo/, .kiro/, .amazonq/, - .kilocode/, .gemini/) and .github/copilot-instructions.md are skipped to - avoid sending thousands of redundant tokens. - """ - user_candidates = [ - Path.home() / ".config" / "git-ai-commit" / "instructions.md", - Path.home() / ".config" / "copilot" / "instructions.md", - ] - # Repository-level file candidates. Both AGENTS.md and AGENT.md are - # checked to support projects using either convention. - agents_files = [root / "AGENTS.md", root / "AGENT.md"] - # Procedural/communications files only — no technical reference docs. - # DEVELOPING.md and CLANG_TIDY_CONFIGURATION.md are excluded: any - # relevant technical context should come from the diff or --context. - repo_files_extra = [ - root / "GEMINI.md", - root / "CLAUDE.md", - root / "CONTEXT.md", - root / "INSTRUCTIONS.md", - root / "CONTRIBUTING.md", - root / "HACKING.md", - root / "ARCHITECTURE.md", - root / "STYLEGUIDE.md", - root / ".cursorrules", - root / ".clinerules", - root / ".windsurfrules", - root / "q.rules", - root / ".kilocoderules", - root / ".gemini-rules", - ] - # Tool-specific instruction files/directories. These are skipped when an - # AGENTS.md is present, since AGENTS.md is intended to synthesise them. - tool_files = [ - root / ".github" / "copilot-instructions.md", - ] - # Both .kiro/rules/ (this project's layout) and .kiro/steering/ (used in - # some other projects) are checked. .kilo/agent/ and .kilo/command/ cover - # projects using the modern Kilo config layout without a top-level AGENTS.md. - tool_dirs = [ - root / ".amazonq" / "rules", - root / ".amazonq" / "rules" / "memory-bank", - root / ".kiro" / "rules", - root / ".kiro" / "steering", - root / ".gemini" / "rules", - root / ".kilocode", - root / ".kilo" / "agent", - root / ".kilo" / "command", - ] - - # Determine whether an AGENTS.md / AGENT.md is present and non-empty. - # If so, skip the redundant tool-specific sources. - has_agents_file = any(p.is_file() and p.stat().st_size > 0 for p in agents_files) - repo_files = agents_files + repo_files_extra - if not has_agents_file: - repo_files = repo_files + tool_files - repo_dirs = [] if has_agents_file else tool_dirs - - instructions: list[str] = [] - - for path in user_candidates: - if path.is_file(): - try: - content = path.read_text().strip() - if content: - instructions.append(f"### User-level instructions ({path})\n\n{content}") - except OSError: - continue # file unreadable; skip to next candidate - - for path in repo_files: - if path.is_file(): - try: - content = path.read_text().strip() - if content: - instructions.append(f"### Repository instructions ({path.name})\n\n{content}") - except OSError: - continue # file unreadable; skip to next candidate - - for path in repo_dirs: - if path.is_dir(): - content = _read_dir_md(path) - if content: - rel = path.relative_to(root) - instructions.append(f"### Repository instructions (from {rel}/)\n\n{content}") - - if template_path := _git_config("commit.template"): - try: - full_path = Path(os.path.expanduser(template_path)) - if not full_path.is_absolute(): - full_path = root / full_path - content = full_path.read_text(encoding="utf-8").strip() if full_path.is_file() else "" - except OSError: - content = "" # commit template unreadable; skip it - if content: - instructions.append(f"### Git commit template ({template_path})\n\n{content}") - - return "\n\n".join(instructions) - - -# --------------------------------------------------------------------------- -# Token resolution -# --------------------------------------------------------------------------- - - -def _max_tokens_for_prompt(prompt_size: int, model: str) -> int: - """Return max_tokens based on prompt size and model capabilities. - - Reasoning models (e.g., qwen/qwen3-coder-next, google/gemma4-31b) - spend significant internal thinking tokens before producing visible - output. A low max_tokens budget can cause them to return empty content - (finish_reason=stop with zero visible text) instead of an error. - - The max_tokens budget must be large enough for: - - System prompt - - User prompt (diff, status, log, instructions, context) - - Expected output (commit message with subject + body) - - For large prompts, increase max_tokens to avoid truncating the response. - This applies to all models, not just reasoning ones. - """ - # Base budget: enough for a decent commit message (subject + body) - base_tokens = 2048 - - # Reasoning models need higher budget to avoid empty responses - if model: - model_lower = model.lower() - if any(kw in model_lower for kw in ("reasoning", "4-5", "qwen3-coder-next", "gemma4-31b")): - return 8192 - - # Scale with prompt size to avoid truncated responses - if prompt_size > 50_000: - return 4096 - elif prompt_size > 25_000: - return 3072 - else: - return base_tokens - - -def _gh_config_token() -> str | None: - """Read the first oauth_token from ~/.config/gh/hosts.yml.""" - hosts = Path.home() / ".config" / "gh" / "hosts.yml" - if not hosts.exists(): - return None - try: - m = re.search(r"oauth_token:\s*(\S+)", hosts.read_text()) - return m.group(1) if m else None - except OSError: - return None - - -def _gh_cli_token() -> str | None: - """Obtain a token via `gh auth token`, if gh is installed and logged in.""" - try: - r = subprocess.run( - ["gh", "auth", "token"], - capture_output=True, - text=True, - check=False, - ) - if r.returncode == 0 and r.stdout.strip(): - return r.stdout.strip() - except FileNotFoundError: - return None # gh is not installed; no token available - return None - - -def _gh_oauth_token() -> str | None: - """Return a GitHub OAuth token from env vars, gh CLI, or hosts file. - - This deliberately ignores GIT_AI_COMMIT_TOKEN because that variable may - hold a non-GitHub key (e.g. a LiteLLM API key). Used when a genuine - GitHub OAuth token is required, such as the Copilot token exchange. - """ - for var in ("GITHUB_TOKEN", "GH_TOKEN"): - if val := os.environ.get(var, "").strip(): - return val - if tok := _gh_cli_token(): - return tok - return _gh_config_token() - - -def _token(backend: str, model: str = "") -> str: - """Resolve the best available API token for the given backend and model.""" - # GIT_AI_COMMIT_TOKEN always wins regardless of backend. - if val := os.environ.get("GIT_AI_COMMIT_TOKEN", "").strip(): - return val - - if backend == "kilo": - # For the kilo backend, only use GIT_AI_COMMIT_TOKEN. - # No fallback to GITHUB_TOKEN, GH_TOKEN, gh CLI, or hosts.yml. - # If no token is set, return empty string - let the API handle it. - return "" - - for var in ("GITHUB_TOKEN", "GH_TOKEN"): - if val := os.environ.get(var, "").strip(): - return val - - if tok := _gh_cli_token(): - return tok - - # Last resort: parse the gh hosts file directly (works in read-only - # Codespace FS where `gh auth token` errors on write). - if tok := _gh_config_token(): - return tok - - raise _Error( - "No API token found. Set GIT_AI_COMMIT_TOKEN, GITHUB_TOKEN, or run 'gh auth login'." - ) - - -# --------------------------------------------------------------------------- -# Response cleaning -# --------------------------------------------------------------------------- - - -def _clean_message(text: str) -> str: - r"""Strip model preamble and fenced code blocks from a raw API response. - - Also handles the common case where the AI returns two versions of the commit - message (e.g., a longer version followed by a shorter duplicate), or returns - the same message consecutively. - """ - # 1. Extract all fenced code blocks (if any) and concatenate them. - fences = re.findall(r"```[^\n]*\n(.*?)```", text, re.DOTALL) - if fences: - # Join the contents of all fenced blocks preserving their order. - # Strip surrounding whitespace from each block before joining. - text = "\n".join(block.strip() for block in fences) - else: - # No fenced blocks – fall back to stripping lone fence markers. - # Strip trailing lone fence (e.g. "\n```text\n") - text = re.sub(r"\n```[^\n]*\s*\Z", "", text) - # Strip leading lone fence (e.g. "```text\n") - text = re.sub(r"\A```[^\n]*\n", "", text) - - # 2. Strip leading prose preamble (now using the safer splitlines version) - if not re.match(r"^[a-z]+(?:\([^)]+\))?!?:\s", text): - # Try to remove preamble that ends with a colon and double newline - text = re.sub(r"\A.*?:\s*\n\s*\n", "", text, count=1, flags=re.DOTALL) - - # If that didn't result in a Conventional Commit format, try more - # aggressive preamble removal - if not re.match(r"^[a-z]+(?:\([^)]+\))?!?:\s", text): - lines = text.splitlines() - if lines: - first_line = lines[0].strip() - if len(first_line) > 72 or re.match( - r"^(?:The |This |These |I |Here |Note:|Below|Above|A |An )", first_line - ): - # If the first line looks like preamble, remove everything - # until the first double newline - remainder = re.sub(r"\A.*?\n\s*\n", "", text, count=1, flags=re.DOTALL) - if remainder.strip(): - text = remainder - else: - # If nothing is left after removing preamble, the whole text was preamble - text = "" - - # Final check: if the resulting text still looks like preamble - # (e.g. single line without CC format) and we just did a remainder - # sub that might have left a trailing line - if text.strip() and not re.match(r"^[a-z]+(?:\([^)]+\))?!?:\s", text.strip()): - # If it's just one line and matches preamble patterns, clear it - if len(text.splitlines()) <= 1 and re.match( - r"^(?:The |This |These |I |Here |Note:|Below|Above|A |An )", - text.strip(), - ): - text = "" - - # 3. Remove repeated content - now that preamble is stripped - cleaned = text.strip() - - # Early exit for empty result - if not cleaned: - return "" - - # Apply deduplication until no more changes occur - # This handles cases where the message appears multiple times (2x, 3x, etc.) - prev_cleaned = None - while prev_cleaned != cleaned: - prev_cleaned = cleaned - cleaned = _deduplicate_once(cleaned) - if not cleaned: - return "" - - return cleaned - - -def _deduplicate_once(text: str) -> str: - """Perform a single deduplication pass on text. - - Returns the text with duplicates removed. If no duplicates are found, - returns the original text. - """ - # Case 1: two identical sections separated by double newline – keep first - sections = text.split("\n\n", 1) - if len(sections) == 2 and sections[0].strip() == sections[1].strip(): - return sections[0].strip() - - # Case 2: Handle duplicate messages where section 2 contains section 1's content - # (with possible modification or truncation) - if len(sections) == 2: - sec1 = sections[0].strip() - sec2 = sections[1].strip() - sec1_lines = sec1.splitlines() - sec2_lines = sec2.splitlines() - if sec1_lines and sec2_lines: - sec1_subject = sec1_lines[0].strip() - - # Check if section 1's subject appears in section 2 - # If so, section 2 may contain duplicate content - duplicate_subject_idx = None - for i, line in enumerate(sec2_lines): - if line.strip() == sec1_subject: - duplicate_subject_idx = i - break - - if duplicate_subject_idx is not None: - # The subject line from section 1 appears in section 2 - # Keep only the part of section 2 before the duplicate subject - body_before_duplicate = sec2_lines[:duplicate_subject_idx] - if body_before_duplicate: - # Keep section 1 + body from section 2 (before duplicate) - return sec1 + "\n\n" + "\n".join(body_before_duplicate).strip() - else: - # Section 2 starts with the subject - just return section 1 - return sec1 - - # Check if section 2's first line matches section 1's subject - sec2_subject = sec2_lines[0].strip() - if sec1_subject == sec2_subject: - len_sec1 = len(sec1_lines) - len_sec2 = len(sec2_lines) - if len_sec2 <= len_sec1: - # Check if sec2 is essentially sec1 (maybe slightly different) - matching_lines = sum( - 1 for l1, l2 in zip(sec1_lines, sec2_lines, strict=False) if l1 == l2 - ) - if matching_lines >= len(sec2_lines) * 0.8: # 80% match threshold - return sec1 - elif ( - matching_lines >= len(sec1_lines) * 0.8 - ): # 80% match threshold (sec1 is shorter) - return sec2 - - # Case 3: Check for repeated subject patterns across sections - # Use the subject-based deduplicator - cleaned = _remove_duplicate_paragraphs_with_subject(text) - return cleaned - - -def _remove_duplicate_paragraphs_with_subject(text: str) -> str: - """Remove duplicate or near-duplicate content based on subject lines. - - If multiple paragraphs start with the same subject line pattern - (e.g., 'refactor(scripts): ...'), keep only the first and longest one. - """ - paragraphs = text.split("\n\n") - if len(paragraphs) <= 1: - return text - - # Extract subject from each paragraph - subjects: dict[int, str] = {} # index -> raw subject line - for i, para in enumerate(paragraphs): - lines = para.strip().splitlines() - if lines: - subjects[i] = lines[0].strip() - - # Find and remove duplicate subjects (keep first/longest) - seen_subjects: dict[str, int] = {} # subject -> first index seen - to_keep: list[int] = [] - for i, para in enumerate(paragraphs): - if i not in subjects: - to_keep.append(i) - continue - subject = subjects[i] - if subject not in seen_subjects: - seen_subjects[subject] = i - to_keep.append(i) - else: - # Subject already seen - check if this one is longer - existing_idx = seen_subjects[subject] - existing_len = len(paragraphs[existing_idx]) - current_len = len(para) - if current_len > existing_len: - # This one is longer, replace the existing one - to_keep = [j for j in to_keep if j != existing_idx] - seen_subjects[subject] = i - to_keep.append(i) - # Otherwise skip this one (it's a shorter duplicate) - - return "\n\n".join(paragraphs[i].strip() for i in sorted(to_keep) if paragraphs[i].strip()) - - -# --------------------------------------------------------------------------- -# AI API -# --------------------------------------------------------------------------- - - -def _copilot_token(oauth: str) -> str: - """Exchange a GitHub OAuth token for a short-lived Copilot token.""" - req = urllib.request.Request( - _COPILOT_TOKEN_URL, - headers={ - "Authorization": f"token {oauth}", - "Accept": "application/json", - }, - ) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read())["token"] - except urllib.error.HTTPError as exc: - body = exc.read().decode(errors="replace") - raise _APIError(f"Copilot token exchange failed (HTTP {exc.code}): {body[:300]}") from exc - except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc: - raise _APIError(f"Copilot token exchange failed: {exc}") from exc - - -def _chat_kilo(model: str, messages: list[dict[str, str]], token: str, api_base: str) -> str: - """Send *messages* via the kilo CLI and return the reply. - - The kilo backend always uses --agent=code and resolves model names - against the kilo config to add provider prefixes if needed. - """ - # Build the prompt from messages - system_content = "" - user_content = "" - for m in messages: - if m["role"] == "system": - system_content = m["content"] - elif m["role"] == "user": - user_content = m["content"] - - if system_content: - prompt = f"System Instructions:\n{system_content}\n\n{user_content}" - else: - prompt = user_content - - # Resolve model name against kilo config (add provider prefix if needed) - resolved_model = _resolve_kilo_model(model or _KILO_DEFAULT_MODEL) - - # Build kilo run command with --agent=code - cmd = ["kilo", "run", "--agent=code"] - if resolved_model: - cmd += ["-m", resolved_model] - - # Use environment variables for authentication and API URL - env = os.environ.copy() - if token: - # Clear KILO_SERVER_PASSWORD if we are setting it from GIT_AI_COMMIT_TOKEN - # to ensure the tool's preferred token takes precedence. - env["KILO_SERVER_PASSWORD"] = token - else: - # Ensure no inherited KILO_SERVER_PASSWORD is forwarded when no token is provided. - env.pop("KILO_SERVER_PASSWORD", None) - if api_base: - env["KILO_SERVER_URL"] = api_base - - try: - result = subprocess.run( - cmd, - input=prompt, - capture_output=True, - text=True, - check=False, - timeout=60, - env=env, - ) - except subprocess.TimeoutExpired as exc: - raise _APIError("kilo run timed out after 60 seconds.", status=0) from exc - except FileNotFoundError as exc: - raise _Error("kilo CLI not found in PATH. Ensure it is installed and available.") from exc - - if result.returncode != 0: - raise _APIError( - (f"kilo run failed (exit code {result.returncode}):\n{result.stderr.strip()}"), - result.returncode, - ) - - return _clean_message(result.stdout) - - -def _chat( - token: str, - api_base: str, - model: str, - messages: list[dict[str, str]], - backend: str = "kilo", -) -> str: - """Send *messages* to the chat completions endpoint and return the reply.""" - # Scale max_tokens with prompt size to avoid truncated responses. - # This applies to all models, not just reasoning ones. - prompt_size = sum(len(m["content"]) for m in messages) - max_tokens = _max_tokens_for_prompt(prompt_size, model) - - # Use kilo CLI for the kilo backend - if backend == "kilo": - return _chat_kilo(model, messages, token, api_base) - - payload = json.dumps( - { - "model": model, - "messages": messages, - "temperature": 0.2, - "max_tokens": max_tokens, - } - ).encode() - req = urllib.request.Request( - f"{api_base}/chat/completions", - data=payload, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=60) as resp: - data = json.loads(resp.read()) - raw = data["choices"][0]["message"]["content"] - return _clean_message(raw) - except urllib.error.HTTPError as exc: - body = exc.read().decode(errors="replace") - raise _APIError(f"HTTP {exc.code}: {body[:500]}", exc.code) from exc - except urllib.error.URLError as exc: - raise _APIError(f"Network error: {exc}") from exc - except (KeyError, IndexError, json.JSONDecodeError) as exc: - raise _APIError(f"Unexpected API response: {exc}") from exc - - -def _chat_antigravity(model: str, messages: list[dict[str, str]]) -> str: - """Send *messages* to the antigravity CLI (agy) and return the reply.""" - system_content = "" - user_content = "" - for m in messages: - if m["role"] == "system": - system_content = m["content"] - elif m["role"] == "user": - user_content = m["content"] - - if system_content: - prompt = f"System Instructions:\n{system_content}\n\n{user_content}" - else: - prompt = user_content - - cmd = ["agy", "-p", "-"] - if model: - cmd += ["--model", model] - - try: - result = subprocess.run( - cmd, - input=prompt, - capture_output=True, - text=True, - check=False, - timeout=60, - ) - except subprocess.TimeoutExpired as exc: - raise _APIError("Antigravity CLI (agy) timed out after 60 seconds.", status=0) from exc - except FileNotFoundError as exc: - raise _Error( - "Antigravity CLI (agy) not found in PATH. Ensure it is installed and available." - ) from exc - - if result.returncode != 0: - raise _APIError( - ( - "Antigravity CLI (agy) failed " - f"(exit code {result.returncode}):\n{result.stderr.strip()}" - ), - result.returncode, - ) - - return _clean_message(result.stdout) - - -# --------------------------------------------------------------------------- -# Prompt construction -# --------------------------------------------------------------------------- - - -def _build_messages( - diff: str, - status: str, - log: str, - context: str, - root: Path, - last_msg: str = "", - instr: str = "", - amend_no_diff: bool = False, - max_diff_chars: int = _MAX_DIFF_CHARS_DEFAULT, -) -> list[dict[str, str]]: - """Build the system + user message list for the chat completion request. - - If `amend_no_diff` is True, there are no staged changes; the model should rewrite - the previous commit message to conform to Conventional Commits guidelines. - """ - # Apply hunk-aware truncation using the helper. - diff = _truncate_diff(diff, max_diff_chars) - - parts: list[str] = [ - f"Repository: {root}\n", - f"## Staged diff (git diff --cached)\n\n```diff\n{diff}\n```", - f"\n## Status (git status --short)\n\n```\n{status.strip()}\n```", - f"\n## Recent commits (git log --oneline -5)\n\n```\n{log.strip()}\n```", - ] - if last_msg: - parts.append(f"\n## Previous commit message\n\n```\n{last_msg}\n```") - if amend_no_diff: - parts.append( - "\n## Note\n\nNo staged changes are present. Rewrite the above previous commit " - "message to follow the Conventional Commits format and improve its wording." - ) - if instr: - parts.append(f"\n## Agent instructions\n\n{instr}") - if context: - parts.append(f"\n## Supplemental context\n\n{context}") - if amend_no_diff: - parts.append( - "\n\nRewrite the previous commit message to follow Conventional Commits guidelines." - " Do not simply repeat it; provide an improved version that complies with the format" - " and uses clearer wording." - " Respond with only the revised commit message, starting with the subject line." - ) - else: - parts.append( - "\n\nWrite the commit message for the staged changes above." - " Remember: respond with the commit message text only —" - " start directly with the subject line." - ) - - return [ - {"role": "system", "content": _SYSTEM}, - {"role": "user", "content": "\n".join(parts)}, - ] - - -# --------------------------------------------------------------------------- -# Misc helpers -# --------------------------------------------------------------------------- - - -def _edit(text: str) -> str: - """Open *text* in $EDITOR and return the edited, comment-stripped result.""" - # Determine which editor to invoke. - # If stdin is a TTY, prefer $VISUAL, then $EDITOR; otherwise prefer $EDITOR, then $VISUAL. - if sys.stdin.isatty(): - editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi" - else: - editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".txt", prefix="git-ai-commit-", delete=False - ) as fh: - fh.write(text) - fh.write( - "\n\n" - "# ─── Lines starting with '#' are ignored. ───────────────────────\n" - "# An empty message aborts the commit.\n" - ) - tmp = Path(fh.name) - try: - try: - subprocess.run([editor, str(tmp)], check=True) - except FileNotFoundError: - raise _Error(f"Editor not found: {editor!r}. Set $EDITOR or $VISUAL.") from None - except subprocess.CalledProcessError as exc: - raise _Error(f"Editor exited with status {exc.returncode}.") from exc - try: - lines = tmp.read_text(encoding="utf-8").splitlines() - except (OSError, UnicodeDecodeError) as exc: - raise _Error(f"Could not read editor output: {exc}") from exc - return "\n".join(line for line in lines if not line.startswith("#")).strip() - finally: - tmp.unlink(missing_ok=True) - - -def _confirm(prompt: str) -> bool: - """Prompt for y/N, opening /dev/tty if stdin is a pipe.""" - try: - print(f"{prompt} [y/N] ", end="", flush=True) - if sys.stdin.isatty(): - reply = sys.stdin.readline().strip().lower() - else: - with open("/dev/tty", encoding="utf-8") as source: - reply = source.readline().strip().lower() - return reply in ("y", "yes") - except (EOFError, KeyboardInterrupt, OSError): - print() - return False - - -def _print_rule(msg: str, *, file: TextIO = sys.stderr) -> None: - print(f"\n{'─' * 72}\n{msg}\n{'─' * 72}\n", file=file) - - -# --------------------------------------------------------------------------- -# Backend resolution -# --------------------------------------------------------------------------- - -_BACKENDS = ("kilo", "github-models", "copilot", "antigravity", "agy") - - -def _resolve_backend_and_api(backend: str, api_override: str) -> tuple[str, str]: - """Return (backend, api_base) after applying any --api override.""" - if backend in ("antigravity", "agy"): - return backend, "" - if api_override: - return backend, api_override - if backend == "kilo": - # For kilo backend, only use API URL if explicitly set via env var. - # No default URL - user must set GIT_AI_COMMIT_API or use --api. - return backend, _DEFAULT_KILO_API - if backend == "copilot": - return backend, _COPILOT_API_BASE - return backend, _GITHUB_MODELS_BASE - - -def _default_model(backend: str) -> str: - if backend in ("antigravity", "agy"): - return "" - return _DEFAULT_MODEL_KILO if backend == "kilo" else _DEFAULT_MODEL_OTHER - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def main() -> None: - """Parse arguments, generate a commit message, and optionally commit.""" - default_backend = os.environ.get("GIT_AI_COMMIT_BACKEND", "kilo") - if default_backend not in _BACKENDS: - default_backend = "kilo" - - # Split argv on the first `--` separator. Everything after `--` is passed - # verbatim to `git commit` without any interpretation by this script. - argv = sys.argv[1:] - if "--" in argv: - sep = argv.index("--") - extra_git_args: list[str] = argv[sep + 1 :] - argv = argv[:sep] - else: - extra_git_args = [] - - ap = argparse.ArgumentParser( - prog="git-ai-commit", - description="Commit staged changes with an AI-generated commit message.", - epilog=( - "Supplemental context can be piped on stdin:\n" - " echo 'Fixes #42' | git-ai-commit --yes\n\n" - "Can also be invoked as a Git subcommand:\n" - " git ai-commit [OPTIONS]\n\n" - "Options after -- are forwarded verbatim to git commit:\n" - " git ai-commit --yes -- --no-verify --signoff" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - ap.add_argument("--context", metavar="TEXT", help="Supplemental context for the AI") - ap.add_argument( - "--backend", - choices=_BACKENDS, - default=default_backend, - help=( - "API backend to use: kilo (FNAL LiteLLM, default), " - "github-models, copilot, antigravity, or agy " - "(env: GIT_AI_COMMIT_BACKEND)" - ), - ) - ap.add_argument( - "--model", - default=os.environ.get("GIT_AI_COMMIT_MODEL", ""), - metavar="MODEL", - help=( - f"Model name — defaults to {_DEFAULT_MODEL_KILO} for the kilo " - f"backend and {_DEFAULT_MODEL_OTHER} for others " - "(env: GIT_AI_COMMIT_MODEL, GIT_AI_COMMIT_KILO_MODEL)" - ), - ) - ap.add_argument( - "--api", - default=os.environ.get("GIT_AI_COMMIT_API", ""), - metavar="URL", - help="Override the API base URL (env: GIT_AI_COMMIT_API)", - ) - ap.add_argument("--dry-run", action="store_true", help="Print message; do not commit") - ap.add_argument("--amend", action="store_true", help="Amend the previous commit") - ap.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt") - ap.add_argument("-e", "--edit", action="store_true", help="Open message in $EDITOR") - ap.add_argument( - "--no-instructions", - action="store_true", - help="Skip all instruction/context files; send only the diff, status, and log", - ) - args = ap.parse_args(argv) - - backend, api_base = _resolve_backend_and_api(args.backend, args.api) - # Collect supplemental context from --context and/or stdin. - ctx_parts: list[str] = [] - if args.context: - ctx_parts.append(args.context) - if not sys.stdin.isatty(): - data = sys.stdin.read().strip() - if data: - ctx_parts.append(data) - context = "\n\n".join(ctx_parts) - - try: - if backend == "kilo": - # For kilo backend, ensure model has provider prefix by looking it up in config - model_name = args.model.strip() or _default_model(backend) - model = _resolve_kilo_model(model_name) - else: - model = args.model.strip() or _default_model(backend) - - root = _git_root() - diff = _staged_diff(args.amend) - # Use a clean staged diff (index vs HEAD) to determine if there are new changes, - # avoiding the amended commit's contents which are included in args.amend diff. - staged_only_diff = _staged_diff(False) - # Skip binary and generated file sections before any truncation. - diff = _skip_binary_and_generated(diff) - status = _status() - log = _recent_log() - last_msg = _last_commit_message() if args.amend else "" - instr = "" if args.no_instructions else _get_instructions(root) - tok = "" if backend in ("antigravity", "agy") else _token(backend, model) - except _Error as exc: - print(f"git-ai-commit: {exc}", file=sys.stderr) - sys.exit(1) - - amend_no_diff = False - if not staged_only_diff.strip(): - if not args.amend: - print("Nothing staged. Use 'git add' first.", file=sys.stderr) - sys.exit(0) - if not last_msg: - # --amend with nothing staged and no retrievable prior commit - # message would produce a near-empty prompt; abort early. - print( - "Nothing staged and no prior commit message found. " - "Stage changes or ensure HEAD exists before amending.", - file=sys.stderr, - ) - sys.exit(1) - # No staged changes but we have a prior commit message; rewrite it. - amend_no_diff = True - - max_diff_chars = _MAX_DIFF_CHARS_DEFAULT - # Decision on escalation cap based on raw prompt size and resolved model. - # We compute raw length (pre-truncation) to decide if we should use an escalated cap. - raw_prompt_size = len(diff) + len(status) + len(log) + len(context) + len(instr) - - # If using kilo, we can escalate the cap for high-context models if the prompt is large. - if ( - backend == "kilo" - and not args.model.strip() - and not ( - os.environ.get("GIT_AI_COMMIT_MODEL") or os.environ.get("GIT_AI_COMMIT_KILO_MODEL") - ) - and raw_prompt_size > _MAX_DIFF_CHARS_DEFAULT - ): - # For the default kilo path (qwen/qwen3-coder-next), we allow the escalated cap. - max_diff_chars = _MAX_DIFF_CHARS_ESCALATED - - msgs = _build_messages( - diff, - status, - log, - context, - root, - last_msg, - instr, - amend_no_diff, - max_diff_chars=max_diff_chars, - ) - - print( - f"Generating commit message using {model if model else ''} via {backend}…", - file=sys.stderr, - ) - - # For the copilot backend, exchange the OAuth token for a short-lived one. - # Explicitly resolve a GitHub OAuth token rather than using tok directly — - # tok may be a non-GitHub key (e.g. GIT_AI_COMMIT_TOKEN set to a LiteLLM - # API key) that cannot be exchanged for a Copilot token. - if backend == "copilot": - try: - gh_tok = _gh_oauth_token() - if not gh_tok: - raise _Error("No GitHub OAuth token found for Copilot backend.") - tok = _copilot_token(gh_tok) - except _Error as exc: - print(f"git-ai-commit: {exc}", file=sys.stderr) - sys.exit(1) - - try: - if backend in ("antigravity", "agy"): - message = _chat_antigravity(model, msgs) - else: - message = _chat(tok, api_base, model, msgs, backend) - except _APIError as primary_err: - # If GitHub Models fails with an auth or connectivity error, retry via - # the Copilot API. This fallback is specific to the github-models - # backend; kilo and copilot backends don't have a meaningful fallback. - if backend == "github-models" and primary_err.status in (0, 401, 403): - print( - f"GitHub Models API failed ({primary_err}); retrying via Copilot API…", - file=sys.stderr, - ) - try: - # The Copilot token exchange requires a GitHub OAuth token. - # Re-resolve one explicitly rather than reusing tok, which - # may be a non-GitHub key (e.g. GIT_AI_COMMIT_TOKEN set to a - # LiteLLM API key). - gh_tok = _gh_oauth_token() - if not gh_tok: - raise _Error("No GitHub OAuth token found for Copilot fallback.") - copilot_tok = _copilot_token(gh_tok) - message = _chat(copilot_tok, _COPILOT_API_BASE, model, msgs, "copilot") - except _Error as exc: - print(f"git-ai-commit: {exc}", file=sys.stderr) - sys.exit(1) - else: - print(f"git-ai-commit: {primary_err}", file=sys.stderr) - sys.exit(1) - except _Error as err: - print(f"git-ai-commit: {err}", file=sys.stderr) - sys.exit(1) - - # Empty message check - must happen before dry-run handling and editing - if not message.strip(): - print("git-ai-commit: Empty message — aborting.", file=sys.stderr) - sys.exit(0) - - if args.dry_run: - # Write clean message to stdout so it can be captured by scripts. - # We omit _print_rule(message) above to avoid doubling the output in dry-run mode. - print(message) - return - - if args.edit: - # Open editor automatically and bypass confirmation. - try: - message = _edit(message) - except _Error as exc: - print(f"git-ai-commit: {exc}", file=sys.stderr) - sys.exit(1) - if not message.strip(): - print("Empty message — aborting.", file=sys.stderr) - sys.exit(0) - _print_rule(message) - else: - _print_rule(message) - if not args.yes: - # Prompt user with extended options: yes, no, edit. - print("Commit with this message? [y/N/e] ", end="", flush=True) - try: - if sys.stdin.isatty(): - reply = sys.stdin.readline().strip().lower() - else: - with open("/dev/tty", encoding="utf-8") as source: - reply = source.readline().strip().lower() - except (EOFError, KeyboardInterrupt, OSError): - print() - reply = "" - if reply in ("y", "yes"): - pass # proceed to commit - elif reply in ("e", "edit"): - try: - message = _edit(message) - except _Error as exc: - print(f"git-ai-commit: {exc}", file=sys.stderr) - sys.exit(1) - if not message.strip(): - print("Empty message — aborting.", file=sys.stderr) - sys.exit(0) - _print_rule(message) - else: - print("Aborted.", file=sys.stderr) - sys.exit(0) - - cmd = ["git", "commit"] - if args.amend: - cmd.append("--amend") - cmd += ["-m", message] - if extra_git_args: - cmd += extra_git_args - try: - subprocess.run(cmd, check=True) - except subprocess.CalledProcessError as exc: - sys.exit(exc.returncode) - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/scripts/man/man1/git-ai-commit.1 b/scripts/man/man1/git-ai-commit.1 deleted file mode 100644 index 1cd3aef22..000000000 --- a/scripts/man/man1/git-ai-commit.1 +++ /dev/null @@ -1,261 +0,0 @@ -.\" git-ai-commit(1) — commit staged changes with an AI-generated message. -.\" To make this page available via `git help ai-commit`, add the directory -.\" containing this file's parent (scripts/man) to MANPATH: -.\" export MANPATH="$MANPATH:/path/to/phlex/scripts/man" -.\" or symlink/copy it to $(git --man-path)/man1/git-ai-commit.1. -.TH GIT\-AI\-COMMIT 1 "" "phlex" "Git AI Tools" -.SH NAME -git\-ai\-commit \- commit staged changes with an AI\-generated message -.SH SYNOPSIS -.nf -\fBgit\-ai\-commit\fR [\fIOPTIONS\fR] [\fB\-\-\fR \fIGIT\-COMMIT\-OPTIONS\fR] -\fBgit ai\-commit\fR [\fIOPTIONS\fR] [\fB\-\-\fR \fIGIT\-COMMIT\-OPTIONS\fR] -\fBecho\fR \fI"context"\fR | \fBgit ai\-commit\fR \fB\-\-yes\fR -.fi -.SH DESCRIPTION -\fBgit\-ai\-commit\fR analyses the Git index of the current repository and -generates a detailed, informative commit message using a language model. -It then optionally runs \fBgit commit\fR with the generated message. -.PP -The tool follows the git subcommand plugin convention: because the script is -named \fBgit\-ai\-commit\fR and resides on \fBPATH\fR, Git recognises it as -\fBgit ai\-commit\fR. -.PP -Supplemental context can be piped on standard input: -.PP -.RS 4 -.nf -echo "Fixes #42" | git ai\-commit \-\-yes -.fi -.RE -.SH CONTEXT SOURCES -The following sources are read automatically and included in the prompt -sent to the language model. Pass \fB\-\-no\-instructions\fR to suppress all -sources except the diff, status, and log. -.TP -\fBStaged diff\fR -Output of \fBgit diff \-\-cached \-\-stat \-p\fR, with hunk\-aware truncation -applied to whole file sections and binary/generated files skipped. -.TP -\fBGit status\fR -Output of \fBgit status \-\-short\fR. -.TP -\fBRecent commits\fR -Output of \fBgit log \-\-oneline \-5\fR. -.TP -\fBRepository guidance\fR -\fBAGENTS.md\fR, \fBAGENT.md\fR, \fBGEMINI.md\fR, \fBCLAUDE.md\fR, \fBCONTEXT.md\fR, -\fBINSTRUCTIONS.md\fR, \fBCONTRIBUTING.md\fR, \fBHACKING.md\fR, \fBARCHITECTURE.md\fR, -\fBSTYLEGUIDE.md\fR, \fB.cursorrules\fR, \fB.clinerules\fR, \fB.windsurfrules\fR, -\fBq.rules\fR, \fB.kilocoderules\fR, \fB.gemini\-rules\fR. -.TP -\fBTool\-specific rules\fR -\fB.kilo/agent/\fR, \fB.kilo/command/\fR, \fB.amazonq/rules/\fR, \fB.kiro/rules/\fR, -\fB.kiro/steering/\fR, \fB.gemini/rules/\fR, \fB.kilocode/\fR, -\fB.github/copilot\-instructions.md\fR. -These are skipped when \fBAGENTS.md\fR or \fBAGENT.md\fR is present and -non\-empty, since those files are intended to synthesise the tool\-specific -rules. -.TP -\fBUser\-level instructions\fR -\fB~/.config/git\-ai\-commit/instructions.md\fR (primary), -\fB~/.config/copilot/instructions.md\fR (fallback). -.TP -\fBGit commit template\fR -The file referenced by \fBgit config commit.template\fR, if set. -.SH OPTIONS -.TP -\fB\-\-amend\fR -Amend the previous commit. The prior commit message is included as -context so the model can revise it based on the newly staged changes. -Mutually exclusive with \fB\-\-dry\-run\fR in effect (the generated message is -still shown for confirmation). -.TP -\fB\-\-context\fR \fITEXT\fR -Additional context or instructions to append to the prompt. Useful for -noting issue numbers, rationale, or constraints the model should consider. -Context provided on standard input is appended after any \fB\-\-context\fR text. -.TP -\fB\-\-backend\fR \fIBACKEND\fR -API backend to use. One of \fBkilo\fR (default), \fBgithub\-models\fR, -\fBcopilot\fR, \fBantigravity\fR, or \fBagy\fR. See \fBBACKENDS\fR below. -Overridden by the \fBGIT_AI_COMMIT_BACKEND\fR environment variable. -.TP -\fB\-\-model\fR \fIMODEL\fR -Model name to use. For the \fBkilo\fR backend, the default is -\fBqwen/qwen3\-coder\-next\fR (resolved via the model name or with provider -prefix). If a model name without a provider prefix is provided (e.g., -\fBqwen3\-coder\-next\fR), \fBgit\-ai\-commit\fR looks up the model in -\fB~/.config/kilo/kilo.jsonc\fR to add the appropriate provider prefix -(e.g., \fBqwen/qwen3\-coder\-next\fR). For other backends, the defaults are: -\fBgpt\-4o\fR for github\-models and copilot, none for antigravity/agy. -Overridden by the -\fBGIT_AI_COMMIT_MODEL\fR environment variable. -.TP -\fB\-\-api\fR \fIURL\fR -Optional override of the API base URL for the \fBkilo\fR backend. -If neither \fB\-\-api\fR nor \fBGIT_AI_COMMIT_API\fR is provided, the -Kilo CLI uses its configured endpoint from \fB~/.config/kilo/kilo.jsonc\fR. -Overridden by the \fBGIT_AI_COMMIT_API\fR environment variable. -.TP -\fB\-\-dry\-run\fR -Generate the commit message and print it to standard output without -running \fBgit commit\fR. Useful for scripting or inspection. -.TP -\fB\-y\fR, \fB\-\-yes\fR -Skip the interactive confirmation prompt and commit immediately. -.TP -\fB\-e\fR, \fB\-\-edit\fR -Open the generated message in \fB$VISUAL\fR (if standard input is a terminal and -\fB$VISUAL\fR is set), otherwise \fB$EDITOR\fR, with \fBvi\fR as fallback, before -committing. Lines beginning with \fB#\fR are stripped from the edited message. -An empty edited message aborts the commit. Typing \fBe\fR at the confirmation -prompt triggers the same editor flow. -.TP -\fB\-\-no\-instructions\fR -Skip all instruction and context files; include only the diff, status, -and recent log in the prompt. Reduces token usage and latency. -.TP -\fB\-h\fR, \fB\-\-help\fR -Print a short usage summary and exit. -.TP -\fB\-\-\fR \fIGIT\-COMMIT\-OPTIONS\fR -Everything after the \fB\-\-\fR separator is forwarded verbatim to -\fBgit commit\fR without interpretation. Use this to pass options that -\fBgit\-ai\-commit\fR does not handle directly, for example: -.RS 8 -.nf -git ai\-commit \-\-yes \-\- \-\-no\-verify \-\-signoff -git ai\-commit \-\-yes \-\- \-\-gpg\-sign=KEYID -.fi -.RE -.SH BACKENDS -.TP -\fBkilo\fR -LiteLLM\-compatible endpoint via the \fBkilo\fR CLI (\fBkilo run \-\-agent=code\fR). -The kilo backend reads its configuration from \fB~/.config/kilo/kilo.jsonc\fR. -Model names without a provider prefix are auto\-resolved by looking up the -model in the kilo config. The \fB\-\-agent=code\fR option is always passed to -the kilo CLI. -.PP -Token is taken from \fBGIT_AI_COMMIT_TOKEN\fR and passed to the kilo CLI via -the \fBKILO_SERVER_PASSWORD\fR environment variable. If unset, no token is -sent to the API. The API base URL can be overridden via \fBGIT_AI_COMMIT_API\fR -or passed as \fBKILO_SERVER_URL\fR; if neither is provided, the Kilo CLI uses -its configured endpoint from \fB~/.config/kilo/kilo.jsonc\fR. - -\fBgithub\-models\fR -GitHub Models inference API (\fIhttps://models.inference.ai.azure.com\fR). -Default model: \fBgpt\-4o\fR. -Falls back to the \fBcopilot\fR backend on HTTP 401 or 403. -Token resolution order: -.RS 8 -.IP "1." 4 -\fBGIT_AI_COMMIT_TOKEN\fR -.IP "2." 4 -\fBGITHUB_TOKEN\fR / \fBGH_TOKEN\fR -.IP "3." 4 -\fBgh auth token\fR -.IP "4." 4 -\fB~/.config/gh/hosts.yml\fR -.RE -.TP -\fBcopilot\fR -GitHub Copilot API (\fIhttps://api.githubcopilot.com\fR). Exchanges a -GitHub OAuth token for a short\-lived Copilot bearer token before each -request. Default model: \fBgpt\-4o\fR. -Token resolution order: same as \fBgithub\-models\fR. -.TP -\fBantigravity\fR (or \fBagy\fR) -Antigravity CLI (\fBagy\fR). Runs the prompt non\-interactively via -\fBagy \-p \-\fR. If a model is specified via \fB\-\-model\fR, it is forwarded -as \fB\-\-model\fR to \fBagy\fR. Otherwise, \fBagy\fR uses its default -configured model. No API token or URL resolution is required for this -backend. -.SH ENVIRONMENT -.TP -\fBGIT_AI_COMMIT_TOKEN\fR -API token. For the kilo backend, this is optional; if unset, no token -is sent to the API. For other backends, this takes precedence over -all other token sources. -.TP -\fBGIT_AI_COMMIT_MODEL\fR -Default model name. Overrides the backend default. -.TP -\fBGIT_AI_COMMIT_API\fR -Optional API base URL override for the \fBkilo\fR backend. When not set, the -Kilo CLI uses its configured endpoint from \fB~/.config/kilo/kilo.jsonc\fR. -.TP -\fBGIT_AI_COMMIT_BACKEND\fR -Default backend (\fBkilo\fR, \fBgithub\-models\fR, \fBcopilot\fR, \fBantigravity\fR, or \fBagy\fR). -.TP -\fBGIT_AI_COMMIT_KILO_API\fR -Deprecated. Use \fBGIT_AI_COMMIT_API\fR instead. -.TP -\fBGIT_AI_COMMIT_KILO_MODEL\fR -Deprecated. Use \fBGIT_AI_COMMIT_MODEL\fR instead. -.TP -\fBEDITOR\fR, \fBVISUAL\fR -Editor invoked by \fB\-\-edit\fR. \fB$VISUAL\fR is preferred on a TTY (when standard -input is a terminal and \fB$VISUAL\fR is set), otherwise \fB$EDITOR\fR is used; -\fBvi\fR is the final fallback when neither is set. -.SH EXAMPLES -Stage all changes and commit with a generated message, confirming interactively: -.PP -.RS 4 -.nf -git add \-p -git ai\-commit -.fi -.RE -.PP -Amend the last commit with an updated message reflecting new staged changes: -.PP -.RS 4 -.nf -git add \-u -git ai\-commit \-\-amend \-\-yes -.fi -.RE -.PP -Preview the generated message without committing: -.PP -.RS 4 -.nf -git ai\-commit \-\-dry\-run -.fi -.RE -.PP -Commit immediately, skipping pre\-commit hooks, using the copilot backend: -.PP -.RS 4 -.nf -git ai\-commit \-\-backend copilot \-\-yes \-\- \-\-no\-verify -.fi -.RE -.PP -Provide extra context from stdin: -.PP -.RS 4 -.nf -echo "Closes #1234; keep message terse" | git ai\-commit \-\-yes -.fi -.RE -.SH FILES -.TP -\fB~/.config/git\-ai\-commit/instructions.md\fR -User\-level instructions included in every prompt. -.TP -\fBscripts/man/man1/git\-ai\-commit.1\fR -This man page. To make it available via \fBgit help ai\-commit\fR, add -\fBscripts/man\fR to \fBMANPATH\fR: -.RS 8 -.nf -export MANPATH="$MANPATH:$(git rev\-parse \-\-show\-toplevel)/scripts/man" -.fi -.RE -.SH SEE ALSO -\fBgit\-commit\fR(1), \fBgit\-add\fR(1), \fBgit\-diff\fR(1) -.SH AUTHORS -Developed as part of the Phlex project -(\fIhttps://github.com/Framework\-R\-D/phlex\fR). diff --git a/scripts/test/test_git_ai_commit.py b/scripts/test/test_git_ai_commit.py deleted file mode 100644 index f5e899984..000000000 --- a/scripts/test/test_git_ai_commit.py +++ /dev/null @@ -1,2279 +0,0 @@ -"""Tests for git-ai-commit helper functions. - -git-ai-commit follows the git subcommand plugin convention: placing a script -named git- on PATH makes it available as `git `. The hyphen and -the absence of a .py extension is intentional, so the module is loaded via -importlib.machinery.SourceFileLoader rather than a normal import statement. - -Coverage focuses on functions that received error-handling changes: - -- _gh_cli_token explicit return None for non-zero / missing gh exit -- _edit FileNotFoundError and CalledProcessError surfaced as _Error - -Plus tests for: - -- _gh_oauth_token returns GitHub OAuth token (not LiteLLM keys) -- _clean_message strips model preamble and fenced code blocks -- Empty staged change handling -""" - -from __future__ import annotations - -import importlib.machinery -import importlib.util -import io -import subprocess -import sys -from collections.abc import Callable -from pathlib import Path -from typing import cast -from unittest.mock import patch - -import pytest - -# --------------------------------------------------------------------------- -# Module loading -# --------------------------------------------------------------------------- - -_SCRIPT = Path(__file__).parent.parent / "git-ai-commit" -_loader = importlib.machinery.SourceFileLoader("git_ai_commit", str(_SCRIPT)) -_spec = importlib.util.spec_from_loader("git_ai_commit", _loader) -assert _spec is not None -_M = importlib.util.module_from_spec(_spec) -sys.modules.setdefault("git_ai_commit", _M) -_loader.exec_module(_M) - -# Typed aliases so static analysis can reason about call sites instead of -# treating these as Unknown (the module was loaded dynamically via importlib). -# pylint: disable=protected-access -_gh_cli_token: Callable[[], str | None] = _M._gh_cli_token # type: ignore[attr-defined] -_gh_oauth_token: Callable[[], str | None] = _M._gh_oauth_token # type: ignore[attr-defined] -_clean_message: Callable[[str], str] = _M._clean_message # type: ignore[attr-defined] - -_edit: Callable[[str], str] = _M._edit # type: ignore[attr-defined] -_chat_antigravity: Callable[[str, list[dict[str, str]]], str] = _M._chat_antigravity # type: ignore[attr-defined] -_Error: type[Exception] = _M._Error # type: ignore[attr-defined] -_APIError: type[Exception] = _M._APIError # type: ignore[attr-defined] -# pylint: enable=protected-access - - -# =========================================================================== -# _gh_cli_token -# =========================================================================== - - -class TestGhCliToken: - """_gh_cli_token wraps `gh auth token` and returns None on any failure.""" - - def test_gh_not_installed_returns_none(self) -> None: - """FileNotFoundError from subprocess (gh absent) → None.""" - with patch("subprocess.run", side_effect=FileNotFoundError): - assert _gh_cli_token() is None - - def test_gh_exits_nonzero_returns_none(self) -> None: - """Ensure gh returns a non-zero exit code → explicit None.""" - mock_result = subprocess.CompletedProcess( - ["gh", "auth", "token"], returncode=1, stdout="", stderr="error" - ) - with patch("subprocess.run", return_value=mock_result): - assert _gh_cli_token() is None - - def test_gh_exits_zero_empty_stdout_returns_none(self) -> None: - """Ensure gh returns zero but produces only whitespace → None.""" - mock_result = subprocess.CompletedProcess( - ["gh", "auth", "token"], returncode=0, stdout=" \n", stderr="" - ) - with patch("subprocess.run", return_value=mock_result): - assert _gh_cli_token() is None - - def test_gh_returns_token_stripped(self) -> None: - """Ensure gh succeeds with a token — trailing newline is stripped.""" - mock_result = subprocess.CompletedProcess( - ["gh", "auth", "token"], returncode=0, stdout="ghs_mytoken\n", stderr="" - ) - with patch("subprocess.run", return_value=mock_result): - assert _gh_cli_token() == "ghs_mytoken" - - -# =========================================================================== -# _gh_oauth_token -# =========================================================================== - - -class TestGhOAuthToken: - """_gh_oauth_token returns a GitHub OAuth token. - - This deliberately ignores GIT_AI_COMMIT_TOKEN because that variable may - hold a non-GitHub key (e.g. a LiteLLM API key). Used when a genuine - GitHub OAuth token is required, such as the Copilot token exchange. - """ - - def test_github_token_env_used(self, monkeypatch: pytest.MonkeyPatch) -> None: - """GITHUB_TOKEN env var is used when set.""" - for var in ("GIT_AI_COMMIT_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): - monkeypatch.delenv(var, raising=False) - monkeypatch.setenv("GITHUB_TOKEN", "ghs_oauth_token_123") - with patch.multiple( - _M, - _gh_cli_token=lambda: None, - _gh_config_token=lambda: None, - ): - assert _gh_oauth_token() == "ghs_oauth_token_123" - - def test_github_token_env_used_first(self, monkeypatch: pytest.MonkeyPatch) -> None: - """GITHUB_TOKEN takes precedence when both are set.""" - for var in ("GIT_AI_COMMIT_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): - monkeypatch.delenv(var, raising=False) - monkeypatch.setenv("GH_TOKEN", "ghs_oauth_token_abc") - monkeypatch.setenv("GITHUB_TOKEN", "ghs_oauth_token_xyz") - with patch.multiple( - _M, - _gh_cli_token=lambda: None, - _gh_config_token=lambda: None, - ): - # GITHUB_TOKEN is checked first in _gh_oauth_token - assert _gh_oauth_token() == "ghs_oauth_token_xyz" - - def test_gh_cli_token_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Falls back to gh CLI if no env vars set.""" - for var in ("GIT_AI_COMMIT_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): - monkeypatch.delenv(var, raising=False) - mock_result = subprocess.CompletedProcess( - ["gh", "auth", "token"], returncode=0, stdout="ghs_cli_token\n", stderr="" - ) - with patch("subprocess.run", return_value=mock_result): - with patch.multiple( - _M, - _gh_config_token=lambda: None, - ): - assert _gh_oauth_token() == "ghs_cli_token" - - def test_gh_config_token_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Falls back to ~/.config/gh/hosts.yml if gh CLI not available.""" - for var in ("GIT_AI_COMMIT_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): - monkeypatch.delenv(var, raising=False) - with patch("subprocess.run", side_effect=FileNotFoundError): - with patch.multiple( - _M, - _gh_config_token=lambda: "ghs_hosts_file_token", - ): - assert _gh_oauth_token() == "ghs_hosts_file_token" - - def test_git_ai_commit_token_ignored(self, monkeypatch: pytest.MonkeyPatch) -> None: - """GIT_AI_COMMIT_TOKEN is deliberately ignored for GitHub OAuth.""" - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "not_a_github_oauth_token") - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.delenv("GH_TOKEN", raising=False) - with patch("subprocess.run", side_effect=FileNotFoundError): - with patch.multiple( - _M, - _gh_config_token=lambda: "ghs_oauth_token_from_hosts", - ): - assert _gh_oauth_token() == "ghs_oauth_token_from_hosts" - - def test_no_token_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: - """No token sources available → None.""" - for var in ("GIT_AI_COMMIT_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): - monkeypatch.delenv(var, raising=False) - with patch("subprocess.run", side_effect=FileNotFoundError): - with patch.multiple( - _M, - _gh_config_token=lambda: None, - ): - assert _gh_oauth_token() is None - - -# =========================================================================== -# _clean_message -# =========================================================================== - - -class TestCleanMessage: - """_clean_message strips model preamble and fenced code blocks. - - Additional tests added for multiple fenced blocks and edge cases. - """ - - def test_multiple_fenced_blocks_extracted(self) -> None: - """When the message contains several fenced blocks, all are concatenated in order. - - Language tags are ignored, and surrounding prose is stripped. - """ - raw = ( - "Here is context\n\n" - "```text\nfirst block line 1\nfirst block line 2\n```\n" - "Some interleaving text.\n\n" - '```json\n{\n "key": "value"\n}\n```\n' - ) - # Expected: combine contents of both blocks, preserving line breaks. - expected = 'first block line 1\nfirst block line 2\n{\n "key": "value"\n}' - assert _clean_message(raw) == expected - - def test_fenced_code_block_extracted(self) -> None: - """Content inside a fenced code block is extracted; the language tag is dropped.""" - raw = ( - "Here is the commit message:\n\n```text\nfeat: add new feature\n\n" - "- Implementation details\n```\n\nThat's all!" - ) - expected = "feat: add new feature\n\n- Implementation details" - assert _clean_message(raw) == expected - - def test_fenced_code_block_preserves_language_tag_content(self) -> None: - """Language tag after ``` is discarded, content is preserved.""" - raw = """```json\n{\n "message": "feat: add new feature"\n}\n```\n""" - expected = '{\n "message": "feat: add new feature"\n}' - assert _clean_message(raw) == expected - - def test_leading_prose_preamble_stripped(self) -> None: - """Lines ending with ':' followed by blank line are removed.""" - raw = "Here is the commit message:\n\nfeat: add new feature\n\nAdditional context here." - expected = "feat: add new feature\n\nAdditional context here." - assert _clean_message(raw) == expected - - def test_multiple_leading_preamble_lines_stripped(self) -> None: - """Multiple prose preamble lines are all removed.""" - raw = ( - "I have analyzed the changes.\n\nHere is the commit message:\n\nfeat: add new feature" - ) - expected = "feat: add new feature" - assert _clean_message(raw) == expected - - def test_long_first_line_stripped(self) -> None: - """First line >72 chars (without prefix) is stripped as preamble.""" - long_line = ( - "This is a very long first line that exceeds seventy two characters and should " - "be treated as preamble" - ) - assert len(long_line) > 72 - raw = f"{long_line}\n\nfeat: add new feature\n\nBody content." - expected = "feat: add new feature\n\nBody content." - assert _clean_message(raw) == expected - - def test_prefix_first_line_stripped(self) -> None: - """First line starting with prefix (but <=72 chars) is stripped as preamble.""" - raw = "Here is the commit message\n\nfeat: add new feature\n\nBody content." - expected = "feat: add new feature\n\nBody content." - assert _clean_message(raw) == expected - - def test_no_preamble_returns_cleaned(self) -> None: - """Message without preamble is returned stripped.""" - raw = "feat: add new feature \n \n Some context.\n " - expected = "feat: add new feature \n \n Some context." - assert _clean_message(raw) == expected - - def test_only_preamble_returns_empty(self) -> None: - """Message consisting only of prose preamble becomes empty.""" - raw = "Here is the commit message:\n\n" - assert _clean_message(raw) == "" - - def test_empty_string_returns_empty(self) -> None: - """Empty string returns empty string.""" - assert _clean_message("") == "" - - def test_fenced_code_with_no_language(self) -> None: - """Fence without language tag works correctly.""" - raw = "```\nfeat: add new feature\n```\n" - expected = "feat: add new feature" - assert _clean_message(raw) == expected - - def test_conventional_commit_subject_not_stripped(self) -> None: - """A clean conventional-commit message is never treated as preamble.""" - raw = "fix: broken:\n\nActual body." - assert _clean_message(raw) == "fix: broken:\n\nActual body." - - def test_conventional_commit_with_scope_not_stripped(self) -> None: - """A scoped conventional-commit subject is not treated as preamble.""" - raw = "feat(api): add endpoint\n\nWired up the new handler." - assert _clean_message(raw) == raw - - def test_breaking_change_subject_not_stripped(self) -> None: - """A breaking-change (!) conventional-commit subject is not treated as preamble.""" - raw = "refactor!: rename all the things\n\nOld names were confusing." - assert _clean_message(raw) == raw - - def test_duplicate_full_message_exact(self) -> None: - """When the entire message appears twice, keep only the first.""" - msg = "feat: add feature\n\nBody content." - raw = f"{msg}\n\n{msg}" - assert _clean_message(raw) == msg - - def test_duplicate_full_message_with_truncation(self) -> None: - """When AI returns full message followed by truncated duplicate, keep first.""" - full_msg = "feat(git-ai-commit): improve deduplication\n\nAdded state-based scanner." - # Section 1 is subject only, Section 2 has body then subject again - # This simulates: subject\n\nbody\n\nsubject\n\nbody - truncated = "feat(git-ai-commit): improve deduplication" - raw = f"{full_msg}\n\n{truncated}" - result = _clean_message(raw) - # Subject should appear only once - assert result.count("feat(git-ai-commit): improve deduplication") == 1 - - def test_duplicate_message_shortened_second(self) -> None: - """When AI returns same subject but different body lengths, handle correctly.""" - # Original test case from bug report - msg1 = "feat(git-ai-commit): robust JSONC parsing, model fallback, diff improvements" - body1 = "Implemented a state-based scanner to safely strip comments from kilo.jsonc." - msg2 = "feat(git-ai-commit): robust JSONC parsing, model fallback, diff improvements" - - # Section 1: just subject - # Section 2: body + subject (duplicate) - raw = f"{msg1}\n\n{body1}\n\n{msg2}" - result = _clean_message(raw) - # Subject should appear only once, body should be preserved - assert result.count(msg1) == 1 - assert body1 in result - - def test_duplicate_message_truncated_body(self) -> None: - """When AI returns full message then just the subject line, handle correctly.""" - # This is the exact pattern seen in the bug report with gemma-4-31B-it - subject = "refactor(scripts): optimize git-ai-commit context and parsing" - body = """Improve the robustness of the `git-ai-commit` tool. - -Changes include: -- Replaced JSONC comment removal with a state-based scanner.""" - - # The model outputs: subject\n\nbody\n\nsubject - raw = f"{subject}\n\n{body}\n\n{subject}" - result = _clean_message(raw) - - # Subject should appear only once - assert result.count(subject) == 1 - # Body should be preserved - assert "Replaced JSONC" in result - - -# =========================================================================== -# Empty staged change handling -# =========================================================================== - -# _build_messages is used to verify the prompt wiring; pull it from the module. -# pylint: disable=protected-access -_build_messages = _M._build_messages # type: ignore[attr-defined] -# pylint: enable=protected-access - - -class TestEmptyStagedChanges: - """Tests for the empty-diff guard introduced in main(). - - The guard has two branches: - - no --amend: exit 0 immediately with a hint message. - - --amend + no prior commit message: exit 1 with a descriptive message. - - We test the detection logic by examining what _build_messages receives, and - also verify that the guard condition itself (`not diff.strip()`) is triggered - by the values that `_staged_diff` would return for an empty index. - """ - - def test_main_exits_0_when_no_staged_changes( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """No staged diff and no --amend exits 0 with a hint message.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - # Simulate a non-tty (pipe) stdin with no content so the stdin-read - # branch is exercised without touching pytest's capture machinery. - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - with pytest.raises(SystemExit) as exc: - _M.main() - - assert exc.value.code == 0 - _, err = capsys.readouterr() - assert "Nothing staged" in err - - def test_build_messages_includes_diff_when_nonempty(self, tmp_path: Path) -> None: - """_build_messages embeds the diff verbatim in the user message when non-empty.""" - diff = "diff --git a/foo.py b/foo.py\n+new line\n" - msgs = _build_messages(diff, "", "", "", tmp_path) - user_content = msgs[1]["content"] - assert diff in user_content - - def test_build_messages_with_last_msg_for_amend(self, tmp_path: Path) -> None: - """When --amend provides a prior message, it appears in the prompt even with empty diff.""" - prior = "fix: previous commit subject" - msgs = _build_messages("", "", "", "", tmp_path, last_msg=prior) - user_content = msgs[1]["content"] - assert prior in user_content - - -# =========================================================================== -# _edit -# =========================================================================== - - -class TestEdit: - """_edit opens the staged message in $EDITOR and filters comment lines. - - Added a test to verify that the temporary file is removed after a successful edit. - """ - - def _env(self, monkeypatch: pytest.MonkeyPatch, editor: str = "vi") -> None: - monkeypatch.setenv("EDITOR", editor) - monkeypatch.delenv("VISUAL", raising=False) - - def test_editor_selection_tty_prefers_visual(self, monkeypatch: pytest.MonkeyPatch) -> None: - """When stdin is a TTY, prefer $VISUAL over $EDITOR.""" - monkeypatch.setenv("EDITOR", "vi") - monkeypatch.setenv("VISUAL", "vim") - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - captured_editor: list[str] = [] - - def _capture(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - captured_editor.append(cmd[0]) - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_capture): - with patch("pathlib.Path.read_text", return_value="content"): - _edit("original") - - assert captured_editor[0] == "vim" - - def test_editor_selection_non_tty_prefers_editor( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """When stdin is not a TTY, prefer $EDITOR over $VISUAL.""" - monkeypatch.setenv("EDITOR", "vi") - monkeypatch.setenv("VISUAL", "vim") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - captured_editor: list[str] = [] - - def _capture(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - captured_editor.append(cmd[0]) - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_capture): - with patch("pathlib.Path.read_text", return_value="content"): - _edit("original") - - assert captured_editor[0] == "vi" - - def test_editor_fallback_when_only_editor_set(self, monkeypatch: pytest.MonkeyPatch) -> None: - """When only $EDITOR is set, use it regardless of TTY status.""" - monkeypatch.setenv("EDITOR", "nano") - monkeypatch.delenv("VISUAL", raising=False) - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - captured_editor: list[str] = [] - - def _capture(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - captured_editor.append(cmd[0]) - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_capture): - with patch("pathlib.Path.read_text", return_value="content"): - _edit("original") - - assert captured_editor[0] == "nano" - - def test_editor_fallback_when_only_visual_set(self, monkeypatch: pytest.MonkeyPatch) -> None: - """When only $VISUAL is set, use it regardless of TTY status.""" - monkeypatch.setenv("VISUAL", "code") - monkeypatch.delenv("EDITOR", raising=False) - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - captured_editor: list[str] = [] - - def _capture(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - captured_editor.append(cmd[0]) - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_capture): - with patch("pathlib.Path.read_text", return_value="content"): - _edit("original") - - assert captured_editor[0] == "code" - - def test_editor_fallback_to_vi_when_neither_set(self, monkeypatch: pytest.MonkeyPatch) -> None: - """When neither $EDITOR nor $VISUAL is set, fall back to vi.""" - monkeypatch.delenv("EDITOR", raising=False) - monkeypatch.delenv("VISUAL", raising=False) - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - captured_editor: list[str] = [] - - def _capture(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - captured_editor.append(cmd[0]) - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_capture): - with patch("pathlib.Path.read_text", return_value="content"): - _edit("original") - - assert captured_editor[0] == "vi" - - def test_editor_not_found_raises_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Missing editor binary raises _Error with a descriptive message.""" - self._env(monkeypatch, "nonexistent-editor-xyz") - with patch("subprocess.run", side_effect=FileNotFoundError): - with pytest.raises(_Error, match="Editor not found"): - _edit("subject\n\nbody") - - def test_editor_nonzero_exit_raises_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Non-zero editor exit raises _Error mentioning the status code.""" - self._env(monkeypatch) - exc = subprocess.CalledProcessError(1, "vi") - with patch("subprocess.run", side_effect=exc): - with pytest.raises(_Error, match="status 1"): - _edit("subject\n\nbody") - - def test_tempfile_cleaned_up_after_editor_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """The temp file is removed even when the editor raises.""" - self._env(monkeypatch) - recorded: list[Path] = [] - - def _fail(cmd: list[str], **_kwargs: object) -> None: - recorded.append(Path(cmd[-1])) - raise subprocess.CalledProcessError(130, cmd) - - with patch("subprocess.run", side_effect=_fail): - with pytest.raises(_Error): - _edit("msg") - - assert recorded, "mock was never called" - assert not recorded[0].exists(), "temp file was not cleaned up" - - def test_tempfile_cleaned_up_after_editor_not_found( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The temp file is removed even when the editor binary is missing.""" - self._env(monkeypatch, "no-such-editor") - recorded: list[Path] = [] - - def _missing(cmd: list[str], **_kwargs: object) -> None: - recorded.append(Path(cmd[-1])) - raise FileNotFoundError - - with patch("subprocess.run", side_effect=_missing): - with pytest.raises(_Error): - _edit("msg") - - assert recorded, "mock was never called" - assert not recorded[0].exists(), "temp file was not cleaned up" - - def test_tempfile_removed_after_successful_edit(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Even on a successful edit, the temporary file must be deleted. - - The mock records the temporary file path used by the function and returns - a successful `CompletedProcess`. After `_edit` returns, the file should - no longer exist. - """ - self._env(monkeypatch) - recorded: list[Path] = [] - - def _succeed(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - # Record the temporary file path (last argument) and create a dummy file. - temp_path = Path(cmd[-1]) - recorded.append(temp_path) - temp_path.write_text("subject\n\nbody line\n", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_succeed): - result = _edit("original") - # Result should be the file content without comment lines. - assert "subject" in result and "body line" in result - - # Ensure the temporary file was created and then removed. - assert recorded, "mock was never called" - temp_file = recorded[0] - assert not temp_file.exists(), "temp file was not cleaned up after successful edit" - - def test_comment_lines_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Lines starting with '#' are removed from the returned message.""" - self._env(monkeypatch) - - def _save(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - Path(cmd[-1]).write_text("subject\n\nbody line\n# a comment\n", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_save): - result = _edit("original") - - assert "# a comment" not in result - assert "subject" in result - assert "body line" in result - - def test_empty_result_after_stripping(self, monkeypatch: pytest.MonkeyPatch) -> None: - """A file consisting entirely of comment lines returns an empty string.""" - self._env(monkeypatch) - - def _all_comments(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - Path(cmd[-1]).write_text("# line 1\n# line 2\n", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_all_comments): - result = _edit("original") - - assert result == "" - - def test_read_error_after_edit_raises_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """OSError reading the temp file after a successful editor run raises _Error.""" - self._env(monkeypatch) - - def _succeed(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess(cmd, 0) - - with patch("subprocess.run", side_effect=_succeed): - with patch("pathlib.Path.read_text", side_effect=OSError("disk error")): - with pytest.raises(_Error, match="Could not read"): - _edit("original") - - -# =========================================================================== -# _max_tokens_for_prompt -# =========================================================================== - - -class TestMaxTokensForPrompt: - """_max_tokens_for_prompt returns appropriate token budgets.""" - - def test_default_model_small_prompt_returns_base_budget(self) -> None: - """Default model with small prompt (<25k) returns base budget of 2048.""" - result = _M._max_tokens_for_prompt(10_000, "") - assert result == 2048 - - def test_reasoning_model_large_prompt_returns_high_budget( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Reasoning model (contains 'qwen3-coder-next') with large prompt returns 8192.""" - result = _M._max_tokens_for_prompt(60_000, "qwen/qwen3-coder-next") - assert result == 8192 - - def test_model_with_reasoning_keyword_returns_high_budget( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Model with 'reasoning' in name returns 8192.""" - result = _M._max_tokens_for_prompt(60_000, "google/gemma-4-31b-reasoning") - assert result == 8192 - - def test_large_prompt_over_50k_returns_4096(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Non-reasoning model with prompt >50k returns 4096.""" - result = _M._max_tokens_for_prompt(55_000, "gpt-4o") - assert result == 4096 - - def test_large_prompt_over_25k_under_50k_returns_3072( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Non-reasoning model with prompt >25k and <=50k returns 3072.""" - result = _M._max_tokens_for_prompt(40_000, "gpt-4o") - assert result == 3072 - - def test_small_prompt_returns_base_budget(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Non-reasoning model with prompt <=25k returns base budget of 2048.""" - result = _M._max_tokens_for_prompt(25_000, "gpt-4o") - assert result == 2048 - - -# =========================================================================== -# main() with --no-instructions -# =========================================================================== - - -class TestNoInstructionsFlag: - """Tests for --no-instructions flag behavior.""" - - def test_no_instructions_flag_skips_get_instructions( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - ) -> None: - """When --no-instructions is set, _get_instructions is not called.""" - monkeypatch.setattr( - sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes", "--no-instructions"] - ) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: _raise_exception()) - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - with patch( - "subprocess.run", side_effect=[mock_completed, subprocess.CompletedProcess([], 0)] - ): - _M.main() - - -def _raise_exception() -> str: - """Helper to raise an exception when called.""" - raise RuntimeError("_get_instructions should not have been called") - - -# =========================================================================== -# main() with --edit flag -# =========================================================================== - - -class TestEditFlag: - """Tests for the -e/--edit flag behavior in main().""" - - def test_edit_flag_invokes_editor( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When --edit is set, the editor is invoked on the AI-generated message.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes", "--edit"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.setenv("EDITOR", "vi") - - editor_cmd: list[str] = [] - call_count = [0] - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - call_count[0] += 1 - if cmd[0] == "kilo": - return subprocess.CompletedProcess( - cmd, 0, stdout="feat: original message", stderr="" - ) - elif cmd[0] == "vi": - editor_cmd.extend(cmd) - # Simulate user editing - Path(cmd[-1]).write_text( - "feat: edited message\n# comment to strip", encoding="utf-8" - ) - return subprocess.CompletedProcess(cmd, 0) - elif cmd[0] == "git" and cmd[1] == "commit": - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - _M.main() - - assert editor_cmd[0] == "vi" - - def test_edit_flag_strips_comment_lines( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When --edit is set, comment lines are stripped from the edited message.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes", "--edit"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.setenv("EDITOR", "vi") - - captured_message: list[str] = [] - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - if cmd[0] == "kilo": - # Return a mock kilo response - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - Path(cmd[-1]).write_text( - "feat: final message\n# this is a comment\n\n# another comment", - encoding="utf-8", - ) - return subprocess.CompletedProcess(cmd, 0) - elif cmd[0] == "git" and cmd[1] == "commit": - msg_idx = cmd.index("-m") + 1 - captured_message.append(cmd[msg_idx]) - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - _M.main() - - assert len(captured_message) == 1 - # Comment lines should be stripped - assert "# this is a comment" not in captured_message[0] - assert "# another comment" not in captured_message[0] - assert "feat: final message" in captured_message[0] - - def test_edit_flag_tempfile_cleaned_up( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """The temporary file used by _edit is removed after editing.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes", "--edit"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.setenv("EDITOR", "vi") - - recorded_tmpfile: list[Path] = [] - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - recorded_tmpfile.append(Path(cmd[-1])) - Path(cmd[-1]).write_text("feat: edited", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - elif cmd[0] == "git" and cmd[1] == "commit": - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - _M.main() - - assert len(recorded_tmpfile) == 1 - assert not recorded_tmpfile[0].exists(), "temp file was not cleaned up" - - def test_edit_flag_empty_message_aborts( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] - ) -> None: - """An empty message after editing (only comments or whitespace) aborts the commit.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes", "--edit"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.setenv("EDITOR", "vi") - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - # User enters only comments - Path(cmd[-1]).write_text("# only comments\n# nothing else", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - with pytest.raises(SystemExit) as exc: - _M.main() - - assert exc.value.code == 0 - captured = capsys.readouterr() - assert "Empty message" in captured.err - - def test_edit_flag_with_short_option( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """The -e short option works the same as --edit.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "-e", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.setenv("EDITOR", "vi") - - editor_called = False - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - nonlocal editor_called - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - editor_called = True - Path(cmd[-1]).write_text("feat: via -e", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - elif cmd[0] == "git" and cmd[1] == "commit": - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - _M.main() - - assert editor_called, "editor was not called with -e flag" - - -# =========================================================================== -# main() with 'e' prompt response (interactive mode) -# =========================================================================== - - -class TestEPromptResponse: - """Tests for 'e' (edit) response to the interactive y/N/e prompt.""" - - def test_e_prompt_invokes_editor( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When 'e' is entered at the prompt, the editor is invoked.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.delenv("VISUAL", raising=False) - monkeypatch.setenv("EDITOR", "vi") - - editor_called = False - call_count = [0] - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - call_count[0] += 1 - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - nonlocal editor_called - editor_called = True - Path(cmd[-1]).write_text("feat: edited via prompt\n# comment", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - elif cmd[:2] == ["git", "commit"]: - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - # Simulate stdin with 'e' response - create stdin with isatty() method - stdin_mock = io.StringIO("e\n") - monkeypatch.setattr(stdin_mock, "isatty", lambda: True) - with patch.object(sys, "stdin", stdin_mock): - _M.main() - - assert editor_called, "editor was not called when 'e' was entered at prompt" - - def test_e_prompt_strips_comment_lines( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Comment lines are stripped from edited message when 'e' is entered at prompt.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.delenv("VISUAL", raising=False) - monkeypatch.setenv("EDITOR", "vi") - - captured_message: list[str] = [] - call_count = [0] - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - call_count[0] += 1 - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - Path(cmd[-1]).write_text("feat: via prompt\n# comment line", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - elif cmd[:2] == ["git", "commit"]: - msg_idx = cmd.index("-m") + 1 - captured_message.append(cmd[msg_idx]) - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - # Simulate stdin with 'e' response - create stdin with isatty() method - stdin_mock = io.StringIO("e\n") - monkeypatch.setattr(stdin_mock, "isatty", lambda: True) - with patch.object(sys, "stdin", stdin_mock): - _M.main() - - assert len(captured_message) == 1 - # Comment should be stripped - assert "# comment line" not in captured_message[0] - assert "feat: via prompt" in captured_message[0] - - def test_e_prompt_tempfile_cleaned_up( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """The temporary file is removed after editing via 'e' prompt.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.delenv("VISUAL", raising=False) - monkeypatch.setenv("EDITOR", "vi") - - recorded_tmpfile: list[Path] = [] - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - recorded_tmpfile.append(Path(cmd[-1])) - Path(cmd[-1]).write_text("feat: edited", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - elif cmd[:2] == ["git", "commit"]: - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - # Simulate stdin with 'e' response - create stdin with isatty() method - stdin_mock = io.StringIO("e\n") - monkeypatch.setattr(stdin_mock, "isatty", lambda: True) - with patch.object(sys, "stdin", stdin_mock): - _M.main() - - assert len(recorded_tmpfile) == 1 - assert not recorded_tmpfile[0].exists(), "temp file was not cleaned up after 'e' prompt" - - def test_e_prompt_empty_message_aborts( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] - ) -> None: - """An empty message after editing via 'e' prompt aborts the commit.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff content") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - monkeypatch.delenv("VISUAL", raising=False) - monkeypatch.setenv("EDITOR", "vi") - - def _mock_subprocess( - cmd: list[str], **_kwargs: object - ) -> subprocess.CompletedProcess[str]: - if cmd[0] == "kilo": - return subprocess.CompletedProcess(cmd, 0, stdout="feat: ai generated", stderr="") - elif cmd[0] == "vi": - # User enters only comments - Path(cmd[-1]).write_text("# nothing\n# just comments", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - raise AssertionError(f"Unexpected command: {cmd}") - - with patch("subprocess.run", side_effect=_mock_subprocess): - # Simulate stdin with 'e' response - create stdin with isatty() method - stdin_mock = io.StringIO("e\n") - monkeypatch.setattr(stdin_mock, "isatty", lambda: True) - with patch.object(sys, "stdin", stdin_mock): - with pytest.raises(SystemExit) as exc: - _M.main() - - assert exc.value.code == 0 - captured = capsys.readouterr() - assert "Empty message" in captured.err - - -# =========================================================================== -# main() with --amend and empty diff -# =========================================================================== - - -class TestAmendEmptyDiff: - """Tests for main() with --amend flag and no staged changes.""" - - def test_main_exits_1_with_amend_and_no_staged_changes_nor_prior_message( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """--amend with no staged changes and no prior commit message exits 1.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--amend"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=True: "") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_last_commit_message", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - with pytest.raises(SystemExit) as exc: - _M.main() - - assert exc.value.code == 1 - captured = capsys.readouterr() - assert "Nothing staged" in captured.err - assert "prior commit message" in captured.err - - -# =========================================================================== -# Stdin context handling -# =========================================================================== - - -class TestStdinContext: - """Tests for stdin piped context handling in main().""" - - def test_piped_stdin_data_becomes_part_of_context_when_not_tty( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - ) -> None: - """When stdin is not a TTY and contains data, it becomes part of context.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "diff") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - # Simulate piped data on stdin - piped_data = "Fixes issue #42\n" - monkeypatch.setattr(sys, "stdin", io.StringIO(piped_data)) - - captured_context: list[str] = [] - - def _capture_build_messages(*args: object, **kwargs: object) -> list[dict[str, str]]: - # Extract context from args (4th positional argument) - if len(args) >= 4: - context = cast(str, args[3]) - else: - context = cast(str, kwargs.get("context", "")) - captured_context.append(context) - return [{"role": "system", "content": ""}, {"role": "user", "content": ""}] - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit\n", - stderr="", - ) - with patch( - "subprocess.run", side_effect=[mock_completed, subprocess.CompletedProcess([], 0)] - ): - with patch("git_ai_commit._build_messages", side_effect=_capture_build_messages): - _M.main() - - # Verify the piped data is in the context - assert len(captured_context) == 1 - assert piped_data.strip() in captured_context[0] - - -# =========================================================================== -# Antigravity Backend Tests -# =========================================================================== - - -class TestAntigravityBackend: - """Tests for antigravity backend behavior. - - Adjusted to use more flexible assertions for input content. - """ - - def test_resolve_backend_and_api(self) -> None: - """_resolve_backend_and_api returns empty string for antigravity/agy.""" - backend, api = _M._resolve_backend_and_api("antigravity", "http://ignored") - assert backend == "antigravity" - assert api == "" - - backend, api = _M._resolve_backend_and_api("agy", "") - assert backend == "agy" - assert api == "" - - def test_default_model(self) -> None: - """_default_model returns empty string for antigravity/agy.""" - assert _M._default_model("antigravity") == "" - assert _M._default_model("agy") == "" - - def test_chat_antigravity_success_no_model(self) -> None: - """_chat_antigravity runs agy successfully without model. - - The input sent to the subprocess is verified loosely to avoid - brittleness caused by formatting changes. - """ - messages = [ - {"role": "system", "content": "system instruction"}, - {"role": "user", "content": "user query"}, - ] - - mock_completed = subprocess.CompletedProcess( - args=["agy", "-p", "-"], - returncode=0, - stdout="Generated Commit Message\n", - stderr="", - ) - - with patch("subprocess.run", return_value=mock_completed) as mock_run: - res = _chat_antigravity("", messages) - assert res == "Generated Commit Message" - mock_run.assert_called_once() - cmd, kwargs = mock_run.call_args - # Ensure the command invoked is the agy CLI. - assert cmd[0] == ["agy", "-p", "-"] - # Verify that the input contains both system and user sections. - input_str = kwargs["input"] - assert "system instruction" in input_str - assert "user query" in input_str - - def test_chat_antigravity_success_with_model(self) -> None: - """_chat_antigravity runs agy successfully with model.""" - messages = [ - {"role": "user", "content": "user query"}, - ] - - mock_completed = subprocess.CompletedProcess( - args=["agy", "-p", "-", "--model", "my-model"], - returncode=0, - stdout="Generated Commit Message\n", - stderr="", - ) - - with patch("subprocess.run", return_value=mock_completed) as mock_run: - res = _chat_antigravity("my-model", messages) - assert res == "Generated Commit Message" - mock_run.assert_called_once() - cmd, kwargs = mock_run.call_args - assert cmd[0] == ["agy", "-p", "-", "--model", "my-model"] - assert kwargs["input"] == "user query" - - def test_chat_antigravity_file_not_found(self) -> None: - """_chat_antigravity raises _Error if agy CLI is not found.""" - with patch("subprocess.run", side_effect=FileNotFoundError): - with pytest.raises(_Error, match="not found in PATH"): - _chat_antigravity("my-model", []) - - def test_chat_antigravity_nonzero_exit(self) -> None: - """_chat_antigravity raises _APIError if agy CLI fails.""" - mock_completed = subprocess.CompletedProcess( - args=["agy"], - returncode=1, - stdout="", - stderr="invalid model", - ) - with patch("subprocess.run", return_value=mock_completed): - with pytest.raises(_APIError, match="failed \\(exit code 1\\)"): - _chat_antigravity("my-model", []) - - def test_main_with_antigravity_backend( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Main runs successfully with antigravity backend.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "antigravity", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "some diff") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - # Simulate a non-tty (pipe) stdin with no content. - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - mock_completed = subprocess.CompletedProcess( - args=["agy", "-p", "-"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - - with patch( - "subprocess.run", - side_effect=[mock_completed, subprocess.CompletedProcess([], 0)], - ) as mock_run: - _M.main() - - # The first call should be to 'agy' to generate the commit message. - # The second call should be 'git commit' with the message. - assert mock_run.call_count == 2 - - # Check the first call (agy) - agy_cmd = mock_run.call_args_list[0][0][0] - assert "agy" in agy_cmd - - # Check the second call (git commit) - git_cmd = mock_run.call_args_list[1][0][0] - assert git_cmd == ["git", "commit", "-m", "feat: mock commit message"] - - -# =========================================================================== -# _truncate_diff -# =========================================================================== - - -class TestTruncateDiff: - """Tests for _truncate_diff, the hunk-aware diff truncation helper.""" - - def test_under_budget_returns_unchanged(self) -> None: - """Diff shorter than max_chars is returned unchanged.""" - diff = "diff --git a/foo.py b/foo.py\n+new line\n" - result = _M._truncate_diff(diff, 1000) - assert result == diff - - def test_over_budget_cuts_on_file_boundary(self) -> None: - """Diff with multiple files is cut on file boundary; omission marker present.""" - # Diff with two file sections; first will fit, second will not - stat_block = " git diff --cached --stat -p\n\n" - file1 = "diff --git a/aaa.py b/aaa.py\nnew file mode 100644\n+line 1\n+line 2\n" - file2 = "diff --git a/bbb.py b/bbb.py\nnew file mode 100644\n+line a\n+line b\n" - diff = stat_block + file1 + file2 - # Marker to include after file1 - marker = "\n\n[diff truncated: 1 of 2 files omitted to fit context budget]" - # Budget that fits stat + file1 + marker, but not file2 - budget = len(stat_block) + len(file1) + len(marker) - result = _M._truncate_diff(diff, budget) - - # stat + file1 should be present - assert stat_block in result - assert "aaa.py" in result - assert "line 1" in result - # file2 should be omitted - assert "bbb.py" not in result - assert "line a" not in result - # marker should be present - assert "files omitted to fit context budget" in result - - def test_single_oversized_file_line_boundary(self) -> None: - """Single file section larger than budget is truncated at line boundary.""" - stat_block = " git diff --cached --stat -p\n\n" - # Build a large file section with many lines - this should create one large section - lines = [f"+line {i}\n" for i in range(100)] - # Create a single large file section with all the lines - file_section = "diff --git a/large.py b/large.py\nnew file mode 100644\n" + "".join(lines) - diff = stat_block + file_section - - # Budget: enough for stat block + the header line + a couple of content lines, - # but far less than the full file section. - budget = ( - len(stat_block) - + len("diff --git a/large.py b/large.py\n") - + len("+line 0\n") - + len("+line 1\n") - ) - result = _M._truncate_diff(diff, budget) - - # Result should be shorter than the original diff (truncation occurred) - assert len(result) < len(diff) - # Should contain the truncation marker even for a single-file fallback cut - assert "files omitted to fit context budget" in result - # All content lines (non-marker) should be from the original diff (no mid-line cuts) - marker = "[diff truncated:" - for line in result.split("\n"): - if line and marker not in line: - assert line in diff - - def test_omission_marker_text(self) -> None: - """Marker text contains expected description.""" - stat_block = " git diff --cached --stat -p\n\n" - file1 = "diff --git a/a.py b/a.py\n+1\n" - file2 = "diff --git a/b.py b/b.py\n+2\n" - file3 = "diff --git a/c.py b/c.py\n+3\n" - diff = stat_block + file1 + file2 + file3 - - # Budget fits stat + file1 exactly; file2 and file3 are dropped (2 of 3 omitted) - budget = len(stat_block) + len(file1) - result = _M._truncate_diff(diff, budget) - - assert "files omitted to fit context budget" in result - # Check the marker format: 2 of 3 files omitted - assert "2 of 3 files omitted to fit context budget" in result - - -# =========================================================================== -# _skip_binary_and_generated -# =========================================================================== - - -class TestSkipBinaryAndGenerated: - """Tests for _skip_binary_and_generated, the binary/generated file filter.""" - - def test_binary_section_replaced_with_note(self) -> None: - """Binary file section replaced with [skipped binary/generated file: ...].""" - diff = ( - "git diff --cached --stat -p\n\n" - "Binary files a/img.png and b/img.png differ\n" - "diff --git a/text.txt b/text.txt\n+hello\n" - ) - result = _M._skip_binary_and_generated(diff) - - # Should contain the skip note with filename - assert "[skipped binary/generated file: b/img.png]" in result - # Should NOT contain the original binary diff line - assert "Binary files" not in result - # Normal section should pass through - assert "text.txt" in result - assert "hello" in result - - def test_generated_file_replaced_with_note(self) -> None: - """Generated file section (matching _GENERATED_FILE_GLOBS) is replaced.""" - diff = ( - "git diff --cached --stat -p\n\n" - "diff --git a/foo.pb.h b/foo.pb.h\n" - "new file mode 100644\n" - "+// Generated by protoc\n" - "+class Foo {}\n" - "diff --git a/normal.cpp b/normal.cpp\n+int main() {}\n" - ) - result = _M._skip_binary_and_generated(diff) - - # foo.pb.h is in _GENERATED_FILE_GLOBS (path stored without b/ prefix) - assert "[skipped binary/generated file: foo.pb.h]" in result - # Generated file hunk body should be absent - assert "Generated by protoc" not in result - assert "class Foo {}" not in result - # Normal file should pass through - assert "normal.cpp" in result - assert "int main()" in result - - def test_normal_file_section_kept(self) -> None: - """Plain .py file section passes through unchanged.""" - diff = ( - "git diff --cached --stat -p\n\n" - "diff --git a/program.py b/program.py\n+print('hello')\n" - ) - result = _M._skip_binary_and_generated(diff) - - assert result == diff - assert "print('hello')" in result - - -# =========================================================================== -# _build_messages max_diff_chars parameter -# =========================================================================== - - -class TestBuildMessagesMaxDiffChars: - """Tests for _build_messages with the max_diff_chars parameter.""" - - def _make_large_diff(self, *, num_lines: int = 1500) -> str: - """Construct a large diff with approximately `num_lines` added lines.""" - stat_block = " git diff --cached --stat -p\n\n" - lines = [f"+line {i} with some padding to make it longer\n" for i in range(num_lines)] - file_section = "diff --git a/large.py b/large.py\nnew file mode 100644\n".join(lines) - return stat_block + file_section - - def test_default_cap_truncates_large_diff(self, tmp_path: Path) -> None: - """Large diff (> 60 000 chars) is truncated with default max_diff_chars.""" - diff = self._make_large_diff() - assert len(diff) > 60_000 - - msgs = _build_messages(diff, "", "", "", tmp_path) - user_content = msgs[1]["content"] - - # Should contain truncation marker - assert "files omitted to fit context budget" in user_content - - def test_default_arg_unchanged(self) -> None: - """_build_messages signature has _MAX_DIFF_CHARS_DEFAULT as max_diff_chars default.""" - import inspect - - sig = inspect.signature(_build_messages) - param = sig.parameters["max_diff_chars"] - assert param.default == _M._MAX_DIFF_CHARS_DEFAULT - - -class TestKiloConfig: - """Tests for kilo config loading and model resolution.""" - - def test_kilo_config_candidates_check_json_before_jsonc(self) -> None: - """_KILO_CONFIG_CANDIDATES tries kilo.json before kilo.jsonc.""" - from pathlib import Path - - expected_dir = Path.home() / ".config" / "kilo" - assert _M._KILO_CONFIG_DIR == expected_dir # type: ignore[attr-defined] - candidates = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - assert candidates == (expected_dir / "kilo.json", expected_dir / "kilo.jsonc") - - def test_kilo_config_path_missing_returns_none(self, tmp_path: Path) -> None: - """_kilo_config_path returns None when neither kilo.json nor kilo.jsonc exists.""" - original = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - try: - _M._KILO_CONFIG_CANDIDATES = ( # type: ignore[attr-defined] - tmp_path / "kilo.json", - tmp_path / "kilo.jsonc", - ) - assert _M._kilo_config_path() is None # type: ignore[attr-defined] - finally: - _M._KILO_CONFIG_CANDIDATES = original # type: ignore[attr-defined] - - def test_kilo_config_path_prefers_json_over_jsonc(self, tmp_path: Path) -> None: - """_kilo_config_path returns kilo.json when both kilo.json and kilo.jsonc exist.""" - json_file = tmp_path / "kilo.json" - jsonc_file = tmp_path / "kilo.jsonc" - json_file.write_text("{}", encoding="utf-8") - jsonc_file.write_text("{}", encoding="utf-8") - - original = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - try: - _M._KILO_CONFIG_CANDIDATES = (json_file, jsonc_file) # type: ignore[attr-defined] - assert _M._kilo_config_path() == json_file # type: ignore[attr-defined] - finally: - _M._KILO_CONFIG_CANDIDATES = original # type: ignore[attr-defined] - - def test_kilo_config_path_falls_back_to_jsonc(self, tmp_path: Path) -> None: - """_kilo_config_path returns kilo.jsonc when only it exists.""" - json_file = tmp_path / "kilo.json" - jsonc_file = tmp_path / "kilo.jsonc" - jsonc_file.write_text("{}", encoding="utf-8") - - original = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - try: - _M._KILO_CONFIG_CANDIDATES = (json_file, jsonc_file) # type: ignore[attr-defined] - assert _M._kilo_config_path() == jsonc_file # type: ignore[attr-defined] - finally: - _M._KILO_CONFIG_CANDIDATES = original # type: ignore[attr-defined] - - def test_load_kilo_config_missing_file_returns_empty(self, tmp_path: Path) -> None: - """_load_kilo_config returns empty config when no config file exists.""" - non_existent = tmp_path / "nonexistent" - original = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - try: - _M._KILO_CONFIG_CANDIDATES = ( # type: ignore[attr-defined] - non_existent / "kilo.json", - non_existent / "kilo.jsonc", - ) - result = _M._load_kilo_config() # type: ignore[attr-defined] - assert result == {"disabled_providers": [], "provider": {}} - finally: - _M._KILO_CONFIG_CANDIDATES = original # type: ignore[attr-defined] - - def test_load_kilo_config_with_models_maps_providers( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """_load_kilo_config_with_models returns provider-to-models mapping.""" - # Mock the config to have fnal-ow and fnal-azure providers with test models - mock_config = { - "disabled_providers": ["fnal-azure"], - "provider": { - "fnal-ow": { - "models": { - "qwen/qwen3-coder-next": {"temperature": 0.7}, - "qwen/qwen2.5-32b": {}, - } - }, - "fnal-azure": { - "models": { - "azure/gpt-5-nano": {"temperature": 0.9}, - "azure/claude-3-5-sonnet": {}, - } - }, - }, - } - monkeypatch.setattr(_M, "_load_kilo_config", lambda: mock_config) # type: ignore[attr-defined] - - mapping = _M._load_kilo_config_with_models() # type: ignore[attr-defined] - # fnal-ow should be a provider in the mapping - assert "fnal-ow" in mapping - # fnal-azure should be EXCLUDED because it is disabled - assert "fnal-azure" not in mapping - # mapping[provider] = models_dict (models are directly in the dict) - assert "qwen/qwen3-coder-next" in mapping["fnal-ow"] - assert "qwen/qwen2.5-32b" in mapping["fnal-ow"] - - def test_resolve_kilo_model_with_known_provider_prefix(self) -> None: - """_resolve_kilo_model returns model unchanged if provider prefix is known.""" - # When the prefix (fnal-ow) is a known provider, model is passed as-is - result = _M._resolve_kilo_model("fnal-ow/qwen3-coder-next") - assert result == "fnal-ow/qwen3-coder-next" - - def test_resolve_kilo_model_known_provider_prefix_matches_by_id(self) -> None: - """With a known provider prefix, the remainder may match via "id" (not just key).""" - # Real kilo.json entries store a short config key (e.g. "haiku-4-5") plus - # an "id"/"name" holding the provider-qualified model id - # (e.g. "azure/claude-haiku-4-5"). `kilo run -m` only accepts the key, - # so a match on "id" must still resolve to "provider/key". - mock_config = { - "disabled_providers": [], - "provider": { - "fnal-anthropic": { - "models": { - "haiku-4-5": { - "id": "azure/claude-haiku-4-5", - "name": "azure/claude-haiku-4-5", - }, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - result = _M._resolve_kilo_model("fnal-anthropic/azure/claude-haiku-4-5") - assert result == "fnal-anthropic/haiku-4-5" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_disabled_provider_prefix_is_treated_as_model_name(self) -> None: - """If provider prefix is disabled, it's treated as part of the model name.""" - mock_config = { - "disabled_providers": ["disabled-prov"], - "provider": { - "enabled-prov": { - "models": { - "model-x": {"id": "disabled-prov/model-x"}, - } - }, - "disabled-prov": { - "models": { - "model-x": {}, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - # "disabled-prov" is disabled, so it's not a "known provider". - # "disabled-prov/model-x" is looked up as a whole, matching - # "enabled-prov"'s "model-x" entry by its "id" field. - result = _M._resolve_kilo_model("disabled-prov/model-x") - assert result == "enabled-prov/model-x" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_unknown_provider_prefix_looks_up_model( - self, - ) -> None: - """Model with unknown prefix (qwen/) is looked up in config, by id, across providers.""" - # qwen is NOT a known provider, so "qwen/qwen3-coder-next" (an id/name - # value) is looked up as a whole and matched against the "fnal-ow" - # provider's "qwen3-coder-next" key via its "id" field. - mock_config = { - "disabled_providers": [], - "provider": { - "fnal-ow": { - "models": { - "qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - result = _M._resolve_kilo_model("qwen/qwen3-coder-next") - assert result == "fnal-ow/qwen3-coder-next" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_without_provider_prefix_looks_up_by_key(self) -> None: - """_resolve_kilo_model adds provider prefix when the input matches a config key.""" - mock_config = { - "disabled_providers": [], - "provider": { - "fnal-ow": { - "models": { - "qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - result = _M._resolve_kilo_model("qwen3-coder-next") - assert result == "fnal-ow/qwen3-coder-next" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_without_provider_prefix_looks_up_by_id(self) -> None: - """_resolve_kilo_model adds provider prefix when the input matches "id" (or "name").""" - # A bare (no "/") id/name value should still resolve to the config key. - mock_config = { - "disabled_providers": [], - "provider": { - "ollama": { - "models": { - "codestral:22b": {"name": "codestral:22b-instruct"}, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - result = _M._resolve_kilo_model("codestral:22b-instruct") - assert result == "ollama/codestral:22b" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_unknown_model(self) -> None: - """_resolve_kilo_model returns default model for unknown model without prefix.""" - result = _M._resolve_kilo_model("unknown-model-name") - assert result == "unknown-model-name" - - def test_resolve_kilo_model_resolves_when_one_provider_disabled(self) -> None: - """When one provider is disabled, model resolution falls back to the enabled provider. - - This replaces the ambiguous‑lookup test that previously had a misleading name. - """ - # Model exists under multiple providers, but one is disabled - mock_config = { - "disabled_providers": ["fnal-azure"], - "provider": { - "fnal-ow": { - "models": { - "qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}, - } - }, - "fnal-azure": { - "models": { - "qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - # Only fnal-ow is enabled, so resolution should succeed. - result = _M._resolve_kilo_model("qwen3-coder-next") - assert result == "fnal-ow/qwen3-coder-next" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_still_ambiguous_when_multiple_enabled(self) -> None: - """_resolve_kilo_model still raises _Error if multiple enabled providers have the model.""" - mock_config = { - "disabled_providers": [], - "provider": { - "fnal-ow": {"models": {"qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}}}, - "fnal-azure": {"models": {"qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}}}, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - with pytest.raises(_M._Error, match="is ambiguous"): - _M._resolve_kilo_model("qwen3-coder-next") - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_resolve_kilo_model_model_prefix_not_known_provider( - self, - ) -> None: - """Model with non-provider prefix is looked up and prefix added.""" - # "unknown-ow" is not a known provider, so model name - # "unknown-ow/qwen3-coder-next" is looked up in config, matching by "id". - mock_config = { - "disabled_providers": [], - "provider": { - "fnal-ow": { - "models": { - "qwen3-coder-next": {"id": "unknown-ow/qwen3-coder-next"}, - } - }, - }, - } - original_load = _M._load_kilo_config # type: ignore[attr-defined] - _M._load_kilo_config = lambda: mock_config # type: ignore[attr-defined] - try: - result = _M._resolve_kilo_model("unknown-ow/qwen3-coder-next") - assert result == "fnal-ow/qwen3-coder-next" - finally: - _M._load_kilo_config = original_load # type: ignore[attr-defined] - - def test_default_model_kilo_uses_qwen3_coder_next(self) -> None: - """_KILO_DEFAULT_MODEL (hard-coded fallback) equals qwen/qwen3-coder-next.""" - assert _M._KILO_DEFAULT_MODEL == "qwen/qwen3-coder-next" - # Verify _default_model("kilo") returns current _DEFAULT_MODEL_KILO - # (which may be environment-derived if GIT_AI_COMMIT_MODEL is set) - assert _M._default_model("kilo") == _M._DEFAULT_MODEL_KILO - - -# =========================================================================== -# Cap Selection Logic -# =========================================================================== - - -class TestCapSelection: - """Tests for the dynamic diff character cap selection in main(). - - The logic should escalate to _MAX_DIFF_CHARS_ESCALATED when: - - backend is 'kilo' - - no explicit model is provided via --model - - no model is pinned via environment variables - - the raw prompt size exceeds _MAX_DIFF_CHARS_DEFAULT - """ - - def _setup_mocks(self, monkeypatch: pytest.MonkeyPatch): - """Set up basic mocks for main() to avoid real git/api calls.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit"]) - monkeypatch.setattr(_M, "_git_root", lambda: Path(".")) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - def test_escalation_occurs_with_default_kilo_path( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Escalate cap when using default kilo backend and large prompt.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "x" * 70_000) - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - # Clear model env vars - monkeypatch.delenv("GIT_AI_COMMIT_MODEL", raising=False) - monkeypatch.delenv("GIT_AI_COMMIT_KILO_MODEL", raising=False) - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - with patch( - "subprocess.run", - side_effect=[mock_completed, subprocess.CompletedProcess([], 0)], - ): - with patch("git_ai_commit._build_messages") as mock_build: - # We mock _chat to avoid actual API call after prompt building - with patch("git_ai_commit._chat", return_value="feat: mock"): - with patch( - "git_ai_commit._resolve_backend_and_api", return_value=("kilo", "") - ): - _M.main() - - # Verify max_diff_chars passed to _build_messages - _, kwargs = mock_build.call_args - assert kwargs["max_diff_chars"] == _M._MAX_DIFF_CHARS_ESCALATED - - def test_no_escalation_with_explicit_model( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Do not escalate when a model is explicitly provided.""" - monkeypatch.setattr( - sys, "argv", ["git-ai-commit", "--backend", "kilo", "--model", "some-model", "--yes"] - ) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "x" * 70_000) - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - with patch( - "subprocess.run", - side_effect=[mock_completed, subprocess.CompletedProcess([], 0)], - ): - with patch("git_ai_commit._build_messages") as mock_build: - with patch("git_ai_commit._chat", return_value="feat: mock"): - with patch( - "git_ai_commit._resolve_backend_and_api", return_value=("kilo", "") - ): - _M.main() - - _, kwargs = mock_build.call_args - assert kwargs["max_diff_chars"] == _M._MAX_DIFF_CHARS_DEFAULT - - def test_no_escalation_with_model_env_var( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Do not escalate when a model is pinned via environment variables.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "x" * 70_000) - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - monkeypatch.setenv("GIT_AI_COMMIT_MODEL", "pinned-model") - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - with patch( - "subprocess.run", - side_effect=[mock_completed, subprocess.CompletedProcess([], 0)], - ): - with patch("git_ai_commit._build_messages") as mock_build: - with patch("git_ai_commit._chat", return_value="feat: mock"): - with patch( - "git_ai_commit._resolve_backend_and_api", return_value=("kilo", "") - ): - _M.main() - - _, kwargs = mock_build.call_args - assert kwargs["max_diff_chars"] == _M._MAX_DIFF_CHARS_DEFAULT - - def test_no_escalation_with_small_prompt( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Do not escalate when the prompt is small.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - # Prompt size < _MAX_DIFF_CHARS_DEFAULT - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "small diff") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - with patch( - "subprocess.run", - side_effect=[mock_completed, subprocess.CompletedProcess([], 0)], - ): - with patch("git_ai_commit._build_messages") as mock_build: - with patch("git_ai_commit._chat", return_value="feat: mock"): - with patch( - "git_ai_commit._resolve_backend_and_api", return_value=("kilo", "") - ): - _M.main() - - _, kwargs = mock_build.call_args - assert kwargs["max_diff_chars"] == _M._MAX_DIFF_CHARS_DEFAULT - - def test_no_escalation_with_other_backend( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Do not escalate when using a backend other than kilo.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "copilot", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "x" * 70_000) - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - monkeypatch.setattr(_M, "_token", lambda _backend, _model: "tok") - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - with patch("git_ai_commit._build_messages") as mock_build: - with patch("git_ai_commit._chat", return_value="feat: mock"): - with patch("git_ai_commit._gh_oauth_token", return_value="gh_oauth_token"): - with patch( - "git_ai_commit._copilot_token", - return_value="copilot_token", - ): - with patch( - "git_ai_commit._resolve_backend_and_api", - return_value=("copilot", "http://api"), - ): - # Mock subprocess.run to avoid actual git commit - with patch( - "subprocess.run", - return_value=subprocess.CompletedProcess([], 0), - ): - _M.main() - - _, kwargs = mock_build.call_args - assert kwargs["max_diff_chars"] == _M._MAX_DIFF_CHARS_DEFAULT - - def test_load_kilo_config_removes_comments(self, tmp_path: Path) -> None: - """_load_kilo_config should remove // and /* */ comments but preserve strings.""" - jsonc_content = ( - "{\n" - " // This is a line comment\n" - ' "disabled_providers": [],\n' - ' "provider": {\n' - ' "fnal-ow": { /* block comment */\n' - ' "models": {\n' - ' "qwen/qwen3-coder-next": { "desc": "Keep // in strings" }\n' - " }\n" - " }\n" - " }\n" - "}" - ) - config_file = tmp_path / "kilo.jsonc" - config_file.write_text(jsonc_content, encoding="utf-8") - - original = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - _M._KILO_CONFIG_CANDIDATES = ( # type: ignore[attr-defined] - tmp_path / "kilo.json", - config_file, - ) - try: - result = _M._load_kilo_config() # type: ignore[attr-defined] - assert ( - result["provider"]["fnal-ow"]["models"]["qwen/qwen3-coder-next"]["desc"] - == "Keep // in strings" - ) - assert "disabled_providers" in result - finally: - _M._KILO_CONFIG_CANDIDATES = original # type: ignore[attr-defined] - - def test_load_kilo_config_invalid_json_returns_empty(self, tmp_path: Path) -> None: - """Invalid JSON returns the default empty config.""" - config_file = tmp_path / "kilo.jsonc" - config_file.write_text("{ invalid json }") - - original = _M._KILO_CONFIG_CANDIDATES # type: ignore[attr-defined] - _M._KILO_CONFIG_CANDIDATES = ( # type: ignore[attr-defined] - tmp_path / "kilo.json", - config_file, - ) - try: - result = _M._load_kilo_config() # type: ignore[attr-defined] - assert result == {"disabled_providers": [], "provider": {}} - finally: - _M._KILO_CONFIG_CANDIDATES = original # type: ignore[attr-defined] - - -class TestModelResolution: - """Tests for _resolve_kilo_model logic.""" - - def test_resolve_kilo_model_found_in_config(self) -> None: - """Should return the model if it exists in the provider's config.""" - with patch.object( - _M, - "_load_kilo_config", - return_value={"provider": {"fnal-ow": {"models": {"test-model": {"desc": "test"}}}}}, - ): - assert _M._resolve_kilo_model("test-model") == "fnal-ow/test-model" - - def test_resolve_kilo_model_not_found_fallback(self) -> None: - """Should return requested model if not found in config.""" - with patch.object( - _M, "_load_kilo_config", return_value={"provider": {"fnal-ow": {"models": {}}}} - ): - result = _M._resolve_kilo_model("unknown-model") - assert result == "unknown-model" - - def test_resolve_kilo_model_no_config_fallback(self) -> None: - """Should return requested model if config is empty.""" - with patch.object(_M, "_load_kilo_config", return_value={"provider": {}}): - result = _M._resolve_kilo_model("any-model") - assert result == "any-model" - - -class TestKiloBackend: - """Tests for the kilo backend functionality.""" - - def test_resolve_backend_and_api_kilo(self) -> None: - """_resolve_backend_and_api returns correct api for kilo backend.""" - backend, api = _M._resolve_backend_and_api("kilo", "") - assert backend == "kilo" - # API should be None (not returned) or a valid URL/startswith http - assert api is not None - assert api == "" or api.startswith("http") - - def test_default_model_kilo(self) -> None: - """_default_model returns the current _DEFAULT_MODEL_KILO for kilo backend.""" - assert _M._KILO_DEFAULT_MODEL == "qwen/qwen3-coder-next" - assert _M._default_model("kilo") == _M._DEFAULT_MODEL_KILO - - def test_chat_kilo_uses_agent_code(self, monkeypatch: pytest.MonkeyPatch) -> None: - """_chat_kilo always passes --agent=code to kilo run.""" - messages = [{"role": "user", "content": "test message"}] - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - - # Mock kilo config to ensure consistent model resolution. - # Uses fnal-ow provider with qwen/qwen3-coder-next model (same as CI config). - mock_config: dict[str, object] = { - "disabled_providers": [], - "provider": {"fnal-ow": {"models": {"qwen/qwen3-coder-next": {}}}}, - } - monkeypatch.setattr(_M, "_load_kilo_config", lambda: mock_config) - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="Generated Commit Message\n", - stderr="", - ) - - with patch("subprocess.run", return_value=mock_completed) as mock_run: - res = _M._chat_kilo("qwen3-coder-next", messages, "test_token", "") - assert res == "Generated Commit Message" - mock_run.assert_called_once() - # Verify that the input contains the expected "test message" content. - kwargs = mock_run.call_args[1] - assert "test message" in kwargs["input"] - - def test_chat_kilo_resolves_model_name(self, monkeypatch: pytest.MonkeyPatch) -> None: - """_chat_kilo resolves model name (adds provider prefix) before invoking kilo.""" - messages = [{"role": "user", "content": "test message"}] - monkeypatch.setenv("GIT_AI_COMMIT_TOKEN", "test_token") - - # Mock kilo config to ensure consistent model resolution. - # Uses fnal-ow provider with a "qwen3-coder-next" config key whose - # "id" holds the provider-qualified value (matches real kilo.json). - mock_config: dict[str, object] = { - "disabled_providers": [], - "provider": { - "fnal-ow": {"models": {"qwen3-coder-next": {"id": "qwen/qwen3-coder-next"}}} - }, - } - monkeypatch.setattr(_M, "_load_kilo_config", lambda: mock_config) - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code", "-m", "fnal-ow/qwen3-coder-next"], - returncode=0, - stdout="Generated Commit Message\n", - stderr="", - ) - - with patch("subprocess.run", return_value=mock_completed) as mock_run: - # Pass model without provider prefix - res = _M._chat_kilo("qwen3-coder-next", messages, "test_token", "") - assert res == "Generated Commit Message" - mock_run.assert_called_once() - call_args = mock_run.call_args - cmd_list = call_args[0][0] # First positional arg is the command list - # Verify the model was resolved with provider prefix - assert "-m" in cmd_list - model_idx = cmd_list.index("-m") - assert cmd_list[model_idx + 1] == "fnal-ow/qwen3-coder-next" - - def test_chat_kilo_passes_token_as_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - """_chat_kilo passes token via KILO_SERVER_PASSWORD env var.""" - messages = [{"role": "user", "content": "test message"}] - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="Generated Commit Message\n", - stderr="", - ) - - with patch("subprocess.run", return_value=mock_completed) as mock_run: - res = _M._chat_kilo("qwen/qwen3-coder-next", messages, "my_secret_token", "") - assert res == "Generated Commit Message" - mock_run.assert_called_once() - cmd, kwargs = mock_run.call_args - assert "env" in kwargs - assert kwargs["env"]["KILO_SERVER_PASSWORD"] == "my_secret_token" - - def test_chat_kilo_passes_api_url_as_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - """_chat_kilo passes api_base via KILO_SERVER_URL env var.""" - messages = [{"role": "user", "content": "test message"}] - api_url = "http://localhost:9798/v1" - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="Generated Commit Message\n", - stderr="", - ) - - with patch("subprocess.run", return_value=mock_completed) as mock_run: - res = _M._chat_kilo("qwen/qwen3-coder-next", messages, "token", api_url) - assert res == "Generated Commit Message" - mock_run.assert_called_once() - cmd, kwargs = mock_run.call_args - assert "env" in kwargs - assert kwargs["env"]["KILO_SERVER_URL"] == api_url - - def test_chat_kilo_file_not_found(self) -> None: - """_chat_kilo raises _Error if kilo CLI is not found.""" - with patch("subprocess.run", side_effect=FileNotFoundError): - with pytest.raises(_Error, match="kilo CLI not found"): - _M._chat_kilo("qwen/qwen3-coder-next", [], "", "") - - def test_chat_kilo_nonzero_exit(self) -> None: - """_chat_kilo raises _APIError if kilo run fails.""" - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=1, - stdout="", - stderr="invalid model name", - ) - with patch("subprocess.run", return_value=mock_completed): - with pytest.raises(_APIError, match="failed \\(exit code 1\\)"): - _M._chat_kilo("qwen/qwen3-coder-next", [], "", "") - - def test_chat_kilo_timeout(self) -> None: - """_chat_kilo raises _APIError if kilo run times out.""" - with patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(["kilo", "run"], 60, "kilo run"), - ): - with pytest.raises(_APIError, match="timed out after 60 seconds"): - _M._chat_kilo("qwen/qwen3-coder-next", [], "", "") - - def test_main_with_kilo_backend( - self, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Main runs successfully with kilo backend.""" - monkeypatch.setattr(sys, "argv", ["git-ai-commit", "--backend", "kilo", "--yes"]) - monkeypatch.setattr(_M, "_git_root", lambda: tmp_path) - monkeypatch.setattr(_M, "_staged_diff", lambda _amend=False: "some diff") - monkeypatch.setattr(_M, "_status", lambda: "") - monkeypatch.setattr(_M, "_recent_log", lambda: "") - monkeypatch.setattr(_M, "_get_instructions", lambda _root: "") - # Simulate a non-tty (pipe) stdin with no content. - monkeypatch.setattr(sys, "stdin", io.StringIO("")) - - mock_completed = subprocess.CompletedProcess( - args=["kilo", "run", "--agent=code"], - returncode=0, - stdout="feat: mock commit message\n", - stderr="", - ) - - with patch( - "subprocess.run", - side_effect=[mock_completed, subprocess.CompletedProcess([], 0)], - ) as mock_run: - _M.main() - - # The first call should be to 'kilo' to generate the commit message. - # The second call should be 'git commit' with the message. - assert mock_run.call_count == 2 - - # Check the first call (kilo run) - kilo_cmd = mock_run.call_args_list[0][0][0] - assert "kilo" in kilo_cmd - assert "--agent=code" in kilo_cmd - - # Check the second call (git commit) - git_cmd = mock_run.call_args_list[1][0][0] - assert git_cmd == ["git", "commit", "-m", "feat: mock commit message"]