Repository navigation
--erase matches are silently dropped when the target directory lives under a default-ignored ancestor (.tox, .venv, …) #122
Description
Summary
pyclean DIR --erase '**/*' --yes removes nothing — exit 0, Total 0 files, 0 directories removed — when DIR is nested under a directory whose name is in the default --ignore list (.git, .hg, .svn, .tox, .venv, node_modules, venv).
Since remove_freeform_targets's docstring documents dirname/**/* as the intended pattern for recursive deletion, this is a silent no-op where a full wipe is reasonably expected.
Reproduction
$ mkdir -p .tox/generated/django && touch .tox/generated/django/pyproject.toml
$ pyclean .tox/generated/django --erase '**/*' --yes -v
Ignored directories: .git .hg .svn .tox .venv node_modules venv
Cleaning directory .tox/generated/django
...
Total 0 files, 0 directories removed.Nothing under the explicitly-named .tox/generated/django is deleted.
Real-world impact
Hit in the painless-cicd-django-app Copier template. Its tox setup generates example projects into {toxworkdir}/generated/<stack>/ (i.e. under .tox/), and a clean env is meant to wipe them — tox.ini L65-68:
[testenv:clean]
description = Remove generated project(s)
deps = pyclean
commands = pyclean {toxworkdir}/generated/{posargs} --erase **/* --yesBecause {toxworkdir} is .tox, every erase match has .tox as an ancestor, so tox -e clean silently stopped removing anything — it reports success but leaves all generated stacks in place.
Root cause
pyclean/erase.py::delete_filesystem_objects:
all_names = sorted(directory.glob(path_glob), reverse=True)
if Runner.ignore:
all_names = [n for n in all_names if not Runner.is_ignored(n)]Runner.is_ignored → ignore.path_is_ignored, which tests the path and all of its ancestors:
return any(should_ignore(str(p), ignore_patterns) for p in [path, *path.parents])Every match from directory.glob('**/*') has .tox among its ancestors (.tox/generated/django/pyproject.toml → ancestor .tox), so all matches are filtered out. The check considers ancestors above the directory the user named explicitly — components they didn't ask to be matched against and can't avoid.
Expected behaviour
--erase matches should be filtered by a precedence ladder — defaults < directory positional < explicit --ignore:
- The default ignore list stays valid as pyclean's baseline and keeps governing the bytecode/debris traversal as before.
- A
directorypositional is an explicit instruction to traverse and act on that directory, so the default ignore patterns must not block--erasefrom deleting the named directory, its ancestors, or anything inside it. - Patterns passed explicitly via
--ignoretake precedence and are applied on top.
Resulting behaviour:
| Command | Result |
|---|---|
pyclean DIR --erase '**/*' |
deletes everything inside DIR |
pyclean DIR --erase '**/*' --ignore FOO |
deletes everything inside DIR except FOO entries |
pyclean DIR --erase '**/*' --ignore DIR |
no-op — deletes nothing |
pyclean DIR --erase '**/*' with no explicit --ignore should traverse DIR for better or worse and delete everything therein.
Implementation considerations
The default ignore list must keep applying to the traversal in every run: bytecode cleanup (descend_and_clean, runs unconditionally) and --debris (recursive_delete_debris) both consult Runner.ignore, so dropping the defaults there would make pyclean descend into .git/.tox/etc.
So the explicit-vs-default distinction must be tracked separately, not by replacing the list:
- Give
--ignoredefault=Noneand deriveexplicit_ignore = args.ignore or []. (Today--ignoreusesaction='extend'withdefault=ignore_default_items, so a user value is appended to the defaults and the two are indistinguishable.) - The traversal ignore list stays
ignore_default_items + explicit_ignore— defaults always on, exactly as today; an explicit--ignoreonly adds to them. --erasefiltering uses onlyexplicit_ignore: empty ⇒ no filtering (delete everything matched insideDIR); non-empty ⇒ filter by those patterns.
A naive default=None where an explicit --ignore replaces the defaults would regress the traversal — e.g. pyclean DIR --debris --ignore FOO would start walking .git/.tox.
Environment
pyclean 3.6.0 · Python 3.14.4
Activity
Address review follow-ups for erase precedence fix