Dotfiles, GNU stow, and the three lines that matter
My config lives in a git repo symlinked into place with GNU stow. The setup is boring on purpose — but three lines in it have saved me hours.
Every developer eventually writes a dotfiles repo, and every dotfiles repo eventually grows an install script that is more complicated than the configs it installs.
Mine avoids that by using GNU stow, which is a symlink manager from 1993 that does exactly one thing and needs no configuration.
How stow works
The repo is organized one directory per tool, and inside each directory the files sit at the path they’d occupy relative to your home directory:
dotfiles/
├── zsh/.zshrc
├── git/.gitconfig
└── nvim/.config/nvim/init.vim
Then:
gh repo clone nparker-tc/dotfiles ~/.dotfiles
cd ~/.dotfiles
ls -d */ | xargs stow
stow zsh creates ~/.zshrc as a symlink to ~/.dotfiles/zsh/.zshrc. It recreates the directory
structure as needed, and stow -D zsh removes the links cleanly. That’s the entire mechanism.
The payoff is that editing a config is editing the repo. There’s no sync step to forget, no
install.sh to maintain, and no drift between what’s on the machine and what’s committed. New
machine, three commands, done.
The one thing to know: stow refuses to clobber an existing real file. That’s correct behavior and
it will bite you on a fresh macOS install where ~/.zshrc already exists. Delete it first.
The three lines that actually earn their place
Most of a dotfiles repo is inherited defaults you never touch. These are the ones I’d reinstate first on a bare machine.
conflictstyle = diff3
[merge]
conflictstyle = diff3
Git’s default conflict markers show you two versions: yours and theirs. diff3 adds a third — the
common ancestor both sides diverged from.
This is the difference between guessing and knowing. With two versions you can see that you both changed a line, but not what each of you was changing it from. With the ancestor in view, the intent of each side is usually obvious — you can see that they renamed a variable while you added an argument, and the merge writes itself.
I don’t understand why this isn’t the default. It costs three extra lines of conflict marker and removes most of the guesswork from the worst part of using git.
(Newer git has zdiff3, which is the same idea with the shared context hoisted out of the
conflict region. Worth using if your git is recent enough.)
The fix alias
[alias]
fix = "!f() { ${EDITOR} `git diff --name-only --diff-filter=U`; }; f"
git fix opens every conflicted file in your editor. That’s it.
--diff-filter=U selects unmerged paths, --name-only prints them, and the shell function feeds
them to $EDITOR. It replaces the ritual of running git status, reading the “both modified”
list, and typing out paths by hand.
It’s a five-second saving that I hit several times in a bad rebase, which is exactly when I have the least patience for typing filenames.
delta as the pager
[core]
pager = delta
[interactive]
diffFilter = delta --color-only
[delta]
navigate = true
theme = dracula
delta is a syntax-highlighting pager for git diffs. Side by side line numbers, proper language highlighting, and word-level intra-line diffs so a one-character change doesn’t render as two entirely rewritten lines.
navigate = true is the underrated setting — n and N jump between files in a large diff
instead of scrolling. On a review of thirty changed files, that’s the difference between reading
the diff and giving up on it.
The zsh side
I run oh-my-zsh with znap for plugin loading, and the
.zshrc bootstraps znap itself if it’s missing:
[[ -r ~/Developer/zsh-repos/znap/znap.zsh ]] ||
git clone --depth 1 -- \
https://github.com/marlonrichert/zsh-snap.git ~/Developer/zsh-repos/znap
source ~/Developer/zsh-repos/znap/znap.zsh
Self-bootstrapping config is the right pattern for anything the shell needs before it can be useful. The alternative is a README step that you will skip and then spend ten minutes debugging.
I’ll be honest that running both oh-my-zsh and znap is redundant — znap can do plugin management on its own, and oh-my-zsh brings a lot of machinery I don’t use. It’s on the list. Shell startup config has a strong tendency to accumulate rather than get refactored, because the cost of a mistake is a broken shell on every new terminal.
Neovim
Config is still init.vim with vim-plug rather than the
Lua-and-lazy.nvim setup that’s now standard. It works, and I’ve resisted the yearly rewrite.
The plugins that matter: fzf.vim for file and content search, nvim-treesitter for real syntax
awareness, mason.nvim plus nvim-lspconfig for language servers, vim-fugitive for git,
nvim-tree for the file explorer, and Comment.nvim. Plus vim-ruby, because that’s what pays
the bills.
Keep identity out of the repo
One thing worth doing if you publish your dotfiles: don’t commit an email address.
[user]
name = your-handle
# Machine-local overrides. A missing file is ignored.
[include]
path = ~/.gitconfig.local
Identity goes in ~/.gitconfig.local, which never enters the repo. Git ignores an include whose
file doesn’t exist, so a fresh clone degrades gracefully — you just get the usual prompt to set
your identity, instead of silently committing as whoever last edited the config.
Git has supported conditional includes since 2.13, so you can go further and switch identity by directory:
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
Anything under ~/work/ picks up a separate identity. That’s much better than remembering to set
user.email per repo, which you will forget exactly once, on a commit that ends up somewhere
public.