Back in March I wrote about finally sorting my dotfiles with GNU Stow and Homebrew, after years of setting up every new machine from scratch like a caveman. The whole point was that I’d never have to think about it again.

Reader, I had to think about it again.

I set up a new MacBook this week and the process was mostly brilliant — clone, run the script, go make a brew. But a few things had drifted, one thing had genuinely broken, and I made an absolute meal of the git side of it. So here’s the update, including the bits where I embarrassed myself.

What Changed

Three things, in increasing order of how much trouble they caused.

Ruby 4.0.1 → 4.0.6

Boring but necessary. My mise/.config/mise/config.toml still pinned 4.0.1 from January. Ruby’s shipped five patch releases since then and 4.0.6 is the current stable, so it was just a one-line change:

[tools]
ruby = "4.0.6"
node = "24.13.0"

Rails has moved too — 8.1.3.1 as of July, which is a security patch, so worth having. I don’t pin Rails in the config because gem install rails grabs the latest anyway. The version in my README is documentation, not configuration.

This is the bit of dotfiles maintenance nobody warns you about. The setup doesn’t rot, but the versions do, quietly, and you only notice when you’re staring at ruby -v on a fresh machine wondering why it says something from January.

Zed → VS Code

I gave Zed a proper go. It’s fast, it’s lovely to look at, and I kept reaching for VS Code anyway. The Ruby and Rails tooling in VS Code is just further along, and when you spend your days in Rails that matters more than startup time.

So it’s now VS Code with:

Extension ID
Atom One Dark Theme akamud.vscode-theme-onedark
One Monokai Theme azemoh.one-monokai
Ruby Extensions Pack Shopify.ruby-extensions-pack
Ruby LSP Shopify.ruby-lsp
Ruby on Rails hridoy.rails-snippets
Ruby Sorbet sorbet.sorbet-vscode-extension
Tailwind CSS IntelliSense bradlc.vscode-tailwindcss

Worth saying: the Shopify pack bundles Ruby LSP and Sorbet itself, so listing those separately is belt and braces rather than strictly necessary. I like having them named explicitly — if I ever drop the pack, I don’t silently lose the two extensions I actually care about.

Extensions live in vscode/extensions.txt, one ID per line, and install.sh loops through them:

grep -v '^\s*#' ~/dotfiles/vscode/extensions.txt | grep -v '^\s*$' | while read -r ext; do
    code --install-extension "$ext" --force &> /dev/null || echo "  ⚠️  Failed: $ext"
done

To add one later, install it in VS Code as normal then snapshot the lot:

code --list-extensions > ~/dotfiles/vscode/extensions.txt

The one interesting wrinkle: VS Code can’t be a stow package.

Zed reads from ~/.config/zed/, an XDG path, so it slotted straight into my existing .config stow package. VS Code on macOS doesn’t use ~/.config at all — its settings live at ~/Library/Application Support/Code/User/settings.json.

You could make a stow package mirroring that path. Stow copes fine with the spaces. The problem is folding: if ~/Library/Application Support/Code/User/ doesn’t exist yet on a fresh machine, stow symlinks the highest directory it can rather than the single file. So it links the whole User folder into your repo, and VS Code then writes globalStorage/, workspaceStorage/ and History/ straight into your dotfiles. That’s hundreds of megabytes of churn in git, and you’d need stow --no-folding vscode every single time to avoid it.

So vscode/ sits at the repo root as a plain folder, not a stow package, and install.sh does an explicit ln -sf. Which is exactly the pattern I already use for iTerm2 — same awkward Library/ territory, same solution.

Homebrew 6.0 broke my install script

This one I didn’t see coming. Ran ./install.sh on the new machine and it died partway through:

Error: Refusing to load formula puma/puma/puma-dev from untrusted tap puma/puma.
Run `brew trust --formula puma/puma/puma-dev` or `brew trust puma/puma` to trust it.

Homebrew 6.0 shipped in June and now enforces tap trust. The reasoning is sound: formulae and casks aren’t plain metadata, they’re executable Ruby that Homebrew evaluates to resolve dependencies. Trusting a tap means accepting that code runs with your user’s privileges. So non-official taps are now blocked until you explicitly say otherwise.

I use puma-dev for local .test domains, and it comes from Puma’s own tap. Third-party, so: blocked.

