From 2430ed9ad14d8442a0396a0e5016fbab5fc20cf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:43:35 +0000 Subject: [PATCH 1/4] Initial plan From 84974cd3f67ea7b2855adb20fce9b1f23d145f33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:48:19 +0000 Subject: [PATCH 2/4] Fix erase filtering precedence for default vs explicit ignore Agent-Logs-Url: https://github.com/bittner/pyclean/sessions/c2f57d0d-02cf-462a-981c-183b0cec384d Co-authored-by: bittner <665072+bittner@users.noreply.github.com> --- pyclean/cli.py | 5 ++- pyclean/erase.py | 23 ++++++++++--- pyclean/main.py | 8 ++++- tests/test_cli.py | 1 + tests/test_erase.py | 79 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 109 insertions(+), 7 deletions(-) diff --git a/pyclean/cli.py b/pyclean/cli.py index 5c50384..342fdb4 100644 --- a/pyclean/cli.py +++ b/pyclean/cli.py @@ -91,7 +91,7 @@ def parse_arguments(): metavar='DIRECTORY', action='extend', nargs='+', - default=ignore_default_items, + default=None, help='directory that should be ignored (may be specified multiple times;' ' default: %s)' % ' '.join(ignore_default_items), ) @@ -136,6 +136,9 @@ def parse_arguments(): else: args.debris = [] + args.explicit_ignore = args.ignore or [] + args.ignore = [*ignore_default_items, *args.explicit_ignore] + log.debug('Ignored directories: %s', ' '.join(args.ignore)) return args diff --git a/pyclean/erase.py b/pyclean/erase.py index 940e850..d2a3fc4 100644 --- a/pyclean/erase.py +++ b/pyclean/erase.py @@ -4,11 +4,17 @@ """Freeform target deletion with interactive prompt.""" +from __future__ import annotations + import logging -from pathlib import Path +from typing import TYPE_CHECKING +from .ignore import path_is_ignored from .runner import Runner +if TYPE_CHECKING: + from pathlib import Path + log = logging.getLogger(__name__) @@ -27,6 +33,7 @@ def delete_filesystem_objects( path_glob: str, prompt=False, dry_run=False, + ignore_patterns: list[str] | None = None, ): """ Identifies all pathnames matching a specific glob pattern, and attempts @@ -38,8 +45,9 @@ def delete_filesystem_objects( are empty (for both files & directories) when we attempt to remove them. """ 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)] + ignore_patterns = Runner.ignore if ignore_patterns is None else ignore_patterns + if ignore_patterns: + all_names = [n for n in all_names if not path_is_ignored(n, ignore_patterns)] if not all_names: return log.debug('Erase file system objects matching: %s', path_glob) @@ -73,6 +81,7 @@ def remove_freeform_targets( glob_patterns: list[str], yes, dry_run=False, + explicit_ignore_patterns: list[str] | None = None, ): """ Remove free-form targets using globbing. @@ -90,4 +99,10 @@ def remove_freeform_targets( object is shown (unless the ``--yes`` option is used, in addition). """ for path_glob in glob_patterns: - delete_filesystem_objects(directory, path_glob, prompt=not yes, dry_run=dry_run) + delete_filesystem_objects( + directory, + path_glob, + prompt=not yes, + dry_run=dry_run, + ignore_patterns=explicit_ignore_patterns, + ) diff --git a/pyclean/main.py b/pyclean/main.py index 2b61d48..cfd3be7 100644 --- a/pyclean/main.py +++ b/pyclean/main.py @@ -31,7 +31,13 @@ def pyclean(args): for topic in args.debris: remove_debris_for(topic, dir_path) - remove_freeform_targets(dir_path, args.erase, args.yes, args.dry_run) + remove_freeform_targets( + dir_path, + args.erase, + args.yes, + args.dry_run, + explicit_ignore_patterns=getattr(args, 'explicit_ignore', []), + ) if args.folders: log.debug('Removing empty directories...') diff --git a/tests/test_cli.py b/tests/test_cli.py index f73972b..91b2221 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -112,6 +112,7 @@ def test_ignore_option(): args = pyclean.cli.parse_arguments() assert args.ignore == expected_ignore_list + assert args.explicit_ignore == ['foo', 'bar'] def test_debris_default_args(): diff --git a/tests/test_erase.py b/tests/test_erase.py index 64ff0ca..f96e9ef 100644 --- a/tests/test_erase.py +++ b/tests/test_erase.py @@ -50,7 +50,7 @@ def test_erase_loop(mock_delete_fs_obj): remove_freeform_targets(directory, patterns, yes=False, dry_run=False) assert mock_delete_fs_obj.mock_calls == [ - call(directory, 'foo.txt', prompt=True, dry_run=False), + call(directory, 'foo.txt', prompt=True, dry_run=False, ignore_patterns=None), ] @@ -286,3 +286,80 @@ def test_delete_filesystem_objects_erases_non_ignored(tmp_path): assert ignored_file.exists(), 'File in ignored directory should not be deleted' assert not non_ignored_file1.exists(), 'Non-ignored file should be deleted' assert not non_ignored_file2.exists(), 'Non-ignored file should be deleted' + + +def test_erase_under_default_ignored_ancestor_is_not_blocked(tmp_path): + """ + Does --erase still delete matches in an explicitly named directory + under a default ignored ancestor? + """ + target_dir = tmp_path / '.tox' / 'generated' / 'django' + target_dir.mkdir(parents=True) + target_file = target_dir / 'pyproject.toml' + target_file.write_text('[build-system]') + + args = Namespace( + directory=[str(target_dir)], + debris=[], + erase=['**/*'], + yes=True, + dry_run=False, + folders=False, + git_clean=False, + ignore=[ + '.direnv', + '.git', + '.hg', + '.svn', + '.tox', + '.venv', + 'node_modules', + 'venv', + ], + explicit_ignore=[], + ) + + pyclean.main.pyclean(args) + + assert not target_file.exists(), ( + 'File should be deleted by --erase in target directory' + ) + + +def test_erase_applies_explicit_ignore_on_top_of_defaults(tmp_path): + """ + Does --erase apply only explicit ignore patterns for filtering matches? + """ + target_dir = tmp_path / '.tox' / 'generated' / 'django' + target_dir.mkdir(parents=True) + kept_file = target_dir / 'keep.txt' + removed_file = target_dir / 'erase.txt' + kept_file.write_text('keep') + removed_file.write_text('remove') + + args = Namespace( + directory=[str(target_dir)], + debris=[], + erase=['**/*'], + yes=True, + dry_run=False, + folders=False, + git_clean=False, + ignore=[ + '.direnv', + '.git', + '.hg', + '.svn', + '.tox', + '.venv', + 'node_modules', + 'venv', + 'keep.txt', + ], + explicit_ignore=['keep.txt'], + ) + + pyclean.main.pyclean(args) + + assert kept_file.exists(), 'Explicitly ignored file should not be deleted' + assert not removed_file.exists(), 'Non-ignored file should be deleted' From d16f057c1926c74ca0d0f6548c86d3b706d05f29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:54:56 +0000 Subject: [PATCH 3/4] Add erase precedence fix and regression coverage Agent-Logs-Url: https://github.com/bittner/pyclean/sessions/c2f57d0d-02cf-462a-981c-183b0cec384d Co-authored-by: bittner <665072+bittner@users.noreply.github.com> --- pyclean/cli.py | 5 +++-- pyclean/erase.py | 16 ++++++++++------ tests/test_cli.py | 22 ++++++++++++++++++++++ tests/test_erase.py | 2 +- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/pyclean/cli.py b/pyclean/cli.py index 342fdb4..242943d 100644 --- a/pyclean/cli.py +++ b/pyclean/cli.py @@ -136,8 +136,9 @@ def parse_arguments(): else: args.debris = [] - args.explicit_ignore = args.ignore or [] - args.ignore = [*ignore_default_items, *args.explicit_ignore] + args.explicit_ignore = args.ignore if args.ignore is not None else [] + # Keep defaults first while deduplicating explicit repeats. + args.ignore = list(dict.fromkeys([*ignore_default_items, *args.explicit_ignore])) log.debug('Ignored directories: %s', ' '.join(args.ignore)) diff --git a/pyclean/erase.py b/pyclean/erase.py index d2a3fc4..e8e8207 100644 --- a/pyclean/erase.py +++ b/pyclean/erase.py @@ -7,14 +7,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from pathlib import Path from .ignore import path_is_ignored from .runner import Runner -if TYPE_CHECKING: - from pathlib import Path - log = logging.getLogger(__name__) @@ -29,7 +26,7 @@ def confirm(message): def delete_filesystem_objects( - directory: Path, + directory: Path | str, path_glob: str, prompt=False, dry_run=False, @@ -43,8 +40,14 @@ def delete_filesystem_objects( and first delete *all files* before removing directories. This way we make sure that the directories that are deepest down in the hierarchy are empty (for both files & directories) when we attempt to remove them. + + If ``ignore_patterns`` is not provided, the current ``Runner.ignore`` + patterns are used for compatibility with existing internal call sites. """ + directory = Path(directory) all_names = sorted(directory.glob(path_glob), reverse=True) + # Keep existing behavior for call sites like debris cleanup that invoke + # delete_filesystem_objects() directly and rely on Runner.ignore filtering. ignore_patterns = Runner.ignore if ignore_patterns is None else ignore_patterns if ignore_patterns: all_names = [n for n in all_names if not path_is_ignored(n, ignore_patterns)] @@ -77,7 +80,7 @@ def delete_filesystem_objects( def remove_freeform_targets( - directory: Path, + directory: Path | str, glob_patterns: list[str], yes, dry_run=False, @@ -98,6 +101,7 @@ def remove_freeform_targets( - A confirmation prompt for the deletion of every single file system object is shown (unless the ``--yes`` option is used, in addition). """ + directory = Path(directory) for path_glob in glob_patterns: delete_filesystem_objects( directory, diff --git a/tests/test_cli.py b/tests/test_cli.py index 91b2221..8333663 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -115,6 +115,28 @@ def test_ignore_option(): assert args.explicit_ignore == ['foo', 'bar'] +def test_ignore_defaults_without_option(): + """ + Does --ignore still default to the built-in ignore list? + """ + default = [ + '.direnv', + '.git', + '.hg', + '.svn', + '.tox', + '.venv', + 'node_modules', + 'venv', + ] + + with ArgvContext('pyclean', '.'): + args = pyclean.cli.parse_arguments() + + assert args.ignore == default + assert args.explicit_ignore == [] + + def test_debris_default_args(): """ Does calling `pyclean --debris` use defaults for the debris option? diff --git a/tests/test_erase.py b/tests/test_erase.py index f96e9ef..5af1031 100644 --- a/tests/test_erase.py +++ b/tests/test_erase.py @@ -288,7 +288,7 @@ def test_delete_filesystem_objects_erases_non_ignored(tmp_path): assert not non_ignored_file2.exists(), 'Non-ignored file should be deleted' -def test_erase_under_default_ignored_ancestor_is_not_blocked(tmp_path): +def test_erase_in_explicit_target_under_default_ignored_ancestor(tmp_path): """ Does --erase still delete matches in an explicitly named directory under a default ignored ancestor? From 9dbbfb19159db4fb484c16be353ac3d77241ef8b Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sat, 23 May 2026 03:30:03 +0200 Subject: [PATCH 4/4] Address review follow-ups for erase precedence fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default `remove_freeform_targets`'s `explicit_ignore_patterns` to `[]` internally so production callers (`main.py`) and tests both exercise the same path. The `None`-fallback in `delete_filesystem_objects` remains for direct debris call sites only. - Update the #119 regression test to pass `explicit_ignore_patterns=['foo']` so it exercises the production wiring instead of the `Runner.ignore` fallback. - Add a regression test for case 3 of issue #122's precedence table — `pyclean DIR --erase '**/*' --ignore DIR` is a no-op. - Trim the hardcoded default ignore list to the patterns that actually matter in the two new erase tests (`.tox`, `keep.txt`). - Document the erase/ignore interaction in `remove_freeform_targets`'s docstring and in the README `--erase` section. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.rst | 5 ++++ pyclean/erase.py | 8 +++++- tests/test_erase.py | 66 +++++++++++++++++++++++++++++---------------- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/README.rst b/README.rst index 2702e0f..7d24bac 100644 --- a/README.rst +++ b/README.rst @@ -242,6 +242,11 @@ inside the current folder. If you omit the final ``tmp/`` you'll leave the empty ``tmp`` directory in place. (**WARNING!** Don't put the ``.`` *after* the ``--erase`` option! Obviously, your project files will all be deleted.) +``--erase`` matches are filtered only by patterns you pass explicitly to +``--ignore``; the built-in default ignore list (``.git``, ``.tox``, ``.venv``, +…) does **not** restrict ``--erase``. This lets you wipe contents inside a +directory like ``.tox/generated/`` when you name it explicitly. + Empty directories 📂 ---------------------- diff --git a/pyclean/erase.py b/pyclean/erase.py index e8e8207..596665a 100644 --- a/pyclean/erase.py +++ b/pyclean/erase.py @@ -100,13 +100,19 @@ def remove_freeform_targets( is empty when it is attempted to be deleted. - A confirmation prompt for the deletion of every single file system object is shown (unless the ``--yes`` option is used, in addition). + + Only ``explicit_ignore_patterns`` (typically the user's ``--ignore`` + values) constrain ``--erase`` matches. The built-in default ignore + list does not restrict ``--erase`` — when no explicit ignore patterns + are given, every match is deleted. """ directory = Path(directory) + ignore_patterns = explicit_ignore_patterns or [] for path_glob in glob_patterns: delete_filesystem_objects( directory, path_glob, prompt=not yes, dry_run=dry_run, - ignore_patterns=explicit_ignore_patterns, + ignore_patterns=ignore_patterns, ) diff --git a/tests/test_erase.py b/tests/test_erase.py index 5af1031..2b53609 100644 --- a/tests/test_erase.py +++ b/tests/test_erase.py @@ -50,7 +50,7 @@ def test_erase_loop(mock_delete_fs_obj): remove_freeform_targets(directory, patterns, yes=False, dry_run=False) assert mock_delete_fs_obj.mock_calls == [ - call(directory, 'foo.txt', prompt=True, dry_run=False, ignore_patterns=None), + call(directory, 'foo.txt', prompt=True, dry_run=False, ignore_patterns=[]), ] @@ -238,7 +238,13 @@ def test_erase_ignored_dir_produces_no_output(tmp_path, caplog): pyclean.main.Runner.configure(args) with caplog.at_level(logging.DEBUG): - remove_freeform_targets(tmp_path, ['foo/**/*', 'foo'], yes=True, dry_run=True) + remove_freeform_targets( + tmp_path, + ['foo/**/*', 'foo'], + yes=True, + dry_run=True, + explicit_ignore_patterns=['foo'], + ) assert pyclean.main.Runner.unlink_count == 0 assert pyclean.main.Runner.rmdir_count == 0 @@ -306,16 +312,7 @@ def test_erase_in_explicit_target_under_default_ignored_ancestor(tmp_path): dry_run=False, folders=False, git_clean=False, - ignore=[ - '.direnv', - '.git', - '.hg', - '.svn', - '.tox', - '.venv', - 'node_modules', - 'venv', - ], + ignore=['.tox'], explicit_ignore=[], ) @@ -345,17 +342,7 @@ def test_erase_applies_explicit_ignore_on_top_of_defaults(tmp_path): dry_run=False, folders=False, git_clean=False, - ignore=[ - '.direnv', - '.git', - '.hg', - '.svn', - '.tox', - '.venv', - 'node_modules', - 'venv', - 'keep.txt', - ], + ignore=['.tox', 'keep.txt'], explicit_ignore=['keep.txt'], ) @@ -363,3 +350,36 @@ def test_erase_applies_explicit_ignore_on_top_of_defaults(tmp_path): assert kept_file.exists(), 'Explicitly ignored file should not be deleted' assert not removed_file.exists(), 'Non-ignored file should be deleted' + + +def test_erase_is_no_op_when_explicit_ignore_matches_target(tmp_path): + """ + Does --erase do nothing when --ignore matches the named target directory? + + Per the precedence table in issue #122, + ``pyclean DIR --erase '**/*' --ignore DIR`` is a no-op: explicit + ``--ignore`` takes precedence and the user can still protect the + named directory from erasure. + """ + target_dir = tmp_path / 'protected' + target_dir.mkdir() + safe_file = target_dir / 'safe.txt' + safe_file.write_text('safe') + + args = Namespace( + directory=[str(target_dir)], + debris=[], + erase=['**/*'], + yes=True, + dry_run=False, + folders=False, + git_clean=False, + ignore=['protected'], + explicit_ignore=['protected'], + ) + + pyclean.main.pyclean(args) + + assert safe_file.exists(), ( + 'File should not be deleted when --ignore matches the target directory' + )