v1.0.0 · Rust · Apache-2.0 · no analytics

Get the gigabytes back.
Lose nothing.

dev-prune deletes node_modules, .venv, target and vendor from Git repositories you have not touched in a while — but only after the package manager itself confirms a lockfile can rebuild them. Verification is not a flag you can turn off.

The command is dev-prune. devp is an alias for it — the same executable installed under a shorter name, purely for ease of use. Use either, anywhere, interchangeably; every example below works spelled both ways.

$curl -fsSL https://devprune.vkrishna04.me/install.sh | sh

Also works on Windows under Git Bash, MSYS2 or Cygwin.

8package managers
0network requests
3platforms, one binary each
$ devp run --dry-run
 
Required package managers:
✓ pnpm ✓ uv ✓ cargo
 
MyMonorepo → frontend/node_modules (412.7 MB) [pnpm]
MyMonorepo → services/api/.venv (188.2 MB) [uv]
MyMonorepo → tools/cli/target (1.4 GB) [cargo]
ActiveService — skipped (last commit 2 days ago)
 
Total reclaimable: 2.00 GB across 3 directories
Dry run — nothing was deleted.

Old projects keep their dependencies forever

A repository you last opened eight months ago is still holding a gigabyte of packages that a single command could reinstall. Deleting them by hand is tedious; deleting them with rm -rf and a find pipeline is how you lose a virtual environment nobody wrote a lockfile for.

1

Register

devp init ~/Code walks your workspace and records every Git repository it finds. Git hooks then keep the list current as you clone new ones.

2

Judge idleness

A repository is a candidate only after idle_days (15 by default) with no commit and no source file modified — so uncommitted work in progress protects itself.

3

Prove it is rebuildable

Each project's own package manager is asked to confirm the lockfile, read-only. A cleanup never rewrites a file Git tracks — it reports the problem and leaves the tree alone.

4

Delete, then restore on demand

Only verified directories are removed. Coming back to the project later is as simple as devp restore, and every project in the tree comes back with its own manager.

Seven things it will not do

These are invariants in the code, not conventions in a README.

Delete anything unproven

No directory is removed until its package manager confirms a usable lockfile. No flag bypasses this — --ignore-idle included.

Leave the .git boundary

dev-prune only operates inside a directory containing a valid .git root, and behaves identically regardless of where you invoked it from.

Reach into a nested repository

Discovery stops at a nested .git. A submodule is pruned as itself, or not at all — never as part of its parent.

Follow a symlink or junction

A linked bloat directory points at storage the repository does not own, so it is refused outright rather than followed.

Execute anything from your repo

.devprune.json holds inert data only: an ignore flag, an idle-day override, a display name, automation opt-outs. It can never name a command.

Guess when it cannot read your config

A .devprune.json that will not parse skips the repository and reports the syntax error. The unreadable file may have been the one saying "ignore": true.

Phone home

No analytics, no diagnostics, no usage data, no self-update. One request exists — an unauthenticated release check against GitHub, with no body and no identifier — and devp config set update_check false ends it.

Ignore your opt-out slowly

ignore.devprune.json in a repository root is a single file-existence check, made before any config is read or parsed.

Corrupt its own state

The registry is written to a temporary file and swapped into place, so an interrupted write cannot leave a half-written list of your repositories.

Full detail in Safety Invariants .

Eight managers. Any number per repository.

A repository is not assumed to be one project. dev-prune walks the root and, by default, six levels below it — raise or lower that with devp config set scan_depth N — and every directory a package manager recognises is verified, pruned and restored on its own terms.

JavaScript & TypeScript

Four managers, one directory. All of them install into node_modules, so at most one of them owns any given project.

ManagerDetected byDeletesVerified with read-onlyRestored with
npmpackage-lock.jsonnode_modulesnpm ci --dry-run --ignore-scriptsnpm ci
pnpmpnpm-lock.yamlnode_modulespnpm install --lockfile-only --frozen-lockfilepnpm install --frozen-lockfile
Yarnyarn.locknode_modulesyarn install --immutable --mode update-lockfile (Berry)yarn install --immutable
Bunbun.lockb, bun.locknode_modulesbun install --frozen-lockfile --dry-run --ignore-scriptsbun install --frozen-lockfile

When more than one claims the same tree, the owner is decided in this order: the packageManager field in package.json; else whichever manager's bookkeeping files are actually inside the installed node_modules; else the most recently written lockfile. Only that manager verifies, deletes and restores — the others are not consulted.

Python

Two managers that can describe the same project, because a uv project is still a directory with a virtual environment in it.

