diff --git a/flake.lock b/flake.lock index 9726a9a06..607e10f9c 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,105 @@ { "nodes": { + "crane": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": [ + "putter", + "flake-utils" + ], + "nixpkgs": [ + "putter", + "nixpkgs" + ], + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1697588719, + "narHash": "sha256-n9ALgm3S+ygpzjesBkB9qutEtM4dtIkhn8WnstCPOew=", + "owner": "ipetkov", + "repo": "crane", + "rev": "da6b58e270d339a78a6e95728012ec2eea879612", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.14.3", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696267196, + "narHash": "sha256-AAQ/2sD+0D18bb8hKuEEVpHUYD1GmO2Uh/taFamn6XQ=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "4f910c9827911b1ec2bf26b5a062cd09f8d89f85", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "putter", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1660459072, + "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1765472234, @@ -16,9 +116,133 @@ "type": "github" } }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1685801374, + "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1699094435, + "narHash": "sha256-YLZ5/KKZ1PyLrm2MO8UxRe4H3M0/oaYqNhSlq6FDeeA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9d5d25bbfe8c0297ebe85324addcb5020ed1a454", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat_2", + "flake-utils": [ + "putter", + "flake-utils" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "putter", + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1698852633, + "narHash": "sha256-Hsc/cCHud8ZXLvmm8pxrXpuaPEeNaaUttaCvtdX/Wug=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "dec10399e5b56aa95fcd530e0338be72ad6462a0", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "putter": { + "inputs": { + "crane": "crane", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs_2", + "pre-commit-hooks": "pre-commit-hooks" + }, + "locked": { + "lastModified": 1704013409, + "narHash": "sha256-v7CTHSKcD6vnIwXRPav+3XETf+uNJz3G+RUF/SHZ+vE=", + "ref": "refs/heads/master", + "rev": "4d773d3aa9feca3af4578dc62cc6f91ebb16b002", + "revCount": 33, + "type": "git", + "url": "file:///home/rycee/devel/putter" + }, + "original": { + "type": "git", + "url": "file:///home/rycee/devel/putter" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "putter": "putter" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": [ + "putter", + "crane", + "flake-utils" + ], + "nixpkgs": [ + "putter", + "crane", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1696299134, + "narHash": "sha256-RS77cAa0N+Sfj5EmKbm5IdncNXaBCE1BSSQvUE8exvo=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "611ccdceed92b4d94ae75328148d84ee4a5b462d", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 96e572f40..809e1d9d9 100644 --- a/flake.nix +++ b/flake.nix @@ -2,11 +2,13 @@ description = "Home Manager for Nix"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.putter.url = "git+file:///home/rycee/devel/putter"; outputs = { self, nixpkgs, + putter, ... }: { diff --git a/modules/files.nix b/modules/files.nix index db63fced8..a91d0fff2 100644 --- a/modules/files.nix +++ b/modules/files.nix @@ -2,6 +2,7 @@ pkgs, config, lib, + putter, ... }: @@ -30,6 +31,8 @@ let name = sourceName; }; + putterStatePath = "${config.xdg.stateHome}/home-manager/putter-state.json"; + in { @@ -45,6 +48,14 @@ in internal = true; description = "Package to contain all home files"; }; + + home.internal = { + filePutterConfig = lib.mkOption { + type = lib.types.package; + internal = true; + description = "Putter configuration."; + }; + }; }; config = { @@ -91,156 +102,17 @@ in # This verifies that the links we are about to create will not # overwrite an existing file. - home.activation.checkLinkTargets = lib.hm.dag.entryBefore [ "writeBoundary" ] ( - let - # Paths that should be forcibly overwritten by Home Manager. - # Caveat emptor! - forcedPaths = lib.concatMapStringsSep " " (p: ''"$HOME"/${lib.escapeShellArg p}'') ( - lib.mapAttrsToList (n: v: v.target) (lib.filterAttrs (n: v: v.force) cfg) - ); + home.activation.checkLinkTargets = lib.hm.dag.entryBefore [ "writeBoundary" ] '' + ${lib.getExe putter} check -v \ + --state-file "${putterStatePath}" \ + ${config.home.internal.filePutterConfig} + ''; - storeDir = lib.escapeShellArg builtins.storeDir; - - check = pkgs.replaceVars ./files/check-link-targets.sh { - inherit (config.lib.bash) initHomeManagerLib; - inherit forcedPaths storeDir; - }; - in - '' - function checkNewGenCollision() { - local newGenFiles - newGenFiles="$(readlink -e "$newGenPath/home-files")" - find "$newGenFiles" \( -type f -or -type l \) \ - -exec bash ${check} "$newGenFiles" {} + - } - - checkNewGenCollision || exit 1 - '' - ); - - # This activation script will - # - # 1. Remove files from the old generation that are not in the new - # generation. - # - # 2. Symlink files from the new generation into $HOME. - # - # This order is needed to ensure that we always know which links - # belong to which generation. Specifically, if we're moving from - # generation A to generation B having sets of home file links FA - # and FB, respectively then cleaning before linking produces state - # transitions similar to - # - # FA → FA ∩ FB → (FA ∩ FB) ∪ FB = FB - # - # and a failure during the intermediate state FA ∩ FB will not - # result in lost links because this set of links are in both the - # source and target generation. - home.activation.linkGeneration = lib.hm.dag.entryAfter [ "writeBoundary" ] ( - let - link = pkgs.writeShellScript "link" '' - ${config.lib.bash.initHomeManagerLib} - - newGenFiles="$1" - shift - for sourcePath in "$@" ; do - relativePath="''${sourcePath#$newGenFiles/}" - targetPath="$HOME/$relativePath" - if [[ -e "$targetPath" && ! -L "$targetPath" ]] ; then - if [[ -n "$HOME_MANAGER_BACKUP_COMMAND" ]] ; then - verboseEcho "Running $HOME_MANAGER_BACKUP_COMMAND $targetPath." - run $HOME_MANAGER_BACKUP_COMMAND "$targetPath" || errorEcho "Running `$HOME_MANAGER_BACKUP_COMMAND` on '$targetPath' failed." - elif [[ -n "$HOME_MANAGER_BACKUP_EXT" ]] ; then - # The target exists, back it up - backup="$targetPath.$HOME_MANAGER_BACKUP_EXT" - if [[ -e "$backup" && -n "$HOME_MANAGER_BACKUP_OVERWRITE" ]]; then - run rm $VERBOSE_ARG "$backup" - fi - run mv $VERBOSE_ARG "$targetPath" "$backup" || errorEcho "Moving '$targetPath' failed!" - fi - fi - - if [[ -e "$targetPath" && ! -L "$targetPath" ]] && cmp -s "$sourcePath" "$targetPath" ; then - # The target exists but is identical – don't do anything. - verboseEcho "Skipping '$targetPath' as it is identical to '$sourcePath'" - else - # Place that symlink, --force - # This can still fail if the target is a directory, in which case we bail out. - run mkdir -p $VERBOSE_ARG "$(dirname "$targetPath")" - run ln -Tsf $VERBOSE_ARG "$sourcePath" "$targetPath" || exit 1 - fi - done - ''; - - cleanup = pkgs.writeShellScript "cleanup" '' - ${config.lib.bash.initHomeManagerLib} - - # A symbolic link whose target path matches this pattern will be - # considered part of a Home Manager generation. - homeFilePattern="$(readlink -e ${lib.escapeShellArg builtins.storeDir})/*-home-manager-files/*" - - newGenFiles="$1" - shift 1 - for relativePath in "$@" ; do - targetPath="$HOME/$relativePath" - if [[ -e "$newGenFiles/$relativePath" ]] ; then - verboseEcho "Checking $targetPath: exists" - elif [[ ! "$(readlink "$targetPath")" == $homeFilePattern ]] ; then - warnEcho "Path '$targetPath' does not link into a Home Manager generation. Skipping delete." - else - verboseEcho "Checking $targetPath: gone (deleting)" - run rm $VERBOSE_ARG "$targetPath" - - # Recursively delete empty parent directories. - targetDir="$(dirname "$relativePath")" - if [[ "$targetDir" != "." ]] ; then - pushd "$HOME" > /dev/null - - # Call rmdir with a relative path excluding $HOME. - # Otherwise, it might try to delete $HOME and exit - # with a permission error. - run rmdir $VERBOSE_ARG \ - -p --ignore-fail-on-non-empty \ - "$targetDir" - - popd > /dev/null - fi - fi - done - ''; - in - '' - function linkNewGen() { - _i "Creating home file links in %s" "$HOME" - - local newGenFiles - newGenFiles="$(readlink -e "$newGenPath/home-files")" - find "$newGenFiles" \( -type f -or -type l \) \ - -exec bash ${link} "$newGenFiles" {} + - } - - function cleanOldGen() { - if [[ ! -v oldGenPath || ! -e "$oldGenPath/home-files" ]] ; then - return - fi - - _i "Cleaning up orphan links from %s" "$HOME" - - local newGenFiles oldGenFiles - newGenFiles="$(readlink -e "$newGenPath/home-files")" - oldGenFiles="$(readlink -e "$oldGenPath/home-files")" - - # Apply the cleanup script on each leaf in the old - # generation. The find command below will print the - # relative path of the entry. - find "$oldGenFiles" '(' -type f -or -type l ')' -printf '%P\0' \ - | xargs -0 bash ${cleanup} "$newGenFiles" - } - - cleanOldGen - linkNewGen - '' - ); + home.activation.linkGeneration = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + ${lib.getExe putter} apply $VERBOSE_ARG -v ''${DRY_RUN:+--dry-run} \ + --state-file "${putterStatePath}" \ + ${config.home.internal.filePutterConfig} + ''; home.activation.checkFilesChanged = lib.hm.dag.entryBefore [ "linkGeneration" ] ( let @@ -286,6 +158,18 @@ in '') (lib.filter (v: v.onChange != "") (lib.attrValues cfg)) ); + home.internal.filePutterConfig = + let + putter = import ./lib/putter.nix { inherit lib; }; + manifest = putter.mkPutterManifest { + inherit putterStatePath; + sourceBaseDirectory = config.home-files; + targetBaseDirectory = config.home.homeDirectory; + fileEntries = attrValues cfg; + }; + in + pkgs.writeText "hm-putter.json" manifest; + # Symlink directories and files that have the right execute bit. # Copy files that need their execute bit changed. home-files = diff --git a/modules/home-environment.nix b/modules/home-environment.nix index 32c3ce6b5..0c6460f9d 100644 --- a/modules/home-environment.nix +++ b/modules/home-environment.nix @@ -906,6 +906,7 @@ in --subst-var-by GENERATION_DIR $out ln -s ${config.home-files} $out/home-files + ln -s ${config.home.internal.filePutterConfig} $out/putter.json ln -s ${cfg.path} $out/home-path cp "$extraDependenciesPath" "$out/extra-dependencies" diff --git a/modules/lib/putter.nix b/modules/lib/putter.nix new file mode 100644 index 000000000..2fe42ecc1 --- /dev/null +++ b/modules/lib/putter.nix @@ -0,0 +1,81 @@ +# Contains some handy functions for generating Putter file manifests. + +{ lib }: + +let + + inherit (lib) + concatMap + concatLists + mapAttrsToList + hasPrefix + filter + ; + +in +{ + # Converts a Home Manager style list of file specifications into a Putter + # configuration. + # + # Note, the interface of this function is not considered stable, it may change + # as the needs of Home Manager change. + mkPutterManifest = + { + putterStatePath, + sourceBaseDirectory, + targetBaseDirectory, + fileEntries, + }: + let + # Convert a directory to a Putter configuration. Basically, this will + # create a file entry for each file in the directory. Any sub-directories + # will be handled recursively. + mkDirEntry = + f: + concatLists ( + mapAttrsToList ( + n: v: + let + f' = f // { + source = "${f.source}/${n}"; + target = "${f.target}/${n}"; + }; + in + mkEntriesForType f' v + ) (builtins.readDir f.source) + ); + + mkEntriesForType = + f: t: + if t == "regular" || t == "symlink" then + mkFileEntry f + else if t == "directory" then + mkDirEntry f + else + throw "unexpected file type ${t}"; + + # Create a file entry for the given file. + mkFileEntry = f: [ + { + collision.resolution = if f.force then "force" else "abort"; + action.type = "symlink"; + source = "${sourceBaseDirectory}/${f.target}"; + target = (if hasPrefix "/" f.target then "" else "${targetBaseDirectory}/") + f.target; + } + ]; + + # Given a Home Manager file entry, produce a list of Putter entries. For + # recursive HM file entries, we recursively traverse the source directory + # and generate a Putter entry for each file we encounter. + mkEntries = f: if f.recursive then mkEntriesForType f "directory" else mkFileEntry f; + + putterJson = { + version = "1"; + state = putterStatePath; + files = concatMap mkEntries (filter (f: f.enable) fileEntries); + }; + + putterJsonText = builtins.toJSON putterJson; + in + putterJsonText; +}