pristine-cli 0.1.0

A language-agnostic reclaimable-space finder and 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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
//! What repo mode promises, run as the binary against a real git.
//!
//! Almost none of this can be proved against a mock. The whole design decision behind the mode
//! is "let git decide what is removable", so a fixture that answers the way we *expect* git to
//! answer proves nothing at all — it proves our expectation. Every test here builds a real work
//! tree and reads what git actually says about it.
//!
//! The load-bearing one is [`removing_untracked_files_does_not_take_the_ignored_cache_beside_them`].
//! It is the regression test for the bug that cost real data in the Node predecessor, and it is
//! the entire reason enumeration goes through `git clean -n` rather than
//! `git ls-files --others --directory`.

// `allow-unwrap-in-tests` in clippy.toml only reaches code inside a `#[test]` function, and the
// fixture helpers below sit outside one. An unwrap in a fixture is an assertion.
#![allow(clippy::unwrap_used, clippy::expect_used)]

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use tempfile::TempDir;

/// Runs git in `dir` with the developer's machine shut out, and returns its stdout.
fn git(dir: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .env("GIT_AUTHOR_NAME", "pristine")
        .env("GIT_AUTHOR_EMAIL", "pristine@example.invalid")
        .env("GIT_COMMITTER_NAME", "pristine")
        .env("GIT_COMMITTER_EMAIL", "pristine@example.invalid")
        .env_remove("GIT_DIR")
        .env_remove("GIT_INDEX_FILE")
        .env_remove("GIT_WORK_TREE")
        .stdin(Stdio::null())
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git {args:?} in {}: {}",
        dir.display(),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).into_owned()
}

fn write(path: &Path, contents: &str) {
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    fs::write(path, contents).unwrap();
}

/// A work tree with one tracked file and one commit, resolved so a test can compare paths
/// against what a plan holds.
fn checkout() -> (TempDir, PathBuf) {
    let tmp = TempDir::new().unwrap();
    let root = fs::canonicalize(tmp.path()).unwrap();
    git(&root, &["init", "--quiet"]);
    write(&root.join("tracked.txt"), "the original\n");
    git(&root, &["add", "tracked.txt"]);
    git(&root, &["commit", "--quiet", "-m", "first"]);
    (tmp, root)
}

struct Run {
    stdout: String,
    stderr: String,
    ok: bool,
}

/// Runs `pristine repo <root> <args>` with `answer` on its standard input.
fn run(root: &Path, args: &[&str], answer: &str) -> Run {
    let mut child = Command::new(env!("CARGO_BIN_EXE_pristine"))
        .arg("repo")
        .arg(root)
        .args(args)
        // Repo mode reads git's prose. A developer running the suite under a translated
        // locale must not get a different answer from CI, and the binary forcing `LC_ALL=C`
        // for its own invocations is what makes that true — so the test hands it the hostile
        // environment rather than a clean one.
        .env("LANGUAGE", "de")
        .env("LC_ALL", "de_DE.UTF-8")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(answer.as_bytes())
        .unwrap();
    let output = child.wait_with_output().unwrap();
    Run {
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        ok: output.status.success(),
    }
}

/// Runs it and asserts it exited cleanly.
fn succeeds(root: &Path, args: &[&str], answer: &str) -> String {
    let run = run(root, args, answer);
    assert!(
        run.ok,
        "pristine repo {args:?} failed:\n{}\n{}",
        run.stdout, run.stderr
    );
    run.stdout
}

// ------------------------------------------------------------------------------------------
// The correction that cost real data: untracked and ignored are two independent choices, and
// taking one must not take the other.
// ------------------------------------------------------------------------------------------

/// Builds the exact shape the bug was found on: a directory holding an ignored cache beside
/// untracked data, with nothing tracked anywhere inside it.
fn mixed_directory(root: &Path) {
    write(&root.join(".gitignore"), ".nx/cache/\n");
    git(root, &["add", ".gitignore"]);
    git(root, &["commit", "--quiet", "-m", "ignore the cache"]);
    write(&root.join(".nx/cache/hash.bin"), "expensive to rebuild\n");
    write(&root.join(".nx/workspace-data/state.json"), "{}\n");
}

