Skip to content

Commit e4a52f9

Browse files
authored
Merge pull request #13 from smurftyy/feat-9-compress-artifacts
Feat 9 compress artifacts
2 parents d76d117 + 3a09f53 commit e4a52f9

29 files changed

Lines changed: 2070 additions & 72 deletions

README.md

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pipx install devklean
2525
pip install devklean
2626
```
2727

28-
Requires Python 3.8+. Runtime dependencies are `send2trash` (used for all deletions) and `tomli` (only on Python < 3.11).
28+
Requires Python 3.8+. Runtime dependencies are `send2trash` (used for all deletions) and `tomli` (only on Python < 3.11). `zstandard` is an optional dependency, only needed for `compress_format = "zstd"` — see [Compression](#compression).
2929

3030
## Quick start
3131

@@ -70,7 +70,7 @@ devklean clean --dry-run # show what *would* be deleted; delete nothing
7070
devklean clean -i # interactive: pick items in a TUI (Linux/macOS only)
7171
devklean clean --allow-symlinks # permit deleting symlinked targets (blocked by default)
7272
devklean clean -y # skip the y/N prompt (large deletions still require typing DELETE)
73-
devklean clean --compress # zip eligible directories before sending them to trash
73+
devklean clean --compress # compress eligible directories (gzip) before sending them to trash
7474
```
7575

