changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git

feat(tty): implement TTY detection and error handling for non-interactive environments

Changed files
+573 -11
cmd
internal
+4 -4
ROADMAP.md
··· 108 - [x] `release --dry-run` 109 - [x] Show what would be written without writing 110 - [x] Display preview of CHANGELOG changes with styled output 111 - - [ ] Non-TTY environment handling 112 - - [ ] Detect TTY availability 113 - - [ ] Fallback to non-interactive mode 114 - - [ ] CI-friendly error messages 115 - [ ] Add pre-commit hook examples 116 - [ ] Validate commit message format 117 - [ ] Ensure `.changes/` entries exist for features
··· 108 - [x] `release --dry-run` 109 - [x] Show what would be written without writing 110 - [x] Display preview of CHANGELOG changes with styled output 111 + - [x] Non-TTY environment handling 112 + - [x] Detect TTY availability 113 + - [x] Fallback to non-interactive mode 114 + - [x] CI-friendly error messages 115 - [ ] Add pre-commit hook examples 116 - [ ] Validate commit message format 117 - [ ] Ensure `.changes/` entries exist for features
+44
cmd/diff.go
··· 34 "github.com/spf13/cobra" 35 "github.com/stormlightlabs/git-storm/internal/diff" 36 "github.com/stormlightlabs/git-storm/internal/gitlog" 37 "github.com/stormlightlabs/git-storm/internal/ui" 38 ) 39 ··· 124 }) 125 } 126 127 model := ui.NewMultiFileDiffModel(allDiffs, expanded, view) 128 129 p := tea.NewProgram(model, tea.WithAltScreen()) ··· 144 return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName) 145 } 146 }
··· 34 "github.com/spf13/cobra" 35 "github.com/stormlightlabs/git-storm/internal/diff" 36 "github.com/stormlightlabs/git-storm/internal/gitlog" 37 + "github.com/stormlightlabs/git-storm/internal/tty" 38 "github.com/stormlightlabs/git-storm/internal/ui" 39 ) 40 ··· 125 }) 126 } 127 128 + if !tty.IsInteractive() { 129 + return outputPlainDiff(allDiffs, expanded, view) 130 + } 131 + 132 model := ui.NewMultiFileDiffModel(allDiffs, expanded, view) 133 134 p := tea.NewProgram(model, tea.WithAltScreen()) ··· 149 return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName) 150 } 151 } 152 + 153 + // outputPlainDiff outputs diffs in plain text format for non-interactive environments. 154 + // 155 + // TODO: move this to package [diff] 156 + func outputPlainDiff(allDiffs []ui.FileDiff, expanded bool, view diff.DiffViewKind) error { 157 + for i, fileDiff := range allDiffs { 158 + fmt.Printf("=== File %d/%d ===\n", i+1, len(allDiffs)) 159 + fmt.Printf("--- %s\n", fileDiff.OldPath) 160 + fmt.Printf("+++ %s\n", fileDiff.NewPath) 161 + fmt.Println() 162 + 163 + var formatter diff.Formatter 164 + switch view { 165 + case diff.ViewUnified: 166 + formatter = &diff.UnifiedFormatter{ 167 + TerminalWidth: 80, 168 + ShowLineNumbers: true, 169 + Expanded: expanded, 170 + EnableWordWrap: false, 171 + } 172 + default: 173 + formatter = &diff.SideBySideFormatter{ 174 + TerminalWidth: 80, 175 + ShowLineNumbers: true, 176 + Expanded: expanded, 177 + EnableWordWrap: false, 178 + } 179 + } 180 + 181 + output := formatter.Format(fileDiff.Edits) 182 + fmt.Println(output) 183 + 184 + if i < len(allDiffs)-1 { 185 + fmt.Println() 186 + } 187 + } 188 + 189 + return nil 190 + }
+5 -1
cmd/generate.go
··· 22 "github.com/stormlightlabs/git-storm/internal/changeset" 23 "github.com/stormlightlabs/git-storm/internal/gitlog" 24 "github.com/stormlightlabs/git-storm/internal/style" 25 "github.com/stormlightlabs/git-storm/internal/ui" 26 ) 27 ··· 74 interactive review mode.`, 75 Args: cobra.MaximumNArgs(2), 76 RunE: func(cmd *cobra.Command, args []string) error { 77 var from, to string 78 79 if sinceTag != "" { ··· 246 247 c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI") 248 c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag") 249 - 250 return c 251 }
··· 22 "github.com/stormlightlabs/git-storm/internal/changeset" 23 "github.com/stormlightlabs/git-storm/internal/gitlog" 24 "github.com/stormlightlabs/git-storm/internal/style" 25 + "github.com/stormlightlabs/git-storm/internal/tty" 26 "github.com/stormlightlabs/git-storm/internal/ui" 27 ) 28 ··· 75 interactive review mode.`, 76 Args: cobra.MaximumNArgs(2), 77 RunE: func(cmd *cobra.Command, args []string) error { 78 + if interactive && !tty.IsInteractive() { 79 + return tty.ErrorInteractiveFlag("--interactive") 80 + } 81 + 82 var from, to string 83 84 if sinceTag != "" { ··· 251 252 c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI") 253 c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag") 254 return c 255 }
+8
cmd/unreleased.go
··· 65 "github.com/stormlightlabs/git-storm/internal/changeset" 66 "github.com/stormlightlabs/git-storm/internal/gitlog" 67 "github.com/stormlightlabs/git-storm/internal/style" 68 "github.com/stormlightlabs/git-storm/internal/ui" 69 ) 70 ··· 149 Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 150 unreleased entries before final release.`, 151 RunE: func(cmd *cobra.Command, args []string) error { 152 entries, err := changeset.List(changesDir) 153 if err != nil { 154 return fmt.Errorf("failed to list changelog entries: %w", err)
··· 65 "github.com/stormlightlabs/git-storm/internal/changeset" 66 "github.com/stormlightlabs/git-storm/internal/gitlog" 67 "github.com/stormlightlabs/git-storm/internal/style" 68 + "github.com/stormlightlabs/git-storm/internal/tty" 69 "github.com/stormlightlabs/git-storm/internal/ui" 70 ) 71 ··· 150 Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 151 unreleased entries before final release.`, 152 RunE: func(cmd *cobra.Command, args []string) error { 153 + if !tty.IsInteractive() { 154 + return tty.ErrorInteractiveRequired("storm unreleased review", []string{ 155 + "Use 'storm unreleased list' to view entries in plain text", 156 + "Use 'storm unreleased list --json' for JSON output", 157 + }) 158 + } 159 + 160 entries, err := changeset.List(changesDir) 161 if err != nil { 162 return fmt.Errorf("failed to list changelog entries: %w", err)
+4 -1
go.mod
··· 11 github.com/spf13/cobra v1.10.1 12 ) 13 14 - require github.com/goccy/go-yaml v1.18.0 15 16 require github.com/atotto/clipboard v0.1.4 // indirect 17
··· 11 github.com/spf13/cobra v1.10.1 12 ) 13 14 + require ( 15 + github.com/goccy/go-yaml v1.18.0 16 + golang.org/x/term v0.36.0 17 + ) 18 19 require github.com/atotto/clipboard v0.1.4 // indirect 20
+4
internal/diff/format.go
··· 32 compressedIndicator = "⋮" 33 ) 34 35 // SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting. 36 type SideBySideFormatter struct { 37 // TerminalWidth is the total available width for rendering
··· 32 compressedIndicator = "⋮" 33 ) 34 35 + type Formatter interface { 36 + Format(edits []Edit) string 37 + } 38 + 39 // SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting. 40 type SideBySideFormatter struct { 41 // TerminalWidth is the total available width for rendering
+106 -5
internal/docs/README.md
··· 1 --- 2 title: Testing Workflow 3 updated: 2025-11-08 4 - version: 2 5 --- 6 7 "Ride the lightning." 8 9 This document provides a comprehensive testing workflow for the `storm` changelog manager. 10 - All tests should be run within this repository to validate functionality against real Git history. 11 12 ## Setup 13 ··· 16 task build 17 ``` 18 19 ## Core Workflow 20 21 ### Manual Entry Creation (`unreleased add`) ··· 195 - Allows selection/deselection 196 - Creates only selected entries 197 - Handles cancellation (Ctrl+C) 198 199 #### Since tag 200 ··· 323 324 - Empty changes directory (should show message, not crash) 325 - Corrupted entry file (should handle gracefully) 326 - - Non-TTY environment (should detect and warn) 327 - Cancel review (Esc/q) - no changes applied 328 - Delete file that no longer exists (should error gracefully) 329 - Edit with empty fields (fields preserve original if empty) ··· 463 464 **Expected:** 465 466 - - Shows unified diff with syntax highlighting 467 - - Iceberg theme colors 468 - Context lines displayed 469 - File headers shown 470 ··· 484 - No changes between refs 485 - Binary files (should indicate) 486 - Large diffs (should handle gracefully)
··· 1 --- 2 title: Testing Workflow 3 updated: 2025-11-08 4 + version: 3 5 --- 6 7 "Ride the lightning." 8 9 This document provides a comprehensive testing workflow for the `storm` changelog manager. 10 + All tests should be run within this repository to validate functionality against real Git 11 + history. 12 13 ## Setup 14 ··· 17 task build 18 ``` 19 20 + ## Non-TTY Environment Handling 21 + 22 + Storm automatically detects whether it's running in an interactive terminal (TTY) or a 23 + non-interactive environment (CI pipelines, scripts, pipes). Commands gracefully degrade 24 + or provide helpful error messages. 25 + 26 + ### TTY Detection 27 + 28 + The CLI checks for: 29 + 30 + - Terminal availability on stdin/stdout 31 + - Common CI environment variables (GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, etc.) 32 + 33 + ### Command Behavior 34 + 35 + #### `generate --interactive` 36 + 37 + **Interactive (TTY):** Launches TUI for commit selection 38 + **Non-Interactive:** Returns error with suggestion to use non-interactive mode 39 + 40 + ```bash 41 + # CI/Non-TTY 42 + storm generate HEAD~5 HEAD --interactive 43 + # Error: flag '--interactive' requires an interactive terminal (detected GitHub Actions environment) 44 + ``` 45 + 46 + **Workaround:** 47 + 48 + ```bash 49 + # Use without --interactive flag for automatic processing 50 + storm generate HEAD~5 HEAD 51 + ``` 52 + 53 + #### `unreleased review` 54 + 55 + **Interactive (TTY):** Launches TUI for reviewing entries 56 + **Non-Interactive:** Returns error with alternatives 57 + 58 + ```bash 59 + # CI/Non-TTY 60 + storm unreleased review 61 + # Error: command 'storm unreleased review' requires an interactive terminal (detected CI environment) 62 + # 63 + # Alternatives: 64 + # - Use 'storm unreleased list' to view entries in plain text 65 + # - Use 'storm unreleased list --json' for JSON output 66 + ``` 67 + 68 + #### `diff` 69 + 70 + **Interactive (TTY):** Launches TUI for navigating diffs 71 + **Non-Interactive:** Outputs plain text diff to stdout 72 + 73 + ```bash 74 + # CI/Non-TTY - automatically outputs plain text 75 + storm diff HEAD~1 HEAD 76 + # === File 1/3 === 77 + # --- HEAD~1:file.go 78 + # +++ HEAD:file.go 79 + # [plain text diff output] 80 + ``` 81 + 82 + ### Testing Non-TTY Behavior 83 + 84 + #### Simulate CI environment 85 + 86 + ```bash 87 + CI=true storm unreleased review 88 + # Should error with CI-friendly message 89 + ``` 90 + 91 + #### Pipe output 92 + 93 + ```bash 94 + storm diff HEAD~1 HEAD | less 95 + # Should output plain text diff (not TUI) 96 + ``` 97 + 98 + #### Redirect to file 99 + 100 + ```bash 101 + storm diff HEAD~1 HEAD > changes.diff 102 + # Should write plain text to file 103 + ``` 104 + 105 + **Expected Behaviors:** 106 + 107 + - Clear error messages indicating TTY requirement 108 + - Suggestions for alternative commands 109 + - CI system name detection (e.g., "detected GitHub Actions environment") 110 + - Automatic fallback to plain text for `diff` command 111 + - No ANSI escape codes in piped/redirected output 112 + 113 ## Core Workflow 114 115 ### Manual Entry Creation (`unreleased add`) ··· 289 - Allows selection/deselection 290 - Creates only selected entries 291 - Handles cancellation (Ctrl+C) 292 + - Errors gracefully in non-TTY with helpful message 293 294 #### Since tag 295 ··· 418 419 - Empty changes directory (should show message, not crash) 420 - Corrupted entry file (should handle gracefully) 421 + - Non-TTY environment (detects and errors with alternatives) 422 + - CI environment (detects CI system name in error message) 423 - Cancel review (Esc/q) - no changes applied 424 - Delete file that no longer exists (should error gracefully) 425 - Edit with empty fields (fields preserve original if empty) ··· 559 560 **Expected:** 561 562 + - TTY: Launches interactive TUI with navigation 563 + - Non-TTY: Outputs plain text diff to stdout 564 + - Shows diff with syntax highlighting (TTY only) 565 + - Iceberg theme colors (TTY only) 566 - Context lines displayed 567 - File headers shown 568 ··· 582 - No changes between refs 583 - Binary files (should indicate) 584 - Large diffs (should handle gracefully) 585 + - Non-TTY environment (automatic plain text output) 586 + - Piped output (plain text format) 587 + - Redirected to file (plain text format)
+110
internal/tty/tty.go
···
··· 1 + // package tty provides utilities for detecting terminal (TTY) availability and 2 + // generating appropriate fallback behavior for non-interactive environments. 3 + package tty 4 + 5 + import ( 6 + "errors" 7 + "fmt" 8 + "os" 9 + 10 + "golang.org/x/term" 11 + ) 12 + 13 + // IsTTY checks if the given file descriptor is a terminal. 14 + func IsTTY(fd uintptr) bool { 15 + return term.IsTerminal(int(fd)) 16 + } 17 + 18 + // IsInteractive checks if both stdin and stdout are connected to a terminal. 19 + // This is the primary check for determining if TUI applications can run. 20 + func IsInteractive() bool { 21 + return IsTTY(os.Stdin.Fd()) && IsTTY(os.Stdout.Fd()) 22 + } 23 + 24 + // IsCI detects if the current environment is a CI system by checking for common 25 + // CI environment variables. 26 + func IsCI() bool { 27 + ciEnvVars := []string{ 28 + "CI", // Generic CI indicator 29 + "CONTINUOUS_INTEGRATION", 30 + "GITHUB_ACTIONS", 31 + "GITLAB_CI", 32 + "CIRCLECI", 33 + "TRAVIS", 34 + "JENKINS_URL", 35 + "BUILDKITE", 36 + "DRONE", 37 + "TEAMCITY_VERSION", 38 + } 39 + 40 + for _, envVar := range ciEnvVars { 41 + if os.Getenv(envVar) != "" { 42 + return true 43 + } 44 + } 45 + 46 + return false 47 + } 48 + 49 + // GetCIName attempts to identify the specific CI system being used. 50 + func GetCIName() string { 51 + ciMap := map[string]string{ 52 + "GITHUB_ACTIONS": "GitHub Actions", 53 + "GITLAB_CI": "GitLab CI", 54 + "CIRCLECI": "CircleCI", 55 + "TRAVIS": "Travis CI", 56 + "JENKINS_URL": "Jenkins", 57 + "BUILDKITE": "Buildkite", 58 + "DRONE": "Drone CI", 59 + "TEAMCITY_VERSION": "TeamCity", 60 + } 61 + 62 + for envVar, name := range ciMap { 63 + if os.Getenv(envVar) != "" { 64 + return name 65 + } 66 + } 67 + 68 + if IsCI() { 69 + return "CI" 70 + } 71 + 72 + return "" 73 + } 74 + 75 + // ErrorInteractiveRequired returns a formatted error message indicating that the 76 + // command requires an interactive terminal, with suggestions for alternatives. 77 + func ErrorInteractiveRequired(commandName string, alternatives []string) error { 78 + msg := fmt.Sprintf("command '%s' requires an interactive terminal", commandName) 79 + 80 + if IsCI() { 81 + ciName := GetCIName() 82 + msg += fmt.Sprintf(" (detected %s environment)", ciName) 83 + } else { 84 + msg += " (stdin is not a TTY)" 85 + } 86 + 87 + if len(alternatives) > 0 { 88 + msg += "\n\nAlternatives:" 89 + for _, alt := range alternatives { 90 + msg += fmt.Sprintf("\n - %s", alt) 91 + } 92 + } 93 + 94 + return errors.New(msg) 95 + } 96 + 97 + // ErrorInteractiveFlag returns a formatted error message indicating that an 98 + // interactive flag cannot be used in a non-TTY environment. 99 + func ErrorInteractiveFlag(flagName string) error { 100 + msg := fmt.Sprintf("flag '%s' requires an interactive terminal", flagName) 101 + 102 + if IsCI() { 103 + ciName := GetCIName() 104 + msg += fmt.Sprintf(" (detected %s environment)", ciName) 105 + } else { 106 + msg += " (stdin is not a TTY)" 107 + } 108 + 109 + return errors.New(msg) 110 + }
+288
internal/tty/tty_test.go
···
··· 1 + package tty 2 + 3 + import ( 4 + "os" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + func TestIsCI(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + envVars map[string]string 13 + expected bool 14 + }{ 15 + { 16 + name: "no CI vars", 17 + envVars: map[string]string{}, 18 + expected: false, 19 + }, 20 + { 21 + name: "generic CI var", 22 + envVars: map[string]string{"CI": "true"}, 23 + expected: true, 24 + }, 25 + { 26 + name: "GitHub Actions", 27 + envVars: map[string]string{"GITHUB_ACTIONS": "true"}, 28 + expected: true, 29 + }, 30 + { 31 + name: "GitLab CI", 32 + envVars: map[string]string{"GITLAB_CI": "true"}, 33 + expected: true, 34 + }, 35 + { 36 + name: "CircleCI", 37 + envVars: map[string]string{"CIRCLECI": "true"}, 38 + expected: true, 39 + }, 40 + { 41 + name: "multiple CI vars", 42 + envVars: map[string]string{"CI": "true", "TRAVIS": "true"}, 43 + expected: true, 44 + }, 45 + } 46 + 47 + for _, tt := range tests { 48 + t.Run(tt.name, func(t *testing.T) { 49 + ciEnvVars := []string{ 50 + "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", 51 + "GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL", 52 + "BUILDKITE", "DRONE", "TEAMCITY_VERSION", 53 + } 54 + for _, v := range ciEnvVars { 55 + os.Unsetenv(v) 56 + } 57 + 58 + for k, v := range tt.envVars { 59 + os.Setenv(k, v) 60 + } 61 + 62 + defer func() { 63 + for k := range tt.envVars { 64 + os.Unsetenv(k) 65 + } 66 + }() 67 + 68 + result := IsCI() 69 + if result != tt.expected { 70 + t.Errorf("IsCI() = %v, expected %v", result, tt.expected) 71 + } 72 + }) 73 + } 74 + } 75 + 76 + func TestGetCIName(t *testing.T) { 77 + tests := []struct { 78 + name string 79 + envVar string 80 + expected string 81 + }{ 82 + { 83 + name: "GitHub Actions", 84 + envVar: "GITHUB_ACTIONS", 85 + expected: "GitHub Actions", 86 + }, 87 + { 88 + name: "GitLab CI", 89 + envVar: "GITLAB_CI", 90 + expected: "GitLab CI", 91 + }, 92 + { 93 + name: "CircleCI", 94 + envVar: "CIRCLECI", 95 + expected: "CircleCI", 96 + }, 97 + { 98 + name: "Travis CI", 99 + envVar: "TRAVIS", 100 + expected: "Travis CI", 101 + }, 102 + { 103 + name: "Jenkins", 104 + envVar: "JENKINS_URL", 105 + expected: "Jenkins", 106 + }, 107 + { 108 + name: "Buildkite", 109 + envVar: "BUILDKITE", 110 + expected: "Buildkite", 111 + }, 112 + { 113 + name: "Drone CI", 114 + envVar: "DRONE", 115 + expected: "Drone CI", 116 + }, 117 + { 118 + name: "TeamCity", 119 + envVar: "TEAMCITY_VERSION", 120 + expected: "TeamCity", 121 + }, 122 + { 123 + name: "Generic CI", 124 + envVar: "CI", 125 + expected: "CI", 126 + }, 127 + { 128 + name: "No CI", 129 + envVar: "", 130 + expected: "", 131 + }, 132 + } 133 + 134 + for _, tt := range tests { 135 + t.Run(tt.name, func(t *testing.T) { 136 + ciEnvVars := []string{ 137 + "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", 138 + "GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL", 139 + "BUILDKITE", "DRONE", "TEAMCITY_VERSION", 140 + } 141 + for _, v := range ciEnvVars { 142 + os.Unsetenv(v) 143 + } 144 + 145 + if tt.envVar != "" { 146 + os.Setenv(tt.envVar, "true") 147 + } 148 + 149 + defer func() { 150 + if tt.envVar != "" { 151 + os.Unsetenv(tt.envVar) 152 + } 153 + }() 154 + 155 + result := GetCIName() 156 + if result != tt.expected { 157 + t.Errorf("GetCIName() = %q, expected %q", result, tt.expected) 158 + } 159 + }) 160 + } 161 + } 162 + 163 + func TestErrorInteractiveRequired(t *testing.T) { 164 + tests := []struct { 165 + name string 166 + commandName string 167 + alternatives []string 168 + ciEnv string 169 + wantContains []string 170 + }{ 171 + { 172 + name: "basic error", 173 + commandName: "review", 174 + wantContains: []string{ 175 + "command 'review' requires an interactive terminal", 176 + }, 177 + }, 178 + { 179 + name: "with alternatives", 180 + commandName: "review", 181 + alternatives: []string{ 182 + "Use 'storm unreleased list' to view entries", 183 + "Use 'storm unreleased list --json' for JSON output", 184 + }, 185 + wantContains: []string{ 186 + "command 'review' requires an interactive terminal", 187 + "Alternatives:", 188 + "Use 'storm unreleased list' to view entries", 189 + "Use 'storm unreleased list --json' for JSON output", 190 + }, 191 + }, 192 + { 193 + name: "CI environment", 194 + commandName: "diff", 195 + ciEnv: "GITHUB_ACTIONS", 196 + wantContains: []string{ 197 + "command 'diff' requires an interactive terminal", 198 + "detected GitHub Actions environment", 199 + }, 200 + }, 201 + } 202 + 203 + for _, tt := range tests { 204 + t.Run(tt.name, func(t *testing.T) { 205 + ciEnvVars := []string{ 206 + "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", 207 + "GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL", 208 + "BUILDKITE", "DRONE", "TEAMCITY_VERSION", 209 + } 210 + for _, v := range ciEnvVars { 211 + os.Unsetenv(v) 212 + } 213 + 214 + if tt.ciEnv != "" { 215 + os.Setenv(tt.ciEnv, "true") 216 + defer os.Unsetenv(tt.ciEnv) 217 + } 218 + 219 + err := ErrorInteractiveRequired(tt.commandName, tt.alternatives) 220 + if err == nil { 221 + t.Fatal("ErrorInteractiveRequired() returned nil, expected error") 222 + } 223 + 224 + errMsg := err.Error() 225 + for _, want := range tt.wantContains { 226 + if !strings.Contains(errMsg, want) { 227 + t.Errorf("ErrorInteractiveRequired() error message missing %q\nGot: %s", want, errMsg) 228 + } 229 + } 230 + }) 231 + } 232 + } 233 + 234 + func TestErrorInteractiveFlag(t *testing.T) { 235 + tests := []struct { 236 + name string 237 + flagName string 238 + ciEnv string 239 + wantContains []string 240 + }{ 241 + { 242 + name: "basic error", 243 + flagName: "--interactive", 244 + wantContains: []string{ 245 + "flag '--interactive' requires an interactive terminal", 246 + }, 247 + }, 248 + { 249 + name: "CI environment", 250 + flagName: "--interactive", 251 + ciEnv: "GITLAB_CI", 252 + wantContains: []string{ 253 + "flag '--interactive' requires an interactive terminal", 254 + "detected GitLab CI environment", 255 + }, 256 + }, 257 + } 258 + 259 + for _, tt := range tests { 260 + t.Run(tt.name, func(t *testing.T) { 261 + ciEnvVars := []string{ 262 + "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", 263 + "GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL", 264 + "BUILDKITE", "DRONE", "TEAMCITY_VERSION", 265 + } 266 + for _, v := range ciEnvVars { 267 + os.Unsetenv(v) 268 + } 269 + 270 + if tt.ciEnv != "" { 271 + os.Setenv(tt.ciEnv, "true") 272 + defer os.Unsetenv(tt.ciEnv) 273 + } 274 + 275 + err := ErrorInteractiveFlag(tt.flagName) 276 + if err == nil { 277 + t.Fatal("ErrorInteractiveFlag() returned nil, expected error") 278 + } 279 + 280 + errMsg := err.Error() 281 + for _, want := range tt.wantContains { 282 + if !strings.Contains(errMsg, want) { 283 + t.Errorf("ErrorInteractiveFlag() error message missing %q\nGot: %s", want, errMsg) 284 + } 285 + } 286 + }) 287 + } 288 + }