Ramblings of an aging IT geek
← Ramblings of an aging IT geek
tooling

dotfiles, finally in some kind of order

How I moved my scattered shell and editor config into a single bare-git dotfiles repo with stow-style symlinks and a bootstrap that survives a fresh machine.

A mechanical keyboard next to a terminal

For years my dotfiles existed in three states at once: the canonical version on whichever machine I'd last edited them, an older copy on the laptop, and a gist I'd forgotten the URL to. Setting up a new box meant an evening of "why is my prompt wrong" and copying chunks of .zshrc out of a terminal scrollback over SSH. It was fine, in the way that a thing you've done badly for so long stops registering as a problem. Then I rebuilt my desktop, lost an hour to it, and decided that was the last time.

So I spent a Sunday doing it properly. Not perfectly, properly. There's a difference, and the difference is mostly knowing when to stop.

The core idea is dull and correct: your config files live in a git repo, and your home directory contains symlinks pointing into that repo. Edit a file, git sees the change, commit and push. New machine, clone and link. Nothing lives in two places. The version on disk and the version in git are the same bytes because one points at the other.

There are two common ways to do this and I tried both. The first is GNU Stow, which treats each program as a "package" directory whose internal layout mirrors your home directory, and symlinks it all into place:

~/dotfiles
├── zsh/.zshrc
├── git/.gitconfig
├── nvim/.config/nvim/init.vim
└── tmux/.tmux.conf
$ stow zsh git nvim tmux

That one command walks each package and creates the symlinks in $HOME. Add a new file under zsh/, run stow zsh again, done. The grouping by program is genuinely nice when you want to take just your git and tmux config to a server but leave the heavyweight editor setup at home.

The second approach skips the symlinks entirely: a bare git repo whose work tree is your home directory. You set it up once, alias it, and then your home dir is directly version controlled:

git init --bare $HOME/.dotfiles
alias dot='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dot config status.showUntrackedFiles no

After that dot add .zshrc, dot commit, dot push work exactly like git, with no symlinks anywhere. The showUntrackedFiles no is load-bearing: without it dot status lists your entire home directory, every download and cache, which is unbearable.

I went with stow in the end, because I like being able to see the structure as plain directories and I occasionally want to deploy a subset. But the bare-repo trick is genuinely clever and if you hate symlinks it's the one to use.

Code and configuration on screen

the bootstrap script earns its keep

A repo of config is only half the win. The other half is the script that takes a freshly installed machine and turns it into my machine without me typing the same twelve commands from memory. Mine is a small, boring shell script that I can read in one screen:

#!/usr/bin/env bash
set -euo pipefail

DOTFILES="$HOME/dotfiles"

# clone if we're on a fresh box
if [ ! -d "$DOTFILES" ]; then
  git clone https://github.com/jmylchreest/dotfiles "$DOTFILES"
fi

# install the packages we always want
command -v stow >/dev/null || {
  echo "stow not found, install it first" >&2; exit 1
}

cd "$DOTFILES"
stow zsh git nvim tmux

# set zsh as the login shell if it isn't already
if [ "$SHELL" != "$(command -v zsh)" ]; then
  chsh -s "$(command -v zsh)"
fi

echo "done. open a new shell."

set -euo pipefail at the top is the bit I won't compromise on: fail on error, fail on unset variables, fail if anything in a pipe fails. A bootstrap that silently half-works is worse than one that stops and tells you where. I keep the package install (the actual apt/pacman/brew lines) in a separate per-OS script, because that's the part that genuinely differs between machines and trying to make one script handle every package manager is how you end up with something unmaintainable.

the rules I gave myself

Two of them, because more than two and I'd never have finished.

First: no secrets in the repo, ever. The repo's public. Anything with a token or a key goes in a file that's gitignored and gets a .example committed alongside it. The .zshrc sources the local file if it exists and shrugs if it doesn't, so the same config works on a machine that has the secrets and one that doesn't.

Second: keep it readable. I was tempted to reach for one of the big dotfile frameworks with their plugin managers and their abstractions, and for some people those are great. But my .zshrc had drifted into a thousand lines of copied snippets I no longer understood, and the cleanup was as valuable as the version control. I deleted aliases I hadn't used since I worked somewhere that no longer exists. If I can't explain a line, it goes.

It's not finished, because dotfiles are never finished. But the next time I rebuild a machine it'll be a clone and a script, measured in minutes, not an evening of archaeology in a scrollback buffer. That alone was worth the Sunday.