diff --git a/modules/files.nix b/modules/files.nix index 121b72e24..f1be39b83 100644 --- a/modules/files.nix +++ b/modules/files.nix @@ -55,6 +55,8 @@ let name = sourceName; }; + putterStatePath = "${config.xdg.stateHome}/home-manager/putter-state.json"; + in { @@ -88,11 +90,44 @@ in ''; }; + home.fileActivator = lib.mkOption { + type = + with lib.types; + enum [ + "legacy" + "putter" + ]; + default = "legacy"; + example = "putter"; + visible = false; + description = '' + The tooling to use to place files during activation. + + The legacy option (currently the default) is the built-in tooling that + is very robust, but is limited in future potential. + + The putter option is a new external tool that may replace the legacy + alternative in the future. It is not as hardened as the legacy + alternative but will allow future features such as file copying. + + This option should be considered experimental and is therefore hidden + from documentation at this time. + ''; + }; + home-files = lib.mkOption { type = lib.types.package; internal = true; description = "Package to contain all home files"; }; + + home.internal = { + filePutterConfig = lib.mkOption { + type = lib.types.package; + internal = true; + description = "Putter configuration."; + }; + }; }; config = { @@ -149,21 +184,29 @@ in storeDir = lib.escapeShellArg builtins.storeDir; - check = pkgs.replaceVars ./files/check-link-targets.sh { + legacyCheckScript = 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 - '' + legacyCheckLinkTargets = '' + function checkNewGenCollision() { + local newGenFiles + newGenFiles="$(readlink -e "$newGenPath/home-files")" + find "$newGenFiles" \( -type f -or -type l \) \ + -exec bash ${legacyCheckScript} "$newGenFiles" {} + + } + + checkNewGenCollision || exit 1 + ''; + + putterCheckLinkTargets = '' + ${lib.getExe pkgs.putter} check $VERBOSE_ARG \ + --state-file "${putterStatePath}" \ + ${config.home.internal.filePutterConfig} + ''; + in + if config.home.fileActivator == "putter" then putterCheckLinkTargets else legacyCheckLinkTargets ); # This activation script will @@ -186,7 +229,9 @@ in # source and target generation. home.activation.linkGeneration = lib.hm.dag.entryAfter [ "writeBoundary" ] ( let - link = pkgs.writeShellScript "link" '' + storeDir = lib.escapeShellArg builtins.storeDir; + + legacyLink = pkgs.writeShellScript "link" '' ${config.lib.bash.initHomeManagerLib} newGenFiles="$1" @@ -220,12 +265,12 @@ in done ''; - cleanup = pkgs.writeShellScript "cleanup" '' + legacyCleanup = 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/*" + homeFilePattern="$(readlink -e ${storeDir})/*-home-manager-files/*" newGenFiles="$1" shift 1 @@ -256,38 +301,81 @@ in fi done ''; + + # If Putter is not enabled, then generate a fake state file to allow + # switching to Putter in the future. + putterCompatState = + let + putter = import ./lib/putter.nix { inherit lib; }; + manifest = putter.mkPutterCompatState { + sourceBaseDirectory = config.home-files; + targetBaseDirectory = config.home.homeDirectory; + fileEntries = cfg; + }; + in + pkgs.writeText "hm-putter-state.json" manifest; + + # 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. + legacyLinkGeneration = '' + 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 ${legacyLink} "$newGenFiles" {} + + + # Copy in the Putter compatible state file. This is to allow a later + # switchover to Putter. + run install -Dp -m600 $VERBOSE_ARG ${putterCompatState} ${putterStatePath} + } + + 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 ${legacyCleanup} "$newGenFiles" + } + + cleanOldGen + linkNewGen + ''; + + putterLinkGeneration = '' + ${lib.getExe pkgs.putter} apply $VERBOSE_ARG ''${DRY_RUN:+--dry-run} \ + --state-file "${putterStatePath}" \ + ${config.home.internal.filePutterConfig} + ''; 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 - '' + if config.home.fileActivator == "putter" then putterLinkGeneration else legacyLinkGeneration ); home.activation.checkFilesChanged = lib.hm.dag.entryBefore [ "linkGeneration" ] ( @@ -334,6 +422,18 @@ in '') (lib.filter (v: v.onChange != "") 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 = 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 68c572066..0f44ea7b3 100644 --- a/modules/home-environment.nix +++ b/modules/home-environment.nix @@ -912,6 +912,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..cc46c6dc3 --- /dev/null +++ b/modules/lib/putter.nix @@ -0,0 +1,82 @@ +# Contains some handy functions for generating Putter file manifests. + +{ lib }: + +let + + inherit (lib) + filter + hasPrefix + optionalAttrs + ; + +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 + # Create a Putter entry for the given file. + mkEntry = + f: + { + source = "${sourceBaseDirectory}/${f.target}"; + # source = "${f.source}"; + target = (if hasPrefix "/" f.target then "" else "${targetBaseDirectory}/") + f.target; + } + // optionalAttrs f.force { + collision.resolution = "force"; + } + // optionalAttrs f.recursive { + action.type = "recursive_symlink"; + }; + + putterJson = { + version = "1"; + state = putterStatePath; + files = map mkEntry (filter (f: f.enable) fileEntries); + }; + + putterJsonText = builtins.toJSON putterJson; + in + putterJsonText; + + # Create a putter state file to allow compatibility between legacy and putter + # managed files. + # + # Note, the interface of this function is not considered stable, it may change + # as the needs of Home Manager change. + mkPutterCompatState = + { + sourceBaseDirectory, + targetBaseDirectory, + fileEntries, + }: + let + mkEntry = f: { + name = (if hasPrefix "/" f.target then "" else "${targetBaseDirectory}/") + f.target; + value = { + source = "${sourceBaseDirectory}/${f.target}"; + modified = { + secs_since_epoch = 0; + nanos_since_epoch = 0; + }; + }; + }; + + stateJsonText = builtins.toJSON { + version = "1"; + files = lib.listToAttrs (map mkEntry (filter (f: f.enable) fileEntries)); + }; + in + stateJsonText; +}