Файли
openidea/crates/ide-vcs/tests/git_workflows.rs
T
Shohruh Ismoilovтаshohruh-genius 9298be8798 fix(vcs): allow explicitly overwriting a local branch (#1851)
The New Branch dialog refused any existing local name, so a branch could not be moved to a selected commit. When the name matches a local branch of the target repository, the dialog now offers an unchecked Overwrite existing branch option (git switch -C / git branch --force); the consent clears when the name changes, and Git's checkout checks keep the old tip and local changes if switching fails. New Branch from a Git Log commit also targets that commit's repository in a multi-repository project.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-07 10:36:15 +08:00

1238 рядки
47 KiB
Rust

//! 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", "[email protected]"),
("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<String> {
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<String>) {
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<String> = 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<String> = 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 branch_overwrite_preserves_the_ref_if_checkout_fails() {
let fx = Fixture::new();
fx.write("a.txt", "base\n");
let base = fx.commit_all("base");
fx.write("a.txt", "main\n");
let tip = fx.commit_all("main");
let repo = fx.repo();
repo.create_branch("topic", None, false).unwrap();
repo.create_branch_with_overwrite("topic", Some(&base), false, true).unwrap();
assert_eq!(fx.git(&["rev-parse", "topic"]), base);
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
repo.create_branch_with_overwrite("topic", Some(&tip), false, true).unwrap();
fx.write("a.txt", "dirty\n");
assert!(repo.create_branch_with_overwrite("topic", Some(&base), true, true).is_err());
assert_eq!(fx.git(&["rev-parse", "topic"]), tip);
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
assert_eq!(fx.read("a.txt"), "dirty\n");
fx.write("a.txt", "main\n");
repo.create_branch_with_overwrite("topic", Some(&base), true, true).unwrap();
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic"));
assert_eq!(fx.git(&["rev-parse", "HEAD"]), base);
repo.create_branch_with_overwrite("topic", Some(&tip), true, true).unwrap();
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
}
#[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<_>>(), 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::<String>::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());
}