akhi07rx

8 min read

Dotfiles on Windows, the hard way


A failing hard drive has a way of teaching you things. Lost everything on my old system: configs, editor setups, AutoHotkey scripts, PowerShell profiles. Watching a machine that took years to configure disappear into a boot loop reframes how you think about dotfiles. They stop being a nicety and start being a recovery strategy.

The obvious answer is Nix. Declarative, reproducible, handles everything including package state. I’d been meaning to try it for a while, but Nix has no real Windows support. It depends too deeply on POSIX primitives: hard links, symbolic links, file permissions, process models that don’t map cleanly onto the Win32 API. There are workarounds, and people have made them work, but I never looked hard enough to know if any of them were actually liveable. Maintaining that setup on a daily driver felt like asking for a second job.

Moving to Linux properly wasn’t an option either. College required Windows for a handful of tools that simply didn’t run anywhere else, and group work made it worse. Collaborating with classmates who had never touched a terminal meant shared files, shared sessions, shared headaches. Showing up with a different OS created friction that wasn’t mine to impose on everyone else.

So I landed on a combination of two older, less glamorous tools:chezmoi 11chezmoi has been around since late 2018 and has native Windows support, installable via Scoop or Chocolatey.  for tracking dotfiles and Ansible 22Ansible, originally released in February 2012, handles Windows targets through WinRM rather than SSH, which adds setup friction but works.  for provisioning the machine state around them. Neither is perfect on Windows. Together they get close enough to be useful.

§The problem chezmoi solves

What I actually wanted was a way to say: these specific files, in these specific places, should always be backed up and restorable from a fresh machine. Nothing more complicated than that.

chezmoi stores a copy of those files in ~/.local/share/chezmoi, which is just a git repo. You run chezmoi add <path> to bring a file under management, and chezmoi apply to push the source state back out to the real paths. On a fresh machine you can initialize straight from GitHub:

(irm https://chezmoi.io/get.ps1) | powershell -c - -b ~/.local/bin init --apply --force your-github-username

That is genuinely the whole install-and-restore path. One command, and your dotfiles land where they belong.

The part chezmoi does not handle is the rhythm of keeping everything in sync as files change. If you edit your Neovim config or add an AutoHotkey script, you have to remember to run chezmoi add again. Forget, and the repo drifts from reality. This is the friction I wanted to reduce.

§A PowerShell function that does the bookkeeping

The function I use, Chezmoi-File-Update, handles three things in one pass: adding or updating tracked files that still exist, detecting tracked files that have been deleted from the system, and prompting about what to do with them.

The paths I care about are declared at the top:

$trackedPaths = @(
    "$env:LOCALAPPDATA\nvim",
    "$HOME\Documents\PowerShell\Microsoft.PowerShell_profile.ps1"
)

For each path that exists, it runs chezmoi add. For paths that have gone missing it notes them and handles them separately. The deletion side is where the thing gets awkward.

chezmoi managed returns every path the repo knows about, as relative paths. Converting those back to absolute Windows paths is a string manipulation exercise. The approach in the function handles the common cases (AppData/Local, AppData/Roaming, Documents) by pattern-matching the prefix:

if ($actualPath -match '^AppData/Local/') {
    $actualPath = Join-Path $env:LOCALAPPDATA ($actualPath -replace '^AppData/Local/', '')
} elseif ($actualPath -match '^AppData/Roaming/') {
    $actualPath = Join-Path $env:APPDATA ($actualPath -replace '^AppData/Roaming/', '')
} elseif ($actualPath -match '^Documents/') {
    $actualPath = Join-Path "$HOME\Documents" ($actualPath -replace '^Documents/', '')
} else {
    $actualPath = Join-Path $HOME $actualPath
}

$actualPath = $actualPath -replace '/', '\'

Once it finds a managed file that no longer exists on disk, it offers three options: remove all of them from chezmoi, go through them one at a time, or skip. Removing means calling chezmoi forget to drop the tracking, then git rm to clean it out of the repo.

Drop the function into your PowerShell profile ($PROFILE) and alias it:

Set-Alias -Name cz-update -Value Chezmoi-File-Update

cz-update becomes the single command that keeps the repo current. Run it before you commit.

§Where Ansible fits in

chezmoi handles files. It does not install software, configure Windows features, or set up the environment that those config files expect to exist. That gap is where Ansible comes in, even though running Ansible on Windows is a bit awkward. It targets Windows hosts over WinRM rather than SSH, which means you either run the control node from WSL or from another machine on the network. Neither is seamless, but a playbook that installs your tools and applies your chezmoi dotfiles in sequence is a workable bootstrap path for a new machine.

A playbook is Ansible’s term for a YAML file that defines an ordered list of tasks to run against a target machine.

The rough shape of it: Ansible provisions the applications (Neovim, AutoHotkey, your terminal, whatever else), then chezmoi layers the configs on top. The dotfiles repo and the Ansible playbook live in the same git repository, so recovering from scratch is a matter of cloning that repo and running two commands.

Note Windows support in Ansible is real but has edges. Some modules behave differently, WinRM setup is its own adventure, and the Windows guide in the docs is worth reading before you try to do anything complicated.

§What it still doesn’t do

The path reconstruction from chezmoi managed output is fragile. If a managed file lives outside the common prefixes (AppData/Local, AppData/Roaming, Documents, Desktop), the function falls back to treating the path as relative to $HOME, which may or may not be right. It has not broken for my setup, but it is an assumption.

There is also no conflict resolution. If the repo has a version of a file and the working copy has diverged, chezmoi add overwrites the source state silently. Whether that is what you want depends on which direction the truth is supposed to flow, and the function does not have an opinion.

The Ansible side needs more work. Right now it is closer to a list of intentions than a fully tested playbook. The WinRM configuration is manual and not documented anywhere useful yet.

This works well enough for my setup right now. The path-prefix logic is the part I trust least, and the auto variant of the function is there for when I want to skip the prompts entirely, though it has less checking than the interactive version. If any of this breaks, the fallback is just running chezmoi add manually like before, which is not so bad.