From 0208bc86b681b95eaa5cee086c6212b908d60408 Mon Sep 17 00:00:00 2001 From: Raphael Gruber Date: Sun, 5 Apr 2026 10:00:31 +0200 Subject: [PATCH] feat: complete wt config show with all fields and source annotations Implements the full config show command displaying every configuration field grouped by section with (global)/(local) annotations. Fixes GetRemoteURLs to use stored URLs instead of insteadOf-rewritten ones. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wt/config_cmd.go | 395 +++++++++++++++++++++++++++++++------------ internal/git/repo.go | 27 ++- 2 files changed, 305 insertions(+), 117 deletions(-) diff --git a/cmd/wt/config_cmd.go b/cmd/wt/config_cmd.go index 6547c20..e870d68 100644 --- a/cmd/wt/config_cmd.go +++ b/cmd/wt/config_cmd.go @@ -1,16 +1,19 @@ package main import ( + "context" "encoding/json" "fmt" + "io" "os" "path/filepath" + "sort" + "strings" "github.com/spf13/cobra" "github.com/raphi011/wt/internal/config" "github.com/raphi011/wt/internal/git" - "github.com/raphi011/wt/internal/log" "github.com/raphi011/wt/internal/output" "github.com/raphi011/wt/internal/registry" ) @@ -148,6 +151,43 @@ func initLocalConfig(cmd *cobra.Command, force, stdout bool) error { return nil } +// resolveConfigWithSources returns the effective config along with the raw local +// config and its path (for source annotations). If repoName is set, looks up the +// repo in the registry. Otherwise tries the current working directory. +// Returns global config if no repo context is found (not inside a git repo and +// --repo not specified). Returns an error if the local config exists but cannot +// be loaded (bad TOML or permissions). +func resolveConfigWithSources(ctx context.Context, repoName string) (*config.Config, *config.LocalConfig, string, error) { + cfg := config.FromContext(ctx) + + var repoPath string + if repoName != "" { + reg, err := registry.Load(cfg.RegistryPath) + if err != nil { + return nil, nil, "", fmt.Errorf("load registry: %w", err) + } + repo, err := reg.FindByName(repoName) + if err != nil { + return nil, nil, "", fmt.Errorf("repository %q: %w", repoName, err) + } + repoPath = repo.Path + } else { + workDir := config.WorkDirFromContext(ctx) + repoPath = git.GetCurrentRepoMainPathFrom(ctx, workDir) + } + + if repoPath == "" { + return cfg, nil, "", nil + } + + localConfigPath := filepath.Join(repoPath, config.LocalConfigFileName) + local, err := config.LoadLocal(repoPath) + if err != nil { + return nil, nil, "", fmt.Errorf("local config at %s: %w", localConfigPath, err) + } + return config.MergeLocal(cfg, local), local, localConfigPath, nil +} + func newConfigShowCmd() *cobra.Command { var ( jsonOutput bool @@ -167,44 +207,11 @@ annotations (global vs local). Otherwise shows global config only.`, wt config show --json # Output as JSON`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - cfg := config.FromContext(ctx) - l := log.FromContext(ctx) out := output.FromContext(ctx) - // Determine if we should show merged config - var repoPath string - var localConfigPath string - - if repoName != "" { - // --repo flag: look up in registry - reg, err := registry.Load(cfg.RegistryPath) - if err != nil { - return fmt.Errorf("load registry: %w", err) - } - repo, err := reg.FindByName(repoName) - if err != nil { - return fmt.Errorf("repository %q not found", repoName) - } - repoPath = repo.Path - } else { - // Try current repo - workDir := config.WorkDirFromContext(ctx) - repoPath = git.GetCurrentRepoMainPathFrom(ctx, workDir) - } - - // Load local config once and derive effective config from it - var local *config.LocalConfig - var effCfg *config.Config - if repoPath != "" { - localConfigPath = filepath.Join(repoPath, config.LocalConfigFileName) - var err error - local, err = config.LoadLocal(repoPath) - if err != nil { - l.Printf("Warning: failed to load local config: %v (using global config)\n", err) - } - effCfg = config.MergeLocal(cfg, local) - } else { - effCfg = cfg + effCfg, local, localPath, err := resolveConfigWithSources(ctx, repoName) + if err != nil { + return err } if jsonOutput { @@ -213,41 +220,11 @@ annotations (global vs local). Otherwise shows global config only.`, return enc.Encode(effCfg) } - fmt.Printf("Global config: ~/.wt/config.toml\n") - if localConfigPath != "" { - if local != nil { - fmt.Printf("Local config: %s\n", localConfigPath) - } else { - fmt.Printf("Local config: (none)\n") - } + globalCfgPath, err := effCfg.GetWtDir() + if err != nil { + return fmt.Errorf("resolve config directory: %w", err) } - fmt.Println() - - // Helper to annotate source - source := func(isLocal bool) string { - if isLocal { - return " (local)" - } - return "" - } - - fmt.Printf("checkout.worktree_format: %s%s\n", effCfg.Checkout.WorktreeFormat, source(local != nil && local.Checkout.WorktreeFormat != "")) - fmt.Printf("checkout.base_ref: %s%s\n", effCfg.Checkout.BaseRef, source(local != nil && local.Checkout.BaseRef != "")) - fmt.Printf("checkout.auto_fetch: %v%s\n", effCfg.Checkout.AutoFetch, source(local != nil && local.Checkout.AutoFetch != nil)) - fmt.Printf("checkout.set_upstream: %v%s\n", effCfg.Checkout.ShouldSetUpstream(), source(local != nil && local.Checkout.SetUpstream != nil)) - fmt.Printf("default_sort: %s\n", effCfg.DefaultSort) - fmt.Printf("forge.default: %s%s\n", effCfg.Forge.Default, source(local != nil && local.Forge.Default != "")) - fmt.Printf("merge.strategy: %s%s\n", effCfg.Merge.Strategy, source(local != nil && local.Merge.Strategy != "")) - fmt.Printf("prune.delete_local_branches: %v%s\n", effCfg.Prune.DeleteLocalBranches, source(local != nil && local.Prune.DeleteLocalBranches != nil)) - fmt.Printf("hooks: %d configured\n", len(effCfg.Hooks.Hooks)) - if len(effCfg.Preserve.Patterns) > 0 { - fmt.Printf("preserve.patterns: %v\n", effCfg.Preserve.Patterns) - } - if len(effCfg.Preserve.Exclude) > 0 { - fmt.Printf("preserve.exclude: %v\n", effCfg.Preserve.Exclude) - } - - return nil + return renderConfigText(out.Writer(), effCfg, local, filepath.Join(globalCfgPath, "config.toml"), localPath) }, } @@ -258,6 +235,231 @@ annotations (global vs local). Otherwise shows global config only.`, return cmd } +// renderConfigText writes the effective config to w, grouped by section. +// Source annotations are only shown for non-default values: "(local)" when +// overridden by .wt.toml, or "(env: VAR)" when set via environment variable. +// Annotations within each section are vertically aligned. +func renderConfigText(w io.Writer, cfg *config.Config, local *config.LocalConfig, globalCfgPath, localPath string) error { + type kv struct { + key string + val string + ann string + } + + // fprint/fprintln accumulate the first write error so all errors are checked once. + var writeErr error + fprint := func(format string, args ...any) { + if writeErr == nil { + _, writeErr = fmt.Fprintf(w, format, args...) + } + } + fprintln := func(args ...any) { + if writeErr == nil { + _, writeErr = fmt.Fprintln(w, args...) + } + } + + // printAlignedLines renders key=value lines with aligned annotations. + printAlignedLines := func(lines []kv) { + maxKey, maxVal := 0, 0 + for _, l := range lines { + if len(l.key) > maxKey { + maxKey = len(l.key) + } + if len(l.val) > maxVal { + maxVal = len(l.val) + } + } + for _, l := range lines { + fprint(" %-*s = %-*s %s\n", maxKey, l.key, maxVal, l.val, l.ann) + } + } + + // printSection renders a header and key=value lines with aligned annotations. + printSection := func(header string, lines []kv) { + fprint("%s\n", header) + printAlignedLines(lines) + fprintln() + } + + // withDefault returns dflt when val is empty, otherwise val. Use to fill in + // the known effective default for fields that are not applied by Load(). + withDefault := func(val, dflt string) string { + if val == "" { + return dflt + } + return val + } + + // src returns "(local)" if overridden, else "(global)". Use for bool/int fields + // where the zero value is indistinguishable from an explicit global setting. + src := func(isLocalOverride bool) string { + if isLocalOverride { + return "(local)" + } + return "(global)" + } + // srcStr returns "(local)" if overridden locally, "(default)" if the value is + // the empty string (not explicitly configured), or "(global)" otherwise. + srcStr := func(val string, isLocalOverride bool) string { + if isLocalOverride { + return "(local)" + } + if val == "" { + return "(default)" + } + return "(global)" + } + // srcEnvStr returns "(env: VAR)" if the env var is set, "(default)" if the + // value is empty, or "(global)" otherwise. + srcEnvStr := func(val, envVar string) string { + if os.Getenv(envVar) != "" { + return fmt.Sprintf("(env: %s)", envVar) + } + if val == "" { + return "(default)" + } + return "(global)" + } + + // Header + fprint("Global config: %s\n", globalCfgPath) + if localPath != "" { + if local != nil { + fprint("Local config: %s\n", localPath) + } else { + fprint("Local config: (none)\n") + } + } + fprintln() + + // [checkout] + printSection("[checkout]", []kv{ + {"worktree_format", cfg.Checkout.WorktreeFormat, srcStr(cfg.Checkout.WorktreeFormat, local != nil && local.Checkout.WorktreeFormat != "")}, + {"base_ref", withDefault(cfg.Checkout.BaseRef, "remote"), srcStr(cfg.Checkout.BaseRef, local != nil && local.Checkout.BaseRef != "")}, + {"auto_fetch", fmt.Sprintf("%v", cfg.Checkout.AutoFetch), src(local != nil && local.Checkout.AutoFetch != nil)}, + {"set_upstream", fmt.Sprintf("%v", cfg.Checkout.ShouldSetUpstream()), src(local != nil && local.Checkout.SetUpstream != nil)}, + }) + + // [clone] + printSection("[clone]", []kv{ + {"mode", cfg.Clone.Mode, srcStr(cfg.Clone.Mode, local != nil && local.Clone.Mode != "")}, + }) + + // [forge] — uses printAlignedLines to share alignment logic, then appends rules. + fprint("[forge]\n") + printAlignedLines([]kv{ + {"default", cfg.Forge.Default, srcStr(cfg.Forge.Default, local != nil && local.Forge.Default != "")}, + {"default_org", cfg.Forge.DefaultOrg, srcStr(cfg.Forge.DefaultOrg, false)}, + }) + if len(cfg.Forge.Rules) > 0 { + fprint(" rules:\n") + for i, rule := range cfg.Forge.Rules { + parts := []string{ + fmt.Sprintf("pattern=%s", rule.Pattern), + fmt.Sprintf("type=%s", rule.Type), + } + if rule.User != "" { + parts = append(parts, fmt.Sprintf("user=%s", rule.User)) + } + fprint(" [%d] %s\n", i, strings.Join(parts, " ")) + } + } else { + fprint(" rules: (none)\n") + } + fprintln() + + // [merge] + printSection("[merge]", []kv{ + {"strategy", withDefault(cfg.Merge.Strategy, "squash"), srcStr(cfg.Merge.Strategy, local != nil && local.Merge.Strategy != "")}, + }) + + // [prune] + printSection("[prune]", []kv{ + {"stale_days", fmt.Sprintf("%d", cfg.Prune.StaleDays), "(global)"}, + {"delete_local_branches", fmt.Sprintf("%v", cfg.Prune.DeleteLocalBranches), src(local != nil && local.Prune.DeleteLocalBranches != nil)}, + }) + + // [preserve] + patternAnn := "(global)" + if local != nil && len(local.Preserve.Patterns) > 0 { + patternAnn = "(local)" + } + excludeAnn := "(global)" + if local != nil && len(local.Preserve.Exclude) > 0 { + excludeAnn = "(local)" + } + printSection("[preserve]", []kv{ + {"patterns", "[" + strings.Join(cfg.Preserve.Patterns, ", ") + "]", patternAnn}, + {"exclude", "[" + strings.Join(cfg.Preserve.Exclude, ", ") + "]", excludeAnn}, + }) + + // [hooks] + fprint("[hooks]\n") + if len(cfg.Hooks.Hooks) == 0 { + fprint(" (none configured)\n") + } else { + fprint(" %d configured (run 'wt config hooks' for details)\n", len(cfg.Hooks.Hooks)) + } + fprintln() + + // [theme] + themeLines := []kv{ + {"name", withDefault(cfg.Theme.Name, "default"), srcEnvStr(cfg.Theme.Name, "WT_THEME")}, + {"mode", withDefault(cfg.Theme.Mode, "auto"), srcEnvStr(cfg.Theme.Mode, "WT_THEME_MODE")}, + {"nerdfont", fmt.Sprintf("%v", cfg.Theme.Nerdfont), "(global)"}, + } + if cfg.Theme.Primary != "" { + themeLines = append(themeLines, kv{"primary", cfg.Theme.Primary, "(global)"}) + } + if cfg.Theme.Accent != "" { + themeLines = append(themeLines, kv{"accent", cfg.Theme.Accent, "(global)"}) + } + if cfg.Theme.Success != "" { + themeLines = append(themeLines, kv{"success", cfg.Theme.Success, "(global)"}) + } + if cfg.Theme.Error != "" { + themeLines = append(themeLines, kv{"error", cfg.Theme.Error, "(global)"}) + } + if cfg.Theme.Muted != "" { + themeLines = append(themeLines, kv{"muted", cfg.Theme.Muted, "(global)"}) + } + if cfg.Theme.Normal != "" { + themeLines = append(themeLines, kv{"normal", cfg.Theme.Normal, "(global)"}) + } + if cfg.Theme.Info != "" { + themeLines = append(themeLines, kv{"info", cfg.Theme.Info, "(global)"}) + } + if cfg.Theme.Warning != "" { + themeLines = append(themeLines, kv{"warning", cfg.Theme.Warning, "(global)"}) + } + printSection("[theme]", themeLines) + + // [general] + printSection("[general]", []kv{ + {"default_sort", withDefault(cfg.DefaultSort, "date"), srcStr(cfg.DefaultSort, false)}, + {"default_labels", "[" + strings.Join(cfg.DefaultLabels, ", ") + "]", "(global)"}, + }) + + // [hosts] + fprint("[hosts]\n") + if len(cfg.Hosts) == 0 { + fprint(" (none)\n") + } else { + keys := make([]string, 0, len(cfg.Hosts)) + for k := range cfg.Hosts { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fprint(" %s = %s\n", k, cfg.Hosts[k]) + } + } + fprintln() + + return writeErr +} + func newConfigHooksCmd() *cobra.Command { var ( jsonOutput bool @@ -274,38 +476,11 @@ When inside a repo (or with --repo), shows merged hooks with source annotations. RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := config.FromContext(ctx) - l := log.FromContext(ctx) out := output.FromContext(ctx) - // Determine repo context - var repoPath string - if repoName != "" { - reg, err := registry.Load(cfg.RegistryPath) - if err != nil { - return fmt.Errorf("load registry: %w", err) - } - repo, err := reg.FindByName(repoName) - if err != nil { - return fmt.Errorf("repository %q not found", repoName) - } - repoPath = repo.Path - } else { - workDir := config.WorkDirFromContext(ctx) - repoPath = git.GetCurrentRepoMainPathFrom(ctx, workDir) - } - - // Load local config once and derive effective config from it - var effCfg *config.Config - var local *config.LocalConfig - if repoPath != "" { - var err error - local, err = config.LoadLocal(repoPath) - if err != nil { - l.Printf("Warning: failed to load local config: %v (using global config)\n", err) - } - effCfg = config.MergeLocal(cfg, local) - } else { - effCfg = cfg + effCfg, local, _, err := resolveConfigWithSources(ctx, repoName) + if err != nil { + return err } if jsonOutput { @@ -315,7 +490,7 @@ When inside a repo (or with --repo), shows merged hooks with source annotations. } if len(effCfg.Hooks.Hooks) == 0 { - fmt.Println("No hooks configured") + fmt.Fprintln(out.Writer(), "No hooks configured") return nil } @@ -323,26 +498,26 @@ When inside a repo (or with --repo), shows merged hooks with source annotations. for name, hook := range effCfg.Hooks.Hooks { // Determine source - src := "global" + hookSrc := "global" if local != nil { if _, inLocal := local.Hooks.Hooks[name]; inLocal { if _, inGlobal := globalHooks[name]; inGlobal { - src = "local (override)" + hookSrc = "local (override)" } else { - src = "local" + hookSrc = "local" } } } - fmt.Printf("%s: [%s]\n", name, src) - fmt.Printf(" command: %s\n", hook.Command) + fmt.Fprintf(out.Writer(), "%s: [%s]\n", name, hookSrc) + fmt.Fprintf(out.Writer(), " command: %s\n", hook.Command) if hook.Description != "" { - fmt.Printf(" description: %s\n", hook.Description) + fmt.Fprintf(out.Writer(), " description: %s\n", hook.Description) } if len(hook.On) > 0 { - fmt.Printf(" on: %v\n", hook.On) + fmt.Fprintf(out.Writer(), " on: %v\n", hook.On) } - fmt.Println() + fmt.Fprintln(out.Writer()) } return nil diff --git a/internal/git/repo.go b/internal/git/repo.go index 4e5a197..94aed1b 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -269,12 +269,22 @@ func GetOriginURL(ctx context.Context, repoPath string) (string, error) { return strings.TrimSpace(string(output)), nil } -// GetRemoteURLs returns a map of remote name → fetch URL for a repository. +// GetRemoteURLs returns a map of remote name → configured URL for a repository. // Returns an empty (non-nil) map if the repository has no remotes. -// Uses a single `git remote -v` call for efficiency. +// Uses `git config --get-regexp` to read stored URLs without applying insteadOf rewrites. func GetRemoteURLs(ctx context.Context, repoPath string) (map[string]string, error) { - output, err := outputGit(ctx, repoPath, "remote", "-v") + output, err := outputGit(ctx, repoPath, "config", "--get-regexp", `remote\..*\.url`) if err != nil { + // git config returns exit code 1 when no keys match — treat as no remotes. + // Any other error (e.g. invalid repo path) will also land here; we distinguish + // by checking whether output is empty. + if len(output) == 0 { + // Verify the repo path is valid by running a cheap git command. + if _, verr := outputGit(ctx, repoPath, "rev-parse", "--git-dir"); verr != nil { + return nil, fmt.Errorf("failed to list remotes: %v", verr) + } + return make(map[string]string), nil + } return nil, fmt.Errorf("failed to list remotes: %v", err) } @@ -285,12 +295,15 @@ func GetRemoteURLs(ctx context.Context, repoPath string) (map[string]string, err remotes := make(map[string]string) for line := range strings.SplitSeq(trimmed, "\n") { - if !strings.HasSuffix(line, "(fetch)") { + // Format: remote..url + key, url, ok := strings.Cut(line, " ") + if !ok { continue } - fields := strings.Fields(line) - if len(fields) >= 2 { - remotes[fields[0]] = fields[1] + key = strings.TrimPrefix(key, "remote.") + key = strings.TrimSuffix(key, ".url") + if key != "" { + remotes[key] = url } } return remotes, nil