Read OSS

How `brew` Goes from Shell to Ruby: The Three-Stage Boot Sequence

Intermediate

Prerequisites

  • Basic Bash scripting (variables, sourcing, exec)
  • Ruby fundamentals (require, modules, constants)
  • Understanding of Unix environment variables and process execution

How brew Goes from Shell to Ruby: The Three-Stage Boot Sequence

Every time you type brew install foo, you trigger a carefully orchestrated three-stage pipeline that crosses language boundaries twice before a single line of Ruby package management code runs. This isn't accidental complexity — it's a deliberate architecture that optimizes for startup speed, environment isolation, and platform portability. Understanding this pipeline is the key to understanding everything else in Homebrew's codebase.

The three stages are: a Bash entry point (bin/brew) that sanitizes the environment, a shell bootstrap (brew.sh) that detects the platform and handles fast-path commands, and finally a Ruby entry point (brew.rb) that resolves and dispatches commands. The universal interface between all three stages? Environment variables.

The Big Picture

Before diving into each stage, here's how the entire boot sequence flows:

flowchart TD
    A["User types: brew install foo"] --> B["bin/brew (Bash)"]
    B --> C["Validate preconditions"]
    C --> D["Resolve HOMEBREW_PREFIX via symlinks"]
    D --> E["Load layered brew.env files"]
    E --> F["Copy user env vars into HOMEBREW_* namespace"]
    F --> G["Sanitize environment with env -i"]
    G --> H["brew.sh (Bash)"]
    H --> I{"Fast-path command?"}
    I -->|Yes| J["Execute in shell, exit"]
    I -->|No| K["Platform detection & config"]
    K --> L["Auto-update check"]
    L --> M["exec into Ruby"]
    M --> N["brew.rb (Ruby)"]
    N --> O["Parse ARGV, resolve command"]
    O --> P["Dispatch to command class"]

Stage 1: The Bash Entry Point (bin/brew)

The journey begins in bin/brew, a ~330-line Bash script that serves as the sole entry point for every brew invocation. Its job is deceptively simple: get the environment into a known, safe state.

Precondition Validation

The script opens with aggressive validation. It checks that Bash is the running shell, that $PWD exists and is readable, and that $HOME is set. These aren't paranoid checks — they've each been added in response to real-world bug reports:

bin/brew#L1-L37

One subtle detail at line 59: the script re-enables all Bash builtins and unsets any user-defined functions that shadow them. This prevents a malicious or buggy .bashrc from redefining cd, echo, or other builtins that the rest of the boot sequence depends on.

Lines 73–104 resolve the critical HOMEBREW_PREFIX and HOMEBREW_REPOSITORY paths. Because brew is often a symlink (e.g., /opt/homebrew/bin/brew → ../Library/Homebrew/../../bin/brew), the script chases symlinks to find the real repository location. There's a special case for macOS x86_64 where /usr/local/bin/brew might be a symlink — the script detects this and sets HOMEBREW_PREFIX to /usr/local for bottle compatibility:

bin/brew#L73-L104

Layered Configuration Loading

Homebrew loads configuration from three brew.env files in a specific order, each able to override the previous:

flowchart TD
    A["/etc/homebrew/brew.env<br/>(System-wide)"] --> B["HOMEBREW_PREFIX/etc/homebrew/brew.env<br/>(Prefix-level)"]
    B --> C["~/.homebrew/brew.env<br/>(User-level)"]
    C --> D{"HOMEBREW_SYSTEM_ENV_TAKES_PRIORITY?"}
    D -->|Yes| E["Reload /etc/homebrew/brew.env<br/>(System wins)"]
    D -->|No| F["User config takes priority"]

The export_homebrew_env_file function at line 125 is carefully guarded — it only loads variables matching HOMEBREW_*, proxy settings, or SUDO_ASKPASS, and it explicitly blocks overriding the four core path variables (HOMEBREW_BREW_FILE, HOMEBREW_PREFIX, HOMEBREW_REPOSITORY, HOMEBREW_LIBRARY).

The User-to-HOMEBREW Namespace Copy

