Skip to main content
  1. Posts/

Managing Dotfiles Like a Pro with Yadm

··878 words·5 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Every developer eventually reaches the point where their configs become too valuable to lose. Here's how I use **yadm** to manage my macOS dotfiles with automated testing, daily maintenance, and a pre-commit workflow that keeps everything in check.

For me, the turning point was spending a weekend setting up a new MacBook and realizing I couldn’t reproduce my environment reliably. That’s when I started managing my dotfiles properly.

After trying bare git repos, GNU Stow, and chezmoi, I settled on yadm, and it’s been my go-to for over a year.

Why Yadm?
#

There are many dotfile managers. Here’s why yadm won:

ToolApproachMy Take
Bare gitRaw git with $HOME as work treeWorks but fragile, no extras
GNU StowSymlink farm managerRequires specific directory structure
chezmoiTemplate-based with state managementPowerful but complex, uses its own DSL
yadmThin wrapper around gitGit-native, minimal learning curve, built-in extras
The key insight: yadm is just git. Every git command works: yadm add, yadm commit, yadm push, yadm diff. If you know git, you know yadm.

What yadm adds on top:

  • Alternate files: Different configs per machine using ##hostname or ##os suffixes
  • Encryption: Encrypt sensitive files with GPG before pushing
  • Bootstrap: Run a setup script on first clone
  • Native $HOME tracking: No symlinks, files live where they belong

My Directory Structure
#

Here’s what I track:

~
├── .zshrc                    # Shell config (Zsh + Zinit + Oh-My-Zsh)
├── .tmux.conf                # tmux configuration
├── .config/
│   ├── ghostty/config        # Ghostty terminal
│   ├── kitty/kitty.conf      # Kitty terminal (backup)
│   ├── nvim/                 # Neovim/LazyVim config
│   ├── starship.toml         # Prompt
│   ├── atuin/config.toml     # Shell history
│   ├── mise/config.toml      # Version manager
│   └── ripgrep/config        # Ripgrep defaults
├── .local/bin/               # Custom scripts
├── .Brewfile                 # Homebrew packages
└── .yadm/
    └── hooks/pre-commit      # Pre-commit validation

The .gitignore is crucial. Track only what you need:

# Ignore everything by default
*
# Then selectively un-ignore
!.zshrc
!.tmux.conf
!.config/ghostty/
!.config/nvim/
!.Brewfile
# ... etc

Automated Daily Maintenance
#

  1. Homebrew Update

    Auto

    brew update && brew upgrade && brew cleanup. Keeps all packages fresh.
  2. Zinit Plugins

    Auto

    zsh -ic 'zinit update --all'. Updates all Zsh plugins.
  3. Neovim via bob

    Auto

    bob update --all. Updates Neovim version manager and builds.
  4. LazyVim Sync

    Auto

    nvim --headless "+Lazy! sync" +qa. Syncs all LazyVim plugins.
  5. Cleanup

    Auto

    Removes broken symlinks in ~/.local/bin. Tracks last run date to prevent duplicates.

The script:

  • Tracks last run date to prevent duplicate runs
  • Catches up if the laptop was off (runs on next login)
  • Has quick aliases: mr (run), ms (status), ml (logs)

I also have a control script for managing it:

daily-maintenance-control.sh start   # Enable auto-run
daily-maintenance-control.sh stop    # Disable
daily-maintenance-control.sh status  # Check state
daily-maintenance-control.sh logs    # View recent logs

Pre-Commit Testing
#

Every yadm commit runs through a pre-commit hook:

#!/bin/bash
# .yadm/hooks/pre-commit

# Run the test suite
bash ~/test-dotfiles.sh
if [ $? -ne 0 ]; then
    echo "Tests failed. Commit aborted."
    exit 1
fi

The test suite (test-dotfiles.sh) validates:

Shell syntax
ShellCheck
Markdown lint
YAML lint
File permissions
No secrets

This catches mistakes before they reach the repo. I never push broken configs.

Version Management with Mise
#

Mise (formerly rtx) manages language runtimes across my machines:

