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.
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.
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.
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.
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.
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.
| Manager | Detected by | Deletes | Verified with read-only | Restored with |
|---|---|---|---|---|
| npm | package-lock.json | node_modules | npm ci --dry-run --ignore-scripts | npm ci |
| pnpm | pnpm-lock.yaml | node_modules | pnpm install --lockfile-only --frozen-lockfile | pnpm install --frozen-lockfile |
| Yarn | yarn.lock | node_modules | yarn install --immutable --mode update-lockfile (Berry) | yarn install --immutable |
| Bun | bun.lockb, bun.lock | node_modules | bun install --frozen-lockfile --dry-run --ignore-scripts | bun 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.
| Manager | Detected by | Deletes | Verified with read-only | Restored with |
|---|---|---|---|---|
| uv | uv.lock, [tool.uv] | .venv | uv lock --locked | uv sync |
| venv / pip | requirements.txt + pyvenv.cfg | every dir holding pyvenv.cfg | requirements.txt lists ≥ 1 package | python -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.
| Manager | Detected by | Deletes | Verified with read-only | Restored with |
|---|---|---|---|---|
| Cargo | Cargo.toml | target | cargo metadata --locked | next cargo build |
| Go | go.mod | vendor | go mod download | go 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.tomlRoot plus nested, mixed
monorepo/
├── Cargo.toml
├── web/
│ └── package-lock.json
└── scripts/
└── requirements.txtNine 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.
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.
| Command | What 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 --missing | Drop every registered path whose directory no longer exists, in one pass |
devp undo | Revert the most recent init or link |
devp run [PATH] | Prune every registered repository, or one target |
devp status | Interactive dashboard; a plain table when there is no TTY |
devp caches | Size 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-run | Put 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 skill | Export SKILL.md for AI coding assistants |
devp update | Print the installed version and the upgrade command |
devp uninstall [--deep] | Remove the scheduler, hooks and alias; --deep also clears config |
devp -V | Version 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 stderr2— 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 skillWrites 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
- /llms.txt — summary for language models
- SKILL.md — the shipped skill, verbatim
- devprune.schema.json — JSON Schema for
.devprune.json
Questions worth asking first
Can it delete something I cannot get back?
--ignore-idle, not -y, not the daemon.There used to be a --force. Where did it go?
--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?
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?
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?
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?
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?
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 | shdevp init ~/Code && devp run --dry-runApache-2.0 · no analytics · Windows, macOS and Linux · Rust 1.85