use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;
pub const STATUS_ENTRY_CAP: usize = 5_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusError {
GitUnavailable,
BudgetExceeded,
}
#[must_use]
pub fn scope_pathspecs(scope: &[String]) -> Vec<String> {
scope
.iter()
.map(|d| {
if d == "." {
".".to_string()
} else {
format!(":(literal){d}")
}
})
.collect()
}
pub fn git_status_paths(
root: &Path,
pathspecs: &[String],
entry_cap: Option<usize>,
) -> Result<BTreeSet<String>, StatusError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(root).args([
"--no-optional-locks",
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--no-renames",
"--",
]);
for p in pathspecs {
cmd.arg(p);
}
let output = cmd.output().map_err(|_| StatusError::GitUnavailable)?;
if !output.status.success() {
return Err(StatusError::GitUnavailable);
}
let mut paths = BTreeSet::new();
for (index, record) in output
.stdout
.split(|b| *b == 0)
.filter(|r| !r.is_empty())
.enumerate()
{
if entry_cap.is_some_and(|cap| index >= cap) {
return Err(StatusError::BudgetExceeded);
}
if record.len() < 4 || record[2] != b' ' {
return Err(StatusError::GitUnavailable);
}
let path = std::str::from_utf8(&record[3..]).map_err(|_| StatusError::GitUnavailable)?;
paths.insert(path.to_string());
}
Ok(paths)
}
#[must_use]
pub fn is_working_tree_dirty(root: &Path, scope: &[String]) -> bool {
match git_status_paths(root, &scope_pathspecs(scope), Some(STATUS_ENTRY_CAP)) {
Ok(paths) => !paths.is_empty(),
Err(StatusError::BudgetExceeded) => true,
Err(StatusError::GitUnavailable) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn scratch(label: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let wanted = std::env::temp_dir().join(format!(
"scout-freshness-status-{label}-{}-{n}",
std::process::id()
));
fs::canonicalize(claim(wanted)).unwrap()
}
fn claim(wanted: PathBuf) -> PathBuf {
let name = wanted
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
for attempt in 0..10_000u32 {
let candidate = if attempt == 0 {
wanted.clone()
} else {
wanted.with_file_name(format!("{name}-{attempt}"))
};
match fs::create_dir(&candidate) {
Ok(()) => return candidate,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => panic!("create scratch dir {}: {e}", candidate.display()),
}
}
panic!("no free scratch directory name for {}", wanted.display());
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.stdin(Stdio::null())
.output()
.expect("git must be installed");
assert!(out.status.success(), "git {args:?}: {out:?}");
}
fn commit_all(dir: &Path) {
git(dir, &["add", "-A"]);
let tree = capture(dir, &["write-tree"]);
let sha = capture(dir, &["commit-tree", &tree, "-m", "init"]);
git(dir, &["update-ref", "refs/heads/master", &sha]);
git(dir, &["symbolic-ref", "HEAD", "refs/heads/master"]);
}
fn capture(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.env("GIT_AUTHOR_NAME", "devscout-test")
.env("GIT_AUTHOR_EMAIL", "devscout-test@example.com")
.env("GIT_COMMITTER_NAME", "devscout-test")
.env("GIT_COMMITTER_EMAIL", "devscout-test@example.com")
.current_dir(dir)
.stdin(Stdio::null())
.output()
.expect("git must be installed");
assert!(out.status.success(), "git {args:?}: {out:?}");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn repo(label: &str) -> PathBuf {
let dir = scratch(label);
git(&dir, &["init", "-q", "."]);
dir
}
#[test]
fn scope_pathspecs_marks_each_dir_literal() {
assert_eq!(
scope_pathspecs(&["src".to_string(), "app".to_string()]),
vec![":(literal)src".to_string(), ":(literal)app".to_string()]
);
assert_eq!(scope_pathspecs(&[".".to_string()]), vec![".".to_string()]);
}
#[test]
fn a_clean_repo_reads_as_an_empty_set_not_a_failure() {
let dir = repo("clean");
fs::write(dir.join("a.cs"), "class A {}\n").unwrap();
commit_all(&dir);
let set = git_status_paths(&dir, &[".".to_string()], None).unwrap();
assert!(set.is_empty(), "{set:?}");
}
#[test]
fn untracked_files_and_paths_with_spaces_quotes_and_non_ascii_parse() {
let dir = repo("names");
for name in ["has space.cs", "has\"quote.cs", "café.cs", "plain.cs"] {
fs::write(dir.join(name), "class A {}\n").unwrap();
}
let set = git_status_paths(&dir, &[".".to_string()], None).unwrap();
for name in ["has space.cs", "has\"quote.cs", "café.cs", "plain.cs"] {
assert!(set.contains(name), "{name} missing from {set:?}");
}
}
#[test]
fn pathspecs_bound_the_read_to_the_scope() {
let dir = repo("scope");
fs::create_dir_all(dir.join("src")).unwrap();
fs::create_dir_all(dir.join("other")).unwrap();
fs::write(dir.join("src/in.cs"), "class A {}\n").unwrap();
fs::write(dir.join("other/out.cs"), "class B {}\n").unwrap();
let set = git_status_paths(&dir, &scope_pathspecs(&["src".to_string()]), None).unwrap();
assert!(set.contains("src/in.cs"));
assert!(!set.contains("other/out.cs"), "{set:?}");
}
#[test]
fn a_cap_reports_budget_rather_than_a_truncated_set() {
let dir = repo("cap");
for i in 0..3 {
fs::write(dir.join(format!("f{i}.cs")), "class A {}\n").unwrap();
}
let at_cap = git_status_paths(&dir, &[".".to_string()], Some(3)).unwrap();
assert_eq!(at_cap.len(), 3);
assert_eq!(
git_status_paths(&dir, &[".".to_string()], Some(2)),
Err(StatusError::BudgetExceeded)
);
}
#[test]
fn a_non_repo_is_git_unavailable_and_false() {
let dir = scratch("norepo");
fs::write(dir.join("a.cs"), "class A {}\n").unwrap();
assert_eq!(
git_status_paths(&dir, &[".".to_string()], None),
Err(StatusError::GitUnavailable)
);
assert!(!is_working_tree_dirty(&dir, &[".".to_string()]));
}
#[test]
fn a_dirty_tree_reports_true() {
let dir = repo("dirty");
fs::write(dir.join("a.cs"), "class A {}\n").unwrap();
commit_all(&dir);
fs::write(dir.join("a.cs"), "class A { int X; }\n").unwrap();
assert!(is_working_tree_dirty(&dir, &[".".to_string()]));
}
#[test]
fn a_scratch_directory_left_by_an_earlier_run_is_never_reopened() {
let base = scratch("reuse");
let stale = base.join("stale");
fs::create_dir(&stale).unwrap();
fs::write(stale.join("a.cs"), "class A { }\n").unwrap();
let claimed = claim(stale.clone());
assert_ne!(
claimed, stale,
"a directory an earlier run holds is not reused"
);
assert!(
!claimed.join("a.cs").exists(),
"a claimed scratch directory holds no earlier run's file"
);
}
}