For longer than I'd like to admit, my "dotfiles management" was a folder in Dropbox called configs-final and a folder next to it called configs-final-2. When I got a new machine I'd copy bits across by hand, forget half of them, and rediscover three weeks later that my shell was subtly different from every other machine I owned. This weekend I finally did it properly, and I'm pleased enough with the result that I want to write it down, partly so the next version of me can find it.
The thing I was avoiding
The reason I'd put it off for years is that every dotfiles guide seemed to want me to adopt a framework. A manager, a templating language, a directory layout with opinions. And every time I started, I'd spend an evening fighting the tool instead of fixing my config, lose interest, and go back to copying files by hand. The breakthrough was realising I didn't need a framework. I needed about thirty lines of shell and one slightly clever use of git.
The bare repo trick
The approach that finally clicked is the bare-repo method, where your home directory is the working tree and the git directory lives somewhere out of the way. You never check the whole of $HOME into git, you just point a specially-configured git at it and only ever add the handful of files you care about.
It sets up like this:
git init --bare "$HOME/.dotfiles"
alias dot='git --git-dir="$HOME/.dotfiles" --work-tree="$HOME"'
dot config status.showUntrackedFiles no
That showUntrackedFiles no line is the bit that makes it bearable. Without it, dot status lists every file in your home directory and the whole thing is unusable. With it, dot status only ever shows files you've explicitly added, which is exactly what you want. From then on it's just git:
dot add ~/.zshrc
dot commit -m "zsh: add fzf keybindings"
dot push
No symlinks, no manager, no separate "install" step that copies files into place. The files live where they're meant to live, and git tracks them in situ. The first time I ran dot add ~/.config/nvim/init.lua and it just worked, I felt slightly silly for the years of folders called final.
The bootstrap
The one genuinely fiddly part is the first checkout on a fresh machine, because the working tree (your home directory) already has files in it that will collide with what's in the repo. A default shell profile, usually. So the bootstrap script clones the bare repo, tries to check out, and if it fails because of conflicts, it backs the offending files up rather than clobbering them.
#!/usr/bin/env bash
set -euo pipefail
git clone --bare https://example.com/me/dotfiles.git "$HOME/.dotfiles"
dot() { git --git-dir="$HOME/.dotfiles" --work-tree="$HOME" "$@"; }
if ! dot checkout 2>/dev/null; then
echo "Backing up pre-existing dotfiles to ~/.dotfiles-backup"
mkdir -p "$HOME/.dotfiles-backup"
dot checkout 2>&1 | grep -E "^\s+\." | awk '{print $1}' | while read -r f; do
mkdir -p "$HOME/.dotfiles-backup/$(dirname "$f")"
mv "$HOME/$f" "$HOME/.dotfiles-backup/$f"
done
dot checkout
fi
dot config status.showUntrackedFiles no
echo "Done. Open a new shell."
It's not elegant. The grep-and-awk dance to pull conflicting paths out of git's error message is exactly the sort of thing that breaks when git rewords its output. But it's run perhaps four times in its life, on machines I'm setting up by hand anyway, and a slightly ugly script that I understand completely beats a polished tool that I don't.
What I keep in there, and what I don't
The repo holds shell config, my editor setup, git config, the terminal emulator, the window manager bits, and a small bin directory of scripts I've accumulated. What it deliberately does not hold is anything with a secret in it. No tokens, no keys, no machine-specific hostnames baked into config. Those live outside the repo and get sourced if present:
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"
That .local pattern is doing a lot of quiet work. Per-machine differences (a work proxy, a different default editor on the server boxes, an API token for some local tool) all go in .zshrc.local, which is never committed. The shared config in the repo is genuinely shared, identical on every machine, and the differences are isolated to one untracked file per box. It means I can read my own .zshrc and know that every line of it applies everywhere, which was never true before.
Was it worth it
A weekend, most of which was actually spent reading my old config and deleting the embarrassing bits rather than building anything. I found aliases I'd forgotten I had, functions referencing tools I no longer use, and at least one workaround for a bug that was fixed in 2021. Clearing that out felt better than the setup itself.
The real test will be the next new machine. If I can clone, run one script, open a new shell and be home, then it was worth it. I suspect it will be. And if not, well, I still have the folder called configs-final, sitting there as a monument to how I used to do things. I'm not deleting it yet. Some habits you keep around just to remind yourself how far you've come.