dev_prune/lib.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Copyright 2026 VKrishna04
5//
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18pub mod adapters;
19pub mod commands;
20pub mod config;
21pub mod constants;
22pub mod daemon;
23pub mod engine;
24pub mod json;
25pub mod output;
26pub mod scanner;
27pub mod setup;
28pub mod tui;
29pub mod workspace;
30
31use clap::{Parser, Subcommand};
32
33/// Process exit codes, so scripts and CI can branch on the outcome.
34///
35/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
36/// changing one is a breaking change.
37pub mod exit_code {
38 /// The command did what it was asked to do. A prune that deleted nothing because
39 /// nothing was idle is still a success.
40 pub const OK: i32 = 0;
41 /// The command failed. The reason is on stderr.
42 pub const FAILURE: i32 = 1;
43 /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
44 pub const USAGE: i32 = 2;
45}
46
47/// Restore the default disposition for `SIGPIPE`.
48///
49/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
50/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
51/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
52#[cfg(unix)]
53fn restore_sigpipe() {
54 // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
55 // thread is spawned.
56 unsafe {
57 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
58 }
59}
60
61#[cfg(not(unix))]
62fn restore_sigpipe() {}
63
64/// Explain the rename, then answer the question the user was really asking.
65///
66/// Nobody types `--force` for fun. They type it because something was not pruned and
67/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
68/// them exactly as stuck as before. The list below is every reason a directory gets
69/// skipped, with the fix, because six of the seven are not what `--force` was for.
70///
71/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
72fn print_force_help() {
73 output::print_notice(
74 "`--force` is now `--ignore-idle`, which is what it has always actually done. \
75 The old spelling still works.",
76 );
77 eprintln!(
78 "
79 Reaching for --force usually means something did not get pruned. It is one of these:
80
81 Not idle yet A commit or a source edit inside idle_days (15 by default).
82 This is the one --ignore-idle is for.
83 Lockfile unusable The package manager could not confirm it. Run the command
84 dev-prune printed, then try again. No flag skips this check.
85 Opted out `ignore.devprune.json` in the root, or `\"ignore\": true`
86 in `.devprune.json`.
87 Under the size floor Smaller than min_size_mb. `--min-size 0` includes it.
88 Not registered `devp link .` first; `devp status` shows what is tracked.
89 Nested or symlinked A submodule is pruned as itself, never as part of its
90 parent, and a linked directory is refused. By design.
91 Too deep Beyond scan_depth (6 levels). `devp config set scan_depth N`.
92
93 `devp run --dry-run` names the actual reason, per repository.
94
95 Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
96 tree, including this list. It has read it. It wrote it.
97"
98 );
99}
100
101/// Whether a failure is just the reader at the other end of a pipe hanging up.
102///
103/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
104/// not an error worth printing — printing it would itself fail.
105fn is_broken_pipe(err: &anyhow::Error) -> bool {
106 err.chain().any(|cause| {
107 cause
108 .downcast_ref::<std::io::Error>()
109 .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
110 })
111}
112
113/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
114///
115/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
116#[derive(Parser, Debug)]
117#[command(name = constants::APP_NAME)]
118#[command(version = constants::VERSION)]
119#[command(
120 about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
121)]
122#[command(
123 after_help = "EXAMPLES:\n devp init ~/Code Scan directory trees & onboard workspaces\n devp link Register current repository\n devp run Execute prune pass across inactive repositories\n devp status View system status dashboard\n devp caches Size every package manager cache (deletes nothing)\n devp status daemon Check background daemon status (alias for `devp config daemon status`)\n devp status . hook Check workspace Git hook status (alias for `devp config . hook status`)\n devp config . daemon disable Disable daemon background pass for current workspace\n devp restore . Restore missing node_modules/.venv via lockfile\n devp undo Revert most recent init or link action\n\nBINARY ALIAS:\n `dev-prune` and `devp` invoke the exact same executable."
124)]
125pub struct Cli {
126 #[command(subcommand)]
127 command: Commands,
128
129 /// Simulate pruning without deleting any files.
130 #[arg(long, global = true)]
131 dry_run: bool,
132
133 /// Prune repositories you are still working in, ignoring the idle-day threshold.
134 ///
135 /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
136 /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
137 #[arg(long, global = true)]
138 ignore_idle: bool,
139
140 /// Deprecated spelling of `--ignore-idle`.
141 ///
142 /// Renamed because "force" reads like "override the safety checks", which it never
143 /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
144 #[arg(long, global = true)]
145 force: bool,
146
147 /// Bypass interactive confirmation prompts.
148 #[arg(long, short = 'y', global = true)]
149 yes: bool,
150}
151
152#[derive(Subcommand, Debug)]
153pub enum Commands {
154 /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
155 #[command(alias = "scan", alias = "onboard")]
156 Init {
157 /// Paths to scan for Git repositories (defaults to current directory).
158 #[arg(default_value = ".")]
159 paths: Vec<String>,
160 },
161
162 /// Register a single Git repository for pruning (defaults to current directory `.`).
163 Link {
164 /// Path to the Git repository to register.
165 #[arg(default_value = ".")]
166 path: String,
167
168 /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
169 #[arg(long)]
170 quiet: bool,
171 },
172
173 /// Remove a repository from the dev-prune registry (does not delete workspace files).
174 Unlink {
175 /// Path to the Git repository to unregister.
176 #[arg(default_value = ".")]
177 path: String,
178
179 /// Unregister every path that no longer exists, instead of one named repository.
180 #[arg(long, conflicts_with = "path")]
181 missing: bool,
182 },
183
184 /// Revert the most recent init or link action.
185 Undo,
186
187 /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
188 Run {
189 /// Optional target workspace path. If omitted, runs across all registered repositories.
190 target_path: Option<String>,
191
192 /// Mark this as the scheduled background pass. Repositories that set
193 /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
194 #[arg(long)]
195 daemon: bool,
196
197 /// Act only on these package managers (comma-separated),
198 /// e.g. `--only npm,pnpm`. Unknown names are an error.
199 #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
200 only: Option<String>,
201
202 /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
203 #[arg(long, value_name = "ADAPTERS")]
204 skip: Option<String>,
205
206 /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
207 #[arg(long, value_name = "MIB")]
208 min_size: Option<u64>,
209
210 /// Prune everything except these repositories (comma-separated paths or names).
211 ///
212 /// The safe way to express "clean up but keep the API project": that project is
213 /// never verified, never deleted and never reinstalled, instead of being pruned
214 /// and then restored over the network.
215 #[arg(long, value_name = "REPOS")]
216 except: Option<String>,
217
218 /// Emit one JSON document instead of the human report. Implies non-interactive.
219 #[arg(long)]
220 json: bool,
221 },
222
223 /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
224 Status {
225 /// Emit the dashboard as one JSON document instead of the TUI or text table.
226 #[arg(long)]
227 json: bool,
228 },
229
230 /// Report the size of every package manager cache on this machine (read-only, deletes nothing).
231 Caches {
232 /// Emit the report as one JSON document instead of the table.
233 #[arg(long)]
234 json: bool,
235 },
236
237 /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
238 Config {
239 #[command(subcommand)]
240 action: Option<ConfigAction>,
241 },
242
243 /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
244 Restore {
245 /// Path to the project to restore (defaults to current directory).
246 path: Option<String>,
247
248 /// Put back exactly what the most recent prune pass deleted, in every repository
249 /// it touched. The undo for a `run`.
250 #[arg(long, conflicts_with = "path")]
251 last_run: bool,
252 },
253
254 /// Print the installed version, check for a newer release, and show how to upgrade.
255 Update {
256 /// Skip the release check for this run. The check is the only thing in dev-prune
257 /// that opens a network connection; `devp config set update_check false` turns
258 /// it off for good.
259 #[arg(long)]
260 offline: bool,
261 },
262
263 /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
264 Skill,
265
266 /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
267 Setup {
268 /// Report what is installed without changing anything.
269 #[arg(long)]
270 status: bool,
271 },
272
273 /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
274 Doctor {
275 /// Repository to diagnose. Omit to check the installation itself.
276 path: Option<String>,
277 },
278
279 /// Uninstall background daemon, Git hooks, and optionally wipe configuration.
280 Uninstall {
281 /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
282 #[arg(long)]
283 deep: bool,
284 },
285}
286
287#[derive(Subcommand, Debug)]
288pub enum ConfigAction {
289 /// Display a global configuration value.
290 Get {
291 /// Any key `devp config show` lists — idle_days, min_size_mb, scan_depth,
292 /// require_confirmation, allow_manifest_rewrite, command_timeout_secs,
293 /// auto_setup, auto_daemon, check_interval_days, auto_hooks, auto_hooks_chain,
294 /// update_check, update_check_interval_days, update_check_timeout_secs.
295 key: String,
296 },
297 /// Set a global configuration value.
298 Set {
299 /// Configuration key.
300 key: String,
301 /// New value.
302 value: String,
303 },
304 /// Show all global configuration values or sync per-repo configurations.
305 Show {
306 /// Force update/sync pass across all registered repos.
307 #[arg(long, short)]
308 update: bool,
309 },
310 /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
311 Project {
312 /// Path to the repository (defaults to current directory).
313 #[arg(default_value = ".")]
314 path: String,
315 /// Force update/sync pass on this project config.
316 #[arg(long, short)]
317 update: bool,
318 },
319 /// Configure OS background daemon scheduler globally or for a workspace path.
320 Daemon {
321 /// Optional workspace path or sub-action (enable, disable, status).
322 target: Option<String>,
323 /// Sub-action if path was provided (enable, disable, status).
324 sub_action: Option<String>,
325 },
326 /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
327 Hook {
328 /// Optional workspace path or sub-action (enable, disable, status).
329 target: Option<String>,
330 /// Sub-action if path was provided (enable, disable, status).
331 sub_action: Option<String>,
332 /// Install in front of the hooks directory already configured, forwarding to it,
333 /// instead of refusing to take a slot another tool is using.
334 #[arg(long)]
335 chain: bool,
336 },
337 /// Register a file-manager icon for .devprune.json, and print an editor snippet.
338 Icon,
339 /// Walk through every global setting, confirming or changing each one.
340 Wizard,
341}
342
343/// Create the `devp` executable alias next to `dev-prune`, and keep it current.
344///
345/// Runs on every invocation because it is two `stat` calls in the settled case, and
346/// because the alias is how most people invoke this tool — it must never be the stale
347/// half of an upgrade.
348///
349/// `DEV_PRUNE_NO_AUTO_SETUP` suppresses it, because writing a second executable next to
350/// the first is a self-installation like any other. `devp setup` still creates the alias
351/// when the variable is set: the variable governs the unattended pass, not the explicit
352/// request.
353pub fn ensure_devp_alias() {
354 if std::env::var_os(setup::ENV_NO_AUTO_SETUP).is_some() {
355 return;
356 }
357 let _ = setup::ensure_alias();
358}
359
360/// Print rich version & system environment details for -v / -V / --version.
361pub fn print_version_info() {
362 output::print_banner();
363 println!("dev-prune (devp) v{}", constants::VERSION);
364 println!(" Binary Aliases: dev-prune | devp (interchangeable)");
365 println!(" Target OS: {}", std::env::consts::OS);
366 println!(" Architecture: {}", std::env::consts::ARCH);
367 println!(" Compiler: Rust 1.85+ (edition 2024)");
368 println!(" License: Apache-2.0 (no analytics, no diagnostics)");
369 println!();
370 let reg_path = config::Registry::registry_path()
371 .map(|p| output::clean_path(&p))
372 .unwrap_or_else(|_| "unknown".to_string());
373 println!(" Config Path: {reg_path}");
374
375 if let Ok(exe) = std::env::current_exe() {
376 if let Some(exe_dir) = exe.parent() {
377 let exe_dir_str = output::clean_path(exe_dir);
378 let path_var = std::env::var("PATH").unwrap_or_default();
379 let is_in_path = path_var
380 .split(if cfg!(windows) { ';' } else { ':' })
381 .any(|p| std::path::Path::new(p) == exe_dir);
382
383 println!(" Binary Dir: {exe_dir_str}");
384 if is_in_path {
385 println!(" PATH Audit: ✓ Executable directory is active in system PATH.");
386 } else {
387 println!(" PATH Audit: ⚠ Executable directory is NOT in system PATH!");
388 println!(" Add `{exe_dir_str}` to Environment Variables.");
389 }
390 }
391 }
392}
393
394/// Case-insensitive subcommand normalizer and status alias router.
395fn normalize_args() -> Vec<String> {
396 let args: Vec<String> = std::env::args().collect();
397 if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
398 print_version_info();
399 std::process::exit(exit_code::OK);
400 }
401 if args.len() <= 1
402 || args
403 .iter()
404 .any(|a| a == "-h" || a == "--help" || a == "help")
405 {
406 output::print_banner();
407 }
408 if args.len() <= 1 {
409 return args;
410 }
411
412 let mut normalized = vec![args[0].clone()];
413 for (i, arg) in args.iter().enumerate().skip(1) {
414 if i == 1 && !arg.starts_with('-') {
415 normalized.push(arg.to_lowercase());
416 } else {
417 normalized.push(arg.clone());
418 }
419 }
420
421 // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
422 //
423 // These live under `config` because that is where the rest of the persistent
424 // settings live, but nobody types `devp config hook install` when they mean
425 // "install the hook" — and the tool's own output has always said `devp hook
426 // install`. Accepting both costs one insert and removes a papercut.
427 if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
428 normalized.insert(1, "config".to_string());
429 }
430
431 // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
432 // Map `devp status [PATH] hook` -> `devp config hook [PATH] status`
433 if normalized.len() >= 3 && normalized[1] == "status" {
434 let last = normalized
435 .last()
436 .map(|s| s.to_lowercase())
437 .unwrap_or_default();
438 if last == "daemon" || last == "hook" {
439 let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
440 if normalized.len() > 3 {
441 rewrited.push(normalized[2].clone());
442 }
443 rewrited.push("status".to_string());
444 return rewrited;
445 }
446 }
447
448 // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
449 // Map `devp config [PATH] hook [ACTION]` -> `devp config hook [PATH] [ACTION]`
450 if normalized.len() >= 4 && normalized[1] == "config" {
451 let third = normalized[3].to_lowercase();
452 if third == "daemon" || third == "hook" {
453 let mut rewrited = vec![
454 normalized[0].clone(),
455 "config".to_string(),
456 third,
457 normalized[2].clone(),
458 ];
459 for extra in &normalized[4..] {
460 rewrited.push(extra.clone());
461 }
462 return rewrited;
463 }
464 }
465
466 normalized
467}
468
469/// Whether the automatic setup pass may run for this invocation.
470///
471/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
472/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
473/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
474/// for the next command a human types. `uninstall` is excluded for the obvious reason,
475/// and `setup` because it is the pass, run deliberately.
476fn auto_setup_allowed(args: &[String]) -> bool {
477 let subcommand = args.get(1).map(String::as_str).unwrap_or("");
478 !matches!(subcommand, "uninstall" | "setup")
479 && !args.iter().any(|a| a == "--quiet" || a == "--daemon")
480}
481
482/// Run the CLI application.
483pub fn run_cli() {
484 restore_sigpipe();
485 ensure_devp_alias();
486
487 let args = normalize_args();
488 if auto_setup_allowed(&args) {
489 setup::auto_setup_if_due();
490 }
491 let cli = Cli::parse_from(args);
492
493 // Both spellings mean the same thing; the old one just says so first.
494 let ignore_idle = cli.ignore_idle || cli.force;
495 if cli.force {
496 print_force_help();
497 }
498
499 // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
500 // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
501 // registers a directory literally named `~`.
502 let result = match cli.command {
503 Commands::Init { paths } => {
504 let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
505 commands::init::run(&paths, cli.dry_run)
506 }
507 Commands::Link { path, quiet } => {
508 commands::link::run_link(&config::expand_tilde(&path), quiet)
509 }
510 Commands::Unlink { path, missing } => {
511 if missing {
512 commands::link::run_unlink_missing()
513 } else {
514 commands::link::run_unlink(&config::expand_tilde(&path))
515 }
516 }
517 Commands::Undo => commands::undo::run(),
518 Commands::Run {
519 target_path,
520 daemon,
521 only,
522 skip,
523 min_size,
524 except,
525 json,
526 } => {
527 let target_path = target_path.map(|p| config::expand_tilde(&p));
528 commands::run::run(commands::run::RunArgs {
529 target_path: target_path.as_deref(),
530 dry_run: cli.dry_run,
531 force: ignore_idle,
532 yes: cli.yes,
533 daemon,
534 only: only.as_deref(),
535 skip: skip.as_deref(),
536 min_size_mb: min_size,
537 except: except.as_deref(),
538 json,
539 })
540 }
541 Commands::Status { json } => commands::status::run(json),
542 Commands::Caches { json } => commands::caches::run(json),
543 Commands::Config { action } => match action {
544 Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
545 Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
546 Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
547 Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
548 Some(ConfigAction::Project { path, update }) => {
549 commands::config::run_path_config(&config::expand_tilde(&path), update)
550 }
551 Some(ConfigAction::Daemon { target, sub_action }) => {
552 // A toggle word (`on`, `off`) never starts with `~`, so expanding the
553 // target before the match cannot turn one into a path.
554 let target = target.map(|t| config::expand_tilde(&t));
555 let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
556 (Some(t), Some(a)) => (Some(t), a),
557 (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
558 (Some(t), None) => (Some(t), "status"),
559 (None, Some(a)) => (None, a),
560 (None, None) => (None, "status"),
561 };
562 commands::config::run_daemon_toggle(path, action)
563 }
564 Some(ConfigAction::Hook {
565 target,
566 sub_action,
567 chain,
568 }) => {
569 let target = target.map(|t| config::expand_tilde(&t));
570 let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
571 (Some(t), Some(a)) => (Some(t), a),
572 (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
573 (Some(t), None) => (Some(t), "status"),
574 (None, Some(a)) => (None, a),
575 // `--chain` on its own is an install instruction, not a status query.
576 (None, None) if chain => (None, "install"),
577 (None, None) => (None, "status"),
578 };
579 commands::config::run_hook_toggle(path, action, chain)
580 }
581 Some(ConfigAction::Icon) => commands::icon::run_install(),
582 Some(ConfigAction::Wizard) => commands::config::run_wizard(),
583 },
584 Commands::Restore { path, last_run } => {
585 if last_run {
586 commands::restore::run_last_run()
587 } else {
588 commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
589 }
590 }
591 Commands::Update { offline } => commands::update::run(offline),
592 Commands::Skill => commands::skill::run(),
593 Commands::Setup { status } => commands::setup::run(status),
594 Commands::Doctor { path } => {
595 let path = path.map(|p| config::expand_tilde(&p));
596 commands::doctor::run(path.as_deref())
597 }
598 Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
599 };
600
601 if let Err(e) = result {
602 if is_broken_pipe(&e) {
603 std::process::exit(exit_code::OK);
604 }
605 output::print_error(&format!("{e:#}"));
606 std::process::exit(exit_code::FAILURE);
607 }
608}