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())
+ }
+}