dev-prune 1.11.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

// End-to-end coverage for `devp uninstall` — the one command whose job is deletion,
// and therefore the one whose tests most need to prove they touch nothing real.
//
// Isolation is layered. `DEV_PRUNE_CONFIG_DIR` puts the managed installation — the
// binaries, hooks, registry — inside a fresh temp directory. `DEV_PRUNE_NO_AUTO_SETUP`
// makes uninstall hands-off about everything setup would have installed: the real
// scheduler is not touched, no agent skill directory is deleted, and the stray-copy
// sweep searches only `PATH` — which every test here pins to its own directories, so
// the sweep can find exactly the strays the test planted and nothing on the
// developer's machine. `git` is deliberately absent from that `PATH`: the hook step
// must cope with a machine that has no git, and coping is what these tests observe.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;

/// The uninstall command, sandboxed as described in the header.
fn devp_uninstall(config_dir: &Path, extra_path_dirs: &[&Path]) -> Command {
    let exe = Path::new(env!("CARGO_BIN_EXE_dev-prune"));
    let mut cmd = Command::new(exe);
    cmd.env("DEV_PRUNE_NO_AUTO_SETUP", "1");
    cmd.env("DEV_PRUNE_OFFLINE", "1");
    cmd.env("DEV_PRUNE_CONFIG_DIR", config_dir);
    // The Linux file-type unregistration honours XDG_DATA_HOME; point it inside the
    // sandbox so the test can never sweep a real desktop database entry.
    cmd.env("XDG_DATA_HOME", config_dir.join("xdg-data"));
    let mut paths: Vec<PathBuf> = vec![exe.parent().unwrap().to_path_buf()];
    paths.extend(extra_path_dirs.iter().map(|p| p.to_path_buf()));
    cmd.env("PATH", std::env::join_paths(paths).unwrap());
    cmd.stdin(std::process::Stdio::null());
    cmd.arg("uninstall");
    cmd
}

/// A command against the same sandbox with the developer's own PATH intact — for the
/// fixture steps (like `link`) that need a working `git`.
fn devp_with_real_path(config_dir: &Path) -> Command {
    let exe = Path::new(env!("CARGO_BIN_EXE_dev-prune"));
    let mut cmd = Command::new(exe);
    cmd.env("DEV_PRUNE_NO_AUTO_SETUP", "1");
    cmd.env("DEV_PRUNE_OFFLINE", "1");
    cmd.env("DEV_PRUNE_CONFIG_DIR", config_dir);
    cmd
}

fn combined(out: &Output) -> String {
    format!(
        "{}\n{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    )
}

fn exe_name(stem: &str) -> String {
    if cfg!(windows) {
        format!("{stem}.exe")
    } else {
        stem.to_string()
    }
}

/// A fake managed installation: `<config>/bin` holding the pair of binaries.
fn fake_managed_install(config_dir: &Path) -> PathBuf {
    let bin = config_dir.join("bin");
    fs::create_dir_all(&bin).unwrap();
    for stem in ["dev-prune", "devp"] {
        fs::write(bin.join(exe_name(stem)), b"not a real binary").unwrap();
    }
    bin
}

/// A real registry in the sandbox, created the way a user would create one — by
/// linking a repository. Hand-written JSON would drift from the schema.
fn seed_registry(config_dir: &Path, repo_parent: &Path) {
    let repo = repo_parent.join("seedrepo");
    git_repo(&repo);
    let out = devp_with_real_path(config_dir)
        .args(["link", repo.to_str().unwrap()])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "seed link failed:\n{}",
        combined(&out)
    );
    assert!(config_dir.join("registry.json").exists());
}

/// An isolated git repository the sandbox registry can hold.
fn git_repo(path: &Path) {
    fs::create_dir_all(path).unwrap();
    let no_config = path.join("no-such-gitconfig");
    let run = |args: &[&str]| {
        Command::new("git")
            .args(args)
            .current_dir(path)
            .env("GIT_CONFIG_GLOBAL", &no_config)
            .env("GIT_CONFIG_SYSTEM", &no_config)
            .output()
            .unwrap()
    };
    run(&["init"]);
    fs::write(path.join("README.md"), "fixture").unwrap();
    run(&["add", "."]);
    run(&[
        "-c",
        "user.email=t@t",
        "-c",
        "user.name=t",
        "commit",
        "-m",
        "init",
    ]);
}

