Using Home Manager to Manage Symlinks to Dotfiles

Posted on February 20, 2022 by Richard Goulter
Tags:

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.

Commit.

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.

Commit

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)) //
    unconventionalHomeFilesToLink;
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;

  # ...
}

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; }.
genAttrsForSimpleLink = fileName: ./. + ("/" + fileName);

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; }.
genAttrsForSimpleDotLink = fileName: { ".${fileName}" = ./. + ("/" + fileName); };

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; }).


Newer post Older post