use std::io;
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::{ambient_authority, fs_utf8::Dir};
use rstest::rstest;
use super::{GitListError, GitLsFiles};
use crate::select::git_output::CandidateListing;
fn names(listing: &CandidateListing) -> Vec<String> {
let mut names: Vec<String> = listing
.paths
.iter()
.map(std::string::ToString::to_string)
.collect();
names.sort();
names
}
fn write(root: &Utf8Path, name: &str, content: &str) {
let directory =
Dir::open_ambient_dir(root, ambient_authority()).expect("open fixture directory");
let name = Utf8Path::new(name);
if let Some(parent) = name.parent().filter(|parent| !parent.as_str().is_empty()) {
directory
.create_dir_all(parent)
.expect("create the fixture directory");
}
directory.write(name, content).expect("write a fixture");
}
fn git(directory: &Utf8Path, args: &[&str]) {
let mut command = std::process::Command::new("git");
command
.current_dir(directory)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", directory)
.env("LC_ALL", "C")
.env("LANGUAGE", "")
.env("GIT_AUTHOR_NAME", "mdtablefix tests")
.env("GIT_AUTHOR_EMAIL", "tests@example.invalid")
.env("GIT_COMMITTER_NAME", "mdtablefix tests")
.env("GIT_COMMITTER_EMAIL", "tests@example.invalid");
for variable in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
] {
command.env_remove(variable);
}
let output = command
.output()
.expect("git on PATH; the boundary test needs a real one");
assert!(
output.status.success(),
"git {args:?} failed with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
fn repository() -> (tempfile::TempDir, Utf8PathBuf) {
let directory = tempfile::tempdir().expect("a temporary directory");
let root = Utf8Path::from_path(directory.path())
.expect("a UTF-8 temporary directory")
.to_owned();
git(&root, &["init", "--quiet", "-b", "main"]);
(directory, root)
}
#[test]
fn lists_the_index_and_the_untracked_files_on_request() {
let (_directory, root) = repository();
write(&root, ".gitignore", "build/\n");
write(&root, "docs/guide.md", "|A|B|\n");
write(&root, "src/lib.rs", "fn main() {}\n");
write(&root, "notes.md", "|C|D|\n");
write(&root, "build/out.md", "|E|F|\n");
git(
&root,
&["add", "--", ".gitignore", "docs/guide.md", "src/lib.rs"],
);
let tracked = GitLsFiles::new(false)
.list_candidates(&root)
.expect("git ls-files");
assert_eq!(
names(&tracked),
[".gitignore", "docs/guide.md", "src/lib.rs"],
"the default listing is the index alone"
);
assert_eq!(tracked.skipped_non_utf8, 0);
let untracked = GitLsFiles::new(true)
.list_candidates(&root)
.expect("git ls-files");
assert_eq!(
names(&untracked),
[".gitignore", "docs/guide.md", "notes.md", "src/lib.rs"],
"the extension adds what is neither ignored nor in the index"
);
}
#[test]
fn an_absent_program_is_reported_as_such_rather_than_as_a_git_failure() {
let error = GitLsFiles::with_program("mdtablefix-no-such-program-6f2c", false)
.list_candidates(Utf8Path::new("."))
.expect_err("no such program exists");
assert!(
matches!(error, GitListError::ProgramNotFound { .. }),
"{error}"
);
assert_eq!(
error.to_string(),
"`mdtablefix-no-such-program-6f2c` is not installed or not on PATH"
);
}
#[cfg(unix)]
#[test]
fn a_failing_command_is_reported_with_its_status_and_gits_own_stderr() {
let error = GitLsFiles::with_program("/bin/false", false)
.list_candidates(Utf8Path::new("."))
.expect_err("`false` always fails");
let GitListError::Failed {
command,
status,
stderr,
} = error
else {
panic!("expected a git failure, got {error}");
};
assert_eq!(command, "/bin/false ls-files");
assert_eq!(status.code(), Some(1));
assert!(stderr.is_empty(), "`false` writes nothing: {stderr:?}");
}
#[cfg(unix)]
#[test]
fn a_failure_names_the_command_with_the_program_that_ran() {
let error = GitLsFiles::with_program("/bin/false", false)
.list_candidates(Utf8Path::new("."))
.expect_err("`false` always fails");
assert_eq!(
error.to_string(),
"`/bin/false ls-files` failed with exit status: 1"
);
}
#[cfg(unix)]
#[test]
fn a_failure_with_no_git_text_carries_our_wording_alone() {
let error = GitLsFiles::with_program("/bin/false", false)
.list_candidates(Utf8Path::new("."))
.expect_err("`false` always fails");
let diagnostic = error.diagnostic();
assert_eq!(diagnostic, error.to_string());
assert!(
!diagnostic.ends_with(": "),
"a dangling separator reads as a truncated diagnostic: {diagnostic:?}"
);
}
#[test]
fn the_git_directory_is_resolved_through_git() {
let (_directory, root) = repository();
write(&root, "docs/guide.md", "|A|B|\n");
git(&root, &["add", "--", "docs/guide.md"]);
git(&root, &["commit", "-m", "initialise"]);
let worktree = root.join("linked");
git(
&root,
&[
"worktree",
"add",
"--quiet",
worktree.as_str(),
"-b",
"side",
],
);
let resolved = GitLsFiles::new(false)
.resolve_git_dir(&worktree)
.expect("git rev-parse --absolute-git-dir");
assert!(
resolved
.as_path()
.ends_with(Utf8Path::new("worktrees").join("linked")),
"a linked worktree's Git directory is not the main repository's: {resolved}"
);
assert!(
std::fs::symlink_metadata(resolved.join("HEAD")).is_ok(),
"{resolved} must be the directory git itself reports"
);
}
#[test]
fn a_directory_outside_a_repository_has_no_git_directory() {
let directory = tempfile::tempdir().expect("a temporary directory");
let outside = Utf8Path::from_path(directory.path()).expect("a UTF-8 temporary directory");
let error = GitLsFiles::new(false)
.resolve_git_dir(outside)
.expect_err("no repository governs a fresh temporary directory");
let message = error.to_string();
assert!(
message.starts_with("`git rev-parse` failed with exit "),
"unexpected message: {message}"
);
let diagnostic = error.diagnostic();
assert!(
diagnostic.starts_with(&message) && diagnostic.len() > message.len(),
"the diagnostic must relay git's own text beside our own: {diagnostic:?}"
);
assert!(
!diagnostic.contains('\n') && !diagnostic.contains('\r'),
"the relayed diagnostic must be one line: {diagnostic:?}"
);
}
#[test]
fn the_repository_query_reports_an_absent_program_as_such() {
let error = GitLsFiles::with_program("mdtablefix-no-such-program-6f2c", false)
.resolve_git_dir(Utf8Path::new("."))
.expect_err("no such program exists");
assert!(
matches!(error, GitListError::ProgramNotFound { .. }),
"{error}"
);
}
#[rstest]
#[case(
GitListError::ProgramNotFound { program: "git".to_string() },
"program_not_found"
)]
#[case(
GitListError::Spawn {
command: "git ls-files".to_string(),
source: io::Error::new(io::ErrorKind::PermissionDenied, "fixture"),
},
"spawn"
)]
#[case(
GitListError::NoGitDir { command: "git rev-parse".to_string() },
"no_git_dir"
)]
fn a_category_names_the_class_a_host_may_aggregate(
#[case] error: GitListError,
#[case] expected: &str,
) {
assert_eq!(error.category(), expected);
}
#[test_macros::traced_test]
#[test]
fn a_successful_listing_is_traced_with_its_operation_and_outcome() {
let (_directory, root) = repository();
GitLsFiles::new(false)
.list_candidates(&root)
.expect("git ls-files");
assert!(logs_contain(
"git{operation=\"ls_files\" outcome=\"success\""
));
assert!(logs_contain(
"git invocation completed operation=\"ls_files\" outcome=\"success\""
));
assert!(logs_contain("elapsed_seconds="));
}
#[test_macros::traced_test]
#[test]
fn the_repository_query_is_traced_under_its_own_operation() {
let (_directory, root) = repository();
GitLsFiles::new(false)
.resolve_git_dir(&root)
.expect("git rev-parse --absolute-git-dir");
assert!(logs_contain(
"git{operation=\"rev_parse\" outcome=\"success\""
));
assert!(logs_contain(
"git invocation completed operation=\"rev_parse\" outcome=\"success\""
));
}
#[cfg(unix)]
#[test_macros::traced_test]
#[test]
fn a_failed_invocation_is_traced_with_its_bounded_category() {
let error = GitLsFiles::with_program("/bin/false", false)
.list_candidates(Utf8Path::new("."))
.expect_err("`false` always fails");
assert_eq!(error.category(), "nonzero_exit");
assert!(logs_contain("git{operation=\"ls_files\" outcome=\"error\""));
assert!(logs_contain(
"git invocation failed operation=\"ls_files\" outcome=\"error\""
));
assert!(logs_contain("failure=\"nonzero_exit\""));
assert!(logs_contain("elapsed_seconds="));
}