use super::commit::{git_text, remove_settled};
use super::service_commit::{Running, start, through_service};
use super::support::{Fixture, direct, fixture, git};
use git_policy_cli::service_journal::DONE_EXTENSION;
use std::path::{Path, PathBuf};
use std::process::Output;
pub(super) const TRACKED: [&str; 4] = ["a.txt", "-dash.txt", "--quiet", "-o"];
fn try_git(fixture: &Fixture, repo: &Path, arguments: &[&str]) -> Output {
return direct(fixture).current_dir(repo).args(arguments).output().expect("real Git runs");
}
fn write_all(fixture: &Fixture, repo: &Path, text: &str) {
for name in TRACKED {
std::fs::write(repo.join(name), text).expect("write tracked file");
}
let mut arguments: Vec<&str> = vec!["add", "--"];
arguments.extend(TRACKED);
git(fixture, repo, &arguments);
}
#[derive(Clone, Copy, Debug)]
pub(super) enum State {
Revert,
CherryPick,
ConflictedRevert,
ConflictedCherryPick,
Merge,
}
impl State {
fn label(self) -> &'static str {
return match self {
State::Revert => "revert",
State::CherryPick => "cherry-pick",
State::ConflictedRevert => "conflicted-revert",
State::ConflictedCherryPick => "conflicted-cherry-pick",
State::Merge => "merge",
};
}
fn tag(self) -> &'static str {
return match self {
State::Revert => "rv",
State::CherryPick => "cp",
State::ConflictedRevert => "crv",
State::ConflictedCherryPick => "ccp",
State::Merge => "mg",
};
}
fn git_takes_paths(self) -> bool {
return matches!(self, State::Revert | State::CherryPick | State::ConflictedRevert);
}
pub(super) fn prepare(self, fixture: &Fixture, name: &str) -> PathBuf {
let repo: PathBuf = super::support::repository(fixture, std::ffi::OsStr::new(name));
git(fixture, &repo, &["config", "maintenance.autoDetach", "false"]);
write_all(fixture, &repo, "base\n");
std::fs::write(repo.join("b.txt"), "base\n").expect("b.txt");
git(fixture, &repo, &["add", "--", "b.txt"]);
git(fixture, &repo, &["commit", "--quiet", "--message=base"]);
git(fixture, &repo, &["switch", "--quiet", "--create", "other"]);
write_all(fixture, &repo, "changed\n");
git(fixture, &repo, &["commit", "--quiet", "--message=change"]);
git(fixture, &repo, &["switch", "--quiet", "main"]);
match self {
State::Revert => {
git(fixture, &repo, &["merge", "--quiet", "--ff-only", "other"]);
git(fixture, &repo, &["revert", "--no-commit", "HEAD"]);
}
State::CherryPick => {
git(fixture, &repo, &["cherry-pick", "--no-commit", "other"]);
}
State::ConflictedRevert => {
git(fixture, &repo, &["merge", "--quiet", "--ff-only", "other"]);
std::fs::write(repo.join("a.txt"), "later\n").expect("a.txt");
git(fixture, &repo, &["commit", "--quiet", "--message=later", "--", "a.txt"]);
let reverted: Output = try_git(fixture, &repo, &["revert", "--no-edit", "HEAD~1"]);
assert!(!reverted.status.success(), "the revert was meant to conflict");
std::fs::write(repo.join("a.txt"), "resolved\n").expect("a.txt");
git(fixture, &repo, &["add", "--", "a.txt"]);
}
State::ConflictedCherryPick => {
std::fs::write(repo.join("a.txt"), "main\n").expect("a.txt");
git(fixture, &repo, &["commit", "--quiet", "--message=main", "--", "a.txt"]);
let picked: Output = try_git(fixture, &repo, &["cherry-pick", "other"]);
assert!(!picked.status.success(), "the cherry-pick was meant to conflict");
std::fs::write(repo.join("a.txt"), "resolved\n").expect("a.txt");
git(fixture, &repo, &["add", "--", "a.txt"]);
}
State::Merge => {
std::fs::write(repo.join("b.txt"), "main\n").expect("b.txt");
git(fixture, &repo, &["commit", "--quiet", "--message=main", "--", "b.txt"]);
git(fixture, &repo, &["merge", "--no-commit", "--no-ff", "other"]);
}
}
return repo;
}
}
#[derive(Debug, Eq, PartialEq)]
struct Outcome {
code: Option<i32>,
stdout: String,
stderr: String,
tree: String,
parents: usize,
message: String,
status: String,
markers: String,
}
fn masked(text: &str) -> String {
let mut lines: Vec<String> = Vec::new();
for line in text.lines() {
if line.starts_with(" Date:") {
lines.push(String::from(" Date: MASKED"));
continue;
}
let words: Vec<String> = line
.split(' ')
.map(|word| {
let name: &str = word.trim_end_matches(']');
let named: bool = word.ends_with(']') && name.len() >= 7 && name.chars().all(|letter| { return letter.is_ascii_hexdigit(); });
return if named { String::from("NAME]") } else { word.to_string() };
})
.collect();
lines.push(words.join(" "));
}
return lines.join("\n");
}
fn outcome(fixture: &Fixture, repo: &Path, run: &Output) -> Outcome {
let markers: Vec<String> = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG", "COMMIT_EDITMSG"]
.iter()
.filter(|name| { return repo.join(".git").join(name).exists(); })
.map(|name| { return (*name).to_string(); })
.collect();
return Outcome {
code: run.status.code(),
stdout: masked(String::from_utf8_lossy(&run.stdout).as_ref()),
stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
tree: git_text(fixture, repo, &["rev-parse", "HEAD^{tree}"]),
parents: git_text(fixture, repo, &["rev-list", "--parents", "-1", "HEAD"]).split_whitespace().count() - 1,
message: git_text(fixture, repo, &["log", "-1", "--format=%an <%ae>|%B"]),
status: git_text(fixture, repo, &["status", "--short"]),
markers: markers.join(","),
};
}
fn landed(fixture: &Fixture) -> usize {
return std::fs::read_dir(fixture.root.join("journal")).map_or(0, |entries| {
return entries
.flatten()
.filter(|entry| { return entry.path().extension().is_some_and(|extension| { return extension == DONE_EXTENSION; }); })
.count();
});
}
fn command_lines(fixture: &Fixture) -> Vec<(&'static str, Vec<String>)> {
let list_of = |name: &str, text: &str| -> String {
let file: PathBuf = fixture.root.join(name);
std::fs::write(&file, text).expect("pathspec file");
return format!("--pathspec-from-file={}", file.display());
};
let named = |tail: &[&str]| -> Vec<String> {
let mut line: Vec<String> = vec![String::from("commit"), String::from("--message=undo")];
line.extend(tail.iter().map(|word| { return (*word).to_string(); }));
return line;
};
return vec![
("plain pathspec", named(&["--", "a.txt"])),
("pathspec file", named(&[list_of("paths-plain", "a.txt\n").as_str()])),
("dash-led pathspec", named(&["--", "-dash.txt"])),
("file named like an option", named(&["--", "--quiet"])),
("file named like a commit option", named(&["--", "-o"])),
("dash-led name in a pathspec file", named(&[list_of("paths-dash", "-dash.txt\n").as_str()])),
("typed quiet", vec![String::from("commit"), String::from("--quiet"), String::from("--message=undo"), String::from("--"), String::from("a.txt")]),
("message that is a separator", vec![String::from("commit"), String::from("-m"), String::from("--"), String::from("--"), String::from("a.txt")]),
];
}
fn agrees_with_git(state: State) {
let label: String = format!("sequencer-{}", state.label());
let fixture: Fixture = fixture(label.as_str());
let lines: Vec<(&'static str, Vec<String>)> = command_lines(&fixture);
let pairs: Vec<(PathBuf, PathBuf)> = (0..lines.len())
.map(|index| {
return (state.prepare(&fixture, format!("wrapped-{index}").as_str()), state.prepare(&fixture, format!("native-{index}").as_str()));
})
.collect();
let name: String = format!("sq-{}-{}", state.tag(), std::process::id());
let mut service: Running = start(&fixture, name.as_str());
let mut differences: Vec<String> = Vec::new();
for ((what, line), (wrapped_repo, native_repo)) in lines.iter().zip(pairs.iter()) {
let words: Vec<&str> = line.iter().map(String::as_str).collect();
let before: usize = landed(&fixture);
let served: Output = through_service(&fixture, wrapped_repo, name.as_str(), &words);
let native: Output = direct(&fixture).current_dir(native_repo).args(&words).output().expect("real Git runs");
assert_eq!(native.status.success(), state.git_takes_paths(), "{} / {what}: real Git: {}", state.label(), String::from_utf8_lossy(&native.stderr));
let wrapped_outcome: Outcome = outcome(&fixture, wrapped_repo, &served);
let native_outcome: Outcome = outcome(&fixture, native_repo, &native);
if wrapped_outcome != native_outcome {
differences.push(format!("{} / {what}: the wrapper and real Git differ\n wrapper: {wrapped_outcome:?}\n real Git: {native_outcome:?}", state.label()));
}
let landed_now: usize = landed(&fixture) - before;
if landed_now != usize::from(native.status.success()) {
differences.push(format!("{} / {what}: a commit lands through the service exactly when Git takes it, landed {landed_now}", state.label()));
}
}
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
assert!(differences.is_empty(), "{}", differences.join("\n"));
}
#[test]
fn a_pathspec_commit_during_a_revert_is_what_git_makes() {
agrees_with_git(State::Revert);
}
#[test]
fn a_pathspec_commit_during_a_cherry_pick_is_what_git_makes() {
agrees_with_git(State::CherryPick);
}
#[test]
fn a_pathspec_commit_after_a_conflicted_revert_is_what_git_makes() {
agrees_with_git(State::ConflictedRevert);
}
#[test]
fn a_pathspec_commit_after_a_conflicted_cherry_pick_is_refused_as_git_refuses_it() {
agrees_with_git(State::ConflictedCherryPick);
}
#[test]
fn a_pathspec_commit_during_a_merge_is_refused_as_git_refuses_it() {
agrees_with_git(State::Merge);
}