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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`analyze [PATH]`** — scan for cleanable directories and cross-reference
them against a new artifact-signature registry: buckets results into
recognized (a known artifact type, with risk tier, confidence, and a
staleness estimate for its parent project) and unrecognized (no registry
entry, no verdict fabricated), flags project roots with conflicting
package-manager lockfiles, and reports an overall workspace-health score
(0-100). `--verbose` prints the scoring formula and its raw inputs.
- **`explain PATH`** — look up a single directory against the same
signature registry and explain what it is: ecosystem, what generates it,
how to regenerate it, risk tier, confidence, and the rationale behind the
entry. An unrecognized path gets no risk/confidence verdict at all.
- Artifact-signature registry (`devklean.signatures`) backing both commands
above — static, maintainer-assigned data (not inferred) covering
`node_modules`, `venv`/`.venv`/`env`, `__pycache__`, `.next`, `dist`, and
`.cache`.
- `--compress` flag (and matching `compress` config default) for `clean`:
when set, eligible directories are archived into a sibling `.tar.gz` (or
`.tar.zst`, via the `devklean[zstd]` extra) before being sent to trash,
shrinking the footprint of large artifacts like `node_modules` or
`.venv`. Compression is ordered for safety — archive, verify, send the
archive to trash, and only then remove the original — so a failure at
any step leaves the source directory untouched. New `compress_min_size`
and `compress_format` config keys tune the size threshold and archive
format. Metadata schema bumped to version 5 to record the archive path,
format, and original/compressed sizes; 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
86 changes: 84 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pipx install devklean
pip install devklean
```

Requires Python 3.8+. Runtime dependencies are `send2trash` (used for all deletions) and `tomli` (only on Python < 3.11).
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).

## Quick start

Expand All @@ -46,6 +46,8 @@ devklean clean --dry-run
| --- | --- |
| `devklean scan [PATH]` | Find cleanable directories and report sizes. Never deletes. |
| `devklean clean [PATH]` | Scan, then delete (to trash) after confirmation. |
| `devklean analyze [PATH]` | Scan and report a signature-backed analysis: risk, staleness, structural issues, workspace health. |
| `devklean explain PATH` | Explain what a single directory is, using the artifact-signature registry. |
| `devklean restore` | Explain how to recover deleted items from your system trash. |
| `devklean history` | Show previous cleanup operations (timestamp, size, strategy, item count). |
| `devklean doctor` | Inspect and repair the deletion metadata store. |
Expand All @@ -70,6 +72,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 # compress eligible directories (gzip) before sending them to trash
```

