From 13eea7d638596546021e7262f5ea82ba25c1c596 Mon Sep 17 00:00:00 2001 From: Lars Jansen Date: Fri, 6 Nov 2026 11:10:21 +0100 Subject: [PATCH] feat: interactive rebase from a commit's menu: reorder, squash, fix up or drop the commits after it --- CONTRIBUTING.md | 3 +- README.md | 2 ++ ROADMAP.md | 6 ++-- crates/app/src/app.rs | 69 ++++++++++++++++++++++++++++++++++--- crates/app/src/graph.rs | 1 + crates/app/src/main.rs | 1 + crates/app/src/rebase.rs | 71 +++++++++++++++++++++++++++++++++++++++ crates/app/src/tests.rs | 68 +++++++++++++++++++++++++++++++++++++ crates/app/src/theme.rs | 6 ++++ crates/app/src/ui.rs | 7 ++-- crates/core/src/git.rs | 61 ++++++++++++++++++++++++++++++++- crates/core/tests/repo.rs | 30 ++++++++++++++++- 12 files changed, 313 insertions(+), 12 deletions(-) create mode 100644 crates/app/src/rebase.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbea210..10eb793 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,8 @@ Its tests run against real repositories made with `git` in a temporary folder in the meantime runs next. The tab on screen also reloads by itself when its watcher saw a change. - `start.rs` (open, clone and create), `ui.rs` (tabs and toolbar), `graph.rs`, `sidebar.rs`, `details.rs`, - `diff.rs` and `merge.rs` (a conflicted file) draw the views, and `theme.rs` has the colours. + `diff.rs`, `merge.rs` (a conflicted file) and `rebase.rs` (an interactive rebase) draw the + views, and `theme.rs` has the colours. The app's tests (`crates/app/src/tests.rs`) drive the whole window the way a user does, through the accessibility tree with [egui_kittest](https://crates.io/crates/egui_kittest). That also keeps diff --git a/README.md b/README.md index 986a09b..82c4ce1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ GitCraft is young. This is what it does today; [ROADMAP.md](ROADMAP.md) has what - **Discard**: throw away the changes to a file, a hunk or single lines, after you confirm. - **Branches and tags**: create, check out, merge, rebase, delete, from the sidebar or a right click. - **Commits from the graph**: check out, cherry-pick, revert, reset (soft, mixed, hard), copy the SHA. +- **Interactive rebase**: right-click a commit to rebase onto it, and put the commits after it in + a new order, squash or fix them up into the one below, or drop them, before you start. - **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. diff --git a/ROADMAP.md b/ROADMAP.md index d1491f2..8536e40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,11 +32,13 @@ drive it the way a user does, and keeps every benchmark as fast as before. that side deleted it) or mark it resolved - [x] Conflicts, part 2: resolve a conflicted file conflict by conflict, with ours and theirs side by side and the result below; take ours, theirs or both for each conflict, then save +- [x] Interactive rebase, part 1: from a commit's menu, the commits after it in a list, newest + first; move them up and down, pick, squash, fix up or drop each, then start ## Next -1. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the - graph. +1. **Interactive rebase, part 2**: reword a commit with a new message, and reorder by dragging, + in the list and in the graph. 2. **Credentials**: ask for a username, password or passphrase when git needs one. 3. **Push and pull options**: force push on request, push tags, pick the remote, pull with rebase. 4. **Remotes**: add, edit and remove. diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 8024775..91f0089 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::conflict::{Conflicts, Pick}; use gitcraft_core::diff::{self, FileDiff, Source}; -use gitcraft_core::git::Op; +use gitcraft_core::git::{Op, Step}; use gitcraft_core::repo::{Commit, History, Repo, Underway}; use gitcraft_core::status::{self, Change, FileChange, Status}; use gitcraft_core::undo::{self, Undo}; @@ -44,6 +44,12 @@ pub enum Action { Pick(Pick), /// Show a conflict of the file being resolved. GoTo(usize), + /// Put together an interactive rebase onto a commit, in the centre. + Plan(String), + /// Set what the rebase does with a commit of the plan. + Step(usize, Step), + /// Move a commit of the plan to another place. + Shift(usize, usize), /// Ask for a name first. Prompt(Prompt), /// Ask whether to go ahead first. @@ -176,6 +182,31 @@ impl Merge { } } +/// An interactive rebase being put together in the centre. +#[derive(Debug)] +pub struct Plan { + /// The commit to rebase onto. + pub onto: String, + /// The commits it takes along, as git listed them. + pub listed: Vec, + /// Each commit with what to do with it, in the new order, newest first like the graph. + pub steps: Vec<(Step, ObjectId)>, +} + +impl Plan { + fn new(onto: String, listed: Vec) -> Self { + Self { steps: listed.iter().rev().map(|&id| (Step::Pick, id)).collect(), onto, listed } + } + + /// The rebase, unless the oldest commit it keeps would have nothing to meld into. + pub fn op(&self) -> Option { + let todo: Vec<_> = self.steps.iter().rev().map(|&(step, id)| (step, id.to_string())).collect(); + let first = todo.iter().map(|(step, _)| *step).find(|&step| step != Step::Drop); + let melds = matches!(first, Some(Step::Squash | Step::Fixup)); + (!melds).then(|| Op::RebaseInteractive { onto: self.onto.clone(), todo }) + } +} + /// Commits read so far, so scrolling back costs nothing. #[derive(Debug, Default)] pub struct Commits(HashMap); @@ -220,6 +251,8 @@ pub struct Workspace { pub open_file: Option, /// The conflicted file in the centre, if any. pub merge: Option, + /// The interactive rebase in the centre, if any. + pub plan: Option, /// The commit summary being written. pub summary: String, /// The rest of the commit message. @@ -258,6 +291,7 @@ impl Workspace { files: None, open_file: None, merge: None, + plan: None, summary: String::new(), description: String::new(), amend: false, @@ -314,18 +348,30 @@ impl Workspace { } Action::Open(source) => match diff::read(&self.repo.git, &source) { Ok(diff) => { - self.merge = None; + (self.merge, self.plan) = (None, None); self.open_file = Some(OpenFile { source, diff, lines: BTreeSet::new() }); } Err(error) => self.error = Some(error.to_string()), }, Action::Resolve(path) => match Conflicts::read(&self.repo.git, &path) { - Ok(conflicts) => (self.merge, self.open_file) = (Some(Merge::new(path, conflicts)), None), + Ok(conflicts) => { + (self.merge, self.open_file, self.plan) = (Some(Merge::new(path, conflicts)), None, None); + } Err(error) => self.error = Some(error.to_string()), }, Action::Pick(pick) => self.merge.iter_mut().for_each(|merge| merge.pick(pick)), Action::GoTo(at) => self.merge.iter_mut().for_each(|merge| merge.go_to(at)), - Action::CloseFile => (self.open_file, self.merge) = (None, None), + Action::Plan(onto) => match self.repo.git.rebase_plan(&onto) { + Ok(listed) if listed.is_empty() => self.error = Some(format!("Nothing to rebase onto {onto:.7}")), + Ok(listed) => (self.plan, self.open_file, self.merge) = (Some(Plan::new(onto, listed)), None, None), + Err(error) => self.error = Some(error.to_string()), + }, + Action::Step(row, step) => self.plan.iter_mut().for_each(|plan| plan.steps[row].0 = step), + Action::Shift(from, to) => self.plan.iter_mut().for_each(|plan| { + let step = plan.steps.remove(from); + plan.steps.insert(to, step); + }), + Action::CloseFile => (self.open_file, self.merge, self.plan) = (None, None, None), Action::ToggleLine(hunk, line) => { if let Some(file) = &mut self.open_file && !file.lines.remove(&(hunk, line)) @@ -454,6 +500,21 @@ impl Workspace { Some(Err(error)) => (self.merge, self.error) = (None, Some(error.to_string())), } } + // A rebase that started is done with its plan, also when it stopped at a conflict. + if matches!(op, Some(Op::RebaseInteractive { .. })) + && (self.error.is_none() || self.snapshot.underway.is_some()) + { + self.plan = None; + } + if let Some(plan) = &mut self.plan { + // Commits changed outside start the plan again, so that the rebase never leaves one out. + match self.repo.git.rebase_plan(&plan.onto) { + Ok(listed) if listed == plan.listed => {} + Ok(listed) if listed.is_empty() => self.plan = None, + Ok(listed) => *plan = Plan::new(std::mem::take(&mut plan.onto), listed), + Err(error) => (self.plan, self.error) = (None, Some(error.to_string())), + } + } if let Some(file) = &mut self.open_file { // A file in the working tree without changes left, or gone altogether, closes. let entries = &self.snapshot.status.entries; diff --git a/crates/app/src/graph.rs b/crates/app/src/graph.rs index 101c4d6..7e9b3ea 100644 --- a/crates/app/src/graph.rs +++ b/crates/app/src/graph.rs @@ -203,6 +203,7 @@ pub fn commit_menu(ui: &mut Ui, sha: &str, labels: &[Ref], current: Option<&str> item(ui, "Create tag here…", Action::Prompt(Prompt::Tag { at: sha.to_owned() })); item(ui, "Cherry-pick commit", Action::Run(Op::CherryPick(sha.to_owned()))); item(ui, "Revert commit", Action::Run(Op::Revert(sha.to_owned()))); + item(ui, "Interactive rebase onto this commit…", Action::Plan(sha.to_owned())); ui.menu_button("Reset current branch to this commit", |ui| { for (text, mode) in [ ("Soft: keep all changes", ResetMode::Soft), diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 9f76d38..1e56300 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -6,6 +6,7 @@ mod details; mod diff; mod graph; mod merge; +mod rebase; mod sidebar; mod start; #[cfg(test)] diff --git a/crates/app/src/rebase.rs b/crates/app/src/rebase.rs new file mode 100644 index 0000000..bb6db71 --- /dev/null +++ b/crates/app/src/rebase.rs @@ -0,0 +1,71 @@ +//! An interactive rebase being put together: the commits it takes along, newest first like the +//! graph, each with what to do with it. + +use eframe::egui::{self, Button, Key, RichText, Role, Ui, WidgetInfo}; +use gitcraft_core::git::Step; + +use crate::app::{Action, Workspace}; +use crate::theme; + +/// Shows the plan of the workspace. +pub fn show(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { + let idle = ws.busy().is_none(); + let Workspace { plan: Some(plan), commits, repo, .. } = ws else { return }; + let count = plan.steps.len(); + ui.horizontal(|ui| { + ui.strong(format!("Interactive rebase onto {:.7}", plan.onto)); + ui.label(theme::count(count, "commit")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(Key::Escape)) { + actions.push(Action::CloseFile); + } + let op = plan.op(); + let start = ui.add_enabled(idle && op.is_some(), Button::new("Start rebase")); + if start.on_disabled_hover_text("The oldest commit it keeps has nothing below it to meld into").clicked() + && let Some(op) = op + { + actions.push(Action::Run(op)); + } + }); + }); + ui.weak("Squash and fixup meld a commit into the one below it. Squash keeps both messages, fixup that of the one below."); + ui.separator(); + egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + for (row, &(step, id)) in plan.steps.iter().enumerate() { + let summary = match commits.get(repo, id) { + Ok(commit) => commit.summary().to_owned(), + Err(error) => error.to_string(), + }; + ui.horizontal(|ui| { + for (arrow, way, to) in [("↑", "up", row.checked_sub(1)), ("↓", "down", Some(row + 1))] { + let to = to.filter(|&to| to < count); + let button = ui.add_enabled(to.is_some(), Button::new(RichText::new(arrow).monospace()).small()); + button.widget_info(|| { + WidgetInfo::labeled(Role::Button, to.is_some(), format!("Move {summary} {way}")) + }); + if button.clicked() + && let Some(to) = to + { + actions.push(Action::Shift(row, to)); + } + } + for choice in Step::ALL { + let (name, chosen) = (theme::capital(choice.word()), step == choice); + let button = ui.add(Button::selectable(chosen, &name)); + button + .widget_info(|| WidgetInfo::selected(Role::Button, true, chosen, format!("{name} {summary}"))); + if button.clicked() { + actions.push(Action::Step(row, choice)); + } + } + ui.monospace(RichText::new(&id.to_string()[..7]).weak()); + let text = RichText::new(&summary); + ui.label(match step { + Step::Pick => text, + Step::Squash | Step::Fixup => text.weak(), + Step::Drop => text.strikethrough().weak(), + }); + }); + } + }); +} diff --git a/crates/app/src/tests.rs b/crates/app/src/tests.rs index 0dd6642..76feb3b 100644 --- a/crates/app/src/tests.rs +++ b/crates/app/src/tests.rs @@ -709,3 +709,71 @@ fn a_conflicted_file_changed_outside_starts_again() { }); assert!(harness.state().workspace().unwrap().merge.is_none()); } + +/// Commits a file of its own for each name, so that the commits go in any order. +fn add_files(git: &Git, names: &[&str]) { + for name in names { + fs::write(git.dir().join(name), name).unwrap(); + git.apply(&Op::Stage(vec![(*name).into()])).unwrap(); + git.apply(&Op::Commit { message: format!("Add {name}"), amend: false }).unwrap(); + } +} + +#[test] +fn reorder_squash_and_drop_commits_in_an_interactive_rebase() { + let (_dir, git) = repo(); + add_files(&git, &["one", "two", "three"]); + let mut harness = app(Some(git.dir())); + menu(&mut harness, "First commit", &["Interactive rebase onto this commit…"]); + harness.get_by_label(&format!("Interactive rebase onto {}", &rev(&git, "HEAD~3")[..7])); + assert!(harness.get_by_label("Move Add three up").accesskit_node().is_disabled()); + + harness.get_by_label("Squash Add one").click(); + settle(&mut harness); + let start = |harness: &Harness<'_, App>| !harness.get_by_label("Start rebase").accesskit_node().is_disabled(); + assert!(!start(&harness), "the oldest commit has nothing to meld into"); + harness.get_by_label("Drop Add one").click(); + settle(&mut harness); + harness.get_by_label("Squash Add two").click(); + settle(&mut harness); + assert!(!start(&harness), "nor has the oldest one it keeps"); + + harness.get_by_label("Pick Add two").click(); + harness.get_by_label("Pick Add one").click(); + harness.get_by_label("Move Add one up").click(); + settle(&mut harness); + harness.get_by_label("Move Add one up").click(); + settle(&mut harness); + // Newest first: one, three, two. + harness.get_by_label("Squash Add one").click(); + harness.get_by_label("Drop Add two").click(); + settle(&mut harness); + assert!(start(&harness)); + harness.get_by_label("Start rebase").click(); + settle(&mut harness); + assert_eq!(git.run(&["log", "--format=%B|"]).unwrap(), "Add three\n\nAdd one\n|\nFirst commit\n|\n"); + assert_eq!(git.run(&["ls-files"]).unwrap(), "file.txt\none\nthree\n"); + assert!(harness.state().workspace().unwrap().plan.is_none()); + assert_eq!(harness.state().workspace().unwrap().error, None); +} + +#[test] +fn a_rebase_plan_starts_again_when_its_commits_change_outside() { + let (_dir, git) = repo(); + add_files(&git, &["one"]); + let mut harness = app(Some(git.dir())); + menu(&mut harness, "First commit", &["Interactive rebase onto this commit…"]); + harness.get_by_label("Drop Add one").click(); + settle(&mut harness); + + add_files(&git, &["two"]); + wait_until(&mut harness, "the new commit", |harness| harness.query_by_label("Drop Add two").is_some()); + harness.get_by_label("2 commits"); + harness.get_by_label("Start rebase").click(); + settle(&mut harness); + assert_eq!(log(&git), "Add two\nAdd one\nFirst commit\n", "nothing was dropped"); + + menu(&mut harness, "Add two", &["Interactive rebase onto this commit…"]); + assert!(harness.state().workspace().unwrap().plan.is_none()); + harness.get_by_label_contains("Nothing to rebase onto"); +} diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs index ac85034..813c756 100644 --- a/crates/app/src/theme.rs +++ b/crates/app/src/theme.rs @@ -76,6 +76,12 @@ pub fn count(n: usize, thing: &str) -> String { format!("{n} {thing}{}", if n == 1 { "" } else { "s" }) } +/// A word with a capital first letter, like "Rebase". +pub fn capital(word: &str) -> String { + let (first, rest) = word.split_at(word.chars().next().map_or(0, char::len_utf8)); + format!("{}{rest}", first.to_uppercase()) +} + /// Seconds since 1970, now. pub fn now() -> i64 { std::time::SystemTime::now() diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index f32329c..7b83706 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -9,7 +9,7 @@ use gitcraft_core::status::Change; use crate::app::{Action, Confirm, Selection, Workspace}; use crate::start::Start; -use crate::{details, diff, graph, merge, sidebar, theme}; +use crate::{details, diff, graph, merge, rebase, sidebar, theme}; const RECENT: usize = 10; @@ -196,6 +196,8 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { egui::CentralPanel::default().show(ui, |ui| { if let Some(merge) = &mut ws.merge { merge::show(ui, merge, actions); + } else if ws.plan.is_some() { + rebase::show(ui, ws, actions); } else if let Some(file) = &ws.open_file { diff::show(ui, file, actions); } else { @@ -267,8 +269,7 @@ fn underway(ui: &mut Ui, ws: &Workspace, actions: &mut Vec) { 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.strong(format!("{} in progress", theme::capital(command))); ui.weak(&underway.message); if conflicts > 0 { ui.colored_label(theme::ERROR, theme::count(conflicts, "conflicted file")); diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index 8d811a8..f791fd6 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -5,7 +5,7 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; -use crate::{Error, Result}; +use crate::{Error, ObjectId, Result}; /// A repository directory to run `git` in. #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,6 +39,11 @@ impl Git { if let Op::Undo { mark, .. } = op { command.env("GIT_REFLOG_ACTION", mark); } + if let Op::RebaseInteractive { todo, .. } = op { + // git hands its list of what to do to an editor; this one writes ours over it. + let todo: String = todo.iter().flat_map(|(step, sha)| [step.word(), " ", sha, "\n"]).collect(); + command.env("GIT_SEQUENCE_EDITOR", r#"printf "%s" "$GITCRAFT_TODO" >"#).env("GITCRAFT_TODO", todo); + } checked(&args, &output(command, op.input())?, &[0]) } @@ -51,6 +56,22 @@ impl Git { count.trim().parse().map_err(|_| Error::Parse(count)) } + /// The commits a rebase onto `onto` takes along, oldest first, as `git rebase -i` lists them: + /// those of `HEAD` without a copy in `onto`, leaving out merges. + pub fn rebase_plan(&self, onto: &str) -> Result> { + let range = format!("{onto}...HEAD"); + let list = self.run(&[ + "rev-list", + "--reverse", + "--topo-order", + "--no-merges", + "--cherry-pick", + "--right-only", + &range, + ])?; + list.lines().map(|sha| ObjectId::from_hex(sha.as_bytes()).map_err(|_| Error::Parse(sha.to_owned()))).collect() + } + /// Runs `git` with `input` on standard input, accepting the exit codes in `ok`. pub(crate) fn run_with(&self, args: &[impl AsRef], input: Option<&[u8]>, ok: &[i32]) -> Result { checked(args, &output(self.command(args), input)?, ok) @@ -168,6 +189,34 @@ impl ResetMode { } } +/// What an interactive rebase does with a commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + /// Keep it. + Pick, + /// Meld it into the commit before it, keeping both messages. + Squash, + /// Meld it into the commit before it, keeping the message of that one. + Fixup, + /// Leave it out. + Drop, +} + +impl Step { + /// Every step. + pub const ALL: [Self; 4] = [Self::Pick, Self::Squash, Self::Fixup, Self::Drop]; + + /// Its command in the list `git rebase -i` works through. + pub fn word(self) -> &'static str { + match self { + Self::Pick => "pick", + Self::Squash => "squash", + Self::Fixup => "fixup", + Self::Drop => "drop", + } + } +} + fn strs(list: &[String]) -> Vec<&str> { list.iter().map(String::as_str).collect() } @@ -236,6 +285,15 @@ pub enum Op { Merge(String), /// Rebase the current branch onto another. Rebase(String), + /// Rebase the current branch onto a commit, doing with each commit what the list says, in its + /// order. The first commit it keeps must be a pick: git cannot meld it into nothing, and stops + /// with the rebase under way. + RebaseInteractive { + /// The commit to rebase onto. + onto: String, + /// Each commit with its step, oldest first (see [`Git::rebase_plan`]). + todo: Vec<(Step, String)>, + }, /// Copy a commit onto the current branch. CherryPick(String), /// Add a commit that undoes another. @@ -322,6 +380,7 @@ impl Op { Self::DeleteBranch(name) => (&["branch", "-D"], vec![name]), Self::Merge(target) => (&["merge", "--no-edit"], vec![target]), Self::Rebase(onto) => (&["rebase"], vec![onto]), + Self::RebaseInteractive { onto, .. } => (&["rebase", "-i"], vec![onto]), Self::CherryPick(commit) => (&["cherry-pick"], vec![commit]), Self::Revert(commit) => (&["revert", "--no-edit"], vec![commit]), Self::Continue(command) => (&[], vec![command, "--continue"]), diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index 8443ff2..7e6b1b0 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -6,7 +6,7 @@ use std::time::Duration; use gitcraft_core::conflict::{Conflicts, Pick}; use gitcraft_core::diff::{self, FileDiff, LineKind, Source}; -use gitcraft_core::git::{self, Git, Op, ResetMode}; +use gitcraft_core::git::{self, Git, Op, ResetMode, Step}; use gitcraft_core::repo::{Label, RefKind, Repo, Underway}; use gitcraft_core::status::{self, Change, Status}; use gitcraft_core::undo::{self, Move, Undo}; @@ -382,6 +382,34 @@ fn a_rebase_is_one_action_and_undo_keeps_uncommitted_work() { assert_eq!(what(undo(&git).last.as_ref()), Some("commit: Clash")); } +#[test] +fn rebase_interactively_in_a_new_order_with_squash_fixup_and_drop() { + let (_dir, git) = repo(); + let root = commit(&git, "root.txt", "root\n", "Root"); + let [one, two, three, four, five] = + ["one", "two", "three", "four", "five"].map(|name| commit(&git, &format!("{name}.txt"), "\n", name)); + git.apply(&Op::Branch { name: "side".into(), at: root.clone() }).unwrap(); + git.apply(&Op::CherryPick(five.clone())).unwrap(); + git.apply(&Op::Checkout("main".into())).unwrap(); + + let listed = |onto: &str| git.rebase_plan(onto).unwrap(); + assert_eq!(listed(&root), [&one, &two, &three, &four, &five].map(|sha| id(sha))); + assert_eq!(listed("side"), [&one, &two, &three, &four].map(|sha| id(sha)), "side has a copy of five"); + assert_eq!(listed("HEAD"), [] as [ObjectId; 0]); + + let todo = + [(Step::Pick, three), (Step::Squash, one), (Step::Drop, two), (Step::Pick, five.clone()), (Step::Fixup, four)]; + git.apply(&Op::RebaseInteractive { onto: root, todo: todo.into() }).unwrap(); + assert_eq!(git.run(&["log", "--format=%B|"]).unwrap(), "five\n|\nthree\n\none\n|\nRoot\n|\n"); + assert_eq!(git.run(&["show", "--name-only", "--format="]).unwrap(), "five.txt\nfour.txt\n"); + assert_eq!(git.run(&["ls-files"]).unwrap(), "five.txt\nfour.txt\none.txt\nroot.txt\nthree.txt\n"); + + let steps = undo(&git); + assert_eq!(what(steps.last.as_ref()), Some("rebase")); + git.apply(&steps.last.unwrap().op).unwrap(); + assert_eq!(head(&git), five); +} + /// `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) {