#[test]
fn removing_untracked_files_does_not_take_the_ignored_cache_beside_them() {
    let (_tmp, root) = checkout();
    mixed_directory(&root);

    let printed = succeeds(&root, &["--untracked", "--yes"], "");

    assert!(
        !root.join(".nx/workspace-data").exists(),
        "the untracked half survived:\n{printed}"
    );
    // The bug. `git ls-files --others --directory` collapses `.nx/` to one entry because
    // nothing in it is TRACKED, so removing untracked files also wiped the ignored cache the
    // user had chosen to keep. `git clean` collapses only when everything inside is going.
    assert!(
        root.join(".nx/cache/hash.bin").exists(),
        "removing untracked files took the ignored cache with them:\n{printed}"
    );
}

#[test]
fn removing_ignored_files_does_not_take_the_untracked_data_beside_them() {
    let (_tmp, root) = checkout();
    mixed_directory(&root);

    let printed = succeeds(&root, &["--ignored", "--yes"], "");

    assert!(!root.join(".nx/cache").exists(), "{printed}");
    assert!(
        root.join(".nx/workspace-data/state.json").exists(),
        "removing ignored files took the untracked data with them:\n{printed}"
    );
}

#[test]
fn a_tracked_file_is_never_removed_whatever_was_asked_for() {
    let (_tmp, root) = checkout();
    write(&root.join(".gitignore"), "dist/\n");
    write(&root.join("dist/bundle.js"), "built\n");
    write(&root.join("scratch.txt"), "untracked\n");

    succeeds(
        &root,
        &[
            "--untracked",
            "--ignored",
            "--node-modules",
            "--env",
            "--yes",
        ],
        "",
    );

    assert!(root.join("tracked.txt").exists(), "a tracked file went");
    assert!(!root.join("dist").exists());
    assert!(!root.join("scratch.txt").exists());
}

// ------------------------------------------------------------------------------------------
// Vendor and env are excluded from a list the user did ask for.
// ------------------------------------------------------------------------------------------

/// A checkout whose ignored set holds one ordinary artefact, one vendor directory and one env
/// file, plus an untracked env file that no ignore rule hides.
fn sediment(root: &Path) {
    write(
        &root.join(".gitignore"),
        "dist/\nnode_modules/\n.env.local\n",
    );
    git(root, &["add", ".gitignore"]);
    git(root, &["commit", "--quiet", "-m", "ignore the usual"]);
    write(&root.join("dist/bundle.js"), "built\n");
    write(&root.join("node_modules/left-pad/index.js"), "module\n");
    write(&root.join(".env.local"), "SECRET=1\n");
    write(&root.join(".env"), "SECRET=2\n");
}

#[test]
fn vendor_and_env_survive_a_run_that_did_not_ask_for_them() {
    let (_tmp, root) = checkout();
    sediment(&root);

    let printed = succeeds(&root, &["--untracked", "--ignored", "--yes"], "");

    assert!(!root.join("dist").exists(), "{printed}");
    assert!(
        root.join("node_modules/left-pad/index.js").exists(),
        "{printed}"
    );
    assert!(root.join(".env.local").exists(), "{printed}");
    // The untracked one too. The guard is about what the file is worth, not about which of
    // git's two lists it arrived in, and an env file git is not even hiding is the most
    // precious kind rather than the least.
    assert!(root.join(".env").exists(), "{printed}");
    // A count with no way to act on it is a puzzle rather than a report.
    assert!(printed.contains("--node-modules"), "{printed}");
    assert!(printed.contains("--env"), "{printed}");
}

#[test]
fn opting_in_takes_them() {
    let (_tmp, root) = checkout();
    sediment(&root);

    let printed = succeeds(
        &root,
        &[
            "--untracked",
            "--ignored",
            "--node-modules",
            "--env",
            "--yes",
        ],
        "",
    );

    assert!(!root.join("node_modules").exists(), "{printed}");
    assert!(!root.join(".env.local").exists(), "{printed}");
    assert!(!root.join(".env").exists(), "{printed}");
    assert!(root.join("tracked.txt").exists(), "{printed}");
}

// ------------------------------------------------------------------------------------------
// ...and they survive being hidden behind a directory git collapsed over them.
//
// `git clean` emits a whole directory whenever everything inside it is removable, so a target
// is not a description of its own contents. Judging only the emitted path means a `.env` or a
// `node_modules` one level down is invisible, and the exclusion that was reported as holding
// them back never sees them at all.
// ------------------------------------------------------------------------------------------

