Using git to manage dotfiles
Table of Contents
An educational procrastination technique is managing your dotfiles. There’s some
useful lessons to pick up on over the years, and it’s genuinely useful when you
have multiple machines you use frequently. I’ve used a couple of tools (stow,
then
chezmoi),
but my latest refactor brought me back to plain old git
Dotfiles tools are fantastic, but git is pre-installed on most systems and is
purpose built for tracking file changes and syncing with remote repos. chezmoi
embraces this, but I want to lean away from complexity when possible. chezmoi
has many features, and I felt I wasn’t even using half of them. Plus the mental
model for applying and adding files can be a bit cumbersome. git is simpler,
more transparent, and harder to outsmart yourself with.
So here’s what I did for my latest episode of “practical procrastination”
Bare git repos
To avoid clobbering $HOME with a .git directory (which can result it git
walking through every single file, which can take a while), we can use a bare
repo:
git init --bare $HOME/.dotfilesNow we need to write a .gitignore that functions as a whitelist rather than a blacklist:
echo '*' > .gitignoreThis will prevent git from wasting time walking though $HOME, and also
prevent committing secrets in $HOME. While git is a very explicit system,
the muscle memory of add . && commit -m "..." to just stage and commit all
changes is far too tempting — in this case it would commit everything in
$HOME!
Now to use this bare repo, we need to pass --git-dir and --work-tree on
every single git command. This is because a bare repo is the directory with
tracking metadata in a --git-dir and nothing more. It doesn’t assume the
working tree is outside the directory. Hence, we must tell git where the
--git-dir is (since it’s not named .git) and where the work tree is.
We’ll use this git alias:
git config --global alias.dotfiles '!f() { git --git-dir=$HOME/.dotfiles --work-tree=$HOME "$@"; }; f'Tooling such as the starship prompt and vim-fugitive expects to find a
.git directory, let’s create a quick pointer so these tools work
echo "gitdir: $HOME/.dotfiles" > $HOME/.gitSet up your remote repo as you normally would, and commit the .gitignore and push
it to a remote repo as you normally would (I created a dotfiles repo on my
Github for this).
Clone on new machine
On another machine with git, we can deploy our dotfiles straight away with
git clone --bare git@github.com:<USERNAME>/dotfiles.git $HOME/.dotfiles
git config --global alias.dotfiles '!f() { git --git-dir=$HOME/.dotfiles --work-tree=$HOME "$@"; }; f'
echo "gitdir: $HOME/.dotfiles" > $HOME/.git
git dotfiles checkoutIf there’s conflicts, use git dotfiles checkout --force to obliterate the
local state and replace it. If you want to preserve local state though:
mkdir -p .config-backup
git dotfiles checkout 2>&1 | grep -E "^\s+\." | awk '{print $1}' | while read -r file; do
mkdir -p ".config-backup/$(dirname "$file")"
mv "$file" ".config-backup/$file"
done
git dotfiles checkoutTracking dotfiles
Now, to add new files:
echo "!/path/to/file" >> .gitignore
git dotfiles add /path/to/file
git dotfiles commit -m "feat: Add file"The ! Directive will exclude it from the catch-all ignore on the first line.
Caveats
Nested directories must be exempted first before exempting the files in them — and they must not have a trailing slash on their ignore lines. For example:
*
!.config
!.config/opencode
!.config/opencode/opencode.json
!.config/opencode/AGENTS.md.gitignore patterns should relative to $HOME
To be safe and prevent leaking credentials, always inspect your diffs! Or better
yet, use a pre-commit step that uses
gitleaks! Here’s
.pre-commit-config.yaml:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks# Install pre-commit however you prefer
pre-commit installOr use a gitignore in the nested directory — I wrote about that tip here and provided an over-engineered alias for this, but the simple oneliner for this situation:
echo -e '*\n!.gitignore' >> path/to/dir/.gitignoreFor example, here’s how I manage the config for the llm cli:
# .config/llm/.gitignore
logs.db
keys.json
openrouter_models.json
openrouter_models_structured_outputs.json# ~/.gitignore
*
!.config
!.config/llm
!.config/llm/.gitignore
!.config/llm/**/*If you’re confident in your nested directory, you could add a trailing /* (or
/**/*) wildcard to the directory ignore line in the top-level ignore. But
always inspect those diffs!
Now add commit push. You’re all set up and good to go!
Reply to this post by email blZake@proZbableodyssey.blog (remove Z characters) ↪