devcull 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {devcull-0.1.0 → devcull-0.2.0}/PKG-INFO +1 -1
- devcull-0.2.0/cull/__init__.py +1 -0
- {devcull-0.1.0 → devcull-0.2.0}/cull/cli.py +31 -5
- {devcull-0.1.0 → devcull-0.2.0}/cull/scan.py +32 -4
- {devcull-0.1.0 → devcull-0.2.0}/devcull.egg-info/PKG-INFO +1 -1
- {devcull-0.1.0 → devcull-0.2.0}/pyproject.toml +1 -1
- {devcull-0.1.0 → devcull-0.2.0}/tests/test_scan.py +15 -0
- devcull-0.1.0/cull/__init__.py +0 -1
- {devcull-0.1.0 → devcull-0.2.0}/README.md +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/cull/__main__.py +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/devcull.egg-info/SOURCES.txt +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/devcull.egg-info/dependency_links.txt +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/devcull.egg-info/entry_points.txt +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/devcull.egg-info/requires.txt +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/devcull.egg-info/top_level.txt +0 -0
- {devcull-0.1.0 → devcull-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import shutil
|
|
2
3
|
import sys
|
|
3
4
|
from datetime import datetime, timezone
|
|
@@ -67,11 +68,15 @@ def _show_table(hits: list[Hit]):
|
|
|
67
68
|
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
68
69
|
@click.option("--older-than", default=90, metavar="DAYS",
|
|
69
70
|
help="only show caches not touched in N days (default: 90)")
|
|
71
|
+
@click.option("--min-size", default=0, metavar="MB",
|
|
72
|
+
help="only show caches larger than N megabytes")
|
|
70
73
|
@click.option("--delete", is_flag=True, help="interactively delete found caches")
|
|
71
74
|
@click.option("--all", "delete_all", is_flag=True, help="delete everything without asking (use with care)")
|
|
72
75
|
@click.option("--dry-run", is_flag=True, help="show what would be deleted but don't touch anything")
|
|
76
|
+
@click.option("--report", "report_file", default=None, metavar="FILE",
|
|
77
|
+
help="write findings to a JSON file")
|
|
73
78
|
@click.version_option(__version__, prog_name="cull")
|
|
74
|
-
def cli(path, older_than, delete, delete_all, dry_run):
|
|
79
|
+
def cli(path, older_than, min_size, delete, delete_all, dry_run, report_file):
|
|
75
80
|
"""Find and remove stale dev cache directories.
|
|
76
81
|
|
|
77
82
|
Scans PATH (default: current directory) for node_modules, .venv,
|
|
@@ -95,18 +100,39 @@ def cli(path, older_than, delete, delete_all, dry_run):
|
|
|
95
100
|
rprint("[green]nothing found, you're clean[/green]")
|
|
96
101
|
return
|
|
97
102
|
|
|
98
|
-
# filter by age
|
|
99
|
-
|
|
100
|
-
filtered = [h for h in hits if _age_days(h.last_used) >= older_than]
|
|
103
|
+
# filter by age and size
|
|
104
|
+
min_bytes = min_size * 1024 * 1024
|
|
105
|
+
filtered = [h for h in hits if _age_days(h.last_used) >= older_than and h.size >= min_bytes]
|
|
101
106
|
|
|
102
107
|
if not filtered:
|
|
103
|
-
|
|
108
|
+
qualifier = f"touched in the last {older_than} days"
|
|
109
|
+
if min_size:
|
|
110
|
+
qualifier += f" or smaller than {min_size} MB"
|
|
111
|
+
rprint(f"[green]found {len(hits)} cache dirs but none match your filters ({qualifier})[/green]")
|
|
104
112
|
return
|
|
105
113
|
|
|
106
114
|
total = sum(h.size for h in filtered)
|
|
107
115
|
rprint(f"\nfound [bold]{len(filtered)}[/bold] stale cache dirs totaling [yellow bold]{_fmt_size(total)}[/yellow bold]\n")
|
|
108
116
|
_show_table(filtered)
|
|
109
117
|
|
|
118
|
+
if report_file:
|
|
119
|
+
data = {
|
|
120
|
+
"scanned": str(root),
|
|
121
|
+
"at": datetime.now(tz=timezone.utc).isoformat(),
|
|
122
|
+
"filters": {"older_than_days": older_than, "min_size_mb": min_size},
|
|
123
|
+
"hits": [
|
|
124
|
+
{
|
|
125
|
+
"path": str(h.path),
|
|
126
|
+
"size_bytes": h.size,
|
|
127
|
+
"last_used": h.last_used.isoformat(),
|
|
128
|
+
"project": str(h.project) if h.project else None,
|
|
129
|
+
}
|
|
130
|
+
for h in filtered
|
|
131
|
+
],
|
|
132
|
+
}
|
|
133
|
+
Path(report_file).write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
134
|
+
rprint(f"[dim]report saved → {report_file}[/dim]")
|
|
135
|
+
|
|
110
136
|
if dry_run:
|
|
111
137
|
rprint("\n[dim](dry run — nothing deleted)[/dim]")
|
|
112
138
|
return
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fnmatch
|
|
1
2
|
import os
|
|
2
3
|
import subprocess
|
|
3
4
|
from dataclasses import dataclass, field
|
|
@@ -98,12 +99,37 @@ def _last_used(hit_path: Path, project: Path | None) -> datetime:
|
|
|
98
99
|
return datetime.min.replace(tzinfo=timezone.utc)
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
def _load_ignore(root: Path) -> list[str]:
|
|
103
|
+
ig = root / ".cullignore"
|
|
104
|
+
if not ig.exists():
|
|
105
|
+
return []
|
|
106
|
+
pats = []
|
|
107
|
+
for line in ig.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if line and not line.startswith("#"):
|
|
110
|
+
pats.append(line)
|
|
111
|
+
return pats
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_ignored(hit: Path, root: Path, patterns: list[str]) -> bool:
|
|
115
|
+
if not patterns:
|
|
116
|
+
return False
|
|
117
|
+
rel = hit.relative_to(root).as_posix()
|
|
118
|
+
name = hit.name
|
|
119
|
+
for pat in patterns:
|
|
120
|
+
if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel, pat):
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
101
125
|
def scan(root: Path, progress_cb=None) -> list[Hit]:
|
|
102
126
|
"""
|
|
103
127
|
Walk root looking for cache directories. Doesn't recurse into found dirs.
|
|
104
128
|
progress_cb(path) is called as we enter each directory, for spinner updates.
|
|
129
|
+
Respects .cullignore at root (gitignore-style patterns, matched against name or relative path).
|
|
105
130
|
"""
|
|
106
131
|
hits = []
|
|
132
|
+
ignore_pats = _load_ignore(root)
|
|
107
133
|
# TODO: handle junction points (windows symlink variant)
|
|
108
134
|
|
|
109
135
|
for dirpath, dirnames, _ in os.walk(root, topdown=True, onerror=None, followlinks=False):
|
|
@@ -121,18 +147,20 @@ def scan(root: Path, progress_cb=None) -> list[Hit]:
|
|
|
121
147
|
|
|
122
148
|
if d in CACHE_DIRS:
|
|
123
149
|
full = p / d
|
|
124
|
-
proj = _project_root(full)
|
|
125
|
-
h = Hit(path=full, project=proj)
|
|
126
|
-
hits.append(h)
|
|
127
150
|
prune.append(d)
|
|
151
|
+
if _is_ignored(full, root, ignore_pats):
|
|
152
|
+
continue
|
|
153
|
+
proj = _project_root(full)
|
|
154
|
+
hits.append(Hit(path=full, project=proj))
|
|
128
155
|
continue
|
|
129
156
|
|
|
130
157
|
if d in CONDITIONAL:
|
|
131
158
|
# only include 'target' if a build marker is in the immediate parent
|
|
132
159
|
if (p / "Cargo.toml").exists() or (p / "pom.xml").exists():
|
|
133
160
|
full = p / d
|
|
134
|
-
hits.append(Hit(path=full, project=p))
|
|
135
161
|
prune.append(d)
|
|
162
|
+
if not _is_ignored(full, root, ignore_pats):
|
|
163
|
+
hits.append(Hit(path=full, project=p))
|
|
136
164
|
|
|
137
165
|
for d in prune:
|
|
138
166
|
dirnames.remove(d)
|
|
@@ -65,6 +65,21 @@ def test_skips_recent_when_filtered(tmp_path):
|
|
|
65
65
|
assert any(h.path.name == ".pytest_cache" for h in hits)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def test_cullignore_skips_matching_dirs():
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
root = Path(tmp)
|
|
71
|
+
_make_tree(root, {
|
|
72
|
+
"app1": {"package.json": None, "node_modules": {"react": {"index.js": None}}},
|
|
73
|
+
"app2": {"package.json": None, "node_modules": {"vue": {"index.js": None}}},
|
|
74
|
+
})
|
|
75
|
+
# ignore app1's node_modules
|
|
76
|
+
(root / ".cullignore").write_text("app1/node_modules\n")
|
|
77
|
+
hits = scan(root)
|
|
78
|
+
paths = [str(h.path.relative_to(root)).replace("\\", "/") for h in hits]
|
|
79
|
+
assert "app2/node_modules" in paths
|
|
80
|
+
assert "app1/node_modules" not in paths
|
|
81
|
+
|
|
82
|
+
|
|
68
83
|
def test_conditional_target_with_cargo():
|
|
69
84
|
with tempfile.TemporaryDirectory() as tmp:
|
|
70
85
|
root = Path(tmp)
|
devcull-0.1.0/cull/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|