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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 36 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <p> Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER)
--model <m> Model name to use (provider-specific default if omitted)
--model <m> Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL)
--base-url <url> Override the API base URL (copilot/openai providers)
--api-key <key> API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY)
--prompt <text> Override the prompt template (or use LAZYCOMMIT_PROMPT)
Expand Down Expand Up @@ -123,8 +127,9 @@ Prompt template:
### Examples

A provider must be specified — either pass `--provider <name>`/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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/app/apfel_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/apfel_other_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
8 changes: 6 additions & 2 deletions internal/app/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p> Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER)
--model <m> Model name to use (provider-specific default if omitted)
--model <m> Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL)
--base-url <url> Override the API base URL (copilot/openai providers)
--api-key <key> API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY)
--prompt <text> Override the prompt template (or use LAZYCOMMIT_PROMPT)
Expand Down
13 changes: 13 additions & 0 deletions internal/app/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions internal/app/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
82 changes: 63 additions & 19 deletions internal/app/provider_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,42 +21,92 @@ const (
// GetEnv abstracts environment variable lookup, primarily for testability.
type GetEnv func(string) string

// GetGitConfig abstracts `git config --get <key>` 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":
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading