Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app/ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
215 changes: 196 additions & 19 deletions src/app/ops/rebase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.<name>.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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<String>) {
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<String>) {
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<String>) {
// 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
Expand All @@ -114,7 +274,37 @@ impl App {
fn open_todo_editor(&mut self, base: String, mut flags: Vec<String>) {
// `--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<Vec<TodoEntry>> {
let range = format!("{base}..HEAD");
let out = match self
.git
Expand All @@ -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<TodoEntry> = out
let entries: Vec<TodoEntry> = out
.stdout
.lines()
.filter_map(|l| {
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/ops/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ impl App {

/// The remote pushed to by default (the "push-remote"):
/// `branch.<name>.pushRemote`, falling back to `remote.pushDefault`.
fn push_remote(&self, branch: &str) -> Option<String> {
pub(super) fn push_remote(&self, branch: &str) -> Option<String> {
self.probe(&["config", "--get", &format!("branch.{branch}.pushRemote")])
.or_else(|| self.probe(&["config", "--get", "remote.pushDefault"]))
}
Expand Down
Loading
Loading