Overview
I used to keep my dotfiles in a normal Git repo and symlink them into place with a shell script. It worked, but every new machine meant cloning, running the script, and then remembering which files the script handled. The bare-repo trick replaces all of that with four commands and no symlinks.
The idea is simple: $HOME becomes your working tree, but the Git directory lives somewhere else, so you never accidentally commit your entire home directory.
Setting it up
git init --bare $HOME/.dotfiles
alias dot='git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
dot config --local status.showUntrackedFiles no
That third line matters more than it looks. Without it, dot status lists every file in your home directory and the output is unusable.
Add the alias to your shell config so it survives a restart. In ~/.bashrc or ~/.zshrc:
alias dot='git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
Adding files
Now it behaves like Git, scoped to whatever you point it at:
dot add .bashrc .vimrc .gitconfig
dot commit -m "Initial dotfiles"
dot remote add origin git@github.com:you/dotfiles.git
dot push -u origin main
From here on, dot add, dot commit, and dot push work exactly as you'd expect. There's no copying step, because the files Git tracks are the live files in your home directory.
Restoring on a new machine
The one gotcha: git clone fails if any of the target files already exist. A new machine will already have a .bashrc, which blocks the checkout. Move them out of the way first:
mkdir -p ~/.dotfiles-backup
for f in .bashrc .vimrc .gitconfig; do
[ -f "$HOME/$f" ] && mv "$HOME/$f" ~/.dotfiles-backup/
done
git clone --bare git@github.com:you/dotfiles.git $HOME/.dotfiles
alias dot='git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
dot checkout
dot config --local status.showUntrackedFiles no
If dot checkout still complains, it names the conflicting files. Move those to the backup directory and run it again.
What to track and what to leave out
The temptation is to track everything. Resist it. Files that contain secrets or machine-specific values will cause problems the moment the repo is public, and even a private repo gets cloned onto machines you don't fully control.
| Usually safe | Usually not |
|---|---|
.bashrc, .zshrc | .ssh/id_* — private keys, never |
.vimrc, .config/nvim/ | .aws/credentials |
.gitconfig (without tokens) | .netrc |
.tmux.conf | .config/gh/hosts.yml |
A bin/ directory of scripts | Anything with an API key in it |
For config that must differ per machine — work laptop versus personal — the cleanest approach is a separate file that gets sourced if it exists:
# .gitconfig
[include]
path = ~/.gitconfig.local
# .gitconfig.local (never committed)
[user]
email = work@company.com
Same pattern works for shell config. [ -f ~/.bashrc.local ] && source ~/.bashrc.local at the bottom of your rc file.
Why not symlinks?
Symlinking is fine, and plenty of people use it with GNU Stow or a hand-rolled script. The bare-repo approach is less machinery. No install step beyond the alias, no symlinks to break when you edit a file with a tool that writes a new inode, and no separate copy of your config to keep in sync.
The downside is that dot is a command you have to remember, and if you type plain git status in your home directory you'll get the wrong answer. Some people add export GIT_DIR conditionally, but I've found that causes more confusion than it solves.
Extra: the ignore file
Even with status.showUntrackedFiles no, you may want a global ignore for things like .dotfiles-backup/:
echo ".dotfiles-backup/" >> $HOME/.dotfiles/info/exclude
info/exclude is the local, uncommitted equivalent of .gitignore — useful here because you don't want to commit a .gitignore into your home directory and have it interfere with other repos.
