Skip to content

Instantly share code, notes, and snippets.

@udf
Last active April 15, 2025 07:57
Show Gist options
  • Save udf/4d9301bdc02ab38439fd64fbda06ea43 to your computer and use it in GitHub Desktop.
Save udf/4d9301bdc02ab38439fd64fbda06ea43 to your computer and use it in GitHub Desktop.
A Trick To Use mkMerge at The Top Level of a NixOS module

The Setup

I wanted to write a module that generates multiple systemd services and timers to scrub some zfs pools at certain intervals. The default scrub config does not support individual scrub intervals for each pool.

I want the config to look like this:

{
  services.zfs-auto-scrub = {
    tank = "Sat *-*-* 00:00:00";
    backups = "Sat *-*-* 06:00:00";
  };
}

So let's define the basic structure of our module:

{ config, lib, pkgs, ... }:
with lib;
let
  cfg = config.services.zfs-auto-scrub;
in
{
  options.services.zfs-auto-scrub = mkOption {
    description = "Set of pools to scrub, to when to scrub them";
    type = types.attrsOf types.str;
    default = {};
  };
  
  confg = {}; # TODO: implement
}

Side note: I don't bother with an enable option for my own modules, because I comment out the import in my main config to disable a module, but feel free to add it if you're following along.

So far pretty normal, let's use mapAttrs' to generate the unit and timer for each pool:

{
  # ...

  config.systemd.services = (
    mapAttrs'
    (name: interval: nameValuePair "zfs-scrub-${name}" {
      description = "ZFS scrub for pool ${name}";
      after = [ "zfs-import.target" ];
      serviceConfig = {
        Type = "oneshot";
      };
      script = "${config.boot.zfs.package}/bin/zpool scrub ${name}";
    })
    cfg
  );

  config.systemd.timers = (
    mapAttrs'
    (name: interval: nameValuePair "zfs-scrub-${name}" {
      wantedBy = [ "timers.target" ];
      after = [ "multi-user.target" ];
      timerConfig = {
        OnCalendar = interval;
        Persistent = "yes";
      };
    })
    cfg
  );
}

Well, that's not so bad for this simple example, but I'm sure you can see how repetitive it gets to have to mapAttrs' for every key that you want to generate.

Merge all the keys!

Enter mkMerge, it takes a list of options definitions and merges them. So we should be able to generate the units and timers individually and merge them into one at the top, right?

{
  # ...

  config = mkMerge (mapAttrsToList (
    name: interval: {
      systemd.services."zfs-scrub-${name}" = {
        description = "ZFS scrub for pool ${name}";
        after = [ "zfs-import.target" ];
        serviceConfig = {
          Type = "oneshot";
        };
        script = "${config.boot.zfs.package}/bin/zpool scrub ${name}";
      };

      systemd.timers."zfs-scrub-${name}" = {
        wantedBy = [ "timers.target" ];
        after = [ "multi-user.target" ];
        timerConfig = {
          OnCalendar = interval;
          Persistent = "yes";
        };
      };
    }
  ) cfg);
}

Right?

building Nix...
error: infinite recursion encountered, at /nix/var/nix/profiles/per-user/root/channels/nixos/lib/modules.nix:131:21
(use '--show-trace' to show detailed location information)

Guess not.

jk... unless 😳

There is a quick workaround for this case, since we're only generating systemd.* options we can put our merge at the config.systemd level:

{
  # ...

  config.systemd = mkMerge (mapAttrsToList (
    name: interval: {
      services."zfs-scrub-${name}" = {
        # ...
      };

      timers."zfs-scrub-${name}" = {
        # ...
      };
    }
  ) cfg);
}

(repeated options omitted for brevity)