#[test]
fn an_untracked_directory_that_hides_an_env_file_is_held_back() {
    let (_tmp, root) = checkout();
    write(&root.join("docker/compose.yaml"), "services: {}\n");
    write(&root.join("docker/.env"), "SECRET=1\n");
    // git offers the directory, not its contents: everything inside is untracked.
    let offered = git(&root, &["clean", "-n", "-d"]);
    assert!(offered.contains("docker/"), "{offered}");
    assert!(
        !offered.contains(".env"),
        "the fixture did not collapse: {offered}"
    );

    let printed = succeeds(&root, &["--untracked", "--yes"], "");

    assert!(
        root.join("docker/.env").exists(),
        "an env file was deleted from behind a collapsed directory:\n{printed}"
    );
    assert!(root.join("docker/compose.yaml").exists(), "{printed}");
    // Named, with the flag that would release it. A count nobody can act on is a puzzle.
    assert!(printed.contains("docker"), "{printed}");
    assert!(printed.contains("--env"), "{printed}");
}

#[test]
fn an_untracked_directory_that_hides_node_modules_is_held_back() {
    let (_tmp, root) = checkout();
    write(&root.join("pkg/index.js"), "source\n");
    write(&root.join("pkg/node_modules/left-pad/index.js"), "module\n");

    let printed = succeeds(&root, &["--untracked", "--yes"], "");

    assert!(
        root.join("pkg/node_modules/left-pad/index.js").exists(),
        "a vendored tree was deleted from behind a collapsed directory:\n{printed}"
    );
    assert!(printed.contains("--node-modules"), "{printed}");
}

#[test]
fn an_ignored_directory_that_hides_an_env_file_is_held_back() {
    let (_tmp, root) = checkout();
    // The half git cannot help with. `-e` protects a pattern in the untracked pass, but under
    // `-X` it makes it a TARGET instead, and no pathspec stops the collapse — so the ignored
    // list has to be judged here or not at all.
    write(&root.join(".gitignore"), "build/\n");
    git(&root, &["add", ".gitignore"]);
    git(&root, &["commit", "--quiet", "-m", "ignore build"]);
    write(&root.join("build/out.js"), "built\n");
    write(&root.join("build/.env"), "SECRET=1\n");
    let offered = git(&root, &["clean", "-n", "-d", "-X"]);
    assert!(offered.contains("build/"), "{offered}");
    assert!(
        !offered.contains(".env"),
        "the fixture did not collapse: {offered}"
    );

    let printed = succeeds(&root, &["--ignored", "--yes"], "");

    assert!(
        root.join("build/.env").exists(),
        "an env file was deleted from behind a collapsed ignored directory:\n{printed}"
    );
}

#[test]
fn opting_in_releases_a_directory_that_was_only_held_back_by_what_it_hides() {
    let (_tmp, root) = checkout();
    write(&root.join("docker/compose.yaml"), "services: {}\n");
    write(&root.join("docker/.env"), "SECRET=1\n");
    write(&root.join("pkg/node_modules/left-pad/index.js"), "module\n");

    let printed = succeeds(
        &root,
        &["--untracked", "--node-modules", "--env", "--yes"],
        "",
    );

    assert!(!root.join("docker").exists(), "{printed}");
    assert!(!root.join("pkg").exists(), "{printed}");
}

