use super::commit::wrapped_commit;
use super::support::{Fixture, Observed, executable, fixture, git, observe, remove, repository, run_direct, run_wrapped, stderr_of, wrapped};
use super::word_merge::{ADJACENT, Stop, STOPS, arguments_of, corpus, expected_after, merge_lines, prepared, tracked, unmerged};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
const SECOND_ADJACENT: &str = "md1c";
const STATE: [&str; 6] = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"];
fn state_left(repo: &Path) -> Vec<&'static str> {
return STATE.iter().copied().filter(|name| { return repo.join(".git").join(name).exists(); }).collect();
}
fn text(fixture: &Fixture, repo: &Path, arguments: &[&str]) -> String {
return String::from_utf8_lossy(&git(fixture, repo, arguments).stdout).trim().to_owned();
}
fn status(fixture: &Fixture, repo: &Path) -> String {
return text(fixture, repo, &["status", "--porcelain=v1", "--untracked-files=all"]);
}
fn appending_editor(fixture: &Fixture) -> PathBuf {
let path: PathBuf = fixture.root.join("appending-editor");
executable(path.as_path(), b"#!/bin/sh\nprintf 'edited by the editor\\n' >> \"$1\"\n");
return path;
}
fn run_with_editor(fixture: &Fixture, repo: &Path, editor: &Path, arguments: &[&str]) -> Observed {
return observe(wrapped(fixture).env("GIT_EDITOR", editor).current_dir(repo).args(arguments), b"");
}
#[test]
fn each_operation_is_finished_like_a_clean_run() {
for stop in STOPS {
let fixture: Fixture = fixture(format!("finish-{stop:?}").to_lowercase().as_str());
let repo: PathBuf = prepared(&fixture, stop, &[ADJACENT]);
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(stop).as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{stop:?}: {said}");
assert_eq!(unmerged(&fixture, repo.as_path()), Vec::<String>::new(), "{stop:?}: {said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "{stop:?}: the operation is over: {said}");
assert_eq!(status(&fixture, repo.as_path()), "", "{stop:?}: nothing is left staged or modified: {said}");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == expected_after(stop, ADJACENT), "{stop:?}: {said}");
assert_ne!(text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]), before, "{stop:?}: a commit was made: {said}");
assert!(merge_lines(&observed).iter().any(|line| { return line.contains(tracked(ADJACENT).as_str()); }), "{stop:?}: {said}");
let subject: String = text(&fixture, repo.as_path(), &["log", "-1", "--format=%s"]);
match stop {
Stop::Merge => {
assert_eq!(text(&fixture, repo.as_path(), &["rev-list", "--parents", "--max-count=1", "HEAD"]).split_whitespace().count(), 3, "a merge commit");
assert_eq!(subject, "Merge branch 'topic'");
}
Stop::Rebase => assert_eq!(subject, "theirs"),
Stop::CherryPick => assert_eq!(subject, "theirs"),
Stop::Revert => assert!(subject.starts_with("Revert "), "{subject}"),
}
let message: String = text(&fixture, repo.as_path(), &["log", "-1", "--format=%B"]);
assert!(!message.contains("Conflicts") && !message.contains('#'), "{stop:?}: the conflict hint is not in the message: {message}");
remove(&fixture);
}
}
#[test]
fn a_part_that_does_not_resolve_keeps_the_stop_for_every_operation() {
for stop in STOPS.into_iter().filter(|stop| { return !matches!(stop, Stop::Revert); }) {
let fixture: Fixture = fixture(format!("finish-stays-{stop:?}").to_lowercase().as_str());
let repo: PathBuf = prepared(&fixture, stop, &[ADJACENT, "md2"]);
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments_of(stop).as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(1), "{stop:?}: {said}");
assert_eq!(unmerged(&fixture, repo.as_path()), vec![tracked("md2")], "{stop:?}: {said}");
if !matches!(stop, Stop::Rebase) {
assert_eq!(text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]), before, "{stop:?}: nothing is committed: {said}");
}
assert!(!state_left(repo.as_path()).is_empty(), "{stop:?}: the operation is still in progress");
remove(&fixture);
}
}
#[test]
fn merge_no_commit_stops_after_the_resolution() {
let fixture: Fixture = fixture("finish-merge-no-commit");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["merge", "--no-commit", "--no-edit", "topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert!(said.contains("Automatic merge went well; stopped before committing as requested"), "{said}");
assert_eq!(text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]), before);
assert_eq!(state_left(repo.as_path()), vec!["MERGE_HEAD"]);
assert_eq!(unmerged(&fixture, repo.as_path()), Vec::<String>::new());
let message: String = std::fs::read_to_string(repo.join(".git/MERGE_MSG")).expect("the prepared message");
assert!(!message.contains("Conflicts"), "the message waits for the person's commit without the hint: {message}");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == corpus(ADJACENT, "word.out"));
remove(&fixture);
}
#[test]
fn merge_squash_stops_after_the_resolution() {
let fixture: Fixture = fixture("finish-merge-squash");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["merge", "--squash", "topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert!(said.contains("Automatic merge went well; stopped before committing as requested"), "{said}");
assert_eq!(text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]), before);
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new());
assert!(repo.join(".git/SQUASH_MSG").exists());
assert_eq!(status(&fixture, repo.as_path()), format!("M {}", tracked(ADJACENT)));
remove(&fixture);
}
#[test]
fn merge_edit_runs_the_editor_and_merge_message_is_kept() {
let first: Fixture = fixture("finish-merge-edit");
let repo: PathBuf = prepared(&first, Stop::Merge, &[ADJACENT]);
let editor: PathBuf = appending_editor(&first);
let observed: Observed = run_with_editor(&first, repo.as_path(), editor.as_path(), &["merge", "--edit", "topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
let message: String = text(&first, repo.as_path(), &["log", "-1", "--format=%B"]);
assert!(message.starts_with("Merge branch 'topic'") && message.contains("edited by the editor"), "{message}");
assert!(!message.contains("Conflicts"), "{message}");
remove(&first);
let second: Fixture = fixture("finish-merge-message");
let again: PathBuf = prepared(&second, Stop::Merge, &[ADJACENT]);
let named: Observed = run_wrapped(&second, again.as_path(), &["merge", "-m", "a message of my own", "topic"]);
assert_eq!(named.code, Some(0), "{}", stderr_of(&named));
assert_eq!(text(&second, again.as_path(), &["log", "-1", "--format=%B"]), "a message of my own");
remove(&second);
}
#[test]
fn a_rebase_with_two_conflicting_picks_is_resolved_and_finished() {
let fixture: Fixture = fixture("finish-rebase-two");
let repo: PathBuf = repository(&fixture, OsStr::new("repo"));
let names: [&str; 2] = [ADJACENT, SECOND_ADJACENT];
let write = |extension: &str, name: &str| { std::fs::write(repo.join(tracked(name)), corpus(name, extension)).expect("an input"); };
for name in names {
write("base", name);
}
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"]);
for name in names {
write("theirs", name);
git(&fixture, repo.as_path(), &["commit", "--quiet", "--all", format!("--message=theirs {name}").as_str()]);
}
git(&fixture, repo.as_path(), &["switch", "--quiet", "main"]);
for name in names {
write("ours", name);
}
git(&fixture, repo.as_path(), &["commit", "--quiet", "--all", "--message=ours"]);
git(&fixture, repo.as_path(), &["switch", "--quiet", "topic"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["rebase", "main"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "{said}");
assert_eq!(text(&fixture, repo.as_path(), &["log", "--format=%s", "main..HEAD"]), format!("theirs {SECOND_ADJACENT}\ntheirs {ADJACENT}"), "{said}");
for name in names {
assert!(std::fs::read(repo.join(tracked(name))).expect("the file") == corpus(name, "word.out"), "{name}");
}
assert!(merge_lines(&observed).iter().any(|line| { return line.contains(tracked(ADJACENT).as_str()); }), "{said}");
assert!(merge_lines(&observed).iter().any(|line| { return line.contains(tracked(SECOND_ADJACENT).as_str()); }), "the later pick got the pass: {said}");
remove(&fixture);
}
#[test]
fn cherry_pick_no_commit_stops_after_the_resolution() {
let fixture: Fixture = fixture("finish-pick-no-commit");
let repo: PathBuf = prepared(&fixture, Stop::CherryPick, &[ADJACENT]);
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["cherry-pick", "--no-commit", "topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert_eq!(text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]), before);
assert_eq!(status(&fixture, repo.as_path()), format!("M {}", tracked(ADJACENT)));
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new());
remove(&fixture);
}
#[test]
fn cherry_pick_and_revert_edit_run_the_editor() {
for (stop, arguments) in [(Stop::CherryPick, vec!["cherry-pick", "--edit", "topic"]), (Stop::Revert, vec!["revert", "--edit", "HEAD~1"])] {
let fixture: Fixture = fixture(format!("finish-edit-{stop:?}").to_lowercase().as_str());
let repo: PathBuf = prepared(&fixture, stop, &[ADJACENT]);
let editor: PathBuf = appending_editor(&fixture);
let observed: Observed = run_with_editor(&fixture, repo.as_path(), editor.as_path(), arguments.as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{stop:?}: {said}");
let message: String = text(&fixture, repo.as_path(), &["log", "-1", "--format=%B"]);
assert!(message.contains("edited by the editor") && !message.contains("Conflicts"), "{stop:?}: {message}");
remove(&fixture);
}
}
#[test]
fn a_cherry_pick_range_is_resolved_and_continued() {
let fixture: Fixture = fixture("finish-pick-range");
let repo: PathBuf = prepared(&fixture, Stop::CherryPick, &[ADJACENT]);
git(&fixture, repo.as_path(), &["switch", "--quiet", "topic"]);
std::fs::write(repo.join("second.txt"), b"a second commit\n").expect("the file");
git(&fixture, repo.as_path(), &["add", "--all"]);
git(&fixture, repo.as_path(), &["commit", "--quiet", "--message=second"]);
git(&fixture, repo.as_path(), &["switch", "--quiet", "main"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["cherry-pick", "main..topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "{said}");
assert_eq!(text(&fixture, repo.as_path(), &["log", "--format=%s", "-2"]), "second\ntheirs", "{said}");
assert_eq!(status(&fixture, repo.as_path()), "");
remove(&fixture);
}
#[test]
fn a_pull_is_finished_like_a_clean_one() {
for (label, option, expect_commit) in [("merge", "--no-rebase", true), ("no-commit", "--no-commit", false), ("rebase", "--rebase", true)] {
let fixture: Fixture = fixture(format!("finish-pull-{label}").as_str());
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let remote: PathBuf = fixture.root.join("remote.git");
git(&fixture, fixture.root.as_path(), &["init", "--quiet", "--bare", "--initial-branch=main", "remote.git"]);
git(&fixture, repo.as_path(), &["remote", "add", "origin", remote.to_str().expect("UTF-8")]);
git(&fixture, repo.as_path(), &["push", "--quiet", "origin", "topic:main"]);
git(&fixture, repo.as_path(), &["fetch", "--quiet", "origin"]);
git(&fixture, repo.as_path(), &["branch", "--set-upstream-to=origin/main", "main"]);
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let arguments: Vec<&str> = if label == "no-commit" { vec!["pull", "--no-rebase", option, "--no-edit"] } else if label == "merge" { vec!["pull", option, "--no-edit"] } else { vec!["pull", option] };
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{label}: {said}");
assert_eq!(unmerged(&fixture, repo.as_path()), Vec::<String>::new(), "{label}: {said}");
let moved: bool = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]) != before;
assert_eq!(moved, expect_commit, "{label}: {said}");
assert_eq!(state_left(repo.as_path()).is_empty(), expect_commit, "{label}: {said}");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == corpus(ADJACENT, "word.out"), "{label}: {said}");
assert!(merge_lines(&observed).iter().any(|line| { return line.contains(tracked(ADJACENT).as_str()); }), "{label}: {said}");
remove(&fixture);
}
}
#[test]
fn the_ending_commit_is_judged_by_the_repositorys_policies() {
let fixture: Fixture = fixture("finish-policy");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
std::fs::create_dir(repo.join("rules")).expect("rules directory");
std::fs::write(repo.join("rules/private.txt"), b"Exit code is zero on success.\n").expect("rules");
std::fs::write(
repo.join("cli-git.config.jsonc"),
"{ \"policies\": { \"security/forbidden-strings\": [\"error\", { \"builtinRules\": false, \"rulesFile\": \"rules/private.txt\" }] } }\n",
)
.expect("configuration");
let before: String = text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["merge", "--no-edit", "topic"]);
let said: String = stderr_of(&observed);
assert_ne!(observed.code, Some(0), "the policy rejected the ending commit: {said}");
assert!(said.contains("forbidden-strings"), "{said}");
assert_eq!(text(&fixture, repo.as_path(), &["rev-parse", "HEAD"]), before, "{said}");
assert_eq!(state_left(repo.as_path()), vec!["MERGE_HEAD"], "{said}");
remove(&fixture);
}
fn with_unstaged_work(fixture: &Fixture, repo: &Path) -> &'static str {
std::fs::write(repo.join("dirty.txt"), b"committed\n").expect("the file");
git(fixture, repo, &["add", "--", "dirty.txt"]);
git(fixture, repo, &["commit", "--quiet", "--message=a file that gets unstaged work"]);
std::fs::write(repo.join("dirty.txt"), b"unstaged work\n").expect("the change");
return "unstaged work\n";
}
fn autostash_is_gone(fixture: &Fixture, repo: &Path) -> bool {
let listed: String = text(fixture, repo, &["stash", "list"]);
let reference: Option<i32> = run_direct(fixture, repo, &["rev-parse", "--verify", "--quiet", "MERGE_AUTOSTASH"]).code;
let entries: String = std::fs::read_dir(repo.join(".git")).expect("the Git directory").filter_map(|entry| { return entry.ok()?.file_name().into_string().ok(); }).filter(|name| { return name.contains("AUTOSTASH") || name.contains("MERGE"); }).collect::<Vec<String>>().join(" ");
assert!(listed.is_empty() && reference == Some(1), "stash list {listed:?}, MERGE_AUTOSTASH lookup {reference:?}, state files {entries}");
return true;
}
#[test]
fn a_merge_autostash_is_applied_by_the_ending_commit() {
for (label, arguments, setting) in [("flag", vec!["merge", "--autostash", "--no-edit", "topic"], false), ("config", vec!["merge", "--no-edit", "topic"], true)] {
let fixture: Fixture = fixture(format!("finish-merge-autostash-{label}").as_str());
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
if setting {
git(&fixture, repo.as_path(), &["config", "merge.autoStash", "true"]);
}
let work: &str = with_unstaged_work(&fixture, repo.as_path());
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{label}: {said}");
assert_eq!(std::fs::read_to_string(repo.join("dirty.txt")).expect("the file"), work, "{label}: {said}");
assert!(autostash_is_gone(&fixture, repo.as_path()), "{label}: the stash was applied and dropped: {said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "{label}: {said}");
assert!(std::fs::read(repo.join(tracked(ADJACENT))).expect("the file") == corpus(ADJACENT, "word.out"), "{label}");
remove(&fixture);
}
}
fn with_overlapping_work(fixture: &Fixture, repo: &Path) -> &'static str {
let edited: &str = "Flags are optional, edited by hand.\n";
let original: String = String::from_utf8(corpus(ADJACENT, "ours")).expect("UTF-8");
let changed: String = original.replace("Flags are optional, see below.\n", edited);
assert_ne!(changed, original, "the edit lands on the line the merge keeps");
std::fs::write(repo.join(tracked(ADJACENT)), changed.as_bytes()).expect("the change");
git(fixture, repo, &["status", "--porcelain=v1"]);
return edited;
}
type End = (String, String, String, String);
fn after_autostash(fixture: &Fixture, repo: &Path) -> End {
return (
text(fixture, repo, &["stash", "list"]),
status(fixture, repo),
std::fs::read_to_string(repo.join(tracked(ADJACENT))).expect("the file"),
std::fs::read_to_string(repo.join("dirty.txt")).unwrap_or_default(),
);
}
#[derive(Clone, Copy)]
enum Ask {
Merge,
Pull,
}
impl Ask {
fn arguments(self) -> Vec<&'static str> {
return match self {
Ask::Merge => vec!["merge", "--autostash", "--no-edit", "topic"],
Ask::Pull => vec!["pull", "--no-rebase", "--autostash", "--no-edit"],
};
}
}
fn conflicted_autostash_repo(fixture: &Fixture, ask: Ask, with_dirty_file: bool) -> PathBuf {
let repo: PathBuf = prepared(fixture, Stop::Merge, &[ADJACENT]);
if matches!(ask, Ask::Pull) {
git(fixture, fixture.root.as_path(), &["init", "--quiet", "--bare", "--initial-branch=main", "remote.git"]);
git(fixture, repo.as_path(), &["remote", "add", "origin", fixture.root.join("remote.git").to_str().expect("UTF-8")]);
git(fixture, repo.as_path(), &["push", "--quiet", "origin", "topic:main"]);
git(fixture, repo.as_path(), &["fetch", "--quiet", "origin"]);
git(fixture, repo.as_path(), &["branch", "--set-upstream-to=origin/main", "main"]);
}
if with_dirty_file {
with_unstaged_work(fixture, repo.as_path());
}
with_overlapping_work(fixture, repo.as_path());
return repo;
}
fn git_twin(label: &str, ask: Ask, with_dirty_file: bool, unsaved_after_stop: bool) -> (Option<i32>, End) {
let twin: Fixture = fixture(format!("autostash-twin-{label}").as_str());
let repo: PathBuf = conflicted_autostash_repo(&twin, ask, with_dirty_file);
let stopped: Observed = run_direct(&twin, repo.as_path(), ask.arguments().as_slice());
assert_eq!(stopped.code, Some(1), "Git stops the merge: {}", stderr_of(&stopped));
std::fs::write(repo.join(tracked(ADJACENT)), corpus(ADJACENT, "word.out")).expect("the resolution");
git(&twin, repo.as_path(), &["add", "--", tracked(ADJACENT).as_str()]);
if unsaved_after_stop {
std::fs::write(repo.join("dirty.txt"), b"unsaved again\n").expect("the unsaved work");
}
let concluded: Observed = run_direct(&twin, repo.as_path(), &["commit", "--no-edit"]);
let end: End = after_autostash(&twin, repo.as_path());
assert!(end.0.contains("autostash"), "Git keeps the stash it could not apply: {end:?} {}", stderr_of(&concluded));
remove(&twin);
return (concluded.code, end);
}
#[test]
fn a_merge_autostash_that_conflicts_on_apply_keeps_the_work_as_git_keeps_it() {
let (git_code, git_end): (Option<i32>, End) = git_twin("merge", Ask::Merge, false, false);
let fixture: Fixture = fixture("finish-autostash-conflict-merge");
let repo: PathBuf = conflicted_autostash_repo(&fixture, Ask::Merge, false);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), Ask::Merge.arguments().as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, git_code, "the status is the concluding commit's, as in Git: {said}");
assert_eq!(after_autostash(&fixture, repo.as_path()), git_end, "the stash list, the status and the files are Git's: {said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "the merge is concluded: {said}");
assert!(!repo.join(".git/MERGE_AUTOSTASH").exists(), "the stash was consumed: {said}");
assert_eq!(text(&fixture, repo.as_path(), &["rev-list", "--parents", "--max-count=1", "HEAD"]).split_whitespace().count(), 3, "a merge commit: {said}");
remove(&fixture);
}
#[test]
fn a_pull_autostash_that_conflicts_on_apply_keeps_the_work_as_git_keeps_it() {
let (git_code, git_end): (Option<i32>, End) = git_twin("pull", Ask::Pull, false, false);
let fixture: Fixture = fixture("finish-autostash-conflict-pull");
let repo: PathBuf = conflicted_autostash_repo(&fixture, Ask::Pull, false);
let observed: Observed = run_wrapped(&fixture, repo.as_path(), Ask::Pull.arguments().as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, git_code, "the status is the concluding commit's, as in Git: {said}");
assert_eq!(after_autostash(&fixture, repo.as_path()), git_end, "the stash list, the status and the files are Git's: {said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "the merge is concluded: {said}");
remove(&fixture);
}
fn commit_after_a_stopped_merge_ends_as_git_does(label: &str, unsaved: bool) {
let (git_code, git_end): (Option<i32>, End) = git_twin(label, Ask::Merge, unsaved, unsaved);
let fixture: Fixture = fixture(format!("commit-autostash-conflict-{label}").as_str());
let repo: PathBuf = conflicted_autostash_repo(&fixture, Ask::Merge, unsaved);
let first: Observed = run_direct(&fixture, repo.as_path(), Ask::Merge.arguments().as_slice());
assert_eq!(first.code, Some(1), "Git stops the merge: {}", stderr_of(&first));
std::fs::write(repo.join(tracked(ADJACENT)), corpus(ADJACENT, "word.out")).expect("the resolution");
git(&fixture, repo.as_path(), &["add", "--", tracked(ADJACENT).as_str()]);
if unsaved {
std::fs::write(repo.join("dirty.txt"), b"unsaved again\n").expect("the unsaved work");
}
let concluded: Observed = observe(&mut wrapped_commit(&fixture, repo.as_path(), &["commit", "--no-edit"]), b"");
let said: String = stderr_of(&concluded);
assert_eq!(concluded.code, git_code, "the status is Git's: {said}");
let ours: End = after_autostash(&fixture, repo.as_path());
assert_eq!(ours, git_end, "the stash list, the status and the files are Git's: {said}");
assert!(text(&fixture, repo.as_path(), &["stash", "show", "-p", "stash@{0}"]).contains("edited by hand"), "the stash holds the edit: {said}");
git(&fixture, repo.as_path(), &["fsck", "--strict", "--no-dangling"]);
remove(&fixture);
}
#[test]
fn a_commit_that_applies_a_conflicting_autostash_keeps_the_stash_as_git_keeps_it() {
commit_after_a_stopped_merge_ends_as_git_does("conflict", false);
}
#[test]
fn a_commit_over_unsaved_changes_to_a_stash_file_keeps_the_stash_as_git_keeps_it() {
commit_after_a_stopped_merge_ends_as_git_does("unsaved", true);
}
#[test]
fn a_rebase_autostash_is_applied_when_the_rebase_ends() {
for (label, arguments, setting) in [("flag", vec!["rebase", "--autostash", "main"], false), ("config", vec!["rebase", "main"], true)] {
let fixture: Fixture = fixture(format!("finish-rebase-autostash-{label}").as_str());
let repo: PathBuf = prepared(&fixture, Stop::Rebase, &[ADJACENT]);
if setting {
git(&fixture, repo.as_path(), &["config", "rebase.autoStash", "true"]);
}
let work: &str = with_unstaged_work(&fixture, repo.as_path());
let observed: Observed = run_wrapped(&fixture, repo.as_path(), arguments.as_slice());
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{label}: {said}");
assert_eq!(std::fs::read_to_string(repo.join("dirty.txt")).expect("the file"), work, "{label}: {said}");
assert!(autostash_is_gone(&fixture, repo.as_path()), "{label}: {said}");
assert_eq!(state_left(repo.as_path()), Vec::<&str>::new(), "{label}: {said}");
remove(&fixture);
}
}
#[test]
fn a_pull_autostash_is_applied_by_the_ending_commit() {
let fixture: Fixture = fixture("finish-pull-autostash");
let repo: PathBuf = prepared(&fixture, Stop::Merge, &[ADJACENT]);
let remote: PathBuf = fixture.root.join("remote.git");
git(&fixture, fixture.root.as_path(), &["init", "--quiet", "--bare", "--initial-branch=main", "remote.git"]);
git(&fixture, repo.as_path(), &["remote", "add", "origin", remote.to_str().expect("UTF-8")]);
git(&fixture, repo.as_path(), &["push", "--quiet", "origin", "topic:main"]);
git(&fixture, repo.as_path(), &["fetch", "--quiet", "origin"]);
git(&fixture, repo.as_path(), &["branch", "--set-upstream-to=origin/main", "main"]);
let work: &str = with_unstaged_work(&fixture, repo.as_path());
let observed: Observed = run_wrapped(&fixture, repo.as_path(), &["pull", "--no-rebase", "--autostash", "--no-edit"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert_eq!(std::fs::read_to_string(repo.join("dirty.txt")).expect("the file"), work, "{said}");
assert!(autostash_is_gone(&fixture, repo.as_path()), "{said}");
remove(&fixture);
}
#[test]
fn a_merge_options_setting_is_applied_to_the_finish() {
let first: Fixture = fixture("finish-merge-options-stop");
let repo: PathBuf = prepared(&first, Stop::Merge, &[ADJACENT]);
git(&first, repo.as_path(), &["config", "branch.main.mergeoptions", "--no-commit"]);
let before: String = text(&first, repo.as_path(), &["rev-parse", "HEAD"]);
let observed: Observed = run_wrapped(&first, repo.as_path(), &["merge", "--no-edit", "topic"]);
let said: String = stderr_of(&observed);
assert_eq!(observed.code, Some(0), "{said}");
assert!(said.contains("Automatic merge went well; stopped before committing as requested"), "{said}");
assert_eq!(text(&first, repo.as_path(), &["rev-parse", "HEAD"]), before, "{said}");
assert_eq!(state_left(repo.as_path()), vec!["MERGE_HEAD"], "{said}");
remove(&first);
let second: Fixture = fixture("finish-merge-options-message");
let again: PathBuf = prepared(&second, Stop::Merge, &[ADJACENT]);
git(&second, again.as_path(), &["config", "branch.main.mergeoptions", "--no-edit -m \"from the setting\""]);
let named: Observed = run_wrapped(&second, again.as_path(), &["merge", "topic"]);
assert_eq!(named.code, Some(0), "{}", stderr_of(&named));
assert_eq!(text(&second, again.as_path(), &["log", "-1", "--format=%B"]), "from the setting");
remove(&second);
}
fn record_hooks(fixture: &Fixture, repo: &Path) -> PathBuf {
let log: PathBuf = fixture.root.join("hooks-seen");
let events: [&str; 10] = ["pre-merge-commit", "prepare-commit-msg", "commit-msg", "post-merge", "pre-commit", "post-commit", "post-checkout", "post-rewrite", "pre-rebase", "reference-transaction"];
std::fs::create_dir_all(repo.join(".git/hooks")).expect("hooks directory");
for event in events {
let body: String = format!("#!/bin/sh\nprintf '%s\\n' '{event}' >> '{}'\nexit 0\n", log.display());
executable(repo.join(".git/hooks").join(event).as_path(), body.as_bytes());
let script: PathBuf = fixture.root.join(format!("recorder-{event}"));
executable(script.as_path(), body.as_bytes());
git(fixture, repo, &["config", format!("hook.record-{event}.event").as_str(), event]);
git(fixture, repo, &["config", format!("hook.record-{event}.command").as_str(), script.to_str().expect("UTF-8")]);
}
return log;
}
fn events_in(log: &Path) -> Vec<String> {
let mut seen: Vec<String> = std::fs::read_to_string(log).unwrap_or_default().lines().map(String::from).collect();
seen.sort();
seen.dedup();
return seen;
}
#[test]
fn user_hooks_see_the_same_on_a_finished_run_as_on_a_clean_run() {
for stop in STOPS {
let finished_fixture: Fixture = fixture(format!("finish-hooks-{stop:?}").to_lowercase().as_str());
let finished_repo: PathBuf = prepared(&finished_fixture, stop, &[ADJACENT]);
let finished_log: PathBuf = record_hooks(&finished_fixture, finished_repo.as_path());
let finished: Observed = run_wrapped(&finished_fixture, finished_repo.as_path(), arguments_of(stop).as_slice());
assert_eq!(finished.code, Some(0), "{stop:?}: {}", stderr_of(&finished));
let clean_fixture: Fixture = fixture(format!("finish-hooks-clean-{stop:?}").to_lowercase().as_str());
let clean_repo: PathBuf = prepared(&clean_fixture, stop, &["md4"]);
let clean_log: PathBuf = record_hooks(&clean_fixture, clean_repo.as_path());
let clean: Observed = run_wrapped(&clean_fixture, clean_repo.as_path(), arguments_of(stop).as_slice());
assert_eq!(clean.code, Some(0), "{stop:?}: {}", stderr_of(&clean));
assert_eq!(events_in(finished_log.as_path()), events_in(clean_log.as_path()), "{stop:?}: the finished run and the clean run show the same hooks");
remove(&finished_fixture);
remove(&clean_fixture);
}
let control: Fixture = fixture("finish-hooks-control");
let repo: PathBuf = prepared(&control, Stop::Merge, &["md4"]);
let log: PathBuf = record_hooks(&control, repo.as_path());
let direct: Observed = run_direct(&control, repo.as_path(), &["merge", "--no-edit", "topic"]);
assert_eq!(direct.code, Some(0), "{}", stderr_of(&direct));
assert!(!events_in(log.as_path()).is_empty(), "real Git fires the recorders, so an empty log from the wrapper means something");
remove(&control);
}