Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,23 @@ Common options:

## Configuration

Configuration is layered, allowing global defaults with per-repository overrides:
Configuration is layered. Later layers override earlier ones:

1. User config: `~/.config/git-ai-commit/config.toml`
2. Repo config: `.git-ai-commit.toml` at the repository root
3. Command-line flags
| Priority | Source |
|----------|--------|
| 1 (lowest) | System git config (`/etc/gitconfig`) |
| 2 | Global git config (`~/.gitconfig`) |
| 3 | User TOML (`~/.config/git-ai-commit/config.toml`) |
| 4 | Repo TOML (`.git-ai-commit.toml` at repo root) |
| 5 | Local git config (`.git/config`) |
| 6 | Worktree git config |
| 7 (highest) | Command-line flags |

This makes it easy to keep personal preferences (engine, style) while enforcing repository-specific commit rules without relying on hosted services. Repository config is applied only after an initial trust prompt.
Repository TOML config is applied only after an initial trust prompt, since it is a tracked file that could be set by a repo maintainer.

### TOML config files

`~/.config/git-ai-commit/config.toml` for user-wide defaults, `.git-ai-commit.toml` at the repo root for project defaults.

Example: Use Codex with Conventional Commits by default

Expand All @@ -72,12 +82,48 @@ Supported settings:

- `engine` Default engine name (string)
- `prompt` Bundled prompt preset: `default`, `conventional`, `gitmoji`, `karma`
- `prompt_file` Path to a custom prompt file (relative to the config file)
- `prompt_file` Path to a custom prompt file (relative to the config file; must be within the repo root for repo TOML)
- `engines.<name>.args` Argument list for the engine command (array of strings)
- `filter.max_file_lines` Maximum lines per file in diff (default: 100)
- `filter.exclude_patterns` Additional glob patterns to exclude from diff
- `filter.default_exclude_patterns` Override built-in exclude patterns

### git config

All settings except `engines.<name>.args` can also be set via `git config` using the `ai-commit` section. This is useful for per-repository preferences in repositories you do not own, since `.git/config` is never committed or pushed.

```sh
# Set for the current repository only
git config --local ai-commit.engine claude
git config --local ai-commit.prompt conventional

# Or apply user-wide defaults
git config --global ai-commit.engine claude
git config --global ai-commit.prompt conventional
```

Supported keys:

| git config key | Equivalent TOML setting |
|----------------|------------------------|
| `ai-commit.engine` | `engine` |
| `ai-commit.prompt` | `prompt` |
| `ai-commit.promptFile` | `prompt_file` |
| `ai-commit.maxFileLines` | `filter.max_file_lines` |
| `ai-commit.excludePatterns` | `filter.exclude_patterns` |
| `ai-commit.defaultExcludePatterns` | `filter.default_exclude_patterns` |

`excludePatterns` and `defaultExcludePatterns` support multiple values via `git config --add`:

```sh
git config --add ai-commit.excludePatterns '*.pb.go'
git config --add ai-commit.excludePatterns 'vendor/**'
```

Relative `promptFile` paths are resolved from the repo root for `--local`/`--worktree` scope, and from `$HOME` for `--global` scope. No path containment restriction applies — unlike repo TOML, git config cannot be set by a repository maintainer via push.

`prompt` and `promptFile` cannot both be set within the same scope. Setting both returns an error.

### Engines

git-ai-commit treats LLMs as external commands, not as APIs. This design avoids direct network calls and API key management, and lets you reuse your existing LLM CLI setup.
Expand Down Expand Up @@ -109,7 +155,7 @@ args = ["run", "gemma3:4b"]

### Prompt presets

Bundled presets live in `internal/config/assets/`:
Bundled presets:

