cat posts/2023-06-13-one-set-of-dotfiles-for-windows-macos-and-linux.md
One set of dotfiles for Windows, macOS and Linux
I sat down at the Windows laptop last month, typed gs for git status out of habit, and got PowerShell’s red error text telling me there was no such command. That alias has been in my zsh config on the Macs for years. It had never made it to Windows, and neither had about half the other things my fingers expect, and I’d been living with that for so long I’d stopped noticing until that particular afternoon, when I hit four of them in ten minutes.
So I spent a couple of weekends fixing it properly. I use four machines most weeks: two Macs, a Windows laptop, and the Linux dev box in our AWS account that I’ve been working on over SSH since the M1 arrived. They now share one set of dotfiles, managed with chezmoi, and I wanted to write down how it fits together, because most of the dotfiles setups I found while I was looking are Mac-and-Linux only, and the Windows part is where it got interesting.
Why chezmoi and not a git repo of symlinks
I had the git-repo-and-symlinks version before, a repo in ~/dotfiles and a script that symlinked everything into place. It worked fine on the Macs. It worked badly on Windows, where creating symlinks needs either admin rights or Developer Mode, and where half the files live in completely different places (Documents\PowerShell for the PowerShell profile, somewhere deep in AppData\Local\Packages for Windows Terminal’s settings). And it had no answer for files that are almost the same on every machine but not quite, like a git config where the line endings setting differs on Windows.
chezmoi takes a different approach. It keeps a source directory, which is a git repo, and copies files into place instead of linking them, and any file in the source can be a Go template. You edit a file, run chezmoi diff to see what it would change on this machine, and chezmoi apply to do it. On a new machine, it’s one install command and chezmoi init --apply with the repo URL. It’s a single Go binary, so it’s the same tool everywhere, and I installed it with Homebrew on the Macs, winget on Windows and the install script on Linux.
The naming takes a minute to get used to. A file called dot_zshrc in the source becomes ~/.zshrc. private_dot_ssh becomes ~/.ssh with permissions locked down. Anything ending in .tmpl is run through the template engine first. You stop seeing it after a day.
What goes where
Not everything belongs on every machine. zsh is only on the Macs, bash is what I use on the Linux box, and PowerShell is only on Windows. chezmoi has a .chezmoiignore file, which is itself a template, so the rules for which files go where look like this:
{{ if ne .chezmoi.os "darwin" }}
.zshrc
.zprofile
{{ end }}
{{ if ne .chezmoi.os "linux" }}
.bashrc
.bash_profile
{{ end }}
{{ if ne .chezmoi.os "windows" }}
Documents/PowerShell
AppData
{{ end }}
It’s dull to read, which is what I want from it, since six months from now I’ll have forgotten how it works.
The aliases I want everywhere live in one file, ~/.config/shell/aliases, written in the subset of syntax that bash and zsh both accept, and both rc files source it. PowerShell gets its own version, because PowerShell aliases can’t take arguments and most of mine need to be little functions instead. That’s the one place I’ve accepted keeping two copies of the same idea, and there’s a comment at the top of each telling future me to update the other one.
Templates for the small differences
The git config is the best example of a file that’s almost identical everywhere. It’s a template:
[user]
name = Sijin Joseph
email = {{ .email }}
[init]
defaultBranch = main
{{- if eq .chezmoi.os "windows" }}
[core]
autocrlf = true
sshCommand = C:/Windows/System32/OpenSSH/ssh.exe
{{- end }}
The .email comes from a per-machine config file that chezmoi generates the first time you run init, asking you a couple of questions (which email for this machine, is this a work machine) and remembering the answers. Machines I use for work get the work email and the rest get my own, and I never have to think about it again, which I’d failed to do manually more than once and committed to a work repo as myself.
Secrets don’t go in the repo
The repo is private, but I still treat it as if it were public, because I’ve written enough posts about credentials ending up places they shouldn’t. Nothing in it is a secret. Tokens and API keys either come from the password manager at apply time (chezmoi can call out to the password manager’s CLI from inside a template) or live in a per-machine file that isn’t tracked at all. Each shell rc file ends with a line that sources ~/.zshrc.local or its equivalent if it exists, and that’s where machine-specific paths and anything sensitive go. When I set up a new machine, the local file starts empty, and I add things to it as I find I need them, which is also a good way of noticing what I actually use.
The Neovim trick
The editor is the part that surprised me. I’ve been using Neovim for a lot of my editing over the last couple of years, which the guy who wrote Tools don’t matter in 2009 would find funny, since he’d tried and given up on vim several times. On macOS and Linux, Neovim’s config is in ~/.config/nvim. On Windows the default is ~/AppData/Local/nvim, which meant either keeping two copies of the config in the source or some kind of link.
It turns out Neovim on Windows respects XDG_CONFIG_HOME if it’s set. So on Windows I set that one environment variable to %USERPROFILE%\.config, and now Neovim looks in the same place on every machine, and the whole config is one directory in the source. Plugins get installed by the plugin manager on first start, so the only thing chezmoi needs to know about is the config itself. There are a couple of if statements in it for the platform (a different shell for the built-in terminal on Windows, a clipboard setting on the Linux box that’s only reachable over SSH) and that’s it.
Windows Terminal’s settings file was the opposite experience. Its location includes a package ID, it rewrites the file itself whenever you change a setting in its UI, and it does so in a way that reorders things, so every chezmoi diff showed changes I hadn’t made. I gave up and stopped managing it. I re-copy the color scheme and the font by hand on the rare occasion I set up a new Windows machine, which is about once every two years.
Installing the tools
The last piece is a set of run_once_ scripts, which chezmoi runs the first time it sees them on a machine and then not again unless the script’s contents change. On the Macs it’s a Brewfile and brew bundle. On Windows it’s a PowerShell script with a list of winget install lines. On Linux it’s apt and a couple of things that come from their own install scripts. I don’t try to make these share anything. The lists of tools are different on each OS for good reasons, and a cross-platform package abstraction is the kind of thing I’d spend a weekend on and then regret.
The whole repo is 47 files. Setting up the Linux box from scratch, after I deliberately wiped my home directory to test it, took about four minutes, most of which was apt. I’ve typed gs on the Windows laptop about forty times since, and it’s worked every time.