| Flag | Meaning |
Expand All @@ -78,6 +81,31 @@ 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` | Compress eligible directories into a sibling archive before trashing them, shrinking their footprint in trash. Off by default — see [Compression](#compression). |

### `analyze`

```bash
devklean analyze # analyze the current directory
devklean analyze ~/projects # analyze a specific path
devklean analyze --verbose # also print the workspace-health formula and its raw inputs
```

Scans like `scan`/`clean`, then cross-references every cleanable directory against the artifact-signature registry (the same data `explain` uses) and buckets each into:

- **Recognized** — has a registry entry: shown with its risk tier, ecosystem, and a staleness estimate for its parent project (last git commit date, falling back to the newest source-file mtime).
- **Not recognized** — no registry entry, so no risk/confidence verdict is given.

It also flags project roots with conflicting package-manager lockfiles (e.g. both `package-lock.json` and `pnpm-lock.yaml` present) and prints an overall **workspace health** score from 0-100, weighted by recognized directories' risk and size. `analyze` never deletes anything — it's a read-only report.

### `explain`

```bash
devklean explain node_modules # explain a directory relative to the current path
devklean explain ~/code/app/.next # explain an absolute path
```

Looks up the given path's directory name in the artifact-signature registry and prints what it is: ecosystem, what generates it, how to regenerate it, risk tier, confidence, and the rationale behind the entry. An unrecognized directory gets no fabricated risk or confidence verdict.

### `restore`

Expand All @@ -93,6 +121,12 @@ 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 `.tar.gz`
(or `.tar.zst`, if `compress_format = "zstd"`) archive rather than the original
directory — restore the archive from trash, then extract it to the original
path yourself, e.g. `tar -xf <name>.tar.gz`. devklean does not decompress
automatically (yet).

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

### `history`
Expand Down Expand Up @@ -137,7 +171,8 @@ Delete 3 directories (~834.0 MB)? (y/N) y
## Platform support

`devklean` runs on **Linux, macOS, and Windows**. All core commands — `scan`,
`clean`, `history`, `doctor`, and `restore` — work on every platform.
`clean`, `analyze`, `explain`, `history`, `doctor`, and `restore` — work on
every platform.

**Known limitation:** interactive mode (`-i` / `--interactive`) is **Linux/macOS
only** for now. It is built on Python's `curses` module, which is not available
Expand All @@ -163,6 +198,9 @@ 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 # compress eligible directories before trashing them
compress_min_size = 10485760 # bytes; directories smaller than this are trashed uncompressed (default 10 MiB)
compress_format = "gzip" # "gzip" (stdlib, default) or "zstd" (needs the devklean[zstd] extra)
theme = "default" # "default" or "mono"
confirm_threshold = 1073741824 # bytes; deletions >= this require typing DELETE (default 1 GiB)
path = "."
Expand All @@ -181,6 +219,50 @@ 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 archive each eligible directory into a sibling
`.tar.gz` (gzip, the default) before sending *that* to trash instead of the raw
directory — shrinking how much space the deletion actually occupies in trash
before it's emptied. `zstd` is available as an opt-in format (see below) for a
better compression ratio.

Compression is always ordered for safety: devklean compresses to a temp
archive, verifies it (test-extracts every entry and cross-checks the file
count and total uncompressed size against the source), and only *after*
`send2trash` confirms the archive is in the trash does it remove the original
directory. If compression or verification fails, or `send2trash` itself fails,
the original directory is left completely untouched and the error is reported
per-item — nothing is ever partially deleted.

- Only applies to directories (not symlinks) at or above `compress_min_size`
(default 10 MiB); smaller directories and files are trashed as-is, since the
archive/verify overhead rarely pays for itself below that size.
- The archive path, format, original size, and compressed size 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).

### zstd (optional)

```bash
pip install 'devklean[zstd]'
```

```toml
[defaults]
compress = true
compress_format = "zstd"
```

If `compress_format = "zstd"` is set but the `zstandard` package isn't
installed, devklean logs a warning and falls back to gzip rather than
crashing.

## Logs

`devklean` writes detailed structured logs (commands, scanned/deleted paths, sizes, errors) to:
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ dependencies = [
]

[project.optional-dependencies]
zstd = [
"zstandard>=0.21",
]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"ruff>=0.6",
"mypy>=1.8",
"build>=1.0",
"zstandard>=0.21",
]

[project.scripts]
Expand Down
34 changes: 34 additions & 0 deletions src/devklean/cli/commands/analyze.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from devklean.cli.commands.common import resolve_root
from devklean.config.models import AppConfig
from devklean.scanner.scanner import scan_tree
from devklean.signatures.analysis import analyze_candidates

if TYPE_CHECKING:
from devklean.output.text import TextRenderer


def run_analyze(args, renderer: TextRenderer, config: AppConfig) -> int:
"""Scan for cleanable directories and report a signature-backed analysis.

Calls ``scan_tree`` directly — the exact discovery function ``clean``/
``scan`` use via ``scan_directory`` — rather than a second walker.
``scan_directory`` isn't reused as-is because its "nothing found" path
renders clean/scan's own summary and stops; analyze always wants to
render its own report (recognized/unrecognized buckets, structural
checks, health score), even over an empty candidate list.
"""
root = resolve_root(args.path, renderer)
if root is None:
return 1

scan_report = scan_tree(root, settings=config.scan_settings)
if scan_report.permission_errors:
renderer.permission_warnings(scan_report.permission_errors)

analysis = analyze_candidates(root, scan_report.items)
renderer.analyze_report(analysis, verbose=getattr(args, "verbose", False))
return 0
26 changes: 24 additions & 2 deletions src/devklean/cli/commands/clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
confirm_large_deletion,
exceeds_threshold,
)
from devklean.config.models import AppConfig
from devklean.config.models import DEFAULT_COMPRESS_FORMAT, DEFAULT_COMPRESS_MIN_SIZE, AppConfig
from devklean.deletion.safety import SafetyValidator
from devklean.models import CleanableItem
from devklean.output.base import Renderer
Expand Down Expand Up @@ -45,6 +45,9 @@ def run_standard(
dry_run: bool,
validator: SafetyValidator | None = None,
*,
compress: bool = False,
compress_min_size: int = DEFAULT_COMPRESS_MIN_SIZE,
compress_format: str = DEFAULT_COMPRESS_FORMAT,
default_yes: bool = False,
confirm_threshold: int = DEFAULT_LARGE_THRESHOLD,
) -> None:
Expand All @@ -58,7 +61,14 @@ def run_standard(
renderer.aborted()
return

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


Expand All @@ -76,6 +86,12 @@ 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)
# compress_min_size/compress_format are config-only knobs, not CLI flags:
# --compress is the only opt-in surface on the command line, so these two
# are read straight from defaults rather than duplicated as flags.
compress_min_size = getattr(defaults, "compress_min_size", DEFAULT_COMPRESS_MIN_SIZE)
compress_format = getattr(defaults, "compress_format", DEFAULT_COMPRESS_FORMAT)

if args.interactive:
# Interactive mode relies on curses, which is unavailable on Windows.
Expand All @@ -97,6 +113,9 @@ def run_clean(
found,
args.dry_run,
validator,
compress=compress,
compress_min_size=compress_min_size,
compress_format=compress_format,
confirm_threshold=confirm_threshold,
)
else:
Expand All @@ -105,6 +124,9 @@ def run_clean(
found,
args.dry_run,
validator,
compress=compress,
compress_min_size=compress_min_size,
compress_format=compress_format,
default_yes=default_yes,
confirm_threshold=confirm_threshold,
)
Expand Down
20 changes: 17 additions & 3 deletions src/devklean/cli/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,29 @@
from devklean.scanner.scanner import scan_tree


def resolve_root(path: str, renderer: Renderer) -> str | None:
"""Resolve ``path`` to an absolute directory, reporting and returning
``None`` if it isn't one.

Shared by every command that takes a directory argument (``scan``,
``clean``, ``analyze``, ``explain``) so they agree on what "invalid
directory" means.
"""
root = os.path.abspath(path)
if not os.path.isdir(root):
renderer.invalid_directory(root)
return None
return root


def scan_directory(
args,
renderer: Renderer,
config: AppConfig,
) -> tuple[int, list[CleanableItem] | None]:
"""Validate the root path, scan, and report when nothing is found."""
root = os.path.abspath(args.path)
if not os.path.isdir(root):
renderer.invalid_directory(root)
root = resolve_root(args.path, renderer)
if root is None:
return 1, None

renderer.scan_start(root)
Expand Down
35 changes: 35 additions & 0 deletions src/devklean/cli/commands/explain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from devklean.cli.commands.common import resolve_root
from devklean.signatures import match_signature

if TYPE_CHECKING:
from devklean.output.text import TextRenderer


def run_explain(args, renderer: TextRenderer, config) -> int:
"""Resolve a path against the artifact-signature registry and explain it.

Every field printed on a match comes straight from the matched
``ArtifactSignature`` — nothing here is generated freeform. On no match,
no risk/confidence verdict is given at all; guessing about an unrecognized
path is a hard constraint, not a style choice.

Text-only by design, like ``doctor``/``restore``: this is a direct lookup
report, not a scannable result set, so it never needs the JSON scan/history
payload shape.
"""
path = resolve_root(args.path, renderer)
if path is None:
return 1

signature = match_signature(path)

if signature is None:
renderer.explain_no_match(path)
return 0

renderer.explain_match(path, signature)
return 0
4 changes: 4 additions & 0 deletions src/devklean/cli/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import sys
from typing import Callable

from devklean.cli.commands.analyze import run_analyze
from devklean.cli.commands.clean import run_clean
from devklean.cli.commands.doctor import run_doctor
from devklean.cli.commands.explain import run_explain
from devklean.cli.commands.history import run_history
from devklean.cli.commands.restore import run_restore
from devklean.cli.commands.scan import run_scan
Expand All @@ -23,6 +25,8 @@
"history": run_history,
"doctor": run_doctor,
"restore": run_restore,
"explain": run_explain,
"analyze": run_analyze,
}


Expand Down
Loading
Loading