The fix is a declarative one, because brew bundle understands a trusted: flag now:

brew "puma/puma/puma-dev", trusted: true

Plus a belt-and-braces step in install.sh before the bundle runs, since there have been reports of trusted: not always being honoured:

if brew trust --help &> /dev/null; then
    brew trust --formula puma/puma/puma-dev 2>/dev/null || true
fi

Two deliberate choices in there. The brew trust --help guard means it’s a no-op on older Homebrew that doesn’t have the command. And the || true matters — without it, set -e would kill the entire run if the trust call ever failed.

Trust the formula, not the tap. brew trust puma/puma also works, but it covers every current and future formula in that repo. The docs specifically recommend the narrow form, and they’re right.

There’s a transitional HOMEBREW_NO_REQUIRE_TAP_TRUST escape hatch that turns the whole thing off. Don’t. It’s explicitly temporary and due for removal.

The Bit Where I Made a Mess

Right, the confession.

I’d edited the dotfiles on two machines without pulling first. Classic. So when I finally ran git pull, I got merge conflicts in README.md and install.sh, plus modify/delete conflicts on the Zed files I’d removed on one side and edited on the other.

I resolved it, committed the merge, pushed. Then decided I didn’t want a merge commit cluttering the history, tried to rebase, hit the same conflicts again, pulled again by reflex which recreated the merge, and spent a good half hour going in circles.

Three things I learned the hard way:

--ours and --theirs flip meaning between merge and rebase. During a merge, --ours is your local HEAD. During a rebase, --ours is the commit you’re replaying onto and --theirs is your own work. I got this backwards at least once. If you’re ever unsure, git diff --ours <file> and git diff --theirs <file> before you pick — read what’s actually in each side rather than trusting the label.

git checkout --ours is all-or-nothing on the whole file. It worked for me because my side was a strict superset of the remote’s. If both sides have changes you want, you have to edit the conflict markers by hand. Taking a whole side is a decision, not a shortcut.

Once it’s pushed, pulling won’t undo it. I kept resetting my local branch and then pulling, which just fast-forwarded me straight back to the thing I was trying to delete. To remove something already on the remote you have to push over it — git push --force-with-lease, which refuses if the remote has moved since your last fetch. That’s the safety net that plain --force doesn’t give you.

And git branch backup-whatever before any history rewriting. Costs nothing, saves everything.

The Gotcha That Nearly Got Me

This one’s specific to stow and it’s worth internalising.

After all that, ruby -v was still reporting 4.0.1. I checked ~/.config/mise/config.toml, saw it was a regular file rather than a symlink, and did the obvious thing:

rm ~/.config/mise/config.toml
stow mise

Except when a path is a symlink into your repo, rm follows it and deletes the real file. git status immediately showed deleted: mise/.config/mise/config.toml. I’d removed it from the repo, not just unlinked it.

Recoverable, obviously — git checkout -- and it’s back. But if I’d done that to something uncommitted it would have been gone.

The right move is stow -D mise, which removes the symlinks and leaves the repo alone. Never rm a path under ~ that stow manages.

While I was in there I also found stow --adopt had quietly pulled a load of junk into my mise package — a 1Password telemetry file, a cagent machine UUID, an iTerm2 sockets directory, and a dangling absolute symlink pointing at my old Mac’s home directory. That’s --adopt doing exactly what it’s told: it pulls whatever it finds at the target path into the package. Handy, but it doesn’t ask.

Worth a .gitignore entry so it can’t happen again:

mise/.config/1Password/
mise/.config/cagent/
mise/.config/iterm2/

What I’d Tell March Me

The setup itself held up well. Stow, Homebrew, Mise — all still the right calls six months on, and the new MacBook was configured in one command once I’d sorted the trust issue.

But “set it and forget it” was optimistic. Three things need occasional attention:

  • Pinned versions drift. Worth a glance every few months.
  • The tooling underneath you changes. Homebrew 6.0 broke my script through no fault of mine.
  • git pull before you edit. Every time, on every machine. Every bit of pain in this article traces back to not doing that.

That last one isn’t a dotfiles lesson, it’s just a git lesson. But it bites harder with dotfiles, because you’re editing the same handful of files from multiple machines by design.

Still wouldn’t go back to setting up from scratch, mind. Even a bad day with dotfiles beats a good day with a fresh Mac and a vague memory of what you had installed.