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 the github.com/cli/go-gh/v2/pkg/term package.
  • 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.go via IsInteractive(), which queries the term package and respects a ForceInteractive override.
  • Early returns in cmd/utils.go functions like ensureRerere and pickRemote convert interactive operations into no-ops or explicit errors when running in CI.
  • Conditional UI in commands such as view suppresses 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →