I do have a relatively big bashrc file. If you look at that file, you will see this line towards the end:

test -f ~/.bashrc_local && source ~/.bashrc_local

I use that ~/.bashrc_local to keep my personal / work configurations separate.

For quite some time I was bothered by how long it took for Bash to start on my machines. I never measured the actual startup time, but it made me hesitant to open up new terminal windows.

So one day I decided to take care of the slowness.

If you search on the Internet, there aren’t that many tools to help with profiling Bash scripts. One trick that I found is to add the following lines around isolated pieces of shell commands and see what they actually do:

set -v
set -x 

#<YOUR COMMANDS>

set +v
set +x

Using this technique, you will get a trace log of what’s going on behind the scenes. With some good old trial-and-error, I found these 2 issues in my case:

  1. Unnecessary calls to brew to get configuration
  2. Eagerly processing all shell completion files

Calling brew in your bashrc

I love Homebrew. It is an amazing package manager. However, it is written in Ruby and has a quite slow startup time.

To give you an idea of how slow it can be, I will use Hyperfine to measure the average time for calling brew --prefix. This brew command gives Homebrew’s install path and you can use it to add your installed packages’ bin directories to $PATH, among other things.

On my machine:

$ hyperfine 'brew --prefix'
Benchmark 1: brew --prefix
  Time (mean ± σ):      36.7 ms ±   1.1 ms    [User: 14.5 ms, System: 14.7 ms]
  Range (min … max):    35.2 ms …  41.2 ms    72 runs

That’s 36 ms just to give us the installation path!

You might think that 36 ms is not much, but I had several calls to brew --prefix in my bashrc to get bin paths for coreutils, GNU grep, and other tools. These all add up to hundreds of milliseconds each time the bashrc file is sourced by Bash.

Solution

First, I use brew shellenv to generate environment variables related to Homebrew. One of them is $HOMEBREW_PREFIX which, as you’ve probably guessed, gives us the installation path. Now instead of calling brew --prefix, I can use that environment variable.

Even better, I call brew shellenv only once and store the result in a file ($HOME/.brew_env). Then in my bashrc I just source that file and save some more precious milliseconds!

Here is the relevant part in my bashrc:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
_brew_env_file="$HOME/.brew_env"
__make_brew_envs_file() {
    # File already exists. Nothing to do.
    if [[ -r "$HOME/.brew_env" ]]; then
        return
    fi

    echo "Creating '$_brew_env_file' file for the first time."
    echo "# Auto-generated by .bashrc script" > $_brew_env_file
    echo >> $_brew_env_file

    if [[ -x "$(command -v /opt/homebrew/bin/brew)" ]]; then
        /opt/homebrew/bin/brew shellenv >> $_brew_env_file
    elif [[ -x "$(command -v /usr/local/bin/brew)" ]]; then
        /usr/local/bin/brew shellenv >> $_brew_env_file
    fi
}

__make_brew_envs_file
source $_brew_env_file

Eagerly processing all shell completion files

Before starting my journey in optimizing my bashrc, I suspected that this was the culprit. I wasn’t wrong. This was the second source of slowness.

On macOS, I use Bash Completion package to get shell completion for several commands.

After installing that package, brew tells you to add this line to your Bash startup file:

 [[ -r "/usr/local/etc/profile.d/bash_completion.sh" ]] && 
    . "/usr/local/etc/profile.d/bash_completion.sh"

This works fine. The problem is that whenever you install a package with brew, brew adds the relevant completion files to $HOMEBREW_PREFIX/etc/bash_completion.d/ folder. Using the line above, bash-completion package loads all files in the mentioned folder eagerly. This can get very slow if you have installed too many packages.

What we ideally want is to process a completion file on-demand. In other words, when I type tar -- and press Tab, I want the relevant completion file to be processed so Bash can give me the completion options. Fortunately bash-completion package supports this out of the box.

To get that, first we need to install the HEAD version of it, because the usual Homebrew version is rather old. To install the HEAD version:

brew install --HEAD bash-completion@2

(I am using Bash 5.x, so I need to use the @2 version)

Then, I disable the eager loading of those Homebrew-installed completion files:

export BASH_COMPLETION_COMPAT_DIR="/blackhole!"

/blackhole! can be any non-existing path.

Finally, I ask bash-completion to process Homebrew-installed completion files lazily:

export BASH_COMPLETION_USER_DIR="$HOMEBREW_PREFIX/etc/bash-completion:$HOME/.local/share/bash-completion"

There are actually two paths specified there. One is for packages that are installed via brew install .... The other is for my own custom completion files that I put in ~/.local/share/bash-completion/.

The ability to add several folders to BASH_COMPLETION_USER_DIR is added in recent versions of bash-completion@2. That’s why I used --HEAD when installing it.

The full code looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
__check_bash_completion_symlink() {
    # Create the symlink
    mkdir -p "$HOMEBREW_PREFIX/etc/bash-completion"
    ln -sf "$HOMEBREW_PREFIX/etc/bash_completion.d" "$HOMEBREW_PREFIX/etc/bash-completion/completions"

    # Directories for lazy-loading
    export BASH_COMPLETION_USER_DIR="$HOMEBREW_PREFIX/etc/bash-completion:$HOME/.local/share/bash-completion"

    # Disable the eagerly-loaded completion scripts
    export BASH_COMPLETION_COMPAT_DIR="/blackhole!"
}

__check_bash_completion_symlink
[[ -r "$HOMEBREW_PREFIX/etc/profile.d/bash_completion.sh" ]] && 
    . "$HOMEBREW_PREFIX/etc/profile.d/bash_completion.sh"

On important note: bash-completion expects to find a completions folder inside each folder that’s specified in BASH_COMPLETION_USER_DIR. That’s the reason for the ln -s trick up there.