#[cfg(unix)]
#[test]
fn a_directory_that_cannot_be_read_is_not_a_directory_that_was_cleared() {
    use std::os::unix::fs::PermissionsExt;
    let (_tmp, root) = checkout();
    write(&root.join("opaque/inner/thing.txt"), "who knows\n");
    let sealed = root.join("opaque/inner");
    fs::set_permissions(&sealed, fs::Permissions::from_mode(0o000)).unwrap();
    if fs::read_dir(&sealed).is_ok() {
        fs::set_permissions(&sealed, fs::Permissions::from_mode(0o755)).unwrap();
        return; // running as root, where permissions prove nothing
    }

    // git offers `opaque/` regardless — it warns that it could not open `inner` and collapses
    // anyway, because its guarantee is about the index and the index it can read.
    let offered = git(&root, &["clean", "-n", "-d"]);
    assert!(offered.contains("opaque/"), "{offered}");

    let run = run(&root, &["--untracked", "--yes"], "");
    fs::set_permissions(&sealed, fs::Permissions::from_mode(0o755)).unwrap();

    // #588's lesson, one layer further out: "I could not look" must not read as "there was
    // nothing there". An unreadable subtree could hold anything, env files included.
    assert!(
        root.join("opaque/inner/thing.txt").exists(),
        "a subtree nothing could read was removed anyway:\n{}",
        run.stdout
    );
    // The deleter would have stopped this too — it refuses a directory it cannot read — but it
    // would have stopped it as a FAILURE, half-way in and with a non-zero exit. Catching it
    // while the plan is built is the difference between a run that declined to do something
    // and a run that broke, so this asserts which of the two happened.
    assert!(
        run.stdout.contains("held back"),
        "the unreadable subtree was not caught while planning:\n{}",
        run.stdout
    );
    assert!(run.ok, "declining to act is not a failure:\n{}", run.stderr);
}

// ------------------------------------------------------------------------------------------
// `--yes` gates the confirmation and nothing else.
// ------------------------------------------------------------------------------------------

#[test]
fn an_action_flag_without_yes_still_refuses_to_delete() {
    let (_tmp, root) = checkout();
    sediment(&root);

    // The whole point of the split: a script that selected something has not thereby consented
    // to it. With nothing on standard input the confirmation reads as no.
    let run = run(&root, &["--ignored"], "");

    assert!(run.ok, "{}", run.stderr);
    assert!(root.join("dist/bundle.js").exists(), "{}", run.stdout);
    assert!(run.stdout.contains("[y/N]"), "{}", run.stdout);
    assert!(run.stdout.contains("nothing was"), "{}", run.stdout);
}

#[test]
fn a_bare_enter_at_the_confirmation_removes_nothing() {
    let (_tmp, root) = checkout();
    sediment(&root);

    let run = run(&root, &["--ignored"], "\n");

    assert!(run.ok, "{}", run.stderr);
    assert!(root.join("dist/bundle.js").exists(), "enter was consent");
}

#[test]
fn saying_yes_at_the_confirmation_removes_what_the_plan_listed() {
    let (_tmp, root) = checkout();
    sediment(&root);

    let run = run(&root, &["--ignored"], "y\n");

    assert!(run.ok, "{}", run.stderr);
    assert!(!root.join("dist").exists(), "{}", run.stdout);
}

#[test]
fn yes_on_its_own_selects_nothing() {
    let (_tmp, root) = checkout();
    sediment(&root);

    // `--yes` is consent, not an instruction. It also makes the run non-interactive, so this
    // is the shape that would hang if the rule were the other way round.
    let printed = succeeds(&root, &["--yes"], "");

    assert!(root.join("dist/bundle.js").exists(), "{printed}");
    assert!(printed.contains("nothing selected"), "{printed}");
}

#[test]
fn yes_does_not_ask_what_to_do_however_eagerly_the_input_answers() {
    let (_tmp, root) = checkout();
    sediment(&root);
    write(&root.join("tracked.txt"), "changed\n");

    // Closed input proves nothing here, because every prompt defaults to no anyway. The
    // dangerous shape is an input that says yes to everything: if `--yes` let the cascade run,
    // it would select reset AND both lists and then skip the final confirmation it is supposed
    // to be the answer TO — turning "I consent to what I asked for" into "I consent to
    // whatever I am about to be asked".
    let printed = succeeds(&root, &["--yes"], "3\ny\ny\ny\ny\ny\n");

    assert!(
        !printed.contains("[y/N]"),
        "--yes reached a prompt:\n{printed}"
    );
    assert!(!printed.contains("Reset changed"), "{printed}");
    assert!(printed.contains("nothing selected"), "{printed}");
    assert!(root.join("dist/bundle.js").exists(), "{printed}");
    assert!(root.join("node_modules").exists(), "{printed}");
    assert_eq!(
        fs::read_to_string(root.join("tracked.txt")).unwrap(),
        "changed\n",
        "--yes reset a work tree nobody asked it to"
    );
}

// ------------------------------------------------------------------------------------------
// The dry run.
// ------------------------------------------------------------------------------------------