#[test]
fn a_light_uninstall_removes_the_managed_binaries_and_keeps_the_config() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    let bin = fake_managed_install(&config);
    seed_registry(&config, tmp.path());

    let out = devp_uninstall(&config, &[]).output().unwrap();
    assert!(
        out.status.success(),
        "light uninstall failed:\n{}",
        combined(&out)
    );

    for stem in ["dev-prune", "devp"] {
        assert!(
            !bin.join(exe_name(stem)).exists(),
            "{stem} survived a light uninstall"
        );
    }
    // Light keeps the configuration for a future reinstall — that promise is the
    // whole difference between the two modes.
    assert!(
        config.join("registry.json").exists(),
        "light uninstall deleted the registry:\n{}",
        combined(&out)
    );
    assert!(
        combined(&out).contains("preserved"),
        "success message missing:\n{}",
        combined(&out)
    );
}

#[test]
fn a_deep_uninstall_without_confirmation_refuses_and_deletes_nothing() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    let bin = fake_managed_install(&config);
    seed_registry(&config, tmp.path());

    // stdin is null, so the confirmation cannot be answered — the command must bail
    // out before deleting anything, not fall through to "no answer means yes".
    let out = devp_uninstall(&config, &[]).arg("--deep").output().unwrap();
    assert!(
        !out.status.success(),
        "deep uninstall proceeded without confirmation:\n{}",
        combined(&out)
    );
    assert!(
        combined(&out).contains("--yes"),
        "the refusal must name the flag that overrides it:\n{}",
        combined(&out)
    );
    assert!(bin.join(exe_name("devp")).exists());
    assert!(config.join("registry.json").exists());
}

#[test]
fn a_deep_uninstall_removes_the_config_directory_and_per_repo_configs() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    // A registered repository with a `.devprune.json` — deep uninstall promises to
    // remove that file but must not touch anything else in the repository.
    let repo = tmp.path().join("repo");
    git_repo(&repo);
    fs::write(repo.join(".devprune.json"), "{}").unwrap();
    let out = devp_with_real_path(&config)
        .args(["link", repo.to_str().unwrap()])
        .output()
        .unwrap();
    assert!(out.status.success(), "link failed:\n{}", combined(&out));

    let out = devp_uninstall(&config, &[])
        .args(["--deep", "--yes"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "deep uninstall failed:\n{}",
        combined(&out)
    );
    assert!(!config.exists(), "config directory survived --deep");
    assert!(
        !repo.join(".devprune.json").exists(),
        "per-repo config survived --deep"
    );
    assert!(
        repo.join("README.md").exists(),
        "repo contents were touched"
    );
    assert!(
        repo.join(".git").exists(),
        "the repository itself was touched"
    );
}

#[test]
fn the_sweep_lists_strays_but_deletes_nothing_without_yes() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    let straybin = tmp.path().join("straybin");
    fs::create_dir_all(&straybin).unwrap();
    let stray = straybin.join(exe_name("devp"));
    fs::write(&stray, b"a stray copy").unwrap();

    let out = devp_uninstall(&config, &[&straybin]).output().unwrap();
    let text = combined(&out);
    assert!(
        text.contains("more cop"),
        "the stray was not announced:\n{text}"
    );
    assert!(
        text.contains("straybin"),
        "the stray location was not shown:\n{text}"
    );
    // Not a terminal, no --yes: the sweep must say how to opt in and leave the file.
    assert!(
        text.contains("--yes"),
        "no pointer to --yes for a non-interactive run:\n{text}"
    );
    assert!(stray.exists(), "the sweep deleted without confirmation");
    // A declined sweep is a decision, not a failure.
    assert!(
        out.status.success(),
        "declining the sweep changed the exit code:\n{text}"
    );
}

#[test]
fn the_sweep_with_yes_deletes_strays_and_their_shims() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    let straybin = tmp.path().join("straybin");
    fs::create_dir_all(&straybin).unwrap();
    // On Windows npm leaves `.cmd`/`.ps1` shims and an extensionless sh shim beside
    // the real thing; each is a separate file the sweep must find by name.
    let names: &[&str] = if cfg!(windows) {
        &["devp.exe", "devp.cmd", "devp.ps1", "dev-prune.exe"]
    } else {
        &["devp", "dev-prune"]
    };
    for name in names {
        fs::write(straybin.join(name), b"a stray copy").unwrap();
    }
    // A bystander with a different name must never be touched, whatever happens.
    let bystander = straybin.join("devp-helper.sh");
    fs::write(&bystander, b"not ours").unwrap();

    let out = devp_uninstall(&config, &[&straybin])
        .arg("--yes")
        .output()
        .unwrap();
    let text = combined(&out);
    assert!(
        out.status.success(),
        "uninstall with a sweep failed:\n{text}"
    );
    for name in names {
        assert!(
            !straybin.join(name).exists(),
            "stray `{name}` survived a confirmed sweep:\n{text}"
        );
    }
    assert!(
        bystander.exists(),
        "the sweep deleted a file that is not dev-prune's"
    );
    assert!(text.contains("stray cop"), "no removal report:\n{text}");
}

