Using Home Manager to Manage Symlinks to Dotfiles
dotbot is a popular way of managing symlinks to dotfiles, and I used it for some time.
I’ve gradually been becoming more comfortable with Nix.
Nix remains relatively niche, so: Nix allows programmatic management of packages installed to a computer. Nix refers to the expression language that describes these packages, the tool which evaluates these expressions. It’s also pretty closely tied to Nixpkgs (a large repository of packages), and NixOS (a Linux distribution which is configured using Nix expressions).
Nix tooling is friendly towards words like “declarative” and “X-as-Code”.
Home Manager is a popular tool
which uses Nix configurations to manage a user’s environment, in a similar
manner to how NixOS’s system is configured from a Nix file.
It complements NixOS, but I’ve been happy to use NixOS (or nix on macOS)
without having tried home manager.
I was hesitant to try Home Manager, since I didn’t wanna have “vendor lock-in” and be unable to make use of my dotfiles elsewhere.
To be honest: whenever Nix pops up, I often see people say they don’t like the
Nix language, and that its error messages are hard to understand. My experience
with Nix before this was using it to describe Python packages, or Go
applications, or describe a declarative set of packages to use, and using
NixOS.
– If people’s first experience with Nix is with home manager, I can see why
Nix might seem more trouble than it’s worth.
Here are my notes on the steps I took, following the home manager manual and the NixOS wiki page on Home Manager.
A Taste of NixOS, but for Your Home Directory
EDIT: Having tried this a bit.. one significant difference from the dotbot
approach: dotbot manages symlinks so that e.g. ~/.vimrc
links to the
repository where the dotbot was run from. The home-manager approach first
constructs a Nix package with the configuration files, then symlinks to these
files.
This demands a change in mindset, since this means the file which ~/.vimrc
symlinks to is read-only.
This makes more sense if describing the configuration files using Nix, compared
to the approach described below (which attempts to model dotbot).
I don’t mind “can’t edit the (generated) configuration files” when it comes to OS files. I find it slightly more awkward with configuration files for Vim or Emacs.
It’s often possible for configuration files to include
other configuration
files. This is one way to “work around” the above restriction.
Installing Home Manager
I’d recently started using Nix flakes. Nix flakes are cool.
So, following the standalone setup with Nix Flakes chapter of the manual..
The ‘trouble’ is, per discussion in
nix-community/home-manager#2461,
Home Manager doesn’t support the new “nix command” style of profile management.
i.e. Installing a package in nix classic would be nix-env -iA nixpkgs.neovim
,
but with the new nix command style it’s nix profile install nixpkgs#neovim
.
And the old style of command is incompatible with the new style, so you can’t
use the old nix-env
commands once you’ve used nix profile
.
The PR provides people with forks which fix the issue. Worked for me.
Here’s the
commit
with the simple home.nix
and flake.nix
that I started with:
flake.nix
(which is pretty much boilerplate):
{
description = "rgoulter's Home Manager configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
home-manager = {
# https://github.com/nix-community/home-manager/pull/2461
inputs.nixpkgs.follows = "nixpkgs";
# Fork which allows using `nix profile` instead of `nix-env`
# https://github.com/FlorianFranzen/home-manager/commit/4e97b01b2737bb0f39c18a65d87dd98659391b97
# url = {
type = "github";
owner = "FlorianFranzen";
repo = "home-manager";
rev = "4e97b01b2737bb0f39c18a65d87dd98659391b97";
};
};
outputs = { home-manager, ... }:
let
system = "x86_64-linux";
username = "rgoulter";
in {
homeConfigurations."${username}-${system}" = home-manager.lib.homeManagerConfiguration {
inherit system username;
configuration = import ./home.nix;
homeDirectory = "/home/${username}";
# Update the state version as needed.
# See the changelog here:
# https://nix-community.github.io/home-manager/release-notes.html
stateVersion = "22.05";
};
};
}
(I went with rgoulter-x86_64-linux
as the profile name since I’m not sure
how home manager configurations interact with flake outputs. Presumably
rgoulter
would work just as well instead).
and the home.nix
:
{ config, pkgs, ... }:
{
home.stateVersion = "22.05";
programs.home-manager.enable = true;
}
And following the commands given in the manual. Since I was running this from a git repository on my machine:
nix build --no-link .#homeConfigurations.rgoulter-x86_64-linux.activationPackage
"$(nix path-info .#homeConfigurations.rgoulter-x86_64-linux.activationPackage)"/activate
Symlinking to Existing Dotfiles
Or “using home manager instead of dotbot”.
Dotbot takes an install.conf.yaml to configure what files to link where.
Home Manager can use nix to write various config files. But it can also just symlink given some source files.
Updated home.nix
:
{ config, pkgs, ... }:
{
home.stateVersion = "22.05";
programs.home-manager.enable = true;
# files in ~/.config/
xdg.configFile."alacritty/alacritty.yml".source = ./alacritty.yml;
xdg.configFile."emacs-rgoulter/init.el".source = ./emacs.el;
# etc.
# files in ~/
home.file.".emacs-profiles.el".source = ./emacs-profiles.el;
home.file.".gvimrc".source = ./gvimrc;
# etc.
# Using the submodule in this dotfiles repo would make
# require a more awkward flake URI.
home.file.".nvim/bundle/Vundle.vim".source = pkgs.fetchFromGitHub {
owner = "VundleVim";
repo = "Vundle.vim";
rev = "cfd3b2d388a8c2e9903d7a9d80a65539aabfe933";
sha256 = "sha256-OCCXgMVWj/aBWLGaZmMr+cD546+QgynmEN/ECp1r08Q=";
};
}
The xdg.configFile.<name>.source
(and home.file.<name>.source
) are given as
examples on the Home Manager page in the NixOS
wiki, and
documented in Appendix A of the Home Manager
manual.
I can then apply the home.nix
with, as the Home Manager
manual explains:
home-manager switch --flake '.#rgoulter-x86_64-linux'
First Steps in Refactoring
As can be seen in the above commit, it’s … not pretty.
Look. Complain about or be aware of the Configuration Complexity Clock. But we’re at DSL o’clock here:
First step is to extract the attribute set out to let ... in
bindings.
Updated home.nix
:
{ config, pkgs, ... }:
let
# Attribute set for dotfiles in this repo to link into ~/.config.
# The attribute name is for ~/.config/$attrSetName,
# e.g. "alacritty/alacritty.yml" for ~/.config/alacritty/alacritty.yml
# The attribute value is the path to the dotfile in this repo.
configFilesToLink = {
"alacritty/alacritty.yml" = ./alacritty.yml;
"emacs-rgoulter/init.el" = ./emacs.el;
"emacs-rgoulter/straight/versions/default.el" = ./emacs.d/straight/versions/default.el;
# etc.
};
# Attribute set for dotfiles in this repo to link into home directory.
# The attribute name is for ~/$attrSetName,
# e.g. ".hgrc" for ~/.hgrc.
# The attribute value is the path to the dotfile in this repo.
homeFilesToLink = {
".emacs-profiles.el" = ./emacs-profiles.el;
".gvimrc" = ./gvimrc;
# etc.
};
# Function to help map attrs for symlinking home.file, xdg.configFile
# e.g. from { ".hgrc" = ./hgrc; } to { ".hgrc".source = ./hgrc; }
toSource = configDirName: dotfilesPath: { source = dotfilesPath; };
in
{
# Symlink files under ~, e.g. ~/.hgrc
home.file = pkgs.lib.attrsets.mapAttrs toSource homeFilesToLink;
# Symlink files under ~/.config, e.g. ~/.config/alacritty/alacritty.yml
xdg.configFile = pkgs.lib.attrsets.mapAttrs toSource configFilesToLink;
# ...
}
I figured out to use lib.attrsets.mapAttrs
by looking at this helpful
community-provided documentation page.
But otherwise, the Nix manual has a chapter on its builtin
functions, and
the Nixpkgs manual has a chapter on its library
functions.
To help read the above code, the example given for mapAttrs
is:
lib.attrsets.mapAttrs(name: value: name + "-" + value)
{ x = "foo"; y = "bar"; }
# => { x = "x-foo"; y = "y-bar"; }
I think this level of functional programming is relatively accessible.
Refactoring a bit Further
I mean, the above is okay, but it’s just nagging seeing code like .gvimrc" = ./gvimrc;
.
People have different tastes as how how implicit vs explicit they’d prefer things. It’s hardly a burden to be explicit if you’re unfamiliar with the domain.
But I had to try. Commit.
Updated home.nix
:
{ config, pkgs, ... }:
let
# e.g. given "alacritty/alacritty.yml",
# return the attrset { "alacritty/alacritty.yml" = ./alacritty/alacritty.yml; }.
genAttrsForSimpleLink = fileName: ./. + ("/" + fileName);
# e.g. given "hgrc"
# return the attrset { ".hgrc" = ./hgrc; }.
genAttrsForSimpleDotLink = fileName: { ".${fileName}" = ./. + ("/" + fileName); };
# ...
# List of dotfiles where the path to link under
# ~/.config/ matches the path in the dotfiles repo.
# e.g. ~/.config/alacritty/alacritty.yml matches ./alacritty/alacritty.yml.
simpleConfigFilesToLinkList = [
"fish/config.fish"
"kitty/kitty.conf"
"starship.toml"
# etc.
];
# Files where the symlinks aren't following a nice convention.
unconventionalConfigFilesToLink = {
"emacs-rgoulter/init.el" = ./emacs.el;
"nvim/init.vim" = ./vimrc;
# etc.
};
# e.g. "gvimrc" to link "~/.gvimrc" to ./gvimrc
simpleHomeFilesToLinkList = [
"gvimrc"
"hgrc"
"tmux.conf"
"vimrc"
# etc.
];
unconventionalHomeFilesToLink = {
".nvim/after/ftplugin/org.vim" = ./vim/after/ftplugin/org.vim;
".nvim/bundle/Vundle.vim" = vundleRepoSrc;
".vim/bundle/Vundle.vim" = vundleRepoSrc;
};
# Attribute set for dotfiles in this repo to link into ~/.config.
# The attribute name is for ~/.config/$attrSetName,
# e.g. "alacritty/alacritty.yml" for ~/.config/alacritty/alacritty.yml
# The attribute value is the path to the dotfile in this repo.
configFilesToLink =
(pkgs.lib.attrsets.genAttrs simpleConfigFilesToLinkList genAttrsForSimpleLink) //
;
unconventionalConfigFilesToLink
# Attribute set for dotfiles in this repo to link into home directory.
# The attribute name is for ~/$attrSetName,
# e.g. ".hgrc" for ~/.hgrc.
# The attribute value is the path to the dotfile in this repo.
homeFilesToLink =
(pkgs.lib.lists.foldr (a: b: a // b) {} (map genAttrsForSimpleDotLink simpleHomeFilesToLinkList)) //
;
unconventionalHomeFilesToLinkin
{
# Symlink files under ~, e.g. ~/.hgrc
home.file = pkgs.lib.attrsets.mapAttrs toSource homeFilesToLink;
# Symlink files under ~/.config, e.g. ~/.config/alacritty/alacritty.yml
xdg.configFile = pkgs.lib.attrsets.mapAttrs toSource configFilesToLink;
# ...
}
This one takes a bit more explaining.
In order to get from [ "fish/config.fish" ]
to { "fish/config.fish" = ./fish/config.fish; }
,
genAttrs
can be used.
lib.attrsets.genAttrs [ "foo" "bar" ] (name: "x_${name}")
=> { foo = "x_foo"; bar = "x_bar"; }
Hence, we can factor out the files which follow a nice convention to just a list,
and construct configFilesToLink
with:
# e.g. given "alacritty/alacritty.yml",
# return the attrset { "alacritty/alacritty.yml" = ./alacritty/alacritty.yml; }.
fileName: ./. + ("/" + fileName);
genAttrsForSimpleLink =
configFilesToLink =(pkgs.lib.attrsets.genAttrs simpleConfigFilesToLinkList genAttrsForSimpleLink) //
unconventionalConfigFilesToLink;
Unfortunately, to get [ "vimrc" ]
into { ".vimrc" = ./vimrc }
, this involves
transforming “vimrc” to “.vimrc”, it’s a bit more involved:
# e.g. given "hgrc"
# return the attrset { ".hgrc" = ./hgrc; }.
fileName: { ".${fileName}" = ./. + ("/" + fileName); };
genAttrsForSimpleDotLink =
homeFilesToLink =(pkgs.lib.lists.foldr (a: b: a // b) {} (map genAttrsForSimpleDotLink simpleHomeFilesToLinkList)) //
unconventionalHomeFilesToLink;
(where pkgs.lib.lists.foldr (a: b: a // b) {}
is essentially listToAttrs
,
but my version of Nix didn’t have this. In nix, { a = 3; } // { b = 4; }
evaluates to { a = 3; b = 4; }
).