home-manager/modules/files.nix
Robert Helgesson 8237e3f60f
Experiments
This is a completely experimental proof-of-concept type setup of

- using Putter for file management,

- removing profile management from activation script, and

- add `home-manager test` command, which activates the configuration
  but does not create a new profile generation.
2024-01-24 22:24:32 +01:00

228 lines
6.9 KiB
Nix

{ pkgs, config, lib, putter, ... }:
with lib;
let
cfg = filterAttrs (n: f: f.enable) config.home.file;
homeDirectory = config.home.homeDirectory;
fileType = (import lib/file-type.nix {
inherit homeDirectory lib pkgs;
}).fileType;
sourceStorePath = file:
let
sourcePath = toString file.source;
sourceName = config.lib.strings.storeFileName (baseNameOf sourcePath);
in
if builtins.hasContext sourcePath
then file.source
else builtins.path { path = file.source; name = sourceName; };
putterStatePath = "${config.xdg.stateHome}/home-manager/putter-state.json";
in
{
options = {
home.file = mkOption {
description = "Attribute set of files to link into the user home.";
default = {};
type = fileType "home.file" "{env}`HOME`" homeDirectory;
};
home.internal = {
filePutterConfig = mkOption {
type = types.package;
internal = true;
description = "Putter configuration.";
};
};
home-files = mkOption {
type = types.package;
internal = true;
description = "Package to contain all home files";
};
};
config = {
assertions = [(
let
dups =
attrNames
(filterAttrs (n: v: v > 1)
(foldAttrs (acc: v: acc + v) 0
(mapAttrsToList (n: v: { ${v.target} = 1; }) cfg)));
dupsStr = concatStringsSep ", " dups;
in {
assertion = dups == [];
message = ''
Conflicting managed target files: ${dupsStr}
This may happen, for example, if you have a configuration similar to
home.file = {
conflict1 = { source = ./foo.nix; target = "baz"; };
conflict2 = { source = ./bar.nix; target = "baz"; };
}'';
})
];
lib.file.mkOutOfStoreSymlink = path:
let
pathStr = toString path;
name = hm.strings.storeFileName (baseNameOf pathStr);
in
pkgs.runCommandLocal name {} ''ln -s ${escapeShellArg pathStr} $out'';
# This verifies that the links we are about to create will not
# overwrite an existing file.
home.activation.checkLinkTargets = hm.dag.entryBefore ["writeBoundary"] ''
${getExe putter} check -v \
--state-file "${putterStatePath}" \
${config.home.internal.filePutterConfig}
'';
home.activation.linkGeneration = hm.dag.entryAfter ["writeBoundary"] ''
${getExe putter} apply $VERBOSE_ARG -v ''${DRY_RUN:+--dry-run} \
--state-file "${putterStatePath}" \
${config.home.internal.filePutterConfig}
'';
home.activation.checkFilesChanged = hm.dag.entryBefore ["linkGeneration"] (
let
homeDirArg = escapeShellArg homeDirectory;
in ''
function _cmp() {
if [[ -d $1 && -d $2 ]]; then
diff -rq "$1" "$2" &> /dev/null
else
cmp --quiet "$1" "$2"
fi
}
declare -A changedFiles
'' + concatMapStrings (v:
let
sourceArg = escapeShellArg (sourceStorePath v);
targetArg = escapeShellArg v.target;
in ''
_cmp ${sourceArg} ${homeDirArg}/${targetArg} \
&& changedFiles[${targetArg}]=0 \
|| changedFiles[${targetArg}]=1
'') (filter (v: v.onChange != "") (attrValues cfg))
+ ''
unset -f _cmp
''
);
home.activation.onFilesChange = hm.dag.entryAfter ["linkGeneration"] (
concatMapStrings (v: ''
if (( ''${changedFiles[${escapeShellArg v.target}]} == 1 )); then
if [[ -v DRY_RUN || -v VERBOSE ]]; then
echo "Running onChange hook for" ${escapeShellArg v.target}
fi
if [[ ! -v DRY_RUN ]]; then
${v.onChange}
fi
fi
'') (filter (v: v.onChange != "") (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 = pkgs.runCommandLocal
"home-manager-files"
{
nativeBuildInputs = [ pkgs.xorg.lndir ];
}
(''
mkdir -p $out
# Needed in case /nix is a symbolic link.
realOut="$(realpath -m "$out")"
function insertFile() {
local source="$1"
local relTarget="$2"
local executable="$3"
local recursive="$4"
# If the target already exists then we have a collision. Note, this
# should not happen due to the assertion found in the 'files' module.
# We therefore simply log the conflict and otherwise ignore it, mainly
# to make the `files-target-config` test work as expected.
if [[ -e "$realOut/$relTarget" ]]; then
echo "File conflict for file '$relTarget'" >&2
return
fi
# Figure out the real absolute path to the target.
local target
target="$(realpath -m "$realOut/$relTarget")"
# Target path must be within $HOME.
if [[ ! $target == $realOut* ]] ; then
echo "Error installing file '$relTarget' outside \$HOME" >&2
exit 1
fi
mkdir -p "$(dirname "$target")"
if [[ -d $source ]]; then
if [[ $recursive ]]; then
mkdir -p "$target"
lndir -silent "$source" "$target"
else
ln -s "$source" "$target"
fi
else
[[ -x $source ]] && isExecutable=1 || isExecutable=""
# Link the file into the home file directory if possible,
# i.e., if the executable bit of the source is the same we
# expect for the target. Otherwise, we copy the file and
# set the executable bit to the expected value.
if [[ $executable == inherit || $isExecutable == $executable ]]; then
ln -s "$source" "$target"
else
cp "$source" "$target"
if [[ $executable == inherit ]]; then
# Don't change file mode if it should match the source.
:
elif [[ $executable ]]; then
chmod +x "$target"
else
chmod -x "$target"
fi
fi
fi
}
'' + concatStrings (
mapAttrsToList (n: v: ''
insertFile ${
escapeShellArgs [
(sourceStorePath v)
v.target
(if v.executable == null
then "inherit"
else toString v.executable)
(toString v.recursive)
]}
'') cfg
));
};
}