- `default` – Commit messages aligned with the recommendations in [Pro Git](https://git-scm.com/book/ms/v2/Distributed-Git-Contributing-to-a-Project)
- `conventional` – [Conventional Commits](https://www.conventionalcommits.org/) format
Expand All @@ -118,14 +164,20 @@ Bundled presets live in `internal/config/assets/`:

### Custom Prompts

Example: Use a custom prompt file
Point to a custom prompt file in TOML config:

```toml
engine = "claude"
prompt_file = "prompts/commit.md"
```

Note: `prompt` and `prompt_file` are mutually exclusive within the same config file. If both are set, an error is returned. When settings come from different layers (user config vs repo config), the later layer wins.
Or via git config (useful for repos you do not own):

```sh
git config --local ai-commit.promptFile /path/to/prompt.md
```

`prompt` and `prompt_file` (or `promptFile` in git config) are mutually exclusive within the same config layer. When they come from different layers, the higher-priority layer wins.

### Diff Filtering

Expand Down
77 changes: 53 additions & 24 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,38 @@ func Default() Config {

func Load() (Config, error) {
cfg := Default()
var promptSource string // tracks where the final prompt setting came from
var promptFilePath string // the path to the config file that set prompt_file
var promptFilePath string // resolved path used by resolvePromptFromPath
var promptFileRepoRoot string

// 1. Load user config
// Read all git config scopes in a single call.
gitScopes, err := readGitConfigScopes()
if err != nil {
return cfg, err
}

// Determine repo root once (needed for local/worktree promptFile resolution
// and for loading the repo TOML).
repoRoot, repoPath, err := repoConfigPath()
if err != nil {
return cfg, err
}

// Determine home directory (needed for global promptFile resolution).
homeDir, _ := os.UserHomeDir()

// 1. System git config (lowest priority)
if err := applyGitConfigScope(&cfg, gitScopes.system, "system git config", ""); err != nil {
return cfg, err
}
promptFilePath, promptFileRepoRoot = gitScopePromptPaths(gitScopes.system, promptFilePath, promptFileRepoRoot, cfg)

// 2. Global git config
if err := applyGitConfigScope(&cfg, gitScopes.global, "user git config", homeDir); err != nil {
return cfg, err
}
promptFilePath, promptFileRepoRoot = gitScopePromptPaths(gitScopes.global, promptFilePath, promptFileRepoRoot, cfg)

// 3. User TOML config
userPath, err := configPath()
if err != nil {
return cfg, err
Expand All @@ -83,23 +110,17 @@ func Load() (Config, error) {
return cfg, err
}
if cfg.PromptFile != "" {
promptSource = "user"
promptFilePath = userPath
if _, err := loadPromptFile(cfg.PromptFile, promptFilePath, ""); err != nil {
return cfg, err
}
promptFileRepoRoot = ""
Comment on lines 112 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate user prompt_file before higher layers overwrite it

After loading user TOML, this branch only records promptFilePath and defers file loading until the final merged config. If the user config has an invalid prompt_file and a higher-priority layer later sets prompt/promptFile, the invalid file is never validated and Load() now succeeds, silently masking a broken user config that previously produced an error.

Useful? React with 👍 / 👎.

} else if cfg.Prompt != "" {
promptSource = "user"
promptFilePath = ""
promptFileRepoRoot = ""
}
} else if !os.IsNotExist(err) {
return cfg, fmt.Errorf("read user config: %w", err)
}

// 2. Load repo config (merges on top of user config)
repoRoot, repoPath, err := repoConfigPath()
if err != nil {
return cfg, err
}
// 4. Repo TOML config
if repoPath != "" {
data, trusted, err := loadTrustedRepoConfig(repoRoot, repoPath)
if err != nil {
Expand All @@ -113,21 +134,18 @@ func Load() (Config, error) {
if err := validatePromptExclusivity(repoCfg.Prompt, repoCfg.PromptFile, "repo config"); err != nil {
return cfg, err
}
// Merge repo config into cfg
if repoCfg.DefaultEngine != "" {
cfg.DefaultEngine = repoCfg.DefaultEngine
}
if repoCfg.Prompt != "" {
cfg.Prompt = repoCfg.Prompt
cfg.PromptFile = ""
promptSource = "repo"
promptFilePath = ""
promptFileRepoRoot = ""
}
if repoCfg.PromptFile != "" {
cfg.PromptFile = repoCfg.PromptFile
cfg.Prompt = ""
promptSource = "repo"
promptFilePath = repoPath
promptFileRepoRoot = repoRoot
}
Expand All @@ -137,7 +155,6 @@ func Load() (Config, error) {
}
maps.Copy(cfg.Engines, repoCfg.Engines)
}
// Merge filter config from repo
if repoCfg.Filter.MaxFileLines != 0 {
cfg.Filter.MaxFileLines = repoCfg.Filter.MaxFileLines
}
Expand All @@ -150,23 +167,35 @@ func Load() (Config, error) {
}
}

// 3. Auto-detect engine if still empty
// 5. Local git config (overrides repo TOML)
localPromptFileBase := repoRoot // may be "" if not in a repo
Comment thread
takai marked this conversation as resolved.
if err := applyGitConfigScope(&cfg, gitScopes.local, "repo git config", localPromptFileBase); err != nil {
return cfg, err
}
promptFilePath, promptFileRepoRoot = gitScopePromptPaths(gitScopes.local, promptFilePath, promptFileRepoRoot, cfg)

// 6. Worktree git config (highest git config priority)
if err := applyGitConfigScope(&cfg, gitScopes.worktree, "worktree git config", localPromptFileBase); err != nil {
return cfg, err
}
promptFilePath, promptFileRepoRoot = gitScopePromptPaths(gitScopes.worktree, promptFilePath, promptFileRepoRoot, cfg)

// 7. Auto-detect engine if still empty
if strings.TrimSpace(cfg.DefaultEngine) == "" {
if auto := autodetectEngine(); auto != "" {
cfg.DefaultEngine = auto
}
}

// 4. Ensure Engines map is initialized
// 8. Ensure Engines map is initialized
if cfg.Engines == nil {
cfg.Engines = map[string]EngineConfig{}
}

// 5. Resolve prompt from preset or file
if err := resolvePrompt(&cfg, promptFilePath, promptFileRepoRoot); err != nil {
// 9. Resolve prompt from preset or file.
if err := resolvePromptFromPath(&cfg, promptFilePath, promptFileRepoRoot); err != nil {
return cfg, err
}
_ = promptSource // used for debugging if needed

return cfg, nil
}
Expand Down Expand Up @@ -217,7 +246,7 @@ func validatePromptExclusivity(prompt, promptFile, source string) error {
return nil
}

func resolvePrompt(cfg *Config, promptFilePath, promptFileRepoRoot string) error {
func resolvePromptFromPath(cfg *Config, promptFilePath, promptFileRepoRoot string) error {
// If prompt_file is set, load from file (relative to config file's directory)
if strings.TrimSpace(cfg.PromptFile) != "" {
promptText, err := loadPromptFile(cfg.PromptFile, promptFilePath, promptFileRepoRoot)
Expand Down Expand Up @@ -295,7 +324,7 @@ func repoConfigPath() (string, string, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return "", "", nil
return root, "", nil
}
return "", "", fmt.Errorf("stat repo config: %w", err)
}
Expand Down
Loading