From 05bf74179c6e0c0675e165a155699fe8aa4b4532 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 17:27:24 +0000 Subject: [PATCH 1/3] feat: fill in Magit's remaining rebase menu actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the rebase transient up to parity with magit-rebase: - p: rebase onto the push-remote branch - s: rebase a subset (--onto, picking the new base and the first commit to move) - f: autosquash fixup!/squash! commits onto the merge base with the upstream, accepting the todo as generated - m/w/k: modify, reword, or remove a single commit — the prepared-plan machinery generates the todo with just that commit's action changed and hands it to git via GIT_SEQUENCE_EDITOR/sequence.editor, so reword works in one step (Magit's instant reword) and edit/drop run headlessly in the background The menu is regrouped like Magit's ("Rebase onto" / "Rebase"), and the todo-editor title now names the --onto target for subset rebases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QvRnSchVxkwG6csZ6oLrkp --- src/app/ops/mod.rs | 7 +- src/app/ops/rebase.rs | 218 +++++++++++++++++++++++++++++++++++---- src/app/ops/remote.rs | 2 +- src/ui/transient.rs | 79 +++++++++++++- tests/git_integration.rs | 164 +++++++++++++++++++++++++++++ 5 files changed, 447 insertions(+), 23 deletions(-) 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..ab78394 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,37 @@ 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 +164,111 @@ 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 +277,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 +317,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 +338,9 @@ impl App { .collect(); if entries.is_empty() { self.message = Some(format!("no commits in {range}")); - return; + return None; } - // --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); - } - 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..b0cf77a 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. @@ -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 fixup and squash commits", + 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..6f97f86 100644 --- a/tests/git_integration.rs +++ b/tests/git_integration.rs @@ -255,6 +255,170 @@ 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(); From 5f27bd07f81f78600494be7d8ffdfed5f2a0b382 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:57:11 +0000 Subject: [PATCH 2/3] style: apply rustfmt to the new rebase actions Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QvRnSchVxkwG6csZ6oLrkp --- src/app/ops/rebase.rs | 7 ++----- tests/git_integration.rs | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/app/ops/rebase.rs b/src/app/ops/rebase.rs index ab78394..b5b0147 100644 --- a/src/app/ops/rebase.rs +++ b/src/app/ops/rebase.rs @@ -132,9 +132,7 @@ impl App { } 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::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. @@ -190,8 +188,7 @@ impl App { ); cli.push(base); let mut req = EditorRequest::new("autosquash", cli); - req.envs - .push(("GIT_SEQUENCE_EDITOR".into(), "true".into())); + req.envs.push(("GIT_SEQUENCE_EDITOR".into(), "true".into())); self.editor_request = Some(req); } diff --git a/tests/git_integration.rs b/tests/git_integration.rs index 6f97f86..49845a0 100644 --- a/tests/git_integration.rs +++ b/tests/git_integration.rs @@ -276,7 +276,12 @@ fn three_commit_repo(dir: &Path) -> GitClient { /// 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")]) + .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(); @@ -286,7 +291,11 @@ fn single_action_plan(git: &GitClient, base: &str, target: &str, action: TodoAct .map(|l| { let (hash, subject) = l.split_once('\u{1f}').unwrap(); TodoEntry { - action: if hash == short { action } else { TodoAction::Pick }, + action: if hash == short { + action + } else { + TodoAction::Pick + }, hash: hash.to_string(), subject: subject.to_string(), } @@ -348,7 +357,11 @@ fn prepared_edit_plan_stops_at_the_commit_for_amending() { "HEAD~2", ]) .unwrap(); - assert!(out.ok(), "edit rebase failed to stop cleanly: {}", out.stderr); + 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(); From e35cc271bc0be052bec5310e3f3153910b40447a Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Tue, 4 Aug 2026 02:59:51 +0900 Subject: [PATCH 3/3] fix: shorten the rebase autosquash labels Both the --autosquash switch and the `f` action in the rebase transient spelled out "Autosquash fixup and squash commits", which is far longer than every neighbouring entry and made the menu column ragged. "Autosquash" already names the operation. --- src/ui/transient.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/transient.rs b/src/ui/transient.rs index b0cf77a..4724e39 100644 --- a/src/ui/transient.rs +++ b/src/ui/transient.rs @@ -740,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", @@ -794,7 +794,7 @@ pub static REBASE: TransientDef = TransientDef { }, Item::Action { key: "f", - desc: "Autosquash fixup and squash commits", + desc: "Autosquash", action: TransientAction::RebaseAutosquash, }, Item::Action {