home-manager: optionally use putter for file management

The Putter tool is a Rust implementation of the file management
component of Home Manager. It takes a JSON manifest that indicates how
files should be symlinked and when the manifest is applied, does its
best to ensure that the file system reflect the manifest.

Putter offers some additional features that Home Manager does not
expose today, such as file copying (potentially recursive) and support
for overriding files within a recursive tree (e.g., inside a symlinked
tree one could ensure that a specific file is copies with specific
permissions).

This is considered highly experimental at the moment and to use Putter
one must set a hidden option.
This commit is contained in:
Robert Helgesson
2023-12-04 20:09:16 +01:00
parent 61e2c96593
commit 709c2320fa
3 changed files with 228 additions and 45 deletions

View File

@@ -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 =

View File

@@ -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"

82
modules/lib/putter.nix Normal file
View File

@@ -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;
}