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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcull
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: remove stale dev cache directories
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/gnomecromancer/cull
@@ -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
- now = datetime.now(tz=timezone.utc)
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
- rprint(f"[green]found {len(hits)} cache dirs but all were touched in the last {older_than} days[/green]")
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcull
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: remove stale dev cache directories
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/gnomecromancer/cull
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devcull"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "remove stale dev cache directories"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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)
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes