UPDATE

2022-12-09 I finally got around to updating the git repo to ensure clean history with no sensitive data and made it public! You can take a look here: Dav’s Dotfiles

Now on with the show…


I spend a lot of time using a *nix terminal. It’s comfortable, I like it, I can move quickly, etc. As most of us terminal jockeys do, I have accumulated a lot of customizations to my terminal scripts over the years and have struggled keeping them portable and up to date across systems. I’ve played with a variety of methods over time, and have finally built a framework that works for me. Maybe it can work for other folks as well.

First thing I needed to do was to keep things organized. Counting only the scripts that I share across all machines, my profile startup scripts are 400-500 lines of code. Add to that all of the custom stuff that I want only on certain machines and it gets closer to 1000 lines. That’s pretty unwieldy to manage. Over time I tested a bunch of different methods and frameworks out there, and even started building some of my own, but none of them managed things quite the way I wanted. But now, through a bunch of iterations, I built my own framework to manage all the things:

~ $ tree -La 1 | grep bash.d
├── .bash.d
├── .profile -> /Users/davidbayer/.bash.d/profiles/common/profile
├── .vimrc -> /Users/davidbayer/.bash.d/profiles/common/vim/vimrc
~ $
~ $ tree -L 1 .vim/
.vim/
├── autoload -> /Users/davidbayer/.bash.d/profiles/common/vim/autoload
├── plugin -> /Users/davidbayer/.bash.d/profiles/common/vim/plugin
└── plugins.vim -> /Users/davidbayer/.bash.d/profiles/common/vim/plugins.vim
~ $
~ $ tree -L 3 .bash.d/
.bash.d/
├── README.md
├── profiles
│   ├── active -> /Users/davidbayer/.bash.d/profiles/home
│   ├── common
│   │   ├── 00_safety-zone.sh
│   │   ├── 00_utils.sh
│   │   ├── 01_env.sh
│   │   ├── 01_weather.sh
│   │   ├── 02_ssh-agent.sh
│   │   ├── 96_git.sh
│   │   ├── 97_bash-completions.sh
│   │   ├── 98_aliases.sh
│   │   ├── 99_java.sh
│   │   ├── 99_pip3.sh
│   │   ├── git
│   │   ├── helpers
│   │   ├── profile
│   │   └── vim
│   ├── home
│   │   ├── 01_env.sh
│   │   ├── 98_aliases.sh
│   │   ├── helpers
│   │   └── profile.sh
│   └── work
│       ├── 01_env.sh
│       ├── 01_ulimit.sh
│       ├── 97_bash-completions.sh
│       ├── 98_aliases.sh
│       ├── 98_port-forwarding.sh
│       ├── helpers
│       └── profile.sh
├── safety-zone
│   ├── README.md
│   └── safety-zone_values.ini
└── setup
    ├── links.csv
    └── setup.sh

Let me ‘splain. There is too much, let me sum up:

  • I keep EVERYTHING in ~/.bash.d/.
  • ~/.bash.d/ is a git repo that I push to github.
  • I keep sensitive data like API keys and whatnot in ~/.bash.d/safety-zone/safety-zone_values.ini and have a helper function read the values as needed. .gitignore is set to ignore that folder to keep sensitive data safe.
  • ~/.bash.d/setup/ contains my setup script and links.csv in which I list any symlinks that need to be made when setting up a new system. I can copy setup.sh from github to the local machine and it will do the rest - no need to install git first or clone the repo. The only catch is you need to have an SSH key installed on the local system for github.
  • All profiles live in ~/.bash.d/profiles/. common contains things I want on all systems. Other folders are system-specific profiles that get symlinked to ~/.bash.d/profiles/active during setup.
  • ~/.bash.d/profiles/common/profile gets symlinked to ~/.profile. To keep my profile script(s) manageable, I split them out into XX_name.sh with the numbers XX controlling the order they get loaded. profile will load anything matching that pattern.
  • profile will also look for profile.sh in ~/.bash.d/profiles/active/ and load that, along with any XX_name.sh scripts in active/.
  • Not to be left out, a similar pattern is followed for vim configuration: .vimrc is symlinked to the appropriate file in the common profile as are the requisite items in ~/.vim. “Why not just symlink all of ~/.vim?” you say? Because vim plugins are git repos downloaded to ~/.vim/plugins and I do not want to clutter my dotfiles repo with all of that cruft. It means I need to run :s PlugInstall in vim the first time I run it, but it’s worth it. .vimrc also includes ~/.bash.d/profiles/active/vimrc if it exists.

