diff --git a/modules/files.nix b/modules/files.nix index 407f4a206616..17a78c7ba556 100644 --- a/modules/files.nix +++ b/modules/files.nix @@ -191,7 +191,80 @@ in newGenFiles="$1" shift + + # Classify every target using bash builtins only: even one forked + # process per file costs several milliseconds on some platforms + # (notably darwin), which dominates activation time when a profile + # carries hundreds of links. Targets occupied by a regular file or + # directory keep the original per-file handling (backup and + # identical-content skip) on the slow path below. + declare -a symlinkTargets=() symlinkSources=() + declare -a linkSources=() linkDirs=() + declare -a slowSources=() for sourcePath in "$@" ; do + relativePath="''${sourcePath#$newGenFiles/}" + targetPath="$HOME/$relativePath" + if [[ -L "''${targetPath%/*}" ]] ; then + # The parent directory is itself a symlink (e.g. a stale + # whole-directory link from an older layout). The batched + # `ln -n -t` below would refuse it ("Not a directory"), while + # the original per-file `ln -T` traverses it; keep upstream + # behavior on the slow path. + slowSources+=("$sourcePath") + elif [[ -L "$targetPath" ]] ; then + symlinkTargets+=("$targetPath") + symlinkSources+=("$sourcePath") + elif [[ -e "$targetPath" ]] ; then + slowSources+=("$sourcePath") + else + linkSources+=("$sourcePath") + linkDirs+=("''${targetPath%/*}") + fi + done + + # Resolve all existing symlinks with a single readlink call and + # relink only those not already pointing at the new generation. + if [[ ''${#symlinkTargets[@]} -gt 0 ]] ; then + i=0 + while IFS= read -r -d "" currentSource ; do + if [[ "$currentSource" != "''${symlinkSources[i]}" ]] ; then + linkSources+=("''${symlinkSources[i]}") + linkDirs+=("''${symlinkTargets[i]%/*}") + fi + i=$(( i + 1 )) + done < <(readlink -z -- "''${symlinkTargets[@]}") + fi + + # Create all missing parent directories in one mkdir call. + declare -A missingDirs=() + for targetDir in "''${linkDirs[@]}" ; do + [[ -d "$targetDir" ]] || missingDirs[$targetDir]=1 + done + if [[ ''${#missingDirs[@]} -gt 0 ]] ; then + run mkdir -p $VERBOSE_ARG -- "''${!missingDirs[@]}" || exit 1 + fi + + # Group the pending links by parent directory, one ln call per + # directory. The link name always equals the source basename, and + # -f -n together replace a stale symlink even when it points at a + # directory (the case -T guarded against in the per-file version; + # a regular directory in the way takes the slow path instead and + # fails there just like it always did). + declare -A dirBatches=() + for i in "''${!linkSources[@]}" ; do + dirBatches[''${linkDirs[i]}]+="$i " + done + for targetDir in "''${!dirBatches[@]}" ; do + batch=() + for i in ''${dirBatches[$targetDir]} ; do + batch+=("''${linkSources[i]}") + done + run ln -sfn $VERBOSE_ARG -t "$targetDir" -- "''${batch[@]}" || exit 1 + done + + # Slow path: the target exists and is not a symlink. This is the + # original per-file logic, kept verbatim for the rare collisions. + for sourcePath in "''${slowSources[@]}" ; do relativePath="''${sourcePath#$newGenFiles/}" targetPath="$HOME/$relativePath" if [[ -e "$targetPath" && ! -L "$targetPath" ]] ; then @@ -209,7 +282,7 @@ in fi if [[ -e "$targetPath" && ! -L "$targetPath" ]] && cmp -s "$sourcePath" "$targetPath" ; then - # The target exists but is identical – don't do anything. + # The target exists but is identical - don't do anything. verboseEcho "Skipping '$targetPath' as it is identical to '$sourcePath'" else # Place that symlink, --force diff --git a/modules/files/check-link-targets.sh b/modules/files/check-link-targets.sh index ce9e5df3d53a..718d706cdd0c 100644 --- a/modules/files/check-link-targets.sh +++ b/modules/files/check-link-targets.sh @@ -10,6 +10,39 @@ forcedPaths=(@forcedPaths@) newGenFiles="$1" shift + +# Check a target that already exists and is not a symlink owned by Home +# Manager. +function checkCollision() { + local sourcePath="$1" + local targetPath="$2" + + 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_COMMAND" ]] ; then + # Next, try to run the custom backup command. Assume this always succeeds. + verboseEcho "Existing file '$targetPath' exists and differs from '$sourcePath'. '$HOME_MANAGER_BACKUP_COMMAND' will be used to backup the file." + 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" && -z "$HOME_MANAGER_BACKUP_OVERWRITE" ]] ; then + collisionErrors+=("Existing file '$backup' would be clobbered by backing up '$targetPath'") + elif [[ -e "$backup" && -n "$HOME_MANAGER_BACKUP_OVERWRITE" ]] ; then + warnEcho "Existing file '$targetPath' is in the way of '$sourcePath' and '$backup' exists. Backup will be clobbered due to HOME_MANAGER_BACKUP_OVERWRITE=1" + else + warnEcho "Existing file '$targetPath' is in the way of '$sourcePath', will be moved to '$backup'" + fi + else + # Fail if nothing else works + collisionErrors+=("Existing file '$targetPath' would be clobbered") + fi +} + +# Classify every target using bash builtins only: even one forked process +# per file costs several milliseconds on some platforms (notably darwin), +# which dominates activation time when a profile carries hundreds of links. +declare -a linkTargets=() linkSources=() for sourcePath in "$@" ; do relativePath="${sourcePath#$newGenFiles/}" targetPath="$HOME/$relativePath" @@ -24,32 +57,26 @@ for sourcePath in "$@" ; do if [[ -n $forced ]]; then verboseEcho "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_COMMAND" ]] ; then - # Next, try to run the custom backup command. Assume this always succeeds. - verboseEcho "Existing file '$targetPath' exists and differs from '$sourcePath'. '$HOME_MANAGER_BACKUP_COMMAND' will be used to backup the file." - 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" && -z "$HOME_MANAGER_BACKUP_OVERWRITE" ]] ; then - collisionErrors+=("Existing file '$backup' would be clobbered by backing up '$targetPath'") - elif [[ -e "$backup" && -n "$HOME_MANAGER_BACKUP_OVERWRITE" ]] ; then - warnEcho "Existing file '$targetPath' is in the way of '$sourcePath' and '$backup' exists. Backup will be clobbered due to HOME_MANAGER_BACKUP_OVERWRITE=1" - else - warnEcho "Existing file '$targetPath' is in the way of '$sourcePath', will be moved to '$backup'" - fi - else - # Fail if nothing else works - collisionErrors+=("Existing file '$targetPath' would be clobbered") - fi + elif [[ -L "$targetPath" && -e "$targetPath" ]] ; then + linkTargets+=("$targetPath") + linkSources+=("$sourcePath") + elif [[ -e "$targetPath" ]] ; then + checkCollision "$sourcePath" "$targetPath" fi done +# Resolve all existing symlinks with a single readlink call. A link into a +# Home Manager generation is ours; anything else is a collision candidate. +if [[ ${#linkTargets[@]} -gt 0 ]] ; then + i=0 + while IFS= read -r -d "" currentSource ; do + if [[ ! "$currentSource" == $homeFilePattern ]] ; then + checkCollision "${linkSources[i]}" "${linkTargets[i]}" + fi + i=$(( i + 1 )) + done < <(readlink -z -- "${linkTargets[@]}") +fi + if [[ ${#collisionErrors[@]} -gt 0 ]] ; then errorEcho "Please do one of the following: - In standalone mode, use 'home-manager switch -b backup' to back up"\