Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
40 changes: 40 additions & 0 deletions dotdrop/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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"""
Expand Down
2 changes: 1 addition & 1 deletion dotdrop/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
-n --nodiff Do not diff when installing.
-p --profile=<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=<path> Import as a different path from actual path.
--transr=<key> Associate trans_install key on import.
--transw=<key> Apply trans_update key on import.
Expand Down
91 changes: 91 additions & 0 deletions tests-ng/install-link-children-remove-existing.sh
Original file line number Diff line number Diff line change
@@ -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
101 changes: 100 additions & 1 deletion tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down
Loading