use super::*;
use crate::test_support::{ActionSink, SharedSink, executable, fixture, git_context, git_text, remove, repository, while_test_lives};
use crate::child_class::ChildClass;
use crate::wait_progress::Cadence;
#[test]
fn requests_start_empty() {
let request: GitRequest = GitRequest::new(Path::new("/repo"), &["status", "--short"]);
assert_eq!(request.cwd, PathBuf::from("/repo"));
assert_eq!(
request.arguments,
vec![OsString::from("status"), OsString::from("--short")]
);
assert!(!request.without_prefix);
assert_eq!(request.index, None);
assert_eq!(request.object_directory, None);
assert!(request.environment.is_empty());
assert!(request.unset.is_empty());
assert_eq!(request.input, None);
assert_eq!(
command_text(&[OsString::from("update-ref"), OsString::from("-d")]),
"git update-ref -d"
);
assert_eq!(command_text(&[]), "git");
}
#[test]
fn runs_capture_output_and_feed_input() {
let root: PathBuf = fixture("transaction-git-run");
let repo: PathBuf = repository(root.as_path(), "repo");
let context: GitContext = git_context();
let mut hashing: GitRequest =
GitRequest::new(repo.as_path(), &["hash-object", "-w", "--stdin"]);
hashing.input = Some(b"content\n".to_vec());
let hashed: GitOutput = run_git_checked(&context, &hashing).expect("hash-object");
assert!(hashed.succeeded());
assert_eq!(
String::from_utf8_lossy(hashed.stdout.as_slice()).trim(),
"d95f3ad14dee633a758d2e331151e950dd13e4ed"
);
let mut large: GitRequest = GitRequest::new(repo.as_path(), &["hash-object", "--stdin"]);
large.input = Some(vec![b'x'; 4 * 1024 * 1024]);
assert!(run_git_checked(&context, &large).is_ok());
let missing: GitRequest = GitRequest::new(
repo.as_path(),
&["rev-parse", "--verify", "--quiet", "refs/heads/none"],
);
let output: GitOutput = run_git(&context, &missing).expect("rev-parse runs");
assert_eq!(output.code, Some(1));
assert!(!output.succeeded());
let failure: GitFailure = run_git_checked(
&context,
&GitRequest::new(
repo.as_path(),
&["cat-file", "-t", "0000000000000000000000000000000000000001"],
),
)
.expect_err("missing object");
assert!(
failure
.to_string()
.starts_with("git cat-file -t 0000000000000000000000000000000000000001 failed: fatal:"),
"{failure}"
);
let unstartable: GitContext = GitContext {
real_git: root.join("no-git"),
overlay: Vec::new(),
global_prefix: Vec::new(),
common_dir: None,
};
let start: GitFailure =
run_git_checked(&unstartable, &GitRequest::new(repo.as_path(), &["status"]))
.expect_err("no Git");
assert!(
start.0.starts_with("git status could not start: "),
"{start}"
);
remove(root.as_path());
}
#[test]
fn environment_and_prefix_reach_git() {
let root: PathBuf = fixture("transaction-git-environment");
let repo: PathBuf = repository(root.as_path(), "repo");
let mut context: GitContext = git_context();
context.global_prefix = vec![OsString::from("-c"), OsString::from("cli.probe=prefixed")];
let prefixed: GitOutput = run_git_checked(
&context,
&GitRequest::new(repo.as_path(), &["config", "cli.probe"]),
)
.expect("prefix");
assert_eq!(
String::from_utf8_lossy(prefixed.stdout.as_slice()),
"prefixed\n"
);
let mut bare: GitRequest = GitRequest::new(repo.as_path(), &["config", "cli.probe"]);
bare.without_prefix = true;
assert_eq!(run_git(&context, &bare).expect("unprefixed").code, Some(1));
context.overlay.push((
OsString::from("GIT_CONFIG_PARAMETERS"),
OsString::from("'cli.probe'='overlay'"),
));
let mut removed: GitRequest = GitRequest::new(repo.as_path(), &["config", "cli.probe"]);
removed.without_prefix = true;
removed.unset = vec![OsString::from("GIT_CONFIG_PARAMETERS")];
assert_eq!(run_git(&context, &removed).expect("removed").code, Some(1));
removed.environment = vec![(
OsString::from("GIT_CONFIG_PARAMETERS"),
OsString::from("'cli.probe'='request'"),
)];
let added: GitOutput = run_git_checked(&context, &removed).expect("added");
assert_eq!(
String::from_utf8_lossy(added.stdout.as_slice()),
"request\n"
);
let private_index: PathBuf = root.join("private.index");
let store: PathBuf = root.join("objects");
std::fs::create_dir(&store).expect("store");
let mut update: GitRequest = GitRequest::new(
repo.as_path(),
&[
"update-index",
"--add",
"--cacheinfo",
"100644,d95f3ad14dee633a758d2e331151e950dd13e4ed,new.txt",
],
);
update.without_prefix = true;
update.index = Some(private_index.clone());
run_git_checked(&context, &update).expect("private index write");
assert!(private_index.exists());
assert_eq!(git_text(repo.as_path(), &["ls-files"]), "");
let mut write: GitRequest = GitRequest::new(repo.as_path(), &["hash-object", "-w", "--stdin"]);
write.without_prefix = true;
write.object_directory = Some(store.clone());
write.input = Some(b"private\n".to_vec());
let written: GitOutput = run_git_checked(&context, &write).expect("private object");
let oid: String = String::from_utf8_lossy(written.stdout.as_slice())
.trim()
.to_string();
assert!(store.join(&oid[..2]).join(&oid[2..]).exists());
let unknown: GitOutput = run_git(
&context,
&GitRequest::new(repo.as_path(), &["cat-file", "-e", oid.as_str()]),
)
.expect("cat-file runs");
assert!(!unknown.succeeded());
assert_eq!(unknown.error_text(), "");
remove(root.as_path());
}
#[test]
fn error_text_is_trimmed() {
let output: GitOutput = GitOutput {
code: Some(128),
stdout: Vec::new(),
stderr: b" fatal: \xff broken \n".to_vec(),
};
assert_eq!(output.error_text(), "fatal: \u{fffd} broken");
assert!(
!GitOutput {
code: None,
stdout: Vec::new(),
stderr: Vec::new()
}
.succeeded()
);
}
#[cfg(unix)]
#[test]
fn input_files_leave_nothing_behind() {
use std::io::Read;
use std::os::unix::fs::MetadataExt;
let root: PathBuf = fixture("transaction-git-input");
let mut file: std::fs::File = input_file(b"bytes", Some(root.as_path())).expect("input file");
let metadata: std::fs::Metadata = file.metadata().expect("metadata");
assert_eq!(metadata.nlink(), 0);
assert_eq!(metadata.mode() & 0o777, 0o600);
let mut read: Vec<u8> = Vec::new();
file.read_to_end(&mut read).expect("read");
assert_eq!(read, b"bytes");
let left: Vec<std::ffi::OsString> = std::fs::read_dir(root.join("cli-git-scratch"))
.expect("the repository scratch directory exists")
.map(|entry| { return entry.expect("entry").file_name(); })
.collect();
assert!(left.is_empty(), "nothing is left in the scratch directory: {left:?}");
remove(root.as_path());
}
#[test]
fn input_without_a_repository_is_refused_naming_the_defect() {
let error: std::io::Error = input_file(b"bytes", None).expect_err("no repository");
assert!(error.to_string().contains("no repository scratch directory"), "{error}");
}
#[test]
fn input_in_a_missing_common_directory_fails_without_a_fallback() {
let root: PathBuf = fixture("transaction-git-input-missing");
let missing: PathBuf = root.join("no-such-common-directory");
assert!(input_file(b"bytes", Some(missing.as_path())).is_err());
assert!(!missing.exists());
remove(root.as_path());
}
#[cfg(target_os = "linux")]
const FAKE_GIT_STDIN_PATH: &[u8] = b"#!/bin/sh\nreadlink /proc/$$/fd/0\n";
#[cfg(target_os = "linux")]
#[test]
fn a_command_reads_its_input_from_the_repository_scratch_directory() {
let root: PathBuf = fixture("transaction-git-input-location");
let program: PathBuf = root.join("fake-git");
executable(program.as_path(), FAKE_GIT_STDIN_PATH);
let common: PathBuf = root.join("common");
std::fs::create_dir(&common).expect("common directory");
let context: GitContext = GitContext {
real_git: program,
overlay: Vec::new(),
global_prefix: Vec::new(),
common_dir: Some(common.clone()),
};
let mut request: GitRequest = GitRequest::new(root.as_path(), &["hash-object", "--stdin"]);
request.input = Some(b"12345".to_vec());
let output: GitOutput = run_git(&context, &request).expect("the fake runs");
let named: String = String::from_utf8_lossy(output.stdout.as_slice()).trim().to_owned();
let expected: String = format!("{}/cli-git-scratch/cli-git-input-", common.display());
assert!(named.starts_with(expected.as_str()), "{named} should start with {expected}");
assert_eq!(std::fs::read_dir(common.join("cli-git-scratch")).expect("scratch").count(), 0);
remove(root.as_path());
}
const FAKE_GIT: &[u8] = b"#!/bin/sh\n\
count=$(cat \"$FAKE_COUNT\" 2>/dev/null || echo 0)\n\
count=$((count + 1))\n\
echo $count > \"$FAKE_COUNT\"\n\
bytes=$(wc -c | tr -d ' ')\n\
if [ \"$count\" -le \"$FAKE_REFUSALS\" ]; then\n\
echo 'fatal: unable to create thread: Resource temporarily unavailable' >&2\n\
exit 128\n\
fi\n\
if [ -n \"$FAKE_OTHER_FAILURE\" ]; then\n\
echo 'fatal: bad object' >&2\n\
exit 128\n\
fi\n\
echo \"read $bytes\"\n";
fn fake_run(root: &Path, arguments: &[&str], refusals: u32, other_failure: bool) -> (GitContext, GitRequest, PathBuf) {
let program: PathBuf = root.join("fake-git");
executable(program.as_path(), FAKE_GIT);
let count: PathBuf = root.join("count");
let context: GitContext = GitContext {
real_git: program,
overlay: Vec::new(),
global_prefix: Vec::new(),
common_dir: Some(root.to_path_buf()),
};
let mut request: GitRequest = GitRequest::new(root, arguments);
request.input = Some(b"12345".to_vec());
request.environment = vec![
(OsString::from("FAKE_COUNT"), count.clone().into_os_string()),
(OsString::from("FAKE_REFUSALS"), OsString::from(refusals.to_string())),
(OsString::from("FAKE_OTHER_FAILURE"), OsString::from(if other_failure { "1" } else { "" })),
];
return (context, request, count);
}
fn runs_of(count: &Path) -> u32 {
return std::fs::read_to_string(count).map_or(0, |text| { return text.trim().parse().unwrap_or(0); });
}
fn no_pause(_wait: Duration) {}
fn slow_git(root: &Path) -> String {
return format!("#!/bin/sh\nwhile [ ! -e \"$FAKE_RELEASE\" ] && {}; do sleep 0.02; done\necho slow\n", while_test_lives(root));
}
fn slow_run(root: &Path, release: &Path) -> (GitContext, GitRequest) {
let program: PathBuf = root.join("slow-git");
executable(program.as_path(), slow_git(root).as_bytes());
let context: GitContext =
GitContext { real_git: program, overlay: Vec::new(), global_prefix: Vec::new(), common_dir: None };
let mut request: GitRequest = GitRequest::new(root, &["add", "--", "a.md"]);
request.environment = vec![(OsString::from("FAKE_RELEASE"), release.as_os_str().to_os_string())];
return (context, request);
}
const SCRIPT_GONE_PATIENCE: Duration = Duration::from_secs(20);
const SCRIPT_GONE_LOOK: Duration = Duration::from_millis(50);
#[test]
fn a_fake_git_waiting_for_a_release_ends_when_its_test_is_gone() {
let (sender, receiver) = std::sync::mpsc::channel::<u32>();
let handle = std::thread::spawn(move || {
let root: PathBuf = fixture("transaction-git-script-dies");
let release: PathBuf = root.join("release");
let (context, request) = slow_run(root.as_path(), release.as_path());
let mut command: std::process::Command = std::process::Command::new(&context.real_git);
command.envs(request.environment.iter().map(|(name, value)| { return (name, value); })).current_dir(&root);
let mut child: std::process::Child = command.spawn().expect("the fake Git starts");
let waiting = |number: u32| -> bool {
return std::fs::read_to_string(format!("/proc/{number}/stat")).is_ok_and(|text| { return text.contains(") S "); });
};
let started: std::time::Instant = std::time::Instant::now();
while !waiting(child.id()) {
assert!(started.elapsed() < SCRIPT_GONE_PATIENCE, "the fake Git never reached its wait loop");
std::thread::sleep(SCRIPT_GONE_LOOK);
}
sender.send(child.id()).expect("send the pid");
let _still_running = child.try_wait();
panic!("the test fails before it releases the fake Git");
});
assert!(handle.join().is_err(), "the thread panics on purpose");
let pid: u32 = receiver.recv().expect("the pid");
let runs = |number: u32| { return std::fs::read_to_string(format!("/proc/{number}/stat")).is_ok_and(|text| { return !text.contains(") Z "); }); };
let began: std::time::Instant = std::time::Instant::now();
while runs(pid) && began.elapsed() < SCRIPT_GONE_PATIENCE {
std::thread::sleep(SCRIPT_GONE_LOOK);
}
let left: bool = runs(pid);
if left {
unsafe { libc::kill(libc::pid_t::try_from(pid).expect("pid"), libc::SIGKILL) };
}
assert!(!left, "the fake Git of process {pid} outlived the test that started it");
}
fn quick_terms(limit: Duration, notices: Box<dyn std::io::Write + Send>) -> WatchTerms {
return WatchTerms {
limit,
cadence: Cadence { grace: Duration::from_millis(20), interval: Duration::from_millis(20) },
notices,
};
}
#[test]
fn the_terms_of_a_request_follow_its_subcommand() {
let query: GitRequest = GitRequest::new(Path::new("."), &["rev-parse", "HEAD"]);
let bulk: GitRequest = GitRequest::new(Path::new("."), &["pack-objects", "--stdout"]);
assert_eq!(terms_for_request(&query).limit, ChildClass::Plumbing.limit());
assert_eq!(terms_for_request(&bulk).limit, ChildClass::Object.limit());
}
#[test]
fn a_git_step_that_runs_long_is_announced_by_name() {
let root: PathBuf = fixture("transaction-git-slow");
let release: PathBuf = root.join("release");
let (context, request) = slow_run(root.as_path(), release.as_path());
let sink: SharedSink = SharedSink::default();
let output: GitOutput = run_git_once_with(&context, &request, &|| {
let release_file: PathBuf = release.clone();
let counted: ActionSink = ActionSink::new(&sink, 3, move || {
std::fs::write(&release_file, b"").expect("release the fake Git");
});
return quick_terms(Duration::from_secs(60), Box::new(counted));
})
.expect("finished");
assert_eq!(String::from_utf8_lossy(output.stdout.as_slice()), "slow\n");
let text: String = sink.text();
assert!(text.lines().count() >= 3, "{text}");
assert!(
text.starts_with(format!("cli-git: still waiting for git add -- a.md, {} (process ", class_of(request.arguments.as_slice()).label()).as_str()),
"{text}"
);
remove(root.as_path());
}
#[test]
fn a_git_step_past_its_bound_is_stopped_and_reported_as_such() {
let root: PathBuf = fixture("transaction-git-stuck");
let never: PathBuf = root.join("never-made");
let (context, request) = slow_run(root.as_path(), never.as_path());
let sink: SharedSink = SharedSink::default();
let failure: std::io::Error = run_git_once_with(&context, &request, &|| {
return quick_terms(Duration::from_millis(400), Box::new(sink.clone()));
})
.expect_err("stopped at the bound");
assert_eq!(failure.kind(), std::io::ErrorKind::TimedOut);
assert!(
failure.to_string().starts_with(format!("git add -- a.md, {} did not finish within 0 seconds and was stopped", class_of(request.arguments.as_slice()).label()).as_str()),
"{failure}"
);
assert!(sink.text().lines().count() >= 1, "{}", sink.text());
remove(root.as_path());
}
#[test]
fn the_checked_run_reports_a_bound_without_saying_the_step_failed_to_start() {
let arguments: Vec<OsString> = vec![OsString::from("add")];
let timed_out = checked_from(
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "git add did not finish")),
arguments.as_slice(),
)
.expect_err("a bound is a failure");
assert_eq!(timed_out.0, "git add did not finish");
let other = checked_from(Err(std::io::Error::other("no such file")), arguments.as_slice())
.expect_err("a start failure is a failure");
assert_eq!(other.0, "git add could not start: no such file");
}
#[test]
fn a_rerunnable_command_refused_inside_git_is_run_again_with_its_input() {
let root: PathBuf = fixture("transaction-git-rerun");
let (context, request, count) = fake_run(root.as_path(), &["rev-parse", "HEAD"], 2, false);
let output: GitOutput =
run_git_pausing(&context, &request, START_RETRY_BUDGET, &mut no_pause).expect("the third run works");
assert_eq!(output.code, Some(0));
assert_eq!(String::from_utf8_lossy(output.stdout.as_slice()), "read 5\n", "the input file was fresh");
assert_eq!(runs_of(count.as_path()), 3);
remove(root.as_path());
}
#[test]
fn a_writer_that_may_not_be_rerun_runs_once() {
let root: PathBuf = fixture("transaction-git-once");
let (context, request, count) = fake_run(root.as_path(), &["update-ref", "refs/heads/main", "abc"], 5, false);
let output: GitOutput =
run_git_pausing(&context, &request, START_RETRY_BUDGET, &mut no_pause).expect("the run is reported");
assert_eq!(output.code, Some(128));
assert!(output.error_text().contains("Resource temporarily unavailable"));
assert_eq!(runs_of(count.as_path()), 1);
remove(root.as_path());
}
#[test]
fn another_failure_is_not_run_again() {
let root: PathBuf = fixture("transaction-git-other");
let (context, request, count) = fake_run(root.as_path(), &["rev-parse", "HEAD"], 0, true);
let output: GitOutput =
run_git_pausing(&context, &request, START_RETRY_BUDGET, &mut no_pause).expect("the run is reported");
assert_eq!(output.error_text(), "fatal: bad object");
assert_eq!(runs_of(count.as_path()), 1);
remove(root.as_path());
}
#[test]
fn a_refusal_past_the_budget_returns_gits_last_output() {
let root: PathBuf = fixture("transaction-git-budget");
let (context, request, count) = fake_run(root.as_path(), &["rev-parse", "HEAD"], 1000, false);
let output: GitOutput =
run_git_pausing(&context, &request, START_RETRY_BUDGET, &mut no_pause).expect("Git's last output");
assert_eq!(output.code, Some(128));
assert!(output.error_text().contains("unable to create thread"));
assert_eq!(runs_of(count.as_path()), 15, "the same bound as a refused start");
remove(root.as_path());
}
#[test]
fn only_harmless_commands_may_be_rerun() {
let may = |arguments: &[&str], private_index: bool| -> bool {
let mut request: GitRequest = GitRequest::new(Path::new("/repo"), arguments);
if private_index {
request.index = Some(PathBuf::from("/private.index"));
}
return may_be_rerun(&request);
};
assert!(may(&["rev-parse", "HEAD"], false));
assert!(may(&["-c", "core.hooksPath=/x", "pack-objects", "--revs"], false));
assert!(may(&["--git-dir=/shadow", "-C", "/dir", "index-pack", "--keep=m"], false));
assert!(may(&["config", "--get", "a.b"], false));
assert!(may(&["config", "--file", "config", "--null", "--list"], false));
assert!(may(&["remote", "get-url", "origin"], false));
assert!(may(&["add", "--all", "--", "a.txt"], true));
assert!(may(&["reset", "--quiet", "abc", "--", "a.txt"], true));
assert!(!may(&["add", "--all", "--", "a.txt"], false), "the real index is not rerun");
assert!(!may(&["config", "a.b", "c"], false), "a config write is not a read");
assert!(!may(&["remote", "add", "origin", "url"], false));
assert!(!may(&["update-ref", "refs/heads/main", "abc"], false));
assert!(!may(&["commit", "--message=x"], false));
assert!(!may(&["push", "origin"], false));
assert!(!may(&[], false));
assert!(!may(&["-c", "a=b"], false));
}
#[test]
fn a_start_failure_keeps_its_kind_through_the_rerun_loop() {
let root: PathBuf = fixture("transaction-git-missing");
let context: GitContext =
GitContext { real_git: root.join("no-such-git"), overlay: Vec::new(), global_prefix: Vec::new(), common_dir: None };
let request: GitRequest = GitRequest::new(root.as_path(), &["rev-parse", "HEAD"]);
assert!(may_be_rerun(&request), "the command is one the loop may run again");
let failure: std::io::Error =
run_git_pausing(&context, &request, START_RETRY_BUDGET, &mut no_pause).expect_err("Git cannot start");
assert_eq!(failure.kind(), std::io::ErrorKind::NotFound, "{failure}");
remove(root.as_path());
}
#[test]
fn a_refused_run_followed_by_a_start_failure_reports_the_start_failure() {
let root: PathBuf = fixture("transaction-git-refused-then-gone");
let program: PathBuf = root.join("vanishing-git");
executable(
program.as_path(),
b"#!/bin/sh\necho 'fatal: unable to create thread: Resource temporarily unavailable' >&2\nrm -- \"$0\"\nexit 128\n",
);
let context: GitContext = GitContext { real_git: program, overlay: Vec::new(), global_prefix: Vec::new(), common_dir: None };
let request: GitRequest = GitRequest::new(root.as_path(), &["rev-parse", "HEAD"]);
let failure: std::io::Error = run_git_pausing(&context, &request, START_RETRY_BUDGET, &mut no_pause)
.expect_err("the second start finds no program");
assert_eq!(failure.kind(), std::io::ErrorKind::NotFound, "{failure}");
remove(root.as_path());
}
#[test]
fn only_a_refusal_by_the_system_counts() {
let failure = |text: &str, code: Option<i32>| -> GitOutput {
return GitOutput { code, stdout: Vec::new(), stderr: text.as_bytes().to_vec() };
};
assert!(refused_by_the_system(&failure("fatal: unable to create thread: Resource temporarily unavailable", Some(128))));
assert!(refused_by_the_system(&failure("fatal: cannot fork() for pack-objects: Resource temporarily unavailable", None)));
assert!(!refused_by_the_system(&failure("Resource temporarily unavailable", Some(0))), "a success is not a refusal");
assert!(!refused_by_the_system(&failure("fatal: bad object", Some(128))));
}