↓ 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.

My 2 MB dotfiles repo grew to 31 GB. The cleaner found nothing to clean.

··1498 words·8 mins
My Mac mini froze hard enough that I held the power button to get it back. My first suspect was the macOS beta. My second was a terminal multiplexer I had been trialling. Both were wrong, and I had skipped the one check that would have pointed at the answer in about four seconds: the disk was full. My dotfiles repo tracks 2 MB of content, and it had grown to 31 GB. 26 GB of that was garbage, and two disk cleaners had already looked straight at it and reported nothing to clean.

A cask upgrade left the binary hanging. A copy of it started instantly.

A Homebrew cask upgrade on 2026-08-28 left my `claude` binary hanging forever on `--version`. No error, no exit, just a prompt that never came back. For two days nothing looked wrong, because every session already open kept working perfectly. The first person to notice was the first person to open a new one. The signature was valid. The bytes were intact. A byte-identical copy of the file, sitting in the same directory, started instantly while the original hung. That left exactly one thing it could be, and it was not the file.