From aa1c0dbecb642cd1ce9af936476ccdbcef3ef8fa Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Sat, 7 Mar 2026 18:23:19 +0900 Subject: [PATCH 1/3] feat(config): add git config settings support --- internal/config/config.go | 75 +++-- internal/config/gitconfig.go | 167 +++++++++++ internal/config/gitconfig_test.go | 449 ++++++++++++++++++++++++++++++ 3 files changed, 668 insertions(+), 23 deletions(-) create mode 100644 internal/config/gitconfig.go create mode 100644 internal/config/gitconfig_test.go diff --git a/internal/config/config.go b/internal/config/config.go index c4a573d..a28f3a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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 = "" } 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 { @@ -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 } @@ -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 } @@ -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 + 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 } @@ -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) diff --git a/internal/config/gitconfig.go b/internal/config/gitconfig.go new file mode 100644 index 0000000..5168372 --- /dev/null +++ b/internal/config/gitconfig.go @@ -0,0 +1,167 @@ +package config + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// gitConfigScope holds ai-commit settings parsed from one git config scope. +type gitConfigScope struct { + engine string + prompt string + promptFile string + maxFileLines int + maxFileLinesSet bool + excludePatterns []string + defaultExcludePatterns []string +} + +// gitConfigScopes holds settings parsed from all git config scopes. +type gitConfigScopes struct { + system gitConfigScope + global gitConfigScope + local gitConfigScope + worktree gitConfigScope +} + +// readGitConfigScopes runs "git config --list --show-scope" and returns +// ai-commit settings organised by scope. If git is unavailable or we are not +// in a repository the returned struct is zero-valued and err is nil. +func readGitConfigScopes() (gitConfigScopes, error) { + cmd := exec.Command("git", "config", "--list", "--show-scope") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + // Not in a git repo or git not available; no git config to read. + return gitConfigScopes{}, nil + } + + scopes := gitConfigScopes{} + layerMap := map[string]*gitConfigScope{ + "system": &scopes.system, + "global": &scopes.global, + "local": &scopes.local, + "worktree": &scopes.worktree, + } + + for line := range strings.SplitSeq(stdout.String(), "\n") { + if line == "" { + continue + } + before, after, ok := strings.Cut(line, "\t") + if !ok { + continue + } + scope := before + rest := after + + lyr, ok := layerMap[scope] + if !ok { + // "command" scope and any unknown scopes are ignored. + continue + } + + before, after, ok0 := strings.Cut(rest, "=") + if !ok0 { + continue + } + // git config keys are case-insensitive; the list output is lowercase. + key := strings.ToLower(before) + value := after + + switch key { + case "ai-commit.engine": + lyr.engine = value + case "ai-commit.prompt": + lyr.prompt = value + case "ai-commit.promptfile": + lyr.promptFile = value + case "ai-commit.maxfilelines": + n, err := strconv.Atoi(value) + if err != nil { + lyr.maxFileLinesSet = true // mark that the key was present but invalid + lyr.maxFileLines = -1 // sentinel for "invalid" + } else { + lyr.maxFileLines = n + lyr.maxFileLinesSet = true + } + case "ai-commit.excludepatterns": + lyr.excludePatterns = append(lyr.excludePatterns, value) + case "ai-commit.defaultexcludepatterns": + lyr.defaultExcludePatterns = append(lyr.defaultExcludePatterns, value) + } + } + + return scopes, nil +} + +// applyGitConfigScope merges one git config scope into cfg. +// +// scopeLabel is the human-readable name used in error messages, e.g. "repo +// git config". +// +// promptFileBase is the directory used to resolve relative promptFile values. +// Pass "" to disallow relative paths (require absolute). +func applyGitConfigScope(cfg *Config, scope gitConfigScope, scopeLabel, promptFileBase string) error { + // Validate maxFileLines + if scope.maxFileLinesSet && scope.maxFileLines == -1 { + return fmt.Errorf("invalid ai-commit.maxFileLines value in %s: not an integer", scopeLabel) + } + + // Validate prompt exclusivity at this scope level. + if strings.TrimSpace(scope.prompt) != "" && strings.TrimSpace(scope.promptFile) != "" { + return fmt.Errorf("%s: cannot set both 'prompt' and 'promptFile'", scopeLabel) + } + + if scope.engine != "" { + cfg.DefaultEngine = scope.engine + } + if scope.prompt != "" { + cfg.Prompt = scope.prompt + cfg.PromptFile = "" + } + if scope.promptFile != "" { + cfg.PromptFile = resolvePromptFilePath(scope.promptFile, promptFileBase) + cfg.Prompt = "" + } + if scope.maxFileLinesSet && scope.maxFileLines >= 0 { + cfg.Filter.MaxFileLines = scope.maxFileLines + } + if len(scope.defaultExcludePatterns) > 0 { + cfg.Filter.DefaultExcludePatterns = scope.defaultExcludePatterns + } + if len(scope.excludePatterns) > 0 { + cfg.Filter.ExcludePatterns = append(cfg.Filter.ExcludePatterns, scope.excludePatterns...) + } + + return nil +} + +// resolvePromptFilePath resolves a promptFile value relative to baseDir. +// Absolute paths are returned unchanged. If baseDir is empty, the path is +// returned as-is. +func resolvePromptFilePath(promptFile, baseDir string) string { + if promptFile == "" || baseDir == "" || filepath.IsAbs(promptFile) { + return promptFile + } + return filepath.Join(baseDir, promptFile) +} + +// gitScopePromptPaths returns updated (promptFilePath, promptFileRepoRoot) +// values after a git config scope has been applied. If the scope set a +// promptFile, the resolved value is taken from cfg.PromptFile (already set by +// applyGitConfigScope). If the scope set a prompt preset, both values are +// cleared. Otherwise the current values are returned unchanged. +func gitScopePromptPaths(scope gitConfigScope, curPath, curRoot string, cfg Config) (string, string) { + if scope.promptFile != "" { + return cfg.PromptFile, "" + } + if scope.prompt != "" { + return "", "" + } + return curPath, curRoot +} diff --git a/internal/config/gitconfig_test.go b/internal/config/gitconfig_test.go new file mode 100644 index 0000000..344a7c7 --- /dev/null +++ b/internal/config/gitconfig_test.go @@ -0,0 +1,449 @@ +package config + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// setGitConfig sets a git config value in the given repo using git config --local. +func setGitConfig(t *testing.T, repo, key, value string) { + t.Helper() + runGit(t, repo, "config", "--local", key, value) +} + +// addGitConfig appends a git config multi-value in the given repo. +func addGitConfig(t *testing.T, repo, key, value string) { + t.Helper() + runGit(t, repo, "config", "--add", key, value) +} + +// initTestRepo creates a temp git repo and returns its path. +func initTestRepo(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + runGit(t, repo, "init") + return repo +} + +// isolateGitConfig points global/system config to empty temp files so they +// don't interfere with local-scope tests. +func isolateGitConfig(t *testing.T) { + t.Helper() + tmp := t.TempDir() + // Empty global config + globalCfg := filepath.Join(tmp, "global.gitconfig") + if err := os.WriteFile(globalCfg, []byte{}, 0o644); err != nil { + t.Fatalf("write global config: %v", err) + } + t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) + // Disable system config + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") +} + +// TestGitConfigEngine verifies that ai-commit.engine in local git config sets +// the engine and overrides the user TOML. +func TestGitConfigEngine(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + setGitConfig(t, repo, "ai-commit.engine", "codex") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.DefaultEngine != "codex" { + t.Fatalf("DefaultEngine = %q, want 'codex'", cfg.DefaultEngine) + } + }) +} + +// TestGitConfigPrompt verifies that ai-commit.prompt in local git config sets +// the prompt preset. +func TestGitConfigPrompt(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + setGitConfig(t, repo, "ai-commit.prompt", "conventional") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.Prompt != "conventional" { + t.Fatalf("Prompt = %q, want 'conventional'", cfg.Prompt) + } + if cfg.ResolvedPrompt == "" { + t.Fatal("ResolvedPrompt is empty") + } + }) +} + +// TestGitConfigPromptFile verifies that ai-commit.promptFile in local git +// config loads the file relative to the repo root. +func TestGitConfigPromptFile(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + promptContent := "My custom prompt from git config" + promptFile := filepath.Join(repo, "my-prompt.md") + if err := os.WriteFile(promptFile, []byte(promptContent), 0o644); err != nil { + t.Fatalf("write prompt file: %v", err) + } + + setGitConfig(t, repo, "ai-commit.promptFile", "my-prompt.md") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.ResolvedPrompt != promptContent { + t.Fatalf("ResolvedPrompt = %q, want %q", cfg.ResolvedPrompt, promptContent) + } + if cfg.Prompt != "" { + t.Fatalf("Prompt = %q, want empty", cfg.Prompt) + } + }) +} + +// TestGitConfigPromptFileAbsolutePath verifies that an absolute promptFile +// path in local git config is accepted (no containment restriction). +func TestGitConfigPromptFileAbsolutePath(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + // Prompt file is outside the repo – this is allowed for git config sources. + promptDir := t.TempDir() + promptFile := filepath.Join(promptDir, "external-prompt.md") + if err := os.WriteFile(promptFile, []byte("External prompt"), 0o644); err != nil { + t.Fatalf("write prompt file: %v", err) + } + + setGitConfig(t, repo, "ai-commit.promptFile", promptFile) + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.ResolvedPrompt != "External prompt" { + t.Fatalf("ResolvedPrompt = %q, want 'External prompt'", cfg.ResolvedPrompt) + } + }) +} + +// TestGitConfigMaxFileLines verifies that ai-commit.maxFileLines is applied. +func TestGitConfigMaxFileLines(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + setGitConfig(t, repo, "ai-commit.maxFileLines", "200") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.Filter.MaxFileLines != 200 { + t.Fatalf("MaxFileLines = %d, want 200", cfg.Filter.MaxFileLines) + } + }) +} + +// TestGitConfigMaxFileLinesInvalid verifies that a non-integer maxFileLines +// returns a descriptive error. +func TestGitConfigMaxFileLinesInvalid(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + setGitConfig(t, repo, "ai-commit.maxFileLines", "notanumber") + + withDir(t, repo, func() { + _, err := Load() + if err == nil { + t.Fatal("expected error for invalid maxFileLines") + } + if !contains(err.Error(), "maxFileLines") { + t.Fatalf("error should mention maxFileLines: %v", err) + } + if !contains(err.Error(), "not an integer") { + t.Fatalf("error should mention 'not an integer': %v", err) + } + }) +} + +// TestGitConfigExcludePatterns verifies that multi-value excludePatterns are +// collected and appended. +func TestGitConfigExcludePatterns(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + addGitConfig(t, repo, "ai-commit.excludePatterns", "*.lock") + addGitConfig(t, repo, "ai-commit.excludePatterns", "vendor/**") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if !slices.Contains(cfg.Filter.ExcludePatterns, "*.lock") { + t.Fatalf("ExcludePatterns missing '*.lock': %v", cfg.Filter.ExcludePatterns) + } + if !slices.Contains(cfg.Filter.ExcludePatterns, "vendor/**") { + t.Fatalf("ExcludePatterns missing 'vendor/**': %v", cfg.Filter.ExcludePatterns) + } + }) +} + +// TestGitConfigDefaultExcludePatterns verifies that defaultExcludePatterns in +// local git config overrides the default list. +func TestGitConfigDefaultExcludePatterns(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + addGitConfig(t, repo, "ai-commit.defaultExcludePatterns", "*.lock") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if len(cfg.Filter.DefaultExcludePatterns) != 1 || cfg.Filter.DefaultExcludePatterns[0] != "*.lock" { + t.Fatalf("DefaultExcludePatterns = %v, want ['*.lock']", cfg.Filter.DefaultExcludePatterns) + } + }) +} + +// TestGitConfigPromptExclusivity verifies that setting both prompt and +// promptFile at the same scope returns an error. +func TestGitConfigPromptExclusivity(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + setGitConfig(t, repo, "ai-commit.prompt", "conventional") + setGitConfig(t, repo, "ai-commit.promptFile", "my-prompt.md") + + withDir(t, repo, func() { + _, err := Load() + if err == nil { + t.Fatal("expected error for setting both prompt and promptFile") + } + if !contains(err.Error(), "cannot set both") { + t.Fatalf("error should mention 'cannot set both': %v", err) + } + }) +} + +// TestGitConfigLocalOverridesRepoToml verifies that local git config takes +// priority over the repo TOML. +func TestGitConfigLocalOverridesRepoToml(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + // Repo TOML sets engine to "gemini" + repoConfig := filepath.Join(repo, ".git-ai-commit.toml") + if err := os.WriteFile(repoConfig, []byte("engine = 'gemini'\n"), 0o644); err != nil { + t.Fatalf("write repo config: %v", err) + } + trustRepoConfig(t, repo, repoConfig) + + // Local git config overrides to "codex" + setGitConfig(t, repo, "ai-commit.engine", "codex") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.DefaultEngine != "codex" { + t.Fatalf("DefaultEngine = %q, want 'codex' (local git config should override repo TOML)", cfg.DefaultEngine) + } + }) +} + +// TestGitConfigLocalOverridesUserToml verifies that local git config takes +// priority over the user TOML. +func TestGitConfigLocalOverridesUserToml(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + + // User TOML sets engine to "gemini" + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + configDir := filepath.Join(configHome, "git-ai-commit") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("engine = 'gemini'\n"), 0o644); err != nil { + t.Fatalf("write user config: %v", err) + } + + // Local git config overrides to "codex" + setGitConfig(t, repo, "ai-commit.engine", "codex") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.DefaultEngine != "codex" { + t.Fatalf("DefaultEngine = %q, want 'codex' (local git config should override user TOML)", cfg.DefaultEngine) + } + }) +} + +// TestGitConfigGlobalOverriddenByUserToml verifies that the user TOML takes +// priority over global git config. +func TestGitConfigGlobalOverriddenByUserToml(t *testing.T) { + tmp := t.TempDir() + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + // Global git config sets engine to "gemini" + globalCfg := filepath.Join(tmp, "global.gitconfig") + if err := os.WriteFile(globalCfg, []byte("[ai-commit]\n\tengine = gemini\n"), 0o644); err != nil { + t.Fatalf("write global config: %v", err) + } + t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) + + // User TOML sets engine to "codex" + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + configDir := filepath.Join(configHome, "git-ai-commit") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("engine = 'codex'\n"), 0o644); err != nil { + t.Fatalf("write user config: %v", err) + } + + repo := initTestRepo(t) + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.DefaultEngine != "codex" { + t.Fatalf("DefaultEngine = %q, want 'codex' (user TOML should override global git config)", cfg.DefaultEngine) + } + }) +} + +// TestGitConfigGlobalPromptFile verifies that a promptFile in global git +// config is resolved relative to $HOME. +func TestGitConfigGlobalPromptFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + // Create prompt file in a "home" directory + homeDir := filepath.Join(tmp, "home") + if err := os.MkdirAll(homeDir, 0o755); err != nil { + t.Fatalf("mkdir home: %v", err) + } + promptFile := filepath.Join(homeDir, "my-prompt.md") + if err := os.WriteFile(promptFile, []byte("Global home prompt"), 0o644); err != nil { + t.Fatalf("write prompt file: %v", err) + } + t.Setenv("HOME", homeDir) + + // Global git config points to relative promptFile (resolved from $HOME) + globalCfg := filepath.Join(tmp, "global.gitconfig") + if err := os.WriteFile(globalCfg, []byte("[ai-commit]\n\tpromptFile = my-prompt.md\n"), 0o644); err != nil { + t.Fatalf("write global config: %v", err) + } + t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) + + repo := initTestRepo(t) + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.ResolvedPrompt != "Global home prompt" { + t.Fatalf("ResolvedPrompt = %q, want 'Global home prompt'", cfg.ResolvedPrompt) + } + }) +} + +// TestGitConfigExcludePatternsAppendedAcrossLayers verifies that +// excludePatterns from multiple layers are all collected. +func TestGitConfigExcludePatternsAppendedAcrossLayers(t *testing.T) { + tmp := t.TempDir() + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + // Global adds one pattern + globalCfg := filepath.Join(tmp, "global.gitconfig") + if err := os.WriteFile(globalCfg, []byte("[ai-commit]\n\texcludePatterns = global-pattern\n"), 0o644); err != nil { + t.Fatalf("write global config: %v", err) + } + t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + repo := initTestRepo(t) + // Local adds another pattern + addGitConfig(t, repo, "ai-commit.excludePatterns", "local-pattern") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if !slices.Contains(cfg.Filter.ExcludePatterns, "global-pattern") { + t.Fatalf("ExcludePatterns missing 'global-pattern': %v", cfg.Filter.ExcludePatterns) + } + if !slices.Contains(cfg.Filter.ExcludePatterns, "local-pattern") { + t.Fatalf("ExcludePatterns missing 'local-pattern': %v", cfg.Filter.ExcludePatterns) + } + }) +} + +// TestGitConfigDefaultExcludePatternsHigherScopeWins verifies that a +// higher-priority scope's defaultExcludePatterns overrides a lower one. +func TestGitConfigDefaultExcludePatternsHigherScopeWins(t *testing.T) { + tmp := t.TempDir() + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + + // Global sets default patterns + globalCfg := filepath.Join(tmp, "global.gitconfig") + if err := os.WriteFile(globalCfg, []byte("[ai-commit]\n\tdefaultExcludePatterns = global-default\n"), 0o644); err != nil { + t.Fatalf("write global config: %v", err) + } + t.Setenv("GIT_CONFIG_GLOBAL", globalCfg) + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + repo := initTestRepo(t) + // Local overrides default patterns + addGitConfig(t, repo, "ai-commit.defaultExcludePatterns", "local-default") + + withDir(t, repo, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + // Only local default should be present (overrides global) + if len(cfg.Filter.DefaultExcludePatterns) != 1 || cfg.Filter.DefaultExcludePatterns[0] != "local-default" { + t.Fatalf("DefaultExcludePatterns = %v, want ['local-default']", cfg.Filter.DefaultExcludePatterns) + } + }) +} + From 0583843d9046bc82377bae0c9e9fc41ddb8366c1 Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Sat, 7 Mar 2026 18:23:54 +0900 Subject: [PATCH 2/3] docs: document git config as configuration source --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1028eff..48f585b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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..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..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. @@ -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 @@ -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 From 05df9029030fdc799fd2d3d36658c365d8996c0d Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Sat, 7 Mar 2026 18:45:23 +0900 Subject: [PATCH 3/3] fix(config): return root when TOML file absent --- internal/config/config.go | 2 +- internal/config/gitconfig_test.go | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index a28f3a6..c2fb95c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -324,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) } diff --git a/internal/config/gitconfig_test.go b/internal/config/gitconfig_test.go index 344a7c7..a39269a 100644 --- a/internal/config/gitconfig_test.go +++ b/internal/config/gitconfig_test.go @@ -447,3 +447,36 @@ func TestGitConfigDefaultExcludePatternsHigherScopeWins(t *testing.T) { }) } +// TestGitConfigPromptFileNoRepoToml verifies that a relative promptFile in +// local git config resolves from the repo root even when .git-ai-commit.toml +// is absent (repoConfigPath previously returned an empty root in that case). +func TestGitConfigPromptFileNoRepoToml(t *testing.T) { + repo := initTestRepo(t) + isolateGitConfig(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + // No .git-ai-commit.toml in this repo. + promptContent := "Prompt loaded without repo TOML" + if err := os.WriteFile(filepath.Join(repo, "prompt.md"), []byte(promptContent), 0o644); err != nil { + t.Fatalf("write prompt file: %v", err) + } + + setGitConfig(t, repo, "ai-commit.engine", "fake") + setGitConfig(t, repo, "ai-commit.promptFile", "prompt.md") + + // Run from a subdirectory to confirm CWD is not used as the base. + subdir := filepath.Join(repo, "sub") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } + + withDir(t, subdir, func() { + cfg, err := Load() + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.ResolvedPrompt != promptContent { + t.Fatalf("ResolvedPrompt = %q, want %q", cfg.ResolvedPrompt, promptContent) + } + }) +}