Skip to content

Latest commit

 

History

History
311 lines (257 loc) · 110 KB

File metadata and controls

311 lines (257 loc) · 110 KB

git-sc (Git Smart Commit)

AI-powered smart commit message generator CLI tool written in Rust.

Project Overview

This CLI tool generates commit messages using AI coding agents (opencode, Grok CLI, Antigravity CLI (agy, the successor of Gemini CLI), Codex CLI, Claude Code, Apple Intelligence) with automatic provider fallback and format detection.

Quick Reference

Build Commands

make build      # Debug build
make release    # Release build (optimized)
make install    # Build and install to /usr/local/bin (Apple Intelligence enabled on macOS)
make test       # Run tests
make fmt        # Format code
make check      # Run clippy and cargo check (includes apple-ai on macOS)
make clean      # Clean build artifacts

Common Usage

git-sc              # Generate message for staged changes
git-sc -a -y        # Stage all and commit without confirmation
git-sc -n           # Dry run (preview only)
git-sc -a -y -q     # Quiet mode (suppress progress logs for hooks/scripts)
git-sc --debug      # Show AI prompt and command being executed
git-sc -p claude    # Use specific AI provider (antigravity, codex, claude, opencode, grok, apple-intelligence); legacy "gemini" name is accepted as alias
git-sc --amend      # Regenerate last commit message
git-sc --squash main # Squash commits since main branch
git-sc --reword HEAD # Regenerate a specific commit message

Architecture

src/
├── main.rs      # Entry point, CLI dispatch
├── cli.rs       # clap-based argument parsing
├── app.rs       # Application orchestrator (main workflow)
├── config.rs    # Hierarchical TOML configuration
├── error.rs     # AppError enum with thiserror
├── init.rs      # `git-sc init` subcommand
├── state.rs     # Provider cooldown state management
├── devlog.rs    # Developer generation log (opt-in, one JSON per run)
├── notify.rs    # NanoBuddy notification (macOS DistributedNotificationCenter)
├── ai/
│   ├── mod.rs              # Module wiring (apple is cfg-gated here)
│   ├── service.rs          # AiProvider/AiService, fallback orchestration
│   ├── prompt.rs           # Prompt construction & message cleanup
│   ├── provider_command.rs # Per-provider Command construction & debug display
│   ├── process.rs          # Subprocess execution (timeout, concurrent I/O), TempFile
│   └── apple.rs            # Apple Intelligence native call (macOS + apple-ai only)
└── git/
    ├── mod.rs
    └── service.rs  # GitService for git operations

AI module layout note:

  • ai/ is split by responsibility: service.rs keeps the AiProvider/AiService types and the provider-fallback orchestration; prompt format contracts live in prompt.rs, per-provider CLI argument knowledge in provider_command.rs, subprocess lifecycle (the deadlock-avoidance threading in run_process_with_timeout) and output interpretation in process.rs, and the fm-rs native path in apple.rs. Cross-module items use pub(super) visibility. The unit tests for all of these currently remain in ai/service.rs's #[cfg(test)] module (they exercise the same AiService associated functions regardless of which file defines them); relocating them next to their units is optional follow-up work, not a behavioral concern.

Key Components

Module Responsibility
App Orchestrates workflow: verify env → load config → get diff → detect format → generate → commit. run() itself only reads the agent context, verifies the repository, and hands off: dispatch_special_mode() returns Some(result) for the modes with their own workflow (--generate-for / --reword / --amend / --squash, checked in that order) and None otherwise, in which case run_commit() runs the ordinary staged-diff path. The split keeps each piece's branching readable; the dispatch order and the --generate-for conflict checks are carried over unchanged from when they were inline.
AiService Multi-provider AI with fallback over a chain of ProviderSteps (provider + model + command + env). Each step resolves its provider via AiProvider::from_str (gemini/agyAntigravity); the same provider may appear multiple times with different models/accounts. Default chain: opencode → grok → antigravity → codex → claude → apple-intelligence.
GitService Git operations (diff, commit, amend, squash, reword)
Config Hierarchical config: global (~/.config/git-sc/config.toml) + project (.git-sc), via PartialConfig merge. providers is a Vec<ProviderStep> accepting either a string (provider name) or a {provider, model, command, env, name} table.
DevLog Opt-in developer generation log. Collects the prompt, every provider attempt (raw response, findings, decision) and the run's outcome, then writes one JSON file per run. Shared between App and AiService as an Rc.
ProviderState Tracks failed steps with a composite cooldown key (provider + model + env + command, or an explicit name), so the same provider on a different model/account is demoted independently. Default 1-hour cooldown.

Recent-commit detection note:

  • GitService::get_recent_commits() checks whether HEAD exists before calling git log, so empty repositories work regardless of Git locale or localized stderr text.

Repository detection note:

  • GitService::verify_repository() asks Git whether the current directory is inside a work tree instead of trusting a .git path. A plain .git file or directory is not treated as a valid repository by itself.

Operation mode safety note:

  • --amend, --squash, --reword, and --generate-for are mutually exclusive at CLI argument parsing time, so invalid combinations fail instead of silently choosing one workflow.
  • --squash rejects pre-existing staged changes before git reset --soft so unrelated staged files are not folded into the squash commit. That check runs twice: once up front, and again immediately before git reset --soft. The gap between them is the whole AI generation plus the confirmation prompt — tens of seconds during which another terminal or an editor's auto-save can git add, and anything staged in that window would be folded into the squash commit. The second check mirrors what the normal commit path already does just before committing.
  • The normal commit path re-checks more than presence: App::run_commit records the index's tree hash (GitService::write_tree()) before it reads the diff, and compares it again right before git commit. has_staged_changes() alone only answers "is anything staged", so a concurrent git add during generation slips through and gets committed under a message that never described it. On mismatch the run aborts with an explicit "ステージ内容が変化しました" error rather than committing. The ordering is load-bearing: taking the snapshot after the diff leaves a window where an add lands between the two, so the diff holds the old content while the snapshot already holds the new one — and the final comparison then matches and lets it through, which is exactly the case the check exists to catch. write_tree writes a tree object but leaves the index and worktree untouched (an unreferenced tree is just gc fodder), so using it as a probe is side-effect-free. It fails when the index holds unresolved conflicts, so the snapshot is taken as .ok() and a None simply skips the comparison — this is an extra guard, not a new precondition for committing.
  • --squash records the original HEAD (via GitService::get_head_hash()) before git reset --soft. If the subsequent git commit fails (pre-commit/commit-msg hook rejection, GPG signing failure, etc.), the branch is restored to the original HEAD with another soft reset before the error is returned. Without this, the branch stays rewound at the merge-base and the original commits survive only in the reflog. Soft resets do not touch the index/worktree, so the recovery restores the exact pre-squash state.
  • --squash resolves its user-supplied base branch through branch_exists() (git rev-parse --verify) and get_merge_base() (git merge-base), both of which pass the branch after --end-of-options so a --leading value is treated as a revision rather than a git option. This mirrors the hash-receiving paths' option-injection guard. The base-branch path was never exploitable for arbitrary file writes — unlike git show, rev-parse --verify / merge-base expose no --output-style option, and a --leading value is rejected by the branch_exists gate before reaching get_merge_base — but the guard is kept consistent across every user-controlled ref, and as a side benefit --leading branch names now resolve correctly. Downstream count_commits_from_base / get_diff_from_base receive the git-derived merge-base hash, not user input, so they are not an injection sink.
  • --generate-for keeps stdout reserved for the generated message only. With --debug, every debug block (config settings in App::new, AI prompt, provider command, streaming output, exit code) is routed to stderr in this mode; in other modes debug output stays on stdout as before. App::print_config_debug takes the destination as its to_stderr argument, and everything inside AiService reads it from the debug_to_stderr field, set once in App::new from cli.generate_for.is_some(). The two --debug-only notices emitted from set_debug (the legacy gemini alias reminder and the ai-usage filter log) follow the same field rather than being hardcoded to stderr, which is why App::new calls set_debug_to_stderr before set_debug — the notices are printed inside the latter.
  • Index snapshots are not enough for --squash and --amend: they also compare HEAD. What those two modes fold or rewrite comes from the history, not the index, so a concurrent git commit in another terminal leaves the index perfectly clean and sails past every staged-changes check. GitService::head_snapshot() is taken before the diff is read and compared again right before the history moves (git reset --soft for squash, git commit --amend for amend); on mismatch the run aborts with "HEAD が移動しました". Reproduced with the real binary on September 11, 2026 (JST): with a provider that commits during generation, --squash produced a squash commit containing unrelated.txt — a file the AI never saw — under a message describing only the two files it did see. The same snapshot is now also compared in the normal commit path alongside the existing tree comparison, because git reset --soft HEAD~1 during the confirmation prompt leaves the index tree identical while widening what the commit will contain. head_snapshot() returns None on a repository with no commits yet, and a None skips the comparison — same "extra guard, not a precondition" rule as the tree snapshot.
  • The confirmation prompt treats stdin EOF as a refusal, not as the [Y/n] default. read_line returns Ok(0) at EOF leaving the buffer empty, which the old code could not distinguish from the user pressing Enter — so git-sc --squash main < /dev/null without --yes printed the prompt and then approved itself. "Empty line = yes" is a real answer from a real user; "no input stream at all" is not, and every destructive mode (--amend / --squash / --reword) goes through this one function. It now returns AppError::InvalidArgument naming --yes as the way to run unattended.
  • GitService::stage_all()'s Windows branch staged only the current directory. git add -A -- . ':!nul' scopes the pathspec to the cwd, while the Unix branch's bare git add -A covers the whole worktree — so on Windows git-sc -a from a subdirectory silently left every change outside it unstaged, and the nul exclusion (plus the pre-delete and post-unstage of nul, both built from repo_path = cwd) missed a nul at the repository root. The pathspecs are now :/ and :(exclude,top)nul, and the nul path is resolved from get_git_root(). Pathspec semantics are platform-independent, so this was confirmed on macOS by running the two forms side by side from a subdirectory.
  • Which stream the debug blocks go to is decided by --generate-for alone — --quiet must not touch it. These are two different axes that used to share one silent parameter: AiService used it both to suppress the Using … progress lines (the --quiet job) and to pick println! vs eprintln! in emit_debug_line (the --generate-for job), while generate_with_prefix passed silent || cli.quiet for both. The result was a split-brain run: with -q -d on an ordinary commit, "Config Settings" and "AI Prompt" landed on stdout (those paths were already threaded correctly) while "AI Provider Command", the streaming output, and the exit code went to stderr — so git-sc -q -d > out.log captured some of the debug trace and dropped the rest. Reproduced and then locked down by test_quiet_with_debug_keeps_debug_output_on_stdout (integration). The fix separates the axes rather than swapping which flag is passed: call_provider / call_provider_inner / print_debug_command / run_process_with_timeout no longer take a silent argument at all and read self.debug_to_stderr, while the argument still threaded down from generate_with_prefix keeps its original single meaning of "suppress progress output". Passing silent for both would have been the tempting one-line fix and would have regressed --quiet into printing progress again.

