{ 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 "HOME" 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 if [[ ! -L "$targetPath" && -n "$HOME_MANAGER_BACKUP_EXT" ]] ; then 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 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 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" '' rootPath="$1" newGenFiles="$2" shift 2 for sourcePath in "$@" ; do relativePath="''${sourcePath#$newGenFiles/}" targetPath="$rootPath/$relativePath" if [[ -e "$targetPath" && ! -L "$targetPath" && -n "$HOME_MANAGER_BACKUP_EXT" ]] ; then backup="$targetPath.$HOME_MANAGER_BACKUP_EXT" $DRY_RUN_CMD mv $VERBOSE_ARG "$targetPath" "$backup" || errorEcho "Moving '$targetPath' failed!" fi $DRY_RUN_CMD mkdir -p $VERBOSE_ARG "$(dirname "$targetPath")" $DRY_RUN_CMD ln -nsf $VERBOSE_ARG "$sourcePath" "$targetPath" 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/*" rootPath="$1" newGenFiles="$2" shift 2 for relativePath in "$@" ; do targetPath="$rootPath/$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 # TODO # 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" local rootPath= if [[ $newGenLayoutVersion -eq 0 ]] ; then rootPath="$HOME" fi local newGenFiles newGenFiles="$(readlink -e "$newGenPath/home-files")" find "$newGenFiles" \( -type f -or -type l \) \ -exec bash ${link} "$rootPath" "$newGenFiles" {} + } function cleanOldGen() { if [[ ! -v oldGenPath ]] ; then return fi echo "Cleaning up orphan links" local newGenFiles oldGenFiles newGenFiles="$(readlink -e "$newGenPath/home-files")" oldGenFiles="$(readlink -e "$oldGenPath/home-files")" # When transitioning from layout 0 to 1 we need to prefix all old # paths with the home directory. Conversely, if we ever go from # layout 1 to 0 we need to "subtract" the home directory from the # old generation path, this is done by appending an "antiprefix" to # the layout 1 paths. local prefix= antiprefix= if [[ $oldGenLayoutVersion -eq 0 && $newGenLayoutVersion -ge 1 ]] ; then prefix="''${HOME#/}/" elif [[ $oldGenLayoutVersion -ge 1 && $newGenLayoutVersion -eq 0 ]] ; then antiprefix="/$HOME" fi local rootPath= if [[ $newGenLayoutVersion -eq 0 ]] ; then rootPath="$HOME" fi $VERBOSE_ECHO "Orphan link cleanup uses prefix=$prefix antiprefix=$antiprefix rootPath=$rootPath" # 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$antiprefix" '(' -type f -or -type l ')' -printf "$prefix%P\0" \ | xargs -0 bash ${cleanup} "$rootPath" "$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 )); }; }