devcleaner-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dev_cleaner/__init__.py +1 -0
- dev_cleaner/__main__.py +6 -0
- dev_cleaner/cleaners/__init__.py +1 -0
- dev_cleaner/cleaners/base.py +8 -0
- dev_cleaner/cleaners/native.py +18 -0
- dev_cleaner/cleaners/wsl.py +37 -0
- dev_cleaner/cli/__init__.py +1 -0
- dev_cleaner/cli/interface.py +272 -0
- dev_cleaner/cli/parser.py +81 -0
- dev_cleaner/core/__init__.py +1 -0
- dev_cleaner/core/cleaner.py +171 -0
- dev_cleaner/main.py +180 -0
- devcleaner_cli-1.0.0.dist-info/METADATA +468 -0
- devcleaner_cli-1.0.0.dist-info/RECORD +17 -0
- devcleaner_cli-1.0.0.dist-info/WHEEL +4 -0
- devcleaner_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devcleaner_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
dev_cleaner/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
dev_cleaner/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# __init__.py
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _handle_remove_error(function, path, exc_info) -> None:
|
|
8
|
+
# Read-only files (common inside node_modules on Windows) block rmtree.
|
|
9
|
+
os.chmod(path, stat.S_IWRITE)
|
|
10
|
+
function(path)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NativeCleaner:
|
|
14
|
+
def remove(self, path: Path) -> None:
|
|
15
|
+
if not path.is_dir():
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
shutil.rmtree(path, onexc=_handle_remove_error)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WslCleaner:
|
|
7
|
+
def __init__(self, distribution: str = "Ubuntu"):
|
|
8
|
+
if shutil.which("wsl") is None:
|
|
9
|
+
raise ValueError(
|
|
10
|
+
"The wsl backend requires Windows with WSL installed. "
|
|
11
|
+
"Use the native backend on Linux."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
self.distribution = distribution
|
|
15
|
+
|
|
16
|
+
def remove(self, path: PurePosixPath) -> None:
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
[
|
|
19
|
+
"wsl",
|
|
20
|
+
"-d",
|
|
21
|
+
self.distribution,
|
|
22
|
+
"--",
|
|
23
|
+
"rm",
|
|
24
|
+
"-rf",
|
|
25
|
+
"--",
|
|
26
|
+
str(path),
|
|
27
|
+
],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
34
|
+
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
message or f"wsl rm failed with exit code {result.returncode}"
|
|
37
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# __init__.py
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from dev_cleaner import __version__
|
|
8
|
+
from dev_cleaner.core.cleaner import Cleaner, CleanupReport
|
|
9
|
+
|
|
10
|
+
GREEN = "32"
|
|
11
|
+
RED = "31"
|
|
12
|
+
YELLOW = "33"
|
|
13
|
+
CYAN = "36"
|
|
14
|
+
DIM = "2"
|
|
15
|
+
|
|
16
|
+
LABEL_WIDTH = 12
|
|
17
|
+
SPINNER_FRAMES = "|/-\\"
|
|
18
|
+
SPINNER_INTERVAL = 0.1
|
|
19
|
+
SPINNER_DELAY = 0.2
|
|
20
|
+
STATUS_PROJECT_WIDTH = 20
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _enable_windows_ansi(stream: TextIO) -> None:
|
|
24
|
+
if os.name != "nt":
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import ctypes
|
|
29
|
+
|
|
30
|
+
kernel32 = ctypes.windll.kernel32
|
|
31
|
+
handle_id = -12 if stream is sys.stderr else -11
|
|
32
|
+
handle = kernel32.GetStdHandle(handle_id)
|
|
33
|
+
mode = ctypes.c_uint32()
|
|
34
|
+
|
|
35
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
36
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def supports_color(stream: TextIO) -> bool:
|
|
42
|
+
if os.environ.get("NO_COLOR"):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
if os.environ.get("TERM") == "dumb":
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
if not (hasattr(stream, "isatty") and stream.isatty()):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
_enable_windows_ansi(stream)
|
|
52
|
+
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def backend_label(backend: str, distribution: str | None) -> str:
|
|
57
|
+
if backend == "wsl":
|
|
58
|
+
return f"wsl:{distribution}"
|
|
59
|
+
|
|
60
|
+
return "native"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plural(count: int, word: str) -> str:
|
|
64
|
+
suffix = "" if count == 1 else "s"
|
|
65
|
+
|
|
66
|
+
return f"{count} {word}{suffix}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def supports_animation(stream: TextIO) -> bool:
|
|
70
|
+
if os.environ.get("TERM") == "dumb":
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_elapsed(seconds: float) -> str:
|
|
77
|
+
total = int(seconds)
|
|
78
|
+
minutes, secs = divmod(total, 60)
|
|
79
|
+
hours, minutes = divmod(minutes, 60)
|
|
80
|
+
|
|
81
|
+
if hours:
|
|
82
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
83
|
+
|
|
84
|
+
return f"{minutes}:{secs:02d}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def shorten(text: str, width: int = STATUS_PROJECT_WIDTH) -> str:
|
|
88
|
+
if len(text) <= width:
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
return text[: width - 3] + "..."
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Spinner:
|
|
95
|
+
"""Single-line spinner with an elapsed timer, rendered only on a TTY."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, stream: TextIO, enabled: bool):
|
|
98
|
+
self.stream = stream
|
|
99
|
+
self.enabled = enabled
|
|
100
|
+
self._stop = threading.Event()
|
|
101
|
+
self._thread: threading.Thread | None = None
|
|
102
|
+
self._status = ""
|
|
103
|
+
self._start = 0.0
|
|
104
|
+
self._width = 0
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> "Spinner":
|
|
107
|
+
self._start = time.monotonic()
|
|
108
|
+
|
|
109
|
+
if self.enabled:
|
|
110
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
111
|
+
self._thread.start()
|
|
112
|
+
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
116
|
+
self._stop.set()
|
|
117
|
+
|
|
118
|
+
if self._thread is not None:
|
|
119
|
+
self._thread.join()
|
|
120
|
+
|
|
121
|
+
if self._width:
|
|
122
|
+
self.stream.write("\r" + " " * self._width + "\r")
|
|
123
|
+
self.stream.flush()
|
|
124
|
+
|
|
125
|
+
def update(self, status: str) -> None:
|
|
126
|
+
self._status = status
|
|
127
|
+
|
|
128
|
+
def _spin(self) -> None:
|
|
129
|
+
index = 0
|
|
130
|
+
|
|
131
|
+
while not self._stop.wait(SPINNER_INTERVAL):
|
|
132
|
+
if time.monotonic() - self._start < SPINNER_DELAY:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
elapsed = format_elapsed(time.monotonic() - self._start)
|
|
136
|
+
frame = SPINNER_FRAMES[index % len(SPINNER_FRAMES)]
|
|
137
|
+
line = f"{frame} {self._status} {elapsed}"
|
|
138
|
+
|
|
139
|
+
self._width = max(self._width, len(line))
|
|
140
|
+
self.stream.write("\r" + line.ljust(self._width))
|
|
141
|
+
self.stream.flush()
|
|
142
|
+
index += 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class CLI:
|
|
146
|
+
def __init__(self, out: TextIO | None = None, err: TextIO | None = None):
|
|
147
|
+
self.out = out if out is not None else sys.stdout
|
|
148
|
+
self.err = err if err is not None else sys.stderr
|
|
149
|
+
self.out_color = supports_color(self.out)
|
|
150
|
+
self.err_color = supports_color(self.err)
|
|
151
|
+
|
|
152
|
+
def style(self, text: str, code: str, *, err: bool = False) -> str:
|
|
153
|
+
enabled = self.err_color if err else self.out_color
|
|
154
|
+
|
|
155
|
+
if not enabled:
|
|
156
|
+
return text
|
|
157
|
+
|
|
158
|
+
return f"\033[{code}m{text}\033[0m"
|
|
159
|
+
|
|
160
|
+
def show_header(
|
|
161
|
+
self,
|
|
162
|
+
workspace: str,
|
|
163
|
+
backend: str,
|
|
164
|
+
distribution: str | None,
|
|
165
|
+
) -> None:
|
|
166
|
+
version = self.style(f"dev-cleaner {__version__}", DIM)
|
|
167
|
+
label = backend_label(backend, distribution)
|
|
168
|
+
|
|
169
|
+
print(f"{version} | {label} | {workspace}", file=self.out)
|
|
170
|
+
|
|
171
|
+
def confirm(self, targets: list[str]) -> bool:
|
|
172
|
+
names = ", ".join(targets)
|
|
173
|
+
|
|
174
|
+
self.out.write(f"Remove {names} from every project? [y/N] ")
|
|
175
|
+
self.out.flush()
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
answer = sys.stdin.readline()
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
self.out.write("\n")
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
if not answer:
|
|
184
|
+
self.out.write("\n")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
return answer.strip().lower() in {"y", "yes"}
|
|
188
|
+
|
|
189
|
+
def show_results(self, report: CleanupReport, dry_run: bool = False) -> None:
|
|
190
|
+
for result in report.results:
|
|
191
|
+
path = f"{result.project}/{result.target}"
|
|
192
|
+
|
|
193
|
+
if not result.success:
|
|
194
|
+
label = self.style(f"{'failed':<{LABEL_WIDTH}}", RED, err=True)
|
|
195
|
+
print(f"{label} {path} {result.error}", file=self.err)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
if dry_run:
|
|
199
|
+
label = self.style(f"{'would remove':<{LABEL_WIDTH}}", CYAN)
|
|
200
|
+
else:
|
|
201
|
+
label = self.style(f"{'removed':<{LABEL_WIDTH}}", GREEN)
|
|
202
|
+
|
|
203
|
+
print(f"{label} {path}", file=self.out)
|
|
204
|
+
|
|
205
|
+
def show_summary(
|
|
206
|
+
self,
|
|
207
|
+
report: CleanupReport,
|
|
208
|
+
dry_run: bool = False,
|
|
209
|
+
elapsed: float | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
projects = plural(report.projects_scanned, "project")
|
|
212
|
+
removed = len(report.removed)
|
|
213
|
+
failed = len(report.failed)
|
|
214
|
+
|
|
215
|
+
if dry_run:
|
|
216
|
+
summary = f"{removed} matched ({projects}) [dry run]"
|
|
217
|
+
elif failed:
|
|
218
|
+
summary = f"{removed} removed, {failed} failed ({projects})"
|
|
219
|
+
else:
|
|
220
|
+
summary = f"{removed} removed ({projects})"
|
|
221
|
+
|
|
222
|
+
if elapsed is not None and not dry_run and elapsed >= 1.0:
|
|
223
|
+
summary += f" in {format_elapsed(elapsed)}"
|
|
224
|
+
|
|
225
|
+
code = YELLOW if failed else GREEN
|
|
226
|
+
|
|
227
|
+
print(self.style(summary, code), file=self.out)
|
|
228
|
+
|
|
229
|
+
def run(
|
|
230
|
+
self,
|
|
231
|
+
cleaner: Cleaner,
|
|
232
|
+
workspace: str,
|
|
233
|
+
backend: str,
|
|
234
|
+
distribution: str | None = None,
|
|
235
|
+
assume_yes: bool = False,
|
|
236
|
+
) -> int:
|
|
237
|
+
self.show_header(workspace, backend, distribution)
|
|
238
|
+
print(file=self.out)
|
|
239
|
+
|
|
240
|
+
if not cleaner.dry_run and not assume_yes:
|
|
241
|
+
if not self.confirm(cleaner.target_folders):
|
|
242
|
+
print(self.style("cancelled", YELLOW), file=self.out)
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
print(file=self.out)
|
|
246
|
+
|
|
247
|
+
action = "scanning" if cleaner.dry_run else "cleaning"
|
|
248
|
+
start = time.monotonic()
|
|
249
|
+
|
|
250
|
+
with Spinner(
|
|
251
|
+
self.out,
|
|
252
|
+
enabled=supports_animation(self.out),
|
|
253
|
+
) as spinner:
|
|
254
|
+
spinner.update(f"{action}...")
|
|
255
|
+
|
|
256
|
+
def on_target(current: int, total: int, project, target: str) -> None:
|
|
257
|
+
name = f"{shorten(project.name)}/{target}"
|
|
258
|
+
spinner.update(f"{action} {name} ({current}/{total})")
|
|
259
|
+
|
|
260
|
+
report = cleaner.run(on_target=on_target)
|
|
261
|
+
|
|
262
|
+
elapsed = time.monotonic() - start
|
|
263
|
+
|
|
264
|
+
if not report.results:
|
|
265
|
+
print("no matching directories", file=self.out)
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
self.show_results(report, dry_run=cleaner.dry_run)
|
|
269
|
+
print(file=self.out)
|
|
270
|
+
self.show_summary(report, dry_run=cleaner.dry_run, elapsed=elapsed)
|
|
271
|
+
|
|
272
|
+
return 1 if report.failed else 0
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from dev_cleaner import __version__
|
|
4
|
+
|
|
5
|
+
DEFAULT_TARGETS = [
|
|
6
|
+
"node_modules",
|
|
7
|
+
".next",
|
|
8
|
+
"dist",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="dev-cleaner",
|
|
15
|
+
description="Remove generated directories from every project in a workspace.",
|
|
16
|
+
epilog=(
|
|
17
|
+
"examples:\n"
|
|
18
|
+
" python -m src.main -w C:/Users/me/workspace\n"
|
|
19
|
+
" python -m src.main -w /home/me/workspace -n\n"
|
|
20
|
+
" python -m src.main -w /home/me/workspace -t node_modules -t .venv -y\n"
|
|
21
|
+
),
|
|
22
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-w",
|
|
27
|
+
"--workspace",
|
|
28
|
+
required=True,
|
|
29
|
+
metavar="PATH",
|
|
30
|
+
help="Workspace directory to scan.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"-t",
|
|
35
|
+
"--target",
|
|
36
|
+
action="append",
|
|
37
|
+
dest="targets",
|
|
38
|
+
metavar="DIR",
|
|
39
|
+
help=(
|
|
40
|
+
"Directory to remove. Repeatable. "
|
|
41
|
+
f"Defaults to: {', '.join(DEFAULT_TARGETS)}."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-b",
|
|
47
|
+
"--backend",
|
|
48
|
+
type=str.lower,
|
|
49
|
+
choices=["native", "wsl"],
|
|
50
|
+
metavar="NAME",
|
|
51
|
+
help="Force native or wsl. Defaults to auto-detection.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-d",
|
|
56
|
+
"--distribution",
|
|
57
|
+
metavar="DISTRO",
|
|
58
|
+
help="WSL distribution. Defaults to the detected default, then Ubuntu.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-n",
|
|
63
|
+
"--dry-run",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Show what would be removed without deleting anything.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"-y",
|
|
70
|
+
"--yes",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Do not ask for confirmation.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--version",
|
|
77
|
+
action="version",
|
|
78
|
+
version=f"%(prog)s {__version__}",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return parser
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# __init__.py
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
from dev_cleaner.cleaners.base import FileSystemCleaner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_link(path: Path) -> bool:
|
|
10
|
+
"""True for symlinks and, on Windows, directory junctions."""
|
|
11
|
+
|
|
12
|
+
if path.is_symlink():
|
|
13
|
+
return True
|
|
14
|
+
|
|
15
|
+
is_junction = getattr(os.path, "isjunction", None)
|
|
16
|
+
|
|
17
|
+
return bool(is_junction and is_junction(path))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CleanupResult:
|
|
22
|
+
project: str
|
|
23
|
+
target: str
|
|
24
|
+
path: Path | PurePosixPath
|
|
25
|
+
success: bool
|
|
26
|
+
error: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CleanupReport:
|
|
31
|
+
projects_scanned: int = 0
|
|
32
|
+
results: list[CleanupResult] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def removed(self) -> list[CleanupResult]:
|
|
36
|
+
return [result for result in self.results if result.success]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def failed(self) -> list[CleanupResult]:
|
|
40
|
+
return [result for result in self.results if not result.success]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Cleaner:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
workspace_path: Path,
|
|
47
|
+
target_folders: list[str],
|
|
48
|
+
filesystem: FileSystemCleaner,
|
|
49
|
+
wsl_workspace_path: str | None = None,
|
|
50
|
+
dry_run: bool = False,
|
|
51
|
+
):
|
|
52
|
+
self.workspace_path = workspace_path
|
|
53
|
+
self.target_folders = target_folders
|
|
54
|
+
self.filesystem = filesystem
|
|
55
|
+
self.wsl_workspace_path = wsl_workspace_path
|
|
56
|
+
self.dry_run = dry_run
|
|
57
|
+
|
|
58
|
+
self._validate_workspace()
|
|
59
|
+
self._validate_targets()
|
|
60
|
+
|
|
61
|
+
def _validate_workspace(self) -> None:
|
|
62
|
+
anchor = self.workspace_path.anchor
|
|
63
|
+
|
|
64
|
+
if anchor and self.workspace_path == Path(anchor):
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Refusing to scan a filesystem root: {self.workspace_path}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not self.workspace_path.is_dir():
|
|
70
|
+
raise NotADirectoryError(f"Workspace not found: {self.workspace_path}")
|
|
71
|
+
|
|
72
|
+
def _validate_targets(self) -> None:
|
|
73
|
+
for target in self.target_folders:
|
|
74
|
+
target_path = Path(target)
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
target_path.is_absolute()
|
|
78
|
+
or len(target_path.parts) != 1
|
|
79
|
+
or target in {".", ".."}
|
|
80
|
+
):
|
|
81
|
+
raise ValueError(f"Invalid target directory: {target}")
|
|
82
|
+
|
|
83
|
+
def _removal_path(self, target_path: Path) -> Path | PurePosixPath:
|
|
84
|
+
if self.wsl_workspace_path is None:
|
|
85
|
+
return target_path
|
|
86
|
+
|
|
87
|
+
relative_path = target_path.relative_to(self.workspace_path)
|
|
88
|
+
|
|
89
|
+
return PurePosixPath(self.wsl_workspace_path, *relative_path.parts)
|
|
90
|
+
|
|
91
|
+
def get_projects(self) -> list[Path]:
|
|
92
|
+
if not self.workspace_path.is_dir():
|
|
93
|
+
raise NotADirectoryError(f"Workspace not found: {self.workspace_path}")
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
item
|
|
97
|
+
for item in self.workspace_path.iterdir()
|
|
98
|
+
if item.is_dir() and not is_link(item)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def clean_project(
|
|
102
|
+
self,
|
|
103
|
+
project: Path,
|
|
104
|
+
on_target: Callable[[Path, str], None] | None = None,
|
|
105
|
+
) -> list[CleanupResult]:
|
|
106
|
+
results = []
|
|
107
|
+
|
|
108
|
+
for target in self.target_folders:
|
|
109
|
+
target_path = project / target
|
|
110
|
+
|
|
111
|
+
if is_link(target_path) or not target_path.is_dir():
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if on_target:
|
|
115
|
+
on_target(project, target)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
removal_path = self._removal_path(target_path)
|
|
119
|
+
|
|
120
|
+
if not self.dry_run:
|
|
121
|
+
self.filesystem.remove(removal_path)
|
|
122
|
+
|
|
123
|
+
results.append(
|
|
124
|
+
CleanupResult(
|
|
125
|
+
project=project.name,
|
|
126
|
+
target=target,
|
|
127
|
+
path=removal_path,
|
|
128
|
+
success=True,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
except Exception as error:
|
|
133
|
+
results.append(
|
|
134
|
+
CleanupResult(
|
|
135
|
+
project=project.name,
|
|
136
|
+
target=target,
|
|
137
|
+
path=target_path,
|
|
138
|
+
success=False,
|
|
139
|
+
error=str(error),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
def run(
|
|
146
|
+
self,
|
|
147
|
+
on_progress: Callable[[int, int, Path], None] | None = None,
|
|
148
|
+
on_target: Callable[[int, int, Path, str], None] | None = None,
|
|
149
|
+
) -> CleanupReport:
|
|
150
|
+
projects = self.get_projects()
|
|
151
|
+
|
|
152
|
+
report = CleanupReport(
|
|
153
|
+
projects_scanned=len(projects),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
for index, project in enumerate(projects, start=1):
|
|
157
|
+
|
|
158
|
+
def report_target(
|
|
159
|
+
target_project: Path,
|
|
160
|
+
target: str,
|
|
161
|
+
index: int = index,
|
|
162
|
+
) -> None:
|
|
163
|
+
if on_target is not None:
|
|
164
|
+
on_target(index, len(projects), target_project, target)
|
|
165
|
+
|
|
166
|
+
report.results.extend(self.clean_project(project, on_target=report_target))
|
|
167
|
+
|
|
168
|
+
if on_progress:
|
|
169
|
+
on_progress(index, len(projects), project)
|
|
170
|
+
|
|
171
|
+
return report
|
dev_cleaner/main.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
from dev_cleaner.cleaners.base import FileSystemCleaner
|
|
7
|
+
from dev_cleaner.cleaners.native import NativeCleaner
|
|
8
|
+
from dev_cleaner.cleaners.wsl import WslCleaner
|
|
9
|
+
from dev_cleaner.cli.interface import CLI
|
|
10
|
+
from dev_cleaner.cli.parser import DEFAULT_TARGETS, create_parser
|
|
11
|
+
from dev_cleaner.core.cleaner import Cleaner
|
|
12
|
+
|
|
13
|
+
DEFAULT_DISTRIBUTION = "Ubuntu"
|
|
14
|
+
WSL_UNC_HOST = "wsl.localhost"
|
|
15
|
+
WSL_UNC_HOSTS = {WSL_UNC_HOST, "wsl$"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_wsl_unc_path(workspace: str) -> bool:
|
|
19
|
+
parts = PurePosixPath(workspace.replace("\\", "/")).parts
|
|
20
|
+
|
|
21
|
+
return len(parts) >= 3 and parts[0] == "//" and parts[1].lower() in WSL_UNC_HOSTS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def split_wsl_unc_path(workspace: str) -> tuple[str, PurePosixPath] | None:
|
|
25
|
+
if not is_wsl_unc_path(workspace):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
parts = PurePosixPath(workspace.replace("\\", "/")).parts
|
|
29
|
+
|
|
30
|
+
if len(parts) < 4:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"Workspace must point to a directory inside the WSL distribution, "
|
|
33
|
+
"not to the distribution root."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
distribution = parts[2]
|
|
37
|
+
linux_path = PurePosixPath("/", *parts[3:])
|
|
38
|
+
|
|
39
|
+
return distribution, linux_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_backend(workspace: str) -> str:
|
|
43
|
+
"""Pick a backend from the shape of the workspace path.
|
|
44
|
+
|
|
45
|
+
WSL is only considered when running on Windows, because Linux already
|
|
46
|
+
sees WSL paths natively and has no ``wsl`` command to delegate to.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if os.name != "nt":
|
|
50
|
+
return "native"
|
|
51
|
+
|
|
52
|
+
if is_wsl_unc_path(workspace):
|
|
53
|
+
return "wsl"
|
|
54
|
+
|
|
55
|
+
# A POSIX path such as /home/user/workspace is not a valid Windows path,
|
|
56
|
+
# so it must refer to a filesystem inside WSL.
|
|
57
|
+
if PurePosixPath(workspace).is_absolute() and not Path(workspace).is_absolute():
|
|
58
|
+
return "wsl"
|
|
59
|
+
|
|
60
|
+
return "native"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_wsl_output(data: bytes) -> str:
|
|
64
|
+
# wsl.exe writes UTF-16LE to pipes, which looks like ASCII with NUL bytes.
|
|
65
|
+
if b"\x00" in data:
|
|
66
|
+
text = data.decode("utf-16-le", errors="replace")
|
|
67
|
+
else:
|
|
68
|
+
text = data.decode("utf-8", errors="replace")
|
|
69
|
+
|
|
70
|
+
return text.replace("\ufeff", "").replace("\x00", "")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect_default_distribution() -> str | None:
|
|
74
|
+
"""Return the default WSL distribution, or None when WSL is unavailable."""
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["wsl", "-l", "-q"],
|
|
79
|
+
capture_output=True,
|
|
80
|
+
timeout=15,
|
|
81
|
+
)
|
|
82
|
+
except (OSError, subprocess.SubprocessError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
if result.returncode != 0:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
for line in decode_wsl_output(result.stdout).splitlines():
|
|
89
|
+
name = line.strip()
|
|
90
|
+
|
|
91
|
+
if name:
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_cleaner(backend: str, distribution: str) -> FileSystemCleaner:
|
|
98
|
+
if backend == "native":
|
|
99
|
+
return NativeCleaner()
|
|
100
|
+
|
|
101
|
+
return WslCleaner(distribution)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_workspace(
|
|
105
|
+
workspace: str,
|
|
106
|
+
backend: str,
|
|
107
|
+
distribution: str | None,
|
|
108
|
+
) -> tuple[Path, str | None, str]:
|
|
109
|
+
"""Return the path to scan, the Linux path for WSL, and the distribution."""
|
|
110
|
+
|
|
111
|
+
if backend == "native":
|
|
112
|
+
return (
|
|
113
|
+
Path(workspace).expanduser(),
|
|
114
|
+
None,
|
|
115
|
+
distribution or DEFAULT_DISTRIBUTION,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
unc = split_wsl_unc_path(workspace)
|
|
119
|
+
|
|
120
|
+
if unc is not None:
|
|
121
|
+
unc_distribution, linux_path = unc
|
|
122
|
+
distribution = distribution or unc_distribution
|
|
123
|
+
else:
|
|
124
|
+
linux_path = PurePosixPath(workspace)
|
|
125
|
+
|
|
126
|
+
if not linux_path.is_absolute():
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"WSL workspace must be an absolute Linux path, "
|
|
129
|
+
"for example /home/user/workspace."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if ".." in linux_path.parts:
|
|
133
|
+
raise ValueError("WSL workspace cannot contain '..'.")
|
|
134
|
+
|
|
135
|
+
distribution = distribution or detect_default_distribution() or DEFAULT_DISTRIBUTION
|
|
136
|
+
|
|
137
|
+
scan_path = Path(f"//{WSL_UNC_HOST}/{distribution}{linux_path.as_posix()}")
|
|
138
|
+
|
|
139
|
+
return scan_path, linux_path.as_posix(), distribution
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> int:
|
|
143
|
+
args = create_parser().parse_args()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
backend = args.backend or detect_backend(args.workspace)
|
|
147
|
+
|
|
148
|
+
workspace_path, wsl_workspace_path, distribution = resolve_workspace(
|
|
149
|
+
workspace=args.workspace,
|
|
150
|
+
backend=backend,
|
|
151
|
+
distribution=args.distribution,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
cleaner = Cleaner(
|
|
155
|
+
workspace_path=workspace_path,
|
|
156
|
+
target_folders=args.targets or DEFAULT_TARGETS,
|
|
157
|
+
filesystem=create_cleaner(backend, distribution),
|
|
158
|
+
wsl_workspace_path=wsl_workspace_path,
|
|
159
|
+
dry_run=args.dry_run,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return CLI().run(
|
|
163
|
+
cleaner=cleaner,
|
|
164
|
+
workspace=args.workspace,
|
|
165
|
+
backend=backend,
|
|
166
|
+
distribution=distribution if backend == "wsl" else None,
|
|
167
|
+
assume_yes=args.yes,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
print("\nCancelled.", file=sys.stderr)
|
|
172
|
+
return 130
|
|
173
|
+
|
|
174
|
+
except (NotADirectoryError, ValueError, OSError) as error:
|
|
175
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devcleaner-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Remove generated directories such as node_modules, .next, and dist from every project in a workspace.
|
|
5
|
+
Project-URL: Homepage, https://github.com/adonesbalica/dev-cleaner
|
|
6
|
+
Project-URL: Repository, https://github.com/adonesbalica/dev-cleaner
|
|
7
|
+
Project-URL: Issues, https://github.com/adonesbalica/dev-cleaner/issues
|
|
8
|
+
Author-email: Adones Balica <adonesbalica@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cleanup,cli,developer-tools,disk-space,node-modules,wsl
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
21
|
+
Classifier: Topic :: System :: Filesystems
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.17; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# dev-cleaner
|
|
31
|
+
|
|
32
|
+
A lightweight, cross-platform CLI tool to clean unnecessary directories from development projects.
|
|
33
|
+
|
|
34
|
+
`dev-cleaner` helps developers keep their workspaces clean by automatically detecting projects and removing generated or dependency directories such as `node_modules`, `.next`, and `dist`.
|
|
35
|
+
|
|
36
|
+
## Table of contents
|
|
37
|
+
|
|
38
|
+
- [Features](#features)
|
|
39
|
+
- [Supported environments](#supported-environments)
|
|
40
|
+
- [Requirements](#requirements)
|
|
41
|
+
- [Installation](#installation)
|
|
42
|
+
- [Tutorial](#tutorial)
|
|
43
|
+
- [Usage](#usage)
|
|
44
|
+
- [Development](#development)
|
|
45
|
+
- [Project structure](#project-structure)
|
|
46
|
+
- [Architecture](#architecture)
|
|
47
|
+
- [Safety](#safety)
|
|
48
|
+
- [Troubleshooting](#troubleshooting)
|
|
49
|
+
- [License](#license)
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Cross-platform:** runs natively on Windows, Linux, and inside WSL.
|
|
54
|
+
- **Automatic backend selection:** the right filesystem implementation is picked from the workspace path.
|
|
55
|
+
- **Windows → WSL support:** clean Linux filesystems from Windows without leaving the terminal.
|
|
56
|
+
- **Configurable targets:** defaults to `node_modules`, `.next`, and `dist`, and accepts custom targets.
|
|
57
|
+
- **Safe by default:** asks for confirmation, supports `--dry-run`, skips symlinks and junctions, and refuses to scan a filesystem root.
|
|
58
|
+
- **Per-operation reporting:** failures are reported per project instead of aborting the whole run.
|
|
59
|
+
- **Live progress:** a spinner and elapsed timer show that a long cleanup is still running.
|
|
60
|
+
- **Installable:** published on PyPI as `devcleaner-cli` and exposes the `dev-cleaner` command.
|
|
61
|
+
- **No dependencies:** the CLI uses only the Python standard library.
|
|
62
|
+
|
|
63
|
+
## Supported environments
|
|
64
|
+
|
|
65
|
+
| Where you run it | Workspace path | Backend |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Windows | `C:\Users\me\workspace` | `native` (Windows filesystem) |
|
|
68
|
+
| Windows | `/home/me/workspace` | `wsl` (default distribution) |
|
|
69
|
+
| Windows | `//wsl.localhost/Ubuntu/home/me/workspace` | `wsl` (distribution taken from the path) |
|
|
70
|
+
| Linux | `/home/me/workspace` | `native` (Linux filesystem) |
|
|
71
|
+
| Inside WSL | `/home/me/workspace` or `/mnt/c/...` | `native` (Linux filesystem) |
|
|
72
|
+
|
|
73
|
+
The backend is detected automatically. Use `--backend` only to override the detection.
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
- Python 3.12 or higher.
|
|
78
|
+
- WSL 2 with a Linux distribution such as Ubuntu (only when cleaning a Linux filesystem from Windows).
|
|
79
|
+
|
|
80
|
+
No third-party packages are required.
|
|
81
|
+
|
|
82
|
+
## Installation
|
|
83
|
+
|
|
84
|
+
The package is published on PyPI as [`devcleaner-cli`](https://pypi.org/project/devcleaner-cli/) and installs the `dev-cleaner` command.
|
|
85
|
+
|
|
86
|
+
**pipx (recommended for CLI tools):**
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pipx install devcleaner-cli
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**uv:**
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv tool install devcleaner-cli
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**pip:**
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m pip install --user devcleaner-cli
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
On Windows, install pipx with `py -m pip install --user pipx` and run `py -m pipx ensurepath`, then reopen the terminal.
|
|
105
|
+
|
|
106
|
+
**From source:**
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git clone https://github.com/adonesbalica/dev-cleaner.git
|
|
110
|
+
cd dev-cleaner
|
|
111
|
+
pipx install .
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
After installing, `dev-cleaner` works from any directory:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
dev-cleaner --version
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If the command is not found, reopen the terminal so the updated `PATH` is loaded, or run `python -m dev_cleaner`. The package has no runtime dependencies.
|
|
121
|
+
|
|
122
|
+
## Tutorial
|
|
123
|
+
|
|
124
|
+
This walkthrough creates a throwaway workspace, so nothing real is deleted. It takes about two minutes.
|
|
125
|
+
|
|
126
|
+
### 1. Create a practice workspace
|
|
127
|
+
|
|
128
|
+
**Windows (PowerShell):**
|
|
129
|
+
|
|
130
|
+
```powershell
|
|
131
|
+
$demo = Join-Path $env:TEMP "dev-cleaner-demo"
|
|
132
|
+
New-Item -ItemType Directory -Force "$demo\app-a\node_modules\react" | Out-Null
|
|
133
|
+
New-Item -ItemType Directory -Force "$demo\app-a\dist" | Out-Null
|
|
134
|
+
New-Item -ItemType Directory -Force "$demo\app-b\.next" | Out-Null
|
|
135
|
+
New-Item -ItemType Directory -Force "$demo\app-c\src" | Out-Null
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Linux or WSL:**
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
demo="$(mktemp -d)"
|
|
142
|
+
mkdir -p "$demo/app-a/node_modules/react" "$demo/app-a/dist"
|
|
143
|
+
mkdir -p "$demo/app-b/.next" "$demo/app-c/src"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
You now have three projects: `app-a` and `app-b` contain generated directories, while `app-c` contains only source code.
|
|
147
|
+
|
|
148
|
+
### 2. Preview the cleanup with a dry run
|
|
149
|
+
|
|
150
|
+
**Windows:**
|
|
151
|
+
|
|
152
|
+
```powershell
|
|
153
|
+
dev-cleaner -w $demo -n
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Linux or WSL:**
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
dev-cleaner -w "$demo" -n
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Expected output:
|
|
163
|
+
|
|
164
|
+
```text
|
|
165
|
+
dev-cleaner 1.0.0 | native | <workspace>
|
|
166
|
+
|
|
167
|
+
would remove app-a/node_modules
|
|
168
|
+
would remove app-a/dist
|
|
169
|
+
would remove app-b/.next
|
|
170
|
+
|
|
171
|
+
3 matched (3 projects) [dry run]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The first line shows the tool version, the selected backend, and the workspace you passed. Nothing has been deleted; the `[dry run]` marker confirms it. The order of the lines depends on the filesystem; the counts are what matter.
|
|
175
|
+
|
|
176
|
+
### 3. Run the cleanup for real
|
|
177
|
+
|
|
178
|
+
Drop `-n` and add `-y` to skip the confirmation prompt:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
dev-cleaner -w "$demo" -y
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Expected output:
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
dev-cleaner 1.0.0 | native | <workspace>
|
|
188
|
+
|
|
189
|
+
removed app-a/node_modules
|
|
190
|
+
removed app-a/dist
|
|
191
|
+
removed app-b/.next
|
|
192
|
+
|
|
193
|
+
3 removed (3 projects)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
On a real terminal, a single-line spinner and timer appear while the directories are removed, so a slow cleanup never looks frozen:
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
| cleaning app-a/node_modules (1/3) 0:04
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The line is erased when the run finishes, and the summary appends the total time when it took at least one second (for example `3 removed (3 projects) in 0:04`). When the output is piped or redirected, the spinner is skipped and only the final lines are written, which keeps the output easy to parse.
|
|
203
|
+
|
|
204
|
+
Without `-y`, the tool asks first:
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
Remove node_modules, .next, dist from every project? [y/N]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Press `y` and Enter to continue, or Enter to cancel. The default is always **No**.
|
|
211
|
+
|
|
212
|
+
### 4. Verify the result
|
|
213
|
+
|
|
214
|
+
**Windows:**
|
|
215
|
+
|
|
216
|
+
```powershell
|
|
217
|
+
Get-ChildItem -Directory -Recurse $demo | Select-Object -ExpandProperty FullName
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Linux or WSL:**
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
find "$demo" -type d
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Only `app-a`, `app-b`, and `app-c` (plus `app-c/src`) should remain. The generated directories are gone.
|
|
227
|
+
|
|
228
|
+
### 5. Remove the practice workspace
|
|
229
|
+
|
|
230
|
+
**Windows:**
|
|
231
|
+
|
|
232
|
+
```powershell
|
|
233
|
+
Remove-Item $demo -Recurse -Force
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Linux or WSL:**
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
rm -rf "$demo"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Tutorial: clean a WSL workspace from Windows
|
|
243
|
+
|
|
244
|
+
Use the Linux path of the workspace. The tool detects that it is not a Windows path and switches to the WSL backend automatically.
|
|
245
|
+
|
|
246
|
+
```powershell
|
|
247
|
+
# See which distributions are available
|
|
248
|
+
wsl -l -q
|
|
249
|
+
|
|
250
|
+
# Preview the cleanup
|
|
251
|
+
dev-cleaner -w /home/me/workspace -n
|
|
252
|
+
|
|
253
|
+
# Use a specific distribution if the default is not the right one
|
|
254
|
+
dev-cleaner -w /home/me/workspace -d Ubuntu -n
|
|
255
|
+
|
|
256
|
+
# Run it
|
|
257
|
+
dev-cleaner -w /home/me/workspace -y
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The header shows which backend and distribution were selected:
|
|
261
|
+
|
|
262
|
+
```text
|
|
263
|
+
dev-cleaner 1.0.0 | wsl:Ubuntu | /home/me/workspace
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
You can also pass a Windows UNC path such as `//wsl.localhost/Ubuntu/home/me/workspace`; the distribution is read from the path.
|
|
267
|
+
|
|
268
|
+
### Tutorial: use custom targets
|
|
269
|
+
|
|
270
|
+
Passing `-t` **replaces** the default target list, so list every directory you want to remove:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
dev-cleaner -w /home/me/workspace -t node_modules -t .venv -t target -n
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
This removes only `node_modules`, `.venv`, and `target`; it does not also remove `.next` or `dist`.
|
|
277
|
+
|
|
278
|
+
### Tutorial: use it in a script
|
|
279
|
+
|
|
280
|
+
Use `-y` so the command never waits for input, and check the exit code:
|
|
281
|
+
|
|
282
|
+
**Bash:**
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
dev-cleaner -w /home/me/workspace -y
|
|
286
|
+
if [ $? -ne 0 ]; then
|
|
287
|
+
echo "cleanup failed" >&2
|
|
288
|
+
fi
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**PowerShell:**
|
|
292
|
+
|
|
293
|
+
```powershell
|
|
294
|
+
dev-cleaner -w C:/Users/me/workspace -y
|
|
295
|
+
if ($LASTEXITCODE -ne 0) {
|
|
296
|
+
Write-Error "cleanup failed"
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Usage
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
dev-cleaner -w <workspace> [options]
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Options
|
|
307
|
+
|
|
308
|
+
| Option | Description |
|
|
309
|
+
|---|---|
|
|
310
|
+
| `-w`, `--workspace` | Workspace directory to scan. Required. |
|
|
311
|
+
| `-t`, `--target` | Directory to remove. Repeatable; replaces the defaults. Defaults to `node_modules`, `.next`, `dist`. |
|
|
312
|
+
| `-b`, `--backend` | Force `native` or `wsl`. Auto-detected by default. |
|
|
313
|
+
| `-d`, `--distribution` | WSL distribution to use. Defaults to the WSL default distribution, or `Ubuntu`. |
|
|
314
|
+
| `-n`, `--dry-run` | Show what would be removed without deleting anything. |
|
|
315
|
+
| `-y`, `--yes` | Do not ask for confirmation. |
|
|
316
|
+
| `--version` | Show the version and exit. |
|
|
317
|
+
|
|
318
|
+
### Progress indicator
|
|
319
|
+
|
|
320
|
+
While scanning or deleting, the CLI shows one animated line with the directory being removed and an elapsed timer:
|
|
321
|
+
|
|
322
|
+
```text
|
|
323
|
+
/ cleaning app-a/node_modules (2/7) 0:12
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
- The spinner uses plain ASCII frames (`|`, `/`, `-`, `\`), so it works in any terminal.
|
|
327
|
+
- The status updates before **each directory** is removed, so the name changes even when a single project takes a long time.
|
|
328
|
+
- The timer uses `m:ss`, switching to `h:mm:ss` after one hour.
|
|
329
|
+
- The line is cleared before the per-directory results are printed.
|
|
330
|
+
- The summary appends `in m:ss` when the run took at least one second.
|
|
331
|
+
- The spinner is **TTY-only**. Piped or redirected output never contains the animation, so logs and scripts stay clean.
|
|
332
|
+
|
|
333
|
+
### Exit codes
|
|
334
|
+
|
|
335
|
+
| Code | Meaning |
|
|
336
|
+
|---|---|
|
|
337
|
+
| `0` | Success, including "nothing to remove" and a cancelled prompt. |
|
|
338
|
+
| `1` | Error: invalid workspace, invalid target, failed removal, or WSL unavailable. |
|
|
339
|
+
| `2` | Invalid command-line arguments. |
|
|
340
|
+
| `130` | Interrupted with Ctrl+C. |
|
|
341
|
+
|
|
342
|
+
### How it works
|
|
343
|
+
|
|
344
|
+
1. Detects the backend from the workspace path (unless `--backend` is given).
|
|
345
|
+
2. Maps the workspace to a scan path and, for WSL, to the equivalent Linux path.
|
|
346
|
+
3. Lists the direct children of the workspace as projects.
|
|
347
|
+
4. Checks each project for the configured target directories.
|
|
348
|
+
5. Removes matching directories using the selected backend, reporting every operation.
|
|
349
|
+
|
|
350
|
+
Only direct children of the workspace are treated as projects, and only direct children of each project are removed. Symlinked projects and targets, as well as Windows directory junctions, are skipped.
|
|
351
|
+
|
|
352
|
+
## Development
|
|
353
|
+
|
|
354
|
+
Clone the repository and create a virtual environment:
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
git clone https://github.com/adonesbalica/dev-cleaner.git
|
|
358
|
+
cd dev-cleaner
|
|
359
|
+
python -m venv .venv
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Activate it:
|
|
363
|
+
|
|
364
|
+
**Windows (PowerShell):**
|
|
365
|
+
|
|
366
|
+
```powershell
|
|
367
|
+
.venv\Scripts\Activate.ps1
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Linux / WSL:**
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
source .venv/bin/activate
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Install the package in editable mode with the development tools:
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
python -m pip install -e ".[dev]"
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Run the CLI without the console script:
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
python -m dev_cleaner --help
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Run the checks and build the distribution:
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
ruff check src
|
|
392
|
+
ruff format --check src
|
|
393
|
+
python -m build
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The build writes `dist/devcleaner_cli-<version>-py3-none-any.whl` and a source archive. See [RELEASING.md](https://github.com/adonesbalica/dev-cleaner/blob/main/RELEASING.md) for the publish steps.
|
|
397
|
+
|
|
398
|
+
## Project structure
|
|
399
|
+
|
|
400
|
+
```text
|
|
401
|
+
dev-cleaner/
|
|
402
|
+
├── src/
|
|
403
|
+
│ └── dev_cleaner/
|
|
404
|
+
│ ├── __init__.py
|
|
405
|
+
│ ├── __main__.py
|
|
406
|
+
│ ├── main.py
|
|
407
|
+
│ ├── core/
|
|
408
|
+
│ │ ├── __init__.py
|
|
409
|
+
│ │ └── cleaner.py
|
|
410
|
+
│ ├── cleaners/
|
|
411
|
+
│ │ ├── __init__.py
|
|
412
|
+
│ │ ├── base.py
|
|
413
|
+
│ │ ├── native.py
|
|
414
|
+
│ │ └── wsl.py
|
|
415
|
+
│ └── cli/
|
|
416
|
+
│ ├── __init__.py
|
|
417
|
+
│ ├── interface.py
|
|
418
|
+
│ └── parser.py
|
|
419
|
+
├── pyproject.toml
|
|
420
|
+
├── README.md
|
|
421
|
+
├── RELEASING.md
|
|
422
|
+
├── LICENSE
|
|
423
|
+
└── .gitignore
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
## Architecture
|
|
427
|
+
|
|
428
|
+
The project uses a strategy-based architecture to separate cleanup orchestration from filesystem operations.
|
|
429
|
+
|
|
430
|
+
- **`FileSystemCleaner`:** defines the contract for filesystem cleanup implementations.
|
|
431
|
+
- **`NativeCleaner`:** uses `shutil.rmtree` on the filesystem the application runs on (Windows or Linux), including retries for read-only files.
|
|
432
|
+
- **`WslCleaner`:** runs `wsl -d <distribution> -- rm -rf` to remove directories inside a Linux filesystem from Windows.
|
|
433
|
+
- **`Cleaner`:** handles workspace scanning, project detection, WSL path mapping, dry runs, and cleanup orchestration.
|
|
434
|
+
- **`main.py`:** detects the backend, resolves the workspace paths, and wires everything together.
|
|
435
|
+
- **`cli/`:** parses arguments, shows the TTY-only spinner and timer, and prints plain-text results, a summary, and the confirmation prompt. No third-party dependencies.
|
|
436
|
+
|
|
437
|
+
This design allows new filesystem cleanup implementations to be added without changing the core cleanup logic.
|
|
438
|
+
|
|
439
|
+
## Safety
|
|
440
|
+
|
|
441
|
+
Directory removal is permanent.
|
|
442
|
+
|
|
443
|
+
Before running the application, make sure that:
|
|
444
|
+
|
|
445
|
+
- The workspace path is correct.
|
|
446
|
+
- The target directories are the ones you intend to remove.
|
|
447
|
+
- The selected backend matches your environment.
|
|
448
|
+
|
|
449
|
+
Use `--dry-run` first to review what will be removed. Symlinked projects and targets are skipped, and the tool refuses to scan a filesystem root. Only direct children of the workspace are considered, so pointing `-w` at a folder that contains projects is safer than pointing it at a single project.
|
|
450
|
+
|
|
451
|
+
## Troubleshooting
|
|
452
|
+
|
|
453
|
+
| Problem | Cause and fix |
|
|
454
|
+
|---|---|
|
|
455
|
+
| `Error: Workspace not found: ...` | The path is wrong, or the WSL distribution is not running. Check the path and run `wsl -l -q` to see available distributions. |
|
|
456
|
+
| `Error: The wsl backend requires Windows with WSL installed.` | You forced `-b wsl` on Linux. Remove `-b wsl`; Linux paths already work with the native backend. |
|
|
457
|
+
| `Error: Refusing to scan a filesystem root: ...` | Pass the folder that contains your projects, not `/`, `C:\`, or a WSL distribution root. |
|
|
458
|
+
| `Error: Invalid target directory: ...` | Targets must be a single directory name. Do not use paths such as `src/dist` or `..`. |
|
|
459
|
+
| `failed ... [WinError 32]` or `Permission denied` | A file is open or read-only. Close editors, dev servers, and terminals using the directory, then retry. |
|
|
460
|
+
| Nothing is removed | Only direct children of the workspace are scanned. Point `-w` at the folder containing your project folders. |
|
|
461
|
+
| Paths with spaces do not work | Quote the path: `-w "C:/Users/me/My Projects"`. |
|
|
462
|
+
| Colors look wrong or appear as escape codes | Set `NO_COLOR=1`, or use a terminal with ANSI support. |
|
|
463
|
+
| No spinner or timer appears | The spinner is TTY-only. It is hidden when output is piped or redirected, and when `TERM=dumb`. Run the tool directly in a terminal to see it. |
|
|
464
|
+
| `dev-cleaner` is not recognized | Install it with `pipx install devcleaner-cli` or `uv tool install devcleaner-cli`, then reopen the terminal so `PATH` is updated. You can always run `python -m dev_cleaner` instead. |
|
|
465
|
+
|
|
466
|
+
## License
|
|
467
|
+
|
|
468
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
dev_cleaner/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
2
|
+
dev_cleaner/__main__.py,sha256=IuIEzeQW7UXTs6IfuP-K7IC8RCKRcAu-n74mLVE5zi0,95
|
|
3
|
+
dev_cleaner/main.py,sha256=cc2SEtRxBftfk9TxWPgpV_u9POP0wS9qk3gzBQZTiVQ,5108
|
|
4
|
+
dev_cleaner/cleaners/__init__.py,sha256=5YdTwwQoP6maFB-_aV12Jf5cZJMLoUeunz-pb1XWKV0,14
|
|
5
|
+
dev_cleaner/cleaners/base.py,sha256=ez3K4ptQfKTQBoKwO7-EOhYfmijzhslypQ8mB1cVedM,229
|
|
6
|
+
dev_cleaner/cleaners/native.py,sha256=-FBXjTNCBa66QMeaMkvzkyJgeHulku2Ji3gW-TJvWdo,423
|
|
7
|
+
dev_cleaner/cleaners/wsl.py,sha256=HtbQoQe2_vTPtIaeqnFWfiC5lK9EqOejDi8DUhEjxCg,996
|
|
8
|
+
dev_cleaner/cli/__init__.py,sha256=5YdTwwQoP6maFB-_aV12Jf5cZJMLoUeunz-pb1XWKV0,14
|
|
9
|
+
dev_cleaner/cli/interface.py,sha256=gG4CEyCujGmAluImSMFgdj_EKPEOvYdMBvQcvjdFhfI,7593
|
|
10
|
+
dev_cleaner/cli/parser.py,sha256=MU6HyZlMh5WDC4eKltcBhMwfBn8PqQoyb_6N0vF7x0I,1926
|
|
11
|
+
dev_cleaner/core/__init__.py,sha256=5YdTwwQoP6maFB-_aV12Jf5cZJMLoUeunz-pb1XWKV0,14
|
|
12
|
+
dev_cleaner/core/cleaner.py,sha256=9tdpYNtunSFTitKDoc0CjIqV8EsNeXjWhoMX09oTu1U,4976
|
|
13
|
+
devcleaner_cli-1.0.0.dist-info/METADATA,sha256=V8inQgEtDMW4zTV_9evWRq71pfwmspEzxVoiBYr2uxI,15578
|
|
14
|
+
devcleaner_cli-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
+
devcleaner_cli-1.0.0.dist-info/entry_points.txt,sha256=weNwCHbPFbggrX7BEHfpZlh0oRib4jxKfQU71csaHoI,54
|
|
16
|
+
devcleaner_cli-1.0.0.dist-info/licenses/LICENSE,sha256=GjwWVWb-7ncF_RZHaRzniNX6hDPkOkWqKqRw_O5i-Lk,1069
|
|
17
|
+
devcleaner_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 adonesbalica
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|