mirror of
https://github.com/nix-community/home-manager
synced 2024-11-23 19:49:45 +01:00
7f976da068
When the target file exists but has the exact same content then we will now skip creation of the link from the source file. Fixes #1213
390 lines
14 KiB
Nix
390 lines
14 KiB
Nix
{ pkgs, config, lib, ... }:
|
||
|
||
with lib;
|
||
|
||
let
|
||
|
||
cfg = 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 = mkOption {
|
||
description = "Attribute set of files to link into the user home.";
|
||
default = {};
|
||
type = fileType "<envar>HOME</envar>" homeDirectory;
|
||
};
|
||
|
||
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"] (
|
||
let
|
||
# Paths that should be forcibly overwritten by Home Manager.
|
||
# Caveat emptor!
|
||
forcedPaths =
|
||
concatMapStringsSep " " (p: ''"$HOME"/${escapeShellArg p}'')
|
||
(mapAttrsToList (n: v: v.target)
|
||
(filterAttrs (n: v: v.force) cfg));
|
||
|
||
check = pkgs.writeText "check" ''
|
||
. ${./lib-bash/color-echo.sh}
|
||
|
||
# A symbolic link whose target path matches this pattern will be
|
||
# considered part of a Home Manager generation.
|
||
homeFilePattern="$(readlink -e ${escapeShellArg builtins.storeDir})/*-home-manager-files/*"
|
||
|
||
forcedPaths=(${forcedPaths})
|
||
|
||
newGenFiles="$1"
|
||
shift
|
||
for sourcePath in "$@" ; do
|
||
relativePath="''${sourcePath#$newGenFiles/}"
|
||
targetPath="$HOME/$relativePath"
|
||
|
||
forced=""
|
||
for forcedPath in "''${forcedPaths[@]}"; do
|
||
if [[ $targetPath == $forcedPath* ]]; then
|
||
forced="yeah"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -n $forced ]]; then
|
||
$VERBOSE_ECHO "Skipping collision check for $targetPath"
|
||
elif [[ -e "$targetPath" \
|
||
&& ! "$(readlink "$targetPath")" == $homeFilePattern ]] ; then
|
||
# The target file already exists and it isn't a symlink owned by Home Manager.
|
||
if cmp -s $sourcePath $targetPath; then
|
||
# First compare the files' content. If they're equal, we're fine.
|
||
warnEcho "Existing file '$targetPath' is in the way of '$sourcePath', will be skipped since they are the same"
|
||
elif [[ ! -L "$targetPath" && -n "$HOME_MANAGER_BACKUP_EXT" ]] ; then
|
||
# Next, try to move the file to a backup location if configured and possible
|
||
backup="$targetPath.$HOME_MANAGER_BACKUP_EXT"
|
||
if [[ -e "$backup" ]]; then
|
||
errorEcho "Existing file '$backup' would be clobbered by backing up '$targetPath'"
|
||
collision=1
|
||
else
|
||
warnEcho "Existing file '$targetPath' is in the way of '$sourcePath', will be moved to '$backup'"
|
||
fi
|
||
else
|
||
# Fail if nothing else works
|
||
errorEcho "Existing file '$targetPath' is in the way of '$sourcePath'"
|
||
collision=1
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [[ -v collision ]] ; then
|
||
errorEcho "Please move the above files and try again or use -b <ext> to move automatically."
|
||
exit 1
|
||
fi
|
||
'';
|
||
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. Switch over the Home Manager gcroot and current profile
|
||
# links.
|
||
#
|
||
# 3. 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 = hm.dag.entryAfter ["writeBoundary"] (
|
||
let
|
||
link = pkgs.writeShellScript "link" ''
|
||
newGenFiles="$1"
|
||
shift
|
||
for sourcePath in "$@" ; do
|
||
relativePath="''${sourcePath#$newGenFiles/}"
|
||
targetPath="$HOME/$relativePath"
|
||
if [[ -e "$targetPath" && ! -L "$targetPath" && -n "$HOME_MANAGER_BACKUP_EXT" ]] ; then
|
||
# The target exists, back it up
|
||
backup="$targetPath.$HOME_MANAGER_BACKUP_EXT"
|
||
$DRY_RUN_CMD mv $VERBOSE_ARG "$targetPath" "$backup" || errorEcho "Moving '$targetPath' failed!"
|
||
fi
|
||
|
||
if [[ -e "$targetPath" && ! -L "$targetPath" ]] && cmp -s $sourcePath $targetPath ; then
|
||
# The target exists but is identical – don't do anything.
|
||
$VERBOSE_ECHO "Skipping '$targetPath' as it is identical to '$sourcePath'"
|
||
else
|
||
# Place that symlink, --force
|
||
$DRY_RUN_CMD mkdir -p $VERBOSE_ARG "$(dirname "$targetPath")"
|
||
$DRY_RUN_CMD ln -nsf $VERBOSE_ARG "$sourcePath" "$targetPath"
|
||
fi
|
||
done
|
||
'';
|
||
|
||
cleanup = pkgs.writeShellScript "cleanup" ''
|
||
. ${./lib-bash/color-echo.sh}
|
||
|
||
# A symbolic link whose target path matches this pattern will be
|
||
# considered part of a Home Manager generation.
|
||
homeFilePattern="$(readlink -e ${escapeShellArg builtins.storeDir})/*-home-manager-files/*"
|
||
|
||
newGenFiles="$1"
|
||
shift 1
|
||
for relativePath in "$@" ; do
|
||
targetPath="$HOME/$relativePath"
|
||
if [[ -e "$newGenFiles/$relativePath" ]] ; then
|
||
$VERBOSE_ECHO "Checking $targetPath: exists"
|
||
elif [[ ! "$(readlink "$targetPath")" == $homeFilePattern ]] ; then
|
||
warnEcho "Path '$targetPath' does not link into a Home Manager generation. Skipping delete."
|
||
else
|
||
$VERBOSE_ECHO "Checking $targetPath: gone (deleting)"
|
||
$DRY_RUN_CMD 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.
|
||
$DRY_RUN_CMD rmdir $VERBOSE_ARG \
|
||
-p --ignore-fail-on-non-empty \
|
||
"$targetDir"
|
||
|
||
popd > /dev/null
|
||
fi
|
||
fi
|
||
done
|
||
'';
|
||
in
|
||
''
|
||
function linkNewGen() {
|
||
echo "Creating home file links in $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 ]] ; then
|
||
return
|
||
fi
|
||
|
||
echo "Cleaning up orphan links from $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
|
||
|
||
if [[ ! -v oldGenPath || "$oldGenPath" != "$newGenPath" ]] ; then
|
||
echo "Creating profile generation $newGenNum"
|
||
$DRY_RUN_CMD nix-env $VERBOSE_ARG --profile "$genProfilePath" --set "$newGenPath"
|
||
$DRY_RUN_CMD ln -Tsf $VERBOSE_ARG "$newGenPath" "$newGenGcPath"
|
||
else
|
||
echo "No change so reusing latest profile generation $oldGenNum"
|
||
fi
|
||
|
||
linkNewGen
|
||
''
|
||
);
|
||
|
||
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}]} -eq 1 ]]; then
|
||
${v.onChange}
|
||
fi
|
||
'') (filter (v: v.onChange != "") (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"
|
||
|
||
# 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
|
||
));
|
||
};
|
||
}
|