While this works, it's really only fine for simple modules that generate options under one top level key. For example let's say we wanted to generate some users as well (doesn't fit with the example, but bare with me). If we add another option like config.users = mkMerge ...? Then we're back to square one.

Hack the planet

What if we were to put the mkMerge's one level lower? Essentially we would want to turn a list of options like:

[ { a = 1; } { a = 2; b = 3; } ]

into

{ a = mkMerge [ 1 2 ]; b = mkMerge [ 3 ]; }

(imagine the integers as real options).

It seems like a complicated problem, but there's a function in lib that solves the whole thing for us, foldAttrs. The example looks exactly like our problem!

lib.attrsets.foldAttrs
  (n: a: [n] ++ a) []
  [
    { a = 2; b = 7; }
    { a = 3; }
    { b = 6; }
  ]
=> { a = [ 2 3 ]; b = [ 7 6 ]; }

So all we need to do is wrap foldAttrs in a mapAttrs so we can put each list through mkMerge:

{
  mkMergeTopLevel = attrs: (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
  );
}

Let's slap that into our module:

let
  # ...
  mkMergeTopLevel = attrs: (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
  );
in
{
  # ...

  config = mkMergeTopLevel (mapAttrsToList (
    name: interval: {
      systemd.services."zfs-scrub-${name}" = {
        # ...
      };

      systemd.timers."zfs-scrub-${name}" = {
        # ...
      };
    }
  ) cfg);
}

And...

building Nix...
error: infinite recursion encountered

(i cri)

What gives? if we take one key from the output of our function and assign it to the module, then it works fine:

{
  config.systemd = (mkMergeTopLevel (...).systemd);
}

Planet status: h4xed

So clearly what we're doing is legal, so lets explicitly pull out the option(s) that we want using getAttrs:

let
  mkMergeTopLevel = names: attrs: getAttrs names (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
  );
in
{
  # ...
  config = mkMergeTopLevel ["systemd"] (...);
}

And it works! I don't really get exactly why explicitly pulling out the options that we want avoids the infinite recursion.

Conclusion

Obviously for this trivial example, putting the merge at the config.systemd level makes more sense, but for a more complex module it definitely helps with readability.

Something else to note is that if we wanted to define service options then we would get a recursion error, the solution in that case is to move our module's options to another top level key that we're not going to use in the config section (for example, options.custom.services.zfs-auto-scrub).

@nevesenin
Copy link

Awesome! Thanks!

@frederikstroem
Copy link

Thanks! 😊

I tweaked this a bit to make it work for my use case, which involved merging multiple home.packages attributes into one.

After a few iterations of code and prompt engineering, I ended up with the following solution. Thought I'd share it here in case it's useful to others too:

{ config, lib, pkgs, hostType, ... }:


# This is a hack to merge multiple `home.packages` attributes into one.
# Allowing multiple `home.packages` attributes in the same file, instead of failing with
# `error: attribute 'home.packages' already defined at /nix/store/…`.
# Source: https://web.archive.org/web/20250415072300/https://gist.github.com/udf/4d9301bdc02ab38439fd64fbda06ea43
let
  inherit (lib) attrNames unique concatMap foldAttrs mapAttrs mkMerge;

  # Get a unique flat list of all top-level keys from a list of attribute sets.
  allTopLevelKeys = attrsList:
    unique (concatMap attrNames attrsList);

  # Merge all the attribute sets.
  mergeEverything = attrsList:
    let
      keys = allTopLevelKeys attrsList;
      merged = mapAttrs (_: v: mkMerge v) (
        foldAttrs (n: a: [n] ++ a) [] attrsList
      );
    in
      merged;
in
{
  config = mergeEverything [
  {
    ###
    ### Git
    ###
    programs.git = {
      enable = true;
      package = pkgs.gitAndTools.gitFull; # Add `gitk` | Default: `pkgs.git`
      lfs.enable = true; # Whether to enable Git Large File Storage.
      userName = "frederikstroem";
      userEmail = "[email protected]";
      signing = {
        key = "87A494F2";
      };
      extraConfig = {
        core = {
          editor = "nvim";
          packedGitLimit = if hostType == "desktop" then "16g" else "2g";
          packedGitWindowSize = if hostType == "desktop" then "16G" else "2g";
        };
        pack = {
          deltaCacheSize = "1g"; # Getting "out of range" error above 1g?!?
          packSizeLimit = if hostType == "desktop" then "16g" else "2g";
          windowMemory = if hostType == "desktop" then "16g" else "2g";
        };
        http = {
          postBuffer = 524288000;
        };
        init = {
          defaultBranch = "main";
        };
      };
    };
  }

  {
    ###
    ### Delta
    ###
    ### https://github.com/dandavison/delta
    ### πŸ¦€ Rust πŸš€
    ###
    programs.git.delta = {
      enable = true;
      options = {
        dark = true;
        features = "decorations";
        line-numbers = true;
      };
    };
  }

  {
    ###
    ### GitHub CLI
    ###
    ### https://cli.github.com/
    ### https://github.com/cli/cli
    ###
    programs.gh = {
      enable = true;
      gitCredentialHelper.enable = true;
    };
  }

  {
    ###
    ### git-* aliases (all shells)
    ###
    # Prune or list local tracking branches that do not exist on remote anymore.
    # https://stackoverflow.com/questions/13064613/how-to-prune-local-tracking-branches-that-do-not-exist-on-remote-anymore/17029936#17029936
    home.packages = with pkgs; [
      (writeShellScriptBin "git-list-untracked" ''
        git fetch --prune && git branch -r | awk "{print \$1}" | grep -E -v -f /dev/fd/0 <(git branch -vv | grep origin) | awk "{print \$1}"
      '')
      (writeShellScriptBin "git-remove-untracked" ''
        git fetch --prune && git branch -r | awk "{print \$1}" | grep -E -v -f /dev/fd/0 <(git branch -vv | grep origin) | awk "{print \$1}" | xargs git branch -d
      '')
      (writeShellScriptBin "git-remove-untracked-force-unmerged" ''
        git fetch --prune && git branch -r | awk "{print \$1}" | grep -E -v -f /dev/fd/0 <(git branch -vv | grep origin) | awk "{print \$1}" | xargs git branch -D
      '')
    ];
  }

  {
    ###
    ### Dracula for gitk
    ### πŸ§› Dark theme for gitk
    ###
    ### https://draculatheme.com/gitk
    ### https://github.com/dracula/gitk
    ###
    # https://github.com/dracula/gitk/blob/6e9749231549ca1a940b733f2629701e80b97fe2/gitk
    xdg.configFile."git/gitk" = {
      force = true;
      text = ''
        set uicolor #44475a
        set want_ttk 0
        set bgcolor #282a36
        set fgcolor #f8f8f2
        set uifgcolor #f8f8f2
        set uifgdisabledcolor #6272a4
        set colors {#50fa7b #ff5555 #bd93f9 #ff79c6 #f8f8f8 #ffb86c #ffb86c}
        set diffcolors {#ff5555 #50fa7b #bd93f9}
        set mergecolors {#ff5555 #bd93f9 #50fa7b #bd93f9 #ffb86c #8be9fd #ff79c6 #f1fa8c #8be9fd #ff79c6 #8be9fd #ffb86c #8be9fd #50fa7b #ffb86c #ff79c6}
        set markbgcolor #282a36
        set selectbgcolor #44475a
        set foundbgcolor #f1fa8c
        set currentsearchhitbgcolor #ffb86c
        set headbgcolor #50fa7b
        set headfgcolor black
        set headoutlinecolor #f8f8f2
        set remotebgcolor #ffb86c
        set tagbgcolor #f1fa8c
        set tagfgcolor black
        set tagoutlinecolor #f8f8f2
        set reflinecolor #f8f8f2
        set filesepbgcolor #44475a
        set filesepfgcolor #f8f8f2
        set linehoverbgcolor #f1fa8c
        set linehoverfgcolor black
        set linehoveroutlinecolor #f8f8f2
        set mainheadcirclecolor #f1fa8c
        set workingfilescirclecolor #ff5555
        set indexcirclecolor #50fa7b
        set circlecolors {#282a36 #bd93f9 #44475a #bd93f9 #bd93f9}
        set linkfgcolor #bd93f9
        set circleoutlinecolor #44475a
        set diffbgcolors {{#342a36} #283636}
      '';
    };
  }

  {
    ###
    ### πŸ¦’ Mergiraf πŸ¦’
    ### A syntax-aware git merge driver for a growing collection of programming languages and file formats.
    ###
    ### https://mergiraf.org/
    ### https://codeberg.org/mergiraf/mergiraf
    ### πŸ¦€ Rust πŸš€
    ###
    # For now, I only want Mergiraf to be invoked after a merge conflict, instead of registering it as a merge driver.
    # Therefore, I will just install it as a package.
    home.packages = with pkgs; [
      mergiraf
    ];
  }

];}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment