Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `--compress` flag (and matching `compress` config default) for `clean`: when
set, eligible directories are zipped into a sibling archive before being
sent to trash, shrinking the footprint of large artifacts like
`node_modules` or `.venv`. Metadata records the archive path and format
(schema version 4); restoring a compressed item currently requires
unpacking the archive by hand after pulling it out of trash.
## [1.0.2] - 2026-07-01

### Changed
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ devklean clean --dry-run # show what *would* be deleted; delete nothing
devklean clean -i # interactive: pick items in a TUI (Linux/macOS only)
devklean clean --allow-symlinks # permit deleting symlinked targets (blocked by default)
devklean clean -y # skip the y/N prompt (large deletions still require typing DELETE)
devklean clean --compress # zip eligible directories before sending them to trash
```

| Flag | Meaning |
Expand All @@ -78,6 +79,7 @@ devklean clean -y # skip the y/N prompt (large deletions still req
| `-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). |
| `--allow-symlinks` | Allow deleting symbolic links. Off by default (symlinks are blocked). |
| `-y`, `--yes` | Skip the standard confirmation. Deletions over the size threshold still require typing `DELETE`. |
| `--compress` | Zip eligible directories into a sibling archive before trashing them, shrinking their footprint in trash. Off by default — see [Compression](#compression). |

### `restore`

Expand All @@ -93,6 +95,11 @@ devklean restore # explains how to recover from the Recycle Bin / Trash
- **macOS** — open Trash in Finder and "Put Back".
- **Linux** — open Trash in your file manager and restore.

If the item was deleted with `--compress`, what lands in trash is a `.zip`
archive rather than the original directory — restore the archive from trash,
then unpack it to the original path yourself. devklean does not decompress
automatically (yet).

Run `devklean history` to see what was removed and when.

### `history`
Expand Down Expand Up @@ -163,6 +170,7 @@ exclude = ["node_modules", ".git"]
dry_run = false
interactive = false
default_yes = false # skip the y/N prompt (the large-deletion DELETE gate still applies)
compress = false # zip eligible directories before trashing them
theme = "default" # "default" or "mono"
confirm_threshold = 1073741824 # bytes; deletions >= this require typing DELETE (default 1 GiB)
path = "."
Expand All @@ -181,6 +189,22 @@ Scalar keys from the project file override the global file; list keys (`exclude`

Color follows the `theme` setting and is automatically disabled when output is piped or `NO_COLOR` is set.

## Compression

Common artifacts (`node_modules`, `.venv`, `.next`, build caches) often compress
to a fraction of their on-disk size. Pass `--compress` (or set `compress = true`
in config) and devklean will zip each eligible directory into a sibling
`<name>.zip` archive and send *that* to trash instead of the raw directory —
shrinking how much space the deletion actually occupies in trash before it's
emptied.

- Only applies to directories (not symlinks); files are trashed as-is.
- The archive path and format are recorded in deletion metadata, so `history`
and `doctor` see compressed deletions the same as uncompressed ones.
- Off by default, so existing scripts and habits aren't surprised by archives
appearing in trash.
- Restoring a compressed item is manual today — see [`restore`](#restore).

## Logs

`devklean` writes detailed structured logs (commands, scanned/deleted paths, sizes, errors) to:
Expand Down
6 changes: 5 additions & 1 deletion src/devklean/cli/commands/clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def run_standard(
dry_run: bool,
validator: SafetyValidator | None = None,
*,
compress: bool = False,
default_yes: bool = False,
confirm_threshold: int = DEFAULT_LARGE_THRESHOLD,
) -> None:
Expand All @@ -58,7 +59,7 @@ def run_standard(
renderer.aborted()
return

result = delete_items(found, total_size, validator=validator)
result = delete_items(found, total_size, validator=validator, compress=compress)
renderer.deletion_result(result)


Expand All @@ -76,6 +77,7 @@ def run_clean(
defaults = config.defaults
default_yes = getattr(args, "yes", False) or getattr(defaults, "default_yes", False)
confirm_threshold = getattr(defaults, "confirm_threshold", DEFAULT_LARGE_THRESHOLD)
compress = getattr(args, "compress", False) or getattr(defaults, "compress", False)

if args.interactive:
# Interactive mode relies on curses, which is unavailable on Windows.
Expand All @@ -97,6 +99,7 @@ def run_clean(
found,
args.dry_run,
validator,
compress=compress,
confirm_threshold=confirm_threshold,
)
else:
Expand All @@ -105,6 +108,7 @@ def run_clean(
found,
args.dry_run,
validator,
compress=compress,
default_yes=default_yes,
confirm_threshold=confirm_threshold,
)
Expand Down
5 changes: 5 additions & 0 deletions src/devklean/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ def _add_clean_arguments(parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Show what would be deleted without deleting anything",
)
parser.add_argument(
"--compress",
action="store_true",
help="Compress eligible directories into zip archives before trashing them",
)
parser.add_argument(
"-i",
"--interactive",
Expand Down
6 changes: 6 additions & 0 deletions src/devklean/config/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
_KNOWN_DEFAULTS = {
"dry_run",
"interactive",
"compress",
"path",
"default_yes",
"theme",
Expand Down Expand Up @@ -83,6 +84,8 @@ def apply_defaults(self, args, raw_argv: list[str]) -> None:
args.dry_run = config.defaults.dry_run
if "-i" not in raw_argv and "--interactive" not in raw_argv:
args.interactive = config.defaults.interactive
if "--compress" not in raw_argv:
args.compress = config.defaults.compress

if getattr(args, "path", None) == "." and not _explicit_path_provided(raw_argv):
args.path = os.path.expanduser(config.defaults.path)
Expand Down Expand Up @@ -149,6 +152,7 @@ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig:
merged: dict[str, Any] = {
"dry_run": base.dry_run,
"interactive": base.interactive,
"compress": base.compress,
"path": base.path,
"default_yes": base.default_yes,
"theme": base.theme,
Expand All @@ -161,6 +165,8 @@ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig:
for key in ("dry_run", "interactive", "default_yes"):
if key in section:
merged[key] = bool(section[key])
if "compress" in section:
merged["compress"] = bool(section["compress"])
if "path" in section:
merged["path"] = str(section["path"])
if "theme" in section:
Expand Down
1 change: 1 addition & 0 deletions src/devklean/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
class DefaultsConfig:
dry_run: bool = False
interactive: bool = False
compress: bool = False
path: str = "."
default_yes: bool = False
theme: str = "default"
Expand Down
27 changes: 27 additions & 0 deletions src/devklean/deletion/compression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

import shutil
from dataclasses import dataclass
from pathlib import Path

ARCHIVE_FORMAT = "zip"


@dataclass(frozen=True)
class CompressionArchive:
path: str
format: str


def compress_directory(source: Path) -> CompressionArchive:
"""Archive a directory into a sibling zip file and remove the source tree."""
archive_path = Path(
shutil.make_archive(
str(source),
ARCHIVE_FORMAT,
root_dir=source.parent,
base_dir=source.name,
)
)
shutil.rmtree(source)
return CompressionArchive(path=str(archive_path), format=ARCHIVE_FORMAT)
53 changes: 50 additions & 3 deletions src/devklean/deletion/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Sequence
from typing import Mapping, Sequence
from uuid import uuid4

from devklean.deletion.paths import get_deletion_metadata_dir
Expand All @@ -14,6 +14,7 @@
# the write side and this validation can't drift; a record with any other value
# was written by a removed backend and is treated as corrupt.
TRASH_STRATEGY = "trash"
SCHEMA_VERSION = 4


@dataclass(frozen=True)
Expand All @@ -30,6 +31,18 @@ def to_dict(self) -> dict[str, object]:
}


@dataclass(frozen=True)
class DeletionArchive:
path: str
format: str

def to_dict(self) -> dict[str, object]:
return {
"path": self.path,
"format": self.format,
}


@dataclass(frozen=True)
class DeletionMetadataRecord:
schema_version: int
Expand All @@ -38,9 +51,10 @@ class DeletionMetadataRecord:
timestamp: str
strategy: str
item: DeletionMetadataItem
archive: DeletionArchive | None = None

def to_dict(self) -> dict[str, object]:
return {
payload = {
"schema_version": self.schema_version,
"deletion": {
"id": self.deletion_id,
Expand All @@ -50,6 +64,9 @@ def to_dict(self) -> dict[str, object]:
},
"item": self.item.to_dict(),
}
if self.archive is not None:
payload["archive"] = self.archive.to_dict()
return payload


@dataclass(frozen=True)
Expand Down Expand Up @@ -89,6 +106,7 @@ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord:
original_path = item.get("original_path")
display_name = item.get("display_name")
size = item.get("size")
archive_data = data.get("archive")

# Records predating the schema_version field are treated as v1. Any integer
# version is accepted as-is; there are no migrations yet.
Expand All @@ -106,6 +124,16 @@ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord:
):
raise ValueError("missing or wrong-typed metadata fields")

archive: DeletionArchive | None = None
if archive_data is not None:
if not isinstance(archive_data, dict):
raise ValueError("missing or invalid 'archive' section")
archive_path = archive_data.get("path")
archive_format = archive_data.get("format")
if not isinstance(archive_path, str) or not isinstance(archive_format, str):
raise ValueError("missing or wrong-typed archive fields")
archive = DeletionArchive(path=archive_path, format=archive_format)

if strategy != TRASH_STRATEGY:
raise ValueError(f"unrecognized strategy {strategy!r}")

Expand All @@ -120,6 +148,7 @@ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord:
display_name=display_name,
size=size,
),
archive=archive,
)


Expand Down Expand Up @@ -174,6 +203,7 @@ def record_successes(
items: Sequence[CleanableItem],
result: DeleteResult,
strategy: str,
archives: Mapping[str, DeletionArchive | Mapping[str, str]] | None = None,
) -> None:
deleted_paths = set(result.deleted)
if not deleted_paths:
Expand All @@ -183,13 +213,17 @@ def record_successes(

run_id = uuid4().hex
timestamp = datetime.now(timezone.utc).isoformat()
archives = archives or {}

for item in items:
if item.path not in deleted_paths:
continue

archive_value = archives.get(item.path)
archive = _coerce_archive(archive_value)

record = DeletionMetadataRecord(
schema_version=3,
schema_version=SCHEMA_VERSION,
deletion_id=uuid4().hex,
run_id=run_id,
timestamp=timestamp,
Expand All @@ -199,8 +233,21 @@ def record_successes(
display_name=item.display_label,
size=item.size,
),
archive=archive,
)
stamp = record.timestamp.replace(":", "").replace("+00:00", "Z")
filename = f"{stamp}_{record.deletion_id}.json"
path = self._storage_dir / filename
path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8")


def _coerce_archive(value: DeletionArchive | Mapping[str, str] | None) -> DeletionArchive | None:
if value is None:
return None
if isinstance(value, DeletionArchive):
return value
path = value.get("path")
format_name = value.get("format")
if not isinstance(path, str) or not isinstance(format_name, str):
raise ValueError("archive metadata must contain string path and format")
return DeletionArchive(path=path, format=format_name)
20 changes: 17 additions & 3 deletions src/devklean/deletion/trash.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

from collections.abc import Sequence
from pathlib import Path

from send2trash import send2trash

from devklean.deletion.metadata import TRASH_STRATEGY, MetadataManager
from devklean.deletion.compression import compress_directory
from devklean.deletion.metadata import TRASH_STRATEGY, DeletionArchive, MetadataManager
from devklean.deletion.safety import SafetyValidator
from devklean.logging_setup import get_logger
from devklean.models import CleanableItem, DeleteFailure, DeleteResult
Expand All @@ -22,6 +24,7 @@ def delete_items(
validator: SafetyValidator | None = None,
metadata_manager: MetadataManager | None = None,
dry_run: bool = False,
compress: bool = False,
) -> DeleteResult:
"""Validate, then move safe items to the native OS trash via ``send2trash``.

Expand Down Expand Up @@ -56,9 +59,15 @@ def delete_items(

deleted: list[str] = []
failures: list[DeleteFailure] = []
archives: dict[str, DeletionArchive] = {}
for item in safe:
try:
send2trash(item.path)
trash_path = item.path
if compress and _should_compress(item):
archive = compress_directory(Path(item.path))
archives[item.path] = DeletionArchive(path=archive.path, format=archive.format)
trash_path = archive.path
send2trash(trash_path)
deleted.append(item.path)
except OSError as exc:
# TrashPermissionError subclasses OSError; ENOENT/EACCES and
Expand All @@ -85,5 +94,10 @@ def delete_items(
)

manager = metadata_manager or MetadataManager()
manager.record_successes(items, result, STRATEGY_NAME)
manager.record_successes(items, result, STRATEGY_NAME, archives=archives)
return result


def _should_compress(item: CleanableItem) -> bool:
source = Path(item.path)
return source.is_dir() and not source.is_symlink()
4 changes: 4 additions & 0 deletions src/devklean/output/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,9 @@ def restore_help(self) -> None:
c.detail(" • Windows — open the Recycle Bin and restore the item.")
c.detail(" • macOS — open Trash in Finder and 'Put Back'.")
c.detail(" • Linux — open Trash in your file manager and restore.")
c.detail(
"If compression was enabled, restore the archive from trash and unpack it "
"to the original path."
)
self._println()
c.detail("Run `devklean history` to see what was removed and when.")
Loading
Loading