use super::support::{Fixture, Observed, fixture, git, observe, remove, repository, run_direct, run_wrapped, stderr_of, wrapped};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub(super) const MERGE_LINE_MARK: &str = "word-level merge";
pub(super) const ADJACENT: &str = "md1b";
pub(super) const BOTH_APPEND: &str = "md2";
pub(super) fn corpus(name: &str, extension: &str) -> Vec<u8> {
return std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/native/merge/word_merge_corpus").join(format!("{name}.{extension}")))
.expect("a stored corpus file");
}
pub(super) fn tracked(name: &str) -> String {
return format!("{name}.md");
}
#[derive(Clone, Copy, Debug)]
pub(super) enum Stop {
Merge,
Rebase,
CherryPick,
Revert,
}
pub(super) const STOPS: [Stop; 4] = [Stop::Merge, Stop::Rebase, Stop::CherryPick, Stop::Revert];
pub(super) fn prepared(fixture: &Fixture, stop: Stop, names: &[&str]) -> PathBuf {
let repo: PathBuf = repository(fixture, OsStr::new("repo"));
let write = |extension: &str| {
for name in names {
std::fs::write(repo.join(tracked(name)), corpus(name, extension)).expect("an input");
}
};
let commit_all = |message: &str| {
git(fixture, repo.as_path(), &["add", "--all"]);
git(fixture, repo.as_path(), &["commit", "--quiet", format!("--message={message}").as_str()]);
};
write("base");
commit_all("base");
if matches!(stop, Stop::Revert) {
write("ours");
commit_all("the edit that is reverted");
for name in names {
std::fs::write(repo.join(tracked(name)), corpus(name, "word.out")).expect("the later state");
}
commit_all("the insertion after the edit");
return repo;
}
git(fixture, repo.as_path(), &["switch", "--quiet", "--create", "topic"]);
write("theirs");
commit_all("theirs");
git(fixture, repo.as_path(), &["switch", "--quiet", "main"]);
write("ours");
commit_all("ours");
if matches!(stop, Stop::Rebase) {
git(fixture, repo.as_path(), &["switch", "--quiet", "topic"]);
}
return repo;
}
pub(super) fn arguments_of(stop: Stop) -> Vec<&'static str> {
return match stop {
Stop::Merge => vec!["merge", "--no-edit", "topic"],
Stop::Rebase => vec!["rebase", "main"],
Stop::CherryPick => vec!["cherry-pick", "topic"],
Stop::Revert => vec!["revert", "--no-edit", "HEAD~1"],
};
}
pub(super) fn expected_after(stop: Stop, name: &str) -> Vec<u8> {
return match stop {
Stop::Revert => corpus(name, "theirs"),
_ => corpus(name, "word.out"),
};
}
pub(super) fn unmerged(fixture: &Fixture, repo: &Path) -> Vec<String> {
let listed = git(fixture, repo, &["ls-files", "--unmerged", "-z"]);
let mut paths: Vec<String> = listed
.stdout
.split(|byte| { return *byte == 0; })
.filter_map(|entry| { return entry.split(|byte| { return *byte == b'\t'; }).nth(1); })
.map(|path| { return String::from_utf8_lossy(path).into_owned(); })
.collect();
paths.dedup();
return paths;
}
pub(super) fn merge_lines(observed: &Observed) -> Vec<String> {
return stderr_of(observed).lines().filter(|line| { return line.contains(MERGE_LINE_MARK); }).map(String::from).collect();
}
fn journal_entries(receiver: &std::os::unix::net::UnixDatagram) -> Vec<std::collections::BTreeMap<String, String>> {
let mut entries: Vec<std::collections::BTreeMap<String, String>> = Vec::new();
let mut buffer: Vec<u8> = vec![0; 65_536];
receiver.set_nonblocking(true).expect("non-blocking");
while let Ok(length) = receiver.recv(buffer.as_mut_slice()) {
entries.push(
String::from_utf8_lossy(&buffer[..length])
.lines()
.filter_map(|line: &str| { return line.split_once('=').map(|(field, value): (&str, &str)| { return (field.to_string(), value.to_string()); }); })
.collect(),
);
}
return entries;
}
fn run_with_journal(fixture: &Fixture, repo: &Path, arguments: &[&str]) -> (Observed, std::os::unix::net::UnixDatagram) {
use std::os::linux::net::SocketAddrExt;
let name: String = format!("cli-git-test-journal-word-merge-{}-{}", std::process::id(), fixture.root.display().to_string().len());
let address = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes()).expect("an abstract address");
let receiver = std::os::unix::net::UnixDatagram::bind_addr(&address).expect("bind the journal stand-in");
let observed: Observed = observe(
wrapped(fixture).env(git_policy_cli::journald_native::TEST_SOCKET_VARIABLE, format!("@{name}")).current_dir(repo).args(arguments),
b"",
);
return (observed, receiver);
}
#[test]
fn an_option_the_finish_does_not_know_leaves_the_operation_stopped_with_the_file_staged() {
let fixture: Fixture = fixture("word-merge-unknown-option");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let head: Vec<u8> = git(&fixture, repo.as_path(), &["rev-parse", "HEAD"]).stdout;
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["merge", "--no-edit", "--no-comm", "topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(1), "{said}");
assert_eq!(unmerged(&fixture, repo.as_path()), Vec::<String>::new(), "{said}");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == corpus(ADJACENT, "word.out"), "{said}");
assert_eq!(git(&fixture, repo.as_path(), &["rev-parse", "HEAD"]).stdout, head, "no commit is made when an option is not read");
assert!(merge_lines(&observed).iter().any(|line| { return line.contains(tracked(ADJACENT).as_str()); }), "{said}");
remove(&fixture);
}
#[test]
fn the_journal_receives_each_merged_part() {
let fixture: Fixture = fixture("word-merge-journal");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let (observed, receiver) = run_with_journal(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
let entries = journal_entries(&receiver);
let merged: Vec<_> = entries.iter().filter(|entry| { return entry.get("MESSAGE").is_some_and(|message| { return message.contains(MERGE_LINE_MARK); }); }).collect();
assert_eq!(merged.len(), 1, "one entry for the one merged file: {entries:?} {}", stderr_of(&observed));
let entry = merged[0];
assert_eq!(entry.get("SYSLOG_IDENTIFIER").map(String::as_str), Some("cli-git"), "{entry:?}");
assert!(entry.get("MESSAGE").is_some_and(|message| { return message.contains(tracked(ADJACENT).as_str()); }), "{entry:?}");
assert_eq!(entry.get("CLI_GIT_WORD_MERGE_FILE").map(String::as_str), Some(tracked(ADJACENT).as_str()), "{entry:?}");
remove(&fixture);
}
#[test]
fn a_file_that_does_not_resolve_stays_conflicted_beside_one_that_does() {
let fixture: Fixture = fixture("word-merge-beside");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT, BOTH_APPEND]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
let said: String = stderr_of(&observed);
assert_eq!(unmerged(&fixture, repo.as_path()), vec![tracked(BOTH_APPEND)], "{said}");
let marked: String = String::from_utf8(std::fs::read(repo.join(tracked(BOTH_APPEND))).expect("the file")).expect("UTF-8");
assert!(marked.contains("<<<<<<<") && marked.contains(">>>>>>>"), "Git's markers stay: {marked}");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == corpus(ADJACENT, "word.out"), "{said}");
assert!(!merge_lines(&observed).iter().any(|line| { return line.contains(tracked(BOTH_APPEND).as_str()); }), "an unresolved file is not listed as merged: {said}");
remove(&fixture);
}
#[test]
fn a_file_with_one_unresolvable_part_stays_fully_conflicted() {
let fixture: Fixture = fixture("word-merge-partial");
let repo: PathBuf = repository(&fixture, OsStr::new("repo"));
let write = |text: &str| { std::fs::write(repo.join("partial.md"), text).expect("the file"); };
write("alpha one\nkeep\nkeep again\nbeta two\n");
git(&fixture, repo.as_path(), &["add", "--all"]);
git(&fixture, repo.as_path(), &["commit", "--quiet", "--message=base"]);
git(&fixture, repo.as_path(), &["switch", "--quiet", "--create", "topic"]);
write("ALPHA one\nkeep\nkeep again\nbeta zwei\n");
git(&fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=theirs"]);
git(&fixture, repo.as_path(), &["switch", "--quiet", "main"]);
write("alpha uno\nkeep\nkeep again\nbeta 2\n");
git(&fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=ours"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
let said: String = stderr_of(&observed);
let marked: String = String::from_utf8(std::fs::read(repo.join("partial.md")).expect("the file")).expect("UTF-8");
assert!(marked.contains("<<<<<<<"), "{marked}");
assert_eq!(unmerged(&fixture, repo.as_path()), vec![String::from("partial.md")], "{said}");
assert!(merge_lines(&observed).is_empty(), "{said}");
remove(&fixture);
}
#[test]
fn a_hand_edited_conflicted_file_is_left_alone() {
let fixture: Fixture = fixture("word-merge-hand-edit");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let started: Observed = run_direct(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
assert_eq!(started.code, Some(1), "{}", stderr_of(&started));
let hand_edited: &[u8] = b"my own half-resolved text\n<<<<<<< ours\nstill to decide\n=======\n>>>>>>> topic\n";
std::fs::write(repo.join(tracked(ADJACENT)), hand_edited).expect("hand edit");
let again: Observed = run_wrapped(&fixture, repo.as_path(), &["merge", "--continue"]);
assert_ne!(again.code, Some(0), "{}", stderr_of(&again));
assert_eq!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file"), hand_edited, "{}", stderr_of(&again));
assert_eq!(unmerged(&fixture, repo.as_path()), vec![tracked(ADJACENT)]);
assert!(merge_lines(&again).is_empty(), "{}", stderr_of(&again));
remove(&fixture);
}
#[test]
fn an_isolated_merge_with_an_adjacent_edit_is_completed_and_listed() {
let fixture: Fixture = fixture("word-merge-isolated");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
std::fs::write(repo.join("staged.txt"), b"another session's work\n").expect("staged file");
git(&fixture, repo.as_path(), &["add", "--", "staged.txt"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
let parents = git(&fixture, repo.as_path(), &["rev-list", "--parents", "--max-count=1", "HEAD"]);
assert_eq!(String::from_utf8_lossy(&parents.stdout).split_whitespace().count(), 3, "a merge commit with two parents");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == corpus(ADJACENT, "word.out"), "{said}");
assert!(merge_lines(&observed).iter().any(|line| { return line.contains(tracked(ADJACENT).as_str()); }), "{said}");
let status = git(&fixture, repo.as_path(), &["status", "--porcelain=v1"]);
assert!(String::from_utf8_lossy(&status.stdout).contains("A staged.txt"), "the staged work stays staged");
remove(&fixture);
}
#[test]
fn an_isolated_merge_with_a_both_append_conflict_is_still_refused() {
let fixture: Fixture = fixture("word-merge-isolated-refused");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[BOTH_APPEND]);
std::fs::write(repo.join("staged.txt"), b"another session's work\n").expect("staged file");
git(&fixture, repo.as_path(), &["add", "--", "staged.txt"]);
let head: Vec<u8> = git(&fixture, repo.as_path(), &["rev-parse", "HEAD"]).stdout;
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(2), "{said}");
assert!(said.contains(tracked(BOTH_APPEND).as_str()), "{said}");
assert_eq!(git(&fixture, repo.as_path(), &["rev-parse", "HEAD"]).stdout, head);
remove(&fixture);
}
#[test]
fn an_isolated_merge_with_one_unresolvable_file_among_resolvable_ones_is_refused_whole() {
let fixture: Fixture = fixture("word-merge-isolated-partial");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT, BOTH_APPEND]);
std::fs::write(repo.join("staged.txt"), b"another session's work\n").expect("staged file");
git(&fixture, repo.as_path(), &["add", "--", "staged.txt"]);
let head: Vec<u8> = git(&fixture, repo.as_path(), &["rev-parse", "HEAD"]).stdout;
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(Stop::Merge).as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(2), "{said}");
assert!(said.contains(tracked(BOTH_APPEND).as_str()) && said.contains(tracked(ADJACENT).as_str()), "both conflicted paths are named: {said}");
assert_eq!(git(&fixture, repo.as_path(), &["rev-parse", "HEAD"]).stdout, head);
assert!(merge_lines(&observed).is_empty(), "{said}");
remove(&fixture);
}