From f27324016cb7f137447c236399e6dcb19c04525b Mon Sep 17 00:00:00 2001 From: Bear Date: Thu, 8 Oct 2026 11:49:05 -0600 Subject: [PATCH] feat: support variables and expansion in config file paths Add a top-level vars map to the config file. Paths may reference $NAME or ${NAME}, resolved from vars first and then the environment, with ~ expansion and cycle detection. The config is never rewritten with expanded values. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 18 +++++++++ cmd/config.go | 94 +++++++++++++++++++++++++++++++++++++------ cmd/prune_archived.go | 7 +++- cmd/root.go | 5 ++- 4 files changed, 109 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5f0f21b..fe5a503 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,24 @@ Then simply run: github-pokemon ``` +#### Variables + +Define reusable values under `vars` and reference them in `path` with `$NAME` or `${NAME}`: + +```yaml +vars: + WORKSPACE: /my/path/here + OTHERSPACE: /testing +orgs: + - org: "my-org" + path: $WORKSPACE/$OTHERSPACE # -> /my/path/here/testing +``` + +- Variables can reference other variables, and fall back to environment variables (e.g. `$HOME`) if not defined under `vars`. +- A leading `~` expands to your home directory, and the result is cleaned (duplicate slashes removed). +- Undefined variables and reference cycles are reported as errors. +- Variables only apply to `path`; the file is never rewritten with expanded values. + All orgs will be processed sequentially. You can also point to a custom config file: ```bash diff --git a/cmd/config.go b/cmd/config.go index 8f1fc46..2f58eb7 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -18,12 +18,13 @@ type OrgConfig struct { // Config represents the top-level configuration file structure. type Config struct { - Orgs []OrgConfig `yaml:"orgs"` - Parallel int `yaml:"parallel,omitempty"` - SkipUpdate bool `yaml:"skip_update,omitempty"` - Verbose bool `yaml:"verbose,omitempty"` - IncludeArchived bool `yaml:"include_archived,omitempty"` - NoColor bool `yaml:"no_color,omitempty"` + Vars map[string]string `yaml:"vars,omitempty"` + Orgs []OrgConfig `yaml:"orgs"` + Parallel int `yaml:"parallel,omitempty"` + SkipUpdate bool `yaml:"skip_update,omitempty"` + Verbose bool `yaml:"verbose,omitempty"` + IncludeArchived bool `yaml:"include_archived,omitempty"` + NoColor bool `yaml:"no_color,omitempty"` } // defaultConfigPath returns ~/.config/github-pokemon/config.yaml. @@ -54,13 +55,19 @@ func loadConfig(path string) (Config, error) { return Config{}, fmt.Errorf("config file %s has no orgs defined", path) } - for i, entry := range cfg.Orgs { + for i := range cfg.Orgs { + entry := &cfg.Orgs[i] if entry.Org == "" { return Config{}, fmt.Errorf("config entry %d: \"org\" is required", i+1) } if entry.Path == "" { return Config{}, fmt.Errorf("config entry %d: \"path\" is required", i+1) } + expanded, err := expandPath(cfg.Vars, entry.Path) + if err != nil { + return Config{}, fmt.Errorf("config entry %d (%s): %w", i+1, entry.Org, err) + } + entry.Path = expanded } return cfg, nil @@ -102,24 +109,87 @@ func saveConfig(path string, cfg Config) error { return nil } +// expandVar resolves a single variable name. Names defined in vars take +// precedence over environment variables. Variables may reference other +// variables; seen tracks the chain to detect cycles. +func expandVar(vars map[string]string, name string, seen []string) (string, error) { + for _, s := range seen { + if s == name { + return "", fmt.Errorf("variable cycle: %s -> %s", strings.Join(seen, " -> "), name) + } + } + + raw, ok := vars[name] + if !ok { + if env, found := os.LookupEnv(name); found { + return env, nil + } + return "", fmt.Errorf("undefined variable %q", name) + } + + return expandString(vars, raw, append(seen, name)) +} + +func expandString(vars map[string]string, in string, seen []string) (string, error) { + var firstErr error + out := os.Expand(in, func(name string) string { + val, err := expandVar(vars, name, seen) + if err != nil && firstErr == nil { + firstErr = err + } + return val + }) + return out, firstErr +} + +// expandPath expands $VAR / ${VAR} references (config vars first, then +// environment variables) and a leading ~ in a path, then cleans the result. +func expandPath(vars map[string]string, path string) (string, error) { + expanded, err := expandString(vars, path, nil) + if err != nil { + return "", fmt.Errorf("expanding path %q: %w", path, err) + } + + if expanded == "~" || strings.HasPrefix(expanded, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("determining home directory: %w", err) + } + expanded = home + expanded[1:] + } + + return filepath.Clean(expanded), nil +} + // configHasOrg returns true if the config already contains the given org/path pair. +// Config paths are compared after variable expansion. func configHasOrg(cfg Config, org, path string) bool { for _, entry := range cfg.Orgs { - if entry.Org == org && entry.Path == path { + entryPath, err := expandPath(cfg.Vars, entry.Path) + if err != nil { + entryPath = entry.Path + } + if entry.Org == org && entryPath == path { return true } } return false } -// configLookupOrg returns the first matching OrgConfig for the given org name, or false if not found. -func configLookupOrg(cfg Config, org string) (OrgConfig, bool) { +// configLookupOrg returns the first matching OrgConfig for the given org name, with its +// path expanded, or false if not found. +func configLookupOrg(cfg Config, org string) (OrgConfig, bool, error) { for _, entry := range cfg.Orgs { if entry.Org == org { - return entry, true + expanded, err := expandPath(cfg.Vars, entry.Path) + if err != nil { + return OrgConfig{}, true, err + } + entry.Path = expanded + return entry, true, nil } } - return OrgConfig{}, false + return OrgConfig{}, false, nil } // promptToSaveConfig asks the user if they want to save the org/path to the config file. diff --git a/cmd/prune_archived.go b/cmd/prune_archived.go index 8072c79..7e54108 100644 --- a/cmd/prune_archived.go +++ b/cmd/prune_archived.go @@ -61,7 +61,10 @@ func runPruneRoot(cmd *cobra.Command) error { if err != nil { return fmt.Errorf("--path is required: could not load config: %w", err) } - entry, found := configLookupOrg(cfg, pruneOrg) + entry, found, err := configLookupOrg(cfg, pruneOrg) + if err != nil { + return err + } if !found { return fmt.Errorf("org %q not found in config file %s; provide --path explicitly", pruneOrg, cfgFile) } @@ -188,4 +191,4 @@ func runPruneArchived(ctx context.Context, org string, path string) error { } return nil -} \ No newline at end of file +} diff --git a/cmd/root.go b/cmd/root.go index 69c9a18..454ca57 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -307,7 +307,10 @@ func runRoot(cmd *cobra.Command) error { if err != nil { return fmt.Errorf("--path is required: could not load config: %w", err) } - entry, found := configLookupOrg(cfg, organization) + entry, found, err := configLookupOrg(cfg, organization) + if err != nil { + return err + } if !found { return fmt.Errorf("org %q not found in config file %s; provide --path explicitly", organization, cfgFile) }