diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a34d125..95f8398 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ There are two crates. **`crates/core` (`gitcraft-core`)** knows nothing about the screen. - `repo.rs` reads the history, refs and commits with gitoxide, in-process, because the graph needs - millions of lookups. + millions of lookups, and what is under way: a merge, rebase, cherry-pick or revert that stopped. - `graph.rs` orders commits (`git log --date-order`) and lays out the lanes. It keeps a snapshot of the lanes every 256 rows, so it only ever builds the rows on screen. - `git.rs` runs the user's own `git` for everything else. Every change to a repository is one diff --git a/README.md b/README.md index ce1cc8b..87080cf 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ GitCraft is young. This is what it does today; [ROADMAP.md](ROADMAP.md) has what - **Remotes**: fetch, pull and push, with how far ahead and behind you are. A branch that went its own way, after an amend or a rebase, is force pushed with a lease, so nobody else's work is lost. - **Stashes**: stash, apply, pop and drop. +- **Conflicts**: when a merge, rebase, cherry-pick or revert stops at a conflict, a bar says so and + counts the conflicted files. Take a whole file from your side or theirs (also when one side + deleted it), or edit it and mark it resolved, then continue, skip the commit, or abort. - **Undo and redo**: take back a commit, an amend, a checkout, a merge, a pull, a rebase, a cherry-pick, a revert or a reset, one step at a time, and do it again. It reads git's reflog, so it works for what you did on the command line too. Undoing a commit puts its changes back in the diff --git a/ROADMAP.md b/ROADMAP.md index 9645e91..f21c860 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,11 +27,14 @@ drive it the way a user does, and keeps every benchmark as fast as before. has, force push (with lease, for a branch that went its own way), dropping a stash - [x] Undo and redo through the reflog: commits, amends, checkouts, merges, pulls, rebases, cherry-picks, reverts and resets, also those made on the command line, several steps deep +- [x] Conflicts, part 1: a bar while a merge, rebase, cherry-pick or revert is stopped, with the + number of conflicted files; continue, skip or abort; per file take ours or theirs (also when + that side deleted it) or mark it resolved ## Next -1. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result), - mark them resolved, and continue or abort a merge, rebase or cherry-pick. +1. **Conflicts, part 2**: resolve a conflicted file in a three-way view (ours, theirs, result), + conflict by conflict. 2. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the graph. 3. **Credentials**: ask for a username, password or passphrase when git needs one. diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index cd79c84..5a6682d 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use eframe::egui; use gitcraft_core::diff::{self, FileDiff, Source}; use gitcraft_core::git::Op; -use gitcraft_core::repo::{Commit, History, Repo}; +use gitcraft_core::repo::{Commit, History, Repo, Underway}; use gitcraft_core::status::{self, FileChange, Status}; use gitcraft_core::undo::{self, Undo}; use gitcraft_core::watch::Watch; @@ -107,6 +107,8 @@ pub struct Snapshot { pub stashes: Vec, /// What undo and redo would do. pub undo: Undo, + /// What stopped half way, if anything. + pub underway: Option, } impl Snapshot { @@ -116,6 +118,7 @@ impl Snapshot { history: repo.history()?, undo: undo::read(repo, &status)?, stashes: status::stashes(&repo.git)?, + underway: repo.underway(), status, }) } diff --git a/crates/app/src/details.rs b/crates/app/src/details.rs index 64f8216..557704c 100644 --- a/crates/app/src/details.rs +++ b/crates/app/src/details.rs @@ -48,7 +48,29 @@ fn discard(change: Change, path: &str) -> Confirm { Confirm { question, verb: "Discard", op: Op::Discard(vec![path.into()]) } } +/// The buttons of a conflicted file: take the whole file from one of the `sides` (name, commit, +/// whether it has the file), when git says what they are, or mark it resolved as it is now. +fn resolve(ui: &mut Ui, path: &str, sides: Option<[(&str, &str, bool); 2]>, actions: &mut Vec) { + for (side, from, has) in sides.into_iter().flatten() { + let (op, hint) = if has { + (Op::Restore { from: from.into(), paths: vec![path.into()] }, format!("Take the whole file from {from}")) + } else { + (Op::Remove(vec![path.into()]), format!("Delete the file, as {from} did")) + }; + let button = ui.small_button(side).on_hover_text(hint); + let name = format!("Take {} for {path}", side.to_lowercase()); + button.widget_info(|| WidgetInfo::labeled(Role::Button, true, &name)); + if button.clicked() { + actions.push(Action::Run(op)); + } + } + if ui.small_button("Mark resolved").clicked() { + actions.push(Action::Run(Op::Stage(vec![path.into()]))); + } +} + fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { + let underway = ws.snapshot.underway.as_ref(); let open = ws.open_file.as_ref().map(|f| &f.source); let entries = &ws.snapshot.status.entries; let unstaged: Vec<_> = entries.iter().filter_map(|e| Some((e.unstaged?, e.path.as_str()))).collect(); @@ -69,20 +91,30 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { } else { Source::Unstaged(path.into()) }; + let conflicted = matches!(change, Change::Conflicted { .. }); let clicked = file(ui, change, path, open == Some(&source), |ui| { + // A conflicted file has its buttons on a line of their own, below. + if conflicted { + return; + } if ui.small_button("Stage").clicked() { actions.push(Action::Run(Op::Stage(vec![path.into()]))); } - // Conflicts are resolved, not discarded. - if change != Change::Conflicted { - let button = ui.small_button("Discard"); - button.widget_info(|| WidgetInfo::labeled(Role::Button, true, format!("Discard {path}"))); - if button.clicked() { - actions.push(Action::Confirm(discard(change, path))); - } + let button = ui.small_button("Discard"); + button.widget_info(|| WidgetInfo::labeled(Role::Button, true, format!("Discard {path}"))); + if button.clicked() { + actions.push(Action::Confirm(discard(change, path))); } }); - if clicked { + if let Change::Conflicted { ours, theirs } = change { + let sides = underway.map(|u| [("Ours", "HEAD", ours), ("Theirs", u.theirs, theirs)]); + ui.horizontal(|ui| { + ui.add_space(16.0); + resolve(ui, path, sides, actions); + }); + } + // `git diff` shows a conflict in a form of its own, which has no view yet. + if clicked && !conflicted { actions.push(Action::Open(source)); } } diff --git a/crates/app/src/tests.rs b/crates/app/src/tests.rs index db25562..3b3ff72 100644 --- a/crates/app/src/tests.rs +++ b/crates/app/src/tests.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; use eframe::egui::{Key, Role}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use gitcraft_core::git::{Git, Op}; use tempfile::TempDir; @@ -569,3 +569,72 @@ fn deleting_a_stash_asks_first() { settle(&mut harness); assert_eq!(git.run(&["stash", "list"]).unwrap(), ""); } + +/// Makes `main` and `topic` each change file.txt their own way, with `main` checked out. +fn clash(git: &Git) { + git.run(&["branch", "topic"]).unwrap(); + commit(git, "On main"); + git.apply(&Op::Checkout("topic".into())).unwrap(); + commit(git, "On topic"); + git.apply(&Op::Checkout("main".into())).unwrap(); +} + +#[test] +fn take_their_side_of_a_conflict_and_continue_the_merge() { + let (_dir, git) = repo(); + clash(&git); + assert!(git.apply(&Op::Merge("topic".into())).is_err()); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Merge in progress"); + harness.get_by_label("1 conflicted file"); + assert!(harness.get_by_label("Continue").accesskit_node().is_disabled()); + assert!(harness.query_by_label("Skip").is_none(), "a merge has no commit to skip"); + + harness.get_by_label("Uncommitted changes").click(); + settle(&mut harness); + harness.get_by_label("Take theirs for file.txt").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), "On topic"); + harness.get_by_label("Continue").click(); + settle(&mut harness); + assert_eq!(git.run(&["log", "-1", "--format=%s"]).unwrap(), "Merge branch 'topic'\n"); + assert!(harness.query_by_label("Merge in progress").is_none()); +} + +#[test] +fn mark_a_conflict_resolved_and_continue_the_rebase() { + let (_dir, git) = repo(); + clash(&git); + git.apply(&Op::Checkout("topic".into())).unwrap(); + assert!(git.apply(&Op::Rebase("main".into())).is_err()); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Rebase in progress"); + harness.get_by_label("Skip"); + + fs::write(git.dir().join("file.txt"), "On both").unwrap(); + harness.get_by_label("Uncommitted changes").click(); + settle(&mut harness); + harness.get_by_label("Mark resolved").click(); + settle(&mut harness); + harness.get_by_label("Continue").click(); + settle(&mut harness); + assert_eq!(log(&git), "On topic\nOn main\nFirst commit\n"); + assert_eq!(read(&git, "file.txt"), "On both"); + assert!(harness.query_by_label("Rebase in progress").is_none()); +} + +#[test] +fn abort_a_cherry_pick_that_stopped_at_a_conflict() { + let (_dir, git) = repo(); + clash(&git); + assert!(git.apply(&Op::CherryPick("topic".into())).is_err()); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Cherry-pick in progress"); + assert_eq!(harness.get_all_by_label("On topic").count(), 2, "the commit in the graph, and above it"); + + harness.get_by_label("Abort").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), "On main"); + assert!(harness.query_by_label("Cherry-pick in progress").is_none()); + assert!(harness.query_by_label("Uncommitted changes").is_none()); +} diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs index 7834c46..273b2e7 100644 --- a/crates/app/src/theme.rs +++ b/crates/app/src/theme.rs @@ -41,7 +41,7 @@ pub fn change(change: Change) -> (&'static str, Color32) { Change::Renamed | Change::Copied => ("R", LANES[0]), Change::TypeChanged => ("T", LANES[1]), Change::Untracked => ("U", LANES[7]), - Change::Conflicted => ("C", LANES[9]), + Change::Conflicted { .. } => ("C", LANES[9]), } } diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index 573ac67..1817719 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use eframe::egui::{self, Button, Key, Role, Ui, WidgetInfo}; use gitcraft_core::git::Op; use gitcraft_core::repo::{RefKind, Repo}; +use gitcraft_core::status::Change; use crate::app::{Action, Confirm, Selection, Workspace}; use crate::start::Start; @@ -177,6 +178,9 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { prompt(ui.ctx(), ws, actions); confirm(ui.ctx(), ws, actions); egui::Panel::top("toolbar").show(ui, |ui| toolbar(ui, ws, actions)); + if ws.snapshot.underway.is_some() { + egui::Panel::top("underway").show(ui, |ui| underway(ui, ws, actions)); + } if let Some(error) = ws.error.clone() { egui::Panel::bottom("error").show(ui, |ui| { ui.horizontal(|ui| { @@ -251,6 +255,33 @@ fn toolbar(ui: &mut Ui, ws: &Workspace, actions: &mut Vec) { }); } +/// What stopped half way, the conflicts left, and the ways on from there. +fn underway(ui: &mut Ui, ws: &Workspace, actions: &mut Vec) { + let Some(underway) = &ws.snapshot.underway else { return }; + let entries = &ws.snapshot.status.entries; + let conflicts = entries.iter().filter(|e| matches!(e.unstaged, Some(Change::Conflicted { .. }))).count(); + let (command, idle) = (underway.command, ws.busy().is_none()); + ui.horizontal(|ui| { + let (first, rest) = command.split_at(1); + ui.strong(format!("{}{rest} in progress", first.to_uppercase())); + ui.weak(&underway.message); + if conflicts > 0 { + ui.colored_label(theme::ERROR, theme::count(conflicts, "conflicted file")); + } + let go_on = ui.add_enabled(idle && conflicts == 0, Button::new("Continue")); + if go_on.on_disabled_hover_text("Resolve every conflict first").clicked() { + actions.push(Action::Run(Op::Continue(command))); + } + // A merge has no commit of its own to leave out. + let skip = (command != "merge").then_some(("Skip", Op::Skip(command))); + for (verb, op) in skip.into_iter().chain([("Abort", Op::Abort(command))]) { + if ui.add_enabled(idle, Button::new(verb)).clicked() { + actions.push(Action::Run(op)); + } + } + }); +} + fn prompt(ctx: &egui::Context, ws: &mut Workspace, actions: &mut Vec) { let Some((prompt, name)) = &mut ws.prompt else { return }; let mut done = false; diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index f2d1a4d..a514947 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -184,6 +184,16 @@ pub enum Op { Discard(Vec), /// Delete untracked files. DeleteUntracked(Vec), + /// Put files in the index and the working tree as a commit has them, which also resolves + /// their conflicts. + Restore { + /// The commit. + from: String, + /// The files. + paths: Vec, + }, + /// Delete tracked files from the index and the working tree. + Remove(Vec), /// Apply a patch to the index, or take it out with `reverse`. ApplyToIndex { /// The patch, as built by [`crate::diff::FileDiff::patch`]. @@ -220,6 +230,12 @@ pub enum Op { CherryPick(String), /// Add a commit that undoes another. Revert(String), + /// Carry on with what stopped half way, given as its command (see [`crate::repo::Underway`]). + Continue(&'static str), + /// Leave out the commit it stopped at, and carry on. + Skip(&'static str), + /// Go back to where it started. + Abort(&'static str), /// Move the current branch. Reset { /// Where to. @@ -281,6 +297,10 @@ impl Op { Self::Unstage(paths) => (&["reset", "-q", "--"], strs(paths)), Self::Discard(paths) => (&["checkout", "-q", "--"], strs(paths)), Self::DeleteUntracked(paths) => (&["clean", "-f", "-q", "--"], strs(paths)), + Self::Restore { from, paths } => { + (&["restore", "--staged", "--worktree", "--source"], [vec![from.as_str(), "--"], strs(paths)].concat()) + } + Self::Remove(paths) => (&["rm", "-q", "--"], strs(paths)), Self::ApplyToIndex { reverse, .. } => { (&["apply", "--cached", "--recount"], [flag(*reverse, "--reverse"), vec!["-"]].concat()) } @@ -293,6 +313,9 @@ impl Op { Self::Rebase(onto) => (&["rebase"], vec![onto]), Self::CherryPick(commit) => (&["cherry-pick"], vec![commit]), Self::Revert(commit) => (&["revert", "--no-edit"], vec![commit]), + Self::Continue(command) => (&[], vec![command, "--continue"]), + Self::Skip(command) => (&[], vec![command, "--skip"]), + Self::Abort(command) => (&[], vec![command, "--abort"]), Self::Reset { to, mode } => (&["reset", "-q"], vec![mode.flag(), to]), Self::Tag { name, at } => (&["tag"], vec![name, at]), Self::DeleteTag(name) => (&["tag", "-d"], vec![name]), diff --git a/crates/core/src/repo.rs b/crates/core/src/repo.rs index 7495c4e..62993dc 100644 --- a/crates/core/src/repo.rs +++ b/crates/core/src/repo.rs @@ -81,7 +81,36 @@ impl Commit { } } +/// A merge, rebase, cherry-pick or revert that stopped half way, mostly at a conflict. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Underway { + /// The command that started it and carries on with it: `merge`, `rebase`, `cherry-pick` or + /// `revert`. + pub command: &'static str, + /// The commit whose changes come in, which git calls theirs; ours is `HEAD`. + pub theirs: &'static str, + /// The first line of the message for the commit it makes next. + pub message: String, +} + impl Repo { + /// What stopped half way, if anything. + pub fn underway(&self) -> Option { + use gix::state::InProgress; + let (command, theirs) = match self.gix.state()? { + InProgress::Merge => ("merge", "MERGE_HEAD"), + InProgress::Rebase | InProgress::RebaseInteractive => ("rebase", "REBASE_HEAD"), + InProgress::CherryPick | InProgress::CherryPickSequence => ("cherry-pick", "CHERRY_PICK_HEAD"), + // A revert takes out the changes of a commit, so what comes in is its parent. + InProgress::Revert | InProgress::RevertSequence => ("revert", "REVERT_HEAD^"), + InProgress::ApplyMailbox | InProgress::ApplyMailboxRebase | InProgress::Bisect => return None, + }; + // The message is only shown. Without one, as when a rebase stopped to edit a commit, the + // rest works the same. + let message = std::fs::read_to_string(self.gix.path().join("MERGE_MSG")).unwrap_or_default(); + Some(Underway { command, theirs, message: message.lines().next().unwrap_or_default().to_owned() }) + } + /// Opens the repository that contains `path`. pub fn open(path: impl AsRef) -> Result { let mut gix = gix::discover(path).map_err(Error::repository)?; diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index 7ab1203..56b7456 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -21,7 +21,12 @@ pub enum Change { /// Git does not track the file yet. Untracked, /// A merge left conflicts in the file. - Conflicted, + Conflicted { + /// Whether `HEAD` has the file; `false` when it deleted it. + ours: bool, + /// Whether the commit coming in has the file. + theirs: bool, + }, } impl Change { @@ -105,7 +110,13 @@ impl Status { let path = parts.get(8).ok_or_else(bad)?; entry(path, Some(fields.next().ok_or_else(bad)?), xy(parts[0])) } - "u" => entry(rest.splitn(10, ' ').nth(9).ok_or_else(bad)?, None, (None, Some(Change::Conflicted))), + "u" => { + let parts: Vec<_> = rest.splitn(10, ' ').collect(); + let path = parts.get(9).ok_or_else(bad)?; + // The modes of our and their file; `000000` when that side has none. + let change = Change::Conflicted { ours: parts[3] != "000000", theirs: parts[4] != "000000" }; + entry(path, None, (None, Some(change))) + } "?" => entry(rest, None, (None, Some(Change::Untracked))), "!" => continue, _ => return Err(bad()), @@ -197,6 +208,7 @@ mod tests { "2 R. N... 100644 100644 100644 aaa bbb R100 new name.rs", "old name.rs", "u UU N... 100644 100644 100644 100644 a b c conflict.rs", + "u DU N... 100644 000000 100644 100644 a b c deleted by us.rs", "? notes.md", "! target", "", @@ -214,7 +226,8 @@ mod tests { ("src/main.rs", None, None, Some(Change::Modified)), ("with space.txt", None, Some(Change::Added), None), ("new name.rs", Some("old name.rs"), Some(Change::Renamed), None), - ("conflict.rs", None, None, Some(Change::Conflicted)), + ("conflict.rs", None, None, Some(Change::Conflicted { ours: true, theirs: true })), + ("deleted by us.rs", None, None, Some(Change::Conflicted { ours: false, theirs: true })), ("notes.md", None, None, Some(Change::Untracked)), ] ); @@ -231,6 +244,7 @@ mod tests { fn rejects_what_it_does_not_know() { assert!(Status::parse("x something\0").is_err()); assert!(Status::parse("1 .M\0").is_err()); + assert!(Status::parse("u UU N... 100644\0").is_err()); assert!(Status::parse("# branch.ab +x -1\0").is_err()); assert!(parse_name_status("Z\0path\0").is_err()); } diff --git a/crates/core/src/undo.rs b/crates/core/src/undo.rs index 4fdae3c..ef63865 100644 --- a/crates/core/src/undo.rs +++ b/crates/core/src/undo.rs @@ -142,8 +142,8 @@ pub fn read(repo: &Repo, status: &Status) -> Result { } } } - // A rebase under way, or a move the log does not know of, leaves nothing to undo. - if rebase.is_some() || at != repo.gix.head_id().ok().map(gix::Id::detach) { + // Something under way, or a move the log does not know of, leaves nothing to undo. + if rebase.is_some() || repo.underway().is_some() || at != repo.gix.head_id().ok().map(gix::Id::detach) { return Ok(Undo::default()); } Ok(Undo { diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index 6b41cd6..d48fd73 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -6,7 +6,7 @@ use std::time::Duration; use gitcraft_core::diff::{self, FileDiff, LineKind, Source}; use gitcraft_core::git::{self, Git, Op, ResetMode}; -use gitcraft_core::repo::{Label, RefKind, Repo}; +use gitcraft_core::repo::{Label, RefKind, Repo, Underway}; use gitcraft_core::status::{self, Change, Status}; use gitcraft_core::undo::{self, Move, Undo}; use gitcraft_core::watch::Watch; @@ -381,6 +381,88 @@ fn a_rebase_is_one_action_and_undo_keeps_uncommitted_work() { assert_eq!(what(undo(&git).last.as_ref()), Some("commit: Clash")); } +/// `main` and `topic` both change `a.txt`, and `main` deletes `b.txt`, which `topic` changes. +/// Returns the tips of `main`, which is checked out, and `topic`. +fn clashing(git: &Git) -> (String, String) { + write(git, "b.txt", "b\n"); + git.apply(&Op::Stage(vec!["b.txt".into()])).unwrap(); + let root = commit(git, "a.txt", "a\n", "Root"); + git.apply(&Op::Branch { name: "topic".into(), at: root }).unwrap(); + write(git, "b.txt", "topic\n"); + git.apply(&Op::Stage(vec!["b.txt".into()])).unwrap(); + let topic = commit(git, "a.txt", "topic\n", "Topic"); + git.apply(&Op::Checkout("main".into())).unwrap(); + git.apply(&Op::Remove(vec!["b.txt".into()])).unwrap(); + (commit(git, "a.txt", "main\n", "Main"), topic) +} + +fn underway(git: &Git) -> Option { + Repo::open(git.dir()).unwrap().underway() +} + +fn conflicted(ours: bool, theirs: bool) -> Change { + Change::Conflicted { ours, theirs } +} + +fn read(git: &Git, path: &str) -> String { + fs::read_to_string(git.dir().join(path)).unwrap() +} + +#[test] +fn resolve_conflicts_and_continue_a_merge() { + let (_dir, git) = repo(); + clashing(&git); + assert!(git.apply(&Op::Merge("topic".into())).is_err()); + let merge = underway(&git).unwrap(); + assert_eq!((merge.command, merge.theirs, merge.message.as_str()), ("merge", "MERGE_HEAD", "Merge branch 'topic'")); + assert_eq!( + status(&git), + [("a.txt".into(), None, Some(conflicted(true, true))), ("b.txt".into(), None, Some(conflicted(false, true)))] + ); + assert_eq!(undo(&git), Undo::default(), "nothing to undo half way"); + + assert!(git.apply(&Op::Continue("merge")).is_err(), "not before every conflict is resolved"); + git.apply(&Op::Restore { from: merge.theirs.into(), paths: vec!["a.txt".into()] }).unwrap(); + git.apply(&Op::Remove(vec!["b.txt".into()])).unwrap(); + assert_eq!(status(&git), [("a.txt".into(), Some(Change::Modified), None)]); + git.apply(&Op::Continue("merge")).unwrap(); + assert_eq!(underway(&git), None); + assert_eq!(git.run(&["rev-list", "--parents", "-n1", "HEAD"]).unwrap().split(' ').count(), 3, "a merge commit"); + assert_eq!(read(&git, "a.txt"), "topic\n"); + assert!(!git.dir().join("b.txt").exists()); +} + +#[test] +fn skip_abort_and_continue_a_rebase_a_cherry_pick_and_a_revert() { + let (_dir, git) = repo(); + let (main, topic) = clashing(&git); + assert!(git.apply(&Op::Rebase("topic".into())).is_err()); + let rebase = underway(&git).unwrap(); + assert_eq!((rebase.command, rebase.theirs, rebase.message.as_str()), ("rebase", "REBASE_HEAD", "Main")); + // Rebasing turns the sides around: the commit coming in is the one that deleted the file. + assert_eq!(status(&git)[1], ("b.txt".into(), None, Some(conflicted(true, false)))); + git.apply(&Op::Skip("rebase")).unwrap(); + assert_eq!((underway(&git), branch(&git), head(&git)), (None, "main".into(), topic.clone())); + git.apply(&Op::Reset { to: main.clone(), mode: ResetMode::Hard }).unwrap(); + + assert!(git.apply(&Op::CherryPick("topic".into())).is_err()); + assert_eq!(underway(&git).map(|u| (u.command, u.theirs)), Some(("cherry-pick", "CHERRY_PICK_HEAD"))); + git.apply(&Op::Abort("cherry-pick")).unwrap(); + assert_eq!((underway(&git), head(&git)), (None, main)); + assert!(Status::read(&git).unwrap().is_clean()); + + // Reverting "Main" brings back what came before it. + commit(&git, "a.txt", "later\n", "Later"); + assert!(git.apply(&Op::Revert("HEAD~1".into())).is_err()); + let revert = underway(&git).unwrap(); + assert_eq!((revert.command, revert.message.as_str()), ("revert", "Revert \"Main\"")); + git.apply(&Op::Restore { from: revert.theirs.into(), paths: vec!["a.txt".into()] }).unwrap(); + assert_eq!(read(&git, "a.txt"), "a\n"); + git.apply(&Op::Continue("revert")).unwrap(); + assert_eq!(underway(&git), None); + assert_eq!(git.run(&["log", "-1", "--format=%s"]).unwrap(), "Revert \"Main\"\n"); +} + #[test] fn stash_tags_and_errors() { let (_dir, git) = repo();