#[test]
fn a_manager_owned_stray_is_never_deleted_behind_its_managers_back() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    // `Channel::detect_at` classifies by path alone, so a `.cargo/bin` anywhere is
    // cargo's. Both names go in the one directory: that is the case that used to run
    // `cargo uninstall dev-prune` twice, the second exiting 101 on a package it had
    // already removed.
    let cargobin = tmp.path().join(".cargo").join("bin");
    fs::create_dir_all(&cargobin).unwrap();
    let planted: Vec<PathBuf> = ["dev-prune", "devp"]
        .iter()
        .map(|stem| {
            let path = cargobin.join(exe_name(stem));
            fs::write(&path, b"a cargo-installed copy").unwrap();
            path
        })
        .collect();

    // The sandbox PATH carries no `cargo`, which is both what keeps this test off the
    // machine it runs on and the exact condition the guard exists for.
    let out = devp_uninstall(&config, &[&cargobin])
        .arg("--yes")
        .output()
        .unwrap();
    let text = combined(&out);
    assert!(out.status.success(), "uninstall failed:\n{text}");

    // Deleting the file would leave cargo listing a binary that is gone, and
    // `cargo uninstall` then exits 101 without clearing the entry — so the remedy
    // printed below could never work afterwards.
    for path in &planted {
        assert!(
            path.exists(),
            "a cargo-owned copy was deleted behind cargo's back: {}\n{text}",
            path.display()
        );
    }
    assert!(
        text.contains("cargo is not on PATH"),
        "the copy was skipped without saying why:\n{text}"
    );
    assert_eq!(
        text.matches("cargo is not on PATH").count(),
        1,
        "the manager was reported once per file rather than once:\n{text}"
    );
    assert!(
        text.contains("cargo uninstall dev-prune"),
        "the command that finishes the job was not printed:\n{text}"
    );
}

#[test]
fn a_copy_inside_a_manager_dev_prune_cannot_drive_is_named_and_left() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    // Deno installs global executables into `~/.deno/bin` and leaves no fragment any
    // other marker matches. Until `Channel::Foreign` existed this classified as
    // `Unknown`, and a confirmed sweep deleted it out from under Deno.
    let denobin = tmp.path().join(".deno").join("bin");
    fs::create_dir_all(&denobin).unwrap();
    let planted: Vec<PathBuf> = ["dev-prune", "devp"]
        .iter()
        .map(|stem| {
            let path = denobin.join(exe_name(stem));
            fs::write(&path, b"a deno-installed copy").unwrap();
            path
        })
        .collect();

    let out = devp_uninstall(&config, &[&denobin])
        .arg("--yes")
        .output()
        .unwrap();
    let text = combined(&out);
    assert!(out.status.success(), "uninstall failed:\n{text}");

    for path in &planted {
        assert!(
            path.exists(),
            "a Deno-owned copy was deleted behind Deno's back: {}\n{text}",
            path.display()
        );
    }
    assert!(
        text.contains("Deno"),
        "the copy was left without naming who owns it:\n{text}"
    );
}

#[test]
fn the_sweep_never_touches_a_development_build() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    // The running test binary lives in `target/debug` and is on the sweep's PATH via
    // its own parent directory. Deleting it would destroy the build being tested —
    // the dev-build guard is what stands between the sweep and that outcome.
    let out = devp_uninstall(&config, &[]).arg("--yes").output().unwrap();
    assert!(
        out.status.success(),
        "uninstall failed:\n{}",
        combined(&out)
    );
    assert!(
        Path::new(env!("CARGO_BIN_EXE_dev-prune")).exists(),
        "the sweep deleted the development build under test"
    );
}

#[test]
fn hands_off_uninstall_reports_what_it_leaves_alone() {
    let tmp = TempDir::new().unwrap();
    let config = tmp.path().join("config");
    fake_managed_install(&config);

    let out = devp_uninstall(&config, &[]).output().unwrap();
    let text = combined(&out);
    // `DEV_PRUNE_NO_AUTO_SETUP` is set in every one of these tests; the command must
    // say that the scheduler and skills were deliberately skipped, not silently
    // pretend it removed them.
    assert!(
        text.contains("DEV_PRUNE_NO_AUTO_SETUP"),
        "the hands-off skip was silent:\n{text}"
    );
}