From 7b9fd896ecd342d7bf9c21c62c38695d3840a7b8 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:31:58 +1000 Subject: [PATCH 01/16] fix: limit downloadFile() response body to 10 MB An uncapped io.Copy allowed a malicious or slow server to write an arbitrarily large file when running `ahoy config init `, exhausting disk space. Wrap the response body with io.LimitReader before copying. --- config_init.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config_init.go b/config_init.go index 9ae9cf8..bcd25a5 100644 --- a/config_init.go +++ b/config_init.go @@ -52,7 +52,8 @@ func downloadFile(rawURL, destPath string) error { // Always clean up the temp file; harmless no-op after a successful rename. defer os.Remove(tmpPath) - if _, err = io.Copy(out, resp.Body); err != nil { + const maxDownloadBytes = 10 * 1024 * 1024 // 10 MB — generous for any YAML config file + if _, err = io.Copy(out, io.LimitReader(resp.Body, maxDownloadBytes)); err != nil { out.Close() return fmt.Errorf("failed to write file %s: %v", destPath, err) } From 5c3d999b25fb32638221f21ab72c8f43e3b45b36 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:32:14 +1000 Subject: [PATCH 02/16] fix: declare GitCommit, GitBranch, BuildTime vars for ldflags injection The Makefile injected these three symbols via -X ldflags but they were never declared in Go source, so the linker silently dropped them. Every shipped binary was missing build provenance metadata. --- ahoy.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ahoy.go b/ahoy.go index d1afe0b..4009f0e 100644 --- a/ahoy.go +++ b/ahoy.go @@ -49,9 +49,13 @@ var ( importVisited map[string]bool ) -// The build version can be set using the go linker flag `-ldflags "-X main.version=$VERSION"` -// Complete command: `go build -ldflags "-X main.version=$VERSION"` -var version string +// Build metadata variables injected at link time via -ldflags "-X main.version=...". +var ( + version string + GitCommit string + GitBranch string + BuildTime string +) // AhoyConf stores the global config. var AhoyConf struct { From c278995936004d9ba3d905971079fc6a0c0ff419 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:32:45 +1000 Subject: [PATCH 03/16] fix: env-file vars now correctly override inherited env on macOS The previous append(command.Environ(), cmdEnvVars...) placed user vars last. Linux uses last-match semantics so it worked there, but macOS getenv(3) returns the first match, silently ignoring the overrides. Replace with a deduplicating merge: cmdEnvVars go first, then inherited entries are added only when their key is not already present. --- ahoy.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ahoy.go b/ahoy.go index 4009f0e..0fb8751 100644 --- a/ahoy.go +++ b/ahoy.go @@ -405,7 +405,24 @@ func getCommands(config Config) []*cobra.Command { command.Stdout = os.Stdout command.Stdin = os.Stdin command.Stderr = os.Stderr - command.Env = append(command.Environ(), cmdEnvVars...) + // Build the environment so cmdEnvVars always take precedence. + // macOS getenv(3) returns the first match, so we put cmdEnvVars + // first and append inherited entries only when their key is not + // already covered. + overridden := make(map[string]bool, len(cmdEnvVars)) + for _, kv := range cmdEnvVars { + if i := strings.Index(kv, "="); i > 0 { + overridden[kv[:i]] = true + } + } + mergedEnv := make([]string, len(cmdEnvVars), len(cmdEnvVars)+len(command.Environ())) + copy(mergedEnv, cmdEnvVars) + for _, kv := range command.Environ() { + if i := strings.Index(kv, "="); i <= 0 || !overridden[kv[:i]] { + mergedEnv = append(mergedEnv, kv) + } + } + command.Env = mergedEnv if err := command.Run(); err != nil { fmt.Fprintln(os.Stderr) return err From 84bcd680e602084208834906406b6b53d7e41ae6 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:33:13 +1000 Subject: [PATCH 04/16] fix: move no-config Execute() call out of setupApp() into main() setupApp() was calling rootCmd.Execute() and os.Exit(0) internally when no .ahoy.yml was found, bypassing main()'s pipe-drain goroutine and making setupApp() impossible to unit-test as a pure builder. It now returns rootCmd early and main() handles execution uniformly. --- ahoy.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ahoy.go b/ahoy.go index 0fb8751..3178551 100644 --- a/ahoy.go +++ b/ahoy.go @@ -667,12 +667,12 @@ func setupApp(localArgs []string) *cobra.Command { if AhoyConf.srcFile != "" { importVisited[normalizePath(AhoyConf.srcFile)] = true } - // If we don't have a sourcefile, then just supply the default commands. + // If we don't have a sourcefile, just supply the default commands and + // return — main() is responsible for calling Execute(). if AhoyConf.srcFile == "" { commands := addDefaultCommands([]*cobra.Command{}) rootCmd.AddCommand(commands...) - rootCmd.Execute() - os.Exit(0) + return rootCmd } config, err := getConfig(AhoyConf.srcFile) if err != nil { From 08279c99668d8a9a833a6d28ebd33f236ee76e89 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:54:13 +1000 Subject: [PATCH 05/16] fix: getConfig() error message names the failing file, not the root config When an imported file had the wrong ahoyapi version the error embedded the global `sourcefile` (root config path) instead of the `file` argument passed to getConfig(), sending the user to the wrong file. --- ahoy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ahoy.go b/ahoy.go index 3178551..7af2b45 100644 --- a/ahoy.go +++ b/ahoy.go @@ -176,7 +176,7 @@ func getConfig(file string) (Config, error) { // All ahoy files (and imports) must specify the ahoy version. // This is so we can support backwards compatibility in the future. if config.AhoyAPI != "v2" { - err = errors.New("Ahoy only supports API version 'v2', but '" + config.AhoyAPI + "' given in " + sourcefile) + err = errors.New("Ahoy only supports API version 'v2', but '" + config.AhoyAPI + "' given in " + file) return config, err } From acaab1684c48b653f5a4873e660498f3a5748c01 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:54:35 +1000 Subject: [PATCH 06/16] fix: preserve bare -- end-of-options separator in normaliseLongFlagPrefixes The prefix rewriter converted -- to a single -, corrupting the standard end-of-options sentinel before it reached Cobra. Any arguments after -- that look like flags would be incorrectly parsed as ahoy flags. --- flag.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flag.go b/flag.go index 4fbe9fd..ebecb61 100644 --- a/flag.go +++ b/flag.go @@ -98,7 +98,9 @@ func resetFlagState() { func normaliseLongFlagPrefixes(args []string) []string { out := make([]string, len(args)) for i, arg := range args { - if strings.HasPrefix(arg, "--") { + if arg == "--" { + out[i] = arg + } else if strings.HasPrefix(arg, "--") { out[i] = "-" + strings.TrimPrefix(arg, "--") } else { out[i] = arg From e50df853ff764b7cdb109e3573c6f23d23bf954d Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:55:09 +1000 Subject: [PATCH 07/16] fix: delete dead PrintValidationIssues() function The live code path uses PrintConfigReport() which writes to stdout. PrintValidationIssues() was never called and wrote to stderr, so any future caller would silently break scripts that pipe ahoy config validate. --- config_validation.go | 49 -------------------------------------------- 1 file changed, 49 deletions(-) diff --git a/config_validation.go b/config_validation.go index 958f3cc..7574544 100644 --- a/config_validation.go +++ b/config_validation.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "os" "path/filepath" "strconv" "strings" @@ -285,54 +284,6 @@ func validateEnvFile(cmdName, envPath, configFile string) []ValidationIssue { return issues } -// PrintValidationIssues prints validation issues in a user-friendly format. -func PrintValidationIssues(result ValidationResult) { - if len(result.Issues) == 0 { - return - } - - fmt.Fprintf(os.Stderr, "\nConfiguration Validation Issues:\n") - fmt.Fprintf(os.Stderr, "================================\n\n") - - errorCount := 0 - warningCount := 0 - infoCount := 0 - - for _, issue := range result.Issues { - switch issue.Severity { - case "error": - fmt.Fprintf(os.Stderr, "ERROR: %s\n", issue.Message) - errorCount++ - case "warning": - fmt.Fprintf(os.Stderr, "WARNING: %s\n", issue.Message) - warningCount++ - case "info": - fmt.Fprintf(os.Stderr, "INFO: %s\n", issue.Message) - infoCount++ - } - - if issue.File != "" { - fmt.Fprintf(os.Stderr, "File: %s\n", issue.File) - } - if issue.Field != "" { - fmt.Fprintf(os.Stderr, "Field: %s\n", issue.Field) - } - if issue.RequiredVersion != "" && issue.CurrentVersion != "" { - fmt.Fprintf(os.Stderr, "Required Version: %s (current: %s)\n", issue.RequiredVersion, issue.CurrentVersion) - } - if issue.Suggestion != "" { - fmt.Fprintf(os.Stderr, "Suggestion: %s\n", issue.Suggestion) - } - fmt.Fprintf(os.Stderr, "\n") - } - - fmt.Fprintf(os.Stderr, "Summary: %d error(s), %d warning(s), %d info\n", errorCount, warningCount, infoCount) - - if errorCount > 0 { - fmt.Fprintf(os.Stderr, "\nRun 'ahoy config validate' for more detailed diagnostics and solutions.\n") - } -} - // ConfigReport contains comprehensive diagnostic information about an Ahoy configuration. type ConfigReport struct { ConfigFile string From 6b05c211d18e4175d890280af017a3f8e6d99262 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:55:30 +1000 Subject: [PATCH 08/16] fix: compare pre-release version segments numerically, not lexicographically String comparison caused rc10 < rc9 because "1" < "9". Split pre-release labels on "." and compare numeric-looking segments as integers so that rc10 > rc9 and 10 > 9 compare correctly. --- config_validation.go | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/config_validation.go b/config_validation.go index 7574544..b3eaf6b 100644 --- a/config_validation.go +++ b/config_validation.go @@ -108,10 +108,37 @@ func compareVersions(v1, v2 string) int { if v1HasPre && !v2HasPre { return -1 // Pre-release < normal version. } - if v1PreRelease < v2PreRelease { - return -1 - } else if v1PreRelease > v2PreRelease { - return 1 + // Compare pre-release labels segment by segment so multi-digit numeric + // identifiers sort correctly (e.g. rc10 > rc9). + pre1Segs := strings.Split(v1PreRelease, ".") + pre2Segs := strings.Split(v2PreRelease, ".") + maxLen := len(pre1Segs) + if len(pre2Segs) > maxLen { + maxLen = len(pre2Segs) + } + for i := range maxLen { + var s1, s2 string + if i < len(pre1Segs) { + s1 = pre1Segs[i] + } + if i < len(pre2Segs) { + s2 = pre2Segs[i] + } + n1, err1 := strconv.Atoi(s1) + n2, err2 := strconv.Atoi(s2) + if err1 == nil && err2 == nil { + if n1 < n2 { + return -1 + } else if n1 > n2 { + return 1 + } + } else { + if s1 < s2 { + return -1 + } else if s1 > s2 { + return 1 + } + } } return 0 } From 86f9e1d7e44fe824b991226d5a7b13fc1899e184 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:55:49 +1000 Subject: [PATCH 09/16] fix: Makefile install target uses $(BINARY_NAME) not hardcoded ahoy On Windows the built binary is ahoy.exe, so `cp ahoy` silently failed. Use $(BINARY_NAME) which is set to ahoy.exe on Windows_NT and ahoy elsewhere. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index bb0893e..b95e984 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ default: go build -ldflags $(LDFLAGS) -v -o ./$(BINARY_NAME) install: - cp ahoy /usr/local/bin/ahoy + cp $(BINARY_NAME) /usr/local/bin/ahoy chmod +x /usr/local/bin/ahoy build_dir: From c6f1d76c05d5cee38685af7ce7376ff40a092c74 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:56:13 +1000 Subject: [PATCH 10/16] fix: warn and skip malformed lines in env files (no KEY=VALUE separator) Lines without '=' were passed verbatim into exec.Cmd.Env, silently creating invalid entries. A common cause is shell 'export KEY=VALUE' syntax which is not supported. Now logs a warning and skips the line. --- ahoy.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ahoy.go b/ahoy.go index 7af2b45..4728923 100644 --- a/ahoy.go +++ b/ahoy.go @@ -275,6 +275,12 @@ func getEnvironmentVars(envFile string) []string { if line == "" || strings.HasPrefix(line, "#") { continue } + // Warn on lines that don't contain '=' — common culprit is shell + // `export KEY=VALUE` syntax, which is not supported here. + if !strings.Contains(line, "=") { + logger("warning", "ignoring malformed line in env file '"+envFile+"' (expected KEY=VALUE, got: "+line+")") + continue + } envVars = append(envVars, line) } return envVars From 5c555e1af8ff3f86e49873ecfcbbe608b61af00f Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 12:56:35 +1000 Subject: [PATCH 11/16] fix: appRun() test helper handles --file=value and -f=value flag forms The hand-rolled flag parser only recognised the space-separated forms (--file , -f ). The equals form would have been treated as a command argument, corrupting the call silently. --- ahoy_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ahoy_test.go b/ahoy_test.go index 943b665..d285bf1 100644 --- a/ahoy_test.go +++ b/ahoy_test.go @@ -289,6 +289,9 @@ func appRun(args []string) (string, error) { skipNext = true continue } + if strings.HasPrefix(arg, "--file=") || strings.HasPrefix(arg, "-f=") { + continue + } if arg == "-v" || arg == "--verbose" { continue } From 58fb2fca32c15fdd9de9d0b00528781d2467b95b Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 17:34:31 +1000 Subject: [PATCH 12/16] refactor: encapsulate package-level globals into appState struct (#5) Remove all mutable package-level globals (sourcefile, verbose, ahoyExecutable, importVisited, AhoyConf, versionFlagSet, helpFlagSet, bashCompletionFlagSet, invalidFlagError) and replace with an appState struct whose methods carry state through the call chain. main() creates a fresh appState and calls setupApp; tests create isolated appState instances, eliminating the save/restore patterns that caused test-order sensitivity. --- ahoy.go | 253 ++++++++++++++++++++++---------------------- ahoy_test.go | 73 +++++-------- cli_parsing_test.go | 114 +++++++------------- config.go | 14 +-- description_test.go | 4 +- flag.go | 67 ++++++------ windows_test.go | 6 +- 7 files changed, 230 insertions(+), 301 deletions(-) diff --git a/ahoy.go b/ahoy.go index 4728923..440f077 100644 --- a/ahoy.go +++ b/ahoy.go @@ -40,15 +40,6 @@ type Command struct { Aliases []string `yaml:"aliases"` } -var ( - rootCmd *cobra.Command - sourcefile string - verbose bool - simulateVersion string - ahoyExecutable string - importVisited map[string]bool -) - // Build metadata variables injected at link time via -ldflags "-X main.version=...". var ( version string @@ -57,17 +48,36 @@ var ( BuildTime string ) -// AhoyConf stores the global config. -var AhoyConf struct { - srcDir string - srcFile string +// simulateVersion is a test-only package-level var set by the hidden +// --simulate-version flag. It overrides the reported Ahoy version for +// exercising the validation system without rebuilding the binary. +// Never set in production use. +var simulateVersion string + +// appState holds all mutable runtime state for an ahoy invocation, +// replacing the package-level globals that made concurrent testing unsafe. +type appState struct { + sourcefile string + verbose bool + ahoyExecutable string + importVisited map[string]bool + srcDir string + srcFile string + // flag pre-parse results written by initFlags, read by setupApp/main. + invalidFlagError string + versionFlagSet bool + helpFlagSet bool + bashCompletionFlagSet bool } -func logger(errType string, text string) { - // Disable the flags which add date and time for instance. +func newAppState() *appState { + return &appState{} +} + +func (s *appState) logger(errType string, text string) { log.SetFlags(0) if errType == "debug" { - if verbose { + if s.verbose { log.Println("[debug] " + text) } return @@ -125,38 +135,31 @@ func normalizePath(path string) string { return cleaned } -func getConfigPath(sourcefile string) (string, error) { - var err error - config := "" - - // If a specific source file was set, then try to load it directly. - if sourcefile != "" { - if _, statErr := os.Stat(sourcefile); statErr == nil { - return sourcefile, nil +func (s *appState) getConfigPath() (string, error) { + if s.sourcefile != "" { + if _, statErr := os.Stat(s.sourcefile); statErr == nil { + return s.sourcefile, nil } - err = errors.New("An ahoy config file was specified using -f to be at " + sourcefile + " but couldn't be found. Check your path.") - return config, err + return "", errors.New("An ahoy config file was specified using -f to be at " + s.sourcefile + " but couldn't be found. Check your path.") } dir, err := os.Getwd() if err != nil { - return config, err + return "", err } - // Keep track of the previous directory to detect when we've reached the root prevDir := "" for dir != prevDir { ymlpath := filepath.Join(dir, ".ahoy.yml") if _, err := os.Stat(ymlpath); err == nil { - logger("debug", "Found .ahoy.yml at "+ymlpath) - return ymlpath, err + s.logger("debug", "Found .ahoy.yml at "+ymlpath) + return ymlpath, nil } - // Chop off the last part of the path. prevDir = dir dir = filepath.Dir(dir) } - logger("debug", "Can't find an .ahoy.yml file.") - return "", err + s.logger("debug", "Can't find an .ahoy.yml file.") + return "", nil } func getConfig(file string) (Config, error) { @@ -167,14 +170,11 @@ func getConfig(file string) (Config, error) { return config, err } - // Extract the yaml file into the config variable. err = yaml.Unmarshal(yamlFile, &config) if err != nil { return config, err } - // All ahoy files (and imports) must specify the ahoy version. - // This is so we can support backwards compatibility in the future. if config.AhoyAPI != "v2" { err = errors.New("Ahoy only supports API version 'v2', but '" + config.AhoyAPI + "' given in " + file) return config, err @@ -187,29 +187,29 @@ func getConfig(file string) (Config, error) { return config, err } -func processImport(include string, commands map[string]*cobra.Command) { - include = expandPath(include, AhoyConf.srcDir) +func (s *appState) processImport(include string, commands map[string]*cobra.Command) { + include = expandPath(include, s.srcDir) normalizedInclude := normalizePath(include) // Guard against circular imports. Lazily initialise so direct callers // in tests don't need to prime the map themselves. - if importVisited == nil { - importVisited = map[string]bool{} + if s.importVisited == nil { + s.importVisited = map[string]bool{} } - if importVisited[normalizedInclude] { - logger("warn", "Circular import detected for '"+include+"', skipping.") + if s.importVisited[normalizedInclude] { + s.logger("warn", "Circular import detected for '"+include+"', skipping.") return } - importVisited[normalizedInclude] = true + s.importVisited[normalizedInclude] = true defer func() { - delete(importVisited, normalizedInclude) + delete(s.importVisited, normalizedInclude) }() if _, err := os.Stat(include); err != nil { if !os.IsNotExist(err) { // File exists but is unreadable (e.g. EACCES) - log so the // user knows why commands are missing. - logger("error", "Cannot access import file '"+include+"': "+err.Error()) + s.logger("error", "Cannot access import file '"+include+"': "+err.Error()) } // Skipping missing or unreadable files allows subcommands to be // separated into public and private sets. @@ -217,16 +217,16 @@ func processImport(include string, commands map[string]*cobra.Command) { } config, err := getConfig(include) if err != nil { - logger("error", "Could not load imported config '"+include+"': "+err.Error()) + s.logger("error", "Could not load imported config '"+include+"': "+err.Error()) return } - includeCommands := getCommands(config) + includeCommands := s.getCommands(config) for _, command := range includeCommands { commands[command.Name()] = command } } -func getSubCommands(includes []string) []*cobra.Command { +func (s *appState) getSubCommands(includes []string) []*cobra.Command { subCommands := []*cobra.Command{} if len(includes) == 0 { return subCommands @@ -236,7 +236,7 @@ func getSubCommands(includes []string) []*cobra.Command { if len(include) == 0 { continue } - processImport(include, commands) + s.processImport(include, commands) } var names []string @@ -250,8 +250,8 @@ func getSubCommands(includes []string) []*cobra.Command { return subCommands } -// Given a filepath, return a string array of environment variables. -func getEnvironmentVars(envFile string) []string { +// getEnvironmentVars returns a string array of environment variables from a filepath. +func (s *appState) getEnvironmentVars(envFile string) []string { var envVars []string // We allow non-existent "env" files, so skip if file doesn't exist. @@ -263,7 +263,7 @@ func getEnvironmentVars(envFile string) []string { if err != nil { // The file was confirmed to exist above, so this is a real read // failure (e.g. EACCES, EIO) - not a routine missing-file case. - logger("error", "Failed to read environment file '"+envFile+"': "+err.Error()) + s.logger("error", "Failed to read environment file '"+envFile+"': "+err.Error()) return nil } @@ -278,7 +278,7 @@ func getEnvironmentVars(envFile string) []string { // Warn on lines that don't contain '=' — common culprit is shell // `export KEY=VALUE` syntax, which is not supported here. if !strings.Contains(line, "=") { - logger("warning", "ignoring malformed line in env file '"+envFile+"' (expected KEY=VALUE, got: "+line+")") + s.logger("warning", "ignoring malformed line in env file '"+envFile+"' (expected KEY=VALUE, got: "+line+")") continue } envVars = append(envVars, line) @@ -286,15 +286,15 @@ func getEnvironmentVars(envFile string) []string { return envVars } -func getCommands(config Config) []*cobra.Command { +func (s *appState) getCommands(config Config) []*cobra.Command { exportCmds := []*cobra.Command{} envVars := []string{} // Get environment variables from all 'global' environment variable files, if any are defined. if len(config.Env) > 0 { for _, envPath := range config.Env { - globalEnvFile := expandPath(envPath, AhoyConf.srcDir) - vars := getEnvironmentVars(globalEnvFile) + globalEnvFile := expandPath(envPath, s.srcDir) + vars := s.getEnvironmentVars(globalEnvFile) if vars != nil { envVars = append(envVars, vars...) } @@ -332,8 +332,8 @@ func getCommands(config Config) []*cobra.Command { newCmd := &cobra.Command{ Use: name, Aliases: cmd.Aliases, - // Don't use DisableFlagParsing - it prevents persistent flags from being parsed - // Instead, we'll use FParseErrWhitelist to allow unknown flags to pass through + // Don't use DisableFlagParsing - it prevents persistent flags from being parsed. + // Instead, we use FParseErrWhitelist to allow unknown flags to pass through. FParseErrWhitelist: cobra.FParseErrWhitelist{ UnknownFlags: true, }, @@ -349,7 +349,7 @@ func getCommands(config Config) []*cobra.Command { } if cmd.Cmd != "" { - // Capture variables for the closure + // Capture variables for the closure. cmdString := cmd.Cmd cmdEnv := cmd.Env cmdName := name @@ -361,7 +361,7 @@ func getCommands(config Config) []*cobra.Command { var cmdArgs []string var cmdEntrypoint []string - // Filter out "--" separator + // Filter out "--" separator. for _, arg := range args { if arg != "--" { cmdArgs = append(cmdArgs, arg) @@ -379,7 +379,7 @@ func getCommands(config Config) []*cobra.Command { } cmdItems = append(cmdEntrypoint, cmdArgs...) - // Collect environment variables + // Collect environment variables. cmdEnvVars := append([]string{}, envVars...) // If defined, include any command-level environment variables. @@ -387,8 +387,8 @@ func getCommands(config Config) []*cobra.Command { // defined in the 'global' env file. if len(cmdEnv) > 0 { for _, envPath := range cmdEnv { - cmdEnvFile := expandPath(envPath, AhoyConf.srcDir) - vars := getEnvironmentVars(cmdEnvFile) + cmdEnvFile := expandPath(envPath, s.srcDir) + vars := s.getEnvironmentVars(cmdEnvFile) if vars != nil { cmdEnvVars = append(cmdEnvVars, vars...) } @@ -398,16 +398,16 @@ func getCommands(config Config) []*cobra.Command { // Inject ahoy-specific environment variables so subprocesses can // identify the running binary and the invoked command name. ahoyEnvVars := []string{"AHOY_COMMAND_NAME=" + cmdName} - if ahoyExecutable != "" { - ahoyEnvVars = append(ahoyEnvVars, "AHOY_CMD="+ahoyExecutable) + if s.ahoyExecutable != "" { + ahoyEnvVars = append(ahoyEnvVars, "AHOY_CMD="+s.ahoyExecutable) } cmdEnvVars = append(cmdEnvVars, ahoyEnvVars...) - if verbose { - log.Println("===> Ahoy", cmdName, "from", sourcefile, ":", cmdItems) + if s.verbose { + log.Println("===> Ahoy", cmdName, "from", s.sourcefile, ":", cmdItems) } command := exec.Command(cmdItems[0], cmdItems[1:]...) - command.Dir = AhoyConf.srcDir + command.Dir = s.srcDir command.Stdout = os.Stdout command.Stdin = os.Stdin command.Stderr = os.Stderr @@ -438,7 +438,7 @@ func getCommands(config Config) []*cobra.Command { } if cmd.Imports != nil { - subCommands := getSubCommands(cmd.Imports) + subCommands := s.getSubCommands(cmd.Imports) if len(subCommands) == 0 { if !cmd.Optional { errorMsg := fmt.Sprintf("Command [%s] has 'imports' set, but no commands were found.", name) @@ -446,7 +446,7 @@ func getCommands(config Config) []*cobra.Command { // List any import files that are missing to help diagnose the issue. var missingFiles []string for _, importPath := range cmd.Imports { - fullPath := expandPath(importPath, AhoyConf.srcDir) + fullPath := expandPath(importPath, s.srcDir) if !fileExists(fullPath) { missingFiles = append(missingFiles, importPath) } @@ -463,7 +463,7 @@ func getCommands(config Config) []*cobra.Command { errorMsg += "\n\nFor more help, run: ahoy config validate" } - logger("fatal", errorMsg) + s.logger("fatal", errorMsg) } else { if !VersionSupports(GetAhoyVersion(), "optional_imports") { errorMsg := fmt.Sprintf("Command [%s] uses 'optional: true' but this Ahoy version (%s) doesn't support optional imports.", name, GetAhoyVersion()) @@ -472,7 +472,7 @@ func getCommands(config Config) []*cobra.Command { errorMsg += "\n1. Upgrade Ahoy to the latest version" errorMsg += "\n2. Remove 'optional: true' and create the missing import files" errorMsg += "\n\nFor more help, run: ahoy config validate" - logger("fatal", errorMsg) + s.logger("fatal", errorMsg) } continue } @@ -487,16 +487,16 @@ func getCommands(config Config) []*cobra.Command { } for _, e := range configErrors { - logger("error", e) + s.logger("error", e) } if len(configErrors) > 0 { - logger("fatal", "Fix the above configuration errors and try again.") + s.logger("fatal", "Fix the above configuration errors and try again.") } return exportCmds } -func addDefaultCommands(commands []*cobra.Command) []*cobra.Command { +func (s *appState) addDefaultCommands(commands []*cobra.Command) []*cobra.Command { // 'ahoy config' command group with 'validate' and 'init' subcommands. configCmd := &cobra.Command{ Use: "config", @@ -507,7 +507,7 @@ func addDefaultCommands(commands []*cobra.Command) []*cobra.Command { configValidateCmd := &cobra.Command{ Use: "validate", Short: "Validate and diagnose an Ahoy configuration file.", - Run: validateCommandAction, + Run: s.validateCommandAction, } configInitCmd := &cobra.Command{ @@ -550,9 +550,9 @@ func addDefaultCommands(commands []*cobra.Command) []*cobra.Command { return commands } -// BashComplete prints the list of subcommands as the default app completion method -func BashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - logger("debug", "BashComplete()") +// bashComplete prints the list of subcommands as the default app completion method. +func (s *appState) bashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + s.logger("debug", "bashComplete()") completions := []string{} for _, command := range cmd.Root().Commands() { @@ -562,24 +562,24 @@ func BashComplete(cmd *cobra.Command, args []string, toComplete string) ([]strin return completions, cobra.ShellCompDirectiveNoFileComp } -// NoArgsAction is the application wide default action, for when no flags or arguments +// noArgsAction is the application-wide default action when no flags or arguments // are passed or when a command doesn't exist. -func NoArgsAction(cmd *cobra.Command, args []string) { +func (s *appState) noArgsAction(cmd *cobra.Command, args []string) { if len(args) > 0 { msg := "Command not found for '" + strings.Join(args, " ") + "'" - logger("fatal", msg) + s.logger("fatal", msg) } cmd.Help() - if AhoyConf.srcFile == "" { - logger("error", "No .ahoy.yml found. You can use 'ahoy init' to download an example.") + if s.srcFile == "" { + s.logger("error", "No .ahoy.yml found. You can use 'ahoy init' to download an example.") } helpRequested, _ := cmd.Flags().GetBool("help") versionRequested, _ := cmd.Flags().GetBool("version") if !helpRequested && !versionRequested { - logger("warn", "Missing flag or argument.") + s.logger("warn", "Missing flag or argument.") os.Exit(1) } @@ -587,9 +587,9 @@ func NoArgsAction(cmd *cobra.Command, args []string) { os.Exit(0) } -// BeforeCommand is a PersistentPreRunE hook that handles --version and --help +// beforeCommand is a PersistentPreRunE hook that handles --version and --help // flag processing before cobra executes each command. -func BeforeCommand(cmd *cobra.Command, args []string) error { +func (s *appState) beforeCommand(cmd *cobra.Command, args []string) error { // Check if version was set via --version (double dash) by cobra. versionRequested, _ := cmd.Flags().GetBool("version") if versionRequested { @@ -617,35 +617,33 @@ func BeforeCommand(cmd *cobra.Command, args []string) error { return nil } -func setupApp(localArgs []string) *cobra.Command { - var err error - - initFlags(localArgs) +func (s *appState) setupApp(localArgs []string) *cobra.Command { + s.initFlags(localArgs) // initFlags() pre-parsed sourcefile and verbose from the legacy // single-dash forms (-f, -verbose, etc.) - see flag.go for the full // rationale. The cobra flag definitions below would re-bind those // same variables and reset them to their zero values, so we capture // the parsed values now and pass them as the cobra flag defaults. - parsedSourcefile := sourcefile - parsedVerbose := verbose + parsedSourcefile := s.sourcefile + parsedVerbose := s.verbose - // Create root command - rootCmd = &cobra.Command{ + // Create root command. + rootCmd := &cobra.Command{ Use: "ahoy", Version: version, Short: "Creates a configurable cli app for running commands.", RunE: func(cmd *cobra.Command, args []string) error { - NoArgsAction(cmd, args) + s.noArgsAction(cmd, args) return nil }, - PersistentPreRunE: BeforeCommand, - ValidArgsFunction: BashComplete, + PersistentPreRunE: s.beforeCommand, + ValidArgsFunction: s.bashComplete, } - // Set up global flags with the parsed values as defaults - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", parsedVerbose, "Output extra details like the commands to be run.") - rootCmd.PersistentFlags().StringVarP(&sourcefile, "file", "f", parsedSourcefile, "Use a specific ahoy file.") + // Set up global flags with the parsed values as defaults. + rootCmd.PersistentFlags().BoolVarP(&s.verbose, "verbose", "v", parsedVerbose, "Output extra details like the commands to be run.") + rootCmd.PersistentFlags().StringVarP(&s.sourcefile, "file", "f", parsedSourcefile, "Use a specific ahoy file.") rootCmd.PersistentFlags().Bool("help", false, "show help") rootCmd.PersistentFlags().Bool("version", false, "print the version") rootCmd.PersistentFlags().Bool("generate-bash-completion", false, "") @@ -660,39 +658,40 @@ func setupApp(localArgs []string) *cobra.Command { rootCmd.PersistentFlags().MarkHidden("generate-bash-completion") rootCmd.PersistentFlags().MarkHidden("simulate-version") - // Disable default help command + // Disable default help command. rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - importVisited = map[string]bool{} + s.importVisited = map[string]bool{} - AhoyConf.srcFile, err = getConfigPath(sourcefile) + var err error + s.srcFile, err = s.getConfigPath() if err != nil { - logger("fatal", err.Error()) + s.logger("fatal", err.Error()) } else { - AhoyConf.srcDir = filepath.Dir(AhoyConf.srcFile) - if AhoyConf.srcFile != "" { - importVisited[normalizePath(AhoyConf.srcFile)] = true + s.srcDir = filepath.Dir(s.srcFile) + if s.srcFile != "" { + s.importVisited[normalizePath(s.srcFile)] = true } // If we don't have a sourcefile, just supply the default commands and // return — main() is responsible for calling Execute(). - if AhoyConf.srcFile == "" { - commands := addDefaultCommands([]*cobra.Command{}) + if s.srcFile == "" { + commands := s.addDefaultCommands([]*cobra.Command{}) rootCmd.AddCommand(commands...) return rootCmd } - config, err := getConfig(AhoyConf.srcFile) + config, err := getConfig(s.srcFile) if err != nil { - logger("fatal", err.Error()) + s.logger("fatal", err.Error()) } - commands := getCommands(config) - commands = addDefaultCommands(commands) + commands := s.getCommands(config) + commands = s.addDefaultCommands(commands) rootCmd.AddCommand(commands...) if config.Usage != "" { rootCmd.Short = config.Usage } } - // Set up custom help template + // Set up custom help template. rootCmd.SetHelpFunc(customHelpFunc) // Suppress cobra's built-in error/usage prints. main() inspects the @@ -784,35 +783,35 @@ VERSION: } func main() { - logger("debug", "main()") + state := newAppState() if exe, err := os.Executable(); err == nil { - ahoyExecutable = exe + state.ahoyExecutable = exe } - rootCmd = setupApp(os.Args[1:]) + rootCmd := state.setupApp(os.Args[1:]) // Check for invalid flag error from initFlags - show help and exit 1. - if invalidFlagError != "" { - fmt.Print(invalidFlagError) + if state.invalidFlagError != "" { + fmt.Print(state.invalidFlagError) rootCmd.Help() os.Exit(1) } - // Check for -version and -help flags set during initFlags (single-dash versions) - // This handles single-dash versions that cobra doesn't support - if versionFlagSet { + // Check for -version and -help flags set during initFlags (single-dash versions). + // This handles single-dash versions that cobra doesn't support. + if state.versionFlagSet { if version != "" { fmt.Println(version) } os.Exit(0) } - if helpFlagSet { + if state.helpFlagSet { rootCmd.Help() os.Exit(0) } - // Handle bash completion flag - print completions and exit - if bashCompletionFlagSet { + // Handle bash completion flag - print completions and exit. + if state.bashCompletionFlagSet { for _, command := range rootCmd.Commands() { if !command.Hidden { fmt.Println(command.Name()) @@ -860,7 +859,7 @@ func main() { if len(parts) >= 2 { cmdName := parts[1] msg := "Command not found for '" + cmdName + "'" - logger("fatal", msg) + state.logger("fatal", msg) } } os.Exit(1) diff --git a/ahoy_test.go b/ahoy_test.go index d285bf1..8dcb123 100644 --- a/ahoy_test.go +++ b/ahoy_test.go @@ -37,7 +37,7 @@ func TestGetCommands(t *testing.T) { }, } - commands := getCommands(config) + commands := (&appState{}).getCommands(config) if len(commands) != 1 { t.Error("Expect that getCommands can get one command if passed config with one command.") @@ -45,34 +45,20 @@ func TestGetCommands(t *testing.T) { } func TestGetSubCommand(t *testing.T) { - // Save and restore the global state this test mutates, so it stays isolated - // from other tests regardless of execution order. - origSrcDir := AhoyConf.srcDir - origImportVisited := importVisited - t.Cleanup(func() { - AhoyConf.srcDir = origSrcDir - importVisited = origImportVisited - }) - - // Since we're not running the app directly, globals don't get reset, so - // we need to reset them ourselves. TODO: Remove these globals somehow. - AhoyConf.srcDir = "" - importVisited = nil + // Each scenario uses its own appState — no global save/restore needed. + state := &appState{} // When empty return empty list of commands. - - actual := getSubCommands([]string{}) - + actual := state.getSubCommands([]string{}) if len(actual) != 0 { t.Error("Expect that getSubCommands([]string) returns []Command{}") } // List of bogus or empty strings returns empty list of commands. - actual = getSubCommands([]string{ + actual = state.getSubCommands([]string{ "./testing/bogus1.ahoy.yml", "./testing/private.ahoy.yml", }) - if len(actual) != 0 { t.Error("Expect that getSubCommands([]string) returns []Command{}") } @@ -121,13 +107,13 @@ commands: t.Error("Error writing to file2.") } - actual = getSubCommands([]string{ + actual = state.getSubCommands([]string{ "./testing/a.ahoy.yml", "./testing/b.ahoy.yml", }) if len(actual) != 1 { - t.Error("Sourcedir:", AhoyConf.srcDir) + t.Error("Sourcedir:", state.srcDir) t.Error("Failed: expect that two commands with the same name get merged into one.", actual) } @@ -141,7 +127,6 @@ commands: t.Error("Something went wrong with the file creation - file3.") } - // logger("fatal", "test") yamlConfigC := ` ahoyapi: v2 commands: @@ -156,8 +141,9 @@ commands: t.Error("Error writing to file3.") } - importVisited = nil - actual = getSubCommands([]string{ + // Fresh state so importVisited doesn't carry over. + state2 := &appState{} + actual = state2.getSubCommands([]string{ "./testing/a.ahoy.yml", "./testing/b.ahoy.yml", "./testing/c.ahoy.yml", @@ -222,17 +208,17 @@ func TestGetConfig(t *testing.T) { } func TestGetConfigPath(t *testing.T) { - // Passing an empty string. + // Passing an empty string (no sourcefile set) finds .ahoy.yml in cwd. pwd, _ := os.Getwd() expected := filepath.Join(pwd, ".ahoy.yml") - actual, _ := getConfigPath("") + actual, _ := (&appState{}).getConfigPath() if expected != actual { t.Errorf("ahoy docker override-example: expected - %s; actual - %s", string(expected), string(actual)) } - // Passing known path works as expected + // Passing known path works as expected. expected = filepath.Join(pwd, ".ahoy.yml") - actual, _ = getConfigPath(expected) + actual, _ = (&appState{sourcefile: expected}).getConfigPath() if expected != actual { t.Errorf("ahoy docker override-example: expected - %s; actual - %s", string(expected), string(actual)) @@ -243,7 +229,7 @@ func TestGetConfigPath(t *testing.T) { func TestGetConfigPathErrorOnBogusPath(t *testing.T) { // Test getting a bogus config path. - _, err := getConfigPath("~/bogus/path") + _, err := (&appState{sourcefile: "~/bogus/path"}).getConfigPath() if err == nil { t.Error("getConfigPath did not fail when passed a bogus path.") } @@ -273,7 +259,7 @@ func appRun(args []string) (string, error) { os.Stderr = stderr }() - cmd := setupApp(args[1:]) + cmd := newAppState().setupApp(args[1:]) // Don't call SetArgs again - setupApp already parsed the flags // Just set the args to the command args (after flags) @@ -413,27 +399,18 @@ commands: t.Fatal(err) } - // Save and restore the global state this test mutates, so it stays isolated - // from other tests regardless of execution order. - origSrcDir := AhoyConf.srcDir - origImportVisited := importVisited origLogOutput := log.Writer() t.Cleanup(func() { - AhoyConf.srcDir = origSrcDir - importVisited = origImportVisited log.SetOutput(origLogOutput) }) // Test multi-branch imports. Both branchA and branchB should successfully resolve shared.yml. - // We reset global state as in other tests. - AhoyConf.srcDir = "test_imports" - importVisited = nil - - // We seed the visited map with a root yml - importVisited = map[string]bool{} - importVisited[normalizePath("test_imports/root.yml")] = true + state := &appState{ + srcDir: "test_imports", + importVisited: map[string]bool{normalizePath("test_imports/root.yml"): true}, + } - commands := getSubCommands([]string{ + commands := state.getSubCommands([]string{ "branchA.yml", "branchB.yml", }) @@ -461,15 +438,17 @@ commands: } // Test circular imports to make sure they are caught and do not stack overflow. - importVisited = map[string]bool{} - importVisited[normalizePath("test_imports/root.yml")] = true + circState := &appState{ + srcDir: "test_imports", + importVisited: map[string]bool{normalizePath("test_imports/root.yml"): true}, + } // Capturing log/stdout to verify circular import warning is printed. // The original log output is restored by the t.Cleanup above. var logBuf bytes.Buffer log.SetOutput(&logBuf) - circularCmds := getSubCommands([]string{ + circularCmds := circState.getSubCommands([]string{ "circularA.yml", }) diff --git a/cli_parsing_test.go b/cli_parsing_test.go index 92ba80c..02bc038 100644 --- a/cli_parsing_test.go +++ b/cli_parsing_test.go @@ -8,14 +8,12 @@ import ( ) func TestFlagParsing(t *testing.T) { - // Test that flags are correctly initialized - cmd := setupApp([]string{}) + cmd := newAppState().setupApp([]string{}) if cmd == nil { t.Error("setupApp returned nil") return } - // Check that required flags exist requiredFlags := map[string]bool{ "verbose": false, "file": false, @@ -37,114 +35,97 @@ func TestFlagParsing(t *testing.T) { } func TestInitFlags(t *testing.T) { - // Test that initFlags properly processes incoming flags - originalSrcDir := AhoyConf.srcDir - defer func() { AhoyConf.srcDir = originalSrcDir }() - - // Test with empty flags - initFlags([]string{}) - if AhoyConf.srcDir != "" { + // Test with empty flags — srcDir should be reset to empty string. + s := newAppState() + s.initFlags([]string{}) + if s.srcDir != "" { t.Error("Expected srcDir to be reset to empty string") } - // Test with file flag - sourcefile = "" - initFlags([]string{"-f", "testdata/simple.ahoy.yml"}) - if sourcefile != "testdata/simple.ahoy.yml" { - t.Errorf("Expected sourcefile to be 'testdata/simple.ahoy.yml', got '%s'", sourcefile) + // Test that -f sets sourcefile. + s2 := newAppState() + s2.initFlags([]string{"-f", "testdata/simple.ahoy.yml"}) + if s2.sourcefile != "testdata/simple.ahoy.yml" { + t.Errorf("Expected sourcefile to be 'testdata/simple.ahoy.yml', got '%s'", s2.sourcefile) } } func TestVerboseFlagBehavior(t *testing.T) { - // Test verbose flag behavior - originalVerbose := verbose - defer func() { verbose = originalVerbose }() - - // Test that verbose flag can be set - verbose = true - if !verbose { + s := newAppState() + s.verbose = true + if !s.verbose { t.Error("Failed to set verbose flag") } - verbose = false - if verbose { + s.verbose = false + if s.verbose { t.Error("Failed to unset verbose flag") } } func TestSourcefileFlagBehavior(t *testing.T) { - // Test sourcefile flag behavior - originalSourcefile := sourcefile - defer func() { sourcefile = originalSourcefile }() - - // Test that sourcefile flag can be set - sourcefile = "test.yml" - if sourcefile != "test.yml" { + s := newAppState() + s.sourcefile = "test.yml" + if s.sourcefile != "test.yml" { t.Error("Failed to set sourcefile flag") } - sourcefile = "" - if sourcefile != "" { + s.sourcefile = "" + if s.sourcefile != "" { t.Error("Failed to unset sourcefile flag") } } func TestEnvironmentVariableFlags(t *testing.T) { - originalVerbose := verbose - originalSourcefile := sourcefile defer func() { - verbose = originalVerbose - sourcefile = originalSourcefile os.Unsetenv("AHOY_VERBOSE") os.Unsetenv("AHOY_FILE") }() t.Run("AHOY_VERBOSE sets verbose when no flag given", func(t *testing.T) { - verbose = false os.Setenv("AHOY_VERBOSE", "true") - initFlags([]string{}) - if !verbose { + s := newAppState() + s.initFlags([]string{}) + if !s.verbose { t.Error("Expected verbose to be true via AHOY_VERBOSE env var.") } }) t.Run("explicit -v flag takes precedence over AHOY_VERBOSE=false", func(t *testing.T) { - verbose = false os.Unsetenv("AHOY_VERBOSE") - initFlags([]string{"-v"}) - if !verbose { + s := newAppState() + s.initFlags([]string{"-v"}) + if !s.verbose { t.Error("Expected verbose to be true via -v flag.") } }) t.Run("AHOY_FILE sets sourcefile when no flag given", func(t *testing.T) { - sourcefile = "" os.Setenv("AHOY_FILE", "custom.ahoy.yml") - initFlags([]string{}) - if sourcefile != "custom.ahoy.yml" { - t.Errorf("Expected sourcefile 'custom.ahoy.yml', got '%s'.", sourcefile) + s := newAppState() + s.initFlags([]string{}) + if s.sourcefile != "custom.ahoy.yml" { + t.Errorf("Expected sourcefile 'custom.ahoy.yml', got '%s'.", s.sourcefile) } }) t.Run("explicit -f flag takes precedence over AHOY_FILE", func(t *testing.T) { - sourcefile = "" os.Setenv("AHOY_FILE", "env.ahoy.yml") - initFlags([]string{"-f", "explicit.ahoy.yml"}) - if sourcefile != "explicit.ahoy.yml" { - t.Errorf("Expected sourcefile 'explicit.ahoy.yml' from flag, got '%s'.", sourcefile) + s := newAppState() + s.initFlags([]string{"-f", "explicit.ahoy.yml"}) + if s.sourcefile != "explicit.ahoy.yml" { + t.Errorf("Expected sourcefile 'explicit.ahoy.yml' from flag, got '%s'.", s.sourcefile) } }) } func TestFlagNameAliases(t *testing.T) { - // Test that flag aliases work correctly with cobra - cmd := setupApp([]string{}) + cmd := newAppState().setupApp([]string{}) if cmd == nil { t.Error("setupApp returned nil") return } - // Check verbose flag has short form verboseFlag := cmd.PersistentFlags().Lookup("verbose") if verboseFlag == nil { t.Error("Verbose flag not found") @@ -152,7 +133,6 @@ func TestFlagNameAliases(t *testing.T) { t.Errorf("Expected verbose flag shorthand 'v', got '%s'", verboseFlag.Shorthand) } - // Check file flag has short form fileFlag := cmd.PersistentFlags().Lookup("file") if fileFlag == nil { t.Error("File flag not found") @@ -162,19 +142,7 @@ func TestFlagNameAliases(t *testing.T) { } func TestCLIAppConfiguration(t *testing.T) { - // Test that CLI app is configured correctly for cobra - - // Save original global state - originalSourcefile := sourcefile - originalVerbose := verbose - - defer func() { - sourcefile = originalSourcefile - verbose = originalVerbose - }() - - // Test app setup - testCmd := setupApp([]string{}) + testCmd := newAppState().setupApp([]string{}) if testCmd == nil { t.Error("setupApp returned nil") return @@ -188,16 +156,13 @@ func TestCLIAppConfiguration(t *testing.T) { t.Errorf("Unexpected command description: %s", testCmd.Short) } - // Check that ValidArgsFunction is set for bash completion if testCmd.ValidArgsFunction == nil { t.Error("Bash completion function should be set") } } func TestMigrationCompatibility(t *testing.T) { - // Verify persistent flags are registered on the root cobra command - // so that -v/--verbose and -f/--file work identically. - cmd := setupApp([]string{}) + cmd := newAppState().setupApp([]string{}) if cmd == nil { t.Error("setupApp returned nil") return @@ -211,14 +176,12 @@ func TestMigrationCompatibility(t *testing.T) { } func TestFlagValueTypes(t *testing.T) { - // Test that flag value types are correctly configured - cmd := setupApp([]string{}) + cmd := newAppState().setupApp([]string{}) if cmd == nil { t.Error("setupApp returned nil") return } - // Check verbose flag is boolean verboseFlag := cmd.PersistentFlags().Lookup("verbose") if verboseFlag == nil { t.Error("Verbose flag not found") @@ -226,7 +189,6 @@ func TestFlagValueTypes(t *testing.T) { t.Errorf("Expected verbose flag type 'bool', got '%s'", verboseFlag.Value.Type()) } - // Check file flag is string fileFlag := cmd.PersistentFlags().Lookup("file") if fileFlag == nil { t.Error("File flag not found") diff --git a/config.go b/config.go index cb56a3b..45e2a00 100644 --- a/config.go +++ b/config.go @@ -8,16 +8,12 @@ import ( ) // validateCommandAction is the Cobra handler for the 'ahoy config validate' command. -func validateCommandAction(cmd *cobra.Command, args []string) { - configFile := AhoyConf.srcFile +func (s *appState) validateCommandAction(cmd *cobra.Command, args []string) { + configFile := s.srcFile if configFile == "" { - var err error - configFile, err = getConfigPath("") - if err != nil || configFile == "" { - fmt.Println("Warning: No .ahoy.yml file found") - fmt.Println("Run 'ahoy config init' to create a new configuration file") - return - } + fmt.Println("Warning: No .ahoy.yml file found") + fmt.Println("Run 'ahoy config init' to create a new configuration file") + return } result := RunConfigValidate(configFile) diff --git a/description_test.go b/description_test.go index 3bfdd73..7b66f28 100644 --- a/description_test.go +++ b/description_test.go @@ -71,7 +71,7 @@ func TestDescriptionInCLICommands(t *testing.T) { t.Fatalf("Failed to load test config: %v", err) } - commands := getCommands(config) + commands := (&appState{}).getCommands(config) // Create a map for easy lookup cmdMap := make(map[string]*cobra.Command) @@ -173,7 +173,7 @@ func TestDescriptionWithExistingCommands(t *testing.T) { } // Verify CLI command assignment - commands := getCommands(config) + commands := (&appState{}).getCommands(config) var cliCmd *cobra.Command for _, c := range commands { if c.Name() == test.command { diff --git a/flag.go b/flag.go index ebecb61..f63ecd6 100644 --- a/flag.go +++ b/flag.go @@ -16,12 +16,12 @@ package main // incoming arguments here, before cobra ever sees them, using the stdlib // `flag` package - which natively understands single-dash long names. // -// The results are written to package-level globals consumed by main() +// The results are written to appState fields consumed by main() // and setupApp(): // // sourcefile value of -f / -file / --file // verbose value of -v / -verbose / --verbose -// simulateVersion value of --simulate-version (test-only flag) +// simulateVersion value of --simulate-version (test-only flag, package-level) // versionFlagSet true if -version / --version was seen // helpFlagSet true if -h / -help / --help was seen // bashCompletionFlagSet true if --generate-bash-completion was seen @@ -47,47 +47,40 @@ import ( "strings" ) -var ( - versionFlagSet bool - helpFlagSet bool - bashCompletionFlagSet bool - invalidFlagError string -) - // initFlags pre-parses the incoming arguments to honour legacy single-dash // long flags before cobra runs. See the file-level doc comment above for // the full rationale. -func initFlags(incomingFlags []string) { - resetFlagState() +func (s *appState) initFlags(incomingFlags []string) { + s.resetFlagState() normalisedFlags := normaliseLongFlagPrefixes(incomingFlags) // Local sinks for flags we only need as on/off signals; copied into - // package globals after parsing so the FlagSet's pointer bindings + // appState fields after parsing so the FlagSet's pointer bindings // remain valid for the duration of Parse(). var versionFlag, helpFlag, bashCompletionFlag bool - fs, errBuf := newLegacyFlagSet(&versionFlag, &helpFlag, &bashCompletionFlag) + fs, errBuf := s.newLegacyFlagSet(&versionFlag, &helpFlag, &bashCompletionFlag) if err := fs.Parse(normalisedFlags); err != nil { - invalidFlagError = errBuf.String() + s.invalidFlagError = errBuf.String() } - versionFlagSet = versionFlag - helpFlagSet = helpFlag - bashCompletionFlagSet = bashCompletionFlag + s.versionFlagSet = versionFlag + s.helpFlagSet = helpFlag + s.bashCompletionFlagSet = bashCompletionFlag - applyEnvFallbacks() + s.applyEnvFallbacks() } -// resetFlagState clears all pre-parser globals. Required because tests -// reuse the package-level state between runs. -func resetFlagState() { - AhoyConf.srcDir = "" - versionFlagSet = false - helpFlagSet = false - bashCompletionFlagSet = false - invalidFlagError = "" +// resetFlagState clears all pre-parser state fields. Required because tests +// create fresh appState instances between runs. +func (s *appState) resetFlagState() { + s.srcDir = "" + s.versionFlagSet = false + s.helpFlagSet = false + s.bashCompletionFlagSet = false + s.invalidFlagError = "" } // normaliseLongFlagPrefixes rewrites `--foo` to `-foo` so the stdlib flag @@ -113,16 +106,16 @@ func normaliseLongFlagPrefixes(args []string) []string { // caller passes pointers for the on/off flags so that the FlagSet's // internal pointer bindings remain valid for the duration of Parse(). // The returned errBuf captures any parse-error text for later replay. -func newLegacyFlagSet(versionFlag, helpFlag, bashCompletionFlag *bool) (*flag.FlagSet, *bytes.Buffer) { +func (s *appState) newLegacyFlagSet(versionFlag, helpFlag, bashCompletionFlag *bool) (*flag.FlagSet, *bytes.Buffer) { fs := flag.NewFlagSet("ahoyLegacyFlags", flag.ContinueOnError) errBuf := &bytes.Buffer{} fs.SetOutput(errBuf) - // Flags whose values flow into package globals. - fs.StringVar(&sourcefile, "f", "", "specify the sourcefile") - fs.StringVar(&sourcefile, "file", "", "specify the sourcefile") - fs.BoolVar(&verbose, "v", false, "verbose output") - fs.BoolVar(&verbose, "verbose", false, "verbose output") + // Flags whose values flow into appState fields. + fs.StringVar(&s.sourcefile, "f", "", "specify the sourcefile") + fs.StringVar(&s.sourcefile, "file", "", "specify the sourcefile") + fs.BoolVar(&s.verbose, "v", false, "verbose output") + fs.BoolVar(&s.verbose, "verbose", false, "verbose output") fs.StringVar(&simulateVersion, "simulate-version", "", "") // Flags we only need to detect; cobra also defines them but we exit @@ -138,13 +131,13 @@ func newLegacyFlagSet(versionFlag, helpFlag, bashCompletionFlag *bool) (*flag.Fl // applyEnvFallbacks fills in sourcefile / verbose from AHOY_FILE / // AHOY_VERBOSE when the equivalent flag was not given. Explicit flags // always take precedence. -func applyEnvFallbacks() { - if sourcefile == "" { +func (s *appState) applyEnvFallbacks() { + if s.sourcefile == "" { if v := os.Getenv("AHOY_FILE"); v != "" { - sourcefile = v + s.sourcefile = v } } - if !verbose && os.Getenv("AHOY_VERBOSE") == "true" { - verbose = true + if !s.verbose && os.Getenv("AHOY_VERBOSE") == "true" { + s.verbose = true } } diff --git a/windows_test.go b/windows_test.go index 85e8176..035f750 100644 --- a/windows_test.go +++ b/windows_test.go @@ -61,7 +61,7 @@ commands: defer os.Chdir(originalDir) // Test that getConfigPath finds the .ahoy.yml file - foundPath, err := getConfigPath("") + foundPath, err := (&appState{}).getConfigPath() if err != nil { t.Errorf("getConfigPath failed: %v", err) } @@ -89,7 +89,7 @@ ANOTHER_VAR=another_value` } defer os.Remove(testEnvFile) - envVars := getEnvironmentVars(testEnvFile) + envVars := (&appState{}).getEnvironmentVars(testEnvFile) expectedVars := []string{"WINDOWS_TEST_VAR=test_value", "ANOTHER_VAR=another_value"} if len(envVars) != len(expectedVars) { @@ -159,7 +159,7 @@ func TestWindowsCommandExecution(t *testing.T) { Entrypoint: []string{"cmd", "/c", "{{cmd}}"}, } - commands := getCommands(config) + commands := (&appState{}).getCommands(config) if len(commands) == 0 { t.Error("No commands generated from config") } From d22c6034253ea6d49f41b880fad22b00afd0b73d Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 17:36:12 +1000 Subject: [PATCH 13/16] fix: add errNoConfig sentinel to getConfigPath (#6) Replace the ambiguous ("", nil) return from getConfigPath when no .ahoy.yml is found with ("", errNoConfig). setupApp now tests with errors.Is(err, errNoConfig) so "no config" is clearly distinct from genuine filesystem errors, and the srcFile empty-string check is removed. --- ahoy.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ahoy.go b/ahoy.go index 440f077..d85e520 100644 --- a/ahoy.go +++ b/ahoy.go @@ -17,6 +17,10 @@ import ( "gopkg.in/yaml.v2" ) +// errNoConfig is returned by getConfigPath when no .ahoy.yml file can be +// located in the current directory tree and no -f flag was given. +var errNoConfig = errors.New("no .ahoy.yml config file found") + // Config handles the overall configuration in an ahoy.yml file // with one Config per file. type Config struct { @@ -159,7 +163,7 @@ func (s *appState) getConfigPath() (string, error) { dir = filepath.Dir(dir) } s.logger("debug", "Can't find an .ahoy.yml file.") - return "", nil + return "", errNoConfig } func getConfig(file string) (Config, error) { @@ -665,20 +669,16 @@ func (s *appState) setupApp(localArgs []string) *cobra.Command { var err error s.srcFile, err = s.getConfigPath() - if err != nil { + if errors.Is(err, errNoConfig) { + // No config found — supply default commands only and return early. + commands := s.addDefaultCommands([]*cobra.Command{}) + rootCmd.AddCommand(commands...) + return rootCmd + } else if err != nil { s.logger("fatal", err.Error()) } else { s.srcDir = filepath.Dir(s.srcFile) - if s.srcFile != "" { - s.importVisited[normalizePath(s.srcFile)] = true - } - // If we don't have a sourcefile, just supply the default commands and - // return — main() is responsible for calling Execute(). - if s.srcFile == "" { - commands := s.addDefaultCommands([]*cobra.Command{}) - rootCmd.AddCommand(commands...) - return rootCmd - } + s.importVisited[normalizePath(s.srcFile)] = true config, err := getConfig(s.srcFile) if err != nil { s.logger("fatal", err.Error()) From c9000e126e3745c31adbff10830f3aea42817457 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 17:41:17 +1000 Subject: [PATCH 14/16] test: add errNoConfig sentinel coverage to TestGetConfigPath --- ahoy_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ahoy_test.go b/ahoy_test.go index 8dcb123..49052a9 100644 --- a/ahoy_test.go +++ b/ahoy_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "fmt" "io" "log" @@ -235,6 +236,25 @@ func TestGetConfigPathErrorOnBogusPath(t *testing.T) { } } +func TestGetConfigPathReturnsErrNoConfigWhenNotFound(t *testing.T) { + // Change to a temp directory outside the repo tree so the walk-up never + // finds a .ahoy.yml and getConfigPath must return errNoConfig. + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + tmp := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(orig) }) + + _, gotErr := (&appState{}).getConfigPath() + if !errors.Is(gotErr, errNoConfig) { + t.Errorf("expected errNoConfig, got %v", gotErr) + } +} + func appRun(args []string) (string, error) { stdout := os.Stdout stderr := os.Stderr From cf7460aa03f4c62f7d2ff4073444b5866e423e21 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Tue, 16 Jun 2026 18:14:27 +1000 Subject: [PATCH 15/16] fix: resolve golangci-lint errcheck, unconvert, staticcheck, and noctx issues - Check all cmd.Help() and rootCmd.Help() return values - Consolidate four MarkHidden calls into a loop with _ = discard - Log error from stderr drain goroutine io.Copy - Replace if/else if entrypoint placeholder swap with tagged switch (QF1003) - Use http.NewRequestWithContext instead of client.Get to satisfy noctx - Remove unnecessary string() and []byte() conversions in tests - Handle os.Chdir and testFile.Write return values in tests - Discard cmd.Execute() return in appRun test helper --- ahoy.go | 36 ++++++++++++++++++++++++------------ ahoy_test.go | 18 ++++++++++++------ config_init.go | 7 ++++++- config_init_test.go | 4 +++- windows_test.go | 6 +++++- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/ahoy.go b/ahoy.go index d85e520..3a30e1d 100644 --- a/ahoy.go +++ b/ahoy.go @@ -375,9 +375,10 @@ func (s *appState) getCommands(config Config) []*cobra.Command { // Replace the entry point placeholders. cmdEntrypoint = config.Entrypoint[:] for i := range cmdEntrypoint { - if cmdEntrypoint[i] == "{{cmd}}" { + switch cmdEntrypoint[i] { + case "{{cmd}}": cmdEntrypoint[i] = cmdString - } else if cmdEntrypoint[i] == "{{name}}" { + case "{{name}}": cmdEntrypoint[i] = cmdName } } @@ -574,7 +575,9 @@ func (s *appState) noArgsAction(cmd *cobra.Command, args []string) { s.logger("fatal", msg) } - cmd.Help() + if err := cmd.Help(); err != nil { + s.logger("error", err.Error()) + } if s.srcFile == "" { s.logger("error", "No .ahoy.yml found. You can use 'ahoy init' to download an example.") @@ -610,12 +613,16 @@ func (s *appState) beforeCommand(cmd *cobra.Command, args []string) error { // Find the subcommand and show its help. for _, subcmd := range cmd.Commands() { if subcmd.Name() == args[0] { - subcmd.Help() + if err := subcmd.Help(); err != nil { + s.logger("error", err.Error()) + } os.Exit(0) } } } - cmd.Help() + if err := cmd.Help(); err != nil { + s.logger("error", err.Error()) + } os.Exit(0) } return nil @@ -657,10 +664,9 @@ func (s *appState) setupApp(localArgs []string) *cobra.Command { rootCmd.PersistentFlags().StringVar(&simulateVersion, "simulate-version", "", "simulate a specific Ahoy version for testing") // Mark help, version, and internal flags as hidden since we handle them manually. - rootCmd.PersistentFlags().MarkHidden("help") - rootCmd.PersistentFlags().MarkHidden("version") - rootCmd.PersistentFlags().MarkHidden("generate-bash-completion") - rootCmd.PersistentFlags().MarkHidden("simulate-version") + for _, name := range []string{"help", "version", "generate-bash-completion", "simulate-version"} { + _ = rootCmd.PersistentFlags().MarkHidden(name) + } // Disable default help command. rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) @@ -792,7 +798,9 @@ func main() { // Check for invalid flag error from initFlags - show help and exit 1. if state.invalidFlagError != "" { fmt.Print(state.invalidFlagError) - rootCmd.Help() + if err := rootCmd.Help(); err != nil { + log.Printf("help error: %v", err) + } os.Exit(1) } @@ -806,7 +814,9 @@ func main() { } if state.helpFlagSet { - rootCmd.Help() + if err := rootCmd.Help(); err != nil { + log.Printf("help error: %v", err) + } os.Exit(0) } @@ -838,7 +848,9 @@ func main() { drained := make(chan struct{}) go func() { defer close(drained) - io.Copy(oldStderr, r) + if _, err := io.Copy(oldStderr, r); err != nil { + log.Printf("stderr drain error: %v", err) + } }() err = rootCmd.Execute() diff --git a/ahoy_test.go b/ahoy_test.go index 49052a9..0b5a7ad 100644 --- a/ahoy_test.go +++ b/ahoy_test.go @@ -19,7 +19,7 @@ func TestOverrideExample(t *testing.T) { expected := "Overrode you.\n" actual, _ := appRun([]string{"ahoy", "-f", "testdata/override-base.ahoy.yml", "docker", "override-example"}) if expected != actual { - t.Errorf("ahoy docker override-example: expected - %s; actual - %s", string(expected), string(actual)) + t.Errorf("ahoy docker override-example: expected - %s; actual - %s", expected, actual) } } @@ -189,7 +189,9 @@ func TestGetConfig(t *testing.T) { t.Error("Something went wrong marshalling the test object.") } - testFile.Write([]byte(testYaml)) + if _, err = testFile.Write(testYaml); err != nil { + t.Fatal("Something went wrong writing the test yaml file.") + } config, err := getConfig("test_getConfig.yml") if err != nil { @@ -214,7 +216,7 @@ func TestGetConfigPath(t *testing.T) { expected := filepath.Join(pwd, ".ahoy.yml") actual, _ := (&appState{}).getConfigPath() if expected != actual { - t.Errorf("ahoy docker override-example: expected - %s; actual - %s", string(expected), string(actual)) + t.Errorf("ahoy docker override-example: expected - %s; actual - %s", expected, actual) } // Passing known path works as expected. @@ -222,7 +224,7 @@ func TestGetConfigPath(t *testing.T) { actual, _ = (&appState{sourcefile: expected}).getConfigPath() if expected != actual { - t.Errorf("ahoy docker override-example: expected - %s; actual - %s", string(expected), string(actual)) + t.Errorf("ahoy docker override-example: expected - %s; actual - %s", expected, actual) } // TODO: Passing directory should return default @@ -247,7 +249,11 @@ func TestGetConfigPathReturnsErrNoConfigWhenNotFound(t *testing.T) { if err := os.Chdir(tmp); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chdir(orig) }) + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }) _, gotErr := (&appState{}).getConfigPath() if !errors.Is(gotErr, errNoConfig) { @@ -307,7 +313,7 @@ func appRun(args []string) (string, error) { } cmd.SetArgs(cmdArgs) - cmd.Execute() + _ = cmd.Execute() w.Close() wErr.Close() diff --git a/config_init.go b/config_init.go index bcd25a5..e90f3a1 100644 --- a/config_init.go +++ b/config_init.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "fmt" "io" "net/http" @@ -31,7 +32,11 @@ func downloadFile(rawURL, destPath string) error { Timeout: 30 * time.Second, } - resp, err := client.Get(rawURL) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("failed to create request for %s: %v", rawURL, err) + } + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to fetch URL %s: %v", rawURL, err) } diff --git a/config_init_test.go b/config_init_test.go index ace208d..a048fed 100644 --- a/config_init_test.go +++ b/config_init_test.go @@ -48,7 +48,9 @@ func TestDownloadFile_InvalidURL(t *testing.T) { func TestDownloadFile_200Response(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("ahoyapi: v2\ncommands: {}\n")) + if _, err := w.Write([]byte("ahoyapi: v2\ncommands: {}\n")); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } })) defer srv.Close() diff --git a/windows_test.go b/windows_test.go index 035f750..122c854 100644 --- a/windows_test.go +++ b/windows_test.go @@ -58,7 +58,11 @@ commands: if err != nil { t.Fatalf("Failed to change directory: %v", err) } - defer os.Chdir(originalDir) + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() // Test that getConfigPath finds the .ahoy.yml file foundPath, err := (&appState{}).getConfigPath() From 7964d8f464975943b3628fe308c8e327e0661c14 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Wed, 17 Jun 2026 14:23:48 +1000 Subject: [PATCH 16/16] chore: Remove detritus Signed-off-by: Drew Robinson --- ahoy.go | 4 ++-- ahoy_test.go | 2 +- cli_parsing_test.go | 2 +- config_init.go | 2 +- tests/missing-cmd.bats | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ahoy.go b/ahoy.go index 3a30e1d..71a98cd 100644 --- a/ahoy.go +++ b/ahoy.go @@ -279,7 +279,7 @@ func (s *appState) getEnvironmentVars(envFile string) []string { if line == "" || strings.HasPrefix(line, "#") { continue } - // Warn on lines that don't contain '=' — common culprit is shell + // Warn on lines that don't contain '=' - common culprit is shell // `export KEY=VALUE` syntax, which is not supported here. if !strings.Contains(line, "=") { s.logger("warning", "ignoring malformed line in env file '"+envFile+"' (expected KEY=VALUE, got: "+line+")") @@ -676,7 +676,7 @@ func (s *appState) setupApp(localArgs []string) *cobra.Command { var err error s.srcFile, err = s.getConfigPath() if errors.Is(err, errNoConfig) { - // No config found — supply default commands only and return early. + // No config found - supply default commands only and return early. commands := s.addDefaultCommands([]*cobra.Command{}) rootCmd.AddCommand(commands...) return rootCmd diff --git a/ahoy_test.go b/ahoy_test.go index 0b5a7ad..c4475d4 100644 --- a/ahoy_test.go +++ b/ahoy_test.go @@ -46,7 +46,7 @@ func TestGetCommands(t *testing.T) { } func TestGetSubCommand(t *testing.T) { - // Each scenario uses its own appState — no global save/restore needed. + // Each scenario uses its own appState, no global save/restore needed. state := &appState{} // When empty return empty list of commands. diff --git a/cli_parsing_test.go b/cli_parsing_test.go index 02bc038..518614c 100644 --- a/cli_parsing_test.go +++ b/cli_parsing_test.go @@ -35,7 +35,7 @@ func TestFlagParsing(t *testing.T) { } func TestInitFlags(t *testing.T) { - // Test with empty flags — srcDir should be reset to empty string. + // Test with empty flags - srcDir should be reset to empty string. s := newAppState() s.initFlags([]string{}) if s.srcDir != "" { diff --git a/config_init.go b/config_init.go index e90f3a1..59c65c5 100644 --- a/config_init.go +++ b/config_init.go @@ -57,7 +57,7 @@ func downloadFile(rawURL, destPath string) error { // Always clean up the temp file; harmless no-op after a successful rename. defer os.Remove(tmpPath) - const maxDownloadBytes = 10 * 1024 * 1024 // 10 MB — generous for any YAML config file + const maxDownloadBytes = 5 * 1024 * 1024 // 5 MB - generous for any YAML config file if _, err = io.Copy(out, io.LimitReader(resp.Body, maxDownloadBytes)); err != nil { out.Close() return fmt.Errorf("failed to write file %s: %v", destPath, err) diff --git a/tests/missing-cmd.bats b/tests/missing-cmd.bats index 1df0b57..e16a447 100644 --- a/tests/missing-cmd.bats +++ b/tests/missing-cmd.bats @@ -36,7 +36,7 @@ EOF run ./ahoy -f "$tmpdir/a.ahoy.yml" list rm -rf "$tmpdir" # Circular imports result in a broken config (empty non-optional import group), - # so exit status is non-zero — but the process must not crash or stack overflow. + # so exit status is non-zero, but the process must not crash or stack overflow. [ $status -ne 0 ] [ "${lines[0]}" != "panic: runtime error: invalid memory address or nil pointer dereference" ] [[ "$output" =~ "Circular import detected" ]]