#[test]
fn a_dry_run_prints_the_plan_and_touches_nothing() {
    let (_tmp, root) = checkout();
    sediment(&root);
    write(&root.join("tracked.txt"), "changed\n");

    let printed = succeeds(
        &root,
        &[
            "--reset=hard",
            "--untracked",
            "--ignored",
            "--dry-run",
            "--yes",
        ],
        "",
    );

    assert!(root.join("dist/bundle.js").exists(), "{printed}");
    assert_eq!(
        fs::read_to_string(root.join("tracked.txt")).unwrap(),
        "changed\n",
        "a dry run reset the work tree:\n{printed}"
    );
    // The resolved plan, relative to the work tree root, plus what it would have reset.
    assert!(
        printed.lines().any(|line| line.ends_with("  dist")),
        "{printed}"
    );
    assert!(printed.contains("git reset --hard HEAD"), "{printed}");
    assert!(!printed.contains(root.to_str().unwrap()), "{printed}");
    assert!(printed.contains("dry run"), "{printed}");
}

// ------------------------------------------------------------------------------------------
// The reset verbs, and the one ordering that has a consequence.
// ------------------------------------------------------------------------------------------

#[test]
fn reset_worktree_discards_the_working_copy_and_leaves_the_index() {
    let (_tmp, root) = checkout();
    write(&root.join("staged.txt"), "staged\n");
    git(&root, &["add", "staged.txt"]);
    write(&root.join("tracked.txt"), "changed\n");

    succeeds(&root, &["--reset=worktree", "--yes"], "");

    assert_eq!(
        fs::read_to_string(root.join("tracked.txt")).unwrap(),
        "the original\n",
        "`git restore -- .` did not restore the working tree"
    );
    // `git restore -- .` is the working tree only, which is exactly what distinguishes it
    // from `hard`: a staged addition survives it.
    assert!(
        git(&root, &["diff", "--cached", "--name-only"]).contains("staged.txt"),
        "the index went with the working tree"
    );
}

#[test]
fn reset_hard_discards_the_index_too() {
    let (_tmp, root) = checkout();
    write(&root.join("staged.txt"), "staged\n");
    git(&root, &["add", "staged.txt"]);
    write(&root.join("tracked.txt"), "changed\n");

    succeeds(&root, &["--reset=hard", "--yes"], "");

    assert_eq!(
        fs::read_to_string(root.join("tracked.txt")).unwrap(),
        "the original\n"
    );
    assert!(
        git(&root, &["diff", "--cached", "--name-only"])
            .trim()
            .is_empty(),
        "a hard reset left the index alone"
    );
    // And the file itself is gone, which is the sharpest difference between the two verbs and
    // worth pinning: `git reset --hard HEAD` checks the working tree back out to match HEAD,
    // and a file that is in the index but not in HEAD is deleted rather than merely unstaged.
    // `git clean` never offered it — it was tracked when the plan was built — so this is the
    // reset's doing, and it is why `hard` is the answer a user has to reach for deliberately.
    assert!(
        !root.join("staged.txt").exists(),
        "a hard reset kept a file that is not in HEAD"
    );
}

#[test]
fn a_bare_reset_is_a_hard_one() {
    let (_tmp, root) = checkout();
    write(&root.join("staged.txt"), "staged\n");
    git(&root, &["add", "staged.txt"]);

    succeeds(&root, &["--reset", "--yes"], "");

    assert!(
        git(&root, &["diff", "--cached", "--name-only"])
            .trim()
            .is_empty(),
        "a bare --reset was not a hard one"
    );
}

