Skip to main content

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}