fix(upgrade-config): friendly "no such file" + add --global flag
First-run UX fix. `gnoma upgrade-config --dry-run` in a directory with no `.gnoma/config.toml` used to error with: error: read config: open .../.gnoma/config.toml: no such file or directory That's a hard error for what's actually a non-event. The cleanest user experience: tell the user there's no project config to upgrade, hint that they can pass an explicit path or use --global, and exit 0. Changes: 1. `cmd/gnoma/upgrade_config_cmd.go::runUpgradeConfigCommand` now stats the target before calling `gnomacfg.Upgrade`. For the implicit project/global targets, a missing file produces a friendly exit-0 message. An explicit path the user typed is still a hard error (caller asked for that specific file, didn't get it). 2. New `--global` flag, symmetric with `gnoma config set --global`. The user-level config is where zero-spam actually accumulates over time (most users never have a project config) so this is the more useful default target in practice. `--global <path>` is rejected as mutually-exclusive. 3. Rewrote the flag-parsing loop to avoid a Go slice-aliasing bug discovered while writing the tests. The original implementation did `pathArgs = append(args[:i], args[i+1:]...)` inside a `for i, a := range args` loop, which aliases the underlying array and overwrites earlier `a` values on subsequent iterations. With `--global --dry-run` the `--dry-run` overwrote `args[0]`, so the second iteration read `--dry-run` as `a` for both the `--dry-run` and `--global` cases. The new code walks `args` once and accumulates into a fresh `pathArgs` slice, no aliasing. Tests added in upgrade_config_cmd_test.go: - TestRunUpgradeConfig_MissingProjectConfigIsFriendly - TestRunUpgradeConfig_MissingGlobalConfigIsFriendly - TestRunUpgradeConfig_GlobalFlagUpgradesGlobalConfig - TestRunUpgradeConfig_GlobalWithExplicitPathIsError End-to-end check on the user's actual environment: $ gnoma upgrade-config --dry-run /home/.../gnoma/.gnoma/config.toml: no such file, nothing to upgrade hint: pass an explicit path, or use --global for the user-level config exit: 0 $ gnoma upgrade-config --global --dry-run /home/.../.config/gnoma/config.toml: already clean, nothing to do (dry run) exit: 0
This commit is contained in:
@@ -15,31 +15,67 @@ import (
|
|||||||
//
|
//
|
||||||
// Single-file mode only. `--all-projects` is deferred to the
|
// Single-file mode only. `--all-projects` is deferred to the
|
||||||
// project-registry work in the 2026-05-24 config-migration plan.
|
// project-registry work in the 2026-05-24 config-migration plan.
|
||||||
|
//
|
||||||
|
// Target selection:
|
||||||
|
// - `gnoma upgrade-config` (no args) → project config
|
||||||
|
// - `gnoma upgrade-config --global` → global config
|
||||||
|
// - `gnoma upgrade-config <path>` → the given path
|
||||||
|
// - `gnoma upgrade-config --global <path>` → error (mutually exclusive)
|
||||||
|
//
|
||||||
|
// If the default target (project or global config) doesn't exist,
|
||||||
|
// print a friendly "nothing to upgrade" message and exit 0 — not
|
||||||
|
// a hard error. The user can pass an explicit path to upgrade a
|
||||||
|
// different file.
|
||||||
func runUpgradeConfigCommand(args []string) int {
|
func runUpgradeConfigCommand(args []string) int {
|
||||||
|
// Walk args in a single pass, building pathArgs into a fresh
|
||||||
|
// slice. Using args[:i] / args[i+1:] in-place would alias the
|
||||||
|
// underlying array and corrupt subsequent iterations' `a`
|
||||||
|
// reads (a known Go slice footgun). The fresh-slice approach
|
||||||
|
// keeps the parsing correct regardless of flag ordering.
|
||||||
|
var pathArgs []string
|
||||||
dryRun := false
|
dryRun := false
|
||||||
pathArgs := args
|
global := false
|
||||||
for i, a := range args {
|
for _, a := range args {
|
||||||
if a == "--dry-run" {
|
switch a {
|
||||||
|
case "--dry-run":
|
||||||
dryRun = true
|
dryRun = true
|
||||||
pathArgs = append(args[:i], args[i+1:]...)
|
case "--global":
|
||||||
break
|
global = true
|
||||||
|
default:
|
||||||
|
pathArgs = append(pathArgs, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to the project config if no path given — matches
|
// --global and an explicit path are mutually exclusive.
|
||||||
// the convention of `gnoma config set` writing to the project
|
if global && len(pathArgs) > 0 {
|
||||||
// file by default.
|
fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [--global | <path>]")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
target := ""
|
target := ""
|
||||||
switch len(pathArgs) {
|
switch {
|
||||||
case 0:
|
case global:
|
||||||
|
target = gnomacfg.GlobalConfigPath()
|
||||||
|
case len(pathArgs) == 0:
|
||||||
target = gnomacfg.ProjectConfigPath()
|
target = gnomacfg.ProjectConfigPath()
|
||||||
case 1:
|
case len(pathArgs) == 1:
|
||||||
target = pathArgs[0]
|
target = pathArgs[0]
|
||||||
default:
|
default:
|
||||||
fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [path]")
|
fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [--global | <path>]")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Friendly "nothing to upgrade" when the default target
|
||||||
|
// doesn't exist. We only do this for the default targets
|
||||||
|
// (project/global); an explicit path the user typed that
|
||||||
|
// doesn't exist is a real error surfaced by Upgrade() below.
|
||||||
|
if global || len(pathArgs) == 0 {
|
||||||
|
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||||
|
fmt.Printf("%s: no such file, nothing to upgrade\n", target)
|
||||||
|
fmt.Println("hint: pass an explicit path, or use --global for the user-level config")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
return runUpgradeConfigDryRun(target)
|
return runUpgradeConfigDryRun(target)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,3 +140,81 @@ func TestRunUpgradeConfig_AlreadyCleanIsNoOp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunUpgradeConfig_MissingProjectConfigIsFriendly verifies the
|
||||||
|
// user-experience fix for the 2026-06-04 follow-up: when the
|
||||||
|
// project .gnoma/config.toml doesn't exist, print a friendly
|
||||||
|
// "nothing to upgrade" message and exit 0 instead of a hard
|
||||||
|
// "no such file or directory" error. The user can pass an
|
||||||
|
// explicit path or use --global.
|
||||||
|
func TestRunUpgradeConfig_MissingProjectConfigIsFriendly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
projectDir := filepath.Join(dir, "project")
|
||||||
|
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(projectDir); err != nil {
|
||||||
|
t.Fatalf("chdir: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||||
|
|
||||||
|
// No .gnoma/ dir at all — Upgrade() would error.
|
||||||
|
if rc := runUpgradeConfigCommand(nil); rc != 0 {
|
||||||
|
t.Errorf("rc = %d, want 0 for missing project config (friendly exit)", rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunUpgradeConfig_MissingGlobalConfigIsFriendly mirrors
|
||||||
|
// the above for --global. The user-level config not existing
|
||||||
|
// is also "nothing to upgrade", not an error.
|
||||||
|
func TestRunUpgradeConfig_MissingGlobalConfigIsFriendly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
// Don't create the global config dir either.
|
||||||
|
|
||||||
|
if rc := runUpgradeConfigCommand([]string{"--global"}); rc != 0 {
|
||||||
|
t.Errorf("rc = %d, want 0 for missing global config (friendly exit)", rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunUpgradeConfig_GlobalFlagUpgradesGlobalConfig verifies
|
||||||
|
// the --global flag actually points at the global config and
|
||||||
|
// upgrades it.
|
||||||
|
func TestRunUpgradeConfig_GlobalFlagUpgradesGlobalConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
|
||||||
|
// Seed a global config with a default-equivalent field.
|
||||||
|
globalDir := filepath.Join(dir, "gnoma")
|
||||||
|
if err := os.MkdirAll(globalDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
globalPath := filepath.Join(globalDir, "config.toml")
|
||||||
|
if err := os.WriteFile(globalPath, []byte("[provider]\nmax_tokens = 8192\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc := runUpgradeConfigCommand([]string{"--global"}); rc != 0 {
|
||||||
|
t.Errorf("rc = %d, want 0", rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(globalPath)
|
||||||
|
if strings.Contains(string(got), "max_tokens") {
|
||||||
|
t.Errorf("max_tokens at default not dropped from global config, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunUpgradeConfig_GlobalWithExplicitPathIsError verifies
|
||||||
|
// the mutually-exclusive-flag handling: --global and an
|
||||||
|
// explicit path can't both be supplied.
|
||||||
|
func TestRunUpgradeConfig_GlobalWithExplicitPathIsError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
|
||||||
|
if rc := runUpgradeConfigCommand([]string{"--global", "/tmp/somewhere/config.toml"}); rc != 1 {
|
||||||
|
t.Errorf("rc = %d, want 1 for --global + explicit path", rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user