diff --git a/docs/usage.md b/docs/usage.md index 557661c2..aea0f74d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -102,7 +102,7 @@ Some available options: * `-a`/`--force-actions`: Force the execution of actions even if the dotfiles are not installed (see [Fake dotfile and actions](config/config-actions.md#fake-dotfile-and-actions) as an alternative) * `-f`/`--force`: Do not ask for any confirmation * `-W`/`--workdir-clear`: Clear the `workdir` before installing dotfiles (see [the config entry](config/config-config.md) `clear_workdir`) -* `-R`/`remove-existing`: Applies to directory dotfiles only (`nolink`) and will remove files not managed by dotdrop in the destination directory +* `-R`/`--remove-existing`: Applies to directory dotfiles. With `link: nolink`, it removes files not managed by dotdrop in the destination directory. With `link: link_children`, it removes only dangling direct-child symlinks whose source child no longer exists and whose target is inside the dotfile's source directory or dotdrop's `workdir`; other destination entries are preserved. To ignore specific patterns during installation, see [the ignore patterns](config/config-file.md#ignore-patterns). diff --git a/dotdrop/installer.py b/dotdrop/installer.py index d3233bf0..98d84d1a 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -406,6 +406,8 @@ def _link_children(self, templater, src, dst, self._create_dirs(dst) children = os.listdir(parent) + if self.remove_existing_in_dir: + self._remove_stale_link_children(parent, dst) srcs = [os.path.normpath(os.path.join(parent, child)) for child in children] dsts = [os.path.normpath(os.path.join(dst, child)) @@ -704,6 +706,44 @@ def _remove_existing_in_dir(self, directory, installed_files=None): if not removepath(path, logger=self.log): self.log.warn(f'unable to remove {path}') + def _remove_stale_link_children(self, source, destination): + """remove stale links previously installed by link_children""" + if not os.path.isdir(destination): + return + managed_roots = [os.path.realpath(source), + os.path.realpath(self.workdir)] + for child in os.listdir(destination): + if os.path.lexists(os.path.join(source, child)): + continue + path = os.path.join(destination, child) + if not self._is_managed_dangling_link(path, managed_roots): + continue + + if self.dry: + self.log.dry(f'would remove stale link "{path}"') + continue + if self.safe and not self.log.ask(f'remove stale link "{path}"'): + return + if not removepath(path, logger=self.log): + self.log.warn(f'unable to remove {path}') + continue + self.log.sub(f'removed stale link "{path}"') + + @staticmethod + def _is_managed_dangling_link(path, managed_roots): + """return true for a dangling link into a managed directory""" + if not os.path.islink(path) or os.path.exists(path): + return False + target = os.path.join(os.path.dirname(path), os.readlink(path)) + target = os.path.realpath(target) + for root in managed_roots: + try: + if os.path.commonpath([target, root]) == root: + return True + except ValueError: + continue + return False + @classmethod def _write_content_to_file(cls, content, src, dst): """write content to file""" diff --git a/dotdrop/options.py b/dotdrop/options.py index ca14e225..1769a91a 100644 --- a/dotdrop/options.py +++ b/dotdrop/options.py @@ -96,7 +96,7 @@ -n --nodiff Do not diff when installing. -p --profile= Specify the profile to use [default: {PROFILE}]. -P --show-patch Provide a one-liner to manually patch template. - -R --remove-existing Remove existing file on install directory. + -R --remove-existing Remove stale entries from installed directories. -s --as= Import as a different path from actual path. --transr= Associate trans_install key on import. --transw= Apply trans_update key on import. diff --git a/tests-ng/install-link-children-remove-existing.sh b/tests-ng/install-link-children-remove-existing.sh new file mode 100755 index 00000000..29b8b26b --- /dev/null +++ b/tests-ng/install-link-children-remove-existing.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# author: deadc0de6 (https://github.com/deadc0de6) +# Copyright (c) 2026, deadc0de6 +# +# test removing stale links installed with link_children +# returns 1 in case of error +# + +## start-cookie +set -eu -o errtrace -o pipefail +cur=$(cd "$(dirname "${0}")" && pwd) +ddpath="${cur}/../" +PPATH="{PYTHONPATH:-}" +export PYTHONPATH="${ddpath}:${PPATH}" +altbin="python3 -m dotdrop.dotdrop" +if hash coverage 2>/dev/null; then + mkdir -p coverages/ + altbin="coverage run -p --data-file coverages/coverage --source=dotdrop -m dotdrop.dotdrop" +fi +bin="${DT_BIN:-${altbin}}" +# shellcheck source=tests-ng/helpers +source "${cur}"/helpers +echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)" +## end-cookie + +################################################################ +# this is the test +################################################################ + +# dotdrop directory +basedir=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) +source_dir="${basedir}/dotfiles/skills" + +# deployed directory +destination=$(mktemp -d --suffix='-dotdrop-fs' || mktemp -d) + +clear_on_exit "${basedir}" +clear_on_exit "${destination}" + +# create the config file +cfg="${basedir}/config.yaml" +cat > "${cfg}" << _EOF +config: + backup: true + create: true + dotpath: dotfiles +dotfiles: + d_skills: + src: skills + dst: ${destination} + link: link_children + template: false +profiles: + p1: + dotfiles: + - d_skills +_EOF + +# create managed and unmanaged destination entries +mkdir -p "${source_dir}/kept-skill" +mkdir -p "${source_dir}/removed-skill" +mkdir -p "${destination}/.system" +echo 'local' > "${destination}/local-file" +ln -s "${basedir}/unrelated-missing" "${destination}/unrelated-link" + +# install the managed links +cd "${ddpath}" | ${bin} install -f -c "${cfg}" -p p1 +[ ! -h "${destination}/kept-skill" ] && echo "kept-skill not linked" && exit 1 +[ ! -h "${destination}/removed-skill" ] && echo "removed-skill not linked" && exit 1 + +# remove one source child and ensure pruning remains opt-in +rm -rf "${source_dir}/removed-skill" +cd "${ddpath}" | ${bin} install -f -c "${cfg}" -p p1 +[ ! -h "${destination}/removed-skill" ] && echo "stale link removed without opt-in" && exit 1 + +# dry run must report the stale link without removing it +out=$(cd "${ddpath}" | ${bin} install --remove-existing --dry -f -c "${cfg}" -p p1) +echo "${out}" +echo "${out}" | grep -F "would remove stale link \"${destination}/removed-skill\"" >/dev/null +[ ! -h "${destination}/removed-skill" ] && echo "stale link removed during dry run" && exit 1 + +# remove only the stale managed link +cd "${ddpath}" | ${bin} install --remove-existing -f -c "${cfg}" -p p1 +[ -h "${destination}/removed-skill" ] && echo "stale link not removed" && exit 1 +[ ! -h "${destination}/kept-skill" ] && echo "live link removed" && exit 1 +[ ! -h "${destination}/unrelated-link" ] && echo "unrelated link removed" && exit 1 +[ ! -d "${destination}/.system" ] && echo "local directory removed" && exit 1 +[ ! -f "${destination}/local-file" ] && echo "local file removed" && exit 1 + +echo "OK" +exit 0 diff --git a/tests/test_install.py b/tests/test_install.py index 11afbc6d..1b36977a 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -6,7 +6,7 @@ import os import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import filecmp from tests.helpers import (clean, create_dir, create_fake_config, create_random_file, get_string, get_tempdir, @@ -389,6 +389,105 @@ def test_link_children(self): xyz = os.path.join(dst_dir, src) self.assertEqual(os.path.realpath(xyz), os.path.realpath(src)) + def test_link_children_remove_existing(self): + """test pruning stale links created by link_children""" + source = get_tempdir() + destination = get_tempdir() + workdir = get_tempdir() + self.addCleanup(clean, source) + self.addCleanup(clean, destination) + self.addCleanup(clean, workdir) + + source_file, _ = create_random_file(source) + declared_broken = os.path.join(source, 'declared-broken') + os.symlink('missing', declared_broken) + + stale = { + 'source-absolute': os.path.join(source, 'missing-absolute'), + 'source-relative': os.path.relpath( + os.path.join(source, 'missing-relative'), destination), + 'workdir-absolute': os.path.join(workdir, 'missing-absolute'), + 'workdir-relative': os.path.relpath( + os.path.join(workdir, 'missing-relative'), destination), + } + stale_paths = [] + for name, target in stale.items(): + path = os.path.join(destination, name) + os.symlink(target, path) + stale_paths.append(path) + + regular = os.path.join(destination, 'regular') + with open(regular, 'w', encoding='utf-8') as file: + file.write('preserve') + directory = os.path.join(destination, 'directory') + os.mkdir(directory) + nested = os.path.join(directory, 'nested-stale') + os.symlink(os.path.join(source, 'missing-nested'), nested) + live = os.path.join(destination, 'live') + os.symlink(source_file, live) + unrelated = os.path.join(destination, 'unrelated') + os.symlink(os.path.join(source + '-other', 'missing'), unrelated) + declared = os.path.join(destination, 'declared-broken') + os.symlink(declared_broken, declared) + + installer = Installer(workdir=workdir, + remove_existing_in_dir=True) + installer.log.sub = MagicMock() + installer.install(templater=MagicMock(), src=source, + dst=destination, + linktype=LinkTypes.LINK_CHILDREN, + actionexec=None, is_template=False) + + for path in stale_paths: + self.assertFalse(os.path.lexists(path)) + for path in [regular, directory, nested, live, unrelated, declared]: + self.assertTrue(os.path.lexists(path)) + expected = [call(f'removed stale link "{path}"') + for path in stale_paths] + installer.log.sub.assert_has_calls(expected, any_order=True) + + def test_link_children_remove_existing_is_opt_in(self): + """test link_children pruning is disabled by default""" + source = get_tempdir() + destination = get_tempdir() + workdir = get_tempdir() + self.addCleanup(clean, source) + self.addCleanup(clean, destination) + self.addCleanup(clean, workdir) + stale = os.path.join(destination, 'stale') + os.symlink(os.path.join(source, 'missing'), stale) + + installer = Installer(workdir=workdir) + installer.install(templater=MagicMock(), src=source, + dst=destination, + linktype=LinkTypes.LINK_CHILDREN, + actionexec=None, is_template=False) + + self.assertTrue(os.path.lexists(stale)) + + def test_link_children_remove_existing_dry(self): + """test dry link_children pruning is reported but not applied""" + source = get_tempdir() + destination = get_tempdir() + workdir = get_tempdir() + self.addCleanup(clean, source) + self.addCleanup(clean, destination) + self.addCleanup(clean, workdir) + stale = os.path.join(destination, 'stale') + os.symlink(os.path.join(source, 'missing'), stale) + + installer = Installer(workdir=workdir, dry=True, + remove_existing_in_dir=True) + installer.log.dry = MagicMock() + installer.install(templater=MagicMock(), src=source, + dst=destination, + linktype=LinkTypes.LINK_CHILDREN, + actionexec=None, is_template=False) + + self.assertTrue(os.path.lexists(stale)) + installer.log.dry.assert_called_once_with( + f'would remove stale link "{stale}"') + def test_fails_without_src(self): """test fails without src""" src = '/some/non/existant/file'