mirror of
https://github.com/nix-community/home-manager.git
synced 2025-11-08 11:36:05 +01:00
This adds a new backupCommand option to allow users to specify a custom command to run on existing files during activation, as an alternative to the existing backupFileExtension mechanism. Adds backupCommand option to NixOS and nix-darwin modules. Exports HOME_MANAGER_BACKUP_COMMAND environment variable when set. Updates file activation logic to use the custom backup command if provided, falling back to the existing backup extension logic. Updates collision checking and user-facing instructions to mention the new option. This enables advanced backup workflows, such as moving files to trash or archiving with custom tools, before managing them with Home Manager.
380 lines
13 KiB
Nix
380 lines
13 KiB
Nix
{
|
||
pkgs,
|
||
config,
|
||
lib,
|
||
...
|
||
}:
|
||
|
||
let
|
||
|
||
cfg = lib.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;
|
||
};
|
||
|
||
in
|
||
|
||
{
|
||
options = {
|
||
home.file = lib.mkOption {
|
||
description = "Attribute set of files to link into the user home.";
|
||
default = { };
|
||
type = fileType "home.file" "{env}`HOME`" homeDirectory;
|
||
};
|
||
|
||
home-files = lib.mkOption {
|
||
type = lib.types.package;
|
||
internal = true;
|
||
description = "Package to contain all home files";
|
||
};
|
||
};
|
||
|
||
config = {
|
||
assertions = [
|
||
(
|
||
let
|
||
dups = lib.attrNames (
|
||
lib.filterAttrs (n: v: v > 1) (
|
||
lib.foldAttrs (acc: v: acc + v) 0 (lib.mapAttrsToList (n: v: { ${v.target} = 1; }) cfg)
|
||
)
|
||
);
|
||
dupsStr = lib.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"; };
|
||
}'';
|
||
}
|
||
)
|
||
];
|
||
|
||
# Using this function it is possible to make `home.file` create a
|
||
# symlink to a path outside the Nix store. For example, a Home Manager
|
||
# configuration containing
|
||
#
|
||
# `home.file."foo".source = config.lib.file.mkOutOfStoreSymlink ./bar;`
|
||
#
|
||
# would upon activation create a symlink `~/foo` that points to the
|
||
# absolute path of the `bar` file relative the configuration file.
|
||
lib.file.mkOutOfStoreSymlink =
|
||
path:
|
||
let
|
||
pathStr = toString path;
|
||
name = lib.hm.strings.storeFileName (baseNameOf pathStr);
|
||
in
|
||
pkgs.runCommandLocal name { } ''ln -s ${lib.escapeShellArg pathStr} $out'';
|
||
|
||
# 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)
|
||
);
|
||
|
||
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.checkFilesChanged = lib.hm.dag.entryBefore [ "linkGeneration" ] (
|
||
let
|
||
homeDirArg = lib.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
|
||
''
|
||
+ lib.concatMapStrings (
|
||
v:
|
||
let
|
||
sourceArg = lib.escapeShellArg (sourceStorePath v);
|
||
targetArg = lib.escapeShellArg v.target;
|
||
in
|
||
''
|
||
_cmp ${sourceArg} ${homeDirArg}/${targetArg} \
|
||
&& changedFiles[${targetArg}]=0 \
|
||
|| changedFiles[${targetArg}]=1
|
||
''
|
||
) (lib.filter (v: v.onChange != "") (lib.attrValues cfg))
|
||
+ ''
|
||
unset -f _cmp
|
||
''
|
||
);
|
||
|
||
home.activation.onFilesChange = lib.hm.dag.entryAfter [ "linkGeneration" ] (
|
||
lib.concatMapStrings (v: ''
|
||
if (( ''${changedFiles[${lib.escapeShellArg v.target}]} == 1 )); then
|
||
if [[ -v DRY_RUN || -v VERBOSE ]]; then
|
||
echo "Running onChange hook for" ${lib.escapeShellArg v.target}
|
||
fi
|
||
if [[ ! -v DRY_RUN ]]; then
|
||
${v.onChange}
|
||
fi
|
||
fi
|
||
'') (lib.filter (v: v.onChange != "") (lib.attrValues cfg))
|
||
);
|
||
|
||
# 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"
|
||
local ignorelinks="$5"
|
||
|
||
# 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"
|
||
if [[ $ignorelinks ]]; then
|
||
lndir -silent -ignorelinks "$source" "$target"
|
||
else
|
||
lndir -silent "$source" "$target"
|
||
fi
|
||
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
|
||
}
|
||
''
|
||
+ lib.concatStrings (
|
||
lib.mapAttrsToList (n: v: ''
|
||
insertFile ${
|
||
lib.escapeShellArgs [
|
||
(sourceStorePath v)
|
||
v.target
|
||
(if v.executable == null then "inherit" else toString v.executable)
|
||
(toString v.recursive)
|
||
(toString v.ignorelinks)
|
||
]
|
||
}
|
||
'') cfg
|
||
)
|
||
);
|
||
};
|
||
}
|