use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use std::{fmt, io, str};
use unicode_normalization::UnicodeNormalization;
const AMBIENT_GIT_ENV: [&str; 8] = [
"GIT_DIR",
"GIT_INDEX_FILE",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
"GIT_NAMESPACE",
];
pub(crate) fn git(dir: &Path) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(dir).stdin(Stdio::null());
for variable in AMBIENT_GIT_ENV {
command.env_remove(variable);
}
command.env("LC_ALL", "C").env("LANGUAGE", "");
command
}
#[must_use]
pub fn is_work_tree_root(dir: &Path) -> bool {
dir.join(".git").symlink_metadata().is_ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Checkout {
Repository,
Linked,
Submodule,
}
#[must_use]
pub fn checkout_at(dir: &Path) -> Option<Checkout> {
if !is_work_tree_root(dir) {
return None;
}
let Ok(output) = git(dir)
.args(["rev-parse", "--path-format=absolute", "--git-dir"])
.arg("--git-common-dir")
.output()
else {
return Some(Checkout::Repository);
};
if !output.status.success() {
return Some(Checkout::Repository);
}
let text = String::from_utf8_lossy(&output.stdout);
let mut lines = text.lines();
let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
return Some(Checkout::Repository);
};
let git_dir = Path::new(git_dir.trim());
let linked = git_dir
.parent()
.is_some_and(|holder| holder.file_name() == Some(OsStr::new("worktrees")))
&& git_dir.parent().and_then(Path::parent) == Some(Path::new(common.trim()));
Some(if linked {
Checkout::Linked
} else if git_dir
.components()
.any(|part| part.as_os_str() == OsStr::new("modules"))
{
Checkout::Submodule
} else {
Checkout::Repository
})
}
#[must_use]
pub fn is_clean(dir: &Path) -> bool {
git(dir)
.args(["status", "--porcelain"])
.output()
.is_ok_and(|output| output.status.success() && output.stdout.is_empty())
}
#[must_use]
pub fn head_on_branch(dir: &Path) -> bool {
git(dir)
.args(["symbolic-ref", "--quiet", "HEAD"])
.output()
.is_ok_and(|output| output.status.success())
}
#[must_use]
pub fn discover(from: &Path) -> Option<PathBuf> {
let mut cursor = Some(from);
while let Some(dir) = cursor {
if is_work_tree_root(dir) {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
#[derive(Debug)]
pub struct WorkTree {
root: PathBuf,
tracked: Vec<Box<[u8]>>,
}
impl WorkTree {
pub fn open(root: &Path) -> Result<Self, GitError> {
let mut command = git(root);
command.args(["ls-files", "-z", "--full-name"]);
let output = command
.output()
.map_err(|err| GitError::Run(root.to_path_buf(), err))?;
if !output.status.success() {
return Err(GitError::Refused(
root.to_path_buf(),
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
));
}
let mut tracked: Vec<Box<[u8]>> = output
.stdout
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
.map(|path| Box::from(comparable(path.to_vec())))
.collect();
tracked.sort_unstable();
tracked.dedup();
Ok(Self {
root: root.to_path_buf(),
tracked,
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn tracked(&self) -> usize {
self.tracked.len()
}
#[must_use]
pub fn holds_tracked_path(&self, dir: &Path) -> bool {
let Ok(relative) = dir.strip_prefix(&self.root) else {
return true;
};
let Some(mut prefix) = as_index_path(relative) else {
return true;
};
if prefix.is_empty() {
return !self.tracked.is_empty();
}
if self.tracked.binary_search(&prefix.clone().into()).is_ok() {
return true;
}
prefix.push(b'/');
let at = self
.tracked
.partition_point(|path| path.as_ref() < prefix.as_slice());
self.tracked
.get(at)
.is_some_and(|path| path.starts_with(&prefix))
}
}
fn as_index_path(relative: &Path) -> Option<Vec<u8>> {
let mut out = Vec::new();
for component in relative.components() {
let Component::Normal(segment) = component else {
return None;
};
if !out.is_empty() {
out.push(b'/');
}
out.extend_from_slice(segment.as_encoded_bytes());
}
Some(comparable(out))
}
fn comparable(path: Vec<u8>) -> Vec<u8> {
if path.is_ascii() {
return path;
}
match str::from_utf8(&path) {
Ok(text) => text.nfc().collect::<String>().into_bytes(),
Err(_) => path,
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum GitError {
Run(PathBuf, io::Error),
Refused(PathBuf, String),
}
impl fmt::Display for GitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Run(root, err) => write!(
f,
"could not run git in {}, so nothing there can be judged safe to remove: {err}",
root.display()
),
Self::Refused(root, message) => write!(
f,
"git would not list the index of {}, so nothing there can be judged safe to \
remove: {message}",
root.display()
),
}
}
}
impl std::error::Error for GitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Run(_, err) => Some(err),
Self::Refused(..) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{
Checkout, WorkTree, as_index_path, checkout_at, comparable, git, head_on_branch, is_clean,
};
use std::path::{Path, PathBuf};
fn repo(at: &Path) {
std::fs::create_dir_all(at).unwrap();
run(at, &["init", "--quiet", "."]);
run(at, &["config", "user.email", "test@example.com"]);
run(at, &["config", "user.name", "test"]);
std::fs::write(at.join("tracked.txt"), "content").unwrap();
run(at, &["add", "."]);
run(at, &["commit", "--quiet", "-m", "first"]);
}
fn worktree(main: &Path, args: &[&str]) {
let mut all = vec!["worktree", "add", "--quiet"];
all.extend_from_slice(args);
run(main, &all);
}
fn run(at: &Path, args: &[&str]) {
let output = git(at).args(args).output().unwrap();
assert!(
output.status.success(),
"git {args:?} in {}: {}",
at.display(),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn a_linked_work_tree_is_told_apart_from_the_repository_and_from_a_submodule() {
let tmp = tempfile::TempDir::new().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap();
let main = base.join("main");
let inner = base.join("inner");
repo(&main);
repo(&inner);
worktree(&main, &["../linked", "-b", "feature"]);
run(
&main,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"--quiet",
"add",
inner.to_str().unwrap(),
"vendored",
],
);
run(&main, &["commit", "--quiet", "-m", "vendored"]);
assert_eq!(checkout_at(&main), Some(Checkout::Repository));
assert_eq!(checkout_at(&base.join("linked")), Some(Checkout::Linked));
assert_eq!(
checkout_at(&main.join("vendored")),
Some(Checkout::Submodule),
"a submodule was read as a disposable work tree"
);
assert_eq!(checkout_at(&base), None);
}
#[test]
fn a_work_tree_is_clean_despite_ignored_build_output_and_dirty_with_anything_else() {
let tmp = tempfile::TempDir::new().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap();
let main = base.join("main");
repo(&main);
std::fs::write(main.join(".gitignore"), "node_modules/\n").unwrap();
run(&main, &["add", ".gitignore"]);
run(&main, &["commit", "--quiet", "-m", "ignore"]);
std::fs::create_dir_all(main.join("node_modules/dep")).unwrap();
std::fs::write(main.join("node_modules/dep/index.js"), "x").unwrap();
assert!(
is_clean(&main),
"4 GiB of node_modules must not read as work that exists nowhere else"
);
std::fs::write(main.join("notes.md"), "only copy").unwrap();
assert!(!is_clean(&main));
std::fs::remove_file(main.join("notes.md")).unwrap();
assert!(is_clean(&main));
std::fs::write(main.join("tracked.txt"), "edited").unwrap();
assert!(!is_clean(&main));
}
#[test]
fn a_detached_head_is_refused_because_its_commits_are_reachable_from_nothing_else() {
let tmp = tempfile::TempDir::new().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap();
let main = base.join("main");
repo(&main);
worktree(&main, &["../onbranch", "-b", "feature"]);
worktree(&main, &["--detach", "../loose"]);
assert!(head_on_branch(&base.join("onbranch")));
assert!(!head_on_branch(&base.join("loose")));
}
#[test]
fn every_answer_that_decides_a_deletion_fails_toward_keeping_the_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap();
assert!(!is_clean(&base), "a directory git disowns read as clean");
assert!(!head_on_branch(&base));
assert_eq!(checkout_at(&base), None);
}
fn work_tree(root: &str, tracked: &[&str]) -> WorkTree {
let mut tracked: Vec<Box<[u8]>> = tracked
.iter()
.map(|path| Box::from(comparable(path.as_bytes().to_vec())))
.collect();
tracked.sort_unstable();
WorkTree {
root: PathBuf::from(root),
tracked,
}
}
#[test]
fn a_tracked_file_at_any_depth_bars_the_directory_above_it() {
let tree = work_tree("/repo", &["out/deep/deeper/keep.txt", "src/main.rs"]);
assert!(tree.holds_tracked_path(Path::new("/repo/out")));
assert!(tree.holds_tracked_path(Path::new("/repo/out/deep")));
assert!(!tree.holds_tracked_path(Path::new("/repo/out/other")));
}
#[test]
fn a_name_that_merely_starts_the_same_is_not_a_match() {
let tree = work_tree("/repo", &["out-takes/a.txt", "out.txt", "outer/b.txt"]);
assert!(!tree.holds_tracked_path(Path::new("/repo/out")));
assert!(tree.holds_tracked_path(Path::new("/repo/out-takes")));
assert!(tree.holds_tracked_path(Path::new("/repo/outer")));
}
#[test]
fn a_gitlink_bars_the_directory_it_names() {
let tree = work_tree("/repo", &["vendor/sub"]);
assert!(tree.holds_tracked_path(Path::new("/repo/vendor/sub")));
assert!(tree.holds_tracked_path(Path::new("/repo/vendor")));
}
#[test]
fn a_path_outside_the_work_tree_is_assumed_tracked() {
let tree = work_tree("/repo", &["src/main.rs"]);
assert!(tree.holds_tracked_path(Path::new("/elsewhere/out")));
}
#[test]
fn an_empty_index_holds_nothing() {
let tree = work_tree("/repo", &[]);
assert!(!tree.holds_tracked_path(Path::new("/repo")));
assert!(!tree.holds_tracked_path(Path::new("/repo/out")));
}
#[test]
fn a_decomposed_path_matches_the_composed_one_git_stored() {
let tree = work_tree("/repo", &["caf\u{e9}/build/keep.txt"]);
assert!(tree.holds_tracked_path(Path::new("/repo/cafe\u{301}/build")));
assert!(tree.holds_tracked_path(Path::new("/repo/caf\u{e9}/build")));
assert!(!tree.holds_tracked_path(Path::new("/repo/cafe\u{301}/other")));
}
#[test]
fn index_paths_are_slash_separated_and_reject_anything_exotic() {
assert_eq!(
as_index_path(Path::new("a/b/c")).unwrap(),
b"a/b/c".to_vec()
);
assert_eq!(as_index_path(Path::new("")).unwrap(), Vec::<u8>::new());
assert!(as_index_path(Path::new("../a")).is_none());
}
}