Skip to content

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

Closed

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_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:

  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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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