mirror of
https://github.com/nix-community/home-manager.git
synced 2026-06-05 21:02:51 +00:00
files: handle overlapping file targets
The current behavior of `home.file` is inconsistent when handling
recursive file with another, overlapping, non-recursive file.
Specifically, consider a configuration
```nix
home.file = {
"foo" = { source = ./foo; recursive = true; };
"foo/bar".text = "some other file";
};
```
where `./foo` is a directory containing a file `bar`. Switching to
this configuration will result in the `./foo` directory being
recursively symlinked while the "foo/bar" entry is ignored. Note,
building the home files derivation does log
> File conflict for file 'foo/bar'
On the other hand, the supposedly equivalent configuration
```nix
home.file = {
"foo" = { source = ./foo; recursive = true; };
abc = { target = "foo/bar"; text = "some other file"; };
};
```
results in the `./foo` directory not being recursively symlinked,
i.e., only the file `foo/bar` shows up in the built configuration.
This time the home files build log contains
> File conflict for file 'foo'
This commit makes the behavior more consistent in that we always
handle the file in a unified manner. The conflict resolution is
offered in three flavors, "ignore", "error", and "override" indicating
whether the recursively symlinked file wins, the entire build errors
out, and the regularly symlinked file wins.
The current default is "ignore" since it is the resolution that most
closely matches the current behavior, at least when the file attribute
name is used as the target path.
The other two resolutions can be chosen by setting the
`home.fileOverlapResolution` option, which is set as invisible due to
its experimental nature.
This commit is contained in:
committed by
Austin Horstman
parent
34cb41efe4
commit
527e47b78f
@@ -7,7 +7,30 @@
|
||||
|
||||
let
|
||||
|
||||
cfg = lib.filterAttrs (n: f: f.enable) config.home.file;
|
||||
cfg =
|
||||
let
|
||||
allFiles = lib.attrValues config.home.file;
|
||||
enabledFiles = lib.filter (f: f.enable) allFiles;
|
||||
|
||||
# We sort to ascending target path length. This ensures that a directory
|
||||
# end up earlier in the list so that we can more easily detect when
|
||||
# another file is placed inside this directory.
|
||||
#
|
||||
# Specifically, we want to detect two cases:
|
||||
#
|
||||
# - A directory is symlinked to the target, attempting to place a file
|
||||
# inside this directory is an error since it would entail modifying the
|
||||
# source directory.
|
||||
#
|
||||
# - A directory is recursively symlinked to the target, attempting to
|
||||
# place a file inside this directory is allowed. If the placed file
|
||||
# overlaps with a path from the recursively symlinked directory it will
|
||||
# override the one from the directory.
|
||||
sortedFiles = lib.lists.sortOn (f: lib.stringLength f.target) enabledFiles;
|
||||
in
|
||||
sortedFiles;
|
||||
|
||||
fileOverlapResolution = config.home.fileOverlapResolution;
|
||||
|
||||
homeDirectory = config.home.homeDirectory;
|
||||
|
||||
@@ -40,6 +63,29 @@ in
|
||||
type = fileType "home.file" "{env}`HOME`" homeDirectory;
|
||||
};
|
||||
|
||||
home.fileOverlapResolution = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"ignore"
|
||||
"error"
|
||||
"override"
|
||||
];
|
||||
default = "ignore";
|
||||
visible = false;
|
||||
description = ''
|
||||
Determines how to handle a conflict between a file occurring due to
|
||||
recursive symlinking and regular symlinking.
|
||||
|
||||
The default, "ignore", is the one most closely matching the legacy
|
||||
behavior. It keeps the recursively linked file and ignores the regularly
|
||||
symlinked one. The "error" alternative causes the `file-files` build to
|
||||
error out. The "override" alternative replaces the recursively linked
|
||||
file by the regularly linked one.
|
||||
|
||||
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;
|
||||
@@ -53,7 +99,7 @@ in
|
||||
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)
|
||||
lib.foldAttrs (acc: v: acc + v) 0 (map (v: { ${v.target} = 1; }) cfg)
|
||||
)
|
||||
);
|
||||
dupsStr = lib.concatStringsSep ", " dups;
|
||||
@@ -96,7 +142,7 @@ in
|
||||
# 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)
|
||||
map (v: v.target) (lib.filter (v: v.force) cfg)
|
||||
);
|
||||
|
||||
storeDir = lib.escapeShellArg builtins.storeDir;
|
||||
@@ -267,7 +313,7 @@ in
|
||||
&& changedFiles[${targetArg}]=0 \
|
||||
|| changedFiles[${targetArg}]=1
|
||||
''
|
||||
) (lib.filter (v: v.onChange != "") (lib.attrValues cfg))
|
||||
) (lib.filter (v: v.onChange != "") cfg)
|
||||
+ ''
|
||||
unset -f _cmp
|
||||
''
|
||||
@@ -283,7 +329,7 @@ in
|
||||
${v.onChange}
|
||||
fi
|
||||
fi
|
||||
'') (lib.filter (v: v.onChange != "") (lib.attrValues cfg))
|
||||
'') (lib.filter (v: v.onChange != "") cfg)
|
||||
);
|
||||
|
||||
# Symlink directories and files that have the right execute bit.
|
||||
@@ -300,6 +346,12 @@ in
|
||||
# Needed in case /nix is a symbolic link.
|
||||
realOut="$(realpath -m "$out")"
|
||||
|
||||
# An associative array of previously handled target paths. This is
|
||||
# the path handled for the declared file in home.file. That is, if a
|
||||
# file has been specified as recursive, then this array will only
|
||||
# contain the recursion root, not the visited files.
|
||||
declare -A seenTargets
|
||||
|
||||
function insertFile() {
|
||||
local source="$1"
|
||||
local relTarget="$2"
|
||||
@@ -307,15 +359,35 @@ in
|
||||
local recursive="$4"
|
||||
local ignorelinks="$5"
|
||||
|
||||
# If the target already exists then we have a collision. Note, this
|
||||
# If the target has already been seen 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
|
||||
# We therefore simply log the conflict and otherwise ignore it,
|
||||
# mainly to make the `files-target-conflict` test work as expected.
|
||||
if [[ ''${seenTargets["$relTarget"]} ]]; then
|
||||
echo "File conflict for file '$relTarget'" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# If the path already exists as a non-directory, then we are
|
||||
# conflicting with a file from a recursively linked directory. Log
|
||||
# this fact and error out the build.
|
||||
if [[ -e "$realOut/$relTarget" && ! -d "$realOut/$relTarget" ]]; then
|
||||
echo "$relTarget conflicts with recursively symlinked file" >&2
|
||||
${
|
||||
if fileOverlapResolution == "ignore" then
|
||||
"return"
|
||||
else if fileOverlapResolution == "error" then
|
||||
"exit 1"
|
||||
else if fileOverlapResolution == "override" then
|
||||
''rm "$realOut/$relTarget"''
|
||||
else
|
||||
abort ''Unknown file resolution overlap "${fileOverlapResolution}"''
|
||||
}
|
||||
fi
|
||||
|
||||
# Record that we have seen this target file.
|
||||
seenTargets["$relTarget"]=1
|
||||
|
||||
# Figure out the real absolute path to the target.
|
||||
local target
|
||||
target="$(realpath -m "$realOut/$relTarget")"
|
||||
@@ -363,7 +435,7 @@ in
|
||||
}
|
||||
''
|
||||
+ lib.concatStrings (
|
||||
lib.mapAttrsToList (n: v: ''
|
||||
map (v: ''
|
||||
insertFile ${
|
||||
lib.escapeShellArgs [
|
||||
(sourceStorePath v)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
files-executable = ./executable.nix;
|
||||
files-hidden-source = ./hidden-source.nix;
|
||||
files-out-of-store-symlink = ./out-of-store-symlink.nix;
|
||||
files-recursive-overlap-ignore = ./recursive-overlap-ignore.nix;
|
||||
files-recursive-overlap-override = ./recursive-overlap-override.nix;
|
||||
files-source-with-spaces = ./source-with-spaces.nix;
|
||||
files-target-conflict = ./target-conflict.nix;
|
||||
files-target-with-shellvar = ./target-with-shellvar.nix;
|
||||
|
||||
35
tests/modules/files/recursive-overlap-ignore.nix
Normal file
35
tests/modules/files/recursive-overlap-ignore.nix
Normal file
@@ -0,0 +1,35 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
home.fileOverlapResolution = "ignore";
|
||||
home.file = {
|
||||
"foo" = {
|
||||
source = pkgs.runCommand "foo-recursive" { } ''
|
||||
mkdir $out
|
||||
echo -n foo > $out/foo
|
||||
echo -n bar > $out/bar
|
||||
echo -n baz > $out/baz
|
||||
'';
|
||||
recursive = true;
|
||||
};
|
||||
"foo/bar".text = "bar ignore";
|
||||
"blah" = {
|
||||
text = "baz ignore";
|
||||
target = "foo/baz";
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
assertFileExists 'home-files/foo/foo';
|
||||
assertFileContent 'home-files/foo/foo' \
|
||||
${builtins.toFile "foo-expected" "foo"}
|
||||
|
||||
assertFileExists 'home-files/foo/bar';
|
||||
assertFileContent 'home-files/foo/bar' \
|
||||
${builtins.toFile "bar-expected" "bar"}
|
||||
|
||||
assertFileExists 'home-files/foo/baz';
|
||||
assertFileContent 'home-files/foo/baz' \
|
||||
${builtins.toFile "baz-expected" "baz"}
|
||||
'';
|
||||
}
|
||||
35
tests/modules/files/recursive-overlap-override.nix
Normal file
35
tests/modules/files/recursive-overlap-override.nix
Normal file
@@ -0,0 +1,35 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
home.fileOverlapResolution = "override";
|
||||
home.file = {
|
||||
"foo" = {
|
||||
source = pkgs.runCommand "foo-recursive" { } ''
|
||||
mkdir $out
|
||||
echo -n foo > $out/foo
|
||||
echo -n bar > $out/bar
|
||||
echo -n baz > $out/baz
|
||||
'';
|
||||
recursive = true;
|
||||
};
|
||||
"foo/bar".text = "bar override";
|
||||
"blah" = {
|
||||
text = "baz override";
|
||||
target = "foo/baz";
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
assertFileExists 'home-files/foo/foo';
|
||||
assertFileContent 'home-files/foo/foo' \
|
||||
${builtins.toFile "foo-expected" "foo"}
|
||||
|
||||
assertFileExists 'home-files/foo/bar';
|
||||
assertFileContent 'home-files/foo/bar' \
|
||||
${builtins.toFile "bar-expected" "bar override"}
|
||||
|
||||
assertFileExists 'home-files/foo/baz';
|
||||
assertFileContent 'home-files/foo/baz' \
|
||||
${builtins.toFile "baz-expected" "baz override"}
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user