Skip to content

Clean Drive: list developer junk directory by directory - #26

Open
havokentity wants to merge 2 commits into
mainfrom
feat/cleandrive-dev-junk
Open

Clean Drive: list developer junk directory by directory#26
havokentity wants to merge 2 commits into
mainfrom
feat/cleandrive-dev-junk

Conversation

@havokentity

Copy link
Copy Markdown
Owner

Wave 2 Phase A.

Implementation: Clean Drive: list developer junk directory by directory
Review fixes: Clean Drive: never offer an application's own bundled dependencies

Built by an isolated agent, then adversarially reviewed by an independent agent that checked the branch out and ran swift build + the full suite itself. Every review finding was fixed, with a regression test added for each high and medium.

🤖 Generated with Claude Code

havokentity and others added 2 commits July 21, 2026 13:24
Clean Drive's system categories are a checkbox each: tick "Cache files" and
everything under ~/Library/Caches goes, sight unseen. That is the wrong shape
for a developer's disk, where the reclaimable space is concentrated in a
handful of enormous, individually meaningful directories — one abandoned
project's node_modules, last year's DerivedData, the device support for a
phone sold two years ago. A developer wants to keep this one and drop that
one, and a single checkbox cannot express it.

So developer junk gets its own sheet, where every category expands into the
individual directories that make up its total and each can be unticked on its
own. Categories start with a sensible default and nothing moves until the
user accepts a confirmation naming the count and the total.

The safety argument is the reason the matching lives in pure helpers rather
than inline in the scan. A cleaner that mistakes a source tree for a cache is
not a bug you get to fix in the next release, so eligibility is decided in
exactly one place — `DevJunkCategoryID.accepts` — and that gate is applied
twice: when the scanner discovers a path, and again immediately before the
delete, because entries survive across rescans and across the confirmation
alert. Containment is component-wise, never a string prefix: `hasPrefix`
would happily place ~/Code/MyApp-backup inside ~/Code/MyApp, which is exactly
how this class of tool eats someone's work.

The two categories that live in the user's own folders are the conservative
case. A directory named node_modules only counts when a package.json sits
beside it, and a .build only when there is a Package.swift; a folder someone
named node_modules to hold notes is never touched, and neither is a .build
belonging to some other build system. The walk is depth-capped, confined to
the usual project roots, and prunes package and VCS stores so a nested copy is
not offered twice. Deliberate omissions are documented where they are made:
CoreSimulator/Devices holds installed simulator apps rather than caches,
Docker's Data/vms holds every image and volume the user owns, ~/.gradle/wrapper
holds checksum-pinned distributions, and ~/.cocoapods/repos may be a private
spec repo that cannot be re-cloned.

Deletion goes to the Trash rather than an outright unlink, the reverse of what
the system categories do. Those sweep caches an app will regenerate in
seconds; these are the user's project folders, so if the heuristics are ever
wrong the mistake has to be undoable — and the sheet says plainly that the
space comes back only once the Trash is emptied.

Sizing runs off the main actor and streams a category at a time, so the list
fills in progressively instead of waiting for the slowest tree, and the walk
is cancellable: a node_modules sweep left running for a closed panel would
keep a core busy for nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Electron applications ship their dependencies as Contents/Resources/app/node_modules
with a package.json sitting right beside them, so the sibling-manifest test that keeps
this feature conservative passed on them. An .app on the Desktop is exactly five
components below a search root, which the depth-4 project walk reaches on its last
permitted iteration, so an application the user happened to keep on the Desktop was
listed as the innocuous row "app/node_modules", checked by default, and trashed on
confirm. That breaks the installed application, and unlike a project's node_modules no
npm install brings it back — which contradicts both the category's own detail string
and the promise that the worst case here is a rebuild or a re-download. Bundles are now
stepped over rather than into, and refused as an ancestor by accepts() as well, because
accepts() is the gate the remover re-checks against and a fix in only one of the two
would leave the last line of defence open. The extension list is deliberately wider than
applications: frameworks and plug-ins carry the same layout, and a photo or Final Cut
library is user data that should never be walked at all.

accepts() also treated ".." as an ordinary path component. normalize() deliberately does
not resolve traversal — it is a pure function and must not touch the filesystem — so
containment was purely textual and "~/Documents/../.ssh/node_modules" read as living
under a search root while resolving somewhere else entirely. No scanner today emits such
a path, but this gate is presented as the guarantee that a stale or forged entry cannot
delete the wrong thing, and against the one syntax a forged path would actually use it
did not hold. Rather than teach the helpers to resolve traversal, the syntax is refused
outright.

Two smaller things in the panel. The Move to Trash button is live during a scan, and
trashing starts a fresh one, so the cancelled scan's tail would clear isScanning and
overwrite the status line with a count taken from its successor's half-filled results —
the rescan ran with no progress bar, no Stop button and a wrong total. A generation
counter now lets an outgoing task recognise that the state belongs to somebody else. And
a wholly successful removal used to pass in silence, because presentResult returned early
when there were no failures and startScan had already replaced the status line: the one
path with no feedback was the one where an irreversible-looking bulk move had just
succeeded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant