diff --git a/Makefile.am b/Makefile.am index acb217c3..728c53d6 100644 --- a/Makefile.am +++ b/Makefile.am @@ -51,6 +51,7 @@ TESTS = \ test/rcdn-hooks-failure.t \ test/rcdn-hooks-run-in-situ.t \ test/rcdn-hooks-run-in-order.t \ + test/rcdn-dangling-symlinks.t \ test/rcup-hooks.t \ test/rcup-hooks-failure.t \ test/rcup-hooks-run-in-situ.t \ diff --git a/NEWS.md.in b/NEWS.md.in index a6e6e1d0..c542b58d 100644 --- a/NEWS.md.in +++ b/NEWS.md.in @@ -1,5 +1,7 @@ rcm (@PACKAGE_VERSION@) unstable; urgency=low + * Feature: Remove dangling managed symlinks with `rcdn -D`, respecting + exclusions and tag/host selection (Alexander Goldstein). * Feature: rcup/rcdn hooks can bail early (Patrick Brisbin) * Documentation improvement (Teo Ljungberg) diff --git a/bin/lsrc.in b/bin/lsrc.in index 5ecdc09b..a9239e4b 100755 --- a/bin/lsrc.in +++ b/bin/lsrc.in @@ -159,81 +159,6 @@ is_metafile() { [ "x$host_portion" = 'xhost-' -o "x$tag_portion" = 'xtag-' -o "x$1" = "xhooks" ] } -dotfiles_dir_excludes() { - local dotfiles_dir="$1" - local excludes="$2" - - $DEBUG "dotfiles_dir_excludes $dotfiles_dir" - $DEBUG " with excludes: $excludes" - - set -o noglob - for exclude in $excludes; do - if echo "$exclude" | grep ':' >/dev/null; then - dotfiles_dir_pat="$(echo "$exclude" | sed 's/:.*//')" - file_glob="$(echo "$exclude" | sed 's/.*://')" - - if [ "x$dotfiles_dir_pat" != "x*" ] && is_relative "$dotfiles_dir_pat"; then - dotfiles_dir_pat="$PWD/$dotfiles_dir_pat" - fi - - if [ "x$dotfiles_dir_pat" = "x*" -o "x$dotfiles_dir_pat" = "x$dotfiles_dir" ]; then - echo "$file_glob" - fi - else - echo "$exclude" - fi - done - set +o noglob -} - -is_excluded() { - local file="$1" - local exclude_file_globs="$2" - local include_file_globs="$3" - local base_file="$(basename "$file")" - - $DEBUG "is_excluded $file $exclude_file_globs $include_file_globs" - - for exclude_file_glob in $exclude_file_globs; do - $DEBUG "file: $file" - $DEBUG "exclude_file_glob: $exclude_file_glob" - - is_single_excluded "$file" "$exclude_file_glob" "$include_file_globs" - ret=$? - if [ $ret -eq 0 -o $ret -eq 1 ]; then - return $ret - fi - - is_single_excluded "$base_file" "$exclude_file_glob" "$include_file_globs" - ret=$? - if [ $ret -eq 0 -o $ret -eq 1 ]; then - return $ret - fi - done - - return 1 -} - -is_single_excluded() { - local file="$1" - local exclude_file_glob="$2" - local include_file_globs="$3" - - case "$file" in - $exclude_file_glob) - for include_file_glob in $include_file_globs; do - case "$file" in - $include_file_glob) return 1;; - esac - done - - return 0 - ;; - esac - - return 2 -} - show_help() { local exit_code=${1:-0} diff --git a/bin/rcdn.in b/bin/rcdn.in index ed4ccfda..405dee3c 100755 --- a/bin/rcdn.in +++ b/bin/rcdn.in @@ -20,10 +20,156 @@ remove_link() { fi } +is_selected_file() { + local dest="$1" + local selected_file + local IFS=' +' + local rc_file="$(de_dot "$dest")" + + if [ -z "$FILES" ]; then + return 0 + fi + + for selected_file in $FILES; do + if [ "x$selected_file" = "x$rc_file" ]; then + return 0 + fi + done + + return 1 +} + +is_managed_target() { + local target="$1" + local dotfiles_dir + local source_file + local tag + local selected_tag + local exclude_file_globs + local include_file_globs + local selection_path + + case "$target" in + /*) ;; + *) return 1 ;; + esac + + for dotfiles_dir in $RESOLVED_DOTFILES_DIRS; do + case "$target" in + "$dotfiles_dir"/*) ;; + *) continue ;; + esac + + source_file="${target#"$dotfiles_dir"/}" + # Preserve ambiguous paths rather than treating them as managed sources. + case "$source_file" in + /*|*//*|.|./*|*/./*|*/.|..|../*|*/../*|*/..) continue ;; + esac + case "$source_file" in + host-"$HOSTNAME"/*) source_file="${source_file#*/}" ;; + host-*|hooks|hooks/*) continue ;; + tag-*) + selected_tag=0 + for tag in $TAGS; do + case "$source_file" in + tag-"$tag"/*) selected_tag=1; break ;; + esac + done + [ "$selected_tag" -eq 1 ] || continue + source_file="${source_file#*/}" + ;; + esac + + exclude_file_globs="$(dotfiles_dir_excludes "$dotfiles_dir" "$EXCLUDES")" + include_file_globs="$(dotfiles_dir_excludes "$dotfiles_dir" "$INCLUDES")" + # lsrc skips whole excluded directories before visiting their children. + while [ "x$source_file" != x. ]; do + case "$source_file" in + */*) selection_path="$source_file" ;; + *) selection_path="./$source_file" ;; + esac + if [ -n "$FILES" ]; then + selection_path="./$source_file" + fi + if (set -f; is_excluded "$selection_path" "$exclude_file_globs" "$include_file_globs"); then + break + fi + source_file="$(dirname "$source_file")" + done + [ "x$source_file" = x. ] && return 0 + done + + return 1 +} + +is_dotfiles_path() { + local file="$1" + local dotfiles_dir + + for dotfiles_dir in $RESOLVED_DOTFILES_DIRS; do + if [ "x$file" = "x$dotfiles_dir" ]; then + return 0 + fi + + case "$file" in + "$dotfiles_dir"/*) return 0 ;; + esac + done + + return 1 +} + +resolve_dotfiles_dirs() { + local relative_root_dir="$PWD" + local dotfiles_dir + local selected_dirs="${SELECTED_DOTFILES_DIRS:-$DOTFILES_DIRS}" + + for dotfiles_dir in $selected_dirs; do + cd -- "$relative_root_dir" + dotfiles_dir="$(eval echo "$dotfiles_dir")" + + if is_relative "$dotfiles_dir"; then + dotfiles_dir="$PWD/$dotfiles_dir" + fi + + RESOLVED_DOTFILES_DIRS="$(append_variable "$RESOLVED_DOTFILES_DIRS" "$dotfiles_dir")" + done + + cd -- "$relative_root_dir" +} + +remove_dangling_links() { + local dangling_link + local target + + resolve_dotfiles_dirs + $DEBUG "resolved dotfiles dirs: $RESOLVED_DOTFILES_DIRS" + + find "$DEST_DIR" -type l ! -exec test -e {} \; -print | + while IFS= read -r dangling_link; do + target="$(readlink "$dangling_link")" + + if is_dotfiles_path "$dangling_link"; then + continue + fi + + if ! is_managed_target "$target"; then + continue + fi + + if ! is_selected_file "$dangling_link"; then + continue + fi + + remove_link "$dangling_link" "$dangling_link" + done +} + show_help() { local exit_code=${1:-0} - $PRINT "Usage: rcdn [-hqVv] [-B HOSTNAME] [-d DOT_DIR] [-I EXCL_PAT] [-S EXCL_PAT] [-s EXCL_PAT] [-t TAG] [-U EXCL_PAT] [-u EXCL_PAT] [-x EXCL_PAT]" + $PRINT "Usage: rcdn [-DhqVv] [-B HOSTNAME] [-d DOT_DIR] [-I EXCL_PAT] [-S EXCL_PAT] [-s EXCL_PAT] [-t TAG] [-U EXCL_PAT] [-u EXCL_PAT] [-x EXCL_PAT]" $PRINT "see rcdn(1) and rcm(7) for more details" exit $exit_code @@ -43,9 +189,11 @@ handle_command_line() { local undotted= local never_undotted= local hostname= + local remove_dangling=0 - while getopts :VqvhIKk:x:S:s:U:u:t:d:B: opt; do + while getopts :DVqvhI:Kk:x:S:s:U:u:t:d:B: opt; do case "$opt" in + D) remove_dangling=1 ;; h) show_help ;; B) hostname="$OPTARG" ;; I) includes="$(append_variable "$includes" "$OPTARG")" ;; @@ -73,6 +221,13 @@ handle_command_line() { dotfiles_dirs="${dotfiles_dirs:-$DOTFILES_DIRS}" files="$@" RUN_HOOKS="$run_hooks" + FILES="$(printf '%s\n' "$@")" + REMOVE_DANGLING=$remove_dangling + SELECTED_DOTFILES_DIRS="$dotfiles_dirs" + HOSTNAME="$hostname" + TAGS="$tags" + EXCLUDES="${excludes:-$EXCLUDES}" + INCLUDES="${includes:-$INCLUDES}" for tag in "$tags"; do LS_ARGS="$LS_ARGS -t \"$tag\"" @@ -105,9 +260,13 @@ handle_command_line() { } LS_ARGS=-F +RESOLVED_DOTFILES_DIRS= handle_command_line "$@" : ${DOTFILES_DIRS:=$DOTFILES_DIRS $DEFAULT_DOTFILES_DIR} +if [ -z "$SELECTED_DOTFILES_DIRS" ]; then + SELECTED_DOTFILES_DIRS="$DOTFILES_DIRS" +fi run_hooks pre down pre_dn_ret=$? @@ -132,4 +291,7 @@ for dest_and_src in $dests_and_srcs; do done IFS="$saved_ifs" +if [ $REMOVE_DANGLING -eq 1 ]; then + remove_dangling_links +fi run_hooks post down diff --git a/man/rcdn.1 b/man/rcdn.1 index e5d8bf42..5c69cb44 100644 --- a/man/rcdn.1 +++ b/man/rcdn.1 @@ -6,7 +6,7 @@ .Nd remove dotfiles as managed by rcm .Sh SYNOPSIS .Nm rcdn -.Op Fl hKkqVv +.Op Fl DhKkqVv .Op Fl B Ar hostname .Op Fl d Ar dir .Op Fl I Ar excl_pat @@ -65,6 +65,14 @@ as the host-specific directory instead of computing it remove rc files from the .Ar DIR . This can be specified multiple times. +.It Fl D +remove dangling symlinks that are managed by +.Xr rcm 7 . +Managed dangling symlinks are symlinks in +the destination directory +whose targets are inside the selected dotfiles directories. When +.Ar files +are provided, only dangling symlinks matching those files are removed. .It Fl h show usage instructions. .It Fl I Ar EXCL_PAT diff --git a/share/rcm.sh.in b/share/rcm.sh.in index 47312d9c..72d40793 100644 --- a/share/rcm.sh.in +++ b/share/rcm.sh.in @@ -54,6 +54,81 @@ echo_stderr() { echo "$*" >&2 } +dotfiles_dir_excludes() { + local dotfiles_dir="$1" + local excludes="$2" + + $DEBUG "dotfiles_dir_excludes $dotfiles_dir" + $DEBUG " with excludes: $excludes" + + set -o noglob + for exclude in $excludes; do + if echo "$exclude" | grep ':' >/dev/null; then + dotfiles_dir_pat="$(echo "$exclude" | sed 's/:.*//')" + file_glob="$(echo "$exclude" | sed 's/.*://')" + + if [ "x$dotfiles_dir_pat" != "x*" ] && is_relative "$dotfiles_dir_pat"; then + dotfiles_dir_pat="$PWD/$dotfiles_dir_pat" + fi + + if [ "x$dotfiles_dir_pat" = "x*" -o "x$dotfiles_dir_pat" = "x$dotfiles_dir" ]; then + echo "$file_glob" + fi + else + echo "$exclude" + fi + done + set +o noglob +} + +is_excluded() { + local file="$1" + local exclude_file_globs="$2" + local include_file_globs="$3" + local base_file="$(basename "$file")" + + $DEBUG "is_excluded $file $exclude_file_globs $include_file_globs" + + for exclude_file_glob in $exclude_file_globs; do + $DEBUG "file: $file" + $DEBUG "exclude_file_glob: $exclude_file_glob" + + is_single_excluded "$file" "$exclude_file_glob" "$include_file_globs" + ret=$? + if [ $ret -eq 0 -o $ret -eq 1 ]; then + return $ret + fi + + is_single_excluded "$base_file" "$exclude_file_glob" "$include_file_globs" + ret=$? + if [ $ret -eq 0 -o $ret -eq 1 ]; then + return $ret + fi + done + + return 1 +} + +is_single_excluded() { + local file="$1" + local exclude_file_glob="$2" + local include_file_globs="$3" + + case "$file" in + $exclude_file_glob) + for include_file_glob in $include_file_globs; do + case "$file" in + $include_file_glob) return 1;; + esac + done + + return 0 + ;; + esac + + return 2 +} + is_relative() { echo "$1" | grep -v '^/' >/dev/null } diff --git a/test/rcdn-dangling-symlinks.t b/test/rcdn-dangling-symlinks.t new file mode 100644 index 00000000..e75d91f1 --- /dev/null +++ b/test/rcdn-dangling-symlinks.t @@ -0,0 +1,148 @@ + $ . "$TESTDIR/helper.sh" + +Without -D, rcdn should not remove managed dangling symlinks + + $ touch .dotfiles/alpha + > rcup >/dev/null + > rm .dotfiles/alpha + > rcdn >/dev/null + $ assert "alpha should still be a dangling symlink" -h "$HOME/.alpha" + +With -D, rcdn should remove managed dangling symlinks + + $ rcdn -D >/dev/null + $ refute "alpha should be removed when -D is passed" -h "$HOME/.alpha" + +With -D, rcdn should keep unmanaged dangling symlinks + + $ ln -s "$HOME/not-managed-target" "$HOME/.outside" + $ rcdn -D >/dev/null + $ assert "outside should still be a dangling symlink" -h "$HOME/.outside" + +With -D and FILE arguments, cleanup should be restricted to those files + + $ touch .dotfiles/one .dotfiles/two + > rcup >/dev/null + > rm .dotfiles/one .dotfiles/two + $ assert "one should be dangling symlink" -h "$HOME/.one" + $ assert "two should be dangling symlink" -h "$HOME/.two" + $ rcdn -D one >/dev/null + $ refute "one should be removed" -h "$HOME/.one" + $ assert "two should remain because it was not requested" -h "$HOME/.two" + +With -D, rcdn should not remove dangling symlinks inside source dotfiles dirs + + $ ln -s "$HOME/.dotfiles/missing-in-source" "$HOME/.dotfiles/internal-dangling" + $ rcdn -D >/dev/null + $ assert "internal source-dir dangling link should not be removed" -h "$HOME/.dotfiles/internal-dangling" + +With -D and multiple FILE arguments, every selected link should be removed + + $ ln -s "$HOME/.dotfiles/first-missing" "$HOME/.first-missing" + > ln -s "$HOME/.dotfiles/second-missing" "$HOME/.second-missing" + > rcdn -D first-missing second-missing >/dev/null + $ refute "first requested link should be removed" -h "$HOME/.first-missing" + $ refute "second requested link should be removed" -h "$HOME/.second-missing" + +With multiple dotfiles roots, cleanup should recognize each root + + $ mkdir "$HOME/other-dotfiles" + > ln -s "$HOME/.dotfiles/first-root-missing" "$HOME/.first-root-missing" + > ln -s "$HOME/other-dotfiles/second-root-missing" "$HOME/.second-root-missing" + > rcdn -D -d "$HOME/.dotfiles" -d "$HOME/other-dotfiles" >/dev/null + $ refute "link from first root should be removed" -h "$HOME/.first-root-missing" + $ refute "link from second root should be removed" -h "$HOME/.second-root-missing" + +Source directories should remain protected when multiple roots are selected + + $ assert "internal link should remain with multiple selected roots" -h "$HOME/.dotfiles/internal-dangling" + +Dangling link names should be read literally + + $ ln -s "$HOME/.dotfiles/missing space" "$HOME/.missing space" + > ln -s "$HOME/.dotfiles/missing-backslash" "$HOME/.missing\\backslash" + > rcdn -D >/dev/null + $ refute "name with spaces should be removed" -h "$HOME/.missing space" + $ refute "name with a backslash should be removed" -h "$HOME/.missing\\backslash" + +Selected file arguments should preserve spaces + + $ ln -s "$HOME/.dotfiles/selected space" "$HOME/.selected space" + > ln -s "$HOME/.dotfiles/other space" "$HOME/.other space" + > rcdn -D "selected space" >/dev/null + $ refute "selected spaced name should be removed" -h "$HOME/.selected space" + $ assert "unselected spaced name should remain" -h "$HOME/.other space" + +Cleanup respects exclusions, including excluded parent directories + + $ mkdir -p .dotfiles/config .config + > ln -s "$HOME/.dotfiles/excluded" .excluded + > ln -s "$HOME/.dotfiles/config/missing" .config/missing + > ln -s "$HOME/.dotfiles/included" .included + > rcdn -D -x 'excluded config' >/dev/null + $ assert "excluded link should remain" -h .excluded + $ assert "child of excluded directory should remain" -h .config/missing + $ refute "non-excluded link should be removed" -h .included + $ mkdir config + > touch config/live + > rcdn -D -x 'config/*' >/dev/null + $ assert "path glob should preserve a missing child" -h .config/missing + $ ln -s "$HOME/.dotfiles/included" .included + > ln -s "$HOME/.dotfiles/allowed" .allowed + > rcdn -D -x 'excluded config included' -I included >/dev/null + $ refute "include override should allow cleanup" -h .included + $ refute "include argument should not restrict positional file selection" -h .allowed + $ assert "include override should not select other excluded links" -h .config/missing + + $ ln -s "$HOME/.dotfiles/excluded" .excluded + +Root-qualified exclusions and configuration defaults apply to missing sources + + $ ln -s "$HOME/other-dotfiles/excluded" .other-excluded + > echo 'EXCLUDES="other-dotfiles:excluded"' > "$RCRC" + > rcdn -D -d .dotfiles -d other-dotfiles >/dev/null + $ assert "root-qualified excluded link should remain" -h .other-excluded + $ refute "same name in another root should be removed" -h .excluded + $ rcdn -D -d other-dotfiles -x unrelated >/dev/null + $ refute "CLI exclusions should override configured exclusions" -h .other-excluded + $ rm "$RCRC" + +Cleanup removes only selected tag and host sources, leaving hook sources alone + + $ mkdir -p .dotfiles/tag-work .dotfiles/tag-personal .dotfiles/host-selected .dotfiles/host-other .dotfiles/hooks + > ln -s "$HOME/.dotfiles/tag-work/work" .work + > ln -s "$HOME/.dotfiles/tag-personal/personal" .personal + > ln -s "$HOME/.dotfiles/host-selected/host" .host + > ln -s "$HOME/.dotfiles/host-other/other-host" .other-host + > ln -s "$HOME/.dotfiles/hooks/missing" .hook + > rcdn -D -t work -B selected >/dev/null + $ refute "selected tag should be removed" -h .work + $ refute "selected host should be removed" -h .host + $ assert "unselected tag should remain" -h .personal + $ assert "unselected host should remain" -h .other-host + $ assert "hooks are not managed files" -h .hook + $ echo 'TAGS=personal; HOSTNAME=other' > "$RCRC" + > rcdn -D >/dev/null + $ refute "configured tag should be removed" -h .personal + $ refute "configured host should be removed" -h .other-host + +Explicit nested FILE arguments use the same exclusion path spelling as lsrc + + $ mkdir -p .config + > ln -s "$HOME/.dotfiles/config/missing" .config/missing + > rcdn -D -x './config/*' config/missing >/dev/null + $ assert "explicit nested excluded link should remain" -h .config/missing + $ rcdn -D -x unrelated config/missing >/dev/null + $ refute "explicit nested non-excluded link should be removed" -h .config/missing + +Ambiguous source paths are preserved without hanging or escaping the root + + $ ln -s "$HOME/.dotfiles//missing" .double-slash + > ln -s "$HOME/.dotfiles/tag-personal//missing" .tag-double-slash + > ln -s "$HOME/.dotfiles/./tag-work/missing" .dot-path + > ln -s "$HOME/.dotfiles/../missing" .parent-path + > rcdn -D >/dev/null + $ assert "repeated separator at source root should be preserved" -h .double-slash + $ assert "repeated separator inside selected tag should be preserved" -h .tag-double-slash + $ assert "dot segment should not bypass tag selection" -h .dot-path + $ assert "parent traversal should be preserved" -h .parent-path