ManagerDetected byDeletesVerified with read-onlyRestored with
uvuv.lock, [tool.uv].venvuv lock --lockeduv sync
venv / piprequirements.txt + pyvenv.cfgevery dir holding pyvenv.cfgrequirements.txt lists ≥ 1 packagepython -m venv .venv && pip install -r …

uv wins over plain venv whenever uv.lock or a [tool.uv] table is present, because that lockfile rebuilds the environment exactly and a requirements.txt only approximates it. A project with no uv.lock falls back to venv, and a venv with an empty requirements.txt is refused outright — there would be nothing to reinstall from.

Rust & Go

One manager each, no ambiguity — and the clearest illustration of why every verification in these tables is read-only.

ManagerDetected byDeletesVerified with read-onlyRestored with
CargoCargo.tomltargetcargo metadata --lockednext cargo build
Gogo.modvendorgo mod downloadgo mod vendor

Read-only, in every ecosystem above. cargo generate-lockfile and go mod tidy make it obvious why: they rewrite Cargo.lock and go.mod/go.sum, tracked files that would turn a cleanup into a diff you did not ask for. The same is true of npm install --package-lock-only and uv lock, so none of them run during a normal pass either. A lockfile that has drifted from its manifest is reported and the directory is left alone — because a pass can be started by the OS scheduler, and a background cleanup must never leave a modified tracked file behind. If you would rather it fixed the lockfile for you, devp config set allow_manifest_rewrite true opts in, and it means the same thing for all eight adapters.

Three managers, one root

monorepo/
├── package-lock.json
├── uv.lock
└── Cargo.toml

One manager each, nested

monorepo/
├── frontend/
│   └── pnpm-lock.yaml
├── services/api/
│   └── uv.lock
└── tools/cli/
    └── Cargo.toml

Root plus nested, mixed

monorepo/
├── Cargo.toml
├── web/
│   └── package-lock.json
└── scripts/
    └── requirements.txt

Nine would be better than eight

Adding a manager is deliberately small: implement one Adapter trait — detect, list bloat directories, verify the lockfile, restore — register it in one array, and add its fixtures to the adapter test suite. Nothing else in the codebase has to know it exists. Composer, Gradle, Maven, Bundler, CocoaPods, Nix and Gems are all natural fits.

The walkthrough, with the trait signature and a worked example, is in Adding an adapter — and CONTRIBUTING.md covers what a pull request needs before it can be merged.

Rough back-of-envelope

Not a measurement — your numbers, multiplied. Run devp run --dry-run for the real figure.

Roughly reclaimable11.5 GB

The whole command surface

dev-prune and devp are the same executable under two names. Both work in every shell — cmd, PowerShell, bash, fish, an IDE terminal, a scheduled task — without a profile alias that has to be re-sourced.

Wherever a command takes [PATH], a literal . means the directory you are standing in. It is the default for init, link, unlink, restore and the config per-repo actions, and run accepts it as its target — so devp run . prunes this repository and nothing else. Worth saying out loud because . is usually treated as a shell detail rather than an argument: here it is a real path, it works on every platform, and it works the same in every command that takes one.

CommandWhat it does
devp init [PATHS]Crawl for Git repositories and register them, then run the setup pass
devp link [PATH]Register a single repository
devp unlink [PATH]Unregister a repository. Deletes nothing on disk
devp unlink --missingDrop every registered path whose directory no longer exists, in one pass
devp undoRevert the most recent init or link
devp run [PATH]Prune every registered repository, or one target
devp statusInteractive dashboard; a plain table when there is no TTY
devp cachesSize every package manager cache on the machine and print the command that clears each. Reports only — it deletes nothing
devp restore [PATH]Reinstall dependencies for every project in a tree
devp restore --last-runPut back exactly what the last prune pass deleted, in every repository it touched
devp doctor [PATH]Check the installation, or one repository — ending with the single reason a pass would or would not touch it
devp config …Global settings, per-repo config, scheduler, hooks, file manager icons. devp config wizard walks every setting one at a time
devp setup [--status]Install any missing integration; --status only reports
devp skillExport SKILL.md for AI coding assistants
devp updatePrint the installed version and the upgrade command
devp uninstall [--deep]Remove the scheduler, hooks and alias; --deep also clears config
devp -VVersion plus an environment audit: OS, arch, config path, PATH

Global flags

  • --dry-run — report every candidate and its size; touch nothing
  • --ignore-idle — lift the idle-day wait, and only that. Lockfile verification still applies
  • -y / --yes — skip interactive confirmation

On run: --except keeps named repositories out of the pass, --only / --skip narrow it to certain managers, --min-size sets a size floor, and --json emits one machine-readable document instead of the report.