Prefix script behavior note:

  • Literal prefix script output has only trailing line endings (\n/\r\n) removed before application, so common echo output does not split the commit subject while intentional trailing spaces remain intact.
  • If a prefix script returns empty output, App preserves the generated message and removes only a leading Conventional Commits type prefix (feat:, fix(scope):, feat!: etc.) when present.
  • Prefix script exit code 1 is the explicit "use AI-generated message without prefix" signal. Other non-zero exit codes are treated as execution failures, so prefix selection can fall through to later scripts, rules, config, or auto detection.
  • Relative script paths in project-level .git-sc are resolved from the Git repository root, and prefix scripts run with the Git root as their working directory.
  • If the current HEAD is detached (get_current_branch() returns None), prefix scripts that already matched their url_pattern are skipped with an explicit branch name unavailable (detached HEAD?), skipping script notice instead of silently falling through. This avoids the confusing UX of printing "Running prefix script for..." and then doing nothing visible.
  • Prefix mode is resolved in priority order: (1) prefix_scripts and (2) prefix_rules are evaluated only when a remote URL is available (both match their url_pattern against it), then (3) the config prefix_type and (4) automatic detection from recent commits are evaluated unconditionally. Steps 3 and 4 are remote-URL-independent, so a local-only repository without remote.origin.url still honors a configured prefix_type instead of silently falling back to Auto. Matching prefix_rules validate their prefix_type against the same allowed values as the top-level config; invalid matching rules warn and are skipped so later rules/config/Auto can still apply. Steps 1 and 2 are factored into the try_prefix_scripts() / try_prefix_rules() helpers to keep get_prefix_mode_internal() flat, but the priority and fall-through semantics above are the contract.

Commit hash validation note:

  • verify_commit_hash() uses ^{commit} suffix to constrain to commit objects only. Tree, blob, and other non-commit objects are rejected with InvalidCommitHash error.

Reword safety note:

  • GitService validates that a --reword target hash is in the current HEAD history before merge-range checks and position calculation.
  • If the hash exists but is outside the current history (e.g., another branch), reword fails with an error.
  • If the target hash itself is a merge commit, reword also fails instead of silently treating it as a normal commit. This check is enforced inside reword_commit() itself (including the n == 1 amend-path), not only in the calling layer, so the guarantee holds even when reword_commit_by_hash() is invoked directly without the app.rs pre-check.
  • Rewording the oldest commit in the current branch is supported by switching to git rebase -i --root when needed.
  • The reword rebase always passes --no-autosquash to isolate the user's rebase.autoSquash=true config. Without it, fixup!/squash! commits inside the rebase range get reordered in the todo and silently folded into their targets (history modification beyond the requested reword), and a squash line would additionally overwrite the folded commit's message with the reword message because GIT_EDITOR unconditionally copies the message file.
  • The reword position n is always consumed as HEAD~n (a first-parent depth), so it is counted along the first-parent path: get_commit_position_by_hash() uses git rev-list --count --first-parent <hash>..HEAD, and the out-of-range / "oldest commit" checks use the first-parent depth (git rev-list --count --first-parent HEAD) too. Counting all ancestors topologically (the previous behavior) over-counts when the history contains merges, making n exceed the real first-parent depth so that HEAD~n resolves past the target — which surfaced a cryptic fatal: ambiguous argument 'HEAD~n..HEAD' instead of the intended outcome. With first-parent counting, position, root detection (--root), and merge detection stay consistent: rewording a first-parent ancestor that has a merge between it and HEAD cleanly fails with HasMergeCommits (merge-spanning reword is unsupported), while rewording across a merge-free range still works.
  • Rewording HEAD uses git commit --amend --only so unrelated staged changes remain staged instead of being included in the rewritten commit.
  • The temporary message file used during reword is created with a unique name and cleaned up automatically to avoid collisions between concurrent runs.
  • GIT_EDITOR passes the message file path via GIT_SC_MSG_FILE environment variable (not shell string interpolation) to prevent injection attacks from paths containing special characters.
  • The display-only short hash is computed via chars().take(7) so multibyte input (e.g. accidental non-ASCII argument) does not cause a UTF-8 boundary panic before validation runs.
  • When the underlying git rebase -i fails for any reason (CONFLICT, rejection by commit-msg/pre-commit hooks, editor errors, etc.), GitService::reword_commit() unconditionally runs git rebase --abort before returning the error. This prevents the repository from being left in an "interrupted rebase" state that would block all subsequent git operations.
  • Because that --abort is unconditional, reword refuses to start while another rebase is already in progress (AppError::RebaseInProgress). Without the guard the failure mode is destructive, not merely confusing: if the user is midway through their own git rebase -i (say, resolving a conflict), git-sc's git rebase -i never starts — git refuses with "It seems that there is already a rebase-merge directory … I am stopping in case you still have something valuable there" — and the error path then aborts the user's rebase, discarding their in-progress conflict resolution. Verified by hand on August 28, 2026 (JST) on a scratch repo: .git/rebase-merge disappeared and the branch snapped back to the pre-rebase HEAD. The check is GitService::rebase_in_progress(), which resolves rebase-merge / rebase-apply through git rev-parse --git-path (not a hand-built .git/…, so linked worktrees resolve correctly) and tests for existence. It is enforced at three points: reword_commit_by_hash() on entry, reword_commit() on entry (covering the n == 1 amend path too, since a rebase leaves HEAD detached at an unintended commit), and once more immediately before launching git rebase -i. The by_hash one checks before computing the position, because during a rebase the detached HEAD makes the target look absent from history and the user would get a misleading InvalidRewordTarget instead of the real reason. The third check exists because the entry check is followed by history inspection and temp-file creation, and a rebase started in that gap would again be destroyed by the abort. A window still remains between that last check and the moment git creates its rebase directory; git exposes no lock an external process can take, so this is the practical floor rather than a complete fix.
  • The rebase also passes --no-rebase-merges, and without it reword is a silent no-op. rebase.rebaseMerges = true makes git prepend label onto / (blank) / reset onto to the todo, so the sequence editor's 1s/^pick /reword / — which only ever touches line 1 — matches nothing. The todo is still a valid all-pick list, so git rebase completes with exit 0, reword_commit() returns Ok(()), and git-sc prints ✓ Commit … reworded successfully! followed by its "you may need to force push" note while the commit's hash and message are byte-identical to before. Reproduced with the real binary on September 11, 2026 (JST): under that config the log was unchanged after a reported success; with --no-rebase-merges the same run rewrote the subject. This is the third config isolated here for the same class of reason as rebase.abbreviateCommands and rebase.autoSquash — the difference is that the other two corrupt the outcome, while this one produces no outcome at all and still reports success.
  • The sequence editor now fails the rebase when the todo's first line is not a pick. --no-rebase-merges closes the known cause, but the failure mode it produced — a success message over an unchanged commit, which invites a force push — is expensive enough that the editor no longer assumes line 1 is what it expects: it reads the first line, and exits non-zero (aborting the rebase) unless it starts with pick . Any future config that prepends something to the todo therefore surfaces as an ordinary reword failure instead of a silent no-op. Deliberately not done: "find the first pick line anywhere and rewrite that" — that would happily reword a different commit than the one requested.
  • Both editors are the sh form on every platform, including Windows. The previous PowerShell branches (powershell -Command "$lines = @(Get-Content $args[0]); …" and powershell -Command "Copy-Item $env:GIT_SC_MSG_FILE $args[0]") never worked. Git runs an editor string containing shell metacharacters as sh -c '<editor> "$@"' <editor> <path> — on Windows too, using the bundled sh — so the outer shell expanded $lines, $args and $env before PowerShell ever saw them; verified by reproducing git's invocation form, which turns the argument into " = @(Get-Content [0]); if (.Count -gt 0) { …". Independently, PowerShell's -Command does not populate $args from trailing arguments, so $args[0] could not have received the todo path even without the expansion. The Unix strings survive because their bodies are single-quoted. Git for Windows ships sh, sed and cp, so a single sh implementation covers both platforms; this is why --reword on Windows was untested-and-broken rather than merely untested (CI builds Windows but runs no tests there).

Amend safety note:

  • GitService reads the last-commit diff via git show HEAD, so --amend also works when the current HEAD is the root commit.
  • GitService::amend_commit() uses git commit --amend --only so unrelated staged changes remain staged instead of being included in the amended commit.

Notification safety note:

  • On macOS the notification path calls CFStringCreateWithCString for both the notification name and body. Each return value is null-checked before use, and any already-allocated CFString is CFReleased before bailing out. CFRelease(NULL) is undefined behaviour, so this guard avoids a crash if CoreFoundation fails to allocate (e.g., under memory pressure).

Installation safety note:

  • make install copies the release binary to a temporary file inside INSTALL_PATH, signs that temporary inode on macOS, and only then replaces the installed command with mv. Do not change this back to a direct cp over the existing binary: macOS caches code-signature validation per inode, so overwriting the contents in place can leave the cache tied to the old hash and make the newly installed command die immediately with SIGKILL (exit 137 and no output). Signing before the same-filesystem rename gives the installed path a fresh, already-validated inode; the trap removes the temporary file if copying or signing fails.

