How gh-stack Handles Non-Interactive Terminals and CI Environments
gh-stack detects TTY availability through Config.IsInteractive()—wrapping the term package—to automatically disable prompts, TUI spinners, and interactive workflows when stdout is not a terminal, ensuring safe execution in CI pipelines and scripted automation.
When automating stacked pull requests in continuous integration environments, command-line tools must avoid blocking for user input. The github/gh-stack repository implements a robust terminal detection strategy that gates every interactive feature behind a single configuration method. This design allows the tool to operate seamlessly across local development shells and non-interactive CI runners without hanging or failing.
Terminal Detection via Config.IsInteractive()
At the core of gh-stack’s adaptability is the IsInteractive() method defined in internal/config/config.go. This function combines runtime terminal detection with a test override flag:
func (c *Config) IsInteractive() bool {
return c.ForceInteractive || c.Terminal.IsTerminalOutput()
}
c.Terminal.IsTerminalOutput()returns true only when stdout is attached to a TTY, using thegithub.com/cli/go-gh/v2/pkg/termpackage.ForceInteractive(default false) is a test-only boolean that forces interactive mode regardless of the actual terminal state.
Every command that might require user interaction first queries this method. When IsInteractive() evaluates to false—typical in CI systems, non-TTY shells, or piped commands—the tool follows a non-interactive code path that eliminates all blocking operations.
Non-Interactive Guard Clauses in Critical Paths
The codebase implements specific guard clauses in utility functions and command handlers to prevent interactive operations when running unattended:
Git Rerere Prompting (ensureRerere)
The ensureRerere function in cmd/utils.go checks the configuration before attempting any user-facing prompts:
if !cfg.IsInteractive() {
return nil
}
This early return makes the call a no-op in CI environments, allowing commands to proceed without attempting to configure rerere interactively【/cache/repos/github.com/github/gh-stack/main/cmd/utils.go#L72-L74】.
Remote Selection (pickRemote)
When multiple remotes exist and the session lacks interactivity, the tool aborts with a descriptive error rather than hanging:
if !cfg.IsInteractive() {
return "", fmt.Errorf("multiple remotes configured...")
}
This prevents CI jobs from stalling indefinitely when the remote cannot be determined automatically【/cache/repos/github.com/github/gh-stack/main/cmd/utils.go#L15-L17】.
TUI Loading States (gh stack view)
The view command conditionally renders a "Loading stack..." spinner based on terminal capabilities:
if !opts.short && cfg.IsInteractive() {
// spinner initialization
}
In non-interactive runs, the spinner is suppressed to avoid polluting log files with ANSI escape sequences【/cache/repos/github.com/github/gh-stack/main/cmd/view.go#L78-L82】.
Modify and Submit Workflows
Commands like modify and submit check cfg.IsInteractive() before entering interactive state machines. When false, these commands exit early or select default behaviors, ensuring that automated scripts never encounter unexpected prompt loops.
CI-Specific Safety Patterns
Beyond conditional logic, the codebase includes explicit documentation for CI compatibility. A comment in cmd/utils.go notes the design intent for certain utility functions:
// no-op so commands can still run in CI/scripting.
This pattern—returning early or selecting safe defaults—appears throughout the cmd/ package, ensuring that operations requiring user judgment become inert rather than failing catastrophically when stdin is not available【/cache/repos/github.com/github/gh-stack/main/cmd/utils.go#L1358】.
Implementation Examples
Detecting terminal state before custom logic:
cfg := config.New()
if cfg.IsInteractive() {
// Safe to launch survey prompts or bubbletea TUIs
result, err := interactivePrompt()
} else {
// Use environment variables or defaults
result = os.Getenv("DEFAULT_VALUE")
}
Handling remote selection in scripts:
remote, err := pickRemote(cfg, currentBranch, "")
if err != nil {
// In CI: log error and exit with non-zero status
log.Fatalf("Non-interactive environment: %v", err)
}
// Proceed with identified remote
Forcing interactive mode for testing:
cfg := config.New()
cfg.ForceInteractive = true // Bypass TTY check in unit tests
Summary
- Centralized detection occurs in
internal/config/config.goviaIsInteractive(), which queries thetermpackage and respects aForceInteractiveoverride. - Early returns in
cmd/utils.gofunctions likeensureRerereandpickRemoteconvert interactive operations into no-ops or explicit errors when running in CI. - Conditional UI in commands such as
viewsuppresses spinners and TUI elements based on the terminal state. - Explicit CI safety comments document the intentional no-op behavior for scripting environments throughout the codebase.
Frequently Asked Questions
How does gh-stack detect if it is running in CI?
gh-stack relies on the term package’s IsTerminalOutput() method to determine if stdout is attached to a TTY. When this returns false—common in CI containers and piped shells—the Config.IsInteractive() method returns false, triggering non-interactive code paths. There is no explicit CI environment variable check; the detection is purely based on terminal capabilities.
What happens if a command requires user input in a non-interactive shell?
Commands requiring input check cfg.IsInteractive() first. If false, they either return immediately (no-op), return an error explaining that interaction is impossible, or select safe defaults. For example, pickRemote returns an error when multiple remotes exist and the terminal is non-interactive, while ensureRerere simply skips its configuration prompt.
Can I force interactive mode in tests or scripts?
Yes. The Config struct exposes a ForceInteractive boolean field that, when set to true, causes IsInteractive() to return true regardless of the actual terminal state. This is intended primarily for unit testing interactive workflows in headless environments, allowing test suites to exercise prompt logic without a real TTY.
Which commands are affected by the non-interactive check?
All commands in the cmd/ directory that might display UI or request input implement this check. Notable examples include view (suppresses the loading spinner), modify (skips the interactive state machine), submit (bypasses confirmation prompts), and utility functions like ensureRerere and pickRemote in cmd/utils.go. Any future command following the repository’s patterns should gate interactive behavior behind cfg.IsInteractive().
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →