AI-powered smart commit message generator CLI tool written in Rust.
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.
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 artifactsgit-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 messagesrc/
├── 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.rskeeps theAiProvider/AiServicetypes and the provider-fallback orchestration; prompt format contracts live inprompt.rs, per-provider CLI argument knowledge inprovider_command.rs, subprocess lifecycle (the deadlock-avoidance threading inrun_process_with_timeout) and output interpretation inprocess.rs, and the fm-rs native path inapple.rs. Cross-module items usepub(super)visibility. The unit tests for all of these currently remain inai/service.rs's#[cfg(test)]module (they exercise the sameAiServiceassociated functions regardless of which file defines them); relocating them next to their units is optional follow-up work, not a behavioral concern.
| 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/agy → Antigravity); 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 whetherHEADexists before callinggit 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.gitpath. A plain.gitfile or directory is not treated as a valid repository by itself.
Operation mode safety note:
--amend,--squash,--reword, and--generate-forare mutually exclusive at CLI argument parsing time, so invalid combinations fail instead of silently choosing one workflow.--squashrejects pre-existing staged changes beforegit reset --softso unrelated staged files are not folded into the squash commit. That check runs twice: once up front, and again immediately beforegit 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 cangit 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_commitrecords the index's tree hash (GitService::write_tree()) before it reads the diff, and compares it again right beforegit commit.has_staged_changes()alone only answers "is anything staged", so a concurrentgit addduring 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 anaddlands 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_treewrites 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 aNonesimply skips the comparison — this is an extra guard, not a new precondition for committing. --squashrecords the originalHEAD(viaGitService::get_head_hash()) beforegit reset --soft. If the subsequentgit commitfails (pre-commit/commit-msg hook rejection, GPG signing failure, etc.), the branch is restored to the originalHEADwith 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.--squashresolves its user-supplied base branch throughbranch_exists()(git rev-parse --verify) andget_merge_base()(git merge-base), both of which pass the branch after--end-of-optionsso 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 — unlikegit show,rev-parse --verify/merge-baseexpose no--output-style option, and a--leading value is rejected by thebranch_existsgate before reachingget_merge_base— but the guard is kept consistent across every user-controlled ref, and as a side benefit--leading branch names now resolve correctly. Downstreamcount_commits_from_base/get_diff_from_basereceive the git-derived merge-base hash, not user input, so they are not an injection sink.--generate-forkeeps stdout reserved for the generated message only. With--debug, every debug block (config settings inApp::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_debugtakes the destination as itsto_stderrargument, and everything insideAiServicereads it from thedebug_to_stderrfield, set once inApp::newfromcli.generate_for.is_some(). The two--debug-only notices emitted fromset_debug(the legacygeminialias reminder and the ai-usage filter log) follow the same field rather than being hardcoded to stderr, which is whyApp::newcallsset_debug_to_stderrbeforeset_debug— the notices are printed inside the latter.- Index snapshots are not enough for
--squashand--amend: they also compareHEAD. What those two modes fold or rewrite comes from the history, not the index, so a concurrentgit commitin 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 --softfor squash,git commit --amendfor 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,--squashproduced a squash commit containingunrelated.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, becausegit reset --soft HEAD~1during the confirmation prompt leaves the index tree identical while widening what the commit will contain.head_snapshot()returnsNoneon a repository with no commits yet, and aNoneskips 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_linereturnsOk(0)at EOF leaving the buffer empty, which the old code could not distinguish from the user pressing Enter — sogit-sc --squash main < /dev/nullwithout--yesprinted 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 returnsAppError::InvalidArgumentnaming--yesas 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 baregit add -Acovers the whole worktree — so on Windowsgit-sc -afrom a subdirectory silently left every change outside it unstaged, and thenulexclusion (plus the pre-delete and post-unstage ofnul, both built fromrepo_path= cwd) missed anulat the repository root. The pathspecs are now:/and:(exclude,top)nul, and thenulpath is resolved fromget_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-foralone —--quietmust not touch it. These are two different axes that used to share onesilentparameter:AiServiceused it both to suppress theUsing …progress lines (the--quietjob) and to pickprintln!vseprintln!inemit_debug_line(the--generate-forjob), whilegenerate_with_prefixpassedsilent || cli.quietfor both. The result was a split-brain run: with-q -don 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 — sogit-sc -q -d > out.logcaptured some of the debug trace and dropped the rest. Reproduced and then locked down bytest_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_timeoutno longer take asilentargument at all and readself.debug_to_stderr, while the argument still threaded down fromgenerate_with_prefixkeeps its original single meaning of "suppress progress output". Passingsilentfor both would have been the tempting one-line fix and would have regressed--quietinto printing progress again.
Prefix script behavior note:
- Literal prefix script output has only trailing line endings (
\n/\r\n) removed before application, so commonechooutput does not split the commit subject while intentional trailing spaces remain intact. - If a prefix script returns empty output,
Apppreserves the generated message and removes only a leading Conventional Commits type prefix (feat:,fix(scope):,feat!:etc.) when present. - Prefix script exit code
1is 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
scriptpaths in project-level.git-scare resolved from the Git repository root, and prefix scripts run with the Git root as their working directory. - If the current
HEADis detached (get_current_branch()returnsNone), prefix scripts that already matched theirurl_patternare skipped with an explicitbranch name unavailable (detached HEAD?), skipping scriptnotice 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_scriptsand (2)prefix_rulesare evaluated only when a remote URL is available (both match theirurl_patternagainst it), then (3) the configprefix_typeand (4) automatic detection from recent commits are evaluated unconditionally. Steps 3 and 4 are remote-URL-independent, so a local-only repository withoutremote.origin.urlstill honors a configuredprefix_typeinstead of silently falling back to Auto. Matchingprefix_rulesvalidate theirprefix_typeagainst 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 thetry_prefix_scripts()/try_prefix_rules()helpers to keepget_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 withInvalidCommitHasherror.
Reword safety note:
GitServicevalidates that a--rewordtarget hash is in the currentHEADhistory 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 then == 1amend-path), not only in the calling layer, so the guarantee holds even whenreword_commit_by_hash()is invoked directly without theapp.rspre-check. - Rewording the oldest commit in the current branch is supported by switching to
git rebase -i --rootwhen needed. - The reword rebase always passes
--no-autosquashto isolate the user'srebase.autoSquash=trueconfig. 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 asquashline would additionally overwrite the folded commit's message with the reword message becauseGIT_EDITORunconditionally copies the message file. - The reword position
nis always consumed asHEAD~n(a first-parent depth), so it is counted along the first-parent path:get_commit_position_by_hash()usesgit 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, makingnexceed the real first-parent depth so thatHEAD~nresolves past the target — which surfaced a crypticfatal: 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 andHEADcleanly fails withHasMergeCommits(merge-spanning reword is unsupported), while rewording across a merge-free range still works. - Rewording
HEADusesgit commit --amend --onlyso 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_EDITORpasses the message file path viaGIT_SC_MSG_FILEenvironment 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 -ifails for any reason (CONFLICT, rejection bycommit-msg/pre-commithooks, editor errors, etc.),GitService::reword_commit()unconditionally runsgit rebase --abortbefore returning the error. This prevents the repository from being left in an "interrupted rebase" state that would block all subsequent git operations. - Because that
--abortis 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 owngit rebase -i(say, resolving a conflict), git-sc'sgit rebase -inever 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-mergedisappeared and the branch snapped back to the pre-rebaseHEAD. The check isGitService::rebase_in_progress(), which resolvesrebase-merge/rebase-applythroughgit 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 then == 1amend path too, since a rebase leavesHEADdetached at an unintended commit), and once more immediately before launchinggit rebase -i. Theby_hashone checks before computing the position, because during a rebase the detachedHEADmakes the target look absent from history and the user would get a misleadingInvalidRewordTargetinstead 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 = truemakes git prependlabel onto/ (blank) /reset ontoto the todo, so the sequence editor's1s/^pick /reword /— which only ever touches line 1 — matches nothing. The todo is still a valid all-picklist, sogit rebasecompletes with exit 0,reword_commit()returnsOk(()), 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-mergesthe same run rewrote the subject. This is the third config isolated here for the same class of reason asrebase.abbreviateCommandsandrebase.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-mergescloses 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 withpick. 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 firstpickline anywhere and rewrite that" — that would happily reword a different commit than the one requested. - Both editors are the
shform on every platform, including Windows. The previous PowerShell branches (powershell -Command "$lines = @(Get-Content $args[0]); …"andpowershell -Command "Copy-Item $env:GIT_SC_MSG_FILE $args[0]") never worked. Git runs an editor string containing shell metacharacters assh -c '<editor> "$@"' <editor> <path>— on Windows too, using the bundled sh — so the outer shell expanded$lines,$argsand$envbefore 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-Commanddoes not populate$argsfrom 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 shipssh,sedandcp, so a singleshimplementation covers both platforms; this is why--rewordon Windows was untested-and-broken rather than merely untested (CI builds Windows but runs no tests there).
Amend safety note:
GitServicereads the last-commit diff viagit show HEAD, so--amendalso works when the currentHEADis the root commit.GitService::amend_commit()usesgit commit --amend --onlyso unrelated staged changes remain staged instead of being included in the amended commit.
Notification safety note:
- On macOS the notification path calls
CFStringCreateWithCStringfor both the notification name and body. Each return value is null-checked before use, and any already-allocated CFString isCFReleased 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 installcopies the release binary to a temporary file insideINSTALL_PATH, signs that temporary inode on macOS, and only then replaces the installed command withmv. Do not change this back to a directcpover 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-pkgsasowayo.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) becausewinget-releaseronly callskomac updateand fails withPackage ... does not exist in the winget-pkgs repositoryuntil 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-wingetis a job insiderelease.yml, deliberately not a separateon: releaseworkflow. A Release created withGITHUB_TOKENdoes 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 reasonrelease-tagis passed explicitly fromprepare-release's output: the action's default isgithub.event.release.tag_name || github.ref_name, and underworkflow_dispatchthere is no release event, so it would fall back to the branch name and look for a tag that does not exist.installers-regexmust 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, thenkomac updatedies 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.gzat all — only the Windows target is zipped, which is why the regex has something to match in the first place.WINGET_TOKENmust be a classic PAT withpublic_repo. Fine-grained PATs are rejected by Komac, andGITHUB_TOKENcannot open a pull request against another repository. It also requires a fork ofwinget-pkgsunder 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 writessteps.token.outputs.availablerather than in the job'sif:— thesecretscontext does not exist in a job-levelif, 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 atreviewDecision: REVIEW_REQUIREDsince September 13, 2026 (JST) withAzure-Pipeline-PassedandValidation-Completed— the automated validation passed and there is nothing to fix on this side. Meanwhilewinget-releaserexits 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, andrelease-summarywas skipped along with it.publish-wingetnow resolvesmanifests/o/owayo/git-scthrough 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: trueis deliberately not used, because it would also turn a revoked PAT or a mistypedinstallers-regexgreen; 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 eachcurlis wrapped in|| truebecause Actions runs steps underbash -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-summaryreads the job'ssubmittedoutput (steps.submit.outcome == 'success') so it does not claim a submission that was skipped.
Provider fallback chain note:
Config.providersis aVec<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-writtenDeserialize(string-or-struct viadeserialize_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 missingprovider). The same provider may appear multiple times with different models or accounts — e.g.codexon twoCODEX_HOMEs, orantigravityon Gemini- vs GPT-OSS-family models, which have separate quotas.AiServiceholds the chain assteps: Vec<ProviderStep>.from_configkeeps each raw provider string (alias canonicalization happens only at cooldown-key/comparison time) and drops steps whose provider does not resolve viaAiProvider::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 omitmodel. - 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/-ofor codex,-pfor claude, etc.) are still applied on top, because a wrapper ultimately invokes the same underlying CLI.command[0]'s~is expanded atConfig::loadtime. - Account switching via
env(the load-bearing safety property): each step'senv(BTreeMap<String,String>) is applied with an explicitcmd.env(k, v)inbuild_provider_command, andenv_clear()is not called (PATH/HOME must stay inherited). An explicitCommand::env()override beats whateverCODEX_HOME/CLAUDE_CONFIG_DIRis 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 hardConfigError(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 aConfigError. These keys can redirect a child process's shared-library/interpreter pre-load path, so a malicious project-level.git-sccould otherwise inject arbitrary code into the codex/claude/agy subprocess (the legitimate account-switching use case is unaffected because it relies onCODEX_HOME/CLAUDE_CONFIG_DIRetc., not loader keys).--debugprints each step's explicit env overrides and itscooldown_key.
Each provider is called via CLI subprocess:
- opencode: Uses temp file with
-fflag to avoid command line length limits - grok (Grok Build TUI, X.AI): Uses temp file with
--prompt-fileto 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 --verbatimto every invocation so the TUI agent behaves as a single-turn pure function:--sandbox read-onlyforbids fs writes and network like Codex's sandbox does,--no-plan/--no-memoryblock plan mode and cross-session memory (both are on by default),--disable-web-searchcuts web fetch,--max-turns 1stops any tool loop after one turn, and--verbatimprevents the CLI from rewriting the prompt. Model resolution follows the same rule as other providers (step.model>[models] grok> empty = defer togrokdefault); when non-empty the ID fromgrok models(currently onlygrok-4.5) is passed as-m "<id>". Default[models] grokis 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); ifgrokis not onPATH, this step is skipped and the chain moves on to antigravity. - antigravity (
agy, the successor of the Gemini CLI as of 2026-05): Uses-pflag for prompt input. As ofagyv1.0.x the CLI supports--model(changelog: "Added --model to set model when launching CLI"), so git-sc passes the[models] antigravityvalue straight through asagy --model "<name>"when it is non-empty; an empty value omits--modeland 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 twoagy modelsprints is not stable across agy releases — 1.0.x printed display names, 1.1.10 prints slugs, while theAvailable models:list inside agy's owninvalid model selectionerror still prints display names — so treat both as valid config values rather than assuming the currentagy modelsformat 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] antigravitysurfaces as a normal provider failure and the fallback chain moves to the next step. The CLI still exposes no--debugflag, so debug-related options remain intentionally omitted from the command line. Before launching,AiService::check_arg_size_limitrejects prompts larger than 512 KiB with an explicit error to avoid hitting OS-levelARG_MAX. The legacygeminiprovider name remains accepted as an alias both infrom_strand in the state-file cooldown key (auto-migrated toantigravityin memory on load). Windows is unsupported for this provider: all providers are launched throughcmd /Cthere (npm.cmdshims), 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_commandreturns 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-messageto 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'sformat_section: explicitprefix_typevalues get a forceful (CRITICAL FORMAT RULE) rule for that exact style (none/plainforbids prefixes instead of forcingfeat: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 offeat:) — a model-capability limit, also tolerated by the live tests (assert_apple_intelligence_resultonly 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, andCLAW_HOOKS_AGENT_MESSAGEhas 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, sofit_prompt_to_context()comparesinstructions + promptagainstcontext_size − max_response_tokens − 128and, 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 everydiff --git/diff --cc/diff --combinedheader — 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 sharedMAX_DIFF_CHARSto suit Apple would degrade them. When a run does compact, git-sc prints a warning (suppressed by--quietand--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 byprovider_timeout_seconds. It now callsrespond_with_timeout()with that same value. Note the two disagree on zero — the subprocess wait loop treats0as "already expired" while fm-rs documentsDuration::ZEROas "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
classifyLanguageModelErrormap 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 intoAppError::AiProviderInputError(the prompt is why it failed — context size, guardrail, refusal, unsupported language) andAppError::AiProviderError(the provider is why it failed — assets missing, rate limited, timed out), andAiService::should_record_failure()skipsrecord_provider_failurefor 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 observingresponse.usage()returnSome(SessionUsage { … }), which only the 27 implementation fills. AppError::AiProviderInputErrorcarries#[cfg_attr(not(all(target_os = "macos", feature = "apple-ai")), allow(dead_code))], and removing it breaks CI.ai/apple.rsis the only place that constructs the variant and it is gated onall(target_os = "macos", feature = "apple-ai")(ai/mod.rs), whileshould_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, becausemake checkon macOS enablesapple-ai, whereas CI runs the barecargo clippy -- -D warningson Linux: it failed witherror: variant AiProviderInputError is never constructedon the very next push after the variant was added. Reproduce the CI view locally withcargo clippy -- -D warnings(no features) rather thanmake 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 carriedCorrect: "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 4 —fix: docs: README ファイルを更新,feat: docs: AGENTS.md の更新— whichis_concatenated_subjectthen rejected, so the run failed anyway after ~25 s. Removing theCorrect:/WRONG:examples brought it to 2/4; also rewriting the type list as- docs = documentation only changes(viaapple_conventional_type_list(), which re-derives it from the sharedCONVENTIONAL_COMMITS_GUIDEso 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_formandtest_apple_instructions_follow_prefix_typepin it. is_step_installed()still answers compile-time only, on purpose. It reports whether theapple-aifeature is in the build, not whether this Mac can actually run the model; making it callensure_available()would put model initialization on the path of every run, including ones that finish on the first CLI provider. Insteadverify_installation()checks the runtime only when Apple is the sole remaining candidate (check_apple_runtime()), and the real call path re-checksensure_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 returnUnsupported capability— so requesting it would break a working provider. Private Cloud Compute is worse than unavailable: it reportsavailability: Availableandcontext_size: 32768(8× the on-device window, which would solve the context problem outright), but everyrespondfails in ~0.03 s withLanguageModelError error -1because it needs Apple's managedcom.apple.developer.private-cloud-computeentitlement, which a self-signed CLI cannot obtain. Do not treat itsavailabilityas 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) carriescontext_tokens/prompt_tokens/response_limit_tokens/input_tokens/output_tokens/compactedfor the native path, so the frequency and cost of compaction can be checked against real runs instead of re-deriving it fromgit logthe way every earlier prompt change here had to. Subprocess providers leave itNone— CLI agents do not report their own token usage. max_response_tokensis 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::Disallowedis 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 likeci: GitHub Actions CIワークフローとmise設定をbecomes the commit message.AiService::is_truncated_subject()(ai/prompt.rs) rejects those. On detectiongenerate_commit_message_internalre-runs the same step once (truncation is probabilistic, so the retry usually succeeds) and, if the retry is also truncated, stores the error inlast_errorand falls through to the next step without callingrecord_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 articlesa/anare dropped and the English check is gated onis_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 ofAuthentication 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-rungit log --since=... --pretty=%sacross the local repos before ever widening this rule. - Measured 2026-08-27 (JST),
agy1.1.21 +gpt-oss-120b-medium, one fixed prompt (a 3-file mise/CI diff, ~12.3k input tokens), readingresponsefromagy --output-format json: the current prompt truncated 8/25 (32%), and every single failure ended on the particleを. Those runs reportusage.output_tokensof 129–214 while the returned text accounts for well under 30 tokens, and--output-format stream-jsonshows the finaltext_deltaarriving withstate: DONEalready set — the tokens are generated but never handed back, so the loss is upstream of git-sc, not inrun_process_with_timeout's pipe reader. Four prompt variants were then measured against that 32% baseline, 10–45 runs each: removing theDo NOT end with a periodrule = 3/10 (30%) and removingKeep 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 thee.g.clause truncated 0/25 and returned no empty responses. The example-free form is whatbuild_promptnow emits (it replaced- Output ONLY the commit message as plain text). Thee.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 fromclean_message) unwraps<commit>…</commit>, tolerates either side being missing (a response truncated before</commit>still yields its body, whichis_truncated_subjectthen 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-intelligenceemits no tags and its output is unchanged from the pre-change baseline (ci.yml 追加etc.), whilecodex(gpt-5.4-mini) andclaude(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, becausestrip_commit_tags()only knows the literal<commit>.AiService::split_full_tag_envelope()(ai/prompt.rs) now strips any symmetric wrapper, andclean_message_detailed()carries the tag name up alongside the message asCleanedResponse.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 intotest: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>staysfix: …); and the caller expects Conventional Commits. That last condition isexpects_conventionalingenerate_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 aprefix_typeofplain/none/bracket/emojifrom being overwritten by the model's habit, and it is why the restoration lives in the AI layer (which already receivesprefix_typeandrecent_commits) rather than inclean_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 joinsis_truncated_subject/is_concatenated_subjectas a thirddefectin 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 (featfixdocsstylerefactorperftestbuildcichorerevert), counting only whitespace-delimited tokens that end in:(sotype:,type(scope):,type!:count, while a mid-sentencehttp:or観点 B:does not). Counting anyword: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,
agy1.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 lineon top gave 24/25 (0 truncated, 0 concatenated, 1 empty). Both prompt lines are therefore load-bearing and were kept together; thewith_bodybranch 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_detailedremoves surrounding quotes, andtrim_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 shapegit revertitself writes) becamerevert: "feat: 認証追加, andchore: rename "foo" to "bar"lost its final quote. Nothing downstream catches it:is_truncated_subjectnormalizes the last token's punctuation away before matching,has_leftover_markuponly looks at angle brackets, and one type prefix passesis_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'serrorfield. For Codex that stderr contains the prompt. Codex echoesReading 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 containingerror, lowercase included") scanned from the top, so any diff line holding the substringerror—let mut read_error: …, anything namingstd::io::Error, a renamederror.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 withReading 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
errorsubstring 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_errortoo — it used to paste the whole stderr.process_provider_output's "provider returned an empty response (stderr: …)" branch interpolatedstderr_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'sattempts[].error, which is not covered by thecontent = "metadata"redaction —call_providerdropsstderr_excerptat that level buterroris written unfiltered, and the 256 KiBcapture()cap does not apply to it either. So the "metadata must not contain the diff" property held everywhere except this one branch. It now formatsSelf::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_erroris a selector, not a redactor, so a diff line containingerrorcan 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 --quieton Stop), calling one recurses:git-sc → claude -p → Stop hook → git-sc → commit. The inner git-sc commits immediately, so a--dry-runinvocation still produces a commit. Reproduced 2026-08-27 (JST):git-sc -n -p claudein a repo with staged changes leftsetup: mise と CI ワークフローを追加committed ~30s later (detached hook), and running plainclaude -pin 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:--baredoes disable hooks yet restricts auth toANTHROPIC_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_commandsetsGIT_SC_NESTED=1on every provider command (after the user'senv, so a config typo cannot unset it), andmain()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 installedgit-sc(the hook resolves it fromPATH) to be the built binary — atarget/debugbuild alone will not show the fix.
Temp file safety note:
TempFileandTempRewordMessageFileuse RAII (Drop) for automatic cleanup.- On Unix/macOS, temp files are created with mode
0600so 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 inwrite_allwhile the child blocks on its ownwrite. Because the timeout loop is never reached whilewrite_allis blocked, the hang is unbounded. This is reachable in practice becauseagent_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, andtry_waiterror). Afterchild.kill()andchild.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'skill()/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 byprocess_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 firstErr(invalid UTF-8 or an I/O error), so the oldmap_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-ofile — 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-threadjoin()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 akill(-pid, SIGKILL)on timeout, which needs alibcdependency and anunsafeblock, 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.tmpfirst and thenrename(2)s it onto the final path so concurrentgit-scinvocations 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 thefs::writeitself 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
AtomicU64counter, 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.NANOSfile and the slower thread'srename(2)would fail withENOENTafter the faster thread already moved it. - On Unix the temp file is opened with
OpenOptions::create_new(true).mode(0o600)rather thanfs::write, so the state file is not group/other-readable (fs::writeleaves it to the umask, typically0644). This matters becausecooldown_keyembeds each step'senvvalues verbatim, and those are whatever the user put in.git-sc— normally justCODEX_HOME-style paths, but nothing stops a credential from ending up there.rename(2)carries the mode over, so the final state file is0600too.create_newadditionally 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_failurecan lose an update. The load → mutate → save sequence has no lock, so twogit-scruns that fail different providers at the same time each start from the same snapshot and the laterrenamewins, dropping the other's cooldown entry.renameis 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_minutesis 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/agyremain tied toantigravityand legacyapple-ai/apple_intelligencekeys remain tied toapple-intelligence. - Cooldown keys are composite, not provider-name-only:
ProviderStep::cooldown_key()returns the explicitname(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.codexon account A can be in cooldown whilecodexon account B keeps working, andantigravityon the Gemini model stays usable when the GPT-OSS step is cooling down).State.failuresis aVec<ProviderFailure { key, provider, failed_at }>(was aHashMap<String, _>);State::loadmigrates an old provider-name-keyed file by mapping each legacy key through the "provider-only step"cooldown_key, so existing cooldowns keep applying andgemini/apple-ailegacy keys still merge intoantigravity/apple-intelligence. The legacy in-memorymigrate_legacy_gemini_keyis gone —canonical_provider_key(now inconfig.rs, shared byProviderStep::cooldown_keyand 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-factgit logscans that every earlier prompt fix in this file relied on.DevLog(devlog.rs) is built inApp::new, shared withAiServiceas anRc, and written exactly once fromApp::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 (finishreturns 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_APPENDon 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_recordwrites.{run_id}.tmpwithcreate_new(true)+ mode0600, thenrenames it into place (the same technique asState::save). Analysis converts them back withfind … -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 — socall_providernow drops the stderr body entirely at that level and keeps onlystderr_bytes. The one-line reason a provider failed still survives inerror(fromextract_error), which is what failure analysis actually needs. Raw stdout is kept at both levels on purpose:<test>fix: x</test>andfix: xclean 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:
envoverrides are logged by key name only (values areCODEX_HOME-style paths but nothing stops a credential from being there), andprovider_planusesstep_plan_label— provider + configured model, or an explicitname— rather thancooldown_key, which embeds env values. Files land mode0600inside0700directories. - 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 fitsmax_total_mb(default 500). A.cleanup-stampthrottles the whole scan to once per 24 h so a per-commit process is not walking the tree every run;.tmpfiles 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, plustest_cleanup_is_skipped_while_stamp_is_freshfor 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_intodrops a project[dev_log]with a warning, so a cloned repository's.git-sccannot switch logging on or choosedirand thereby have your source code written somewhere of its choosing. Note this is narrower than the existing.git-scexecution surface (providers[].command/prefix_scripts[].script/ai_usage.commandare 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 bytest_partial_merge_into_project_dev_log_cannot_enable_logging(a project[dev_log] enabled = trueleaves the config with logging off) andtest_partial_merge_into_project_dev_log_does_not_override_global(a project table cannot redirectdiror raisecontentwhen a global one exists). - The default log directory comes from
Config::config_dir()(~/.config/git-sc), notdirs::config_dir(). On macOS the latter is~/Library/Application Support, which would put logs somewhere other than the config they are configured by. started_atis read once, at construction — reading the clock twice made it the finish time.started_at_unix_mswas taken infrom_configwhile the human-readablestarted_atcalledLocal::now()again insidefinish(), which runs after generation and the confirmation prompt. The field therefore sat exactlyduration_msafter 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_msheld in every one, with runs of 12–18 s.DevLognow holdsstarted_at_local: DateTime<Local>taken once, andstarted_at_unix_msis derived from it viatimestamp_millis(), so the two cannot drift. The date directory inwrite_recorduses the same value:run_idis 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 thefinish()-side clock read is restored.
ai-usage integration note:
[ai_usage] enabled = trueopts in to a residual-quota gate for the fallback chain. On construction (AiService::from_config),ai_usage::fetch_snapshot()runsai-usage --json(default; overridable via[ai_usage] command) once with atimeout_seconds(default 10) and parses the JSON into anAiUsageSnapshot. EachProviderStepis then evaluated viaAiUsageSnapshot::evaluate()againstthreshold_percent(default 95) using the selectedwindow(weekly/five_hour/nearest;nearestpicks 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 cooldownState); the cooldown machinery is unchanged.ProviderStep::ai_usage_profile(optional) matches the ai-usage JSONprofilestring 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 amongok=trueaccounts 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'senv(CODEX_HOME/CLAUDE_CONFIG_DIR…), falling back to whatever the parent shell exports. So auto-select withoutenvis 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 explicitai_usage_profileand the matchingenv.ai_usage_profileis deliberately excluded fromProviderStep::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 JSONgroup_labelcase-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 singleprofile = "Antigravity", ai-usage returns two rows,group_label = "Gemini"andgroup_label = "Claude&GPT", with separate weekly windows — measured 2026-08-19, the Gemini pool read 100% used (agy returningRESOURCE_EXHAUSTED (429): Individual quota reached) at the same moment the Claude&GPT pool read 1.17% andgpt-oss-120b-mediumanswered 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 thanai_usage_profile(an exact, case-sensitive Chrome profile name) becausegroup_labelis 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 toNoAccount(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,evaluatepicks the row with the lowest used_percent amongok=truerows 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). Likeai_usage_profile,ai_usage_groupis excluded fromcooldown_key(). The--debugstep label printsprovider(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 OAuthcloudcode-pa …:retrieveUserQuotapath, 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 readremainingFraction: 1(0% used) while agy was hard-refusing withIndividual quota reached. So for antigravity the gate is often a no-op and the cooldown is what bounds wasted calls; aprovider_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-usagebinary must never block a commit. Same for accounts whose lookup yieldsNoAccount(missing profile,ok=false, or provider not signed in):UsageDecision::is_usable()returns true for bothUsableandNoAccount, and onlyOverThresholdfilters. But if the snapshot was fetched successfully, the input chain was non-empty, and every step was filtered out asOverThreshold,apply_ai_usage_filtersetsgate_blocked = true;AiService::from_configthen keeps the empty chain (does not rescue withdefault_steps()), andverify_installationreturnsAppError::AiUsageErrorwith 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 todefault_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 atConfig::loadtime, in the samefinalize_steps()pass that expandsproviders[].command[0]. It was missed there originally, and the asymmetry was invisible in use: acommand = ["~/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::AiUsageErrorhas two surfaces: (1)ai_usage::fetch_snapshotreturns it on fetch failure, and the outer flow catches it and turns it into akeep chain unchangeddebug note — it is not shown to end users unless--debugis on; (2)AiService::verify_installationreturns it fatally whengate_blockedis set, which is always surfaced to the user (no downstream fallback exists in that path). Debug notes captured byapply_ai_usage_filterare surfaced whenAiService::set_debug(true)is called (same lifecycle as the legacy alias notice — printed once viaeprintln!and cleared).
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()inconfig.rs). Changed fromgpt-5.4-minion 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 whenevercodex debug modelschanges. The replacement was chosen with the same procedure as before. Candidates fromcodex debug models(visibility: "list",supported_in_api: true, all of which now supportmedium):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 promptReply 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"), readinginput_tokensfrom the--jsonturn.completedevent: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 requiredokwith 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-lunais 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 supportmediumreasoning —gpt-5.5,gpt-5.4, andgpt-5.4-mini. Each was measured with the fixed promptReply ok.in an empty directory (-C <tmp> --skip-git-repo-check --ignore-user-config --ignore-rules --ephemeral --sandbox read-only), readinginput_tokensfrom the--jsonturn.completedevent:gpt-5.5= 17152,gpt-5.4= 15770,gpt-5.4-mini= 15421. Thegpt-5.4-minirun produced the required final outputokand 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-runcodex debug modelsand 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 producedokwith 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 thegpt-5.4-minidefault 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) withcodex debug modelsshowing the same candidate set:gpt-5.5= 17653,gpt-5.4= 16274,gpt-5.4-mini= 15918; all accepted runs producedokwith no tool calls.gpt-5.4-miniremains 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 aSkill descriptions were shortenedsystem 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 producedok(no tool calls, only theSkill descriptions were shortenedsystem notice). Re-measured on June 23, 2026 (JST):gpt-5.5= 18445,gpt-5.4= 17060,gpt-5.4-mini= 16708; all accepted runs producedokwith no tool calls.gpt-5.4-miniremains 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 producedokwith no tool calls (only theSkill descriptions were shortenedsystem 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 producedokwith no tool calls.gpt-5.4-miniis 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 producedokwith no tool calls (only theSkill descriptions were shortenedsystem notice).gpt-5.4-miniis 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 producedokwith no tool calls.gpt-5.4-miniis 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()inconfig.rs). As ofagy1.1.10 this is an empirical choice, no longer a pricing heuristic. Print mode gained--output-format(text/json/stream-json), and thejsonform returns ausageobject (input_tokens,output_tokens,thinking_tokens,cache_read_tokens,total_tokens) per request, so the Codex-styleinput_tokenscomparison that earlier revisions of this note called impossible is now available. Measured on August 4, 2026 (JST) withagy1.1.10, the fixed promptReply ok.in an empty scratch directory,agy --output-format json --model <slug> -p "Reply ok.", readingusage.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 returnedstatus: SUCCESSwithnum_turns: 1;gpt-oss-120b-mediumalso spentthinking_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-higheffort variants andclaude-opus-4-6-thinkingwere 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. Theagy modelscandidate set on this date wasgemini-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) withagy1.0.12:agy modelslistedGemini 3.5 Flash (Medium/High/Low),Gemini 3.1 Pro (Low/High),Claude Sonnet 4.6 (Thinking),Claude Opus 4.6 (Thinking), andGPT-OSS 120B (Medium)— the same candidate set as on June 25, 2026. Google Cloud Agent Platform pricing listsgpt-oss-120bat $0.09 / 1M input tokens, lower than the listed Gemini and Claude alternatives, soGPT-OSS 120B (Medium)remains the lowest input-price default among the CLI-provided models. The value was the display nameagy modelsprinted at the time, passed verbatim toagy --model "<name>"; an empty string omits--modeland defers to agy's own default. (Since 1.1.10agy modelsprints 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) withagy1.0.13:agy modelslisted the same candidate set as on June 26, 2026, soGPT-OSS 120B (Medium)remained the lowest input-price default and was unchanged. Any future change should now re-run the--output-format jsonmeasurement 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 formergeminifield was removed fromModelsConfig). A legacy[models] gemini = "..."value is still accepted as an input-only alias byPartialModelsConfigand is promoted toantigravityon load; if bothantigravityandgeminiare present, the explicitantigravityvalue wins. Running with--debugprints a one-time notice (AiService::set_debug) when a legacygeminiprovider alias remains in theproviderslist, reminding the user it is normalized toantigravity. The--debugconfig dump inApp::print_config_debugshowsmodels.antigravity(rendering an empty value as(agy default)).
| 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(allOption<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"overridinglanguage = "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_usagewas anOption<AiUsageConfig>— the whole struct — so a project.git-scwriting only[ai_usage] threshold_percent = 50still parsed into a completeAiUsageConfigwith every unwritten field filled from its#[serde(default)], andmerge_intoassigned that over the global one.enableddefaults tofalse, 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 throughPartialAiUsageConfig(every fieldOption, same shape asPartialModelsConfig) andapply_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 byinto_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 toPartialConfigneeds its ownPartial*type, because a bare struct silently converts "unwritten" into "explicitly default".
.git-sc-ignore note:
- Loading is fail-closed.
load_ignore_patterns()returnsResult<Option<Gitignore>, AppError>, andOk(None)means only "no.git-sc-ignoreexists". If the file does exist but cannot be read or parsed (GitignoreBuilder::addreturns an error — including a per-pattern partial error — orbuild()fails), it returnsAppError::ConfigErrorandapply_all_filterspropagates 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 intoNone, 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-ignorea directory so the read failure is deterministic (mode000is 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 thediff --git a/… b/…header, andfilter_ignored_filestreats 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 = trueemitsdiff --git path path,diff.mnemonicPrefix = trueemitsdiff --git c/path i/path,diff.srcPrefix/diff.dstPrefixsubstitute arbitrary strings,color.ui = alwaysprefixes the line with an ANSI escape so it no longer starts withdiff --git, anddiff.externalreplaces the output wholesale (thediff --gitline disappears entirely). None of these is exotic —diff.noprefixis 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 = trueis the one that breaks two things at once, because it changes the base of the paths rather than their decoration.GitService::repo_pathis 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 ofsrc/secrets/**is tested againstsecrets/key.txtand 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-relativepins 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 thatDIFF_FORMAT_ARGSrestores the expected form under all of them, for bothgit diffandgit show. Regression tests:test_ignore_patterns_apply_regardless_of_diff_format_config(runs the real binary under each of the five formatting settings) andtest_ignore_and_full_diff_survive_diff_relative_from_subdirectory(runs it from a subdirectory underdiff.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. Leavingunwrap_or(false)alone is deliberate. Flipping it totruewould 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-relativewas missing from the first version ofDIFF_FORMAT_ARGSand 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-textconvis deliberately not here, because textconv changes a file's rendered content but never the shape of thediff --gitline, so it cannot affect which files are excluded.- Merge commits produce a different header, and the exclusions used to miss all of them.
git showon a merge emits a combined diff whose block header isdiff --cc <path>— one path, noa//b/prefixes — notdiff --git a/… b/….filter_ignored_filesandfilter_binary_diffboth located blocks withstarts_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-closedload_ignore_patternsexists to prevent, reached by a different door:git-sc --generate-for <merge hash>and--amendwhenHEADis a merge. Verified with the real binary on September 11, 2026 (JST): withsecrets/**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 throughis_diff_block_start()(diff --git/diff --cc/diff --combined) andextract_file_paths_from_diff_header()resolves a combined header viaextract_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.txtis unquoted whilediff --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-relativefor the same reason the diff calls do. It is not a diff-producing API, so it was left out ofDIFF_FORMAT_ARGS— but it runsgit diff --cached --quietfromrepo_path(the cwd, not the Git root), and underdiff.relative = truethat 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)--squashsailed 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-diffis deliberately not added here:--quietanswers from the index comparison and never launches an external diff program, verified by settingdiff.externaland 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_pathvalidates 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 --githeaders, so path extraction uses a midpoint split for symmetrica/PATH b/PATHheaders to avoid misparsing (e.g.,diff --git a/foo bar.txt b/foo bar.txt→foo bar.txt, notfooandbar.txt). Asymmetric unquoted rename headers split at the lastb/, sodiff --git a/old file.txt b/generated/new file.txtchecks 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 comparesdiff.len()(byte length) againstMAX_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 mostMAX_DIFF_CHARS+1characters, avoiding the previous full-diffchars().count()walk on multi-MB inputs.
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; usegit branch --show-currentin temporary repositories when switching back to the primary branch.
- 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, featuresclock/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 ingithub-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.1on September 2, 2026 (JST) and verified on September 4, 2026 (JST): the public API only grew —Session::cancellation_handle()and a newCancellationHandle(cancel/is_responding) — while every itemai/apple.rscalls is unchanged, so the provider needed no edits.cargo clippy --features apple-ai --all-targets -- -D warningspasses, the full suite passes, and the--ignoredApple Intelligence live tests (test_apple_intelligence_feat, real on-device generation) pass. History:=0.2.1replaced the long-standing=0.1.4pin on August 28, 2026 (JST) — the first release since0.1.4that built here. Background:0.1.5was 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 (AsyncWaiterisprivateandSystemLanguageModelhas notokenUsagemember insrc/swift/token_usage_api.swift), sodepup --include-pinnedwas reverted each time.0.2.1shipssrc/swift/token_usage_fallback.swift(plus*_fallback.swiftcounterparts for the reasoning / private-cloud-compute / 27-only session APIs) and selects between the_apiand_fallbackvariants 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 since0.1.4, which is why neither bump required a change toai/apple.rs. Keep the exact-version pin:0.xminor bumps are breaking by semver, and this crate's build is SDK-sensitive, so any future bump must re-passcargo clippy --features apple-ai -- -D warningsand 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.rsasksxcrun --show-sdk-versionand, at 27.0 or newer, compilessession_27_api.swift/reasoning_api.swift/generation_options_api.swiftinstead of their*_fallback/*_legacycounterparts — so upgrading the SDK silently enables typedLanguageModelErrorclassification,LanguageModelSession.Usage, andGenerationOptions.toolCallingModewith no dependency change at all. That is whatai/apple.rsnow 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 toError::UnsupportedPlatformorNonerather than failing to compile, and the fallbacks inapple.rs(a 4096-token default forcontext_size,usage: None) cover that path.
- 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