pristine/repo.rs
1//! Repo mode: one checkout, cleaned the way `git clean -fdx` cleans it.
2//!
3//! This is the port of the unpublished Node `@agentender/pristine`, and the whole of its good
4//! idea is that **it enumerates nothing itself**. Point it at a work tree and it asks git what
5//! it would remove, then removes that. What comes free is every part of gitignore that a
6//! hand-rolled matcher gets subtly wrong: nested `.gitignore` files, negations,
7//! `info/exclude`, the user's global excludes, and the refusal to descend into a nested
8//! repository.
9//!
10//! ## Why `git clean -n` and not `git ls-files --directory`
11//!
12//! The original design enumerated with `git ls-files --others --directory`, and dogfooding it
13//! cost real data. `--directory` collapses a directory to `dir/` based only on the absence of
14//! **tracked** files, so `.nx/` — an ignored cache beside untracked data, with nothing tracked
15//! in it — collapsed to one entry `.nx/`. Removing *untracked* files then also wiped the
16//! ignored cache the user had chosen to keep.
17//!
18//! `git clean` collapses a directory only when everything inside it is being removed, and
19//! descends otherwise. Measured on that exact shape: `git clean -n -d` prints
20//! `.nx/workspace-data/` and `git clean -n -d -X` prints `.nx/cache/`. The two lists are
21//! disjoint by construction, which is why both can be offered as independent choices.
22//!
23//! ## Reading git's prose, which is the one uncomfortable part
24//!
25//! `git clean` has no `-z` and no porcelain format. It prints `Would remove <path>` and
26//! `Would skip repository <path>`, so this module parses sentences, and two things follow.
27//!
28//! The sentences are translated, which [`crate::git::git`] handles by forcing the C locale for
29//! every invocation. And the paths are quoted — git's own C-style escaping — which
30//! [`unquote`] undoes. A line matching neither sentence is an **error**, never a target and
31//! never silence: not understanding git's output is exactly the state in which nothing may be
32//! deleted.
33
34use std::ffi::OsString;
35use std::path::{Component, Path, PathBuf};
36use std::process::Stdio;
37use std::{fmt, fs, io};
38
39use crate::delete::Target;
40use crate::git::git;
41use crate::rules::ENV_MARK;
42
43/// What to do about tracked files that have been changed.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Reset {
46 /// Discard changes to tracked files in the working tree, leaving the index alone:
47 /// `git restore -- .`.
48 WorkTree,
49 /// Discard everything, index included: `git reset --hard HEAD`.
50 Hard,
51}
52
53impl Reset {
54 /// What git is asked to do, as it would be typed.
55 #[must_use]
56 pub fn command(self) -> &'static str {
57 match self {
58 Self::WorkTree => "git restore -- .",
59 Self::Hard => "git reset --hard HEAD",
60 }
61 }
62
63 fn args(self) -> &'static [&'static str] {
64 match self {
65 Self::WorkTree => &["restore", "--", "."],
66 Self::Hard => &["reset", "--hard", "HEAD"],
67 }
68 }
69}
70
71impl fmt::Display for Reset {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::WorkTree => write!(f, "discard working-tree changes"),
75 Self::Hard => write!(f, "discard everything (hard reset)"),
76 }
77 }
78}
79
80/// What a run was asked to do, however it was asked.
81///
82/// The defaults are the safe ones and they are the same whether they came from flags or from
83/// prompts: nothing is reset, nothing is removed, and vendor and env are excluded even from a
84/// list the user did ask for.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86#[expect(
87 clippy::struct_excessive_bools,
88 reason = "these are four independent yes/no answers, from four flags or four prompts. The \
89 lint's advice — a state machine — would put a shape between the question and the \
90 answer that neither of them has"
91)]
92pub struct Selection {
93 /// Whether to reset tracked changes, and how far.
94 pub reset: Option<Reset>,
95 /// Whether to remove untracked files.
96 pub untracked: bool,
97 /// Whether to remove ignored files.
98 pub ignored: bool,
99 /// Whether vendored dependency directories are in scope. Off by default: `node_modules`
100 /// is the most expensive thing on the list to get back.
101 pub vendor: bool,
102 /// Whether env files are in scope. Off by default: a `.env` is usually the only copy of
103 /// what is in it, and no command regenerates it.
104 pub env: bool,
105}
106
107impl Selection {
108 /// Whether this selection asks for anything at all.
109 #[must_use]
110 pub fn is_empty(&self) -> bool {
111 self.reset.is_none() && !self.untracked && !self.ignored
112 }
113}
114
115/// The one directory name that means "these are vendored dependencies".
116///
117/// Shared by [`classify`] and [`conceals`] so the two cannot drift. They answer the same
118/// question about different things — what an entry IS, and what an entry HIDES — and a run
119/// where those disagree is a run that reports holding something back and then deletes it.
120const VENDOR_DIR: &str = "node_modules";
121
122// `ENV_MARK` — what the design's `*.env*` reduces to against a single path component — lives in
123// [`crate::rules`] rather than here, because the sweep asks the same question of a gitignored
124// file it found. Same rule as `VENDOR_DIR` above: a run where the two doors disagreed would
125// report holding an env file back through one and delete it through the other.
126
127/// Which of the three classes an entry falls in.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Class {
130 /// A vendored dependency directory. Cheap to get back in the sense that a command does it,
131 /// expensive in the sense that the command takes minutes and a network.
132 Vendor,
133 /// An env file. Nothing regenerates one.
134 Env,
135 /// Build output, caches, everything else.
136 Other,
137}
138
139/// Which class `entry` falls in, judged only from its path, which must be **relative to the
140/// work tree root**.
141///
142/// Relative because the components are searched for `node_modules`, and an absolute path drags
143/// in the components of the root itself: a checkout that happens to live under a directory of
144/// that name would otherwise classify every entry in it as vendored.
145///
146/// `vendor` is any path with a `node_modules` component rather than only an entry that *is*
147/// one. The broader reading matters because git hands back whatever it did not collapse: a
148/// `node_modules` holding one tracked file arrives as its individual children, and each of
149/// those is still a vendored file. Being broad here can only ever keep more, which is the
150/// direction the default already leans.
151///
152/// `env` is the design's `*.env*` against the final component, which is `contains(".env")`.
153/// It catches `.env`, `.env.local` and `prod.env`, and does not catch `environment`.
154///
155/// This judges the entry and nothing else. What an entry *hides* is [`conceals`], and both are
156/// needed — see [`select`].
157#[must_use]
158pub fn classify(entry: &Path) -> Class {
159 if entry
160 .components()
161 .any(|component| component.as_os_str() == VENDOR_DIR)
162 {
163 return Class::Vendor;
164 }
165 let name = entry.file_name().unwrap_or_default().to_string_lossy();
166 if name.contains(ENV_MARK) {
167 return Class::Env;
168 }
169 Class::Other
170}
171
172/// What a directory holds that the run was not asked to remove.
173///
174/// Paths are relative to the work tree root, as everything a user reads is.
175#[derive(Debug, Clone, PartialEq, Eq)]
176#[non_exhaustive]
177pub enum Conceals {
178 /// A vendored directory lives here, under the entry.
179 Vendor(PathBuf),
180 /// An env file lives here, under the entry.
181 Env(PathBuf),
182 /// This directory under the entry could not be read, so nothing below it could be ruled
183 /// out.
184 Unreadable(PathBuf, String),
185}
186
187impl Conceals {
188 /// Which class would have to be opted in to release the entry, or `None` when opting in
189 /// would not help because the obstacle is that something could not be read.
190 #[must_use]
191 pub fn class(&self) -> Option<Class> {
192 match self {
193 Self::Vendor(_) => Some(Class::Vendor),
194 Self::Env(_) => Some(Class::Env),
195 Self::Unreadable(..) => None,
196 }
197 }
198}
199
200impl fmt::Display for Conceals {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 match self {
203 Self::Vendor(path) => write!(f, "holds {}, which is vendored", path.display()),
204 Self::Env(path) => write!(f, "holds {}, which is an env file", path.display()),
205 Self::Unreadable(path, why) => write!(
206 f,
207 "could not read {}, so nothing under it could be ruled out: {why}",
208 path.display()
209 ),
210 }
211 }
212}
213
214/// An entry left where it is because of what is under it rather than what it is.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct Concealed {
217 /// The entry git offered, relative to the work tree root.
218 pub path: PathBuf,
219 /// What is under it that was not asked for.
220 pub reason: Conceals,
221}
222
223/// What git says it would remove from one work tree.
224///
225/// The two lists are disjoint: `-d` is untracked-and-not-ignored, `-dX` is ignored only.
226#[derive(Debug, Clone, Default)]
227pub struct Enumeration {
228 /// The work tree the lists below describe. Carried so an entry can be judged by its
229 /// position *within the checkout* rather than by its absolute path.
230 pub root: PathBuf,
231 /// Untracked paths, absolute. A directory here means everything under it.
232 pub untracked: Vec<PathBuf>,
233 /// Ignored paths, absolute.
234 pub ignored: Vec<PathBuf>,
235 /// Nested repositories git refused to clean, absolute. Reported rather than dropped: a
236 /// checkout parked inside a work tree usually holds work that exists nowhere else, and a
237 /// user who does not see it named will read this run as having covered everything.
238 pub skipped: Vec<PathBuf>,
239}
240
241/// The targets a [`Selection`] picks out of an [`Enumeration`], and what it left behind.
242#[derive(Debug, Clone, Default)]
243pub struct Selected {
244 /// What to remove, untracked before ignored.
245 pub targets: Vec<Target>,
246 /// How many entries were left alone because they *are* vendored and vendor was not opted
247 /// in.
248 pub vendor: usize,
249 /// How many entries were left alone because they *are* env files and env was not opted in.
250 pub env: usize,
251 /// Entries left alone because of what is under them. Named rather than counted, because
252 /// the reason is one level down and a count would send the reader looking for it.
253 pub concealed: Vec<Concealed>,
254}
255
256/// Applies a selection to an enumeration.
257///
258/// The vendor and env filters apply to **both** lists, not only to the ignored one. The guard
259/// is about what the file is worth, and an untracked-but-not-ignored `.env` is the most
260/// precious kind rather than the least: it is the one git is not even hiding.
261///
262/// ## Why an entry has to be judged twice
263///
264/// `git clean` emits a whole directory whenever *everything* inside it is removable, so an
265/// entry is not a description of its own contents. `docker/` arrives as one line and may hold a
266/// `docker/.env`; `pkg/` arrives as one line and may hold a `pkg/node_modules`. Judging only
267/// the emitted path deletes both while the same run reports, truthfully as far as it knows,
268/// that env files were held back.
269///
270/// So every directory entry is also asked what it hides, and one that hides something not
271/// opted in is held back whole. Held back rather than expanded, because expanding would mean
272/// deciding for ourselves what inside it is removable — which is the reimplementation of
273/// `git clean` this mode exists to avoid.
274///
275/// **git cannot be made to do this itself, and it is worth recording why so nobody retries
276/// it.** `git clean -n -d -e '*.env*'` really does expand around the pattern, and for the
277/// untracked pass it is exactly right. But under `-X` the same flag *inverts*: `-e` adds to the
278/// ignore rules and `-X` removes what is ignored, so the protected pattern becomes a target.
279/// A `:(exclude)` pathspec does not stop the collapse at all. And the one form that protects
280/// both, `-d -x -e <pattern>`, merges untracked and ignored into a single pass — which
281/// collapses a mixed directory across the two classes and reintroduces the exact `.nx` bug this
282/// module's header exists to describe. All three measured against real git.
283#[must_use]
284pub fn select(enumeration: &Enumeration, selection: &Selection) -> Selected {
285 let mut selected = Selected::default();
286 let untracked = selection.untracked.then_some(&enumeration.untracked);
287 let ignored = selection.ignored.then_some(&enumeration.ignored);
288 for path in untracked.into_iter().chain(ignored).flatten() {
289 let relative = path.strip_prefix(&enumeration.root).unwrap_or(path);
290 match classify(relative) {
291 Class::Vendor if !selection.vendor => {
292 selected.vendor += 1;
293 continue;
294 }
295 Class::Env if !selection.env => {
296 selected.env += 1;
297 continue;
298 }
299 _ => {}
300 }
301 if let Some(reason) = conceals(path, &enumeration.root, *selection) {
302 selected.concealed.push(Concealed {
303 path: relative.to_path_buf(),
304 reason,
305 });
306 continue;
307 }
308 selected.targets.push(Target::at(path.clone()));
309 }
310 selected
311}
312
313/// Looks under `entry` for the first thing `selection` did not ask to remove.
314///
315/// Returns as soon as it finds one — the answer is "hold this back", and a second reason does
316/// not change it. A directory it cannot read is an answer too: #588's lesson is that the check
317/// to distrust is the one whose failure is silent, and "I could not look" must never read as
318/// "there was nothing there".
319///
320/// Nothing about git's semantics is re-derived here. Everything under `entry` is already, on
321/// git's own authority, in the class the user selected — this only asks whether any of it is
322/// *also* something they held back.
323fn conceals(entry: &Path, root: &Path, selection: Selection) -> Option<Conceals> {
324 // Nothing is being held back, so nothing can be hidden. This is also what keeps the walk
325 // off the common `--node-modules --env` path entirely.
326 if selection.vendor && selection.env {
327 return None;
328 }
329 // A file hides nothing. A symlink is removed as a link rather than followed, so it hides
330 // nothing either, and descending one would leave the work tree.
331 if !entry.symlink_metadata().is_ok_and(|meta| meta.is_dir()) {
332 return None;
333 }
334
335 let show = |path: &Path| path.strip_prefix(root).unwrap_or(path).to_path_buf();
336 let mut stack = vec![entry.to_path_buf()];
337 while let Some(dir) = stack.pop() {
338 let listing = match fs::read_dir(&dir) {
339 Ok(listing) => listing,
340 Err(err) => return Some(Conceals::Unreadable(show(&dir), err.to_string())),
341 };
342 for found in listing {
343 let found = match found {
344 Ok(found) => found,
345 // `read_dir` gave up part-way through a directory it had already opened, so
346 // the listing is short by an unknown amount and the unknown part could be the
347 // env file this is looking for.
348 Err(err) => return Some(Conceals::Unreadable(show(&dir), err.to_string())),
349 };
350 let path = found.path();
351 let name = found.file_name();
352 let name = name.to_string_lossy();
353 if !selection.vendor && name == VENDOR_DIR {
354 return Some(Conceals::Vendor(show(&path)));
355 }
356 if !selection.env && name.contains(ENV_MARK) {
357 return Some(Conceals::Env(show(&path)));
358 }
359 // `DirEntry::file_type` does not follow symlinks, so a link is never descended.
360 match found.file_type() {
361 Ok(kind) if kind.is_dir() => stack.push(path),
362 Ok(_) => {}
363 Err(err) => return Some(Conceals::Unreadable(show(&path), err.to_string())),
364 }
365 }
366 }
367 None
368}
369
370/// One git work tree, asked what it would clean.
371#[derive(Debug, Clone)]
372pub struct Repo {
373 root: PathBuf,
374}
375
376impl Repo {
377 /// Finds the work tree containing `from` and returns it.
378 ///
379 /// The whole checkout, never a subdirectory of one, even when `from` is one. `git clean`
380 /// scoped to a subdirectory cleans only that subtree while `git reset --hard` resets the
381 /// entire work tree regardless, so a run rooted at a subdirectory would mean two different
382 /// things by "here" in the same breath. Repo mode is the mode for cleaning a checkout, so
383 /// it takes the checkout.
384 ///
385 /// # Errors
386 ///
387 /// If git cannot be run, or `from` is not inside a work tree.
388 pub fn discover(from: &Path) -> Result<Self, RepoError> {
389 let output = git(from)
390 .args(["rev-parse", "--show-toplevel"])
391 .stdout(Stdio::piped())
392 .stderr(Stdio::piped())
393 .output()
394 .map_err(RepoError::Run)?;
395 if !output.status.success() {
396 return Err(RepoError::NotAWorkTree {
397 path: from.to_path_buf(),
398 message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
399 });
400 }
401 let printed = trim_newline(&output.stdout);
402 let root = decode(printed.to_vec()).ok_or_else(|| {
403 RepoError::Unreadable(
404 "git named a work tree root this cannot \
405 express as a path"
406 .to_owned(),
407 )
408 })?;
409 Ok(Self {
410 root: PathBuf::from(root),
411 })
412 }
413
414 /// The work tree's root directory, as git names it.
415 #[must_use]
416 pub fn root(&self) -> &Path {
417 &self.root
418 }
419
420 /// Asks git what it would remove.
421 ///
422 /// Two invocations, because the point is to offer the two lists separately: `-d` for
423 /// untracked and `-d -X` for ignored.
424 ///
425 /// # Errors
426 ///
427 /// If git cannot be run, refuses, or prints something this cannot read.
428 pub fn enumerate(&self) -> Result<Enumeration, RepoError> {
429 let untracked = self.clean(&["clean", "-n", "-d"], "list untracked files")?;
430 let ignored = self.clean(&["clean", "-n", "-d", "-X"], "list ignored files")?;
431
432 let mut skipped = untracked.skipped;
433 skipped.extend(ignored.skipped);
434 skipped.sort_unstable();
435 skipped.dedup();
436 Ok(Enumeration {
437 root: self.root.clone(),
438 untracked: untracked.removals,
439 ignored: ignored.removals,
440 skipped,
441 })
442 }
443
444 /// Discards tracked changes.
445 ///
446 /// Runs before any removal, because restoring a file the deleter is about to walk past is
447 /// the one ordering here that has a consequence.
448 ///
449 /// # Errors
450 ///
451 /// If git cannot be run or refuses — an unborn `HEAD` is the ordinary way to reach the
452 /// second, and a run that could not reset has not done what it said it would.
453 pub fn reset(&self, reset: Reset) -> Result<(), RepoError> {
454 let output = git(&self.root)
455 .args(reset.args())
456 .stdout(Stdio::piped())
457 .stderr(Stdio::piped())
458 .output()
459 .map_err(RepoError::Run)?;
460 if output.status.success() {
461 return Ok(());
462 }
463 Err(RepoError::Refused {
464 doing: reset.command(),
465 message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
466 })
467 }
468
469 /// One `git clean -n` invocation, parsed.
470 fn clean(&self, args: &[&str], doing: &'static str) -> Result<Cleaned, RepoError> {
471 let output = git(&self.root)
472 // Non-ASCII names then arrive as their own bytes instead of as octal escapes,
473 // which leaves [`unquote`] with only the names that genuinely need quoting.
474 .args(["-c", "core.quotePath=false"])
475 .args(args)
476 .stdout(Stdio::piped())
477 .stderr(Stdio::piped())
478 .output()
479 .map_err(RepoError::Run)?;
480 if !output.status.success() {
481 return Err(RepoError::Refused {
482 doing,
483 message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
484 });
485 }
486 parse(&output.stdout, &self.root)
487 }
488}
489
490/// What one `git clean -n` said.
491#[derive(Debug, Default)]
492struct Cleaned {
493 removals: Vec<PathBuf>,
494 skipped: Vec<PathBuf>,
495}
496
497/// The sentence `git clean -n` prints for something it would remove.
498const WOULD_REMOVE: &[u8] = b"Would remove ";
499/// The sentence it prints for a nested repository it will not touch.
500const WOULD_SKIP: &[u8] = b"Would skip repository ";
501
502/// Reads `git clean -n` output.
503///
504/// Anything that is neither sentence is refused. That is the load-bearing decision in this
505/// function: the two alternatives are to treat an unknown line as a target, which deletes
506/// whatever a future git prints a warning about, and to ignore it, which is the failure mode
507/// #588 spent a review learning to distrust — a check that goes quiet and reads as a clear
508/// result.
509fn parse(stdout: &[u8], root: &Path) -> Result<Cleaned, RepoError> {
510 let mut cleaned = Cleaned::default();
511 for line in stdout.split(|byte| *byte == b'\n') {
512 let line = trim_newline(line);
513 if line.is_empty() {
514 continue;
515 }
516 let (raw, into) = if let Some(rest) = line.strip_prefix(WOULD_REMOVE) {
517 (rest, &mut cleaned.removals)
518 } else if let Some(rest) = line.strip_prefix(WOULD_SKIP) {
519 (rest, &mut cleaned.skipped)
520 } else {
521 return Err(RepoError::Unreadable(format!(
522 "git clean said `{}`, which is not a sentence this knows how to read",
523 String::from_utf8_lossy(line)
524 )));
525 };
526 into.push(entry(raw, root)?);
527 }
528 Ok(cleaned)
529}
530
531/// One path out of one `git clean -n` line, resolved against the work tree root.
532fn entry(raw: &[u8], root: &Path) -> Result<PathBuf, RepoError> {
533 let mut bytes = if raw.first() == Some(&b'"') && raw.len() >= 2 && raw.last() == Some(&b'"') {
534 unquote(&raw[1..raw.len() - 1]).ok_or_else(|| {
535 RepoError::Unreadable(format!(
536 "git clean quoted `{}` in a way this cannot unquote",
537 String::from_utf8_lossy(raw)
538 ))
539 })?
540 } else {
541 raw.to_vec()
542 };
543 // git marks a directory with a trailing separator, which `PathBuf` keeps verbatim and
544 // prints back at the user. Trimmed in bytes, before the path exists, so every target reads
545 // the way one would be typed.
546 if bytes.last() == Some(&b'/') {
547 bytes.pop();
548 }
549 let decoded = decode(bytes).ok_or_else(|| {
550 RepoError::Unreadable(format!(
551 "git clean named `{}`, which cannot be expressed as a path here",
552 String::from_utf8_lossy(raw)
553 ))
554 })?;
555
556 // `PathBuf` drops the trailing separator git prints on a directory, so a target reads the
557 // way a user would type it.
558 let relative = PathBuf::from(decoded);
559 // Refused rather than handed on. The planner would refuse an escaping path too, but it
560 // would report it as one target the user did not get; git printing one at all means this
561 // has misread the output, and the rest of the list cannot be trusted either. An empty
562 // path is in the same class: joined to the root it *is* the root, which is the one
563 // directory no plan may ever hold.
564 if !relative
565 .components()
566 .all(|component| matches!(component, Component::Normal(_)))
567 || relative.as_os_str().is_empty()
568 {
569 return Err(RepoError::Unreadable(format!(
570 "git clean named `{}`, which is not a path inside the work tree",
571 relative.display()
572 )));
573 }
574 Ok(root.join(relative))
575}
576
577/// Undoes git's C-style quoting, in bytes, without the surrounding quotes.
578///
579/// git escapes a name it cannot print literally, which is how a path holding a newline stays
580/// on one line and line-based parsing stays safe. `\ooo` is always three octal digits and
581/// always one byte, so a name that is not UTF-8 survives this intact.
582fn unquote(inner: &[u8]) -> Option<Vec<u8>> {
583 let mut out = Vec::with_capacity(inner.len());
584 let mut bytes = inner.iter().copied();
585 while let Some(byte) = bytes.next() {
586 if byte != b'\\' {
587 out.push(byte);
588 continue;
589 }
590 let escaped = match bytes.next()? {
591 b'a' => 0x07,
592 b'b' => 0x08,
593 b't' => b'\t',
594 b'n' => b'\n',
595 b'v' => 0x0b,
596 b'f' => 0x0c,
597 b'r' => b'\r',
598 b'"' => b'"',
599 b'\\' => b'\\',
600 first @ b'0'..=b'7' => {
601 let mut value = u16::from(first - b'0');
602 for _ in 0..2 {
603 let digit = bytes.next()?;
604 if !digit.is_ascii_digit() || digit > b'7' {
605 return None;
606 }
607 value = value * 8 + u16::from(digit - b'0');
608 }
609 u8::try_from(value).ok()?
610 }
611 _ => return None,
612 };
613 out.push(escaped);
614 }
615 Some(out)
616}
617
618/// Raw bytes as this platform's path string, or `None` where they cannot be one.
619#[cfg(unix)]
620#[expect(
621 clippy::unnecessary_wraps,
622 reason = "infallible here and fallible off unix, where a path is UTF-16 and arbitrary \
623 bytes are not one. Callers have to handle the failure that exists on the other \
624 platform"
625)]
626fn decode(bytes: Vec<u8>) -> Option<OsString> {
627 use std::os::unix::ffi::OsStringExt;
628 Some(OsString::from_vec(bytes))
629}
630
631/// The same, where a path is UTF-16 and arbitrary bytes are not a path.
632#[cfg(not(unix))]
633fn decode(bytes: Vec<u8>) -> Option<OsString> {
634 String::from_utf8(bytes).ok().map(OsString::from)
635}
636
637/// A line without whatever line ending it arrived with.
638fn trim_newline(line: &[u8]) -> &[u8] {
639 let line = line.strip_suffix(b"\n").unwrap_or(line);
640 line.strip_suffix(b"\r").unwrap_or(line)
641}
642
643/// Why a work tree could not be cleaned.
644#[derive(Debug)]
645#[non_exhaustive]
646pub enum RepoError {
647 /// git could not be run — most often because it is not installed.
648 Run(io::Error),
649 /// The path given is not inside a git work tree.
650 NotAWorkTree {
651 /// What was pointed at.
652 path: PathBuf,
653 /// Whatever git said about it.
654 message: String,
655 },
656 /// git ran and refused, carrying what it was asked to do and what it said.
657 Refused {
658 /// The operation, as it would be typed.
659 doing: &'static str,
660 /// Whatever git said.
661 message: String,
662 },
663 /// git printed something this cannot read, so the listing is not trustworthy and nothing
664 /// is removed on the strength of it.
665 Unreadable(String),
666}
667
668impl fmt::Display for RepoError {
669 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670 match self {
671 Self::Run(err) => write!(f, "could not run git: {err}"),
672 Self::NotAWorkTree { path, message } => {
673 let said = if message.is_empty() {
674 String::new()
675 } else {
676 format!(": {message}")
677 };
678 write!(
679 f,
680 "{} is not inside a git work tree, and repo mode is the mode that cleans \
681 one{said}",
682 path.display()
683 )
684 }
685 Self::Refused { doing, message } => {
686 write!(f, "`{doing}` failed: {message}")
687 }
688 Self::Unreadable(why) => write!(f, "{why}"),
689 }
690 }
691}
692
693impl std::error::Error for RepoError {
694 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
695 match self {
696 Self::Run(err) => Some(err),
697 _ => None,
698 }
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::{
705 Class, Enumeration, RepoError, Reset, Selection, classify, entry, parse, select, unquote,
706 };
707 use std::path::{Path, PathBuf};
708
709 fn paths(cleaned: &[PathBuf]) -> Vec<String> {
710 cleaned
711 .iter()
712 .map(|path| path.display().to_string())
713 .collect()
714 }
715
716 #[test]
717 fn the_two_sentences_git_prints_go_to_two_different_lists() {
718 let cleaned = parse(
719 b"Would remove out/\nWould skip repository vendor/inner\nWould remove note.txt\n",
720 Path::new("/repo"),
721 )
722 .unwrap();
723
724 assert_eq!(paths(&cleaned.removals), ["/repo/out", "/repo/note.txt"]);
725 assert_eq!(paths(&cleaned.skipped), ["/repo/vendor/inner"]);
726 }
727
728 #[test]
729 fn a_sentence_nothing_recognises_is_an_error_rather_than_a_target_or_a_silence() {
730 // The two ways to get this wrong: treat it as a path, and delete whatever a future git
731 // warns about; or ignore it, and report a listing that is short by an unknown amount
732 // as if it were the whole truth.
733 let read = parse(
734 b"Would remove out/\nWurde etwas geloescht\n",
735 Path::new("/repo"),
736 );
737
738 let Err(RepoError::Unreadable(why)) = read else {
739 panic!("an unknown line was accepted: {read:?}");
740 };
741 assert!(why.contains("Wurde etwas geloescht"), "{why}");
742 }
743
744 #[test]
745 fn a_translated_listing_is_refused_rather_than_read_as_a_clean_repository() {
746 // What `LANGUAGE=de` actually prints. The locale is forced to C for every invocation,
747 // so this is unreachable in practice — and it is exactly the failure that would be
748 // invisible if it ever became reachable again, so the parser refuses it out loud.
749 let read = parse("Würde out/ löschen\n".as_bytes(), Path::new("/repo"));
750
751 assert!(matches!(read, Err(RepoError::Unreadable(_))), "{read:?}");
752 }
753
754 #[test]
755 fn a_quoted_name_comes_back_as_the_bytes_it_was() {
756 // git quotes anything it cannot print literally, which is what keeps a name holding a
757 // newline on one line.
758 let cleaned = parse(
759 b"Would remove \"two\\nlines.txt\"\nWould remove \"say \\\"hi\\\".txt\"\n",
760 Path::new("/repo"),
761 )
762 .unwrap();
763
764 assert_eq!(
765 paths(&cleaned.removals),
766 ["/repo/two\nlines.txt", "/repo/say \"hi\".txt"]
767 );
768 }
769
770 #[test]
771 fn octal_escapes_are_bytes_and_not_characters() {
772 // `café` decomposed, as git would escape it with `core.quotePath` left on.
773 assert_eq!(unquote(b"caf\\303\\251").unwrap(), "café".as_bytes());
774 assert_eq!(unquote(b"a\\tb").unwrap(), b"a\tb");
775 // Truncated, and a byte past what one can hold.
776 assert!(unquote(b"caf\\30").is_none());
777 assert!(unquote(b"\\777").is_none());
778 assert!(unquote(b"\\q").is_none());
779 assert!(unquote(b"ends-with\\").is_none());
780 }
781
782 #[test]
783 fn a_path_that_would_leave_the_work_tree_is_refused() {
784 // Unreachable from a git that is behaving. Reachable from a misread line, and one
785 // misread line means the whole listing is guesswork.
786 for bad in [
787 &b"Would remove ../elsewhere"[..],
788 b"Would remove /etc/passwd",
789 b"Would remove ",
790 ] {
791 assert!(
792 parse(bad, Path::new("/repo")).is_err(),
793 "`{}` was accepted",
794 String::from_utf8_lossy(bad)
795 );
796 }
797 }
798
799 #[test]
800 fn a_trailing_separator_is_not_part_of_the_target() {
801 assert_eq!(
802 entry(b"out/", Path::new("/repo")).unwrap(),
803 PathBuf::from("/repo/out")
804 );
805 }
806
807 #[test]
808 fn vendor_is_any_path_with_a_node_modules_in_it() {
809 assert_eq!(classify(Path::new("/r/node_modules")), Class::Vendor);
810 assert_eq!(classify(Path::new("/r/app/node_modules")), Class::Vendor);
811 // What git hands back when a `node_modules` could not be collapsed.
812 assert_eq!(
813 classify(Path::new("/r/node_modules/.bin/tsc")),
814 Class::Vendor
815 );
816 assert_eq!(classify(Path::new("/r/node_modules_old")), Class::Other);
817 }
818
819 #[test]
820 fn env_is_the_designs_star_dot_env_star_against_the_final_component() {
821 for env in [".env", ".env.local", "prod.env", ".env.production.local"] {
822 assert_eq!(classify(&Path::new("/r").join(env)), Class::Env, "{env}");
823 }
824 for other in ["environment", "dist", "envoy.yaml"] {
825 assert_eq!(
826 classify(&Path::new("/r").join(other)),
827 Class::Other,
828 "{other}"
829 );
830 }
831 }
832
833 /// A fixture whose paths do not exist on disk, which is deliberate: nothing here is a
834 /// directory, so [`conceals`] is inert and these tests isolate the classification half.
835 /// What an entry hides is covered against real git in `tests/repo.rs`.
836 fn enumeration() -> Enumeration {
837 Enumeration {
838 root: PathBuf::from("/r"),
839 untracked: vec![PathBuf::from("/r/scratch.txt"), PathBuf::from("/r/.env")],
840 ignored: vec![
841 PathBuf::from("/r/dist"),
842 PathBuf::from("/r/node_modules"),
843 PathBuf::from("/r/.env.local"),
844 ],
845 skipped: Vec::new(),
846 }
847 }
848
849 #[test]
850 fn a_checkout_living_under_a_node_modules_does_not_classify_as_all_vendored() {
851 // `classify` searches the components for `node_modules`, so it has to be handed the
852 // path relative to the work tree — an absolute one drags in the root's own components
853 // and every entry in such a checkout would be held back as vendored.
854 let enumeration = Enumeration {
855 root: PathBuf::from("/home/me/node_modules/checkout"),
856 untracked: vec![PathBuf::from("/home/me/node_modules/checkout/dist")],
857 ..Enumeration::default()
858 };
859
860 let selected = select(
861 &enumeration,
862 &Selection {
863 untracked: true,
864 ..Selection::default()
865 },
866 );
867
868 assert_eq!(selected.targets.len(), 1, "{selected:?}");
869 assert_eq!(selected.vendor, 0);
870 }
871
872 #[test]
873 fn nothing_is_selected_by_default() {
874 let selected = select(&enumeration(), &Selection::default());
875 assert!(selected.targets.is_empty());
876 assert!(Selection::default().is_empty());
877 }
878
879 #[test]
880 fn vendor_and_env_are_held_back_from_a_list_the_user_did_ask_for() {
881 let selected = select(
882 &enumeration(),
883 &Selection {
884 untracked: true,
885 ignored: true,
886 ..Selection::default()
887 },
888 );
889
890 assert_eq!(
891 paths(
892 &selected
893 .targets
894 .iter()
895 .map(|target| target.path.clone())
896 .collect::<Vec<_>>()
897 ),
898 ["/r/scratch.txt", "/r/dist"]
899 );
900 assert_eq!(selected.vendor, 1);
901 // Both of them: the untracked `.env` as well as the ignored one. The guard is about
902 // what the file is worth, not about which of git's two lists it arrived in.
903 assert_eq!(selected.env, 2);
904 }
905
906 #[test]
907 fn opting_in_puts_them_back() {
908 let selected = select(
909 &enumeration(),
910 &Selection {
911 untracked: true,
912 ignored: true,
913 vendor: true,
914 env: true,
915 ..Selection::default()
916 },
917 );
918
919 assert_eq!(selected.targets.len(), 5);
920 assert_eq!((selected.vendor, selected.env), (0, 0));
921 }
922
923 #[test]
924 fn one_list_can_be_taken_without_the_other() {
925 let only_ignored = select(
926 &enumeration(),
927 &Selection {
928 ignored: true,
929 ..Selection::default()
930 },
931 );
932 assert_eq!(
933 paths(
934 &only_ignored
935 .targets
936 .iter()
937 .map(|target| target.path.clone())
938 .collect::<Vec<_>>()
939 ),
940 ["/r/dist"]
941 );
942 }
943
944 #[test]
945 fn the_reset_verbs_are_the_ones_the_design_named() {
946 assert_eq!(Reset::WorkTree.command(), "git restore -- .");
947 assert_eq!(Reset::Hard.command(), "git reset --hard HEAD");
948 }
949}