7676
| Flag | Meaning |
@@ -79,7 +79,7 @@ devklean clean --compress # zip eligible directories before sending them t
7979
| `-i`, `--interactive` | Choose items in a terminal UI (SPACE select, A all, D none, ENTER confirm, Q quit). **Linux/macOS only** — see [Platform support](#platform-support). |
8080
| `--allow-symlinks` | Allow deleting symbolic links. Off by default (symlinks are blocked). |
8181
| `-y`, `--yes` | Skip the standard confirmation. Deletions over the size threshold still require typing `DELETE`. |
82-
| `--compress` | Zip eligible directories into a sibling archive before trashing them, shrinking their footprint in trash. Off by default — see [Compression](#compression). |
82+
| `--compress` | Compress eligible directories into a sibling archive before trashing them, shrinking their footprint in trash. Off by default — see [Compression](#compression). |
8383

8484
### `restore`
8585

@@ -95,9 +95,10 @@ devklean restore # explains how to recover from the Recycle Bin / Trash
9595
- **macOS** — open Trash in Finder and "Put Back".
9696
- **Linux** — open Trash in your file manager and restore.
9797

98-
If the item was deleted with `--compress`, what lands in trash is a `.zip`
99-
archive rather than the original directory — restore the archive from trash,
100-
then unpack it to the original path yourself. devklean does not decompress
98+
If the item was deleted with `--compress`, what lands in trash is a `.tar.gz`
99+
(or `.tar.zst`, if `compress_format = "zstd"`) archive rather than the original
100+
directory — restore the archive from trash, then extract it to the original
101+
path yourself, e.g. `tar -xf <name>.tar.gz`. devklean does not decompress
101102
automatically (yet).
102103

103104
Run `devklean history` to see what was removed and when.
@@ -170,7 +171,9 @@ exclude = ["node_modules", ".git"]
170171
dry_run = false
171172
interactive = false
172173
default_yes = false # skip the y/N prompt (the large-deletion DELETE gate still applies)
173-
compress = false # zip eligible directories before trashing them
174+
compress = false # compress eligible directories before trashing them
175+
compress_min_size = 10485760 # bytes; directories smaller than this are trashed uncompressed (default 10 MiB)
176+
compress_format = "gzip" # "gzip" (stdlib, default) or "zstd" (needs the devklean[zstd] extra)
174177
theme = "default" # "default" or "mono"
175178
confirm_threshold = 1073741824 # bytes; deletions >= this require typing DELETE (default 1 GiB)
176179
path = "."
@@ -193,18 +196,46 @@ Color follows the `theme` setting and is automatically disabled when output is p
193196

194197
Common artifacts (`node_modules`, `.venv`, `.next`, build caches) often compress
195198
to a fraction of their on-disk size. Pass `--compress` (or set `compress = true`
196-
in config) and devklean will zip each eligible directory into a sibling
197-
`<name>.zip` archive and send *that* to trash instead of the raw directory —
198-
shrinking how much space the deletion actually occupies in trash before it's
199-
emptied.
200-
201-
- Only applies to directories (not symlinks); files are trashed as-is.
202-
- The archive path and format are recorded in deletion metadata, so `history`
203-
and `doctor` see compressed deletions the same as uncompressed ones.
199+
in config) and devklean will archive each eligible directory into a sibling
200+
`.tar.gz` (gzip, the default) before sending *that* to trash instead of the raw
201+
directory — shrinking how much space the deletion actually occupies in trash
202+
before it's emptied. `zstd` is available as an opt-in format (see below) for a
203+
better compression ratio.
204+
205+
Compression is always ordered for safety: devklean compresses to a temp
206+
archive, verifies it (test-extracts every entry and cross-checks the file
207+
count and total uncompressed size against the source), and only *after*
208+
`send2trash` confirms the archive is in the trash does it remove the original
209+
directory. If compression or verification fails, or `send2trash` itself fails,
210+
the original directory is left completely untouched and the error is reported
211+
per-item — nothing is ever partially deleted.
212+
213+
- Only applies to directories (not symlinks) at or above `compress_min_size`
214+
(default 10 MiB); smaller directories and files are trashed as-is, since the
215+
archive/verify overhead rarely pays for itself below that size.
216+
- The archive path, format, original size, and compressed size are recorded in
217+
deletion metadata, so `history` and `doctor` see compressed deletions the
218+
same as uncompressed ones.
204219
- Off by default, so existing scripts and habits aren't surprised by archives
205220
appearing in trash.
206221
- Restoring a compressed item is manual today — see [`restore`](#restore).
207222

223+
### zstd (optional)
224+
225+
```bash
226+
pip install 'devklean[zstd]'
227+
```
228+
229+
```toml
230+
[defaults]
231+
compress = true
232+
compress_format = "zstd"
233+
```
234+
235+
If `compress_format = "zstd"` is set but the `zstandard` package isn't
236+
installed, devklean logs a warning and falls back to gzip rather than
237+
crashing.
238+
208239
## Logs
209240

210241
`devklean` writes detailed structured logs (commands, scanned/deleted paths, sizes, errors) to:

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,16 @@ dependencies = [
3636
]
3737

3838
[project.optional-dependencies]
39+
zstd = [
40+
"zstandard>=0.21",
41+
]
3942
dev = [
4043
"pytest>=7.0",
4144
"pytest-cov>=4.0",
4245
"ruff>=0.6",
4346
"mypy>=1.8",
4447
"build>=1.0",
48+
"zstandard>=0.21",
4549
]
4650

4751
[project.scripts]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import TYPE_CHECKING
5+
6+
from devklean.config.models import AppConfig
7+
from devklean.scanner.scanner import scan_tree
8+
from devklean.signatures.analysis import analyze_candidates
9+
10+
if TYPE_CHECKING:
11+
from devklean.output.text import TextRenderer
12+
13+
14+
def run_analyze(args, renderer: TextRenderer, config: AppConfig) -> int:
15+
"""Scan for cleanable directories and report a signature-backed analysis.
16+
17+
Calls ``scan_tree`` directly — the exact discovery function ``clean``/
18+
``scan`` use via ``scan_directory`` — rather than a second walker.
19+
``scan_directory`` isn't reused as-is because its "nothing found" path
20+
renders clean/scan's own summary and stops; analyze always wants to
21+
render its own report (recognized/unrecognized buckets, structural
22+
checks, health score), even over an empty candidate list.
23+
"""
24+
root = os.path.abspath(args.path)
25+
if not os.path.isdir(root):
26+
renderer.invalid_directory(root)
27+
return 1
28+
29+
scan_report = scan_tree(root, settings=config.scan_settings)
30+
if scan_report.permission_errors:
31+
renderer.permission_warnings(scan_report.permission_errors)
32+
33+
analysis = analyze_candidates(root, scan_report.items)
34+
renderer.analysis_report(analysis, verbose=getattr(args, "verbose", False))
35+
return 0

src/devklean/cli/commands/clean.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
confirm_large_deletion,
99
exceeds_threshold,
1010
)
11-
from devklean.config.models import AppConfig
11+
from devklean.config.models import DEFAULT_COMPRESS_FORMAT, DEFAULT_COMPRESS_MIN_SIZE, AppConfig
1212
from devklean.deletion.safety import SafetyValidator
1313
from devklean.models import CleanableItem
1414
from devklean.output.base import Renderer
@@ -46,6 +46,8 @@ def run_standard(
4646
validator: SafetyValidator | None = None,
4747
*,
4848
compress: bool = False,
49+
compress_min_size: int = DEFAULT_COMPRESS_MIN_SIZE,
50+
compress_format: str = DEFAULT_COMPRESS_FORMAT,
4951
default_yes: bool = False,
5052
confirm_threshold: int = DEFAULT_LARGE_THRESHOLD,
5153
) -> None:
@@ -59,7 +61,14 @@ def run_standard(
5961
renderer.aborted()
6062
return
6163

62-
result = delete_items(found, total_size, validator=validator, compress=compress)
64+
result = delete_items(
65+
found,
66+
total_size,
67+
validator=validator,
68+
compress=compress,
69+
compress_min_size=compress_min_size,
70+
compress_format=compress_format,
71+
)
6372
renderer.deletion_result(result)
6473

6574

@@ -78,6 +87,10 @@ def run_clean(
7887
default_yes = getattr(args, "yes", False) or getattr(defaults, "default_yes", False)
7988
confirm_threshold = getattr(defaults, "confirm_threshold", DEFAULT_LARGE_THRESHOLD)
8089
compress = getattr(args, "compress", False) or getattr(defaults, "compress", False)
90+
# Not exposed as CLI flags (per the original issue, --compress itself is
91+
# the only opt-in surface); config-only knobs read straight from defaults.
92+
compress_min_size = getattr(defaults, "compress_min_size", DEFAULT_COMPRESS_MIN_SIZE)
93+
compress_format = getattr(defaults, "compress_format", DEFAULT_COMPRESS_FORMAT)
8194

8295
if args.interactive:
8396
# Interactive mode relies on curses, which is unavailable on Windows.
@@ -100,6 +113,8 @@ def run_clean(
100113
args.dry_run,
101114
validator,
102115
compress=compress,
116+
compress_min_size=compress_min_size,
117+
compress_format=compress_format,
103118
confirm_threshold=confirm_threshold,
104119
)
105120
else:
@@ -109,6 +124,8 @@ def run_clean(
109124
args.dry_run,
110125
validator,
111126
compress=compress,
127+
compress_min_size=compress_min_size,
128+
compress_format=compress_format,
112129
default_yes=default_yes,
113130
confirm_threshold=confirm_threshold,
114131
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import TYPE_CHECKING
5+
6+
from devklean.signatures import match_signature
7+
8+
if TYPE_CHECKING:
9+
from devklean.output.text import TextRenderer
10+
11+
12+
def run_explain(args, renderer: TextRenderer, config) -> int:
13+
"""Resolve a path against the artifact-signature registry and explain it.
14+
15+
Every field printed on a match comes straight from the matched
16+
``ArtifactSignature`` — nothing here is generated freeform. On no match,
17+
no risk/confidence verdict is given at all; guessing about an unrecognized
18+
path is a hard constraint, not a style choice.
19+
20+
Text-only by design, like ``doctor``/``restore``: this is a direct lookup
21+
report, not a scannable result set, so it never needs the JSON scan/history
22+
payload shape.
23+
"""
24+
path = os.path.abspath(args.path)
25+
signature = match_signature(path)
26+
27+
if signature is None:
28+
renderer.explain_no_match(path)
29+
return 0
30+
31+
renderer.explain_match(path, signature)
32+
return 0

src/devklean/cli/dispatcher.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import sys
44
from typing import Callable
55

6+
from devklean.cli.commands.analyze import run_analyze
67
from devklean.cli.commands.clean import run_clean
78
from devklean.cli.commands.doctor import run_doctor
9+
from devklean.cli.commands.explain import run_explain
810
from devklean.cli.commands.history import run_history
911
from devklean.cli.commands.restore import run_restore
1012
from devklean.cli.commands.scan import run_scan
@@ -23,6 +25,8 @@
2325
"history": run_history,
2426
"doctor": run_doctor,
2527
"restore": run_restore,
28+
"explain": run_explain,
29+
"analyze": run_analyze,
2630
}
2731

2832

src/devklean/cli/parser.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,22 @@
55
from devklean._version import __version__
66

77
COMMAND_NAMES = frozenset(
8-
{"scan", "clean", "history", "doctor", "stats", "restore", "config", "plugins"}
8+
{
9+
"scan",
10+
"clean",
11+
"history",
12+
"doctor",
13+
"stats",
14+
"restore",
15+
"explain",
16+
"analyze",
17+
"config",
18+
"plugins",
19+
}
20+
)
21+
IMPLEMENTED_COMMANDS = frozenset(
22+
{"scan", "clean", "history", "doctor", "restore", "explain", "analyze"}
923
)
10-
IMPLEMENTED_COMMANDS = frozenset({"scan", "clean", "history", "doctor", "restore"})
1124
RESERVED_COMMANDS = frozenset({"stats", "config", "plugins"})
1225
GLOBAL_OPTIONS = frozenset({"-h", "--help", "--version"})
1326

@@ -84,6 +97,26 @@ def _add_subparsers(parser: argparse.ArgumentParser) -> None:
8497
"restore", help="Show how to recover deleted items from your system trash"
8598
)
8699

100+
explain_parser = subparsers.add_parser(
101+
"explain",
102+
help="Explain what a directory is, using the artifact-signature registry",
103+
)
104+
explain_parser.add_argument(
105+
"path",
106+
help="Path to a directory to look up in the signature registry",
107+
)
108+
109+
analyze_parser = subparsers.add_parser(
110+
"analyze",
111+
help="Analyze cleanable directories using the artifact-signature registry",
112+
)
113+
_add_path_argument(analyze_parser)
114+
analyze_parser.add_argument(
115+
"--verbose",
116+
action="store_true",
117+
help="Show the workspace-health formula and its raw inputs",
118+
)
119+
87120
history_parser = subparsers.add_parser(
88121
"history",
89122
help="Show previous cleanup operations",

src/devklean/config/manager.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"dry_run",
2222
"interactive",
2323
"compress",
24+
"compress_min_size",
25+
"compress_format",
2426
"path",
2527
"default_yes",
2628
"theme",
@@ -153,6 +155,8 @@ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig:
153155
"dry_run": base.dry_run,
154156
"interactive": base.interactive,
155157
"compress": base.compress,
158+
"compress_min_size": base.compress_min_size,
159+
"compress_format": base.compress_format,
156160
"path": base.path,
157161
"default_yes": base.default_yes,
158162
"theme": base.theme,
@@ -167,6 +171,13 @@ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig:
167171
merged[key] = bool(section[key])
168172
if "compress" in section:
169173
merged["compress"] = bool(section["compress"])
174+
if "compress_min_size" in section:
175+
try:
176+
merged["compress_min_size"] = int(section["compress_min_size"])
177+
except (TypeError, ValueError):
178+
pass
179+
if "compress_format" in section:
180+
merged["compress_format"] = str(section["compress_format"])
170181
if "path" in section:
171182
merged["path"] = str(section["path"])
172183
if "theme" in section:

src/devklean/config/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,25 @@
88
# CLI/TUI confirmation flow re-exports this as DEFAULT_LARGE_THRESHOLD.
99
DEFAULT_CONFIRM_THRESHOLD = 1024**3 # 1 GiB
1010

11+
# Single source of truth for compress-before-trash defaults; deletion/trash.py
12+
# imports these rather than defining its own copies, so config and the
13+
# deletion pipeline can't drift apart on what "the default" is.
14+
# Below this size, tar/gzip overhead and the extra verify-read pass rarely
15+
# pay for themselves, so --compress skips the directory and trashes it as-is.
16+
DEFAULT_COMPRESS_MIN_SIZE = 10 * 1024 * 1024 # 10 MiB
17+
# "gzip" (stdlib tarfile, always available) or "zstd" (needs the
18+
# devklean[zstd] extra; silently falls back to gzip with a logged warning
19+
# when requested but not installed).
20+
DEFAULT_COMPRESS_FORMAT = "gzip"
21+
1122

1223
@dataclass(frozen=True)
1324
class DefaultsConfig:
1425
dry_run: bool = False
1526
interactive: bool = False
1627
compress: bool = False
28+
compress_min_size: int = DEFAULT_COMPRESS_MIN_SIZE
29+
compress_format: str = DEFAULT_COMPRESS_FORMAT
1730
path: str = "."
1831
default_yes: bool = False
1932
theme: str = "default"

0 commit comments

Comments
 (0)