//! End-to-end Git workflows as the IDE drives them (commit / amend, Undo Commit, //! interactive rebase, push / fetch / update against a local bare `origin`, rollback, //! branches, Add to VCS, shelve, cherry-pick / revert), including the edge cases //! IDEA handles. Every repository is a throwaway temp dir built with the git CLI. use ide_core::CancellationToken; use ide_vcs::*; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex}; struct Fixture { tmp: tempfile::TempDir, dir: PathBuf, } fn git_in(dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .current_dir(dir) .args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"]) .args(args) .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("git runs"); assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() } fn configure(dir: &Path) { for (k, v) in [ ("user.name", "Test User"), ("user.email", "test@example.com"), ("commit.gpgsign", "false"), ("tag.gpgsign", "false"), ("core.autocrlf", "false"), ("core.hooksPath", ".git/hooks"), ("merge.conflictstyle", "merge"), // Settings a user may have that must not change what the IDE does. ("pull.ff", "only"), ("pull.rebase", "true"), ("rebase.autoSquash", "true"), ] { git_in(dir, &["config", k, v]); } } impl Fixture { fn new() -> Self { let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path().join("work"); std::fs::create_dir(&dir).unwrap(); git_in(&dir, &["init", "-q"]); configure(&dir); Fixture { tmp, dir } } /// A bare `origin` plus this clone with `a.txt` committed and pushed on main. fn with_origin() -> (Self, PathBuf) { let fx = Fixture::new(); let bare = fx.tmp.path().join("origin.git"); git_in(fx.tmp.path(), &["init", "-q", "--bare", bare.to_str().unwrap()]); git_in(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]); fx.write("a.txt", "one\n"); fx.commit_all("initial"); fx.git(&["remote", "add", "origin", bare.to_str().unwrap()]); fx.git(&["push", "-q", "-u", "origin", "main"]); (fx, bare) } /// Another clone of `bare` that commits `rel` and pushes main. fn push_from_other(&self, bare: &Path, rel: &str, content: &str) { let other = self.tmp.path().join(format!("other-{}", rel.replace('/', "_"))); if !other.exists() { git_in(self.tmp.path(), &["clone", "-q", bare.to_str().unwrap(), other.to_str().unwrap()]); configure(&other); } git_in(&other, &["pull", "-q", "--rebase"]); std::fs::write(other.join(rel), content).unwrap(); git_in(&other, &["add", "-A"]); git_in(&other, &["commit", "-q", "-m", &format!("remote {rel}")]); git_in(&other, &["push", "-q", "origin", "main"]); } fn git(&self, args: &[&str]) -> String { git_in(&self.dir, args) } fn write(&self, rel: &str, content: &str) { let p = self.dir.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } fn read(&self, rel: &str) -> String { std::fs::read_to_string(self.dir.join(rel)).unwrap() } fn exists(&self, rel: &str) -> bool { self.dir.join(rel).exists() } fn commit_all(&self, msg: &str) -> String { self.git(&["add", "-A"]); self.git(&["commit", "-q", "-m", msg]); self.head() } fn head(&self) -> String { self.git(&["rev-parse", "HEAD"]) } fn subjects(&self) -> Vec { self.git(&["log", "--format=%s"]).lines().map(str::to_string).collect() } fn repo(&self) -> Repo { Repo::discover(&self.dir).unwrap().expect("repository found") } fn path(&self, rel: &str) -> PathBuf { self.dir.join(rel) } } fn quiet() -> (CancellationToken, impl Fn(String)) { (CancellationToken::new(), |_line: String| {}) } fn status_of<'a>(st: &'a [FileStatus], rel: &str) -> Option<&'a FileStatus> { st.iter().find(|s| s.rel_path == rel) } fn entry(commit: &str, action: RebaseAction, message: Option<&str>) -> RebaseEntry { RebaseEntry { commit: commit.to_string(), action, message: message.map(str::to_string) } } // --------------------------------------------------------------------------- // Commit / amend / Undo Commit #[test] fn commit_then_amend_keeps_or_replaces_message() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let repo = fx.repo(); repo.stage(&[fx.path("a.txt")]).unwrap(); let first = repo.commit("first\n\nbody", false, false).unwrap(); assert_eq!(first, fx.head()); fx.write("b.txt", "2\n"); repo.stage(&[fx.path("b.txt")]).unwrap(); // Empty message + amend keeps the previous message. let amended = repo.commit("", true, false).unwrap(); assert_ne!(amended, first); assert_eq!(fx.git(&["log", "-1", "--format=%B"]), "first\n\nbody"); assert_eq!(fx.git(&["rev-list", "--count", "HEAD"]), "1"); assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "a.txt\nb.txt"); repo.commit("reworded", true, false).unwrap(); assert_eq!(fx.subjects(), vec!["reworded"]); assert!(repo.commit(" ", false, false).is_err(), "empty message must be rejected"); } #[test] fn undo_commit_keeps_changes_staged() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let base = fx.commit_all("base"); fx.write("a.txt", "2\n"); fx.write("new.txt", "n\n"); let top = fx.commit_all("top message\n\nwith body"); fx.write("a.txt", "3\n"); // unstaged edit on top survives let repo = fx.repo(); let message = repo.undo_commit(&top).unwrap(); assert_eq!(message, "top message\n\nwith body"); assert_eq!(fx.head(), base); let st = repo.status().unwrap(); assert_eq!(status_of(&st, "new.txt").unwrap().staged, Some(ChangeKind::Added)); assert_eq!(status_of(&st, "a.txt").unwrap().staged, Some(ChangeKind::Modified)); assert_eq!(status_of(&st, "a.txt").unwrap().unstaged, Some(ChangeKind::Modified)); assert_eq!(fx.read("a.txt"), "3\n"); } #[test] fn undo_commit_only_for_head() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let base = fx.commit_all("base"); fx.write("a.txt", "2\n"); let top = fx.commit_all("top"); let err = fx.repo().undo_commit(&base).unwrap_err(); assert!(err.message.contains("latest commit"), "{}", err.message); assert_eq!(fx.head(), top); } #[test] fn undo_initial_commit_leaves_unborn_branch_with_files_staged() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.write("dir/b.txt", "2\n"); let root = fx.commit_all("root"); let repo = fx.repo(); assert_eq!(repo.undo_commit(&root).unwrap(), "root"); let info = repo.branch_info().unwrap(); assert_eq!(info.head.as_deref(), Some("main")); assert_eq!(info.head_commit, None); let st = repo.status().unwrap(); assert_eq!(status_of(&st, "a.txt").unwrap().staged, Some(ChangeKind::Added)); assert_eq!(status_of(&st, "dir/b.txt").unwrap().staged, Some(ChangeKind::Added)); // Committing again works from the unborn branch. repo.commit("root again", false, false).unwrap(); assert_eq!(fx.subjects(), vec!["root again"]); } #[test] fn undo_initial_commit_on_detached_head_is_refused() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let root = fx.commit_all("root"); fx.git(&["checkout", "-q", "--detach"]); let err = fx.repo().undo_commit(&root).unwrap_err(); assert!(err.message.contains("detached"), "{}", err.message); assert_eq!(fx.head(), root, "HEAD must survive"); } #[test] fn undo_merge_commit_is_refused() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.commit_all("base"); fx.git(&["checkout", "-q", "-b", "topic"]); fx.write("t.txt", "t\n"); fx.commit_all("topic"); fx.git(&["checkout", "-q", "main"]); fx.write("m.txt", "m\n"); fx.commit_all("main"); fx.git(&["merge", "-q", "--no-edit", "topic"]); let merge = fx.head(); let err = fx.repo().undo_commit(&merge).unwrap_err(); assert!(err.message.contains("merge commit"), "{}", err.message); assert_eq!(fx.head(), merge); } #[test] fn undo_commit_on_detached_head() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let base = fx.commit_all("base"); fx.git(&["checkout", "-q", "--detach"]); fx.write("a.txt", "2\n"); let top = fx.commit_all("detached work"); let repo = fx.repo(); assert_eq!(repo.branch_info().unwrap().head, None); repo.undo_commit(&top).unwrap(); assert_eq!(fx.head(), base); assert_eq!(repo.branch_info().unwrap().head, None, "still detached"); assert_eq!(fx.git(&["rev-parse", "main"]), base, "branch untouched"); } // --------------------------------------------------------------------------- // Interactive rebase /// main: c1..c4, each touching its own file. fn linear() -> (Fixture, Vec) { let fx = Fixture::new(); let mut ids = Vec::new(); for i in 1..=4 { fx.write(&format!("f{i}.txt"), &format!("{i}\n")); ids.push(fx.commit_all(&format!("c{i}"))); } (fx, ids) } #[test] fn interactive_rebase_reorder_squash_fixup_reword_drop() { let (fx, ids) = linear(); let repo = fx.repo(); let (cancel, out) = quiet(); // Rebase c2..c4 onto c1: c4 first (reworded), then c2 with c3 squashed into it. repo.interactive_rebase( Some(&ids[0]), &[ entry(&ids[3], RebaseAction::Reword, Some("c4 reworded\n\nnew body")), entry(&ids[1], RebaseAction::Pick, None), entry(&ids[2], RebaseAction::Squash, Some("c2+c3")), ], &cancel, &out, ) .unwrap(); assert_eq!(fx.subjects(), vec!["c2+c3", "c4 reworded", "c1"]); assert_eq!(fx.git(&["log", "-1", "--format=%B", "HEAD~1"]), "c4 reworded\n\nnew body"); assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "f2.txt\nf3.txt"); assert_eq!(repo.branch_info().unwrap().operation, None); assert!(!fx.path(".git/openidea-rebase").exists(), "plan files are removed once the rebase finished"); // Fixup keeps the first message; drop removes the commit and its file. let ids: Vec = fx.git(&["rev-list", "--reverse", "HEAD"]).lines().map(str::to_string).collect(); repo.interactive_rebase( Some(&ids[0]), &[entry(&ids[1], RebaseAction::Drop, None), entry(&ids[2], RebaseAction::Pick, None)], &cancel, &out, ) .unwrap(); assert_eq!(fx.subjects(), vec!["c2+c3", "c1"]); assert!(!fx.exists("f4.txt")); let ids: Vec = fx.git(&["rev-list", "--reverse", "HEAD"]).lines().map(str::to_string).collect(); repo.interactive_rebase( None, &[entry(&ids[0], RebaseAction::Pick, None), entry(&ids[1], RebaseAction::Fixup, None)], &cancel, &out, ) .unwrap(); assert_eq!(fx.subjects(), vec!["c1"]); assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "f1.txt\nf2.txt\nf3.txt"); } #[test] fn interactive_rebase_reword_keeps_lines_starting_with_a_hash() { let (fx, ids) = linear(); let (cancel, out) = quiet(); let message = "Subject\n\n#body starting with a hash"; fx.repo() .interactive_rebase(Some(&ids[2]), &[entry(&ids[3], RebaseAction::Reword, Some(message))], &cancel, &out) .unwrap(); assert_eq!(fx.git(&["log", "-1", "--format=%B"]), message); } #[test] fn interactive_rebase_squash_without_message_combines_messages() { let (fx, ids) = linear(); let (cancel, out) = quiet(); fx.repo() .interactive_rebase( Some(&ids[1]), &[entry(&ids[2], RebaseAction::Pick, None), entry(&ids[3], RebaseAction::Squash, None)], &cancel, &out, ) .unwrap(); let msg = fx.git(&["log", "-1", "--format=%B"]); assert!(msg.contains("c3") && msg.contains("c4"), "{msg}"); assert!(!msg.contains('#'), "comment lines must be stripped: {msg}"); } #[test] fn interactive_rebase_keeps_local_changes() { let (fx, ids) = linear(); fx.write("f1.txt", "dirty\n"); fx.write("staged.txt", "s\n"); fx.git(&["add", "staged.txt"]); let (cancel, out) = quiet(); fx.repo() .interactive_rebase( Some(&ids[1]), &[entry(&ids[3], RebaseAction::Pick, None), entry(&ids[2], RebaseAction::Reword, Some("c3!"))], &cancel, &out, ) .unwrap(); assert_eq!(fx.subjects(), vec!["c3!", "c4", "c2", "c1"]); assert_eq!(fx.read("f1.txt"), "dirty\n"); assert!(fx.exists("staged.txt")); assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "f3.txt", "staged change not committed"); } #[test] fn interactive_rebase_validates_the_plan() { let (fx, ids) = linear(); let repo = fx.repo(); let (cancel, out) = quiet(); let before = fx.head(); // Missing commit. let err = repo.interactive_rebase(Some(&ids[1]), &[entry(&ids[2], RebaseAction::Pick, None)], &cancel, &out).unwrap_err(); assert!(err.message.contains("does not match"), "{}", err.message); // Squash as first line. let err = repo .interactive_rebase( Some(&ids[1]), &[entry(&ids[2], RebaseAction::Squash, None), entry(&ids[3], RebaseAction::Pick, None)], &cancel, &out, ) .unwrap_err(); assert!(err.message.contains("first commit"), "{}", err.message); assert_eq!(fx.head(), before); assert_eq!(repo.branch_info().unwrap().operation, None); } #[test] fn interactive_rebase_conflict_stops_then_continues_with_reword() { let fx = Fixture::new(); fx.write("f.txt", "0\n"); let base = fx.commit_all("base"); fx.write("f.txt", "1\n"); let one = fx.commit_all("one"); fx.write("f.txt", "2\n"); let two = fx.commit_all("two"); let repo = fx.repo(); let (cancel, out) = quiet(); // Swapping two commits that edit the same line conflicts. let err = repo .interactive_rebase( Some(&base), &[entry(&two, RebaseAction::Reword, Some("two first")), entry(&one, RebaseAction::Pick, None)], &cancel, &out, ) .unwrap_err(); assert!(!err.message.is_empty()); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); let st = repo.status().unwrap(); assert_eq!(status_of(&st, "f.txt").unwrap().unstaged, Some(ChangeKind::Conflicted)); // Resolve and continue: the reword exec runs after the resolved pick. fx.write("f.txt", "2\n"); repo.stage(&[fx.path("f.txt")]).unwrap(); let result = repo.continue_operation(&cancel, &out); // The second pick conflicts as well. if result.is_err() { fx.write("f.txt", "1\n"); repo.stage(&[fx.path("f.txt")]).unwrap(); repo.continue_operation(&cancel, &out).unwrap(); } assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.subjects(), vec!["one", "two first", "base"]); } /// `main` and `feature` both change `c.txt`: rebasing feature onto main stops on it. fn rebase_stopped_on_a_conflict() -> (Fixture, Repo) { let fx = Fixture::new(); fx.write("c.txt", "base\n"); fx.write("other.txt", "other\n"); fx.commit_all("base"); fx.git(&["switch", "-q", "-c", "feature"]); fx.write("c.txt", "feature\n"); fx.commit_all("feature change"); fx.git(&["switch", "-q", "main"]); fx.write("c.txt", "main\n"); fx.commit_all("main change"); fx.git(&["switch", "-q", "feature"]); let repo = fx.repo(); assert!(repo.rebase("main").is_err()); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); (fx, repo) } #[test] fn edits_of_a_resolved_file_go_with_it_when_the_rebase_continues() { let (fx, repo) = rebase_stopped_on_a_conflict(); fx.write("c.txt", "resolved\n"); repo.stage(&[fx.path("c.txt")]).unwrap(); fx.write("c.txt", "resolved\nmore\n"); let (cancel, out) = quiet(); repo.continue_operation(&cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.git(&["show", "HEAD:c.txt"]), "resolved\nmore"); } #[test] fn unstaged_changes_of_other_files_stop_the_rebase_until_staged() { let (fx, repo) = rebase_stopped_on_a_conflict(); fx.write("c.txt", "resolved\n"); repo.stage(&[fx.path("c.txt")]).unwrap(); fx.write("other.txt", "changed while stopped\n"); let (cancel, out) = quiet(); let err = repo.continue_operation(&cancel, &out).unwrap_err(); assert_eq!(err.message, UNSTAGED_CHANGES_PREVENT_REBASE); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); repo.stage_and_continue(&cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.git(&["show", "HEAD:other.txt"]), "changed while stopped"); } #[test] fn stage_and_retry_after_editing_a_commit() { let (fx, ids) = linear(); let repo = fx.repo(); let (cancel, out) = quiet(); let plan = [entry(&ids[2], RebaseAction::Edit, None), entry(&ids[3], RebaseAction::Pick, None)]; repo.interactive_rebase(Some(&ids[1]), &plan, &cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); fx.write("f1.txt", "edited\n"); std::fs::remove_file(fx.path("f2.txt")).unwrap(); assert!(repo.continue_operation(&cancel, &out).is_err()); repo.stage_and_continue(&cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.git(&["status", "--porcelain"]), ""); assert!(!fx.exists("f2.txt")); } #[test] fn rebase_and_merge_onto_a_branch_named_like_a_tag_use_the_branch() { let fx = Fixture::new(); fx.write("a.txt", "a\n"); fx.commit_all("base"); fx.git(&["tag", "main"]); fx.write("m.txt", "m\n"); let tip = fx.commit_all("main moved on"); fx.git(&["switch", "-q", "-c", "feature", "HEAD~1"]); fx.write("f.txt", "f\n"); fx.commit_all("feature"); let repo = fx.repo(); repo.rebase("refs/heads/main").unwrap(); assert_eq!(fx.git(&["rev-parse", "HEAD~1"]), tip); fx.git(&["reset", "-q", "--hard", "HEAD~1"]); fx.git(&["switch", "-q", "-c", "other", "refs/heads/main~1"]); repo.merge("refs/heads/main").unwrap(); assert_eq!(fx.head(), tip, "fast-forwarded to the branch, not the tag"); } #[test] fn interactive_rebase_edit_stops_for_amend() { let (fx, ids) = linear(); let repo = fx.repo(); let (cancel, out) = quiet(); repo.interactive_rebase( Some(&ids[1]), &[entry(&ids[2], RebaseAction::Edit, None), entry(&ids[3], RebaseAction::Pick, None)], &cancel, &out, ) .unwrap(); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); fx.write("f3.txt", "edited\n"); repo.stage(&[fx.path("f3.txt")]).unwrap(); repo.commit("", true, false).unwrap(); repo.continue_operation(&cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.git(&["show", "HEAD~1:f3.txt"]), "edited"); } #[test] fn interactive_rebase_refuses_merges_in_range() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let base = fx.commit_all("base"); fx.git(&["checkout", "-q", "-b", "topic"]); fx.write("t.txt", "t\n"); let t = fx.commit_all("topic"); fx.git(&["checkout", "-q", "main"]); fx.write("m.txt", "m\n"); let m = fx.commit_all("main"); fx.git(&["merge", "-q", "--no-edit", "topic"]); let merge = fx.head(); let (cancel, out) = quiet(); let err = fx .repo() .interactive_rebase( Some(&base), &[ entry(&m, RebaseAction::Pick, None), entry(&t, RebaseAction::Pick, None), entry(&merge, RebaseAction::Pick, None), ], &cancel, &out, ) .unwrap_err(); assert!(err.message.contains("merge"), "{}", err.message); } // --------------------------------------------------------------------------- // Push / fetch / update #[test] fn push_rejected_non_fast_forward_then_update_and_push() { let (fx, bare) = Fixture::with_origin(); fx.push_from_other(&bare, "remote.txt", "r\n"); fx.write("local.txt", "l\n"); fx.commit_all("local"); let repo = fx.repo(); let (cancel, out) = quiet(); let err = repo.push(false, false, &cancel, &out).unwrap_err(); assert!( err.message.contains("rejected") || err.message.contains("fetch first"), "push error must explain the rejection: {}", err.message ); assert!(!err.message.contains("Writing objects"), "progress noise in error: {}", err.message); // Update (merge) with a user config of pull.ff=only still merges. repo.pull(false, &cancel, &out).unwrap(); assert!(fx.exists("remote.txt")); assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "1"); repo.push(false, false, &cancel, &out).unwrap(); let info = repo.branch_info().unwrap(); assert_eq!((info.ahead, info.behind), (0, 0)); } #[test] fn update_with_rebase_and_local_changes() { let (fx, bare) = Fixture::with_origin(); fx.push_from_other(&bare, "a.txt", "remote\n"); fx.write("local.txt", "l\n"); fx.commit_all("local"); // Uncommitted edits: a staged new file and an unstaged edit of a file the update does not touch. fx.write("wip.txt", "w\n"); fx.git(&["add", "wip.txt"]); fx.write("local.txt", "l2\n"); let repo = fx.repo(); let (cancel, out) = quiet(); repo.pull(true, &cancel, &out).unwrap(); assert_eq!(fx.read("a.txt"), "remote\n"); assert_eq!(fx.read("local.txt"), "l2\n"); assert!(fx.exists("wip.txt")); assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "0", "rebased, not merged"); let info = repo.branch_info().unwrap(); assert_eq!((info.ahead, info.behind), (1, 0)); assert_eq!(info.operation, None); } #[test] fn update_merge_with_local_changes_to_the_same_file() { let (fx, bare) = Fixture::with_origin(); fx.push_from_other(&bare, "a.txt", "one\nremote tail\n"); fx.write("local.txt", "l\n"); fx.commit_all("local"); // An uncommitted edit of a file the update changes too (non-overlapping lines). fx.write("a.txt", "uncommitted\none\n"); let repo = fx.repo(); let (cancel, out) = quiet(); repo.pull(false, &cancel, &out).unwrap(); assert_eq!(fx.read("a.txt"), "uncommitted\none\nremote tail\n"); assert_eq!(repo.branch_info().unwrap().behind, 0); assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "1"); } #[test] fn update_with_rebase_keeps_a_file_added_then_deleted_added_to_the_index() { let (fx, bare) = Fixture::with_origin(); fx.push_from_other(&bare, "remote.txt", "r\n"); fx.write("local.txt", "l\n"); fx.commit_all("local"); fx.write("ad.txt", "x\n"); fx.git(&["add", "ad.txt"]); std::fs::remove_file(fx.path("ad.txt")).unwrap(); let (cancel, out) = quiet(); fx.repo().pull(true, &cancel, &out).unwrap(); assert!(fx.exists("remote.txt")); assert!(fx.git(&["status", "--porcelain", "--", "ad.txt"]).starts_with('A'), "still added"); } #[test] fn reset_to_the_remote_branch_drops_local_commits_and_keeps_local_changes() { let (fx, bare) = Fixture::with_origin(); for i in 0..3 { fx.push_from_other(&bare, "bro.txt", &format!("bro {i}\n")); } fx.write("local1.txt", "1\n"); fx.commit_all("local commit"); fx.write("untracked.txt", "u\n"); fx.write("a.txt", "edited\n"); fx.git(&["add", "a.txt"]); let (cancel, out) = quiet(); fx.repo().reset_to_upstream(&cancel, &out).unwrap(); assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); assert!(!fx.exists("local1.txt"), "files of local commits are gone"); assert!(fx.exists("untracked.txt")); assert_eq!(fx.git(&["status", "--porcelain", "--", "a.txt"]), "M a.txt", "staged changes stay staged"); assert_eq!(fx.git(&["stash", "list"]), ""); } #[test] fn reset_to_the_remote_branch_without_new_remote_commits_drops_local_ones() { let (fx, _bare) = Fixture::with_origin(); let before = fx.head(); fx.write("local1.txt", "1\n"); fx.commit_all("local commit"); let (cancel, out) = quiet(); fx.repo().reset_to_upstream(&cancel, &out).unwrap(); assert_eq!(fx.head(), before); } #[test] fn reset_to_the_remote_branch_leaves_conflicting_local_changes_to_resolve() { let (fx, bare) = Fixture::with_origin(); fx.push_from_other(&bare, "bro.txt", "bro\n"); fx.write("bro.txt", "local content\n"); fx.git(&["add", "bro.txt"]); let (cancel, out) = quiet(); assert!(fx.repo().reset_to_upstream(&cancel, &out).is_err()); assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); assert!(fx.read("bro.txt").contains("<<<<<<<"), "the clash is left to resolve"); assert_eq!(fx.git(&["stash", "list"]).lines().count(), 1, "a copy stays in the stash"); } #[test] fn reset_to_the_remote_branch_needs_a_tracked_branch_that_exists() { let (fx, bare) = Fixture::with_origin(); let (cancel, out) = quiet(); fx.git(&["branch", "--unset-upstream"]); let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err(); assert!(err.message.contains("no tracked branch"), "{}", err.message); fx.git(&["push", "-q", "-u", "origin", "main:feature"]); fx.git(&["switch", "-q", "-c", "feature", "--track", "origin/feature"]); git_in(&bare, &["branch", "-D", "feature"]); fx.git(&["fetch", "-q", "--prune"]); let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err(); assert!(err.message.contains("no tracked branch"), "{}", err.message); } #[cfg(unix)] #[test] fn push_can_skip_a_failing_pre_push_hook() { use std::os::unix::fs::PermissionsExt; let (fx, _bare) = Fixture::with_origin(); let repo = fx.repo(); assert!(!repo.has_hook("pre-push").unwrap()); let hook = fx.path(".git/hooks/pre-push"); std::fs::create_dir_all(hook.parent().unwrap()).unwrap(); std::fs::write(&hook, "#!/bin/sh\necho refused >&2\nexit 1\n").unwrap(); std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(repo.has_hook("pre-push").unwrap()); fx.write("local.txt", "l\n"); fx.commit_all("local"); let (cancel, out) = quiet(); assert!(repo.push(false, false, &cancel, &out).is_err(), "the hook refuses the push"); let options = PushOptions { no_verify: true, ..Default::default() }; repo.push_with(options, &cancel, &out).unwrap(); assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); } #[test] fn force_push_without_lease_overwrites_a_remote_that_moved() { let (fx, bare) = Fixture::with_origin(); fx.write("local.txt", "l\n"); fx.commit_all("local"); fx.push_from_other(&bare, "remote.txt", "r\n"); fx.git(&["fetch", "-q"]); fx.push_from_other(&bare, "remote2.txt", "r\n"); let repo = fx.repo(); let (cancel, out) = quiet(); let lease = PushOptions { force_with_lease: true, ..Default::default() }; let err = repo.push_with(lease, &cancel, &out).unwrap_err(); assert!(err.message.contains("stale info"), "{}", err.message); repo.push_with(PushOptions { force: true, ..Default::default() }, &cancel, &out).unwrap(); assert_eq!(git_in(&bare, &["rev-parse", "main"]), fx.head()); } #[test] fn push_to_another_branch_can_make_it_the_upstream() { let (fx, _bare) = Fixture::with_origin(); fx.write("local.txt", "l\n"); fx.commit_all("local"); let repo = fx.repo(); let (cancel, out) = quiet(); let to = |target, set_upstream| PushBranchOptions { target: Some(target), set_upstream, ..Default::default() }; repo.push_branch_to("main", to("feature", false), &cancel, &out).unwrap(); assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/main", "upstream kept"); repo.push_branch_to("main", to("feature-1", true), &cancel, &out).unwrap(); assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/feature-1"); } #[test] fn tags_are_pushed_one_at_a_time_deleted_restored_and_deleted_on_the_remote() { let (fx, bare) = Fixture::with_origin(); fx.git(&["tag", "-a", "v1", "-m", "release one"]); fx.git(&["tag", "v2"]); let repo = fx.repo(); let (cancel, out) = quiet(); repo.push_tag("v1", "origin", &cancel, &out).unwrap(); assert_eq!(git_in(&bare, &["tag"]), "v1", "only the chosen tag goes"); repo.push_tag("v1", "origin", &cancel, &out).unwrap(); let deleted = repo.delete_tag("v1").unwrap(); assert_eq!(fx.git(&["tag"]), "v2"); repo.restore_tag(&deleted).unwrap(); assert_eq!(fx.git(&["cat-file", "-t", "v1"]), "tag", "annotated, as it was"); assert!(fx.git(&["tag", "-n1", "v1"]).contains("release one")); repo.delete_tag("v1").unwrap(); repo.delete_remote_tag("v1", "origin", &cancel, &out).unwrap(); assert_eq!(git_in(&bare, &["tag"]), ""); assert!(repo.delete_tag("missing").is_err()); } #[test] fn fetch_prunes_deleted_remote_branches() { let (fx, bare) = Fixture::with_origin(); fx.git(&["push", "-q", "origin", "main:gone"]); fx.git(&["fetch", "-q"]); let repo = fx.repo(); assert!(repo.branches().unwrap().iter().any(|b| b.name == "origin/gone")); git_in(&bare, &["branch", "-D", "gone"]); let (cancel, out) = quiet(); repo.fetch(&cancel, &out).unwrap(); assert!(!repo.branches().unwrap().iter().any(|b| b.name == "origin/gone")); } #[test] fn push_new_branch_sets_upstream_and_detached_head_cannot() { let (fx, _bare) = Fixture::with_origin(); let repo = fx.repo(); let (cancel, out) = quiet(); repo.create_branch("feature", None, true).unwrap(); fx.write("f.txt", "f\n"); fx.commit_all("feature"); repo.push(false, true, &cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().upstream.as_deref(), Some("origin/feature")); fx.git(&["checkout", "-q", "--detach"]); let err = repo.push(false, true, &cancel, &out).unwrap_err(); assert!(err.message.contains("detached"), "{}", err.message); } #[test] fn branch_tips_are_reported() { let (fx, _bare) = Fixture::with_origin(); let head = fx.head(); let branches = fx.repo().branches().unwrap(); assert!(branches.iter().all(|b| b.commit == head), "{branches:?}"); } // --------------------------------------------------------------------------- // Rollback #[test] fn rollback_newly_added_file() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.commit_all("base"); fx.write("added.txt", "new\n"); fx.git(&["add", "added.txt"]); let repo = fx.repo(); // Rollback without "delete local copies": unstage only, the file stays (unversioned). repo.unstage(&[fx.path("added.txt")]).unwrap(); let st = repo.status().unwrap(); assert_eq!(status_of(&st, "added.txt").unwrap().unstaged, Some(ChangeKind::Untracked)); assert!(fx.exists("added.txt")); // With deletion. fx.git(&["add", "added.txt"]); repo.unstage(&[fx.path("added.txt")]).unwrap(); repo.discard(&[fx.path("added.txt")]).unwrap(); assert!(!fx.exists("added.txt")); assert!(repo.status().unwrap().is_empty()); } #[test] fn rollback_added_file_in_repository_without_commits() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.git(&["add", "a.txt"]); let repo = fx.repo(); repo.unstage(&[fx.path("a.txt")]).unwrap(); assert_eq!(status_of(&repo.status().unwrap(), "a.txt").unwrap().unstaged, Some(ChangeKind::Untracked)); } #[test] fn rollback_staged_rename_restores_the_old_file() { let fx = Fixture::new(); fx.write("old.txt", "content\nline 2\nline 3\n"); fx.commit_all("base"); fx.git(&["mv", "old.txt", "new.txt"]); let repo = fx.repo(); let st = repo.status().unwrap(); let renamed = status_of(&st, "new.txt").unwrap(); assert_eq!(renamed.staged, Some(ChangeKind::Renamed)); assert_eq!(renamed.renamed_from.as_deref(), Some("old.txt")); // The UI rolls back both sides of a rename. repo.unstage(&[fx.path("new.txt"), fx.path("old.txt")]).unwrap(); repo.discard(&[fx.path("new.txt"), fx.path("old.txt")]).unwrap(); assert_eq!(fx.read("old.txt"), "content\nline 2\nline 3\n"); assert!(!fx.exists("new.txt")); assert!(repo.status().unwrap().is_empty(), "{:?}", repo.status().unwrap()); } #[test] fn rollback_conflicted_file_restores_head() { let fx = Fixture::new(); fx.write("f.txt", "base\n"); fx.commit_all("base"); fx.git(&["checkout", "-q", "-b", "topic"]); fx.write("f.txt", "topic\n"); fx.write("theirs-only.txt", "t\n"); fx.commit_all("topic"); fx.git(&["checkout", "-q", "main"]); fx.write("f.txt", "main\n"); fx.commit_all("main"); let repo = fx.repo(); assert!(repo.merge("topic").is_err()); let st = repo.status().unwrap(); assert_eq!(status_of(&st, "f.txt").unwrap().unstaged, Some(ChangeKind::Conflicted)); repo.discard(&[fx.path("f.txt")]).unwrap(); assert_eq!(fx.read("f.txt"), "main\n"); let st = repo.status().unwrap(); assert!(status_of(&st, "f.txt").is_none(), "{st:?}"); // Still merging (theirs-only.txt staged); the merge can be aborted. assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("merge")); } #[test] fn rollback_hunk_via_partial_patch_leaves_other_hunks() { let fx = Fixture::new(); let original: String = (1..=30).map(|i| format!("line {i}\n")).collect(); fx.write("f.txt", &original); fx.commit_all("base"); let edited = original.replace("line 2\n", "line two\n").replace("line 28\n", "line twenty-eight\n"); fx.write("f.txt", &edited); // Stage only the first hunk, then discard the unstaged rest: the first hunk survives. let hunks = diff_hunks(&original, &edited); assert_eq!(hunks.len(), 2); let patch = build_partial_patch("f.txt", &original, &edited, &[0]); let repo = fx.repo(); repo.stage_patch(&patch, &|_| Ok(encoding_rs::UTF_8)).unwrap(); repo.discard(&[fx.path("f.txt")]).unwrap(); assert_eq!(fx.read("f.txt"), original.replace("line 2\n", "line two\n")); } // --------------------------------------------------------------------------- // Branches #[test] fn branch_create_checkout_merge_rebase_delete() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let base = fx.commit_all("base"); let repo = fx.repo(); // Create without checkout, from an explicit start point. repo.create_branch("topic", Some(&base), false).unwrap(); assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main")); assert!(repo.create_branch("bad name", None, false).is_err()); assert!(repo.create_branch("topic", None, false).is_err(), "duplicate"); assert!(repo.create_branch("-x", None, false).is_err()); repo.checkout("topic", false).unwrap(); fx.write("t.txt", "t\n"); fx.commit_all("topic work"); repo.checkout("main", false).unwrap(); fx.write("m.txt", "m\n"); fx.commit_all("main work"); // Merge with a dirty working tree: local changes are kept. fx.write("a.txt", "dirty\n"); repo.merge("topic").unwrap(); assert!(fx.exists("t.txt")); assert_eq!(fx.read("a.txt"), "dirty\n"); // Rebase onto with a dirty working tree. repo.checkout("topic", false).unwrap(); repo.rebase("main~1").unwrap(); assert_eq!(fx.read("a.txt"), "dirty\n"); assert!(repo.rebase("--exec=true").is_err(), "options must not be injected"); // -d refuses an unmerged branch, -D forces. repo.checkout("main", false).unwrap(); repo.create_branch("unmerged", None, true).unwrap(); fx.git(&["stash", "-q"]); fx.write("u.txt", "u\n"); fx.commit_all("unmerged work"); repo.checkout("main", false).unwrap(); let err = repo.delete_branch("unmerged", false).unwrap_err(); assert!(err.message.contains("not fully merged"), "{}", err.message); repo.delete_branch("unmerged", true).unwrap(); assert!(!repo.branches().unwrap().iter().any(|b| b.name == "unmerged")); } #[test] fn new_branch_from_remote_branch_tracks_it() { let (fx, _bare) = Fixture::with_origin(); fx.git(&["push", "-q", "origin", "main:release"]); fx.git(&["fetch", "-q"]); let repo = fx.repo(); repo.create_branch("my-release", Some("origin/release"), true).unwrap(); let info = repo.branch_info().unwrap(); assert_eq!(info.head.as_deref(), Some("my-release")); assert_eq!(info.upstream.as_deref(), Some("origin/release")); } #[test] fn rebase_onto_conflict_leaves_rebase_in_progress() { let fx = Fixture::new(); fx.write("f.txt", "base\n"); fx.commit_all("base"); fx.git(&["checkout", "-q", "-b", "topic"]); fx.write("f.txt", "topic\n"); fx.commit_all("topic"); fx.git(&["checkout", "-q", "main"]); fx.write("f.txt", "main\n"); fx.commit_all("main"); fx.git(&["checkout", "-q", "topic"]); let repo = fx.repo(); let err = repo.rebase("main").unwrap_err(); assert!(err.message.contains("CONFLICT") || err.message.contains("conflict"), "{}", err.message); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); let (cancel, out) = quiet(); repo.abort_operation(&cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.read("f.txt"), "topic\n"); } #[test] fn detached_head_checkout_and_commit() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); let base = fx.commit_all("base"); fx.write("a.txt", "2\n"); fx.commit_all("second"); let repo = fx.repo(); repo.checkout(&base, false).unwrap(); let info = repo.branch_info().unwrap(); assert_eq!(info.head, None); assert_eq!(info.head_commit.as_deref(), Some(base.as_str())); fx.write("a.txt", "detached\n"); repo.stage(&[fx.path("a.txt")]).unwrap(); let id = repo.commit("on detached head", false, false).unwrap(); assert_eq!(fx.head(), id); // A branch can be created from the detached commit. repo.create_branch("rescue", None, true).unwrap(); assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("rescue")); } // --------------------------------------------------------------------------- // Add to VCS, annotate, diffs, history #[test] fn add_to_vcs_annotate_and_history() { let fx = Fixture::new(); fx.write("a.txt", "1\n2\n"); let first = fx.commit_all("first"); fx.write("a.txt", "1\nchanged\n"); let second = fx.commit_all("second"); fx.write("new/b.txt", "b\n"); let repo = fx.repo(); let st = repo.status().unwrap(); assert_eq!(status_of(&st, "new/b.txt").unwrap().unstaged, Some(ChangeKind::Untracked)); repo.stage(&[fx.path("new/b.txt")]).unwrap(); assert_eq!(status_of(&repo.status().unwrap(), "new/b.txt").unwrap().staged, Some(ChangeKind::Added)); let blame = repo.blame(&fx.path("a.txt"), Some("1\nchanged\nunsaved\n"), None, encoding_rs::UTF_8).unwrap(); assert_eq!(blame.len(), 3); assert_eq!(blame[0].commit, first); assert_eq!(blame[1].commit, second); assert_eq!(blame[2].author, "Not Committed Yet"); let history = repo.log(&LogQuery { path: Some(fx.path("a.txt").into()), ..Default::default() }).unwrap(); assert_eq!(history.iter().map(|c| c.id.as_str()).collect::>(), vec![second.as_str(), first.as_str()]); assert_eq!(repo.file_at(&fx.path("a.txt"), Some(&first), encoding_rs::UTF_8).unwrap().as_deref(), Some("1\n2\n")); let diff = repo.diff_text(&fx.path("new/b.txt"), true, encoding_rs::UTF_8).unwrap(); assert!(diff.contains("+b"), "{diff}"); } #[test] fn compare_with_branch_lists_files() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.commit_all("base"); fx.git(&["checkout", "-q", "-b", "other"]); fx.write("a.txt", "other\n"); fx.write("o.txt", "o\n"); fx.commit_all("other"); fx.git(&["checkout", "-q", "main"]); let files = fx.repo().diff_with_worktree("other").unwrap(); let names: Vec<(&str, ChangeKind)> = files.iter().map(|f| (f.path.as_str(), f.kind)).collect(); // Relative to `other`, the working tree lacks o.txt and has a different a.txt. assert_eq!(names, vec![("a.txt", ChangeKind::Modified), ("o.txt", ChangeKind::Deleted)]); } // --------------------------------------------------------------------------- // Shelve, cherry-pick, revert #[test] fn shelve_and_unshelve_new_and_modified_files() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.commit_all("base"); fx.write("a.txt", "changed\n"); fx.write("n.txt", "new\n"); fx.git(&["add", "n.txt"]); let shelf = fx.tmp.path().join("shelf"); let repo = fx.repo(); let list = repo.shelve(&shelf, "wip", &[fx.path("a.txt"), fx.path("n.txt")]).unwrap(); assert_eq!(list.files.len(), 2); assert_eq!(fx.read("a.txt"), "1\n"); assert!(!fx.exists("n.txt")); assert!(repo.status().unwrap().is_empty()); let outcome = repo.unshelve(&shelf, &list.id, true).unwrap(); assert!(outcome.conflicts.is_empty()); assert_eq!(fx.read("a.txt"), "changed\n"); assert_eq!(fx.read("n.txt"), "new\n"); assert!(shelf::list(&shelf).unwrap().is_empty()); } #[test] fn cherry_pick_and_revert_refuse_merge_commits() { let fx = Fixture::new(); fx.write("a.txt", "1\n"); fx.commit_all("base"); fx.git(&["checkout", "-q", "-b", "topic"]); fx.write("t.txt", "t\n"); fx.commit_all("topic"); fx.git(&["checkout", "-q", "main"]); fx.write("m.txt", "m\n"); fx.commit_all("main"); fx.git(&["merge", "-q", "--no-edit", "topic"]); let merge = fx.head(); let repo = fx.repo(); let (cancel, out) = quiet(); let err = repo.revert(std::slice::from_ref(&merge), &cancel, &out).unwrap_err(); assert!(err.message.contains("merge commit"), "{}", err.message); let err = repo.cherry_pick(std::slice::from_ref(&merge), &cancel, &out).unwrap_err(); assert!(err.message.contains("merge commit"), "{}", err.message); assert_eq!(repo.branch_info().unwrap().operation, None); } #[test] fn remote_progress_is_streamed() { let (fx, _bare) = Fixture::with_origin(); fx.write("x.txt", "x\n"); fx.commit_all("x"); let lines = Arc::new(Mutex::new(Vec::::new())); let sink = lines.clone(); fx.repo().push(false, false, &CancellationToken::new(), &move |l| sink.lock().unwrap().push(l)).unwrap(); assert!(!lines.lock().unwrap().is_empty()); } #[test] fn interactive_rebase_restores_local_changes_staged_as_they_were() { let (fx, ids) = linear(); let repo = fx.repo(); let (cancel, out) = quiet(); fx.write("f1.txt", "staged\n"); fx.git(&["add", "f1.txt"]); fx.write("f2.txt", "unstaged\n"); repo.interactive_rebase( Some(&ids[1]), &[entry(&ids[2], RebaseAction::Drop, None), entry(&ids[3], RebaseAction::Pick, None)], &cancel, &out, ) .unwrap(); assert_eq!(fx.subjects(), vec!["c4", "c2", "c1"]); assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt"); assert!(fx.git(&["stash", "list"]).is_empty()); assert!(!fx.path(".git/openidea-rebase").exists()); } #[test] fn local_changes_come_back_once_a_stopped_rebase_is_continued() { let (fx, ids) = linear(); let repo = fx.repo(); let (cancel, out) = quiet(); // c4 rewrites f3.txt so that dropping c3 conflicts. fx.write("f3.txt", "3\nmore\n"); fx.commit_all("c5"); let c5 = fx.git(&["rev-parse", "HEAD"]); fx.write("f1.txt", "staged\n"); fx.git(&["add", "f1.txt"]); let result = repo.interactive_rebase( Some(&ids[1]), &[ entry(&ids[2], RebaseAction::Drop, None), entry(&ids[3], RebaseAction::Pick, None), entry(&c5, RebaseAction::Pick, None), ], &cancel, &out, ); assert!(result.is_err()); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the rebase is stopped"); fx.write("f3.txt", "3\nmore\n"); repo.stage(&[fx.path("f3.txt")]).unwrap(); repo.continue_operation(&cancel, &out).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.read("f1.txt"), "staged\n"); assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); assert!(fx.git(&["stash", "list"]).is_empty()); } #[test] fn merge_brings_staged_changes_back_staged() { let (fx, _ids) = linear(); let repo = fx.repo(); fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]); fx.write("side.txt", "side\n"); fx.git(&["add", "side.txt"]); fx.git(&["commit", "-q", "-m", "side"]); fx.git(&["checkout", "-q", "main"]); fx.write("f1.txt", "staged\n"); fx.git(&["add", "f1.txt"]); fx.write("f2.txt", "unstaged\n"); repo.merge_with(&["side".to_string()], &MergeOptions::default()).unwrap(); assert!(fx.exists("side.txt")); assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt"); assert!(fx.git(&["stash", "list"]).is_empty()); } #[test] fn local_changes_come_back_after_a_conflicting_merge_is_committed() { let (fx, _ids) = linear(); let repo = fx.repo(); fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]); fx.write("f4.txt", "side\n"); fx.git(&["add", "f4.txt"]); fx.git(&["commit", "-q", "-m", "side f4"]); fx.git(&["checkout", "-q", "main"]); fx.write("f1.txt", "staged\n"); fx.git(&["add", "f1.txt"]); assert!(repo.merge_with(&["side".to_string()], &MergeOptions::default()).is_err()); assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("merge")); assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the merge is in progress"); fx.write("f4.txt", "both\n"); repo.stage(&[fx.path("f4.txt")]).unwrap(); repo.commit("Merge side", false, false).unwrap(); assert_eq!(repo.branch_info().unwrap().operation, None); assert_eq!(fx.read("f1.txt"), "staged\n"); assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); assert!(fx.git(&["stash", "list"]).is_empty()); }