ProbableOdyssey | Blake Cook

Using git to manage dotfiles

· 4 min read · 699 words

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/.dotfiles

Now we need to write a .gitignore that functions as a whitelist rather than a blacklist:

echo '*' > .gitignore

This 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/.git

Set 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 checkout

If 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 checkout

Tracking 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 install

Or 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/.gitignore

For 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) ↪