Lines 194–248 implement a pattern I find particularly clever. Homebrew copies standard user environment variables (like EDITOR, BROWSER, PATH) into HOMEBREW_* namespaced equivalents. This means the Ruby code never reads $EDITOR directly — it reads $HOMEBREW_EDITOR. This provides a clean abstraction boundary and allows users to set Homebrew-specific values without affecting their general environment.

bin/brew#L194-L248

Environment Sanitization: The Nuclear Option

The script ends with the single most important line in the entire boot sequence:

bin/brew#L332

exec /usr/bin/env -i "${FILTERED_ENV[@]}" /bin/bash -p "${HOMEBREW_LIBRARY}/Homebrew/brew.sh" "$@"

The env -i flag completely wipes the environment, then rebuilds it from a carefully curated allowlist. Only HOME, SHELL, PATH, TERM, proxy variables, and HOMEBREW_* variables survive. This is the firewall between the user's potentially messy environment and Homebrew's build system — without it, stray CFLAGS or LD_LIBRARY_PATH values could silently break package builds.

Tip: If you're debugging a Homebrew issue and suspect environment pollution, check whether the variable you're looking at survives the FILTERED_ENV allowlist at line 293. Many "works on my machine" bugs trace back to variables that env -i strips away.

Stage 2: Shell Bootstrap (brew.sh)

After the sanitized exec, execution continues in Library/Homebrew/brew.sh, a ~1193-line shell script that handles platform detection, fast-path commands, and eventual dispatch to Ruby.

Platform and Architecture Detection

The very first thing brew.sh does (lines 6–50) is detect the platform using Bash's $MACHTYPE and $OSTYPE variables — no subprocess calls needed:

Library/Homebrew/brew.sh#L6-L50

This sets up the default prefix for each platform configuration:

Platform Default Prefix Default Repository
macOS ARM (Apple Silicon) /opt/homebrew /opt/homebrew
macOS x86_64 (Intel) /usr/local /usr/local/Homebrew
Linux /home/linuxbrew/.linuxbrew /home/linuxbrew/.linuxbrew/Homebrew

Fast-Path Shell Commands

This is where Homebrew gets clever about performance. Before loading Ruby (which involves spinning up an interpreter, loading gems, and parsing hundreds of files), brew.sh handles several common commands entirely in Bash:

Library/Homebrew/brew.sh#L134-L204

Commands like --prefix, --cellar, --cache, shellenv, --version, formulae, and casks all execute and exit without ever touching Ruby. The shellenv command — which users eval in their shell profile — would be painfully slow if it required Ruby startup on every new terminal.

flowchart LR
    A["brew --prefix"] --> B["Echo HOMEBREW_PREFIX, exit 0"]
    C["brew shellenv"] --> D["Source cmd/shellenv.sh, exit 0"]
    E["brew --version"] --> F["Git describe, exit 0"]
    G["brew install foo"] --> H["Falls through to Ruby"]

Version Detection with File Caching

Lines 548–594 show a practical optimization: the git describe output (used for brew --version) is cached to a file keyed by the current Git revision. This avoids running git describe --tags --dirty on every invocation:

Library/Homebrew/brew.sh#L548-L594

Final Dispatch to Ruby

At the very end of brew.sh, after auto-update checks and extensive environment setup, comes the fork in the road at lines 1162–1192:

Library/Homebrew/brew.sh#L1162-L1192

If the command has a .sh implementation, it's sourced directly (not execd — to ensure the entire file is read into memory before execution, preventing bugs from files being updated mid-run). Otherwise, it execs into the Ruby interpreter with brew.rb.

Stage 3: Ruby Entry Point (brew.rb)

Library/Homebrew/brew.rb is the Ruby entry point — a ~228-line file that orchestrates command resolution and provides structured exception handling.

The Boot Sequence Orchestration

Before brew.rb runs any of its own code, line 18 triggers the loading chain via require_relative "global", which in turn requires startup.rb:

Library/Homebrew/startup.rb#L1-L11

