Epistemic Status: lotus Blooming — This isn’t quite fully polished. I need to clean up the example and provide others. It’s related to NixOS Modules as an Expert System.


For a while now I’ve started to use things like shell aliases and scripts, small CLI tools, and so forth as a way to distill knowledge into usable artifacts that not only help do the thing they were written for but help to document the thing they were written for. Whether I use them enough to justify them becomes somewhat irrelevant because they’re documented in the code if I forget them.1

I find I try to do this all the time especially ever since I started using NixOS and Home Manager to codify my configurations. I’ve also started using devshell2 to include useful tooling in project level flake.nixflake.nix files even if they’re simple wrappers around whatever useful commands I use in that project3. Its message of the day feature is perfect for reminding myself of things when I enter the directory and it prints out (because direnv + nix-direnv). Things that I’ll easily forget when enough time passes.

A concrete example is in how I configure Git using Home Manager. I recently learned about the ! in Git Aliases for running arbitrary scripts. I needed it because I wanted an alias for searching Git history and it needed to take arguments in arbitrary places which regular aliases can’t do. I read about a pattern of using it to define a function and then calling it, so that you can easily string together multiple things. I’m certainly not using that part of it yet but it’s there when I need it.

Basically you can do something like this:

.gitconfig
[alias]
    foo = "!f() { command1; command2; }; f"
.gitconfig
[alias]
    foo = "!f() { command1; command2; }; f"

That will take git foogit foo and run command1command1 followed by command2command2. You can use this for places where you want an alias to take arguments. At a certain point it makes more sense to make a script that takes advantage of Git’s subcommand pluggable interface, but that’s sometimes overkill.

To “document” that pattern, I initially wrote a Nix function in my Home Manager configuration file like this4 :

Not quite ready for prime time...

I realized the other day that this code doesn’t actually work for multiple lines of script. It’s too naïve to work.

I have a better solution involving adding an option to create Git subcommands, i.e. scripts that are named git-<name> and thus get treated by Git as subcommands. I’ll update this once I have the new version of the module hammered out. beaming face with smiling eyes

# Takes a string of bash statements and joins them together 
# into an anonymous function that is called for use in a 
# Git alias using the `!` prefix. 
# 
# See https://www.atlassian.com/blog/git/advanced-git-aliases
advancedGitAlias = lines:
  "!f() { ${concatStringsSep "; " (splitString "\n" lines)} }; f";
# Takes a string of bash statements and joins them together 
# into an anonymous function that is called for use in a 
# Git alias using the `!` prefix. 
# 
# See https://www.atlassian.com/blog/git/advanced-git-aliases
advancedGitAlias = lines:
  "!f() { ${concatStringsSep "; " (splitString "\n" lines)} }; f";

Then, when I want to use it down in my configuration, I can just do this:

programs.git = {
  aliases = {
    # History search 
    search = advancedGitAlias ''
      git log -G"$1" -p ''${@:2}     
    '';
  };
};
programs.git = {
  aliases = {
    # History search 
    search = advancedGitAlias ''
      git log -G"$1" -p ''${@:2}     
    '';
  };
};

Ideally this would be codified into the programs.gitprograms.git options.

programs.git = {
  aliases = {
    # ...   
  };
  advancedAliases = {     
    # History search
    search = ''
      git log -G"$1" -p ''${@:2}
    '';   
  }; 
};
programs.git = {
  aliases = {
    # ...   
  };
  advancedAliases = {     
    # History search
    search = ''
      git log -G"$1" -p ''${@:2}
    '';   
  }; 
};

That way I don’t have to think about it, and I can encode the type restrictions in the option type. Module options are the one place where NixOS does a good job of providing typing tools.

I wasn’t sure if this was possible, to modify existing options like this, but I figured I’d give it a try. Here’s the Home Manager module I came up with to do this. It’s probably not in the best shape it could be in, but it illustrates the idea.