# ~/.config/mise/config.toml
[tools]
node = "lts"
python = "latest"
go = "latest"
ruby = "latest"

[settings]
idiomatic_version_file_enable = true  # Reads .nvmrc, .python-version, etc.
not_found_auto_install = true         # Auto-install missing versions
jobs = 4                              # Parallel installations
Key setting: idiomatic_version_file_enable means mise respects .nvmrc, .python-version, and .tool-versions files in project directories. When I cd into a project that needs Node 18, mise automatically activates it.

Practical Tips
#

1. Start Small
#

Don’t try to track everything at once. Start with:

yadm add ~/.zshrc
yadm add ~/.config/ghostty/config
yadm commit -m "initial: shell and terminal config"

Add more as you modify things.

2. Use Branches for Experiments
#

yadm checkout -b experiment/new-shell-config
# Try things out...
yadm checkout main  # Revert if it didn't work

3. Bootstrap Script for New Machines
#

Create a bootstrap that gets a fresh machine to your preferred state:

#!/bin/bash
# ~/.config/yadm/bootstrap

# Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Install packages
brew bundle --file=~/.Brewfile

# Set default shell
chsh -s $(which zsh)

echo "Bootstrap complete. Restart your terminal."

Then on a new machine:

yadm clone https://github.com/youruser/dotfiles.git
yadm bootstrap

4. Keep Sensitive Data Out
#

Use .gitignore aggressively and yadm’s encryption for anything sensitive:

# Encrypt SSH configs
yadm encrypt

Git credentials should go through Git Credential Manager, never in dotfiles.

5. Document Your Setup
#

I keep a CLAUDE.md in my dotfiles repo. It documents the architecture, conventions, and mandatory rules. This serves as both documentation for myself and instructions for AI assistants helping me modify configs.

The Payoff
#

With this setup:

  • New machine setup: Clone + bootstrap, done in under an hour
  • Daily updates: Automated, zero manual intervention
  • Config changes: Tested before commit, never push broken configs
  • Cross-machine sync: yadm pull on any machine
  • Rollback: Full git history, revert any change

The initial investment is a few hours. The ongoing cost is near zero. And the peace of mind knowing your entire development environment is versioned, tested, and reproducible? Priceless.

Check out my full setup:

Related

My dotfiles had a no-exceptions test gate. It had never run once.

··1331 words·7 mins
My dotfiles repo has a CLAUDE.md, and the CLAUDE.md has a rule in bold: every commit must pass the test suite, no exceptions. Within the first hour of an audit this July, I learned that this rule had been enforced exactly zero times since the day it was written. The hook file existed, its contents were correct, it even had its executable bit. It was just sitting at a path that yadm stopped reading a major version ago. No error message. No warning. To yadm, a hook in the wrong place and no hook at all are the same thing.

Five wrong answers in one day. One nearly deleted 10 GB of coursework.

··1903 words·9 mins
`mdls kMDItemLastUsedDate` returned `(null)` for Microsoft Word. I read the null as "never opened" and put Office on a removal list: 10.1 GB, four apps. One last check saved me. My home directory held 150 Office documents, a conference presentation edited two weeks earlier, and a PowerPoint lock file, which only exists while the file is open. The proof that the null was misleading had been sitting in my own diagnostic report for an hour. That was one of five. In a single day of hardening this machine, five different tools told me things that were not true. None of the answers looked like an error. Each one arrived as a clean, confident finding, and under each one a check had quietly failed or asked the wrong question. All five had the same shape underneath. Once I could name the shape, I stopped falling for it.

Three layers of secret defense for a public dotfiles repo. One was decorative.

··1190 words·6 mins
My dotfiles repo is public, which means any slip with a credential is permanent. History rewrites do not un-leak a key that a scraper already saw. So the defense cannot be one layer, and the interesting part of layering is not the count of tools. It is that each layer intercepts at a different moment: one before the commit exists, one at the moment of push, one sweeping the entire history in CI. The uncomfortable part, and the reason this post belongs to this series: one of my three layers used to be a decoration.