winget distribution note:

  • The Windows build is published to microsoft/winget-pkgs as owayo.git-sc (winget install owayo.git-sc). The identifier is case-sensitive and effectively permanent — changing it later registers a separate package and orphans the old one — so it is pinned to the command name rather than the repository name (git-smart-commit). The first version was submitted by hand (PR #434002, komac 2.16.0 driven from macOS) because winget-releaser only calls komac update and fails with Package ... does not exist in the winget-pkgs repository until one version is merged. Later releases are automatic and preserve the nested structure (InstallerType: zip + NestedInstallerType: portable + PortableCommandAlias: git-sc), replacing only version / URL / SHA256.
  • publish-winget is a job inside release.yml, deliberately not a separate on: release workflow. A Release created with GITHUB_TOKEN does not trigger other workflows (GitHub's loop-prevention rule), so a separate file would never run — and the symptom is not a failure but an empty run history, which is easy to misread as "the release did not happen yet". For the same reason release-tag is passed explicitly from prepare-release's output: the action's default is github.event.release.tag_name || github.ref_name, and under workflow_dispatch there is no release event, so it would fall back to the branch name and look for a tag that does not exist.
  • installers-regex must stay overridden to -pc-windows-msvc\.zip$. The action's default (.(exe|msi|msix|appx)(bundle){0,1}$) matches none of this project's assets, and it fails indirectly: the asset filter selects zero URLs without complaining, then komac update dies on an empty --urls. The target triple is part of the pattern rather than a bare \.zip$ so the other platforms' archives are never pulled in. Note winget cannot consume .tar.gz at all — only the Windows target is zipped, which is why the regex has something to match in the first place.
  • WINGET_TOKEN must be a classic PAT with public_repo. Fine-grained PATs are rejected by Komac, and GITHUB_TOKEN cannot open a pull request against another repository. It also requires a fork of winget-pkgs under the same account (owayo/winget-pkgs); keep it synced, since a fork far behind upstream breaks PR creation. The token is checked inside a step that writes steps.token.outputs.available rather than in the job's if: — the secrets context does not exist in a job-level if, where the condition silently evaluates to false and skips the job on every run.
  • The submission is gated on the package actually being in winget-pkgs, because the first version is not merged on a timetable. A community PR needs a moderator's approval, so PR #434002 has been sitting at reviewDecision: REVIEW_REQUIRED since September 13, 2026 (JST) with Azure-Pipeline-Passed and Validation-Completed — the automated validation passed and there is nothing to fix on this side. Meanwhile winget-releaser exits 1 in its very first step (Package owayo.git-sc does not exist in the winget-pkgs repository), so every release in that window went red although the binaries and the Homebrew tap were published fine, and release-summary was skipped along with it. publish-winget now resolves manifests/o/owayo/git-sc through the GitHub contents API before submitting and skips on a 404 — "not merged yet" is the normal state during the wait, not a release failure. Three details are load-bearing: continue-on-error: true is deliberately not used, because it would also turn a revoked PAT or a mistyped installers-regex green; a status that is neither 200 nor 404 (rate limit, outage) falls back to the plain web path and, failing that, submits anyway, since erring toward "skip" would let a permanently broken lookup stop updates silently and forever; and each curl is wrapped in || true because Actions runs steps under bash -e, where a connection failure would otherwise abort the step. Verified locally against the real API on September 19, 2026 (JST) for all three branches: Microsoft.PowerToys → 200/submit, owayo.git-sc → 404/skip, and an invalid token → 401 → web fallback → 404/skip. Nothing needs to be reverted once the PR merges; the next release simply starts submitting again. release-summary reads the job's submitted output (steps.submit.outcome == 'success') so it does not claim a submission that was skipped.

AI Provider Implementation

Provider fallback chain note:

  • Config.providers is a Vec<ProviderStep> (config.rs). Each entry deserializes from either a plain string (provider name only) or a table { provider, model, command, env, name }. This uses a hand-written Deserialize (string-or-struct via deserialize_any), deliberately not #[serde(untagged)]: under toml 1.x untagged collapses malformed input to a "did not match any variant" message, whereas the hand-written visitor surfaces the real missing-field error (e.g. a missing provider). The same provider may appear multiple times with different models or accounts — e.g. codex on two CODEX_HOMEs, or antigravity on Gemini- vs GPT-OSS-family models, which have separate quotas.
  • AiService holds the chain as steps: Vec<ProviderStep>. from_config keeps each raw provider string (alias canonicalization happens only at cooldown-key/comparison time) and drops steps whose provider does not resolve via AiProvider::from_str.
  • Model resolution (AiService::resolve_model): step.model (non-empty) > [models].<provider> > empty (defer to the CLI's own default). [models] stays the per-provider default for steps that omit model.
  • Command/binary: step.command (first element = binary, rest = fixed args; a wrapper-script path is allowed) overrides the provider's default binary (provider.command()); the provider's standard arguments (--disable hooks/exec/-o for codex, -p for claude, etc.) are still applied on top, because a wrapper ultimately invokes the same underlying CLI. command[0]'s ~ is expanded at Config::load time.
  • Account switching via env (the load-bearing safety property): each step's env (BTreeMap<String,String>) is applied with an explicit cmd.env(k, v) in build_provider_command, and env_clear() is not called (PATH/HOME must stay inherited). An explicit Command::env() override beats whatever CODEX_HOME/CLAUDE_CONFIG_DIR is exported in the shell that launched git-sc, which prevents the class of bug where a step that omits the env silently inherits the parent shell's account and burns the wrong quota. env values are ~-expanded and keys are validated against POSIX [A-Za-z_][A-Za-z0-9_]* (is_valid_env_key) at load time; an invalid key is a hard ConfigError (fail-fast, so a typo cannot silently redirect the run to the wrong account). Additionally, dynamic-loader / interpreter pre-load keys (is_dangerous_env_key: LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FALLBACK_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, DYLD_FALLBACK_FRAMEWORK_PATH, DYLD_FORCE_FLAT_NAMESPACE, DYLD_IMAGE_SUFFIX, DYLD_PRINT_LIBRARIES, NODE_OPTIONS, PYTHONPATH, PYTHONSTARTUP, PERL5OPT, PERL5LIB, RUBYOPT, RUBYLIB) are compared case-insensitively and explicitly refused with a ConfigError. These keys can redirect a child process's shared-library/interpreter pre-load path, so a malicious project-level .git-sc could otherwise inject arbitrary code into the codex/claude/agy subprocess (the legitimate account-switching use case is unaffected because it relies on CODEX_HOME / CLAUDE_CONFIG_DIR etc., not loader keys). --debug prints each step's explicit env overrides and its cooldown_key.

Each provider is called via CLI subprocess:

  • opencode: Uses temp file with -f flag to avoid command line length limits
  • grok (Grok Build TUI, X.AI): Uses temp file with --prompt-file to avoid ARG_MAX and cmd.exe metacharacter issues (same reason opencode goes through a file). git-sc adds --output-format plain --sandbox read-only --no-plan --no-memory --disable-web-search --max-turns 1 --verbatim to every invocation so the TUI agent behaves as a single-turn pure function: --sandbox read-only forbids fs writes and network like Codex's sandbox does, --no-plan/--no-memory block plan mode and cross-session memory (both are on by default), --disable-web-search cuts web fetch, --max-turns 1 stops any tool loop after one turn, and --verbatim prevents the CLI from rewriting the prompt. Model resolution follows the same rule as other providers (step.model > [models] grok > empty = defer to grok default); when non-empty the ID from grok models (currently only grok-4.5) is passed as -m "<id>". Default [models] grok is left empty so a future cheaper model added by grok CLI is picked up automatically without a git-sc release. The Grok CLI ships bundled with cmux (/Applications/cmux.app/Contents/Resources/bin/grok); if grok is not on PATH, this step is skipped and the chain moves on to antigravity.
  • antigravity (agy, the successor of the Gemini CLI as of 2026-05): Uses -p flag for prompt input. As of agy v1.0.x the CLI supports --model (changelog: "Added --model to set model when launching CLI"), so git-sc passes the [models] antigravity value straight through as agy --model "<name>" when it is non-empty; an empty value omits --model and defers to agy's own default. Two spellings of a model name are accepted and both are passed through verbatim: the display name with spaces and parentheses (GPT-OSS 120B (Medium), Gemini 3.5 Flash (Low)) and the slug (gpt-oss-120b-medium, gemini-3.5-flash-low); this was verified against agy 1.1.10 on August 4, 2026 (JST). Which of the two agy models prints is not stable across agy releases — 1.0.x printed display names, 1.1.10 prints slugs, while the Available models: list inside agy's own invalid model selection error still prints display names — so treat both as valid config values rather than assuming the current agy models format is the only accepted one. An unrecognized name is rejected outright (Error: invalid model selection (--model "…" --effort ""): … is not recognized as a known model or custom model in settings, exit status 1, empty stdout) instead of silently falling back to agy's default, so a typo in [models] antigravity surfaces as a normal provider failure and the fallback chain moves to the next step. The CLI still exposes no --debug flag, so debug-related options remain intentionally omitted from the command line. Before launching, AiService::check_arg_size_limit rejects prompts larger than 512 KiB with an explicit error to avoid hitting OS-level ARG_MAX. The legacy gemini provider name remains accepted as an alias both in from_str and in the state-file cooldown key (auto-migrated to antigravity in memory on load). Windows is unsupported for this provider: all providers are launched through cmd /C there (npm .cmd shims), but cmd.exe does not understand Rust's MSVCRT-style argument quoting, so a prompt containing newlines or " (always true for a diff) deterministically corrupts the command line and is a CVE-2024-24576-class injection vector. build_provider_command returns an explicit provider error on Windows so the fallback chain moves on instead of failing silently.
  • codex/claude: Uses stdin for prompt input; Codex also uses -o/--output-last-message to read only the final agent message instead of the execution transcript
  • apple-intelligence: fm-rs (Rust FFI) via Foundation Models on-device (macOS 26+, Apple Intelligence enabled). The system instructions are built per request by build_apple_instructions(language, prefix_type, has_recent_commits) so they always agree with the prompt's format_section: explicit prefix_type values get a forceful (CRITICAL FORMAT RULE) rule for that exact style (none/plain forbids prefixes instead of forcing feat: as the old fixed instructions did), Auto mode with recent commits gets an imitate-their-format rule that deliberately contains no concrete prefix examples (the ~3B on-device model parrots example tokens like [Add] instead of reading the listed commits), and Auto without recent commits falls back to the Conventional Commits rule to match the prompt. Format-neutral instructions were tried and rejected: the small model becomes unstable (echoes the diff). Known limitation: in Auto mode the on-device model may still localize the prefix word (e.g. 修正: instead of feat:) — a model-capability limit, also tolerated by the live tests (assert_apple_intelligence_result only warns).

Apple Intelligence context / timeout / failure-classification notes (all measured on macOS 27.0 + Xcode 27 + fm-rs 0.3.0, September 17, 2026 JST):

  • The on-device context window is 4096 tokens, and the shared 10000-character diff cap is not a safe bound for it. SystemLanguageModel::context_size() reports 4096. Across this repository's 120 most recent non-merge commits, the assembled prompt measured median 2939 / p75 3580 / p90 3901 / max 6316 tokens, and 7–8% exceeded the window (8/120 with no agent context, 10/120 with one); the prompt skeleton alone — instructions plus five recent commit subjects — is 355 tokens, and CLAW_HOOKS_AGENT_MESSAGE has no length limit. So roughly one commit in twelve hit this, and before this change each one cost a hard failure plus an hour of cooldown.
  • The prompt is measured and fitted before generation, not after a failure. SystemLanguageModel::token_usage_for() returns an exact count (estimated: false) without generating, so fit_prompt_to_context() compares instructions + prompt against context_size − max_response_tokens − 128 and, when it does not fit, rebuilds the prompt from a compacted diff instead of giving up. Apple is the last step in the chain, so "generate from a partial view" beats "return nothing". compact_diff_for_apple() keeps every diff --git / diff --cc / diff --combined header — the file list is most of what a commit subject is made of — and truncates the body, with the file list itself capped at half the budget so a commit touching hundreds of files cannot crowd the diff out entirely. The shrink is proportional and re-measured up to four times, because the token-per-character ratio depends on the diff's content (Japanese comments and symbol-dense code differ by more than 2×). The rebuild is deliberately Apple-only: the other providers' context windows are orders of magnitude larger, so lowering the shared MAX_DIFF_CHARS to suit Apple would degrade them. When a run does compact, git-sc prints a warning (suppressed by --quiet and --generate-for, like the other provider warnings) because the resulting message was written from a partial view.
  • Timeouts now apply to the native path. It called session.respond(), which has no deadline, so Apple Intelligence was the one provider that could block forever while every CLI provider was bounded by provider_timeout_seconds. It now calls respond_with_timeout() with that same value. Note the two disagree on zero — the subprocess wait loop treats 0 as "already expired" while fm-rs documents Duration::ZERO as "no limit" — so a zero timeout is turned into the same immediate-timeout error rather than passed through, or the identical config value would mean opposite things per provider.
  • Prompt-bound failures no longer put the provider in cooldown. macOS 27 is what makes this possible: fm-rs selects its Foundation Models 27 implementation when the build SDK is 27.0+, and only then does classifyLanguageModelError map failures onto typed variants (ContextSizeExceeded, GuardrailViolation, Refusal, RateLimited, AssetsUnavailable, Timeout, …) instead of collapsing them into one generic generation error. map_apple_generation_error() splits those into AppError::AiProviderInputError (the prompt is why it failed — context size, guardrail, refusal, unsupported language) and AppError::AiProviderError (the provider is why it failed — assets missing, rate limited, timed out), and AiService::should_record_failure() skips record_provider_failure for the first group. This is the same judgement already applied to truncated and empty responses: a provider that is perfectly healthy should not be demoted for an hour because one commit happened to be large. Confirmed present on this machine by observing response.usage() return Some(SessionUsage { … }), which only the 27 implementation fills.
  • AppError::AiProviderInputError carries #[cfg_attr(not(all(target_os = "macos", feature = "apple-ai")), allow(dead_code))], and removing it breaks CI. ai/apple.rs is the only place that constructs the variant and it is gated on all(target_os = "macos", feature = "apple-ai") (ai/mod.rs), while should_record_failure() matches on it unconditionally — so the definition cannot be gated along with its constructor. In a build without the feature the constructor disappears and the variant becomes dead code. That is invisible locally, because make check on macOS enables apple-ai, whereas CI runs the bare cargo clippy -- -D warnings on Linux: it failed with error: variant AiProviderInputError is never constructed on the very next push after the variant was added. Reproduce the CI view locally with cargo clippy -- -D warnings (no features) rather than make check. The same trap applies to anything else added for the Apple path that non-Apple code only ever reads.
  • Any concrete type: in the instructions gets copied into the output. This is the same parroting already recorded for Auto mode, but it was still present in the Conventional Commits rule, which carried Correct: "feat: add user authentication" examples and a type list written as - docs: documentation only changes. On the four largest commits in this repository (all of which need compaction) the model returned a doubled prefix 4 times out of 4fix: docs: README ファイルを更新, feat: docs: AGENTS.md の更新 — which is_concatenated_subject then rejected, so the run failed anyway after ~25 s. Removing the Correct:/WRONG: examples brought it to 2/4; also rewriting the type list as - docs = documentation only changes (via apple_conventional_type_list(), which re-derives it from the shared CONVENTIONAL_COMMITS_GUIDE so new types follow automatically) brought it to 0/4, with small non-compacted diffs unchanged. The lesson generalizes to anything added here later: in instructions for this model, never write a token in the shape you want it to avoid emitting verbatim. test_type_list_has_no_colon_form and test_apple_instructions_follow_prefix_type pin it.
  • is_step_installed() still answers compile-time only, on purpose. It reports whether the apple-ai feature is in the build, not whether this Mac can actually run the model; making it call ensure_available() would put model initialization on the path of every run, including ones that finish on the first CLI provider. Instead verify_installation() checks the runtime only when Apple is the sole remaining candidate (check_apple_runtime()), and the real call path re-checks ensure_available() immediately before generating.
  • Two Foundation Models 27 features were evaluated and rejected. ContextOptions.ReasoningLevel (light/moderate/deep) is not supported by the on-device model — all three levels return Unsupported capability — so requesting it would break a working provider. Private Cloud Compute is worse than unavailable: it reports availability: Available and context_size: 32768 (8× the on-device window, which would solve the context problem outright), but every respond fails in ~0.03 s with LanguageModelError error -1 because it needs Apple's managed com.apple.developer.private-cloud-compute entitlement, which a self-signed CLI cannot obtain. Do not treat its availability as an indication that it works. It would also move source code off the device, so even a working version would need explicit opt-in rather than being folded into the fallback chain.
  • What is now recorded in the dev log. AttemptRecord.token_usage (TokenUsageRecord) carries context_tokens / prompt_tokens / response_limit_tokens / input_tokens / output_tokens / compacted for the native path, so the frequency and cost of compaction can be checked against real runs instead of re-deriving it from git log the way every earlier prompt change here had to. Subprocess providers leave it None — CLI agents do not report their own token usage.
  • max_response_tokens is set (96 for a subject, 256 with --body) and tool calling is explicitly disallowed. Measured output is 17–25 tokens for a subject and around 100 with a body, so these are roughly 4× headroom rather than a tight bound — fm-rs warns that a strict limit produces malformed output, and git-sc's own truncated-subject detector would then reject it and burn a retry. ToolCallingMode::Disallowed is a no-op today (no tools are registered) and is set so that a future framework default of built-in system tools cannot send commit-message generation off into tool calls.

Truncated response note:

  • A provider can return exit 0 (Antigravity: status: SUCCESS, num_turns: 1) while its response body stops mid-sentence, so every existing check ("non-empty", "exit 0", "no error on stderr") passes and a half-finished subject like ci: GitHub Actions CIワークフローとmise設定を becomes the commit message. AiService::is_truncated_subject() (ai/prompt.rs) rejects those. On detection generate_commit_message_internal re-runs the same step once (truncation is probabilistic, so the retry usually succeeds) and, if the retry is also truncated, stores the error in last_error and falls through to the next step without calling record_provider_failure — the same treatment an empty response already gets, so an otherwise healthy provider is not demoted into cooldown over one bad generation.
  • The detection rule is deliberately narrow, because a false positive is the expensive direction: a wrongly-rejected subject is retried, then handed to the next provider, and if every step agrees the run ends in an error instead of a commit. Only these fire: a subject ending in the case particles or a comma (/); and, only when the whole subject is ASCII, a subject whose last word is an English function word (to/for/and/or/with/in/on/at/of/the/from/by/into/via) or one with an unclosed (. The exclusions matter as much as the inclusions and were all derived from real history, not intuition: are not matched because 再生速度をスライダーで調整可能に / バージョンを最新へ / Gitステータス取得失敗でfail-closedに are ordinary noun-stopped subjects; inflectional endings // are not matched for the same reason; the articles a/an are dropped and the English check is gated on is_ascii() so a lone Latin letter inside a Japanese subject (… で塞ぐ (#57 案 A)) is not read as an article; and full-width / are not counted because Japanese annotations nest them asymmetrically.
  • Validated against real commit history on 2026-08-27 (JST): 132 local repositories, 9136 non-merge commits from the previous 6 months. The first draft of the rule (all ten particles, articles included, full-width parens counted) flagged 52 commits of which roughly 40 were ordinary noun-stopped subjects — it would have made those commits impossible. The narrowed rule flags 12 (0.13%) with zero false positives: 8 genuine truncations (all ending on , e.g. perf: 行コンテキスト取得を O(1) に最適化し LineIndex を, feat: 異常状態フラグで赤ベル表示を) plus 4 copies of Authentication required. Please visit the URL to log in: — an unrelated bug where an AI CLI's login prompt was accepted as the commit message, which this check now also catches. Re-run git log --since=... --pretty=%s across the local repos before ever widening this rule.
  • Measured 2026-08-27 (JST), agy 1.1.21 + gpt-oss-120b-medium, one fixed prompt (a 3-file mise/CI diff, ~12.3k input tokens), reading response from agy --output-format json: the current prompt truncated 8/25 (32%), and every single failure ended on the particle . Those runs report usage.output_tokens of 129–214 while the returned text accounts for well under 30 tokens, and --output-format stream-json shows the final text_delta arriving with state: DONE already set — the tokens are generated but never handed back, so the loss is upstream of git-sc, not in run_process_with_timeout's pipe reader. Four prompt variants were then measured against that 32% baseline, 10–45 runs each: removing the Do NOT end with a period rule = 3/10 (30%) and removing Keep it concise (ideally under 72 characters) = 2/10 (20%) are both within noise, so neither rule is what induces the early stop. Wrapping the answer in <commit></commit> tags is what fixes it, and the presence of an example changes the outcome sharply: - Output the commit message wrapped in <commit></commit> tags, e.g. <commit>fix: 不具合を修正</commit> truncated 1/45 (2%) but returned an empty response 7/45 (16%), whereas the same line without the e.g. clause truncated 0/25 and returned no empty responses. The example-free form is what build_prompt now emits (it replaced - Output ONLY the commit message as plain text). The e.g. result is consistent with the on-device Apple Intelligence finding recorded above — a concrete example in the instructions gets parroted or otherwise destabilizes generation — so do not reintroduce one here.
  • The tag is a hint, never a contract: AiService::strip_commit_tags() (ai/prompt.rs, called from clean_message) unwraps <commit>…</commit>, tolerates either side being missing (a response truncated before </commit> still yields its body, which is_truncated_subject then judges on its own merits), and passes untagged text through unchanged. The closing tag itself can be the thing that gets cut off — a live run produced <commit>ci: CIワークフローを追加</, i.e. a complete subject followed by a half-written </commit>, and without handling it the </ lands in the commit message. trim_partial_close_tag() drops any 2-or-more-character prefix of </commit> left at the end (</, </c, … </commit); a lone < is kept because it can legitimately be part of a subject. Note what this implies about the tag change: it does not stop the model from stopping early, it moves the cut from the message body to the trailing tag, where it is recoverable. That is what keeps the prompt change safe for models that ignore the instruction. Verified on 2026-08-27 (JST) against the other providers with the same staged diff: apple-intelligence emits no tags and its output is unchanged from the pre-change baseline (ci.yml 追加 etc.), while codex (gpt-5.4-mini) and claude (haiku) both returned normal subjects (ci: GitHub Actionsとmise設定を追加, chore: mise と CI ワークフローを追加). No provider regressed.

Wrong-tag response note:

  • A third way the tag instruction fails: the model honors "wrap it in tags" but names the tag after the commit type instead of commit. Observed in the wild on 2026-09-02 (JST) — <test>OAuthスコープの狭さとdirectory.readonlyを検証</test> was committed verbatim, angle brackets and all, because strip_commit_tags() only knows the literal <commit>. AiService::split_full_tag_envelope() (ai/prompt.rs) now strips any symmetric wrapper, and clean_message_detailed() carries the tag name up alongside the message as CleanedResponse.envelope_tag.
  • The stripping rule is deliberately narrow: the wrapper must enclose the whole response, the opening and closing names must match exactly, the tag must carry no attributes, and only one layer comes off. Widening it to "a tag anywhere in the text" or "one side may be missing" would destroy ordinary subjects — across 132 local repositories and 8315 non-merge commits from the previous 6 months (measured 2026-09-02), 6 subjects contain < and 5 of them are normal (fix: レビュー結果の<details>内チェックボックス誤検出修正, fix(seat-tier): BigQuery ARRAY<STRUCT> 型記法修正, fix: Swift レンジ演算子 (..</..) のバージョントークン解析を修正, …); only the accident is a full wrapper. Restricted to full wrappers the rule has zero false positives on that history. The envelope check runs before the <commit> fallback, so a response like <wrapper>説明 <commit>本文</commit></wrapper> is not silently reduced to its inner body and the outer commentary lost.
  • Stripping alone would lose the prefix the model was trying to write, so the tag name is treated as a format hint rather than discarded. restore_conventional_prefix() turns <test> back into test: only when all of: the tag is one of the standard Conventional Commits types in lowercase (<TEST> is stripped but not restored — capitalization is weak evidence the model meant a type); the subject does not already carry a type prefix (<test>fix: …</test> stays fix: …); and the caller expects Conventional Commits. That last condition is expects_conventional in generate_commit_message_internal: prefix_type == "conventional", or — in Auto mode — an empty history (where the prompt itself asks for Conventional Commits) or a majority of the recent commits carrying a type prefix. This is what keeps a prefix_type of plain/none/bracket/emoji from being overwritten by the model's habit, and it is why the restoration lives in the AI layer (which already receives prefix_type and recent_commits) rather than in clean_message, which stays a pure function that knows nothing about prefix policy. The restoration touches the subject line only; body lines are untouched.
  • What cannot be repaired is retried instead. has_leftover_markup() rejects a subject that starts with an opening tag or ends with </name> — what remains when the wrapper had mismatched names, carried attributes, or had only one side. It joins is_truncated_subject / is_concatenated_subject as a third defect in the generation loop: retry the same step once, then fall through to the next provider without recording a cooldown. The opening-tag half deliberately does not validate the tag name: an attributed response like <commit foo="bar">…</commit> is half-repaired by the <commit> one-sided fallback (the closing tag is dropped, the opening one is not) and would otherwise slip through as <commit foo="bar">…. No subject in the 8315-commit history matches either half of the check.

Multi-message response note:

  • A second way the generated subject comes out unusable: the model honors "write a single line" but packs several commit messages onto it — ci: GitHub Actions CI設定追加 docs: READMEにmise手順追記 config: mise.toml追加. AiService::is_concatenated_subject() (ai/prompt.rs) rejects a subject carrying two or more standard Conventional Commits types (feat fix docs style refactor perf test build ci chore revert), counting only whitespace-delimited tokens that end in : (so type:, type(scope):, type!: count, while a mid-sentence http: or 観点 B: does not). Counting any word: instead flags 5 real commits out of 9139 (docs(T0.1): … trust but verify: …); restricting to the standard types flags 0. Detection shares the truncation path — retry the same step once, then fall through without a cooldown.
  • Measured 2026-08-27 (JST), same harness as the truncation numbers (25 runs per variant, agy 1.1.21 + gpt-oss-120b-medium): the pre-change prompt produced 17/25 clean subjects (8 truncated, 0 concatenated); adding the <commit> tag alone gave 22/25 (0 truncated, but 3 concatenated — the tag appears to invite filling the wrapper with everything); adding - Write exactly ONE commit message with ONE prefix. Never combine several messages on the line on top gave 24/25 (0 truncated, 0 concatenated, 1 empty). Both prompt lines are therefore load-bearing and were kept together; the with_body branch carries the same rule worded for the subject line. Note the tag change alone traded one defect for another — do not remove the ONE-prefix rule while keeping the tag.

Quote stripping note:

  • clean_message_detailed removes surrounding quotes, and trim_matches('"').trim_matches('\'') was the wrong tool: it trims each end independently, so a subject with a quote on only one side loses it. The realistic case is the closing one — revert: "feat: 認証追加" (the shape git revert itself writes) became revert: "feat: 認証追加, and chore: rename "foo" to "bar" lost its final quote. Nothing downstream catches it: is_truncated_subject normalizes the last token's punctuation away before matching, has_leftover_markup only looks at angle brackets, and one type prefix passes is_concatenated_subject — so the unbalanced subject is what gets committed. The opening side breaks the same way ("foo" のバグを修正foo" のバグを修正). AiService::strip_wrapping_quotes() (ai/prompt.rs) only removes a quote when both ends carry the same one, looping per quote character so the documented layering still holds ('"feat: x"' sheds the outer single and keeps the inner double; ""feat: scope"" collapses fully). All six pre-existing quote tests were checked against the new function before the swap. Regression test: test_clean_message_keeps_unpaired_quotes.
  • Keeping unpaired quotes reopened one narrow hole, so it is closed explicitly: a response of just " (or """, which sheds one pair and leaves one) used to be trimmed to the empty string and rejected as an empty response, but under a pair-only rule it survives as a one-character "message". After the trim, a string consisting solely of quotes and whitespace is therefore mapped back to empty so it takes the existing empty-response path to the next provider. Regression test: test_clean_message_treats_quote_only_response_as_empty.

Provider error extraction note:

  • AiService::extract_error() (ai/process.rs) turns a failed provider's stderr into the one line shown to the user and stored in the dev log's error field. For Codex that stderr contains the prompt. Codex echoes Reading prompt from stdin... followed by the whole prompt — i.e. the staged diff — before whatever actually went wrong, which is the same behaviour recorded in the developer-generation-log note above. Its second tier ("a line containing error, lowercase included") scanned from the top, so any diff line holding the substring errorlet mut read_error: …, anything naming std::io::Error, a renamed error.rs — was returned as the failure reason ahead of the real one. The third tier already scanned in reverse and skipped the echo marker lines; only the second was missed. It now scans .rev() too and skips lines starting with Reading prompt. Regression test: test_extract_error_codex_prefers_real_error_over_prompt_echo.
  • This narrows the window rather than closing it. Because the echo has no terminator, "where the prompt ends" is not recoverable from stderr alone, so a real error that happens to contain no error substring can still lose to a diff line near the end of the echo. That case falls through to the third tier, which returns the last non-empty line and is right for it. Do not "fix" the remainder by widening the skip list to the diff body — the check would then depend on diff content, which is exactly what makes this fragile.
  • The empty-response error goes through extract_error too — it used to paste the whole stderr. process_provider_output's "provider returned an empty response (stderr: …)" branch interpolated stderr_str.trim() verbatim, and for Codex that string is the prompt echo, i.e. the staged diff. It reached two places at once: the terminal (⚠ Codex CLI failed: …, and the scrollback of every hook-driven run) and the dev log's attempts[].error, which is not covered by the content = "metadata" redaction — call_provider drops stderr_excerpt at that level but error is written unfiltered, and the 256 KiB capture() cap does not apply to it either. So the "metadata must not contain the diff" property held everywhere except this one branch. It now formats Self::extract_error(stderr_str, provider), the same one-line reduction every other failure path uses. This inherits that function's residual window (above) rather than closing it: extract_error is a selector, not a redactor, so a diff line containing error can still be the line it picks. The change takes the exposure from "the entire prompt, every time" to "one line, in the cases the tier ordering mis-ranks", which is the same risk level already accepted for the rest of the error path. Regression test: test_process_provider_output_empty_response_does_not_leak_prompt_echo.

Recursive invocation note:

  • The AI CLIs git-sc drives are themselves coding agents that fire their own stop hooks. When git-sc is registered in those hooks (the normal setup here — claw-hooks runs git-sc --all --yes --quiet on Stop), calling one recurses: git-sc → claude -p → Stop hook → git-sc → commit. The inner git-sc commits immediately, so a --dry-run invocation still produces a commit. Reproduced 2026-08-27 (JST): git-sc -n -p claude in a repo with staged changes left setup: mise と CI ワークフローを追加 committed ~30s later (detached hook), and running plain claude -p in that directory is enough to fire the hook.
  • Codex was already handled with --disable hooks, but that is provider-specific and claude has no equivalent: --bare does disable hooks yet restricts auth to ANTHROPIC_API_KEY/apiKeyHelper (breaking OAuth), and --settings '{"hooks":{}}' merges into the existing settings rather than replacing them — measured, the stop hook still fired. So the guard lives in git-sc instead: build_provider_command sets GIT_SC_NESTED=1 on every provider command (after the user's env, so a config typo cannot unset it), and main() returns immediately when it sees that variable. Environment variables are inherited through the AI CLI down to whatever its hooks spawn, which is what makes this work for every provider, present and future, without a per-CLI flag. It exits 0, not an error, because it runs as a hook. Verifying this needs the installed git-sc (the hook resolves it from PATH) to be the built binary — a target/debug build alone will not show the fix.

Temp file safety note:

  • TempFile and TempRewordMessageFile use RAII (Drop) for automatic cleanup.
  • On Unix/macOS, temp files are created with mode 0600 so AI prompts, Codex final-output files, and reword messages are not readable by group or other users while they exist.
  • On write/sync failure, the file is explicitly deleted before returning the error to prevent orphaned temp files.

Subprocess timeout note:

  • AiService::run_process_with_timeout() writes the prompt to the child's stdin on a dedicated thread that runs concurrently with the stdout/stderr reader threads. Writing the full prompt synchronously before starting to read stdout (the previous approach) could deadlock: if a stdin-using provider (codex/claude) emits more than a pipe buffer's worth of stdout before consuming all of stdin, both pipes fill and the parent blocks in write_all while the child blocks on its own write. Because the timeout loop is never reached while write_all is blocked, the hang is unbounded. This is reachable in practice because agent_context (CLAW_HOOKS_AGENT_MESSAGE) is not length-limited. Running the writer concurrently with the readers removes the deadlock.
  • AiService::run_process_with_timeout() joins the stdin writer thread and both stdout/stderr reader threads on every exit path (success, timeout, and try_wait error). After child.kill() and child.wait() close the pipes, all three threads receive EOF/EPIPE and exit cleanly, so no detached threads leak when a provider call times out. std::process::Child::drop() is a no-op, so this explicit join + the timeout loop's kill()/wait() are what prevent zombie children and leaked pipe FDs across the provider fallback chain.
  • If the stdin write fails (e.g., the AI CLI exits immediately and the pipe receives EPIPE) and the child still reports success (exit 0), run_process_with_timeout() returns a provider error instead of treating the possibly-truncated prompt's output as a valid commit message. When the child exits non-zero, that exit status takes precedence and is handled by process_provider_output().
  • The stdout reader carries its read error back instead of discarding it, and an error on an otherwise-successful (exit 0) run is turned into a provider error — the same treatment as the stdin-write failure above, for the same reason. reader.lines() stops at the first Err (invalid UTF-8 or an I/O error), so the old map_while(Result::ok) silently truncated the response at that point and handed back "everything read so far" as if it were complete. Nothing downstream could tell: the process exited 0, stderr was clean, and the partial text is often a syntactically fine subject line, so it would be committed. Two deliberate exemptions: the stderr reader still ignores its own read errors (it only feeds error-message extraction, never the commit message), and Codex is excluded from the stdout check because its stdout is the execution transcript while the message itself comes from the separate -o file — failing there would throw away a perfectly good final message over a corrupted transcript.
  • Known gap (not fixed): the timeout does not cover grandchildren. child.kill() signals only the direct child. If the AI CLI spawned its own children that inherited the stdout/stderr pipes and they outlive it, the pipes never reach EOF and the reader-thread join() blocks past the timeout. The joins are deliberate (see above — they are what prevents detached threads and leaked FDs), so the fix is not to drop them but to kill the whole process group: CommandExt::process_group(0) at spawn plus a kill(-pid, SIGKILL) on timeout, which needs a libc dependency and an unsafe block, plus a Job Object equivalent on Windows. Not observed in practice with the current providers; recorded here so the next person does not "fix" it by removing the joins.

When a step fails, that step enters cooldown (default: 60 minutes, keyed by provider+model+env+command) and the next step in the chain is tried.

Provider state file note:

  • State::save() writes to ~/.config/git-sc/.providers-state.tmp first and then rename(2)s it onto the final path so concurrent git-sc invocations never read a half-written TOML file. On rename failure the temporary file is deleted before the error is returned. The same cleanup also runs when the fs::write itself fails after creating the file (ENOSPC etc.); tmp names are unique per call and never reused, so a leftover file would otherwise accumulate forever.
  • The temporary file suffix combines PID, monotonic nanosecond timestamp, and a process-local AtomicU64 counter, so multiple threads (or rapid consecutive saves) that happen to observe the same wall-clock nanosecond never share a tmp path. Without the counter, two concurrent threads could write to the same *.tmp.PID.NANOS file and the slower thread's rename(2) would fail with ENOENT after the faster thread already moved it.
  • On Unix the temp file is opened with OpenOptions::create_new(true).mode(0o600) rather than fs::write, so the state file is not group/other-readable (fs::write leaves it to the umask, typically 0644). This matters because cooldown_key embeds each step's env values verbatim, and those are whatever the user put in .git-sc — normally just CODEX_HOME-style paths, but nothing stops a credential from ending up there. rename(2) carries the mode over, so the final state file is 0600 too. create_new additionally means the write never follows a pre-planted symlink. Same rationale as the prompt/reword temp files. Regression test: test_save_to_path_creates_file_not_readable_by_group_or_others.
  • Known gap (not fixed): concurrent record_failure can lose an update. The load → mutate → save sequence has no lock, so two git-sc runs that fail different providers at the same time each start from the same snapshot and the later rename wins, dropping the other's cooldown entry. rename is atomic per write but does nothing for the read-modify-write as a whole. The consequence is bounded and self-correcting — one provider is retried once more than intended, then re-enters cooldown on its next failure — so this is deliberately left alone rather than pulling in a file-locking dependency.
  • provider_cooldown_minutes is converted to seconds with saturating arithmetic, so extremely large user-provided values do not panic in debug builds or wrap in release builds; they are treated as effectively indefinite cooldowns.
  • Cooldown reordering canonicalizes provider aliases before comparing state keys with configured providers, so gemini/agy remain tied to antigravity and legacy apple-ai/apple_intelligence keys remain tied to apple-intelligence.
  • Cooldown keys are composite, not provider-name-only: ProviderStep::cooldown_key() returns the explicit name (lowercased) if set, otherwise a deterministic key derived from canonical provider + model + env + command, joined by US (0x1F) so model names with spaces/parentheses/colons and env values with slashes never collide. This is what makes "the same provider on a different model or account is demoted independently" hold (e.g. codex on account A can be in cooldown while codex on account B keeps working, and antigravity on the Gemini model stays usable when the GPT-OSS step is cooling down). State.failures is a Vec<ProviderFailure { key, provider, failed_at }> (was a HashMap<String, _>); State::load migrates an old provider-name-keyed file by mapping each legacy key through the "provider-only step" cooldown_key, so existing cooldowns keep applying and gemini/apple-ai legacy keys still merge into antigravity/apple-intelligence. The legacy in-memory migrate_legacy_gemini_key is gone — canonical_provider_key (now in config.rs, shared by ProviderStep::cooldown_key and the state migration) handles the alias merge.

Developer generation log note:

  • [dev_log] enabled = true (global config only) records one JSON file per run under ~/.config/git-sc/logs/YYYY-MM-DD/, so prompt changes can be evaluated against measured failure rates instead of the after-the-fact git log scans that every earlier prompt fix in this file relied on. DevLog (devlog.rs) is built in App::new, shared with AiService as an Rc, and written exactly once from App::run — routing every mode through a single exit is what keeps a new operation mode from silently skipping the log. A run that never reached generation writes nothing (finish returns early when no prompt was set), because the interesting unit is a prompt/response pair, not a process start.
  • One file per run, published by rename. git-sc is a short-lived process that runs concurrently across repositories, and a record carrying a full prompt is tens of KB. A shared daily JSONL cannot guarantee non-interleaved lines at that size — O_APPEND on a regular file only serializes where the write starts, not that a large write lands in one piece — so the choice is either an inter-process lock or per-run files. Per-run files are simpler and also make a partially written record impossible to mistake for a finished one: write_record writes .{run_id}.tmp with create_new(true) + mode 0600, then renames it into place (the same technique as State::save). Analysis converts them back with find … -name '*.json' | xargs jq -c ..
  • content = "metadata" (default) must not leak the diff, and that takes two exclusions, not one. Omitting the prompt is the obvious half. The other is provider stderr: Codex echoes the prompt to stderr (Reading prompt from stdin... followed by the whole thing), so capturing stderr at the metadata level puts the staged diff back in the log through the side door. Caught by inspecting a real run's record on 2026-09-02 (JST) — the field held 2.7 KB of prompt echo — so call_provider now drops the stderr body entirely at that level and keeps only stderr_bytes. The one-line reason a provider failed still survives in error (from extract_error), which is what failure analysis actually needs. Raw stdout is kept at both levels on purpose: <test>fix: x</test> and fix: x clean up to the same string, so without the raw response a wrong-tag or truncation event is indistinguishable from a normal one after the fact.
  • Other records are deliberately non-secret: env overrides are logged by key name only (values are CODEX_HOME-style paths but nothing stops a credential from being there), and provider_plan uses step_plan_label — provider + configured model, or an explicit name — rather than cooldown_key, which embeds env values. Files land mode 0600 inside 0700 directories.
  • Retention is "days or size": files older than retention_days (default 14 — two days, as in claw-hooks, is far too short to compare a prompt change) go first, then oldest-first until the total fits max_total_mb (default 500). A .cleanup-stamp throttles the whole scan to once per 24 h so a per-commit process is not walking the tree every run; .tmp files are only reaped after an hour, since a younger one may belong to a live run. Everything here is fail-open — a write or cleanup failure prints one warning (silent under --quiet, since the hook path runs that way on every commit) and the commit proceeds. Each of those four cleanup behaviours has a regression test (test_cleanup_removes_files_older_than_retention, test_cleanup_trims_oldest_files_until_total_fits_limit, test_cleanup_removes_only_stale_tmp_files, test_cleanup_removes_emptied_date_directories, plus test_cleanup_is_skipped_while_stamp_is_fresh for the throttle); all four were checked by disabling the corresponding branch and confirming the test fails, so they are not merely passing by accident.
  • Global-config-only is a safety property, not a preference: PartialConfig::merge_into drops a project [dev_log] with a warning, so a cloned repository's .git-sc cannot switch logging on or choose dir and thereby have your source code written somewhere of its choosing. Note this is narrower than the existing .git-sc execution surface (providers[].command / prefix_scripts[].script / ai_usage.command are still project-settable); the log is held to a higher bar because it accumulates content rather than running once. Because this is a safety property and not an incidental behaviour of the merge, it is pinned by test_partial_merge_into_project_dev_log_cannot_enable_logging (a project [dev_log] enabled = true leaves the config with logging off) and test_partial_merge_into_project_dev_log_does_not_override_global (a project table cannot redirect dir or raise content when a global one exists).
  • The default log directory comes from Config::config_dir() (~/.config/git-sc), not dirs::config_dir(). On macOS the latter is ~/Library/Application Support, which would put logs somewhere other than the config they are configured by.
  • started_at is read once, at construction — reading the clock twice made it the finish time. started_at_unix_ms was taken in from_config while the human-readable started_at called Local::now() again inside finish(), which runs after generation and the confirmation prompt. The field therefore sat exactly duration_ms after the epoch field it is supposed to agree with: checked against 8 real records in ~/.config/git-sc/logs, started_at - started_at_unix_ms == duration_ms held in every one, with runs of 12–18 s. DevLog now holds started_at_local: DateTime<Local> taken once, and started_at_unix_ms is derived from it via timestamp_millis(), so the two cannot drift. The date directory in write_record uses the same value: run_id is built from the start epoch, so reading the clock there instead put a run that began at 23:59 into the next day's directory, disagreeing with its own filename. Regression test: test_started_at_records_run_start_not_finish (sleeps, then asserts the two fields agree within 50 ms), confirmed to fail when the finish()-side clock read is restored.

ai-usage integration note:

  • [ai_usage] enabled = true opts in to a residual-quota gate for the fallback chain. On construction (AiService::from_config), ai_usage::fetch_snapshot() runs ai-usage --json (default; overridable via [ai_usage] command) once with a timeout_seconds (default 10) and parses the JSON into an AiUsageSnapshot. Each ProviderStep is then evaluated via AiUsageSnapshot::evaluate() against threshold_percent (default 95) using the selected window (weekly / five_hour / nearest; nearest picks the higher of the two, biasing toward safe filtering). Steps whose account exceeds the threshold are removed from the chain for this run only (independent of the cooldown State); the cooldown machinery is unchanged.
  • ProviderStep::ai_usage_profile (optional) matches the ai-usage JSON profile string exactly (case-sensitive; Chrome display name). When set, the (profile, provider) pair is looked up. When omitted, auto-select reads the account with the lowest used_percent among ok=true accounts for the same canonical provider, and judges the step against that account's number. This gate only decides whether a step stays in the chain; it never changes which account the step actually runs as. The executing account is decided solely by the step's env (CODEX_HOME / CLAUDE_CONFIG_DIR …), falling back to whatever the parent shell exports. So auto-select without env is optimistic-only: with Work at 99% and Home at 0%, the step is kept on Home's number but still runs as whatever the parent shell points at (possibly Work). That is the intended fail-open bias — the cost of guessing wrong is one wasted call followed by a cooldown, whereas filtering out a usable step would block the commit. If you want the gate and the execution to agree, pin both: give the step an explicit ai_usage_profile and the matching env. ai_usage_profile is deliberately excluded from ProviderStep::cooldown_key() because ai-usage is a "residual quota" gate and cooldowns are a "recently failed" gate — mixing them would demote fresh accounts on unrelated failures.
  • ProviderStep::ai_usage_group (optional) matches the ai-usage JSON group_label case-insensitively (surrounding whitespace ignored), and exists because one account's quota can be split into independent per-model-family pools. Antigravity is the concrete case: for a single profile = "Antigravity", ai-usage returns two rows, group_label = "Gemini" and group_label = "Claude&GPT", with separate weekly windows — measured 2026-08-19, the Gemini pool read 100% used (agy returning RESOURCE_EXHAUSTED (429): Individual quota reached) at the same moment the Claude&GPT pool read 1.17% and gpt-oss-120b-medium answered normally. Without a group, two antigravity steps in a chain are indistinguishable to the gate, so one exhausted pool would filter out the healthy step too. Matching is looser than ai_usage_profile (an exact, case-sensitive Chrome profile name) because group_label is a label ai-usage derives from a display name, not a name the user chose. When the group is set but matches no row, the step falls through to NoAccount (kept, fail-open) and the debug reason names the group so a typo is visible. When the group is omitted and several rows still match the (profile, provider) pair, evaluate picks the row with the lowest used_percent among ok=true rows instead of the first match — first-match would make the verdict depend on ai-usage's output order, and the optimistic pick keeps the step (worst case: one wasted call, then cooldown). Like ai_usage_profile, ai_usage_group is excluded from cooldown_key(). The --debug step label prints provider(profile=…, group=…) so per-group decisions are traceable.
  • Known blind spot (measured 2026-08-19): ai-usage can only read the real Antigravity quota through its local language_server path, i.e. while an agy/Antigravity.app process is alive. With no such process it falls back to the OAuth cloudcode-pa …:retrieveUserQuota path, which returns Gemini Code Assist per-model daily buckets (gemini-2.5-flash, -flash-lite, -pro, gemini-3.1-flash-lite) — a different pool that read remainingFraction: 1 (0% used) while agy was hard-refusing with Individual quota reached. So for antigravity the gate is often a no-op and the cooldown is what bounds wasted calls; a provider_cooldown_minutes = 1-style setting re-probes an exhausted daily pool on every commit. This is an ai-usage limitation, not a git-sc one; nothing in git-sc should treat a 0% antigravity reading as authoritative.
  • The integration is fail-open on fetch failure, fail-closed on full quota exhaustion. Failed fetch (spawn error, non-zero exit, timeout, unparseable JSON) yields a debug note and leaves the chain unchanged instead of erroring — a broken ai-usage binary must never block a commit. Same for accounts whose lookup yields NoAccount (missing profile, ok=false, or provider not signed in): UsageDecision::is_usable() returns true for both Usable and NoAccount, and only OverThreshold filters. But if the snapshot was fetched successfully, the input chain was non-empty, and every step was filtered out as OverThreshold, apply_ai_usage_filter sets gate_blocked = true; AiService::from_config then keeps the empty chain (does not rescue with default_steps()), and verify_installation returns AppError::AiUsageError with a threshold-exhausted message. Falling back to the default chain in that case would silently bypass the residual-quota gate — the exact steps ai-usage just refused would be reintroduced through the default path and called anyway. The rescue to default_steps() still applies to unrelated emptying causes (config had no providers, all providers were unknown names, or the input was empty before ai-usage ran), because those are not the gate's decision.
  • [ai_usage] command's first element is ~-expanded at Config::load time, in the same finalize_steps() pass that expands providers[].command[0]. It was missed there originally, and the asymmetry was invisible in use: a command = ["~/bin/ai-usage", "--json"] fails to spawn, the integration is fail-open, and the failure is only reported under --debug — so the residual-quota gate quietly stopped applying while everything still looked fine. Only element 0 is expanded; the remaining arguments are left alone because they are not necessarily paths. Regression test: test_finalize_steps_expands_tilde_in_ai_usage_command.
  • AppError::AiUsageError has two surfaces: (1) ai_usage::fetch_snapshot returns it on fetch failure, and the outer flow catches it and turns it into a keep chain unchanged debug note — it is not shown to end users unless --debug is on; (2) AiService::verify_installation returns it fatally when gate_blocked is set, which is always surfaced to the user (no downstream fallback exists in that path). Debug notes captured by apply_ai_usage_filter are surfaced when AiService::set_debug(true) is called (same lifecycle as the legacy alias notice — printed once via eprintln! and cleared).

Agent Context

When invoked from a coding agent, App::run() reads the CLAW_HOOKS_AGENT_MESSAGE environment variable and passes it to AiService::build_prompt() as agent_context. This context is injected into the AI prompt before the diff section, guiding the AI to reflect the developer's high-level intent in the commit message. The context is applied across standard generation and --amend / --reword / --squash / --generate-for workflows.

Default Codex model note:

  • The default Codex model is gpt-5.6-luna (default_codex_model() in config.rs). Changed from gpt-5.4-mini on September 17, 2026 (JST) because that model stopped existing, and the failure mode is worth noting: specifying it does not fall back to anything, it returns HTTP 400 (The 'gpt-5.4-mini' model is not supported when using Codex with a ChatGPT account.), which git-sc records as a provider failure and puts the step into cooldown — so a stale default silently removes codex from the chain entirely, on every run, for both configured codex accounts. A default that names a specific model has this expiry built in; re-check it whenever codex debug models changes. The replacement was chosen with the same procedure as before. Candidates from codex debug models (visibility: "list", supported_in_api: true, all of which now support medium): gpt-5.6-sol (priority 0), gpt-5.6-terra (1), gpt-5.6-luna (2), gpt-6-astra (3), gpt-5.5 (12). Measured with the fixed prompt Reply ok. in an empty directory (-C <tmp> --skip-git-repo-check --ignore-user-config --ignore-rules --ephemeral --sandbox read-only, -c model_reasoning_effort="medium"), reading input_tokens from the --json turn.completed event: gpt-5.6-luna = 19609, gpt-5.5 = 20181, gpt-5.6-sol = 21174, gpt-5.6-terra = 21174, gpt-6-astra = 22035. Every run returned the required ok with no tool calls, and a second round reproduced all five figures exactly, which is a sharper result than the earlier generation gave (those drifted by tens of tokens between runs). gpt-5.6-luna is the minimum by 2.8% over the next candidate and 7.4% over the priority-0 model, and it is also the one the CLI itself describes as "Fast and affordable agentic coding model" — the two signals agree, which they did not have to. As always this measures minimal response cost, not real-workload quality. The history below is kept for the procedure it documents; every model it names is gone. The previous default was selected on June 9, 2026 (JST): the candidates were narrowed to models that are API-visible, listed, and support medium reasoning — gpt-5.5, gpt-5.4, and gpt-5.4-mini. Each was measured with the fixed prompt Reply ok. in an empty directory (-C <tmp> --skip-git-repo-check --ignore-user-config --ignore-rules --ephemeral --sandbox read-only), reading input_tokens from the --json turn.completed event: gpt-5.5 = 17152, gpt-5.4 = 15770, gpt-5.4-mini = 15421. The gpt-5.4-mini run produced the required final output ok and no tool calls, so it is chosen as the default by the input-token primary metric. This measures minimal response cost only and does not guarantee real-workload (commit/review/refactor) quality; any future change should re-run codex debug models and re-measure, since model availability shifts over time. Re-verified on June 12, 2026 (JST): the candidate set was identical (measurement: gpt-5.5 = 17180, gpt-5.4 = 15801, gpt-5.4-mini = 15446; all produced ok with no tool calls — absolute values drift a few tokens between runs as the upstream system prompt evolves, but the ranking is stable), so the ranking and the gpt-5.4-mini default are unchanged. Re-measured again on June 15, 2026 (JST): gpt-5.5 = 17429, gpt-5.4 = 16044, gpt-5.4-mini = 15692. Re-measured on June 16, 2026 (JST) with codex debug models showing the same candidate set: gpt-5.5 = 17653, gpt-5.4 = 16274, gpt-5.4-mini = 15918; all accepted runs produced ok with no tool calls. gpt-5.4-mini remains the minimum, so the default is still unchanged. Re-measured on June 18, 2026 (JST) with the same candidate set: gpt-5.5 = 30695, gpt-5.4 = 29310, gpt-5.4-mini = 28962. The absolute values rose because Codex now emits a Skill descriptions were shortened system notice that adds to the input even with --ignore-user-config (it is appended by Codex's own prompt path, not the user config). Re-measured later the same day (June 18, 2026 JST) with the same candidate set: gpt-5.5 = 17657, gpt-5.4 = 16274, gpt-5.4-mini = 15922; the notice still appears but absolute values dropped back near the June 16 baseline, showing the Skill-context budget itself fluctuates between runs as the local Skill set changes. Re-measured on June 21, 2026 (JST) with the same candidate set: gpt-5.5 = 31033, gpt-5.4 = 29648, gpt-5.4-mini = 29296; all accepted runs produced ok (no tool calls, only the Skill descriptions were shortened system notice). Re-measured on June 23, 2026 (JST): gpt-5.5 = 18445, gpt-5.4 = 17060, gpt-5.4-mini = 16708; all accepted runs produced ok with no tool calls. gpt-5.4-mini remains the minimum, so the default is unchanged. Re-measured on June 24, 2026 (JST) with the same candidate set: gpt-5.5 = 31887, gpt-5.4 = 30502, gpt-5.4-mini = 30150; all accepted runs produced ok with no tool calls (only the Skill descriptions were shortened system notice). Re-measured on June 25, 2026 (JST) with the same candidate set: gpt-5.5 = 26808, gpt-5.4 = 25423, gpt-5.4-mini = 25071; all accepted runs produced ok with no tool calls. gpt-5.4-mini is still the minimum, so the default remains unchanged. Re-measured on June 26, 2026 (JST) with the same candidate set: gpt-5.5 = 17651, gpt-5.4 = 16266, gpt-5.4-mini = 15912; all accepted runs produced ok with no tool calls (only the Skill descriptions were shortened system notice). gpt-5.4-mini is still the minimum, so the default remains unchanged. Re-measured on June 29, 2026 (JST) with the same candidate set: gpt-5.5 = 17593, gpt-5.4 = 16206, gpt-5.4-mini = 15856; all accepted runs produced ok with no tool calls. gpt-5.4-mini is still the minimum, so the default remains unchanged.

Default Antigravity (agy) model note:

  • The default Antigravity model is GPT-OSS 120B (Medium) (default_antigravity_model() in config.rs). As of agy 1.1.10 this is an empirical choice, no longer a pricing heuristic. Print mode gained --output-format (text / json / stream-json), and the json form returns a usage object (input_tokens, output_tokens, thinking_tokens, cache_read_tokens, total_tokens) per request, so the Codex-style input_tokens comparison that earlier revisions of this note called impossible is now available. Measured on August 4, 2026 (JST) with agy 1.1.10, the fixed prompt Reply ok. in an empty scratch directory, agy --output-format json --model <slug> -p "Reply ok.", reading usage.input_tokens: gpt-oss-120b-medium = 13680, gemini-3.5-flash-medium = 16994, gemini-3.5-flash-low = 16998, gemini-3.1-pro-low = 17684, gemini-3.6-flash-low = 18175, gemini-3.6-flash-medium = 18176, claude-sonnet-4-6 = 19346. Every run returned status: SUCCESS with num_turns: 1; gpt-oss-120b-medium also spent thinking_tokens: 0. It is the minimum by a wide margin (~19% below the next candidate), which agrees with the pricing heuristic that previously justified it, so the default is unchanged — now for a measured reason. Scope of the measurement: the -high effort variants and claude-opus-4-6-thinking were not measured, because effort tiers share a base model and only add output/thinking tokens, and the Opus tier is the most expensive on offer; re-measure them only if a default change is actually being considered. As with Codex, this measures minimal response cost and does not speak to real-workload (commit/review/refactor) quality. The agy models candidate set on this date was gemini-3.6-flash-{high,medium,low}, gemini-3.5-flash-{high,medium,low}, gemini-3.1-pro-{high,low}, claude-sonnet-4-6, claude-opus-4-6-thinking, gpt-oss-120b-medium — the Gemini 3.6 Flash tier is new since June 29, 2026. Historical basis, kept because it is what the value rested on before the measurement existed: re-verified on June 26, 2026 (JST) with agy 1.0.12: agy models listed Gemini 3.5 Flash (Medium/High/Low), Gemini 3.1 Pro (Low/High), Claude Sonnet 4.6 (Thinking), Claude Opus 4.6 (Thinking), and GPT-OSS 120B (Medium) — the same candidate set as on June 25, 2026. Google Cloud Agent Platform pricing lists gpt-oss-120b at $0.09 / 1M input tokens, lower than the listed Gemini and Claude alternatives, so GPT-OSS 120B (Medium) remains the lowest input-price default among the CLI-provided models. The value was the display name agy models printed at the time, passed verbatim to agy --model "<name>"; an empty string omits --model and defers to agy's own default. (Since 1.1.10 agy models prints slugs instead, and both spellings are accepted — see the antigravity provider bullet above.) That was a cost-based heuristic, not a quality measurement. Re-verified on June 29, 2026 (JST) with agy 1.0.13: agy models listed the same candidate set as on June 26, 2026, so GPT-OSS 120B (Medium) remained the lowest input-price default and was unchanged. Any future change should now re-run the --output-format json measurement above rather than reasoning from published prices, since model availability shifts over time and the token overhead is directly observable.

[models] field note:

  • The canonical model key for the Antigravity provider is antigravity (the former gemini field was removed from ModelsConfig). A legacy [models] gemini = "..." value is still accepted as an input-only alias by PartialModelsConfig and is promoted to antigravity on load; if both antigravity and gemini are present, the explicit antigravity value wins. Running with --debug prints a one-time notice (AiService::set_debug) when a legacy gemini provider alias remains in the providers list, reminding the user it is normalized to antigravity. The --debug config dump in App::print_config_debug shows models.antigravity (rendering an empty value as (agy default)).

Configuration Files

File Scope
~/.config/git-sc/config.toml Global user settings
.git-sc Project-level overrides (repo root)
.git-sc-ignore Patterns to exclude from diff

Config merge note:

  • Settings are loaded via PartialConfig (all Option<T> fields) to distinguish "unset" from "explicitly set to default value".
  • Project config correctly overrides global config even when the project value equals the default (e.g., language = "Japanese" overriding language = "English").
  • PartialConfig::merge_into() only overwrites fields that are explicitly present in the project config file.
  • That contract holds per field, not per table, and [ai_usage] used to break it. PartialConfig.ai_usage was an Option<AiUsageConfig> — the whole struct — so a project .git-sc writing only [ai_usage] threshold_percent = 50 still parsed into a complete AiUsageConfig with every unwritten field filled from its #[serde(default)], and merge_into assigned that over the global one. enabled defaults to false, so adjusting a threshold in one repository silently switched the residual-quota gate off for that repository, with no warning and nothing in the config file to suggest it. [ai_usage] now goes through PartialAiUsageConfig (every field Option, same shape as PartialModelsConfig) and apply_to() layers only the written fields onto the existing value — the global config when merging a project file, AiUsageConfig::default() when a global-only file is converted by into_config. Regression tests: test_partial_merge_into_ai_usage_keeps_unspecified_global_fields / ..._overrides_specified_fields / ..._without_global_uses_defaults_as_base / test_into_config_ai_usage_fills_unspecified_fields_with_defaults. The lesson generalizes: any future [table] added to PartialConfig needs its own Partial* type, because a bare struct silently converts "unwritten" into "explicitly default".

.git-sc-ignore note:

  • Loading is fail-closed. load_ignore_patterns() returns Result<Option<Gitignore>, AppError>, and Ok(None) means only "no .git-sc-ignore exists". If the file does exist but cannot be read or parsed (GitignoreBuilder::add returns an error — including a per-pattern partial error — or build() fails), it returns AppError::ConfigError and apply_all_filters propagates it, so every diff-producing API (get_staged_diff / get_diff_from_base / get_commit_diff_by_hash) fails instead of returning a diff. Previously those errors collapsed into None, which is indistinguishable from "no ignore file": the exclusions silently stopped applying and the very files the user wanted withheld (credentials, keys, generated blobs) were sent to the AI provider with no warning. Failing the run is the cheap direction — the user fixes the file and re-runs — whereas fail-open leaks quietly and irreversibly. Regression test: test_apply_all_filters_fails_closed_when_ignore_file_is_unreadable, which makes .git-sc-ignore a directory so the read failure is deterministic (mode 000 is not, since it is still readable when running as root).
  • The diff is requested in a fixed format so the user's Git config cannot switch the exclusions off. Every diff-producing call passes DIFF_FORMAT_ARGS (--no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/). Exclusion works by reading the path out of the diff --git a/… b/… header, and filter_ignored_files treats an unparseable header as "nothing to exclude" (unwrap_or(false)) — so anything that changes the shape of that line turns the whole ignore list into a no-op, silently. Several settings do exactly that: diff.noprefix = true emits diff --git path path, diff.mnemonicPrefix = true emits diff --git c/path i/path, diff.srcPrefix / diff.dstPrefix substitute arbitrary strings, color.ui = always prefixes the line with an ANSI escape so it no longer starts with diff --git, and diff.external replaces the output wholesale (the diff --git line disappears entirely). None of these is exotic — diff.noprefix is a common "copy-pasteable diff" preference — and the failure is invisible: the run succeeds and the excluded file's contents go to the AI provider anyway.
  • diff.relative = true is the one that breaks two things at once, because it changes the base of the paths rather than their decoration. GitService::repo_path is the current directory, not the Git root, so running git-sc from a subdirectory under this setting (a) prints paths relative to the cwd, which no longer match a root-anchored .git-sc-ignore (a rule of src/secrets/** is tested against secrets/key.txt and misses), and (b) omits every change outside the cwd from the diff, so the message is written from a partial view of what is about to be committed. --no-relative pins the base back to the Git root. This one was missed in the first pass and found in review — see the note on completeness below. Verified by hand on September 4, 2026 (JST) that each setting changes the output as described and that DIFF_FORMAT_ARGS restores the expected form under all of them, for both git diff and git show. Regression tests: test_ignore_patterns_apply_regardless_of_diff_format_config (runs the real binary under each of the five formatting settings) and test_ignore_and_full_diff_survive_diff_relative_from_subdirectory (runs it from a subdirectory under diff.relative, asserting both that the secret stays out and that the out-of-cwd change stays in). Both were confirmed to fail with the corresponding argument removed. Leaving unwrap_or(false) alone is deliberate. Flipping it to true would make one unparseable header drop every file in the diff, and an empty diff aborts the run — so with the format pinned, the remaining way to reach that branch is a bug in the header parser itself (an unusual filename it mishandles), where refusing to commit anything at all is the more disruptive outcome. The exclusions themselves are protected by pinning the input format, not by guessing at the output of a failed parse. The corollary is that this list is the whole defence, so it has to stay complete. --no-relative was missing from the first version of DIFF_FORMAT_ARGS and only surfaced in external review — "some other setting reshapes the diff" is not a hypothetical. When Git adds a diff-formatting option, check it against this list; and note that --no-textconv is deliberately not here, because textconv changes a file's rendered content but never the shape of the diff --git line, so it cannot affect which files are excluded.
  • Merge commits produce a different header, and the exclusions used to miss all of them. git show on a merge emits a combined diff whose block header is diff --cc <path> — one path, no a//b/ prefixes — not diff --git a/… b/…. filter_ignored_files and filter_binary_diff both located blocks with starts_with("diff --git"), so on a merge commit not a single block was recognized, every line fell through to the pass-through branch, and the ignore list was a complete no-op. This is the same "you wrote an exclusion, it silently did nothing" failure the fail-closed load_ignore_patterns exists to prevent, reached by a different door: git-sc --generate-for <merge hash> and --amend when HEAD is a merge. Verified with the real binary on September 11, 2026 (JST): with secrets/** in .git-sc-ignore, the resolved conflict's contents appeared in the prompt; after the fix the block is gone and a non-excluded file in the same merge still appears. Block detection now goes through is_diff_block_start() (diff --git / diff --cc / diff --combined ) and extract_file_paths_from_diff_header() resolves a combined header via extract_combined_diff_path(), returning the single path as both sides. Combined headers quote the path only when it needs escaping — measured, diff --cc sp ace.txt is unquoted while diff --cc "nonascii-\303\251.txt" is quoted — so the unquoted branch takes the rest of the line verbatim rather than splitting on whitespace. Regression tests: test_filter_ignored_files_excludes_combined_diff_block, test_extract_combined_diff_path_handles_spaces_and_quoting, test_ignore_patterns_apply_to_merge_commit_combined_diff (end-to-end through the binary).
  • has_staged_changes() passes --no-relative for the same reason the diff calls do. It is not a diff-producing API, so it was left out of DIFF_FORMAT_ARGS — but it runs git diff --cached --quiet from repo_path (the cwd, not the Git root), and under diff.relative = true that only sees the cwd subtree. Two consequences, both reproduced with the real binary on September 11, 2026 (JST) from a subdirectory: (a) a normal commit generated a message from the full diff and then skipped committing with "ステージ済みの変更がありません", and (b) --squash sailed past its "refuse to start with staged changes" guard and folded an unrelated staged file into the squash commit — the exact outcome that guard exists to prevent, and the double-check does not help because both calls share the same blind function. Regression test: test_squash_guard_sees_staged_changes_outside_cwd_under_diff_relative. Note --no-ext-diff is deliberately not added here: --quiet answers from the index comparison and never launches an external diff program, verified by setting diff.external and observing the exit status is unchanged.
  • Patterns are matched against decoded Git paths, including quoted diff headers with non-ASCII filenames.
  • For rename diffs, ignore matching checks both the pre-rename and post-rename path so moves into ignored directories are excluded consistently.
  • Patterns apply to both text and binary files. Ignore filtering runs before binary-to-summary conversion so that binary files matching ignore patterns are fully excluded from the diff.
  • decode_quoted_diff_path validates that 3-digit octal escape values are within the u8 range (0-377). Values exceeding 255 (e.g., \400) are rejected as invalid input.
  • Paths containing spaces are supported. Git does not quote space-only filenames in diff --git headers, so path extraction uses a midpoint split for symmetric a/PATH b/PATH headers to avoid misparsing (e.g., diff --git a/foo bar.txt b/foo bar.txtfoo bar.txt, not foo and bar.txt). Asymmetric unquoted rename headers split at the last b/, so diff --git a/old file.txt b/generated/new file.txt checks both paths. Mixed rename headers are handled in both directions: quoted→unquoted consumes the unquoted side to the end of line, and unquoted(with spaces)→quoted (e.g. diff --git a/old name.txt "b/new\303\251.txt" — Git quotes each side independently, and a space alone does not trigger quoting) splits at the first ", which is unambiguous because an unquoted side can never contain ".

Diff truncation note:

  • truncate_diff() first compares diff.len() (byte length) against MAX_DIFF_CHARS. Since UTF-8 always uses at least one byte per character, byte length within the limit guarantees character count is also within the limit, so the common ASCII path returns immediately without scanning the entire diff.
  • When byte length exceeds the limit, char_indices().nth(MAX_DIFF_CHARS) finds the cutoff after scanning at most MAX_DIFF_CHARS+1 characters, avoiding the previous full-diff chars().count() walk on multi-MB inputs.

Testing

cargo test                    # Run all tests
cargo test test_name          # Run specific test
cargo test -- --nocapture     # Show println! output
  • Unit tests: #[cfg(test)] modules in each source file
  • Integration tests: tests/cli_integration.rs (CLI behavior via assert_cmd)
  • Git tests must not assume the initial branch is master; use git branch --show-current in temporary repositories when switching back to the primary branch.

Dependencies

Runtime

  • clap: CLI argument parsing with derive macros
  • anyhow/thiserror: Error handling
  • serde/toml: Configuration parsing
  • colored: Terminal output styling
  • regex: Commit format detection
  • ignore: Gitignore-style pattern matching
  • dirs: Platform-specific directory paths
  • chrono (default-features = false, features clock/std): local-time timestamps and the date directory for the developer generation log. Added for that feature on September 2, 2026 (JST). The standard library cannot get a local UTC offset, and computing one by hand is exactly the class of bug recorded in github-guide (a UTC-vs-JST slip puts a 09:00 JST run in the previous day's directory), so this is a deliberate dependency rather than hand-rolled date math.
  • fm-rs (optional, macOS, pinned to =0.3.0): Apple Intelligence Foundation Models FFI. Updated from =0.2.1 on September 2, 2026 (JST) and verified on September 4, 2026 (JST): the public API only grew — Session::cancellation_handle() and a new CancellationHandle (cancel / is_responding) — while every item ai/apple.rs calls is unchanged, so the provider needed no edits. cargo clippy --features apple-ai --all-targets -- -D warnings passes, the full suite passes, and the --ignored Apple Intelligence live tests (test_apple_intelligence_feat, real on-device generation) pass. History: =0.2.1 replaced the long-standing =0.1.4 pin on August 28, 2026 (JST) — the first release since 0.1.4 that built here. Background: 0.1.5 was rechecked on June 3, June 12, June 16, June 24, June 25, June 26, and June 29, 2026 (JST) and failed against the macOS 26.5 SDK every time, because its Swift token-usage shim did not compile (AsyncWaiter is private and SystemLanguageModel has no tokenUsage member in src/swift/token_usage_api.swift), so depup --include-pinned was reverted each time. 0.2.1 ships src/swift/token_usage_fallback.swift (plus *_fallback.swift counterparts for the reasoning / private-cloud-compute / 27-only session APIs) and selects between the _api and _fallback variants from the build SDK, which is what removes that failure. That release was checked the same way on August 28, 2026 (JST). The part of the crate's API this project depends on (SystemLanguageModel::new / ensure_available / Session::with_instructions / GenerationOptions::builder().temperature() / session.respond / response.content()) has been unchanged since 0.1.4, which is why neither bump required a change to ai/apple.rs. Keep the exact-version pin: 0.x minor bumps are breaking by semver, and this crate's build is SDK-sensitive, so any future bump must re-pass cargo clippy --features apple-ai -- -D warnings and the live tests before it is accepted. The pin did not change when this machine moved to macOS 27 / Xcode 27 (September 17, 2026 JST), but what the same crate version compiles did. build.rs asks xcrun --show-sdk-version and, at 27.0 or newer, compiles session_27_api.swift / reasoning_api.swift / generation_options_api.swift instead of their *_fallback / *_legacy counterparts — so upgrading the SDK silently enables typed LanguageModelError classification, LanguageModelSession.Usage, and GenerationOptions.toolCallingMode with no dependency change at all. That is what ai/apple.rs now builds on: context_size(), token_usage_for(), respond_with_timeout(), response.usage(), max_response_tokens(), tool_calling_mode(). The API surface used before (SystemLanguageModel::new / ensure_available / Session::with_instructions / respond) is unchanged and still present, so the code keeps building against a pre-27 SDK — the 27-only calls degrade to Error::UnsupportedPlatform or None rather than failing to compile, and the fallbacks in apple.rs (a 4096-token default for context_size, usage: None) cover that path.

Dev

  • rstest: Parameterized test framework
  • pretty_assertions: Readable diff output for test assertions
  • tempfile: Temporary file/directory management for tests
  • assert_cmd: CLI integration testing
  • predicates: Assertion matchers for assert_cmd