#![cfg(not(coverage_nightly))]
#![expect(
clippy::tests_outside_test_module,
reason = "Cargo integration tests live at the file's module root"
)]
#![expect(
clippy::unwrap_used,
reason = "a generator/oracle setup failure should abort the test loudly"
)]
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use proptest::prelude::*;
use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner};
use tempfile::TempDir;
#[derive(Clone, Copy, Debug)]
enum FileState {
Committed, Untracked, Ignored, ModifiedWorktree, StagedModify, StagedAdd, DeletedWorktree, StagedDelete, TypeChange, }
fn state_strategy() -> impl Strategy<Value = FileState> {
prop_oneof![
Just(FileState::Committed),
Just(FileState::Untracked),
Just(FileState::Ignored),
Just(FileState::ModifiedWorktree),
Just(FileState::StagedModify),
Just(FileState::StagedAdd),
Just(FileState::DeletedWorktree),
Just(FileState::StagedDelete),
Just(FileState::TypeChange),
]
}
#[derive(Clone, Debug)]
struct FileSpec {
dirs: Vec<u8>,
state: FileState,
}
fn file_spec_strategy() -> impl Strategy<Value = FileSpec> {
(prop::collection::vec(0u8..3, 0..=2), state_strategy())
.prop_map(|(dirs, state)| FileSpec { dirs, state })
}
#[derive(Clone, Debug)]
struct RepoSpec {
files: Vec<FileSpec>,
empty_chains: Vec<u8>,
}
fn repo_spec_strategy() -> impl Strategy<Value = RepoSpec> {
(
prop::collection::vec(file_spec_strategy(), 0..=10),
prop::collection::vec(1u8..=3, 0..=3),
)
.prop_map(|(files, empty_chains)| RepoSpec {
files,
empty_chains,
})
}
fn file_rel(spec: &FileSpec, idx: usize) -> PathBuf {
let mut p = PathBuf::new();
for d in &spec.dirs {
p.push(format!("d{d}"));
}
p.push(format!("f{idx}"));
p
}
fn git_base(dir: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.invalid")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.invalid")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("HOME", dir);
cmd
}
fn git(dir: &Path, args: &[&str]) {
let out = git_base(dir).args(args).output().unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn git_capture(dir: &Path, args: &[&str]) -> Vec<u8> {
let out = git_base(dir).args(args).output().unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
fn materialize(spec: &RepoSpec) -> TempDir {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
git(root, &["init", "-q", "-b", "main"]);
let rels: Vec<PathBuf> = spec
.files
.iter()
.enumerate()
.map(|(i, f)| file_rel(f, i))
.collect();
std::fs::write(root.join(".keep"), b"keep\n").unwrap();
let ignored: Vec<String> = spec
.files
.iter()
.zip(&rels)
.filter(|(f, _)| matches!(f.state, FileState::Ignored))
.map(|(_, rel)| rel.to_string_lossy().into_owned())
.collect();
if !ignored.is_empty() {
std::fs::write(root.join(".gitignore"), format!("{}\n", ignored.join("\n"))).unwrap();
}
for (f, rel) in spec.files.iter().zip(&rels) {
if needs_base(f.state) {
write_file(root, rel, &format!("BASE-CONTENT-for-{}\n", rel.display()));
}
}
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "base"]);
for (f, rel) in spec.files.iter().zip(&rels) {
let abs = root.join(rel);
let rel_str = rel.to_string_lossy();
match f.state {
FileState::ModifiedWorktree => append(&abs, b"edited\n"),
FileState::StagedModify => {
append(&abs, b"edited\n");
git(root, &["add", "--", &rel_str]);
}
FileState::DeletedWorktree => std::fs::remove_file(&abs).unwrap(),
FileState::StagedDelete => git(root, &["rm", "-q", "--", &rel_str]),
FileState::TypeChange => {
std::fs::remove_file(&abs).unwrap();
std::os::unix::fs::symlink("type-change-target", &abs).unwrap();
}
_ => {}
}
}
for (f, rel) in spec.files.iter().zip(&rels) {
let rel_str = rel.to_string_lossy();
match f.state {
FileState::Untracked => {
write_file(root, rel, &format!("UNTRACKED-CONTENT-for-{rel_str}\n"));
}
FileState::StagedAdd => {
write_file(root, rel, &format!("STAGED-ADD-CONTENT-for-{rel_str}\n"));
git(root, &["add", "--", &rel_str]);
}
FileState::Ignored => {
write_file(root, rel, &format!("IGNORED-CONTENT-for-{rel_str}\n"));
}
_ => {}
}
}
for (i, &depth) in spec.empty_chains.iter().enumerate() {
let mut p = root.to_path_buf();
for _ in 0..depth {
p.push(format!("c{i}"));
}
std::fs::create_dir_all(&p).unwrap();
}
tmp
}
const fn needs_base(state: FileState) -> bool {
matches!(
state,
FileState::Committed
| FileState::ModifiedWorktree
| FileState::StagedModify
| FileState::DeletedWorktree
| FileState::StagedDelete
| FileState::TypeChange
)
}
fn write_file(root: &Path, rel: &Path, content: &str) {
let abs = root.join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(abs, content).unwrap();
}
fn append(abs: &Path, bytes: &[u8]) {
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new().append(true).open(abs).unwrap();
f.write_all(bytes).unwrap();
}
fn xy_glyph(x: char, y: char) -> char {
let worktree = match y {
'M' => Some('●'),
'D' => Some('▽'),
'T' => Some('≈'),
'R' => Some('→'),
'C' => Some('⇉'),
_ => None,
};
let index = match x {
'M' => Some('●'),
'A' => Some('+'),
'D' => Some('▽'),
'R' => Some('→'),
'C' => Some('⇉'),
_ => None,
};
worktree.or(index).unwrap_or('?')
}
fn last_field(text: &str) -> PathBuf {
PathBuf::from(text.rsplit(' ').next().unwrap_or_default())
}
fn parse_status(raw: &[u8]) -> HashMap<PathBuf, char> {
let mut map = HashMap::new();
let records: Vec<&[u8]> = raw.split(|&b| b == 0).filter(|r| !r.is_empty()).collect();
let mut i = 0;
while let Some(rec) = records.get(i) {
i += 1;
let text = String::from_utf8_lossy(rec);
if let Some(path) = text.strip_prefix("? ") {
map.insert(PathBuf::from(path), '?');
} else if let Some(path) = text.strip_prefix("! ") {
map.insert(PathBuf::from(path), '·');
} else if let Some(rest) = text.strip_prefix("1 ") {
let mut xy = rest.chars();
let g = xy_glyph(xy.next().unwrap_or(' '), xy.next().unwrap_or(' '));
map.insert(last_field(&text), g);
} else if let Some(rest) = text.strip_prefix("2 ") {
let mut xy = rest.chars();
let g = xy_glyph(xy.next().unwrap_or(' '), xy.next().unwrap_or(' '));
map.insert(last_field(&text), g);
i += 1; } else if text.starts_with("u ") {
map.insert(last_field(&text), '✘');
}
}
map
}
fn parse_tracked(raw: &[u8]) -> HashSet<PathBuf> {
raw.split(|&b| b == 0)
.filter(|r| !r.is_empty())
.map(|r| PathBuf::from(String::from_utf8_lossy(r).into_owned()))
.collect()
}
fn expected_file_glyph(
rel: &Path,
status: &HashMap<PathBuf, char>,
tracked: &HashSet<PathBuf>,
) -> char {
if let Some(&g) = status.get(rel) {
g
} else if tracked.contains(rel) {
'○'
} else {
'?'
}
}
fn expected_dir_glyph(
rel: &Path,
tracked: &HashSet<PathBuf>,
status: &HashMap<PathBuf, char>,
) -> char {
let is_tracked = rel.as_os_str().is_empty() || tracked.iter().any(|t| t.starts_with(rel));
let under = |want: char| status.iter().any(|(p, &g)| g == want && p.starts_with(rel));
if is_tracked {
let has_dirty = status.iter().any(|(p, &g)| g != '·' && p.starts_with(rel));
if has_dirty { '⋯' } else { '○' }
} else if under('?') {
'?'
} else if under('·') {
'·'
} else {
' ' }
}
fn walk(root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut dirs = vec![PathBuf::new()]; let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in std::fs::read_dir(¤t).unwrap().flatten() {
let path = entry.path();
let rel = path.strip_prefix(root).unwrap().to_path_buf();
if rel.as_os_str() == ".git" {
continue;
}
match entry.file_type() {
Ok(ft) if ft.is_dir() => {
dirs.push(rel);
stack.push(path);
}
_ => files.push(rel),
}
}
}
(dirs, files)
}
fn check_one(spec: &RepoSpec) -> Result<(), TestCaseError> {
let tmp = materialize(spec);
let root = tmp.path();
let status = parse_status(&git_capture(
root,
&[
"status",
"--porcelain=v2",
"-z",
"--ignored=matching",
"--untracked-files=all",
],
));
let tracked = parse_tracked(&git_capture(root, &["ls-files", "-z"]));
let snap = freshl::git::discover(root).expect("repo discovered");
let canon = snap.root.clone();
let (dirs, files) = walk(&canon);
for rel in &files {
let actual = snap
.display_code_for(&canon.join(rel), false)
.glyph();
let expected = expected_file_glyph(rel, &status, &tracked);
prop_assert_eq!(
actual,
expected,
"file {:?}: freshl={:?} git={:?} (porcelain {:?})",
rel,
actual,
expected,
status.get(rel)
);
}
for rel in &dirs {
let actual = snap
.display_code_for(&canon.join(rel), true)
.glyph();
let expected = expected_dir_glyph(rel, &tracked, &status);
prop_assert_eq!(
actual,
expected,
"dir {:?}: freshl={:?} expected={:?}",
rel,
actual,
expected
);
}
Ok(())
}
#[test]
fn freshl_git_column_matches_git_cli() {
let cases = std::env::var("PROPTEST_CASES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(64);
let mut config = Config::with_cases(cases);
config.failure_persistence = None;
let mut runner =
TestRunner::new_with_rng(config, TestRng::deterministic_rng(RngAlgorithm::ChaCha));
runner
.run(&repo_spec_strategy(), |spec| check_one(&spec))
.expect("freshl's git column should match the git CLI on every generated worktree");
}
const ALL_STATES: [FileState; 9] = [
FileState::Committed,
FileState::Untracked,
FileState::Ignored,
FileState::ModifiedWorktree,
FileState::StagedModify,
FileState::StagedAdd,
FileState::DeletedWorktree,
FileState::StagedDelete,
FileState::TypeChange,
];
fn small_repo_specs() -> Vec<RepoSpec> {
let mut specs = Vec::new();
for chains in [Vec::new(), vec![1u8], vec![2u8]] {
specs.push(RepoSpec {
files: Vec::new(),
empty_chains: chains,
});
}
for state in ALL_STATES {
for dirs in [Vec::new(), vec![0u8]] {
for chains in [Vec::new(), vec![1u8]] {
specs.push(RepoSpec {
files: vec![FileSpec {
dirs: dirs.clone(),
state,
}],
empty_chains: chains,
});
}
}
}
for (i, &s1) in ALL_STATES.iter().enumerate() {
for &s2 in ALL_STATES.iter().skip(i) {
specs.push(RepoSpec {
files: vec![
FileSpec {
dirs: vec![0u8],
state: s1,
},
FileSpec {
dirs: vec![0u8],
state: s2,
},
],
empty_chains: Vec::new(),
});
}
}
specs
}
#[test]
fn small_repos_match_git_cli_exhaustively() {
for spec in small_repo_specs() {
let result = check_one(&spec);
assert!(
result.is_ok(),
"exhaustive small-repo differential failed for {spec:?}: {result:?}"
);
}
}