use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{Resp, read};
struct Repo {
dir: PathBuf,
}
impl Drop for Repo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
impl Repo {
fn new() -> Repo {
static COUNT: AtomicUsize = AtomicUsize::new(0);
let n = COUNT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("lazier-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("make the directory");
let repo = Repo { dir };
repo.git(&["init", "--quiet"]);
repo.git(&["config", "user.name", "Lazier Test"]);
repo.git(&["config", "user.email", "test@example.com"]);
repo.git(&["config", "commit.gpgsign", "false"]);
repo.git(&["config", "tag.gpgsign", "false"]);
repo.write("start.txt", "first\n");
repo.git(&["add", "start.txt"]);
repo.commit("start");
repo
}
fn git(&self, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&self.dir)
.args(args)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn write(&self, name: &str, text: &str) {
let path = self.dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("make the parent directory");
}
std::fs::write(path, text).expect("write the file");
}
fn remove(&self, name: &str) {
std::fs::remove_file(self.dir.join(name)).expect("remove the file");
}
fn commit(&self, message: &str) {
self.git(&["commit", "--quiet", "-m", message]);
}
fn open(&self) -> gix::Repository {
gix::open(&self.dir).expect("open with gix")
}
fn status(&self) -> Vec<String> {
let Some(Resp::Status(files)) = read::status(&self.open()) else {
panic!("the status gave nothing");
};
let mut rows: Vec<String> =
files.iter().map(|f| format!("{}{} {}", f.index, f.work, f.path)).collect();
rows.sort();
rows
}
fn status_paths(&self, paths: &[&str]) -> Vec<String> {
let want: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let Some(Resp::StatusPaths { scanned, files }) = read::status_paths(&self.open(), &want)
else {
panic!("the status gave nothing");
};
assert_eq!(scanned, want, "the answer must say what it looked at");
let mut rows: Vec<String> =
files.iter().map(|f| format!("{}{} {}", f.index, f.work, f.path)).collect();
rows.sort();
rows
}
}
#[test]
fn a_clean_repository_has_no_rows() {
let repo = Repo::new();
assert!(repo.status().is_empty());
}
#[test]
fn an_untracked_file_is_not_a_staged_file() {
let repo = Repo::new();
repo.write("new.txt", "hello\n");
assert_eq!(repo.status(), [" ? new.txt"]);
}
#[test]
fn a_changed_file_shows_in_the_work_tree_column() {
let repo = Repo::new();
repo.write("start.txt", "second\n");
assert_eq!(repo.status(), [" M start.txt"]);
}
#[test]
fn a_staged_file_shows_in_the_index_column() {
let repo = Repo::new();
repo.write("new.txt", "hello\n");
repo.git(&["add", "new.txt"]);
assert_eq!(repo.status(), ["A new.txt"]);
}
#[test]
fn one_file_with_two_changes_takes_one_row() {
let repo = Repo::new();
repo.write("start.txt", "second\n");
repo.git(&["add", "start.txt"]);
repo.write("start.txt", "third\n");
assert_eq!(repo.status(), ["MM start.txt"]);
}
#[test]
fn a_removed_file_shows_as_removed() {
let repo = Repo::new();
repo.remove("start.txt");
assert_eq!(repo.status(), [" D start.txt"]);
}
#[test]
fn the_cache_tree_shortcut_still_finds_a_work_tree_change() {
let repo = Repo::new();
repo.write("start.txt", "second\n");
assert_eq!(repo.status(), [" M start.txt"]);
repo.git(&["add", "start.txt"]);
assert_eq!(repo.status(), ["M start.txt"]);
}
#[test]
fn a_scan_of_one_path_reports_only_that_path() {
let repo = Repo::new();
repo.write("one.txt", "one\n");
repo.write("two.txt", "two\n");
assert_eq!(repo.status_paths(&["one.txt"]), [" ? one.txt"]);
assert_eq!(repo.status_paths(&["two.txt"]), [" ? two.txt"]);
}
#[test]
fn a_scan_of_a_path_agrees_with_the_full_scan() {
let repo = Repo::new();
repo.write("src/a.txt", "a\n");
repo.write("src/b.txt", "b\n");
repo.write("start.txt", "second\n");
repo.git(&["add", "start.txt"]);
let full = repo.status();
for path in ["src", "start.txt"] {
let rows = repo.status_paths(&[path]);
let want: Vec<String> = full.iter().filter(|r| r[3..].starts_with(path)).cloned().collect();
assert_eq!(rows, want, "the scan of {path} must agree with the full scan");
}
}
#[test]
fn a_scan_of_a_clean_path_reports_no_row() {
let repo = Repo::new();
assert!(repo.status_paths(&["start.txt"]).is_empty());
}
#[test]
fn a_tag_points_at_its_commit() {
let repo = Repo::new();
repo.git(&["tag", "-a", "v1.0", "-m", "v1.0"]);
let id = repo.git(&["rev-parse", "--short=7", "HEAD"]);
let Some(Resp::Tags(map)) = read::tags(&repo.open()) else { panic!("no tags") };
assert_eq!(map.get(&id).map(Vec::as_slice), Some(["v1.0".to_string()].as_slice()));
}
#[test]
fn a_repository_with_no_tag_gives_an_empty_map() {
let repo = Repo::new();
let Some(Resp::Tags(map)) = read::tags(&repo.open()) else { panic!("no tags") };
assert!(map.is_empty());
}
#[test]
fn the_head_name_is_the_branch_name() {
let repo = Repo::new();
repo.git(&["branch", "-m", "work"]);
assert_eq!(read::head_name(&repo.open()).as_deref(), Some("work"));
}
#[test]
fn a_detached_head_has_no_branch_name() {
let repo = Repo::new();
let id = repo.git(&["rev-parse", "HEAD"]);
repo.git(&["checkout", "--quiet", &id]);
assert_eq!(read::head_name(&repo.open()), None);
}
#[test]
fn a_stash_shows_its_message() {
let repo = Repo::new();
repo.write("start.txt", "second\n");
repo.git(&["stash", "push", "--quiet", "-m", "keep this"]);
let Some(Resp::Stashes(list)) = read::stashes(&repo.open()) else { panic!("no stashes") };
assert_eq!(list.len(), 1);
assert!(list[0].contains("keep this"), "the row was {:?}", list[0]);
assert!(repo.status().is_empty());
}
#[test]
fn no_stash_gives_an_empty_list() {
let repo = Repo::new();
let Some(Resp::Stashes(list)) = read::stashes(&repo.open()) else { panic!("no stashes") };
assert!(list.is_empty());
}