diff --git a/README.md b/README.md index 8983293..2e99342 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ blank commit message. Completions APIs (including local servers like Ollama and LM Studio), or the local [`apfel`](https://github.com/Arthur-Ficial/apfel) on-device model (macOS only) — no network calls required. A provider must be - selected explicitly via `--provider`/a shortcut flag or - `LAZYCOMMIT_PROVIDER`; there is no default. + selected explicitly via `--provider`/a shortcut flag, `LAZYCOMMIT_PROVIDER`, + or `git config lazycommit.provider`; there is no default. - **Editor review by default**: the generated message is pre-populated in `$EDITOR` before committing, so you can tweak it. Use `--no-edit` to skip the review step and commit as-is. @@ -76,12 +76,16 @@ Usage: lazycommit [options] [-- git-commit-flags] Auto-generates a commit message using an LLM provider, pre-populates $EDITOR for review, then commits. A provider must be specified via ---provider, a shortcut flag, or LAZYCOMMIT_PROVIDER. +--provider, a shortcut flag, LAZYCOMMIT_PROVIDER, or +"git config lazycommit.provider". + +Settings may also be read from git config (e.g. "git config +lazycommit.provider copilot"); precedence is flag > env var > git config. Options: -p, --patch Interactively stage hunks via git add -p before committing --provider

Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER) - --model Model name to use (provider-specific default if omitted) + --model Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL) --base-url Override the API base URL (copilot/openai providers) --api-key API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY) --prompt Override the prompt template (or use LAZYCOMMIT_PROMPT) @@ -123,8 +127,9 @@ Prompt template: ### Examples A provider must be specified — either pass `--provider `/a shortcut -flag on every invocation, or set `export LAZYCOMMIT_PROVIDER=copilot` (or -`openai`/`apfel`) once so you don't have to repeat it: +flag on every invocation, set `export LAZYCOMMIT_PROVIDER=copilot` (or +`openai`/`apfel`) once, or run `git config lazycommit.provider copilot` so +you don't have to repeat it: ```sh # Use Copilot @@ -215,21 +220,31 @@ instead of trying (and failing) to exec a nonexistent `apfel` binary. ## Configuration -Flags always take precedence over environment variables. `--provider` (or -one of `--copilot`/`--apfel`/`--ollama`/`--lmstudio`) or `LAZYCOMMIT_PROVIDER` -is **required** — there is no default provider. - -| Flag | Environment variable | Description | -|---------------|-------------------------|------------------------------------------------------| -| `--provider` | `LAZYCOMMIT_PROVIDER` | Provider to use: `copilot`, `openai`, or `apfel` (macOS only) (required) | -| `--model` | `LAZYCOMMIT_MODEL` | Model name (provider-specific default if omitted) | -| `--prompt` | `LAZYCOMMIT_PROMPT` | Prompt template override (see below) | -| `--base-url` | `GITHUB_API_URL` | Base URL override for the `copilot` provider | -| `--base-url` | `OPENAI_BASE_URL` | Base URL override for the `openai` provider | -| `--api-key` | `OPENAI_API_KEY` | API key for `openai`, or OAuth token for `copilot` (skips hosts/apps file lookup) | -| — | `EDITOR` | Editor used to review the generated message | -| — | `COPILOT_HOSTS_FILE` | Path to the Copilot OAuth hosts.json file | -| — | `COPILOT_APPS_FILE` | Path to the Copilot OAuth apps.json file | +Settings can be provided via a CLI flag, an environment variable, or a +`git config` key, in that order of precedence (flags win over environment +variables, which win over `git config`). `--provider` (or one of +`--copilot`/`--apfel`/`--ollama`/`--lmstudio`), `LAZYCOMMIT_PROVIDER`, or +`git config lazycommit.provider` is **required** — there is no default +provider. + +| Flag | Environment variable | `git config` key | Description | +|---------------|-------------------------|---------------------------|------------------------------------------------------| +| `--provider` | `LAZYCOMMIT_PROVIDER` | `lazycommit.provider` | Provider to use: `copilot`, `openai`, or `apfel` (macOS only) (required) | +| `--model` | `LAZYCOMMIT_MODEL` | `lazycommit.model` | Model name (provider-specific default if omitted) | +| `--prompt` | `LAZYCOMMIT_PROMPT` | `lazycommit.prompt` | Prompt template override (see below) | +| `--base-url` | `GITHUB_API_URL` | `lazycommit.baseUrl` | Base URL override for the `copilot` provider | +| `--base-url` | `OPENAI_BASE_URL` | `lazycommit.baseUrl` | Base URL override for the `openai` provider | +| `--api-key` | `OPENAI_API_KEY` | `lazycommit.apiKey` | API key for `openai`, or OAuth token for `copilot` (skips hosts/apps file lookup) | +| — | `EDITOR` | — | Editor used to review the generated message | +| — | `COPILOT_HOSTS_FILE` | — | Path to the Copilot OAuth hosts.json file | +| — | `COPILOT_APPS_FILE` | — | Path to the Copilot OAuth apps.json file | + +For example, to persistently configure a repo (or `--global`) to use the +`copilot` provider without an env var or flag: + +```sh +git config lazycommit.provider copilot +``` > **Note:** If `EDITOR` is unset or empty, the review step is skipped > entirely and the generated message is committed as-is, the same as diff --git a/internal/app/apfel_darwin_test.go b/internal/app/apfel_darwin_test.go index e3bcebf..5e0e155 100644 --- a/internal/app/apfel_darwin_test.go +++ b/internal/app/apfel_darwin_test.go @@ -26,7 +26,7 @@ func TestNewApfelProviderDarwin(t *testing.T) { } func TestNewProviderApfelViaFactoryDarwin(t *testing.T) { - gen, err := NewProvider("apfel", "", "", "", envMap(nil)) + gen, err := NewProvider("apfel", "", "", "", envMap(nil), gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/app/apfel_other_test.go b/internal/app/apfel_other_test.go index 0086e2b..92fd1da 100644 --- a/internal/app/apfel_other_test.go +++ b/internal/app/apfel_other_test.go @@ -21,7 +21,7 @@ func TestNewApfelProviderNonDarwin(t *testing.T) { } func TestNewProviderApfelViaFactoryNonDarwin(t *testing.T) { - gen, err := NewProvider("apfel", "", "", "", envMap(nil)) + gen, err := NewProvider("apfel", "", "", "", envMap(nil), gitConfigMap(nil)) if err == nil { t.Fatal("expected error on non-darwin platforms") } diff --git a/internal/app/args.go b/internal/app/args.go index 8e1ffc0..f1f5182 100644 --- a/internal/app/args.go +++ b/internal/app/args.go @@ -134,12 +134,16 @@ func Usage() string { Auto-generates a commit message using an LLM provider, pre-populates $EDITOR for review, then commits. A provider must be specified via ---provider, a shortcut flag, or LAZYCOMMIT_PROVIDER. +--provider, a shortcut flag, LAZYCOMMIT_PROVIDER, or +"git config lazycommit.provider". + +Settings may also be read from git config (e.g. "git config +lazycommit.provider copilot"); precedence is flag > env var > git config. Options: -p, --patch Interactively stage hunks via git add -p before committing --provider

Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER) - --model Model name to use (provider-specific default if omitted) + --model Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL) --base-url Override the API base URL (copilot/openai providers) --api-key API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY) --prompt Override the prompt template (or use LAZYCOMMIT_PROMPT) diff --git a/internal/app/git.go b/internal/app/git.go index 5281973..fc0f96e 100644 --- a/internal/app/git.go +++ b/internal/app/git.go @@ -103,6 +103,19 @@ func (g *Git) Commit(message string, flags []string) error { return g.interactive()(args) } +// ConfigGet returns the resolved value of the given `git config` key (e.g. +// "lazycommit.provider"), checking local, global, and system config in +// git's usual order. It returns "" if the key is unset or the lookup +// otherwise fails (e.g. no git config value at all), mirroring how GetEnv +// reports unset environment variables. +func (g *Git) ConfigGet(key string) string { + out, err := g.run("config", "--get", key) + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + // LastCommitOneline returns `git log -1 --oneline` for display purposes. func (g *Git) LastCommitOneline() (string, error) { out, err := g.run("log", "-1", "--oneline") diff --git a/internal/app/git_test.go b/internal/app/git_test.go index 5d048c6..f131e1b 100644 --- a/internal/app/git_test.go +++ b/internal/app/git_test.go @@ -179,6 +179,27 @@ func TestGitLastCommitOneline(t *testing.T) { } } +func TestGitConfigGet(t *testing.T) { + g := &Git{Runner: fakeRunner(t, map[string]struct { + out string + err error + }{ + "git config --get lazycommit.provider": {out: "copilot\n", err: nil}, + })} + if got := g.ConfigGet("lazycommit.provider"); got != "copilot" { + t.Errorf("got %q, want %q", got, "copilot") + } + + // Unset keys make `git config --get` exit non-zero; ConfigGet must + // treat that as "no value" rather than surfacing an error. + g2 := &Git{Runner: func(name string, args []string) ([]byte, error) { + return nil, errors.New("exit status 1") + }} + if got := g2.ConfigGet("lazycommit.model"); got != "" { + t.Errorf("expected empty string for unset key, got %q", got) + } +} + func TestGitDefaultRunnerAndInteractive(t *testing.T) { // Exercise the default() accessors without invoking real commands. g := &Git{} diff --git a/internal/app/provider_factory.go b/internal/app/provider_factory.go index 785d9f7..efc1347 100644 --- a/internal/app/provider_factory.go +++ b/internal/app/provider_factory.go @@ -21,42 +21,92 @@ const ( // GetEnv abstracts environment variable lookup, primarily for testability. type GetEnv func(string) string +// GetGitConfig abstracts `git config --get ` lookup, primarily for +// testability. Implementations should return "" when the key is unset. +type GetGitConfig func(key string) string + // ResolveProvider determines the effective provider name from the flag, -// falling back to LAZYCOMMIT_PROVIDER. Returns "" if neither is set, -// meaning no provider was specified. -func ResolveProvider(flagValue string, getenv GetEnv) string { +// falling back to LAZYCOMMIT_PROVIDER, then the "lazycommit.provider" git +// config key. Returns "" if none are set, meaning no provider was +// specified. +func ResolveProvider(flagValue string, getenv GetEnv, getGitConfig GetGitConfig) string { if flagValue != "" { return flagValue } - return getenv("LAZYCOMMIT_PROVIDER") + if v := getenv("LAZYCOMMIT_PROVIDER"); v != "" { + return v + } + return getGitConfig("lazycommit.provider") } // ResolveModel determines the effective model name from the flag, falling -// back to LAZYCOMMIT_MODEL, then "" (provider-specific default applies). -func ResolveModel(flagValue string, getenv GetEnv) string { +// back to LAZYCOMMIT_MODEL, then the "lazycommit.model" git config key, +// then "" (provider-specific default applies). +func ResolveModel(flagValue string, getenv GetEnv, getGitConfig GetGitConfig) string { if flagValue != "" { return flagValue } - return getenv("LAZYCOMMIT_MODEL") + if v := getenv("LAZYCOMMIT_MODEL"); v != "" { + return v + } + return getGitConfig("lazycommit.model") } // ResolvePrompt determines the effective prompt template from the flag, -// falling back to LAZYCOMMIT_PROMPT, then the built-in default. -func ResolvePrompt(flagValue string, getenv GetEnv) string { +// falling back to LAZYCOMMIT_PROMPT, then the "lazycommit.prompt" git +// config key, then the built-in default. +func ResolvePrompt(flagValue string, getenv GetEnv, getGitConfig GetGitConfig) string { if flagValue != "" { return flagValue } if v := getenv("LAZYCOMMIT_PROMPT"); v != "" { return v } + if v := getGitConfig("lazycommit.prompt"); v != "" { + return v + } return DefaultPromptTemplate } -// NewProvider builds the Generator for the given resolved provider name. -func NewProvider(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { - if apiKey == "" { - apiKey = getenv("OPENAI_API_KEY") +// ResolveBaseURL determines the effective API base URL from the flag, +// falling back to a provider-specific environment variable (GITHUB_API_URL +// for copilot, OPENAI_BASE_URL for openai), then the generic +// "lazycommit.baseUrl" git config key. +func ResolveBaseURL(flagValue, providerName string, getenv GetEnv, getGitConfig GetGitConfig) string { + if flagValue != "" { + return flagValue } + var envKey string + switch providerName { + case "copilot": + envKey = "GITHUB_API_URL" + case "openai": + envKey = "OPENAI_BASE_URL" + } + if envKey != "" { + if v := getenv(envKey); v != "" { + return v + } + } + return getGitConfig("lazycommit.baseUrl") +} + +// ResolveAPIKey determines the effective API key from the flag, falling +// back to OPENAI_API_KEY, then the "lazycommit.apiKey" git config key. +func ResolveAPIKey(flagValue string, getenv GetEnv, getGitConfig GetGitConfig) string { + if flagValue != "" { + return flagValue + } + if v := getenv("OPENAI_API_KEY"); v != "" { + return v + } + return getGitConfig("lazycommit.apiKey") +} + +// NewProvider builds the Generator for the given resolved provider name. +func NewProvider(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { + apiKey = ResolveAPIKey(apiKey, getenv, getGitConfig) + baseURL = ResolveBaseURL(baseURL, name, getenv, getGitConfig) switch name { case "copilot": @@ -68,9 +118,6 @@ func NewProvider(name, model, baseURL, apiKey string, getenv GetEnv) (provider.G if appsFile == "" { appsFile = filepath.Join(homeDir(getenv), ".config", "github-copilot", "apps.json") } - if baseURL == "" { - baseURL = getenv("GITHUB_API_URL") - } return &provider.CopilotProvider{ Model: model, APIBaseURL: baseURL, @@ -81,9 +128,6 @@ func NewProvider(name, model, baseURL, apiKey string, getenv GetEnv) (provider.G Temperature: 0.2, }, nil case "openai": - if baseURL == "" { - baseURL = getenv("OPENAI_BASE_URL") - } return &provider.OpenAIProvider{ APIKey: apiKey, Model: model, diff --git a/internal/app/provider_factory_test.go b/internal/app/provider_factory_test.go index 5b8797a..b1e29fa 100644 --- a/internal/app/provider_factory_test.go +++ b/internal/app/provider_factory_test.go @@ -10,49 +10,95 @@ func envMap(m map[string]string) GetEnv { return func(k string) string { return m[k] } } +func gitConfigMap(m map[string]string) GetGitConfig { + return func(k string) string { return m[k] } +} + func TestResolveProvider(t *testing.T) { - if got := ResolveProvider("openai", envMap(map[string]string{"LAZYCOMMIT_PROVIDER": "apfel"})); got != "openai" { + if got := ResolveProvider("openai", envMap(map[string]string{"LAZYCOMMIT_PROVIDER": "apfel"}), gitConfigMap(nil)); got != "openai" { t.Errorf("flag should win, got %q", got) } - if got := ResolveProvider("", envMap(map[string]string{"LAZYCOMMIT_PROVIDER": "apfel"})); got != "apfel" { - t.Errorf("expected env var, got %q", got) + if got := ResolveProvider("", envMap(map[string]string{"LAZYCOMMIT_PROVIDER": "apfel"}), gitConfigMap(map[string]string{"lazycommit.provider": "copilot"})); got != "apfel" { + t.Errorf("expected env var to win over git config, got %q", got) } - if got := ResolveProvider("", envMap(nil)); got != "" { + if got := ResolveProvider("", envMap(nil), gitConfigMap(map[string]string{"lazycommit.provider": "copilot"})); got != "copilot" { + t.Errorf("expected git config value, got %q", got) + } + if got := ResolveProvider("", envMap(nil), gitConfigMap(nil)); got != "" { t.Errorf("expected empty string when no provider specified, got %q", got) } } func TestResolveModel(t *testing.T) { - if got := ResolveModel("gpt-5", envMap(map[string]string{"LAZYCOMMIT_MODEL": "gpt-4o"})); got != "gpt-5" { + if got := ResolveModel("gpt-5", envMap(map[string]string{"LAZYCOMMIT_MODEL": "gpt-4o"}), gitConfigMap(nil)); got != "gpt-5" { t.Errorf("flag should win, got %q", got) } - if got := ResolveModel("", envMap(map[string]string{"LAZYCOMMIT_MODEL": "gpt-4o"})); got != "gpt-4o" { - t.Errorf("expected env var, got %q", got) + if got := ResolveModel("", envMap(map[string]string{"LAZYCOMMIT_MODEL": "gpt-4o"}), gitConfigMap(map[string]string{"lazycommit.model": "gpt-3.5"})); got != "gpt-4o" { + t.Errorf("expected env var to win over git config, got %q", got) + } + if got := ResolveModel("", envMap(nil), gitConfigMap(map[string]string{"lazycommit.model": "gpt-3.5"})); got != "gpt-3.5" { + t.Errorf("expected git config value, got %q", got) } - if got := ResolveModel("", envMap(nil)); got != "" { + if got := ResolveModel("", envMap(nil), gitConfigMap(nil)); got != "" { t.Errorf("expected empty default, got %q", got) } } func TestResolvePrompt(t *testing.T) { - if got := ResolvePrompt("custom", envMap(map[string]string{"LAZYCOMMIT_PROMPT": "env prompt"})); got != "custom" { + if got := ResolvePrompt("custom", envMap(map[string]string{"LAZYCOMMIT_PROMPT": "env prompt"}), gitConfigMap(nil)); got != "custom" { t.Errorf("flag should win, got %q", got) } - if got := ResolvePrompt("", envMap(map[string]string{"LAZYCOMMIT_PROMPT": "env prompt"})); got != "env prompt" { - t.Errorf("expected env var, got %q", got) + if got := ResolvePrompt("", envMap(map[string]string{"LAZYCOMMIT_PROMPT": "env prompt"}), gitConfigMap(map[string]string{"lazycommit.prompt": "config prompt"})); got != "env prompt" { + t.Errorf("expected env var to win over git config, got %q", got) } - if got := ResolvePrompt("", envMap(nil)); got != DefaultPromptTemplate { + if got := ResolvePrompt("", envMap(nil), gitConfigMap(map[string]string{"lazycommit.prompt": "config prompt"})); got != "config prompt" { + t.Errorf("expected git config value, got %q", got) + } + if got := ResolvePrompt("", envMap(nil), gitConfigMap(nil)); got != DefaultPromptTemplate { t.Errorf("expected default template") } } +func TestResolveBaseURL(t *testing.T) { + if got := ResolveBaseURL("https://flag.example.com", "copilot", envMap(map[string]string{"GITHUB_API_URL": "https://env.example.com"}), gitConfigMap(nil)); got != "https://flag.example.com" { + t.Errorf("flag should win, got %q", got) + } + if got := ResolveBaseURL("", "copilot", envMap(map[string]string{"GITHUB_API_URL": "https://env.example.com"}), gitConfigMap(map[string]string{"lazycommit.baseUrl": "https://config.example.com"})); got != "https://env.example.com" { + t.Errorf("expected provider-specific env var to win over git config, got %q", got) + } + if got := ResolveBaseURL("", "openai", envMap(map[string]string{"OPENAI_BASE_URL": "https://env.example.com"}), gitConfigMap(nil)); got != "https://env.example.com" { + t.Errorf("expected openai env var, got %q", got) + } + if got := ResolveBaseURL("", "copilot", envMap(nil), gitConfigMap(map[string]string{"lazycommit.baseUrl": "https://config.example.com"})); got != "https://config.example.com" { + t.Errorf("expected git config value, got %q", got) + } + if got := ResolveBaseURL("", "openai", envMap(nil), gitConfigMap(nil)); got != "" { + t.Errorf("expected empty default, got %q", got) + } +} + +func TestResolveAPIKey(t *testing.T) { + if got := ResolveAPIKey("flag-key", envMap(map[string]string{"OPENAI_API_KEY": "env-key"}), gitConfigMap(nil)); got != "flag-key" { + t.Errorf("flag should win, got %q", got) + } + if got := ResolveAPIKey("", envMap(map[string]string{"OPENAI_API_KEY": "env-key"}), gitConfigMap(map[string]string{"lazycommit.apiKey": "config-key"})); got != "env-key" { + t.Errorf("expected env var to win over git config, got %q", got) + } + if got := ResolveAPIKey("", envMap(nil), gitConfigMap(map[string]string{"lazycommit.apiKey": "config-key"})); got != "config-key" { + t.Errorf("expected git config value, got %q", got) + } + if got := ResolveAPIKey("", envMap(nil), gitConfigMap(nil)); got != "" { + t.Errorf("expected empty default, got %q", got) + } +} + func TestNewProviderCopilot(t *testing.T) { env := envMap(map[string]string{ "HOME": "/home/test", "COPILOT_HOSTS_FILE": "", "GITHUB_API_URL": "https://ghe.example.com/api/v3", }) - gen, err := NewProvider("copilot", "custom-model", "", "", env) + gen, err := NewProvider("copilot", "custom-model", "", "", env, gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -72,7 +118,7 @@ func TestNewProviderCopilot(t *testing.T) { } func TestNewProviderCopilotUsesAPIKeyFlag(t *testing.T) { - gen, err := NewProvider("copilot", "", "", "explicit-key", envMap(nil)) + gen, err := NewProvider("copilot", "", "", "explicit-key", envMap(nil), gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -84,7 +130,7 @@ func TestNewProviderCopilotUsesAPIKeyFlag(t *testing.T) { func TestNewProviderCopilotUsesOpenAIAPIKeyEnvFallback(t *testing.T) { env := envMap(map[string]string{"OPENAI_API_KEY": "env-key"}) - gen, err := NewProvider("copilot", "", "", "", env) + gen, err := NewProvider("copilot", "", "", "", env, gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -94,9 +140,20 @@ func TestNewProviderCopilotUsesOpenAIAPIKeyEnvFallback(t *testing.T) { } } +func TestNewProviderCopilotUsesGitConfigAPIKeyFallback(t *testing.T) { + gen, err := NewProvider("copilot", "", "", "", envMap(nil), gitConfigMap(map[string]string{"lazycommit.apiKey": "config-key"})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cp := gen.(*provider.CopilotProvider) + if cp.APIKey != "config-key" { + t.Errorf("APIKey = %q, want %q", cp.APIKey, "config-key") + } +} + func TestNewProviderCopilotBaseURLFlagWinsOverEnv(t *testing.T) { env := envMap(map[string]string{"GITHUB_API_URL": "https://env.example.com"}) - gen, err := NewProvider("copilot", "", "https://flag.example.com", "", env) + gen, err := NewProvider("copilot", "", "https://flag.example.com", "", env, gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -106,12 +163,23 @@ func TestNewProviderCopilotBaseURLFlagWinsOverEnv(t *testing.T) { } } +func TestNewProviderCopilotUsesGitConfigBaseURLFallback(t *testing.T) { + gen, err := NewProvider("copilot", "", "", "", envMap(nil), gitConfigMap(map[string]string{"lazycommit.baseUrl": "https://config.example.com"})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cp := gen.(*provider.CopilotProvider) + if cp.APIBaseURL != "https://config.example.com" { + t.Errorf("expected git config base URL, got %q", cp.APIBaseURL) + } +} + func TestNewProviderOpenAI(t *testing.T) { env := envMap(map[string]string{ "OPENAI_API_KEY": "sk-test", "OPENAI_BASE_URL": "https://custom.openai.example/v1", }) - gen, err := NewProvider("openai", "", "", "", env) + gen, err := NewProvider("openai", "", "", "", env, gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -129,7 +197,7 @@ func TestNewProviderOpenAI(t *testing.T) { func TestNewProviderOpenAIAPIKeyFlagWinsOverEnv(t *testing.T) { env := envMap(map[string]string{"OPENAI_API_KEY": "sk-env"}) - gen, err := NewProvider("openai", "", "", "sk-flag", env) + gen, err := NewProvider("openai", "", "", "sk-flag", env, gitConfigMap(nil)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -140,7 +208,7 @@ func TestNewProviderOpenAIAPIKeyFlagWinsOverEnv(t *testing.T) { } func TestNewProviderUnknown(t *testing.T) { - if _, err := NewProvider("bogus", "", "", "", envMap(nil)); err == nil { + if _, err := NewProvider("bogus", "", "", "", envMap(nil), gitConfigMap(nil)); err == nil { t.Fatal("expected error for unknown provider") } } diff --git a/internal/app/run.go b/internal/app/run.go index 0b015d2..d5b2438 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -14,7 +14,7 @@ import ( // fields fall back to real implementations. type Deps struct { Git *Git - NewProvider func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) + NewProvider func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) Editor Editor // AppName, Version, Commit, and BuildDate are shown by --version. They @@ -75,15 +75,16 @@ func RunWithDeps(args []string, stdout, stderr io.Writer, getenv GetEnv, deps De if newProvider == nil { newProvider = NewProvider } + getGitConfig := GetGitConfig(git.ConfigGet) if cfg.ListModels { - providerName := ResolveProvider(cfg.Provider, getenv) + providerName := ResolveProvider(cfg.Provider, getenv, getGitConfig) if providerName == "" { fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider , a shortcut flag (--copilot, --apfel, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.") return 1 } - model := ResolveModel(cfg.Model, getenv) - gen, err := newProvider(providerName, model, cfg.BaseURL, cfg.APIKey, getenv) + model := ResolveModel(cfg.Model, getenv, getGitConfig) + gen, err := newProvider(providerName, model, cfg.BaseURL, cfg.APIKey, getenv, getGitConfig) if err != nil { fmt.Fprintf(stderr, "\u274c %s\n", err) return 1 @@ -136,16 +137,16 @@ func RunWithDeps(args []string, stdout, stderr io.Writer, getenv GetEnv, deps De return 1 } - providerName := ResolveProvider(cfg.Provider, getenv) + providerName := ResolveProvider(cfg.Provider, getenv, getGitConfig) if providerName == "" { fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider , a shortcut flag (--copilot, --apfel, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.") return 1 } - model := ResolveModel(cfg.Model, getenv) - promptTemplate := ResolvePrompt(cfg.Prompt, getenv) + model := ResolveModel(cfg.Model, getenv, getGitConfig) + promptTemplate := ResolvePrompt(cfg.Prompt, getenv, getGitConfig) prompt := BuildPrompt(promptTemplate, diff, stat) - gen, err := newProvider(providerName, model, cfg.BaseURL, cfg.APIKey, getenv) + gen, err := newProvider(providerName, model, cfg.BaseURL, cfg.APIKey, getenv, getGitConfig) if err != nil { fmt.Fprintf(stderr, "\u274c %s\n", err) return 1 diff --git a/internal/app/run_test.go b/internal/app/run_test.go index 269e1bd..2ae006d 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -117,7 +117,7 @@ func TestRunVersionDefaults(t *testing.T) { func TestRunListModelsSuccess(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeModelLister{models: []string{"gpt-4o", "gpt-4.1"}}, nil }, } @@ -134,7 +134,7 @@ func TestRunListModelsSuccess(t *testing.T) { func TestRunListModelsEmpty(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeModelLister{models: nil}, nil }, } @@ -150,7 +150,7 @@ func TestRunListModelsEmpty(t *testing.T) { func TestRunListModelsListError(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeModelLister{err: errors.New("models request failed")}, nil }, } @@ -166,7 +166,7 @@ func TestRunListModelsListError(t *testing.T) { func TestRunListModelsUnsupportedProvider(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { // apfel (and any provider that doesn't implement ModelLister). return fakeGenerator{}, nil }, @@ -183,7 +183,7 @@ func TestRunListModelsUnsupportedProvider(t *testing.T) { func TestRunListModelsProviderConstructionError(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return nil, errors.New("boom") }, } @@ -200,7 +200,7 @@ func TestRunListModelsNoProviderSpecified(t *testing.T) { var stdout, stderr bytes.Buffer called := false deps := Deps{ - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { called = true return fakeModelLister{}, nil }, @@ -221,7 +221,7 @@ func TestRunListModelsDoesNotRequireGitRepo(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(false, false), // not a repo - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeModelLister{models: []string{"m1"}}, nil }, } @@ -267,7 +267,7 @@ func TestRunDryRun(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(true, true), - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: "feat: add cool thing"}, nil }, } @@ -290,7 +290,7 @@ func TestRunEmptyGeneratedMessageFallsBackToCommit(t *testing.T) { } deps := Deps{ Git: git, - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: ""}, nil }, } @@ -313,7 +313,7 @@ func TestRunNoEditCommitsDirectly(t *testing.T) { } deps := Deps{ Git: git, - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: "feat: no edit path"}, nil }, } @@ -341,7 +341,7 @@ func TestRunEmptyEditorSkipsReviewLikeNoEdit(t *testing.T) { } deps := Deps{ Git: git, - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: "feat: unset editor"}, nil }, Editor: func(path string) error { @@ -372,7 +372,7 @@ func TestRunWithEditorReview(t *testing.T) { } deps := Deps{ Git: git, - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: "feat: reviewed"}, nil }, Editor: func(path string) error { @@ -393,7 +393,7 @@ func TestRunEditorWipesMessageAborts(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(true, true), - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: "feat: original"}, nil }, Editor: func(path string) error { @@ -413,7 +413,7 @@ func TestRunGeneratorError(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(true, true), - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{err: errors.New("network down")}, nil }, } @@ -430,7 +430,7 @@ func TestRunUnknownProviderError(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(true, true), - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return nil, errors.New("unknown provider") }, } @@ -452,7 +452,7 @@ func TestRunPatchFlagInvokesAddPatch(t *testing.T) { } deps := Deps{ Git: git, - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { return fakeGenerator{msg: "feat: patched"}, nil }, Editor: func(path string) error { return nil }, @@ -478,7 +478,7 @@ func TestRunNoProviderSpecifiedError(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(true, true), - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { t.Fatal("NewProvider should not be called when no provider is specified") return nil, nil }, @@ -496,7 +496,7 @@ func TestRunProviderFromEnvVar(t *testing.T) { var stdout, stderr bytes.Buffer deps := Deps{ Git: newFakeGit(true, true), - NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv) (provider.Generator, error) { + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { if name != "apfel" { t.Errorf("expected provider %q, got %q", "apfel", name) } @@ -511,3 +511,58 @@ func TestRunProviderFromEnvVar(t *testing.T) { t.Errorf("got stdout %q", stdout.String()) } } + +// newFakeGitConfig wraps newFakeGit's Runner, additionally answering +// `git config --get ` from the given map so tests can exercise the +// lazycommit.* git-config resolution path end-to-end. +func newFakeGitConfig(isRepo, hasStaged bool, config map[string]string) *Git { + g := newFakeGit(isRepo, hasStaged) + inner := g.Runner + g.Runner = func(name string, args []string) ([]byte, error) { + if len(args) == 3 && args[0] == "config" && args[1] == "--get" { + if v, ok := config[args[2]]; ok { + return []byte(v), nil + } + return nil, errors.New("exit status 1") + } + return inner(name, args) + } + return g +} + +func TestRunProviderFromGitConfig(t *testing.T) { + var stdout, stderr bytes.Buffer + deps := Deps{ + Git: newFakeGitConfig(true, true, map[string]string{"lazycommit.provider": "apfel"}), + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { + if name != "apfel" { + t.Errorf("expected provider %q, got %q", "apfel", name) + } + return fakeGenerator{msg: "feat: via git config provider"}, nil + }, + } + code := RunWithDeps([]string{"--dry-run"}, &stdout, &stderr, envMap(nil), deps) + if code != 0 { + t.Fatalf("expected exit 0, got %d: stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "feat: via git config provider") { + t.Errorf("got stdout %q", stdout.String()) + } +} + +func TestRunEnvVarWinsOverGitConfigProvider(t *testing.T) { + var stdout, stderr bytes.Buffer + deps := Deps{ + Git: newFakeGitConfig(true, true, map[string]string{"lazycommit.provider": "copilot"}), + NewProvider: func(name, model, baseURL, apiKey string, getenv GetEnv, getGitConfig GetGitConfig) (provider.Generator, error) { + if name != "apfel" { + t.Errorf("expected env var to win, got provider %q", name) + } + return fakeGenerator{msg: "feat: env wins"}, nil + }, + } + code := RunWithDeps([]string{"--dry-run"}, &stdout, &stderr, envMap(map[string]string{"LAZYCOMMIT_PROVIDER": "apfel"}), deps) + if code != 0 { + t.Fatalf("expected exit 0, got %d: stderr=%s", code, stderr.String()) + } +}