sequenceDiagram
    participant brew.rb
    participant global.rb
    participant startup.rb
    participant standalone/init.rb
    participant startup/bootsnap.rb
    participant startup/config.rb
    participant standalone/sorbet.rb

    brew.rb->>global.rb: require_relative "global"
    global.rb->>startup.rb: require_relative "startup"
    startup.rb->>standalone/init.rb: Ruby version check, $LOAD_PATH setup
    startup.rb->>startup/bootsnap.rb: Compilation caching
    startup.rb->>startup/config.rb: Frozen Pathname constants
    startup.rb->>standalone/sorbet.rb: Sorbet runtime setup
    startup.rb-->>brew.rb: Environment ready

ARGV Parsing and Command Resolution

Lines 35–45 of brew.rb implement a small but important ARGV parser that extracts the command name from the argument list, handling both brew help install and brew install --help:

Library/Homebrew/brew.rb#L35-L47

Command resolution at lines 70–84 follows a priority order: first check internal command aliases, then internal commands (cmd/ and dev-cmd/), then external tap commands. The Commands.valid_internal_cmd? method lazily requires the command file, and AbstractCommand.command(cmd) looks up the matching subclass:

Library/Homebrew/brew.rb#L70-L84

Exception Handling Hierarchy

The bottom half of brew.rb (lines 143–221) implements a carefully ordered rescue chain that provides user-friendly error messages for every failure mode:

Library/Homebrew/brew.rb#L143-L221

UsageError triggers the help system, BuildError runs analytics reporting and prints build diagnostics, RuntimeError gets a clean one-line message, and the catch-all Exception handler includes smart suggestions like "you haven't run brew update today" or links to the appropriate issue tracker.

The Environment-as-Interface Pattern

The most architecturally significant pattern in Homebrew's boot sequence is how environment variables serve as the universal interface between Bash and Ruby. The two languages never communicate through files, pipes, or sockets — only through HOMEBREW_* environment variables.

The declarative env_config.rb defines a central ENVS hash that maps every HOMEBREW_* variable to its description, default value, and type. This single hash drives runtime behavior, manpage generation, and brew --env output simultaneously.

On the Ruby side, startup/config.rb converts these environment variables into frozen Pathname constants — HOMEBREW_PREFIX, HOMEBREW_CELLAR, HOMEBREW_CACHE, and dozens more. Once frozen, these constants can never change during a process's lifetime, providing a safe, immutable configuration surface.

flowchart LR
    A["bin/brew<br/>Sets HOMEBREW_PREFIX=/opt/homebrew"] --> B["brew.sh<br/>Sets HOMEBREW_CELLAR, HOMEBREW_CACHE, etc."]
    B --> C["startup/config.rb<br/>HOMEBREW_PREFIX = Pathname(ENV['HOMEBREW_PREFIX']).freeze"]
    C --> D["env_config.rb<br/>EnvConfig.no_auto_update? reads HOMEBREW_NO_AUTO_UPDATE"]

Performance Optimizations in the Boot Path

Homebrew's boot path is optimized at every stage:

  1. Fast-path shell commands avoid Ruby entirely. brew --prefix returns in milliseconds.

  2. Bootsnap compilation caching (startup/bootsnap.rb) uses Shopify's Bootsnap gem to cache compiled Ruby bytecode, dramatically reducing subsequent startup times.

  3. Git describe caching stores the version string to a file, avoiding a git describe subprocess on every invocation.

  4. FastBootRequire (standalone/init.rb#L37-L49) bypasses the normal require path for standard library files by joining paths directly against Ruby's archdir and rubylibdir.

  5. The experimental Rust frontend (brew-rs) at lines 1134–1160 of brew.sh can handle common commands like install, search, and info without Ruby at all — though it's currently developer-only and limited to specific platforms.

Tip: Set HOMEBREW_NO_BOOTSNAP=1 if you're actively editing Homebrew's Ruby source — otherwise, cached bytecode may mask your changes.

What's Next

Now that we understand how brew gets from a shell invocation to Ruby code, the next article dives into what happens after Ruby takes over: the AbstractCommand pattern that organizes all of Homebrew's commands, the CLI parser that turns --flags into typed arguments, and the discovery mechanism that finds both internal and external commands.