I’m a big believer in building tools, not just scripts, so here’s this:

~ $ ~/.bash.d/setup/setup.sh -h
Usage: setup.sh [-b] [-d DEST_DIR] [-y] [-h]

    -b            Backup all items that would be overridden
    -d DEST_DIR   Specify alternate directory for dotfiles
    -p PROFILE    Specify bash profile for this computer
    -y            Respond yes to all prompts
    -h            Display this help message

If not specified, DEST_DIR defaults to ~/.bash.d

If PROFILE is not specified the user will be prompted to
select from the profiles available or create a new one.

setup.sh is designed to be idempotent and only makes changes
when desired state does not match existing state.

You may be wondering “wait - setup lets you select the destination folder? How do .profile and other scripts find everything?” That is one of the few things not contained in the dotfiles hierarchy. During setup it adds export DOTFILES_BASEDIR=<DEST_DIR> to ~/.bashrc and that gets loaded when the shell starts up. All scripts are written to use that variable to resolve paths.

You may also be wondering how onerous is the task of keeping everything up to date. Have no fear! That too has been handled. Early in ~/.profile it pulls any new changes to the repo (only items required to do that happen earlier). Also, it traps EXIT signals for bash and pushes any new changes up to github before closing.

Finally, you may wonder why I’m still using bash instead of zsh. That is mostly due to laziness. I haven’t played with zsh enough to understand the differences or to figure out which of my scripts would break. One of these days I’ll make that change, but until that time I’m still using bash.

I haven’t made the dotfiles repo public yet. I’m still scouring everything to make sure there’s nothing sensitive to either me or my employer (or previous employers) before doing that. If you’re interested in the full .profile script here you go:

#!/usr/bin/env bash
# shellcheck disable=SC1091

exit_bash() {
    cd "${DOTFILES_BASEDIR}"
    if [[ ! $(git status | grep 'nothing to commit') ]]; then
        echo "Committing changes to dotfiles before exit"
        git add .
        git commit -m 'Auto-add changes to dotfiles on shell exit'
        git push
    fi
}

trap exit_bash EXIT

BASH_VERSION=$($BASH --version | awk '/bash/ {print $4}')
BASH_MAJOR_VERSION=$(echo "$BASH_VERSION" | awk -F'.' '{print $1}')

if [[ $BASH_MAJOR_VERSION -ge "4" ]]; then
    shopt -s globstar
fi

shopt -s histappend

# DOTFILES_BASEDIR is set in ~/.bashrc
[[ -f "${HOME}/.bashrc" ]] && source "${HOME}/.bashrc"

# update with remote changes before loading the remaining profile
cd "$DOTFILES_BASEDIR"
git pull > /dev/null
cd - > /dev/null

# Use add-in scripts
re='[0-9]{2}_.*\.sh$'

for script in ${DOTFILES_BASEDIR}/profiles/common/* ${DOTFILES_BASEDIR}/profiles/active/*; do
    if [[ $script =~ $re ]]; then
        source "${script}"
    fi
done

test -e "${HOME}/.iterm2_shell_integration.bash" && source "${HOME}/.iterm2_shell_integration.bash"

echo
echo "Welcome to Bash $BASH_VERSION"
echo 


if [[ -f "${DOTFILES_BASEDIR}/profiles/active/profile.sh" ]]; then
    echo "Loading ${PROFILE:=active} profile"
    . "${DOTFILES_BASEDIR}/profiles/active/profile.sh"
fi

if [[ -f "${DOTFILES_BASEDIR}/profiles/common/helpers/profile_utils.sh" ]]; then
    source "${DOTFILES_BASEDIR}/profiles/common/helpers/profile_utils.sh"
fi