#[test]
fn a_reset_that_makes_a_planned_target_tracked_does_not_delete_it() {
    let (_tmp, root) = checkout();
    // The stale-index window, and the reason the plan cannot outlive the reset. `git rm
    // --cached` takes the file out of the INDEX and leaves it on disk, so `git clean -n -d`
    // reports it as untracked and it lands on the plan — and then `git reset --hard HEAD` puts
    // it back in the index, making it a tracked file. A plan built before the reset and
    // executed after it deletes a file that is, by the time of the unlink, tracked and
    // committed.
    git(&root, &["rm", "--cached", "--quiet", "tracked.txt"]);
    write(&root.join("scratch.txt"), "untracked\n");
    assert!(
        git(&root, &["clean", "-n", "-d"]).contains("tracked.txt"),
        "the fixture did not reach the state the bug needs"
    );

    let printed = succeeds(&root, &["--reset=hard", "--untracked", "--yes"], "");

    assert!(
        root.join("tracked.txt").exists(),
        "a file the reset made tracked was deleted by a plan built before it:\n{printed}"
    );
    assert_eq!(
        fs::read_to_string(root.join("tracked.txt")).unwrap(),
        "the original\n"
    );
    // The rest of the run still happens: this is a narrowing, not an abort.
    assert!(!root.join("scratch.txt").exists(), "{printed}");
}

#[test]
fn a_reset_that_makes_git_collapse_a_directory_does_not_widen_the_plan() {
    let (_tmp, root) = checkout();
    // The other half of the same window. `dir/` holds an untracked file and a file staged but
    // never committed — nothing protected, so this isolates the narrowing from the exclusions.
    write(&root.join("dir/a.txt"), "untracked\n");
    write(&root.join("dir/staged.txt"), "staged\n");
    git(&root, &["add", "dir/staged.txt"]);

    // Before the reset git will not collapse `dir/`, because `staged.txt` is tracked, so the
    // plan names `dir/a.txt` — a file inside it, not the directory.
    let before = git(&root, &["clean", "-n", "-d"]);
    assert!(before.contains("dir/a.txt"), "{before}");
    assert!(!before.contains("Would remove dir/\n"), "{before}");

    let printed = succeeds(&root, &["--reset=hard", "--untracked", "--yes"], "");

    // The reset deletes `staged.txt`, and git then collapses `dir/` — so a re-enumeration that
    // was merely trusted would remove the whole directory when the plan named one file in it.
    assert!(
        printed.contains("withdrawn after the reset"),
        "the widening was not reported:\n{printed}"
    );
    assert!(
        root.join("dir/a.txt").exists(),
        "the withdrawn target was removed anyway:\n{printed}"
    );
    // Nothing was committed, so the reset removed the staged file.
    assert!(!root.join("dir/staged.txt").exists(), "{printed}");
}

#[test]
fn a_reset_that_uncovers_an_env_file_holds_the_directory_back_on_this_run_and_the_next() {
    let (_tmp, root) = checkout();
    // The same widening, with an env file in the directory — which is what makes it
    // destructive rather than merely wrong. The two guards stack here: the reset uncovers a
    // collapsed `dir/`, and `dir/` holds a `.env` that no plan ever offered.
    write(&root.join("dir/a.txt"), "untracked\n");
    write(&root.join("dir/.env"), "SECRET=1\n");
    write(&root.join("dir/staged.txt"), "staged\n");
    git(&root, &["add", "dir/staged.txt"]);

    let printed = succeeds(&root, &["--reset=hard", "--untracked", "--yes"], "");

    assert!(
        root.join("dir/.env").exists(),
        "the reset widened the plan onto a file that was deliberately excluded:\n{printed}"
    );

    // The rerun the report tells the user to do. `dir/` is collapsed for real now, so the
    // second run sees the shape the first one refused to act on — and it still must not take
    // the env file, which is what makes the first run's refusal a deferral rather than a
    // loophole. This is the rerun that a fix living only in the reset path would fail.
    let again = succeeds(&root, &["--untracked", "--yes"], "");

    assert!(
        root.join("dir/.env").exists(),
        "the rerun deleted the env file the first run protected:\n{again}"
    );
    // The whole directory stays, `dir/a.txt` included. git offers `dir/` as one entry and it
    // holds an env file, so it is held back whole — expanding it would mean deciding for
    // ourselves what inside it is removable, which is the reimplementation this mode exists to
    // avoid. The report names it and the flag that releases it.
    assert!(root.join("dir/a.txt").exists(), "{again}");
    assert!(again.contains("held back"), "{again}");
    assert!(again.contains("--env includes it"), "{again}");
}

