Skip to content

--erase matches are silently dropped when the target directory lives under a default-ignored ancestor (.tox, .venv, …) #122

@bittner

Description

@bittner

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 **/* --yes

Because {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_ignoredignore.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:

  1. The default ignore list stays valid as pyclean's baseline and keeps governing the bytecode/debris traversal as before.
  2. A directory positional is an explicit instruction to traverse and act on that directory, so the default ignore patterns must not block --erase from deleting the named directory, its ancestors, or anything inside it.
  3. Patterns passed explicitly via --ignore take 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 --ignore default=None and derive explicit_ignore = args.ignore or []. (Today --ignore uses action='extend' with default=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 --ignore only adds to them.
  • --erase filtering uses only explicit_ignore: empty ⇒ no filtering (delete everything matched inside DIR); 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

Metadata

Metadata

Labels

bugSomething isn't workingusabilityAffects user experience

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions