use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::process::Stdio;
use std::{fmt, fs, io};
use crate::delete::Target;
use crate::git::git;
use crate::rules::ENV_MARK;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reset {
WorkTree,
Hard,
}
impl Reset {
#[must_use]
pub fn command(self) -> &'static str {
match self {
Self::WorkTree => "git restore -- .",
Self::Hard => "git reset --hard HEAD",
}
}
fn args(self) -> &'static [&'static str] {
match self {
Self::WorkTree => &["restore", "--", "."],
Self::Hard => &["reset", "--hard", "HEAD"],
}
}
}
impl fmt::Display for Reset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WorkTree => write!(f, "discard working-tree changes"),
Self::Hard => write!(f, "discard everything (hard reset)"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[expect(
clippy::struct_excessive_bools,
reason = "these are four independent yes/no answers, from four flags or four prompts. The \
lint's advice — a state machine — would put a shape between the question and the \
answer that neither of them has"
)]
pub struct Selection {
pub reset: Option<Reset>,
pub untracked: bool,
pub ignored: bool,
pub vendor: bool,
pub env: bool,
}
impl Selection {
#[must_use]
pub fn is_empty(&self) -> bool {
self.reset.is_none() && !self.untracked && !self.ignored
}
}
const VENDOR_DIR: &str = "node_modules";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
Vendor,
Env,
Other,
}
#[must_use]
pub fn classify(entry: &Path) -> Class {
if entry
.components()
.any(|component| component.as_os_str() == VENDOR_DIR)
{
return Class::Vendor;
}
let name = entry.file_name().unwrap_or_default().to_string_lossy();
if name.contains(ENV_MARK) {
return Class::Env;
}
Class::Other
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Conceals {
Vendor(PathBuf),
Env(PathBuf),
Unreadable(PathBuf, String),
}
impl Conceals {
#[must_use]
pub fn class(&self) -> Option<Class> {
match self {
Self::Vendor(_) => Some(Class::Vendor),
Self::Env(_) => Some(Class::Env),
Self::Unreadable(..) => None,
}
}
}
impl fmt::Display for Conceals {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Vendor(path) => write!(f, "holds {}, which is vendored", path.display()),
Self::Env(path) => write!(f, "holds {}, which is an env file", path.display()),
Self::Unreadable(path, why) => write!(
f,
"could not read {}, so nothing under it could be ruled out: {why}",
path.display()
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Concealed {
pub path: PathBuf,
pub reason: Conceals,
}
#[derive(Debug, Clone, Default)]
pub struct Enumeration {
pub root: PathBuf,
pub untracked: Vec<PathBuf>,
pub ignored: Vec<PathBuf>,
pub skipped: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct Selected {
pub targets: Vec<Target>,
pub vendor: usize,
pub env: usize,
pub concealed: Vec<Concealed>,
}
#[must_use]
pub fn select(enumeration: &Enumeration, selection: &Selection) -> Selected {
let mut selected = Selected::default();
let untracked = selection.untracked.then_some(&enumeration.untracked);
let ignored = selection.ignored.then_some(&enumeration.ignored);
for path in untracked.into_iter().chain(ignored).flatten() {
let relative = path.strip_prefix(&enumeration.root).unwrap_or(path);
match classify(relative) {
Class::Vendor if !selection.vendor => {
selected.vendor += 1;
continue;
}
Class::Env if !selection.env => {
selected.env += 1;
continue;
}
_ => {}
}
if let Some(reason) = conceals(path, &enumeration.root, *selection) {
selected.concealed.push(Concealed {
path: relative.to_path_buf(),
reason,
});
continue;
}
selected.targets.push(Target::at(path.clone()));
}
selected
}
fn conceals(entry: &Path, root: &Path, selection: Selection) -> Option<Conceals> {
if selection.vendor && selection.env {
return None;
}
if !entry.symlink_metadata().is_ok_and(|meta| meta.is_dir()) {
return None;
}
let show = |path: &Path| path.strip_prefix(root).unwrap_or(path).to_path_buf();
let mut stack = vec![entry.to_path_buf()];
while let Some(dir) = stack.pop() {
let listing = match fs::read_dir(&dir) {
Ok(listing) => listing,
Err(err) => return Some(Conceals::Unreadable(show(&dir), err.to_string())),
};
for found in listing {
let found = match found {
Ok(found) => found,
Err(err) => return Some(Conceals::Unreadable(show(&dir), err.to_string())),
};
let path = found.path();
let name = found.file_name();
let name = name.to_string_lossy();
if !selection.vendor && name == VENDOR_DIR {
return Some(Conceals::Vendor(show(&path)));
}
if !selection.env && name.contains(ENV_MARK) {
return Some(Conceals::Env(show(&path)));
}
match found.file_type() {
Ok(kind) if kind.is_dir() => stack.push(path),
Ok(_) => {}
Err(err) => return Some(Conceals::Unreadable(show(&path), err.to_string())),
}
}
}
None
}
#[derive(Debug, Clone)]
pub struct Repo {
root: PathBuf,
}
impl Repo {
pub fn discover(from: &Path) -> Result<Self, RepoError> {
let output = git(from)
.args(["rev-parse", "--show-toplevel"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(RepoError::Run)?;
if !output.status.success() {
return Err(RepoError::NotAWorkTree {
path: from.to_path_buf(),
message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
});
}
let printed = trim_newline(&output.stdout);
let root = decode(printed.to_vec()).ok_or_else(|| {
RepoError::Unreadable(
"git named a work tree root this cannot \
express as a path"
.to_owned(),
)
})?;
Ok(Self {
root: PathBuf::from(root),
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn enumerate(&self) -> Result<Enumeration, RepoError> {
let untracked = self.clean(&["clean", "-n", "-d"], "list untracked files")?;
let ignored = self.clean(&["clean", "-n", "-d", "-X"], "list ignored files")?;
let mut skipped = untracked.skipped;
skipped.extend(ignored.skipped);
skipped.sort_unstable();
skipped.dedup();
Ok(Enumeration {
root: self.root.clone(),
untracked: untracked.removals,
ignored: ignored.removals,
skipped,
})
}
pub fn reset(&self, reset: Reset) -> Result<(), RepoError> {
let output = git(&self.root)
.args(reset.args())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(RepoError::Run)?;
if output.status.success() {
return Ok(());
}
Err(RepoError::Refused {
doing: reset.command(),
message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
})
}
fn clean(&self, args: &[&str], doing: &'static str) -> Result<Cleaned, RepoError> {
let output = git(&self.root)
.args(["-c", "core.quotePath=false"])
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(RepoError::Run)?;
if !output.status.success() {
return Err(RepoError::Refused {
doing,
message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
});
}
parse(&output.stdout, &self.root)
}
}
#[derive(Debug, Default)]
struct Cleaned {
removals: Vec<PathBuf>,
skipped: Vec<PathBuf>,
}
const WOULD_REMOVE: &[u8] = b"Would remove ";
const WOULD_SKIP: &[u8] = b"Would skip repository ";
fn parse(stdout: &[u8], root: &Path) -> Result<Cleaned, RepoError> {
let mut cleaned = Cleaned::default();
for line in stdout.split(|byte| *byte == b'\n') {
let line = trim_newline(line);
if line.is_empty() {
continue;
}
let (raw, into) = if let Some(rest) = line.strip_prefix(WOULD_REMOVE) {
(rest, &mut cleaned.removals)
} else if let Some(rest) = line.strip_prefix(WOULD_SKIP) {
(rest, &mut cleaned.skipped)
} else {
return Err(RepoError::Unreadable(format!(
"git clean said `{}`, which is not a sentence this knows how to read",
String::from_utf8_lossy(line)
)));
};
into.push(entry(raw, root)?);
}
Ok(cleaned)
}
fn entry(raw: &[u8], root: &Path) -> Result<PathBuf, RepoError> {
let mut bytes = if raw.first() == Some(&b'"') && raw.len() >= 2 && raw.last() == Some(&b'"') {
unquote(&raw[1..raw.len() - 1]).ok_or_else(|| {
RepoError::Unreadable(format!(
"git clean quoted `{}` in a way this cannot unquote",
String::from_utf8_lossy(raw)
))
})?
} else {
raw.to_vec()
};
if bytes.last() == Some(&b'/') {
bytes.pop();
}
let decoded = decode(bytes).ok_or_else(|| {
RepoError::Unreadable(format!(
"git clean named `{}`, which cannot be expressed as a path here",
String::from_utf8_lossy(raw)
))
})?;
let relative = PathBuf::from(decoded);
if !relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
|| relative.as_os_str().is_empty()
{
return Err(RepoError::Unreadable(format!(
"git clean named `{}`, which is not a path inside the work tree",
relative.display()
)));
}
Ok(root.join(relative))
}
fn unquote(inner: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(inner.len());
let mut bytes = inner.iter().copied();
while let Some(byte) = bytes.next() {
if byte != b'\\' {
out.push(byte);
continue;
}
let escaped = match bytes.next()? {
b'a' => 0x07,
b'b' => 0x08,
b't' => b'\t',
b'n' => b'\n',
b'v' => 0x0b,
b'f' => 0x0c,
b'r' => b'\r',
b'"' => b'"',
b'\\' => b'\\',
first @ b'0'..=b'7' => {
let mut value = u16::from(first - b'0');
for _ in 0..2 {
let digit = bytes.next()?;
if !digit.is_ascii_digit() || digit > b'7' {
return None;
}
value = value * 8 + u16::from(digit - b'0');
}
u8::try_from(value).ok()?
}
_ => return None,
};
out.push(escaped);
}
Some(out)
}
#[cfg(unix)]
#[expect(
clippy::unnecessary_wraps,
reason = "infallible here and fallible off unix, where a path is UTF-16 and arbitrary \
bytes are not one. Callers have to handle the failure that exists on the other \
platform"
)]
fn decode(bytes: Vec<u8>) -> Option<OsString> {
use std::os::unix::ffi::OsStringExt;
Some(OsString::from_vec(bytes))
}
#[cfg(not(unix))]
fn decode(bytes: Vec<u8>) -> Option<OsString> {
String::from_utf8(bytes).ok().map(OsString::from)
}
fn trim_newline(line: &[u8]) -> &[u8] {
let line = line.strip_suffix(b"\n").unwrap_or(line);
line.strip_suffix(b"\r").unwrap_or(line)
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RepoError {
Run(io::Error),
NotAWorkTree {
path: PathBuf,
message: String,
},
Refused {
doing: &'static str,
message: String,
},
Unreadable(String),
}
impl fmt::Display for RepoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Run(err) => write!(f, "could not run git: {err}"),
Self::NotAWorkTree { path, message } => {
let said = if message.is_empty() {
String::new()
} else {
format!(": {message}")
};
write!(
f,
"{} is not inside a git work tree, and repo mode is the mode that cleans \
one{said}",
path.display()
)
}
Self::Refused { doing, message } => {
write!(f, "`{doing}` failed: {message}")
}
Self::Unreadable(why) => write!(f, "{why}"),
}
}
}
impl std::error::Error for RepoError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Run(err) => Some(err),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{
Class, Enumeration, RepoError, Reset, Selection, classify, entry, parse, select, unquote,
};
use std::path::{Path, PathBuf};
fn paths(cleaned: &[PathBuf]) -> Vec<String> {
cleaned
.iter()
.map(|path| path.display().to_string())
.collect()
}
#[test]
fn the_two_sentences_git_prints_go_to_two_different_lists() {
let cleaned = parse(
b"Would remove out/\nWould skip repository vendor/inner\nWould remove note.txt\n",
Path::new("/repo"),
)
.unwrap();
assert_eq!(paths(&cleaned.removals), ["/repo/out", "/repo/note.txt"]);
assert_eq!(paths(&cleaned.skipped), ["/repo/vendor/inner"]);
}
#[test]
fn a_sentence_nothing_recognises_is_an_error_rather_than_a_target_or_a_silence() {
let read = parse(
b"Would remove out/\nWurde etwas geloescht\n",
Path::new("/repo"),
);
let Err(RepoError::Unreadable(why)) = read else {
panic!("an unknown line was accepted: {read:?}");
};
assert!(why.contains("Wurde etwas geloescht"), "{why}");
}
#[test]
fn a_translated_listing_is_refused_rather_than_read_as_a_clean_repository() {
let read = parse("Würde out/ löschen\n".as_bytes(), Path::new("/repo"));
assert!(matches!(read, Err(RepoError::Unreadable(_))), "{read:?}");
}
#[test]
fn a_quoted_name_comes_back_as_the_bytes_it_was() {
let cleaned = parse(
b"Would remove \"two\\nlines.txt\"\nWould remove \"say \\\"hi\\\".txt\"\n",
Path::new("/repo"),
)
.unwrap();
assert_eq!(
paths(&cleaned.removals),
["/repo/two\nlines.txt", "/repo/say \"hi\".txt"]
);
}
#[test]
fn octal_escapes_are_bytes_and_not_characters() {
assert_eq!(unquote(b"caf\\303\\251").unwrap(), "café".as_bytes());
assert_eq!(unquote(b"a\\tb").unwrap(), b"a\tb");
assert!(unquote(b"caf\\30").is_none());
assert!(unquote(b"\\777").is_none());
assert!(unquote(b"\\q").is_none());
assert!(unquote(b"ends-with\\").is_none());
}
#[test]
fn a_path_that_would_leave_the_work_tree_is_refused() {
for bad in [
&b"Would remove ../elsewhere"[..],
b"Would remove /etc/passwd",
b"Would remove ",
] {
assert!(
parse(bad, Path::new("/repo")).is_err(),
"`{}` was accepted",
String::from_utf8_lossy(bad)
);
}
}
#[test]
fn a_trailing_separator_is_not_part_of_the_target() {
assert_eq!(
entry(b"out/", Path::new("/repo")).unwrap(),
PathBuf::from("/repo/out")
);
}
#[test]
fn vendor_is_any_path_with_a_node_modules_in_it() {
assert_eq!(classify(Path::new("/r/node_modules")), Class::Vendor);
assert_eq!(classify(Path::new("/r/app/node_modules")), Class::Vendor);
assert_eq!(
classify(Path::new("/r/node_modules/.bin/tsc")),
Class::Vendor
);
assert_eq!(classify(Path::new("/r/node_modules_old")), Class::Other);
}
#[test]
fn env_is_the_designs_star_dot_env_star_against_the_final_component() {
for env in [".env", ".env.local", "prod.env", ".env.production.local"] {
assert_eq!(classify(&Path::new("/r").join(env)), Class::Env, "{env}");
}
for other in ["environment", "dist", "envoy.yaml"] {
assert_eq!(
classify(&Path::new("/r").join(other)),
Class::Other,
"{other}"
);
}
}
fn enumeration() -> Enumeration {
Enumeration {
root: PathBuf::from("/r"),
untracked: vec![PathBuf::from("/r/scratch.txt"), PathBuf::from("/r/.env")],
ignored: vec![
PathBuf::from("/r/dist"),
PathBuf::from("/r/node_modules"),
PathBuf::from("/r/.env.local"),
],
skipped: Vec::new(),
}
}
#[test]
fn a_checkout_living_under_a_node_modules_does_not_classify_as_all_vendored() {
let enumeration = Enumeration {
root: PathBuf::from("/home/me/node_modules/checkout"),
untracked: vec![PathBuf::from("/home/me/node_modules/checkout/dist")],
..Enumeration::default()
};
let selected = select(
&enumeration,
&Selection {
untracked: true,
..Selection::default()
},
);
assert_eq!(selected.targets.len(), 1, "{selected:?}");
assert_eq!(selected.vendor, 0);
}
#[test]
fn nothing_is_selected_by_default() {
let selected = select(&enumeration(), &Selection::default());
assert!(selected.targets.is_empty());
assert!(Selection::default().is_empty());
}
#[test]
fn vendor_and_env_are_held_back_from_a_list_the_user_did_ask_for() {
let selected = select(
&enumeration(),
&Selection {
untracked: true,
ignored: true,
..Selection::default()
},
);
assert_eq!(
paths(
&selected
.targets
.iter()
.map(|target| target.path.clone())
.collect::<Vec<_>>()
),
["/r/scratch.txt", "/r/dist"]
);
assert_eq!(selected.vendor, 1);
assert_eq!(selected.env, 2);
}
#[test]
fn opting_in_puts_them_back() {
let selected = select(
&enumeration(),
&Selection {
untracked: true,
ignored: true,
vendor: true,
env: true,
..Selection::default()
},
);
assert_eq!(selected.targets.len(), 5);
assert_eq!((selected.vendor, selected.env), (0, 0));
}
#[test]
fn one_list_can_be_taken_without_the_other() {
let only_ignored = select(
&enumeration(),
&Selection {
ignored: true,
..Selection::default()
},
);
assert_eq!(
paths(
&only_ignored
.targets
.iter()
.map(|target| target.path.clone())
.collect::<Vec<_>>()
),
["/r/dist"]
);
}
#[test]
fn the_reset_verbs_are_the_ones_the_design_named() {
assert_eq!(Reset::WorkTree.command(), "git restore -- .");
assert_eq!(Reset::Hard.command(), "git reset --hard HEAD");
}
}