use super::support::{Fixture, Observed, fixture, git, remove, repository, run_direct, run_wrapped, stderr_of};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
const HAND_EDITED: &[u8] = b"<<<<<<< ours\nmine \xff\n=======\ntheirs\n>>>>>>> topic\nand my own line\n";
#[derive(Clone, Copy, Debug)]
enum Stop {
Merge,
RebaseMerge,
RebaseApply,
CherryPick,
Revert,
}
fn conflicted(fixture: &Fixture, stop: Stop) -> PathBuf {
let repo: PathBuf = repository(fixture, OsStr::new("repo"));
for (name, text) in [("a.rs", "base\n"), ("b.rs", "base\n"), ("c.rs", "keep\n")] {
std::fs::write(repo.join(name), text).expect("file");
}
git(fixture, repo.as_path(), &["add", "--", "a.rs", "b.rs", "c.rs"]);
git(fixture, repo.as_path(), &["commit", "--quiet", "--message=base"]);
git(fixture, repo.as_path(), &["switch", "--quiet", "--create", "topic"]);
std::fs::write(repo.join("a.rs"), "topic\n").expect("topic side");
git(fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=topic side"]);
git(fixture, repo.as_path(), &["switch", "--quiet", "main"]);
std::fs::write(repo.join("a.rs"), "main\n").expect("main side");
git(fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=main side"]);
let started: Observed = match stop {
Stop::Merge => run_direct(fixture, repo.as_path(), &["merge", "--no-edit", "topic"]),
Stop::CherryPick => run_direct(fixture, repo.as_path(), &["cherry-pick", "topic"]),
Stop::RebaseMerge | Stop::RebaseApply => {
git(fixture, repo.as_path(), &["switch", "--quiet", "topic"]);
let arguments: Vec<&str> = if matches!(stop, Stop::RebaseApply) { vec!["rebase", "--apply", "main"] } else { vec!["rebase", "main"] };
run_direct(fixture, repo.as_path(), arguments.as_slice())
}
Stop::Revert => {
std::fs::write(repo.join("a.rs"), "later\n").expect("later change");
git(fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=later change"]);
run_direct(fixture, repo.as_path(), &["revert", "--no-edit", "HEAD~1"])
}
};
assert_eq!(started.code, Some(1), "the operation stops with a conflict: {}{}", String::from_utf8_lossy(&started.stdout), stderr_of(&started));
assert!(
git(fixture, repo.as_path(), &["ls-files", "--unmerged"]).stdout.windows(4).any(|window| { return window == b"a.rs"; }),
"a.rs is unmerged"
);
std::fs::write(repo.join("a.rs"), HAND_EDITED).expect("hand edit");
return repo;
}
fn rejected_by_policy(observed: &Observed) -> bool {
return observed.code == Some(1) && stderr_of(observed).contains("\"policyId\":\"linked-worktree-only\"");
}
fn saved_places(stderr: &str) -> Vec<PathBuf> {
let lines: Vec<&str> = stderr.lines().collect();
return lines
.iter()
.enumerate()
.filter(|(_, line)| { return line.starts_with("cli-git: saved your copy of "); })
.filter_map(|(at, _)| { return lines.get(at + 1).map(|place| { return PathBuf::from(place.trim()); }); })
.collect();
}
fn saved_root(repo: &Path) -> PathBuf {
return repo.join(".git/cli-git/discarded");
}
fn audit_lines(repo: &Path) -> Vec<String> {
return std::fs::read_to_string(repo.join(".git/cli-git-audit.jsonl")).unwrap_or_default().lines().map(String::from).collect();
}
fn allowed_forms_save_the_hand_edited_file(label: &str, stop: Stop, forms: &[&[&str]]) {
for (round, arguments) in forms.iter().enumerate() {
let fixture: Fixture = fixture(format!("{label}-{round}").as_str());
let repo: PathBuf = conflicted(&fixture, stop);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments);
let said: String = stderr_of(&observed);
assert!(!rejected_by_policy(&observed), "{stop:?} {arguments:?}: {said}");
let places: Vec<PathBuf> = saved_places(said.as_str());
assert_eq!(places.len(), 1, "{stop:?} {arguments:?}: one copy is named: {said}");
assert!(places[0].starts_with(saved_root(repo.as_path())), "{stop:?} {arguments:?}: the copy is beside the audit log: {places:?}");
assert!(places[0].ends_with("a.rs"), "{places:?}");
assert_eq!(std::fs::read(&places[0]).expect("the copy exists"), HAND_EDITED, "{stop:?} {arguments:?}: the original bytes");
assert!(said.contains("a.rs was conflicted"), "{said}");
let audit: Vec<String> = audit_lines(repo.as_path());
let saved: Vec<&String> = audit.iter().filter(|line| { return line.contains("\"outcome\":\"saved-conflict\""); }).collect();
assert_eq!(saved.len(), 1, "{audit:?}");
assert!(saved[0].contains("\"copies\":[\"a.rs => "), "{}", saved[0]);
remove(&fixture);
}
}
#[test]
fn a_merge_conflict_side_is_taken_after_the_file_is_saved() {
allowed_forms_save_the_hand_edited_file(
"side-merge",
Stop::Merge,
&[
&["checkout", "--theirs", "--", "a.rs"],
&["checkout", "--ours", "--", "a.rs"],
&["checkout", "topic", "--", "a.rs"],
&["checkout", "--merge", "--", "a.rs"],
&["restore", "--theirs", "--", "a.rs"],
&["restore", "--ours", "--", "a.rs"],
&["restore", "--source=topic", "--", "a.rs"],
],
);
}
#[test]
fn a_rebase_conflict_side_is_taken_after_the_file_is_saved() {
allowed_forms_save_the_hand_edited_file(
"side-rebase-merge",
Stop::RebaseMerge,
&[&["checkout", "--theirs", "--", "a.rs"], &["checkout", "--ours", "--", "a.rs"], &["restore", "--theirs", "--", "a.rs"]],
);
}
#[test]
fn a_rebase_conflict_of_the_apply_backend_side_is_taken_after_the_file_is_saved() {
allowed_forms_save_the_hand_edited_file(
"side-rebase-apply",
Stop::RebaseApply,
&[&["checkout", "--theirs", "--", "a.rs"], &["restore", "--ours", "--", "a.rs"]],
);
}
#[test]
fn a_cherry_pick_conflict_side_is_taken_after_the_file_is_saved() {
allowed_forms_save_the_hand_edited_file(
"side-cherry-pick",
Stop::CherryPick,
&[&["checkout", "--theirs", "--", "a.rs"], &["checkout", "topic", "--", "a.rs"], &["restore", "--theirs", "--", "a.rs"]],
);
}
#[test]
fn a_revert_conflict_side_is_taken_after_the_file_is_saved() {
allowed_forms_save_the_hand_edited_file("side-revert", Stop::Revert, &[&["checkout", "--theirs", "--", "a.rs"], &["restore", "--ours", "--", "a.rs"]]);
}
#[test]
fn taking_their_side_replaces_the_file_once_it_is_saved() {
let fixture: Fixture = fixture("side-replaces");
let repo: PathBuf = conflicted(&fixture, Stop::Merge);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["checkout", "--theirs", "--", "a.rs"]);
assert_eq!(observed.code, Some(0), "{}", stderr_of(&observed));
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs"), b"topic\n");
let place: PathBuf = saved_places(stderr_of(&observed).as_str()).remove(0);
assert_eq!(std::fs::read(place).expect("the copy"), HAND_EDITED);
remove(&fixture);
}
#[test]
fn clean_paths_keep_the_rejection_and_a_mixed_line_is_rejected_whole() {
for stop in [Stop::Merge, Stop::RebaseMerge, Stop::CherryPick] {
let fixture: Fixture = fixture(format!("side-clean-{stop:?}").to_lowercase().as_str());
let repo: PathBuf = conflicted(&fixture, stop);
for arguments in [
vec!["checkout", "--theirs", "--", "c.rs"],
vec!["checkout", "topic", "--", "b.rs"],
vec!["restore", "--theirs", "--", "c.rs"],
vec!["checkout", "--theirs", "--", "."],
vec!["checkout", "--theirs", "--", "*.rs"],
] {
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
assert!(rejected_by_policy(&observed), "{stop:?} {arguments:?}: {}", stderr_of(&observed));
}
for arguments in [vec!["checkout", "--theirs", "--", "a.rs", "c.rs"], vec!["restore", "--ours", "--", "c.rs", "a.rs"]] {
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
let said: String = stderr_of(&observed);
assert!(rejected_by_policy(&observed), "{stop:?} {arguments:?}: {said}");
assert!(said.contains("`c.rs`") && said.contains("not conflicted files"), "{stop:?} {arguments:?} names the clean path: {said}");
assert!(!said.contains("`a.rs`"), "the conflicted path is not named as clean: {said}");
}
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs"), HAND_EDITED, "the hand edit is untouched");
assert!(!saved_root(repo.as_path()).exists(), "no copy was made for a rejected line");
remove(&fixture);
}
}
#[test]
fn forms_outside_the_decision_keep_the_rejection() {
let fixture: Fixture = fixture("side-uncovered");
let repo: PathBuf = conflicted(&fixture, Stop::Merge);
for arguments in [
vec!["checkout", "-f", "--", "a.rs"],
vec!["checkout", "--theirs", "a.rs"],
vec!["checkout", "--patch", "--", "a.rs"],
vec!["restore", "--staged", "--theirs", "--", "a.rs"],
vec!["restore", "--theirs", "a.rs"],
vec!["checkout", "--theirs", "--", "sub/"],
vec!["reset", "--hard"],
vec!["merge", "--abort"],
] {
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
assert!(rejected_by_policy(&observed), "{arguments:?}: {}", stderr_of(&observed));
}
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs"), HAND_EDITED);
assert!(!saved_root(repo.as_path()).exists());
remove(&fixture);
}
#[test]
fn without_an_operation_in_progress_the_rejection_stays() {
let fixture: Fixture = fixture("side-no-operation");
let repo: PathBuf = conflicted(&fixture, Stop::Merge);
std::fs::remove_file(repo.join(".git/MERGE_HEAD")).expect("end the merge's state, leaving the unmerged entries");
for arguments in [vec!["checkout", "--theirs", "--", "a.rs"], vec!["restore", "--source=topic", "--", "a.rs"]] {
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
assert!(rejected_by_policy(&observed), "{arguments:?}: {}", stderr_of(&observed));
}
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs"), HAND_EDITED);
assert!(!saved_root(repo.as_path()).exists());
remove(&fixture);
}
#[test]
fn a_copy_that_cannot_be_written_rejects_the_command() {
let fixture: Fixture = fixture("side-copy-fails");
let repo: PathBuf = conflicted(&fixture, Stop::Merge);
std::fs::write(repo.join(".git/cli-git"), b"in the way").expect("blocker");
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["checkout", "--theirs", "--", "a.rs"]);
let said: String = stderr_of(&observed);
assert!(rejected_by_policy(&observed), "{said}");
assert!(said.contains("your copy of it could not be saved first"), "{said}");
assert!(said.contains("discarded"), "the place is named: {said}");
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs"), HAND_EDITED, "the file was not replaced");
assert!(audit_lines(repo.as_path()).iter().all(|line| { return !line.contains("saved-conflict"); }));
std::fs::remove_file(repo.join(".git/cli-git")).expect("remove blocker");
let again: Observed = run_wrapped(&fixture, repo.as_path(), &["checkout", "--theirs", "--", "a.rs"]);
assert_eq!(again.code, Some(0), "{}", stderr_of(&again));
remove(&fixture);
}
#[test]
fn a_copy_cut_short_by_a_failing_write_rejects_the_command_and_leaves_no_partial_copy() {
use std::os::unix::process::CommandExt;
let fixture: Fixture = fixture("side-write-fails");
let repo: PathBuf = conflicted(&fixture, Stop::Merge);
let mut command = super::support::wrapped(&fixture);
command.current_dir(&repo).args(["checkout", "--theirs", "--", "a.rs"]);
unsafe {
command.pre_exec(|| {
libc::signal(libc::SIGXFSZ, libc::SIG_IGN);
let cap: libc::rlimit = libc::rlimit { rlim_cur: 16, rlim_max: 16 };
if libc::setrlimit(libc::RLIMIT_FSIZE, &raw const cap) != 0 {
return Err(std::io::Error::last_os_error());
}
return Ok(());
});
}
let observed: Observed = super::support::observe(&mut command, b"");
let said: String = stderr_of(&observed);
assert!(rejected_by_policy(&observed), "{said}");
assert!(said.contains("your copy of it could not be saved first"), "{said}");
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs"), HAND_EDITED, "the file was not replaced");
let leftovers: Vec<PathBuf> = walk(saved_root(repo.as_path()).as_path()).into_iter().filter(|path| { return path.is_file(); }).collect();
assert!(leftovers.is_empty(), "no partial copy stays: {leftovers:?}");
remove(&fixture);
}
fn walk(directory: &Path) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
let Ok(entries) = std::fs::read_dir(directory) else {
return found;
};
for entry in entries.flatten() {
found.push(entry.path());
if entry.path().is_dir() {
found.extend(walk(entry.path().as_path()));
}
}
return found;
}
#[test]
fn a_conflicted_path_deleted_in_the_worktree_is_allowed_with_no_copy() {
let fixture: Fixture = fixture("side-deleted");
let repo: PathBuf = conflicted(&fixture, Stop::Merge);
std::fs::remove_file(repo.join("a.rs")).expect("delete the conflicted file");
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["checkout", "--theirs", "--", "a.rs"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert!(said.contains("a.rs was conflicted and is deleted in your worktree, so there is no copy to save"), "{said}");
assert_eq!(std::fs::read(repo.join("a.rs")).expect("a.rs is back"), b"topic\n");
assert!(!saved_root(repo.as_path()).exists(), "nothing was copied");
remove(&fixture);
}
#[test]
fn a_linked_worktree_saves_beside_the_common_audit_log() {
let fixture: Fixture = fixture("side-linked");
let repo: PathBuf = repository(&fixture, OsStr::new("repo"));
std::fs::write(repo.join("a.rs"), "base\n").expect("a.rs");
git(&fixture, repo.as_path(), &["add", "--", "a.rs"]);
git(&fixture, repo.as_path(), &["commit", "--quiet", "--message=base"]);
git(&fixture, repo.as_path(), &["branch", "topic"]);
let linked: PathBuf = fixture.root.join("linked");
git(&fixture, repo.as_path(), &["worktree", "add", "--quiet", "-b", "side", "../linked"]);
std::fs::write(linked.join("a.rs"), "side\n").expect("side edit");
git(&fixture, linked.as_path(), &["commit", "--quiet", "--all", "--message=side"]);
git(&fixture, repo.as_path(), &["switch", "--quiet", "topic"]);
std::fs::write(repo.join("a.rs"), "topic\n").expect("topic edit");
git(&fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=topic"]);
let merged: Observed = run_direct(&fixture, linked.as_path(), &["merge", "--no-edit", "topic"]);
assert_eq!(merged.code, Some(1), "{}", stderr_of(&merged));
std::fs::write(linked.join("a.rs"), HAND_EDITED).expect("hand edit");
let observed: Observed = run_wrapped(&fixture, linked.as_path(), &["checkout", "--theirs", "--", "a.rs"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
let place: PathBuf = saved_places(said.as_str()).remove(0);
assert!(place.starts_with(std::fs::canonicalize(repo.join(".git")).expect("common directory").join("cli-git/discarded")), "{place:?}");
assert_eq!(std::fs::read(place).expect("copy"), HAND_EDITED);
remove(&fixture);
}