diff --git a/src/app/ops/mod.rs b/src/app/ops/mod.rs index aaab4aa..6e921c6 100644 --- a/src/app/ops/mod.rs +++ b/src/app/ops/mod.rs @@ -260,8 +260,11 @@ impl App { } Merge | MergeEdit | MergeNoCommit | MergeSquash | MergeAbsorb | MergePreview | MergeInto | MergeAbort => self.merge_action(action, args), - RebaseUpstream | RebaseElsewhere | RebaseInteractive | RebaseContinue | RebaseSkip - | RebaseEditTodo | RebaseAbort => self.rebase_action(action, args), + RebaseOntoPushRemote | RebaseUpstream | RebaseElsewhere | RebaseInteractive + | RebaseSubset | RebaseAutosquash | RebaseEditCommit | RebaseRewordCommit + | RebaseRemoveCommit | RebaseContinue | RebaseSkip | RebaseEditTodo | RebaseAbort => { + self.rebase_action(action, args) + } CherryPick | CherryPickApply | CherryPickContinue | CherryPickSkip | CherryPickAbort => self.cherry_pick_action(action, args), Revert | RevertNoCommit | RevertContinue | RevertSkip | RevertAbort => { diff --git a/src/app/ops/rebase.rs b/src/app/ops/rebase.rs index c45ef14..b5b0147 100644 --- a/src/app/ops/rebase.rs +++ b/src/app/ops/rebase.rs @@ -5,6 +5,11 @@ //! points `GIT_SEQUENCE_EDITOR` at a `cp` of that file, so git's own //! sequencer runs the plan; per-commit message edits still open $EDITOR via //! the foreground editor handoff. Abort confirms first. +//! +//! The single-commit actions (modify/reword/remove, Magit's +//! magit-rebase-edit/reword/remove-commit) reuse the same plan machinery +//! without opening the editor pane: the plan is generated with the chosen +//! commit's action pre-set and handed straight to git. use crate::app::{svec, App, Confirm, EditorRequest, PendingAction}; use crate::command::TodoCmd; @@ -39,6 +44,28 @@ impl App { return; } match action { + TransientAction::RebaseOntoPushRemote => { + let Some(branch) = self.current_branch() else { + self.message = Some("not on a branch".into()); + return; + }; + let Some(remote) = self.push_remote(&branch) else { + self.message = Some( + "no push-remote (set branch..pushRemote or remote.pushDefault)" + .into(), + ); + return; + }; + let target = format!("{remote}/{branch}"); + if args.iter().any(|f| f == "--interactive") { + self.open_todo_editor(target, args); + return; + } + let mut cli = svec(&["rebase"]); + cli.extend(args); + cli.push(target.clone()); + self.run_git_bg(format!("rebase onto {target}"), cli, None); + } TransientAction::RebaseUpstream => { let Some(upstream) = self .snapshot @@ -78,6 +105,35 @@ impl App { app.open_todo_editor(rev, args) }); } + TransientAction::RebaseSubset => { + let bases = self.list_revs_at_point(); + self.open_picker("Rebase subset onto", bases, move |app, newbase| { + let starts = app.list_revs_at_point(); + let prompt = format!("Rebase from commit (inclusive) onto {newbase}"); + app.open_picker(prompt, starts, move |app, start| { + // The chosen commit moves too, so both the rebased + // range and the todo span begin at its parent. + let base = format!("{start}^"); + if args.iter().any(|f| f == "--interactive") { + let mut flags = args; + flags.push("--onto".into()); + flags.push(newbase); + app.open_todo_editor(base, flags); + return; + } + let mut cli = svec(&["rebase"]); + cli.extend(args); + cli.push("--onto".into()); + cli.push(newbase.clone()); + cli.push(base); + app.run_git_bg(format!("rebase subset onto {newbase}"), cli, None); + }); + }); + } + TransientAction::RebaseAutosquash => self.rebase_autosquash(args), + TransientAction::RebaseEditCommit => self.pick_instant_todo(TodoAction::Edit, args), + TransientAction::RebaseRewordCommit => self.pick_instant_todo(TodoAction::Reword, args), + TransientAction::RebaseRemoveCommit => self.pick_instant_todo(TodoAction::Drop, args), // Continuing commits the resolved conflict, which can open // $EDITOR for the message; hand the terminal over. TransientAction::RebaseContinue => { @@ -106,6 +162,110 @@ impl App { } } + /// Autosquash the commits not on the upstream (magit-rebase-autosquash): + /// git applies `--autosquash` to its own todo, which is accepted as + /// generated. A `squash!` step still opens $EDITOR for the combined + /// message, so the rebase takes the foreground handoff, like instant + /// squash. + fn rebase_autosquash(&mut self, args: Vec) { + let Some(upstream) = self + .snapshot + .as_ref() + .and_then(|s| s.branch.upstream.clone()) + else { + self.message = Some("no upstream configured".into()); + return; + }; + // Rebasing onto the merge base squashes without moving the branch. + let Some(base) = self.probe(&["merge-base", &upstream, "HEAD"]) else { + self.message = Some(format!("no merge base with {upstream}")); + return; + }; + let mut cli = svec(&["rebase", "--interactive", "--autosquash"]); + cli.extend( + args.into_iter() + .filter(|f| f != "--interactive" && f != "--autosquash"), + ); + cli.push(base); + let mut req = EditorRequest::new("autosquash", cli); + req.envs.push(("GIT_SEQUENCE_EDITOR".into(), "true".into())); + self.editor_request = Some(req); + } + + /// m/w/k: pick the target commit, then run a rebase whose prepared todo + /// applies `action` to just that commit + /// (magit-rebase-edit/reword/remove-commit). + fn pick_instant_todo(&mut self, action: TodoAction, args: Vec) { + let prompt = match action { + TodoAction::Edit => "Modify commit", + TodoAction::Reword => "Reword commit", + TodoAction::Drop => "Remove commit", + _ => unreachable!("not an instant todo action"), + }; + let revs = self.list_revs_at_point(); + self.open_picker(prompt, revs, move |app, rev| { + app.instant_todo_rebase(action, rev, args) + }); + } + + /// Rebase from `rev`'s parent with a plan that picks everything except + /// `rev`, which gets `action`. Reword opens $EDITOR for the message, so + /// it takes the foreground handoff; edit and drop never prompt and run + /// in the background with `sequence.editor` installing the plan. + fn instant_todo_rebase(&mut self, action: TodoAction, rev: String, mut flags: Vec) { + // The rebase runs on a fixed plan, never an editable todo. + flags.retain(|f| f != "--interactive"); + if self + .probe(&["rev-parse", "--verify", &format!("{rev}^")]) + .is_none() + { + self.message = Some(format!("{rev} has no parent to rebase from")); + return; + } + let base = format!("{rev}^"); + let Some(mut entries) = self.list_plan_entries(&base) else { + return; + }; + // `rev` may be a symbolic name; the plan identifies it by short hash. + let Some(short) = self.probe(&["rev-parse", "--short", &rev]) else { + self.message = Some(format!("cannot resolve {rev}")); + return; + }; + let Some(i) = entries.iter().position(|e| e.hash == short) else { + self.message = Some(format!("{rev} is not an ancestor of HEAD")); + return; + }; + entries[i].action = action; + let plan_path = self.git.git_dir.join("rugit-rebase-todo"); + if let Err(e) = std::fs::write(&plan_path, todo::serialize_todo(&entries)) { + self.message = Some(format!("cannot write todo: {e}")); + return; + } + let verb = match action { + TodoAction::Edit => "edit", + TodoAction::Reword => "reword", + TodoAction::Drop => "remove", + _ => unreachable!("not an instant todo action"), + }; + let desc = format!("{verb} commit {short}"); + let seq_editor = format!("cp {}", shell_quote(&plan_path.to_string_lossy())); + if action == TodoAction::Reword { + let mut args = svec(&["rebase", "--interactive"]); + args.extend(flags); + args.push(base); + let mut req = EditorRequest::new(desc, args); + req.envs.push(("GIT_SEQUENCE_EDITOR".into(), seq_editor)); + self.editor_request = Some(req); + return; + } + let mut args = svec(&["-c"]); + args.push(format!("sequence.editor={seq_editor}")); + args.extend(svec(&["rebase", "--interactive"])); + args.extend(flags); + args.push(base); + self.run_git_bg(desc, args, None); + } + // ---- todo editor ------------------------------------------------------- /// Start an interactive rebase: list `base..HEAD`, seed the plan with @@ -114,7 +274,37 @@ impl App { fn open_todo_editor(&mut self, base: String, mut flags: Vec) { // `--interactive` is what this editor *is*; confirm adds it back. flags.retain(|f| f != "--interactive"); - // Listing the span is a fast local read, like `list_branches`. + let Some(mut entries) = self.list_plan_entries(&base) else { + return; + }; + // --autosquash acts on the todo, which git never generates here; the + // remaining flags are passed through to `git rebase` at confirm. + if let Some(i) = flags.iter().position(|f| f == "--autosquash") { + flags.remove(i); + entries = todo::autosquash(entries); + } + // A subset rebase (`--onto`) replays the span somewhere other than + // `base`; the title names where the commits actually land. + let onto = flags + .iter() + .position(|f| f == "--onto") + .and_then(|i| flags.get(i + 1)) + .unwrap_or(&base) + .clone(); + self.push_todo_pane( + format!("Interactive rebase onto {onto}"), + RebaseTodoState { + entries, + flags, + base: Some(base), + }, + ); + } + + /// The pick-everything plan for `base..HEAD`, or `None` (with a message) + /// when the range is empty or cannot be listed. Listing the span is a + /// fast local read, like `list_branches`. + fn list_plan_entries(&mut self, base: &str) -> Option> { let range = format!("{base}..HEAD"); let out = match self .git @@ -124,14 +314,14 @@ impl App { Ok(out) => { let first = out.stderr.lines().next().unwrap_or("").to_string(); self.message = Some(format!("rebase onto {base} failed: {first}")); - return; + return None; } Err(e) => { self.message = Some(format!("rebase onto {base} failed: {e}")); - return; + return None; } }; - let mut entries: Vec = out + let entries: Vec = out .stdout .lines() .filter_map(|l| { @@ -145,22 +335,9 @@ impl App { .collect(); if entries.is_empty() { self.message = Some(format!("no commits in {range}")); - return; - } - // --autosquash acts on the todo, which git never generates here; the - // remaining flags are passed through to `git rebase` at confirm. - if let Some(i) = flags.iter().position(|f| f == "--autosquash") { - flags.remove(i); - entries = todo::autosquash(entries); + return None; } - self.push_todo_pane( - format!("Interactive rebase onto {base}"), - RebaseTodoState { - entries, - flags, - base: Some(base), - }, - ); + Some(entries) } /// Edit the remaining todo of the rebase in progress. Plans containing diff --git a/src/app/ops/remote.rs b/src/app/ops/remote.rs index 8292c11..f29ccb3 100644 --- a/src/app/ops/remote.rs +++ b/src/app/ops/remote.rs @@ -257,7 +257,7 @@ impl App { /// The remote pushed to by default (the "push-remote"): /// `branch..pushRemote`, falling back to `remote.pushDefault`. - fn push_remote(&self, branch: &str) -> Option { + pub(super) fn push_remote(&self, branch: &str) -> Option { self.probe(&["config", "--get", &format!("branch.{branch}.pushRemote")]) .or_else(|| self.probe(&["config", "--get", "remote.pushDefault"])) } diff --git a/src/ui/transient.rs b/src/ui/transient.rs index d2d4b7d..4724e39 100644 --- a/src/ui/transient.rs +++ b/src/ui/transient.rs @@ -102,12 +102,31 @@ pub enum TransientAction { RevertSkip, /// Abort the in-progress revert (after a y/n confirm). RevertAbort, + /// Rebase the current branch onto its push-remote branch. + RebaseOntoPushRemote, /// Rebase the current branch onto its upstream. RebaseUpstream, /// Opens a picker over branches, then rebases onto the chosen one. RebaseElsewhere, /// Like `RebaseElsewhere`, but interactive: the todo list opens in $EDITOR. RebaseInteractive, + /// Opens pickers for the new base and the first commit to move, then + /// rebases just that span with `--onto` (magit-rebase-subset). + RebaseSubset, + /// Fold `fixup!`/`squash!` commits into their targets across the + /// commits not on the upstream, accepting the todo as generated + /// (magit-rebase-autosquash). + RebaseAutosquash, + /// Opens a picker over commits, then starts a rebase that stops at the + /// chosen commit for amending (magit-rebase-edit-commit). + RebaseEditCommit, + /// Opens a picker over commits, then rewords just that commit: $EDITOR + /// opens on its message and the rebase finishes by itself + /// (magit-rebase-reword-commit). + RebaseRewordCommit, + /// Opens a picker over commits, then removes the chosen commit with a + /// rebase whose todo drops it (magit-rebase-remove-commit). + RebaseRemoveCommit, /// Continue the in-progress rebase (may open $EDITOR for a message). RebaseContinue, /// Skip the commit that stopped the in-progress rebase. @@ -721,7 +740,7 @@ pub static REBASE: TransientDef = TransientDef { Item::Switch { key: "-a", flag: "--autosquash", - desc: "Autosquash fixup and squash commits", + desc: "Autosquash", }, Item::Switch { key: "-A", @@ -741,8 +760,13 @@ pub static REBASE: TransientDef = TransientDef { ], }, GroupDef { - title: "Actions", + title: "Rebase onto", items: &[ + Item::Action { + key: "p", + desc: "Rebase onto push-remote", + action: TransientAction::RebaseOntoPushRemote, + }, Item::Action { key: "u", desc: "Rebase onto upstream", @@ -753,11 +777,41 @@ pub static REBASE: TransientDef = TransientDef { desc: "Rebase onto elsewhere", action: TransientAction::RebaseElsewhere, }, + ], + }, + GroupDef { + title: "Rebase", + items: &[ Item::Action { key: "i", desc: "Rebase interactively", action: TransientAction::RebaseInteractive, }, + Item::Action { + key: "s", + desc: "Rebase a subset", + action: TransientAction::RebaseSubset, + }, + Item::Action { + key: "f", + desc: "Autosquash", + action: TransientAction::RebaseAutosquash, + }, + Item::Action { + key: "m", + desc: "Modify a commit", + action: TransientAction::RebaseEditCommit, + }, + Item::Action { + key: "w", + desc: "Reword a commit", + action: TransientAction::RebaseRewordCommit, + }, + Item::Action { + key: "k", + desc: "Remove a commit", + action: TransientAction::RebaseRemoveCommit, + }, ], }, ], @@ -2004,6 +2058,29 @@ mod tests { } } + #[test] + fn rebase_menu_offers_magits_single_commit_and_subset_actions() { + // The keys mirror magit-rebase: p/u/e rebase onto something, while + // i/s/f/m/w/k start the interactive family. + let mut st = TransientState::new(&REBASE); + for (ch, action) in [ + ('p', TransientAction::RebaseOntoPushRemote), + ('s', TransientAction::RebaseSubset), + ('f', TransientAction::RebaseAutosquash), + ('m', TransientAction::RebaseEditCommit), + ('w', TransientAction::RebaseRewordCommit), + ('k', TransientAction::RebaseRemoveCommit), + ] { + match st.on_key(&key(ch)) { + TransientResult::Invoke(got, args) => { + assert_eq!(got, action); + assert_eq!(args, vec!["--autostash"]); + } + other => panic!("unexpected for {ch:?}: {other:?}"), + } + } + } + #[test] fn in_progress_rebase_menu_only_manages_the_current_rebase() { let mut st = TransientState::new(&REBASE_IN_PROGRESS); diff --git a/tests/git_integration.rs b/tests/git_integration.rs index 6f53f1b..49845a0 100644 --- a/tests/git_integration.rs +++ b/tests/git_integration.rs @@ -255,6 +255,183 @@ fn instant_fixup_folds_staged_changes_into_target() { ); } +/// Three commits on main for the single-commit rebase tests: base, "add a", +/// "add b". Returns the client. +fn three_commit_repo(dir: &Path) -> GitClient { + let git = init_repo(dir); + for (file, content, msg) in [ + ("base.txt", "base\n", "base"), + ("a.txt", "one\n", "add a"), + ("b.txt", "two\n", "add b"), + ] { + fs::write(dir.join(file), content).unwrap(); + sh(dir, &["add", "."]); + sh(dir, &["commit", "-q", "-m", msg]); + } + git +} + +/// The pick-everything plan for `base..HEAD`, with `action` applied to the +/// entry whose hash matches `target` — what `instant_todo_rebase` prepares +/// for the rebase menu's modify/reword/remove actions. +fn single_action_plan(git: &GitClient, base: &str, target: &str, action: TodoAction) -> String { + let out = git + .run(&[ + "log", + "--reverse", + "--format=%h\u{1f}%s", + &format!("{base}..HEAD"), + ]) + .unwrap(); + let short = git.run(&["rev-parse", "--short", target]).unwrap(); + let short = short.stdout.trim(); + let entries: Vec = out + .stdout + .lines() + .map(|l| { + let (hash, subject) = l.split_once('\u{1f}').unwrap(); + TodoEntry { + action: if hash == short { + action + } else { + TodoAction::Pick + }, + hash: hash.to_string(), + subject: subject.to_string(), + } + }) + .collect(); + todo::serialize_todo(&entries) +} + +/// The command behind the rebase menu's "remove a commit" (`r k`): a +/// background `rebase --interactive` whose todo is the prepared plan with +/// the victim dropped, installed via `sequence.editor=cp`. Must never stop +/// for an editor. +#[test] +fn prepared_drop_plan_removes_a_commit_headlessly() { + let tmp = tempfile::tempdir().unwrap(); + let git = three_commit_repo(tmp.path()); + + let plan = single_action_plan(&git, "HEAD~2", "HEAD~1", TodoAction::Drop); + let plan_path = tmp.path().join("plan"); + fs::write(&plan_path, plan).unwrap(); + + let out = git + .run(&[ + "-c", + &format!("sequence.editor=cp '{}'", plan_path.display()), + "rebase", + "--interactive", + "--autostash", + "HEAD~2", + ]) + .unwrap(); + assert!(out.ok(), "drop rebase failed: {}", out.stderr); + + let snap = git.read_snapshot().unwrap(); + assert_eq!(snap.state, None); + let subjects: Vec<&str> = snap.recent.iter().map(|c| c.subject.as_str()).collect(); + assert_eq!(subjects, vec!["add b", "base"]); + assert!(!tmp.path().join("a.txt").exists()); +} + +/// The command behind "modify a commit" (`r m`): the same prepared-plan +/// rebase with the target set to `edit`, which stops there for amending +/// without ever opening an editor. +#[test] +fn prepared_edit_plan_stops_at_the_commit_for_amending() { + let tmp = tempfile::tempdir().unwrap(); + let git = three_commit_repo(tmp.path()); + + let plan = single_action_plan(&git, "HEAD~2", "HEAD~1", TodoAction::Edit); + let plan_path = tmp.path().join("plan"); + fs::write(&plan_path, plan).unwrap(); + + let out = git + .run(&[ + "-c", + &format!("sequence.editor=cp '{}'", plan_path.display()), + "rebase", + "--interactive", + "HEAD~2", + ]) + .unwrap(); + assert!( + out.ok(), + "edit rebase failed to stop cleanly: {}", + out.stderr + ); + + // Stopped at "add a" with the rebase still in progress. + let snap = git.read_snapshot().unwrap(); + assert_eq!(snap.state.as_deref(), Some("rebasing")); + let head = git.run(&["log", "-1", "--format=%s"]).unwrap(); + assert_eq!(head.stdout.trim(), "add a"); + + let out = git.run(&["rebase", "--continue"]).unwrap(); + assert!(out.ok(), "rebase --continue failed: {}", out.stderr); + assert_eq!(git.read_snapshot().unwrap().state, None); +} + +/// The command behind "reword a commit" (`r w`, Magit's instant reword): +/// the prepared plan sets the target to `reword`, `GIT_SEQUENCE_EDITOR` +/// installs it, and only the message editor opens — the rebase finishes by +/// itself. +#[test] +fn prepared_reword_plan_rewords_a_commit_in_one_step() { + let tmp = tempfile::tempdir().unwrap(); + let git = three_commit_repo(tmp.path()); + + let plan = single_action_plan(&git, "HEAD~2", "HEAD~1", TodoAction::Reword); + let plan_path = tmp.path().join("plan"); + fs::write(&plan_path, plan).unwrap(); + + let status = Command::new("git") + .args(["rebase", "--interactive", "HEAD~2"]) + .env( + "GIT_SEQUENCE_EDITOR", + format!("cp '{}'", plan_path.display()), + ) + // Stand-in for the user's $EDITOR: replace the message outright. + .env("GIT_EDITOR", r#"sh -c 'printf "add a reworded\n" >"$1"' x"#) + .env("GIT_TERMINAL_PROMPT", "0") + .current_dir(tmp.path()) + .status() + .expect("spawn git rebase"); + assert!(status.success(), "reword rebase failed"); + + let snap = git.read_snapshot().unwrap(); + assert_eq!(snap.state, None); + let subjects: Vec<&str> = snap.recent.iter().map(|c| c.subject.as_str()).collect(); + assert_eq!(subjects, vec!["add b", "add a reworded", "base"]); + // Only the message changed. + assert_eq!( + fs::read_to_string(tmp.path().join("a.txt")).unwrap(), + "one\n" + ); +} + +/// The command behind "rebase a subset" (`r s`): `--onto` moves only the +/// span from the chosen commit (inclusive) to HEAD. +#[test] +fn subset_rebase_moves_only_the_chosen_span() { + let tmp = tempfile::tempdir().unwrap(); + let git = three_commit_repo(tmp.path()); + // A side branch to land the subset on. + sh(tmp.path(), &["branch", "target", "HEAD~2"]); + + // Move just "add b" (HEAD, inclusive) onto target: --onto target HEAD^. + let out = git + .run(&["rebase", "--autostash", "--onto", "target", "HEAD~1"]) + .unwrap(); + assert!(out.ok(), "subset rebase failed: {}", out.stderr); + + let snap = git.read_snapshot().unwrap(); + let subjects: Vec<&str> = snap.recent.iter().map(|c| c.subject.as_str()).collect(); + assert_eq!(subjects, vec!["add b", "base"]); +} + #[test] fn stage_single_line_from_hunk() { let tmp = tempfile::tempdir().unwrap();