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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v
the agent may read files outside the diff (including untracked secrets)
and the pre-send secret scan covers the diff only. Context and diff-only
runs cache under distinct keys (ADR-0017).
- **`command.default` config key — customize bare `commitbrief`.** Set
it to an argument string (e.g. `--unstaged --cli gemini`) and a bare
`commitbrief` behaves as if you typed those args. Empty/unset keeps the
built-in `commitbrief` == `commitbrief --staged`. Applies **only** to
the truly bare invocation — any explicit flag or subcommand bypasses it.
Expanded git-alias style before argument parsing (ADR-0005). Set via
`config set -- command.default "…"` or by editing `config.yml`.

## [1.2.1]

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,27 @@ cache:
enabled: true
ttl_days: 7
max_size_mb: 0 # 0 = unlimited; >0 evicts oldest entries past the cap
command:
default: "" # args applied to a bare `commitbrief`; empty = `--staged`
```

### Default command (`command.default`)

A bare `commitbrief` reviews staged changes (`commitbrief --staged`). To
change that default, set `command.default` to the argument string you'd
otherwise type:

```yaml
command:
default: --unstaged --cli gemini # now `commitbrief` == `commitbrief --unstaged --cli gemini`
```

It applies **only** to the truly bare invocation. The moment you pass any
flag or subcommand — `commitbrief --staged`, `commitbrief --json`,
`commitbrief dry-run` — the default is bypassed and you get exactly what
you typed. Empty/unset keeps the built-in `--staged`. Tokens are
whitespace-split (no shell quoting).

Review content lives in two files:

- **`COMMITBRIEF.md`** at the repo root — team-shared review rules,
Expand Down
29 changes: 27 additions & 2 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,23 @@ func configFieldGet(cfg *config.Config, path string) (string, error) {
return "", fmt.Errorf("config: unknown field %q in cost (allowed: warn_threshold_usd)", parts[1])
}

case "command":
if len(parts) != 2 {
return "", fmt.Errorf("config: %q must be command.<field>", path)
}
switch parts[1] {
case "default":
return cfg.Command.Default, nil
default:
return "", fmt.Errorf("config: unknown field %q in command (allowed: default)", parts[1])
}

case "version":
// Read-only via get; explicitly rejected by configFieldSet.
return strconv.Itoa(cfg.Version), nil

default:
return "", fmt.Errorf("config: unknown top-level field %q (allowed: provider, providers.*, output.*, cache.*, guard.*, cost.*, version)", parts[0])
return "", fmt.Errorf("config: unknown top-level field %q (allowed: provider, providers.*, output.*, cache.*, guard.*, cost.*, command.*, version)", parts[0])
}
}

Expand Down Expand Up @@ -352,11 +363,25 @@ func configFieldSet(cfg *config.Config, path, value string) error {
}
return nil

case "command":
if len(parts) != 2 {
return fmt.Errorf("config: %q must be command.<field>", path)
}
switch parts[1] {
case "default":
// Free-form argument string applied to a bare `commitbrief`.
// Stored verbatim; tokenization happens at invocation time.
cfg.Command.Default = value
default:
return fmt.Errorf("config: unknown field %q in command (allowed: default)", parts[1])
}
return nil

case "version":
return errors.New("config: version is managed by migrations and cannot be set manually")

default:
return fmt.Errorf("config: unknown top-level field %q (allowed: provider, providers.*, output.*, cache.*, guard.*, cost.*)", parts[0])
return fmt.Errorf("config: unknown top-level field %q (allowed: provider, providers.*, output.*, cache.*, guard.*, cost.*, command.*)", parts[0])
}
}

Expand Down
22 changes: 22 additions & 0 deletions internal/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,28 @@ func TestConfigGetMaxSizeMBDefaultsToZero(t *testing.T) {
}
}

func TestConfigCommandDefaultRoundTrips(t *testing.T) {
e := newCLIEnv(t)
// Default is empty until set.
if err := e.run("config", "get", "command.default"); err != nil {
t.Fatalf("config get command.default: %v", err)
}
if got := strings.TrimSpace(e.out.String()); got != "" {
t.Errorf("command.default default = %q, want empty", got)
}

// `--` stops cobra flag parsing so the value (which starts with `--`)
// reaches `config set` as a positional rather than being mistaken for
// flags. Real shell usage quotes it: `config set command.default "--unstaged ..."`.
if err := e.run("config", "set", "--", "command.default", "--unstaged --cli gemini"); err != nil {
t.Fatalf("config set command.default: %v", err)
}
cfg := loadCfg(t, e.homeDir)
if cfg.Command.Default != "--unstaged --cli gemini" {
t.Errorf("command.default = %q, want %q", cfg.Command.Default, "--unstaged --cli gemini")
}
}

func TestConfigSetMaxSizeMBRoundTrips(t *testing.T) {
e := newCLIEnv(t)
if err := e.run("config", "set", "cache.max_size_mb", "200"); err != nil {
Expand Down
29 changes: 19 additions & 10 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,7 @@ func resolveContext(requireRepo bool) (*appContext, error) {
}
}

globalPath := os.Getenv("COMMITBRIEF_CONFIG")
if globalPath == "" {
if home, err := os.UserHomeDir(); err == nil {
globalPath = home + "/.commitbrief/config.yml"
}
}
repoPath := ""
if repoRoot != "" {
repoPath = repoRoot + "/.commitbrief/config.yml"
}
globalPath, repoPath := configFilePaths(repoRoot)

cfg, err := config.Load(globalPath, repoPath)
if err != nil {
Expand Down Expand Up @@ -125,3 +116,21 @@ func userHome() string {
}
return home
}

// configFilePaths resolves the global and repo config.yml paths used by
// config.Load. globalPath honors $COMMITBRIEF_CONFIG, else
// ~/.commitbrief/config.yml; repoPath is <repoRoot>/.commitbrief/config.yml
// when repoRoot is non-empty (else ""). Shared by resolveContext and the
// pre-parse default-command expansion in Execute so the two never drift.
func configFilePaths(repoRoot string) (globalPath, repoPath string) {
globalPath = os.Getenv("COMMITBRIEF_CONFIG")
if globalPath == "" {
if home, err := os.UserHomeDir(); err == nil {
globalPath = home + "/.commitbrief/config.yml"
}
}
if repoRoot != "" {
repoPath = repoRoot + "/.commitbrief/config.yml"
}
return globalPath, repoPath
}
73 changes: 73 additions & 0 deletions internal/cli/default_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package cli

import (
"os"
"path/filepath"
"reflect"
"testing"
)

func TestExpandDefault(t *testing.T) {
cases := []struct {
name string
rawArgs []string
def string
wantArgs []string
wantApply bool
}{
{"bare + default expands", nil, "--unstaged --cli gemini",
[]string{"--unstaged", "--cli", "gemini"}, true},
{"bare + single token", []string{}, "--staged",
[]string{"--staged"}, true},
{"bare + empty default → unchanged", nil, "", nil, false},
{"bare + whitespace default → unchanged", nil, " \t ", nil, false},
{"explicit flag bypasses default", []string{"--json"}, "--unstaged",
[]string{"--json"}, false},
{"explicit subcommand bypasses default", []string{"dry-run"}, "--unstaged",
[]string{"dry-run"}, false},
{"extra whitespace in default is collapsed", nil, " --unstaged --verbose ",
[]string{"--unstaged", "--verbose"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, apply := expandDefault(tc.rawArgs, tc.def)
if apply != tc.wantApply {
t.Errorf("apply = %v, want %v", apply, tc.wantApply)
}
if apply && !reflect.DeepEqual(got, tc.wantArgs) {
t.Errorf("args = %v, want %v", got, tc.wantArgs)
}
if !apply && !reflect.DeepEqual(got, tc.rawArgs) {
t.Errorf("non-apply must return rawArgs unchanged; got %v, want %v", got, tc.rawArgs)
}
})
}
}

// TestLoadDefaultCommandFromConfig wires loadDefaultCommand against a real
// config file via $COMMITBRIEF_CONFIG, confirming the command.default
// field is read end-to-end (load + field access).
func TestLoadDefaultCommandFromConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yml")
if err := os.WriteFile(cfgPath, []byte("command:\n default: \"--unstaged --cli gemini\"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("COMMITBRIEF_CONFIG", cfgPath)

if got := loadDefaultCommand(); got != "--unstaged --cli gemini" {
t.Errorf("loadDefaultCommand() = %q, want %q", got, "--unstaged --cli gemini")
}
}

// TestLoadDefaultCommandEmptyWhenUnset: a config without command.default
// (here, a missing file) yields "" so the bare invocation keeps the
// built-in --staged behavior.
func TestLoadDefaultCommandEmptyWhenUnset(t *testing.T) {
t.Setenv("COMMITBRIEF_CONFIG", filepath.Join(t.TempDir(), "does-not-exist.yml"))
if got := loadDefaultCommand(); got != "" {
t.Errorf("loadDefaultCommand() with no config = %q, want empty", got)
}
}
47 changes: 47 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"

"github.com/CommitBrief/commitbrief/internal/config"
"github.com/CommitBrief/commitbrief/internal/git"
"github.com/CommitBrief/commitbrief/internal/i18n"
"github.com/CommitBrief/commitbrief/internal/logo"
"github.com/CommitBrief/commitbrief/internal/ui"
Expand Down Expand Up @@ -163,6 +166,15 @@ func Execute() {
defer cancel()

root := newRootCmd()
// Default-command expansion: a truly bare `commitbrief` (no args) is
// rewritten to the configured `command.default` token list, so a user
// can make `commitbrief` mean e.g. `--unstaged --cli gemini`. Any
// explicit flag or subcommand bypasses this — the user is being
// explicit. Empty/unset default leaves the built-in `--staged` behavior
// untouched. See config.CommandConfig.
if expanded, ok := expandDefault(os.Args[1:], loadDefaultCommand()); ok {
root.SetArgs(expanded)
}
if err := root.ExecuteContext(ctx); err != nil {
// Best-effort error-prefix translation. appContext isn't built at
// this layer (cobra surfaces errors from RunE before/instead of
Expand All @@ -174,6 +186,41 @@ func Execute() {
}
}

// expandDefault decides the args cobra should run for a bare invocation.
// When rawArgs is empty AND defaultCmd has at least one token, it returns
// the whitespace-split default and true. Otherwise it returns rawArgs
// unchanged and false (so any explicit flag/subcommand, or an empty
// default, leaves behavior exactly as before). Kept pure for testing —
// the config load lives in loadDefaultCommand.
func expandDefault(rawArgs []string, defaultCmd string) ([]string, bool) {
if len(rawArgs) > 0 {
return rawArgs, false
}
tokens := strings.Fields(defaultCmd)
if len(tokens) == 0 {
return rawArgs, false
}
return tokens, true
}

// loadDefaultCommand best-effort reads config.command.default for the
// pre-parse expansion. It loads the same global+repo config layers as
// resolveContext but swallows errors and returns "" — a malformed config
// must not break a bare `commitbrief`; resolveContext will surface the
// real error once the command actually runs.
func loadDefaultCommand() string {
repoRoot := ""
if root, err := git.FindRepo(""); err == nil {
repoRoot = root
}
globalPath, repoPath := configFilePaths(repoRoot)
cfg, err := config.Load(globalPath, repoPath)
if err != nil {
return ""
}
return cfg.Command.Default
}

// pickErrorCatalog returns the i18n catalog used for the top-level "Error:"
// prefix when a command fails before appContext is resolved.
func pickErrorCatalog() *i18n.Catalog {
Expand Down
13 changes: 13 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ type Config struct {
Cache CacheConfig `yaml:"cache"`
Guard GuardConfig `yaml:"guard"`
Cost CostConfig `yaml:"cost"`
Command CommandConfig `yaml:"command"`
}

// CommandConfig customizes the bare `commitbrief` invocation. Default is
// the argument string applied when `commitbrief` is run with NO arguments
// at all — e.g. "--unstaged --cli gemini" makes a bare `commitbrief`
// behave like `commitbrief --unstaged --cli gemini`. Empty (the default)
// preserves the built-in behavior, `commitbrief` == `commitbrief --staged`.
// It only fires for the truly bare invocation; passing any flag or
// subcommand bypasses it entirely (the user is being explicit). Tokens are
// whitespace-split; shell quoting is not interpreted.
type CommandConfig struct {
Default string `yaml:"default"`
}

// CostConfig controls the pre-send cost preflight added in v0.8.0.
Expand Down
Loading