gitModule.nix
{ config, lib, ... }:
let
  inherit (builtins) mapAttrs;
  inherit (lib) mdDoc mkIf mkOption pipe types;
  inherit (lib.strings) concatStringsSep hasSuffix splitString;
 
  cfg = config.programs.git;
 
  # Takes a string of bash statements and joins them together
  # into an anonymous function that is called for use in a
  # Git alias using the `!` prefix.
  #
  # See https://www.atlassian.com/blog/git/advanced-git-aliases
  advancedGitAlias = lines:
    let lines_w_nl = if (hasSuffix "\n" lines) then lines else lines + "\n";
    in "!f() { ${
      pipe lines_w_nl [ (splitString "\n") (concatStringsSep ";") ]
    } }; f";
in {
  options.programs.git.advancedAliases = mkOption {
    type = types.attrsOf types.str;
    description = mdDoc ''
      Advanced aliases that can take multiple lines and arguments from the command line.
      Uses Git's ! feature. See https://www.atlassian.com/blog/git/advanced-git-aliases
    '';
    default = { };
    example = {
      search = ''
        git log -G"$1" -p ''${@:2}
      '';
    };
  };
 
  config = mkIf cfg.enable {
    programs.git.aliases = mapAttrs (_: advancedGitAlias) cfg.advancedAliases;
  };
}
gitModule.nix
{ config, lib, ... }:
let
  inherit (builtins) mapAttrs;
  inherit (lib) mdDoc mkIf mkOption pipe types;
  inherit (lib.strings) concatStringsSep hasSuffix splitString;
 
  cfg = config.programs.git;
 
  # Takes a string of bash statements and joins them together
  # into an anonymous function that is called for use in a
  # Git alias using the `!` prefix.
  #
  # See https://www.atlassian.com/blog/git/advanced-git-aliases
  advancedGitAlias = lines:
    let lines_w_nl = if (hasSuffix "\n" lines) then lines else lines + "\n";
    in "!f() { ${
      pipe lines_w_nl [ (splitString "\n") (concatStringsSep ";") ]
    } }; f";
in {
  options.programs.git.advancedAliases = mkOption {
    type = types.attrsOf types.str;
    description = mdDoc ''
      Advanced aliases that can take multiple lines and arguments from the command line.
      Uses Git's ! feature. See https://www.atlassian.com/blog/git/advanced-git-aliases
    '';
    default = { };
    example = {
      search = ''
        git log -G"$1" -p ''${@:2}
      '';
    };
  };
 
  config = mkIf cfg.enable {
    programs.git.aliases = mapAttrs (_: advancedGitAlias) cfg.advancedAliases;
  };
}

The advantage of this approach is that I don’t have to repeat that pattern everywhere, or remember what it’s for, or where I got it from.

This idea comes up often in my various configuration files where I document why I changed a setting, or especially with NixOS, how a collection of settings is interrelated to a problem I’m solving, such as configuring a Windows VM with PCI Passthrough for gaming, or the various settings needed to access the VPN and office networks at work, or settings related to a ZFS setup that resets the root partition on boot (see Erase your darlings5). Often times that leads to a new module that I pull out and abstract from the configuration.

Footnotes

  1. I find arguments about portability somewhat moot. This is the “what if you’re logged into a system that doesn’t have your aliases?” argument. If it’s easier for me to remember short aliases than a long series of command arguments, then I’d rather have that documented than try to remember the underlying command. Especially when you’re someone like me with poor working memory, the less I need to actively remember, the better. I can look it up on my local machine if I don’t have it remotely, or better, just use my home manager configs on the remote system.

  2. I don’t usually use the devshell.tomldevshell.toml support, but put it all directly into the Nix code in flake.nixflake.nix. If I were working with other people less experienced with Nix, I might use it, but even then I’d probably just try to nudge them towards the underlying Nix.

  3. One nice version of this is that I’ve written a script that goes through the steps I take to configure a new computer with NixOS, and included it in the system configuration flake so that I can just do a nix run flake-name#new-nixos-setupnix run flake-name#new-nixos-setup and run through it on a new system, even without checking out the repo as long as I have network access to it. I do it so infrequently, but when I do, having that makes it much easier.

  4. This obviously has some edge cases that I haven’t tested or tried, e.g. what happens if I give it a single line without a terminating newline? It should probably make sure the string ends with a semicolon and add one if not.

  5. I have big caveats for that method that I intend to document at some point. I ran into a lot of small gotchas along the way the first time I tried to set this up. Yet another reason I wrote a script for the process, so I wouldn’t have to remember.