Managing Dotfiles
Every developer eventually hits the point where losing a .zshrc or an .gitconfig on a fresh machine is annoying enough to do something about it. That something, for most people, starts as a bash script.
The bash script era
My first attempt was a install.sh — a few dozen lines that looped through a hardcoded list of paths and ran ln -sf for each one. It worked. It had zero dependencies, ran instantly, and I understood every line of it. The problem was that it was fragile in exactly the kind of way that scripts tend to be: adding a new config meant editing the loop, handling the case where a target already existed was an afterthought, and there was no way to see what it was about to do before it did it.
It also grew. Slowly, it accumulated if statements to detect macOS vs Linux, conditionally link things, run brew bundle, and so on. By the time it had grown to a few hundred lines it had become the thing I was reluctant to touch.
dotbot
At some point I discovered dotbot and it was a real improvement. You describe what you want in YAML — which symlinks to create, which scripts to run — and dotbot handles the rest. The declarative format made the intent obvious and it handled edge cases (existing files, broken symlinks) cleanly.
The friction was the setup cost. dotbot is Python and the recommended way to use it is as a git submodule inside your dotfiles repo. Every new machine needed Python, running git submodule update --init before the installer could even start felt backwards, and occasionally I’d clone dotfiles on a machine where the submodule state was out of sync. None of these are showstoppers but together they were enough of a paper cut that I kept thinking about alternatives.
dfm
Eventually I wrote my own: dfm. The goals were simple — keep the YAML profile format that makes dotbot readable, get rid of the Python dependency, and ship a single binary that you can drop anywhere and run.
It’s written in Go, so installing it is:
1
curl -fsSL https://raw.githubusercontent.com/fisenkodv/dfm/master/scripts/install.sh | sh
That drops dfm into ~/.local/bin. No runtime required.
Profiles live in profiles/<name>.conf.yaml and support five directives: defaults, link, shell, clean, and create. Here’s a trimmed version of my personal profile:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
defaults:
link:
relink: true
shell:
stdout: true
stderr: true
clean:
- "~"
link:
~/.config/nvim: config/nvim
~/.config/fish: config/fish
~/.config/git/config: config/git/config.personal
~/.config/zed: config/zed
~/.config/tmux: config/tmux
~/.zshrc: config/zsh/zshrc.zsh
shell:
- name: Setting default shell to fish
script: ./scripts/change-shell.sh
- name: Installing brew packages
script: brew bundle install --file=os/macos/brewfile
- name: Installing mise tools
script: mise install
Applying it is dfm apply personal. Before committing to anything, dfm diff personal shows exactly what will change. After the fact, dfm doctor checks that all created symlinks still resolve — useful after moving files around or switching branches.
One thing I appreciate: unknown keys in the YAML are hard errors, not silent no-ops. A typo like lnik: fails immediately with a line reference instead of doing nothing and leaving you confused.
Non-symlink targets get backed up to ~/.dotfiles-backup/<timestamp>/ before being replaced, so apply is safe to re-run and nothing is lost.
What’s different now
The dotfiles repo itself got cleaner. There’s no submodule to keep in sync, no Python version to worry about, and the profile YAML is the whole truth about what gets installed on a machine. Going from a fresh macOS install to a fully configured environment is:
1
2
3
curl -fsSL https://raw.githubusercontent.com/fisenkodv/dfm/master/scripts/install.sh | sh
git clone https://github.com/fisenkodv/dotfiles.git ~/.dotfiles
cd ~/.dotfiles && dfm apply personal
That’s it.
The dotfiles are at github.com/fisenkodv/dotfiles and dfm is at github.com/fisenkodv/dfm.