#[test]
fn the_reset_happens_before_anything_is_removed() {
    let (_tmp, root) = checkout();
    // A tracked file deleted from the working tree, and an untracked file beside it. The reset
    // puts the tracked file back; the removal takes the untracked one. Run in the other order
    // the restored file would be a fresh untracked file the plan had already listed — which is
    // the only way these two steps can interfere, and the reason the order is stated at all.
    fs::remove_file(root.join("tracked.txt")).unwrap();
    write(&root.join("scratch.txt"), "untracked\n");

    succeeds(&root, &["--reset=hard", "--untracked", "--yes"], "");

    assert!(
        root.join("tracked.txt").exists(),
        "the reset did not restore the tracked file, or the removal took it back off"
    );
    assert!(!root.join("scratch.txt").exists());
}

// ------------------------------------------------------------------------------------------
// What git will not clean, and what is not a work tree at all.
// ------------------------------------------------------------------------------------------

#[test]
fn a_nested_checkout_is_left_alone_and_named() {
    let (_tmp, root) = checkout();
    write(&root.join(".gitignore"), "sandboxes/\n");
    git(&root, &["add", ".gitignore"]);
    git(&root, &["commit", "--quiet", "-m", "ignore the sandboxes"]);
    let inner = root.join("sandboxes/work");
    fs::create_dir_all(&inner).unwrap();
    git(&inner, &["init", "--quiet"]);
    write(&inner.join("uncommitted.txt"), "exists nowhere else\n");

    let printed = succeeds(&root, &["--ignored", "--yes"], "");

    assert!(
        inner.join("uncommitted.txt").exists(),
        "a nested checkout was removed:\n{printed}"
    );
    // Reported rather than silently absent. A user who does not see it named reads this run as
    // having covered everything.
    assert!(printed.contains("nested repositor"), "{printed}");
    assert!(printed.contains("sandboxes/work"), "{printed}");
}

#[test]
fn outside_a_work_tree_it_says_so_and_exits_non_zero() {
    let tmp = TempDir::new().unwrap();

    let run = run(tmp.path(), &["--ignored", "--yes"], "");

    assert!(!run.ok, "a directory that is not a checkout succeeded");
    assert!(run.stderr.contains("git work tree"), "{}", run.stderr);
}

#[test]
fn a_path_inside_the_checkout_cleans_the_whole_checkout() {
    let (_tmp, root) = checkout();
    write(&root.join(".gitignore"), "dist/\n");
    git(&root, &["add", ".gitignore"]);
    git(&root, &["commit", "--quiet", "-m", "ignore dist"]);
    write(&root.join("dist/a.js"), "built\n");
    write(&root.join("packages/web/dist/b.js"), "built\n");

    // Pointed at a subdirectory. `git clean` scoped there would clean only that subtree while
    // `git reset --hard` resets everything regardless, so the mode takes the checkout instead
    // of letting one run mean two different things by "here".
    let printed = succeeds(&root.join("packages/web"), &["--ignored", "--yes"], "");

    assert!(!root.join("dist").exists(), "{printed}");
    assert!(!root.join("packages/web/dist").exists(), "{printed}");
}

// ------------------------------------------------------------------------------------------
// The interactive cascade, driven through the real binary.
// ------------------------------------------------------------------------------------------

#[test]
fn a_run_with_no_flags_and_no_input_does_nothing() {
    let (_tmp, root) = checkout();
    sediment(&root);
    write(&root.join("tracked.txt"), "changed\n");

    // The CI shape that must not hang and must not delete. Every question defaults to the
    // answer that changes nothing, and end of input is every question at once.
    let printed = succeeds(&root, &[], "");

    assert!(root.join("dist/bundle.js").exists(), "{printed}");
    assert_eq!(
        fs::read_to_string(root.join("tracked.txt")).unwrap(),
        "changed\n"
    );
    assert!(
        printed.contains("Reset changed (tracked) files?"),
        "{printed}"
    );
}

#[test]
fn the_cascade_reaches_the_deleter_when_it_is_answered() {
    let (_tmp, root) = checkout();
    sediment(&root);

    // reset: no. untracked: no. ignored: yes. vendor: no. env: no. proceed: yes.
    let printed = succeeds(&root, &[], "1\nn\ny\nn\nn\ny\n");

    assert!(!root.join("dist").exists(), "{printed}");
    assert!(
        root.join("node_modules").exists(),
        "vendor was not held back"
    );
    assert!(root.join(".env.local").exists(), "env was not held back");
    assert!(root.join(".env").exists(), "{printed}");
}