use std::env;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::{NamedTempFile, TempDir};
use walkdir::WalkDir;
use crate::call;
use crate::cli::{path_from_bytes, path_to_bytes};
use crate::errors::{FileError, PruneError};
const EMPTY_BLOB: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
pub fn remove_symlinks(dir: &Path) -> Result<(), FileError> {
for entry in WalkDir::new(dir)
.min_depth(1)
.into_iter()
.filter_map(Result::ok)
{
if entry.file_type().is_symlink() {
remove_if_present(entry.path(), fs::remove_file)?;
}
}
Ok(())
}
pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<(), PruneError> {
if patterns.is_empty() {
return Ok(());
}
fs::read_dir(repo_path).map_err(file_error("reading", repo_path))?;
let repo_path = &std::path::absolute(repo_path).map_err(file_error("resolving", repo_path))?;
for file in excluded_files(repo_path, patterns)? {
remove_if_present(&repo_path.join(file), fs::remove_file)?;
}
remove_empty_dirs(repo_path)?;
Ok(())
}
fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>, PruneError> {
let exclusions = exclusions_file(patterns)?;
let mut exclude_from = OsString::from("--exclude-from=");
exclude_from.push(exclusions.path());
let git_dir = empty_git_dir()?;
index_every_file(git_dir.path(), repo_path)?;
let output = call::run_blocking(
"ls-files",
Command::new("git")
.arg("--git-dir")
.arg(git_dir.path())
.arg("--work-tree")
.arg(repo_path)
.args(["-c", "core.ignorecase=false"])
.args(["ls-files", "-z", "--cached", "--ignored"])
.arg(exclude_from)
.current_dir(repo_path),
)?;
Ok(output
.stdout
.split(|byte| *byte == 0)
.filter(|file| !file.is_empty())
.map(path_from_bytes)
.collect())
}
fn index_every_file(git_dir: &Path, repo_path: &Path) -> Result<(), PruneError> {
let mut entries = Vec::new();
for entry in WalkDir::new(repo_path)
.min_depth(1)
.into_iter()
.filter_entry(|entry| entry.file_name() != ".git")
.filter_map(Result::ok)
.filter(|entry| !entry.file_type().is_dir())
{
if let Ok(path) = entry.path().strip_prefix(repo_path) {
entries.extend_from_slice(format!("100644 {EMPTY_BLOB}\t").as_bytes());
entries.extend_from_slice(&path_to_bytes(path));
entries.push(0);
}
}
let mut file = NamedTempFile::new().map_err(file_error("creating", &env::temp_dir()))?;
file.write_all(&entries)
.map_err(file_error("writing", file.path()))?;
let stdin = file.reopen().map_err(file_error("reading", file.path()))?;
call::run_blocking(
"update-index",
Command::new("git")
.arg("--git-dir")
.arg(git_dir)
.args(["update-index", "-z", "--index-info"])
.stdin(stdin)
.current_dir(git_dir),
)?;
Ok(())
}
fn empty_git_dir() -> Result<TempDir, PruneError> {
let git_dir = TempDir::new().map_err(file_error("creating", &env::temp_dir()))?;
call::run_blocking(
"init",
Command::new("git")
.args(["init", "-q", "--bare"])
.arg(git_dir.path())
.current_dir(git_dir.path()),
)?;
Ok(git_dir)
}
fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile, FileError> {
let mut file = NamedTempFile::new().map_err(file_error("creating", &env::temp_dir()))?;
for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
writeln!(file, "{pattern}").map_err(file_error("writing", file.path()))?;
}
Ok(file)
}
fn remove_empty_dirs(root: &Path) -> Result<(), FileError> {
let mut dirs: Vec<_> = Vec::new();
for entry in WalkDir::new(root) {
let entry = entry.map_err(|error| FileError {
action: "reading",
path: error.path().unwrap_or(root).to_path_buf(),
source: error.into(),
})?;
let path = entry.path();
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let rel_str = rel.to_string_lossy();
if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
continue;
}
if entry.file_type().is_dir() && path != root {
dirs.push(path.to_path_buf());
}
}
dirs.sort();
dirs.reverse();
for dir in dirs {
let is_empty = match fs::read_dir(&dir) {
Ok(mut entries) => entries.next().is_none(),
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(source) => return Err(file_error("reading", &dir)(source)),
};
if is_empty {
remove_if_present(&dir, fs::remove_dir)?;
}
}
Ok(())
}
fn file_error<'a>(
action: &'static str,
path: &'a Path,
) -> impl FnOnce(io::Error) -> FileError + 'a {
move |source| FileError {
action,
path: path.to_path_buf(),
source,
}
}
fn remove_if_present<'a>(
path: &'a Path,
remove: fn(&'a Path) -> io::Result<()>,
) -> Result<(), FileError> {
match remove(path) {
Err(source) if source.kind() != io::ErrorKind::NotFound => {
Err(file_error("removing", path)(source))
}
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::{delete_gitignored_files, remove_symlinks};
fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for file in files {
let path = dir.path().join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"").unwrap();
}
for args in [&["init", "-q"][..], &["add", "-A"]] {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(args)
.status()
.unwrap();
assert!(status.success());
}
dir
}
fn exclusions(patterns: &[&str]) -> Vec<String> {
patterns
.iter()
.map(|pattern| (*pattern).to_owned())
.collect()
}
#[test]
fn a_directory_pattern_deletes_what_is_under_it() {
let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
assert!(!repo.join("docs").exists());
assert!(repo.join("keep.py").exists());
assert!(repo.join(".git/config").exists());
}
#[test]
fn a_directory_that_is_not_a_repository_is_pruned_too() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("docs/sub")).unwrap();
std::fs::write(root.join("docs/sub/a.md"), b"").unwrap();
std::fs::write(root.join("keep.py"), b"").unwrap();
delete_gitignored_files(root, &exclusions(&["docs"])).unwrap();
assert!(!root.join("docs").exists());
assert!(root.join("keep.py").exists());
}
#[test]
fn a_file_in_a_nested_repository_is_deleted_too() {
let dir = indexed_repo(&["top.log", "inner/a.log", "inner/keep.py"]);
let repo = dir.path();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.arg(repo.join("inner"))
.status()
.unwrap();
assert!(status.success());
delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
assert!(!repo.join("top.log").exists());
assert!(!repo.join("inner/a.log").exists());
assert!(repo.join("inner/keep.py").exists());
assert!(repo.join("inner/.git/HEAD").exists());
}
#[test]
fn the_gitignore_of_the_tree_adds_no_rules() {
let dir = indexed_repo(&["keep.py", "a.log"]);
let repo = dir.path();
std::fs::write(repo.join(".gitignore"), b"*.py\n").unwrap();
delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
assert!(repo.join("keep.py").exists());
assert!(!repo.join("a.log").exists());
}
#[test]
fn no_exclusions_keep_every_file() {
let dir = indexed_repo(&["keep.py"]);
delete_gitignored_files(dir.path(), &[]).unwrap();
assert!(dir.path().join("keep.py").exists());
}
#[test]
fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
let dir = indexed_repo(&["docs/a.md", "keep.py"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
assert!(!repo.join("docs/a.md").exists());
assert!(repo.join("keep.py").exists());
}
#[test]
fn an_unclosed_bracket_matches_nothing() {
let dir = indexed_repo(&["[main", "]"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
assert!(repo.join("[main").exists());
assert!(!repo.join("]").exists());
}
#[test]
fn braces_match_literally() {
let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
assert!(repo.join("a/x").exists());
assert!(!repo.join("{a,b}/x").exists());
assert!(!repo.join("a}b").exists());
assert!(!repo.join("cq").exists());
}
#[test]
fn a_backslash_takes_the_next_character_literally() {
let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
assert!(!repo.join("a[{b}").exists());
assert!(!repo.join("{a}").exists());
assert!(repo.join("ab").exists());
}
#[test]
fn a_named_class_matches_its_characters() {
let dir = indexed_repo(&["f5", "fx"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
assert!(!repo.join("f5").exists());
assert!(repo.join("fx").exists());
}
#[test]
fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
let dir = indexed_repo(&["README.md"]);
let repo = dir.path();
let status = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["config", "core.ignorecase", "true"])
.status()
.unwrap();
assert!(status.success());
delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
assert!(repo.join("README.md").exists());
}
#[test]
fn an_exclusion_reads_as_a_gitignore_line() {
let dir = indexed_repo(&["#a", "b", "c"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
assert!(repo.join("#a").exists());
assert!(!repo.join("b").exists());
assert!(!repo.join("c").exists());
}
#[test]
fn an_untracked_file_is_deleted_too() {
let dir = indexed_repo(&["docs/a.md"]);
let repo = dir.path();
std::fs::write(repo.join("docs/new.md"), b"").unwrap();
delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
assert!(!repo.join("docs").exists());
}
#[test]
fn an_exclusion_with_a_line_break_adds_no_rules() {
let dir = indexed_repo(&["a", "b"]);
let repo = dir.path();
delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
assert!(repo.join("a").exists());
assert!(repo.join("b").exists());
}
#[test]
#[cfg(unix)]
fn an_unreadable_directory_does_not_stop_the_removal() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.txt");
std::fs::write(&target, b"content").unwrap();
let blocked = dir.path().join("b_blocked");
std::fs::create_dir(&blocked).unwrap();
let late = dir.path().join("c_late.lnk");
std::os::unix::fs::symlink(&target, &late).unwrap();
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
remove_symlinks(dir.path()).unwrap();
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!late.is_symlink());
assert!(target.exists());
}
#[test]
#[cfg(unix)]
fn a_symlink_that_cannot_be_removed_is_reported() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let sealed = dir.path().join("sealed");
std::fs::create_dir(&sealed).unwrap();
let link = sealed.join("link.lnk");
std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(sealed.join("probe"), b"").is_ok() {
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
return;
}
let result = remove_symlinks(dir.path());
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(result.unwrap_err().path, link);
assert!(link.is_symlink());
}
#[test]
#[cfg(unix)]
fn the_directory_itself_is_never_removed() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
std::fs::create_dir(&real).unwrap();
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
remove_symlinks(&alias).unwrap();
assert!(alias.is_symlink());
}
}