Exit codes

  • 0 — success, including "nothing was idle enough to prune"
  • 1 — the command failed; the reason is on stderr
  • 2 — the arguments were not usable

Background automation

The OS scheduler and Git auto-registration hooks install themselves at install time, and again after an upgrade if anything went missing. The pass skips the hooks when git is absent, and when core.hooksPath already belongs to husky, pre-commit or lefthook it says so instead of taking the slot — devp hook install --chain takes it politely, by forwarding every hook on to the tool that had it.

Off switches: auto_daemon, auto_hooks, auto_setup, or DEV_PRUNE_NO_AUTO_SETUP=1.

Per-repository config

{
  "project_name": "My App",
  "ignore": false,
  "override_idle_days": 30,
  "disable_daemon": false,
  "disable_hooks": false
}

Or drop an empty ignore.devprune.json in the root to opt out entirely.

Every flag, alias and shorthand is in the CLI reference .

Your agent already knows how to use it

dev-prune ships a skill file describing its full command surface, its safety rules, its exit codes and a troubleshooting decision tree. It is exported to your config directory automatically and kept in step with the installed binary.

Export it

$devp skill

Writes SKILL.md to the config directory and prints ready-to-paste onboarding prompts for Claude Code, Cursor, Windsurf, Copilot, Antigravity and anything else that reads a skill file.

Just ask

“How much space can I get back?” → the agent runs devp run --dry-run and reads you the total. “Clean up but keep the API project” → it runs devp run --except api, which never verifies, deletes or reinstalls that one, rather than pruning everything and downloading it back afterwards. “Why didn't it delete anything?” → it runs devp doctor ., which ends by naming the one reason, and tells you.

You do not have to learn the flags. That is the point.

Machine-readable docs

Questions worth asking first

Can it delete something I cannot get back?
No directory is removed until its package manager has confirmed a usable lockfile. If verification fails, the directory is left alone and dev-prune prints the exact command to fix it. Nothing bypasses this — not --ignore-idle, not -y, not the daemon.
There used to be a --force. Where did it go?
It is now --ignore-idle, which is what it always actually did: lift the idle-day wait, and nothing else. The old spelling was misleading — “force” reads like “override the safety checks”, and there is no flag that does that. Typing --force still works and prints a one-line note pointing at the new name, along with the usual reasons a directory was skipped and how to fix each one.
What about uncommitted work?
Idleness is judged from git log and source file modification times. A repository you edited yesterday without committing is Active, and is not a candidate.
Does it work on monorepos?
That is the design. Every package-manager project inside a repository is discovered — six levels deep by default, devp config set scan_depth N to change it — and handled independently, and each directory is reported by its repository-relative path.
Will it break husky or pre-commit?
No. dev-prune's Git hooks use the global core.hooksPath, and Git allows exactly one hooks directory with no way to chain them. If that setting already belongs to another tool, the setup pass leaves it alone and says so rather than silently taking the slot. When you want both, devp hook install --chain claims the slot and writes a shim for every hook the displaced directory has, each one running dev-prune's registration and then exec-ing the original — so husky still fires, and devp hook uninstall puts the old path back exactly as it was. If the other tool later adds a hook, the next setup pass notices the drift and rebuilds the shims.
How do I stop it installing background things?
It already stops itself in the places that would be wrong: a CI runner, a container, or any non-interactive session is detected and the pass is skipped without being asked. Otherwise devp config set auto_setup false turns off the whole pass, auto_hooks and auto_daemon turn off one part each, and DEV_PRUNE_NO_AUTO_SETUP=1 overrides all three without a config file — useful in a Dockerfile, where you can set it before the binary is ever run.
Does it send anything over the network?
No analytics, no diagnostics, no usage data — none collected, none sent. There is exactly one request: an unauthenticated GET to GitHub's public releases endpoint, so it can tell you when a newer version is out. It has no body, carries no identifier, and runs at most once a week. It is opt-out rather than opt-in, because a tool that deletes directories is one whose fixes you want: devp config set update_check false switches it off, and devp update --offline skips it once. Everything else on the wire is your own package manager during verification or restore.
How do I remove it?
devp uninstall removes the scheduler, hooks and alias. Add --deep to also wipe the configuration directory and every registered repository's .devprune.json — it asks first.

Try it in dry-run. It deletes nothing.

$curl -fsSL https://devprune.vkrishna04.me/install.sh | sh
$devp init ~/Code && devp run --dry-run

Apache-2.0 · no analytics · Windows, macOS and Linux · Rust 1.85