lattice_magit/magit_rebase_mode.rs
1//! MG.9: magit-rebase major mode.
2//!
3//! Editable interactive rebase todo buffer. C-c C-c runs rebase,
4//! C-c C-k aborts.
5//!
6//! Fold audit fix: this used to populate the buffer with a
7//! hardcoded fake todo and, on `C-c C-c`, write it straight to
8//! `.git/rebase-merge/git-rebase-todo` and run `git rebase
9//! --continue` — against a rebase that had never actually been
10//! started, which always failed silently. The real flow: build the
11//! todo from `git log` against a real upstream, and on `C-c C-c`
12//! actually START the interactive rebase, injecting the buffer's
13//! (possibly user-edited) todo via the standard
14//! `GIT_SEQUENCE_EDITOR` trick — `git rebase -i` invokes the
15//! sequence editor as `<editor> <path-to-generated-todo>`, so
16//! setting it to `cp <our-file>` replaces git's todo with ours in
17//! one step. `GIT_EDITOR=true` avoids hanging on a `reword` step's
18//! commit-message prompt by accepting the original message unchanged.
19//!
20//! MG.43c lifted that limitation for the rebase `w` row, using the
21//! same trick one level down: the message is collected in a compose
22//! buffer FIRST, then `GIT_EDITOR` is pointed at `cp <message-file>`
23//! so git's reword step writes it. `GIT_EDITOR=true` remains the
24//! default for `edit` and `drop`, neither of which opens an editor.
25//!
26//! The todo buffer itself still keeps the original message on a
27//! hand-typed `reword` line — it has no message-editing UI. That
28//! remains a known limitation rather than a silent failure.
29
30use std::path::Path;
31use std::sync::{Arc, Mutex, OnceLock};
32
33use lattice_protocol::position::Position;
34
35use lattice_config;
36use lattice_grammar::Effect;
37use lattice_mode::{
38 ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
39 KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
40 keymap_entry,
41};
42use lattice_vcs::Repository;
43
44use crate::buffer_state::{BufferStateGuard, BufferStates};
45use crate::headerline;
46
47pub struct MagitRebaseMode;
48
49impl MagitRebaseMode {
50 pub fn mode_id() -> ModeId {
51 ModeId::new("magit-rebase-mode")
52 }
53}
54
55fn magit_rebase_keymap_entries() -> &'static [KeymapEntry] {
56 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
57 ENTRIES.get_or_init(|| {
58 vec![
59 keymap_entry! { mode: Insert, chord: "<C-c><C-c>", doc: "Execute rebase", cmd: "action:magit-rebase-confirm" },
60 keymap_entry! { mode: Insert, chord: "<C-c><C-k>", doc: "Abort rebase", cmd: "action:magit-rebase-abort" },
61 keymap_entry! { mode: Normal, chord: "<C-c><C-c>", doc: "Execute rebase", cmd: "action:magit-rebase-confirm" },
62 keymap_entry! { mode: Normal, chord: "<C-c><C-k>", doc: "Abort rebase", cmd: "action:magit-rebase-abort" },
63 keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show commit detail at cursor", cmd: "action:magit-rebase-show-commit" },
64 ]
65 })
66}
67
68pub struct RebaseState {
69 buffer_id: lattice_core::BufferId,
70 store: Arc<BufferStoreHandle>,
71 workdir: std::path::PathBuf,
72 upstream: String,
73 /// Resolved once at activation so the abort handler can decide
74 /// *synchronously* whether a rebase is in progress without walking
75 /// the filesystem to find the repo first (MG.12 — the confirm has
76 /// to be part of the effect the chord returns, so the check cannot
77 /// be deferred to `spawn_blocking` the way the abort itself is).
78 gitdir: std::path::PathBuf,
79}
80
81/// MG.13: service alias for this mode's per-buffer state
82/// (`feedback_servicesregistry_arc_typeid`).
83pub type RebaseStatesHandle = Arc<BufferStates<RebaseState>>;
84
85/// MG.24c: this buffer's [`MagitView`], so `A` / `_` / `O` act on the
86/// commit under the cursor.
87///
88/// `magit-core-mode.md` has claimed since MG.20 that those chords work
89/// on "the rebase todo". They never have: they resolve through
90/// `MagitView::commit_at_cursor`, and this mode published no view at
91/// all, so the trait default returned `None` and every press was a
92/// consumed dead key. The data was always here — `<CR>` reads the same
93/// sha off the same line with the same parser.
94struct RebaseView(Arc<Mutex<RebaseState>>);
95
96impl crate::buffer_state::MagitView for RebaseView {
97 /// **Deliberately nothing.** A rebase todo is a file the user is
98 /// part-way through editing, and `gr` means "rebuild this view from
99 /// git" everywhere else — here that would re-read the todo from
100 /// disk and silently discard the reordering they were in the middle
101 /// of. There is no refresh that is safe to offer.
102 fn refresh(&self) -> Option<Effect> {
103 None
104 }
105
106 fn commit_at_cursor(&self, cursor: Position) -> Option<String> {
107 let g = self.0.lock().ok()?;
108 let handle = g.store.handle_for(g.buffer_id)?;
109 let snap = handle.snapshot();
110 let line = snap.buffer.line(cursor.line)?;
111 extract_sha(&line).map(str::to_string)
112 }
113
114 fn workdir(&self) -> Option<std::path::PathBuf> {
115 Some(self.0.lock().ok()?.workdir.clone())
116 }
117}
118
119fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<RebaseState>>> {
120 crate::buffer_state::state_for::<RebaseState>(ctx)
121}
122
123impl Mode for MagitRebaseMode {
124 type Guard = BufferStateGuard<RebaseState>;
125
126 fn id(&self) -> ModeId {
127 Self::mode_id()
128 }
129 fn kind(&self) -> ModeKind {
130 ModeKind::Major
131 }
132 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
133 None
134 }
135
136 fn options(&self) -> OptionOverrideSet {
137 lattice_config::overrides! {
138 lattice_config::NoFile = true,
139 }
140 }
141
142 fn required_capabilities(&self) -> CapabilitySet {
143 CapabilitySet::empty()
144 }
145 fn keymap(&self) -> Keymap {
146 Keymap::from_entries(magit_rebase_keymap_entries())
147 }
148
149 /// MG.13: boot-registered — see `buffer_state`'s module docs.
150 ///
151 /// `upstream` is the field this mode cannot resolve before its
152 /// `.await`. It is published empty, and `confirm` already refuses
153 /// to run against an empty upstream — so a `C-c C-c` in that window
154 /// correctly does nothing rather than rebasing onto an unresolved
155 /// ref.
156 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
157 vec![
158 // confirm (C-c C-c)
159 ActionHandlerContribution {
160 action_name: "action:magit-rebase-confirm",
161 handler: Arc::new(|ctx: &ActionContext<'_>| {
162 let s = state(ctx)?;
163 let (todo, workdir, upstream) = {
164 let g = s.lock().ok()?;
165 if g.upstream.is_empty() {
166 return None;
167 }
168 let handle = g.store.handle_for(g.buffer_id)?;
169 let snap = handle.snapshot();
170 let mut todo = String::new();
171 for l in 0..snap.buffer.content_line_count() {
172 let text = snap.buffer.line(l).unwrap_or_default();
173 if text.starts_with('#') || text.trim().is_empty() {
174 continue;
175 }
176 todo.push_str(&text);
177 todo.push('\n');
178 }
179 (todo, g.workdir.clone(), g.upstream.clone())
180 };
181 if todo.trim().is_empty() {
182 return None;
183 }
184 // Bounded, single-shot git invocation, off the actor
185 // thread — same optimistic-close shape as
186 // magit-commit's confirm.
187 tokio::task::spawn(tokio::task::spawn_blocking(move || {
188 if let Err(e) = run_rebase(&workdir, &upstream, &todo) {
189 tracing::error!(target: "lattice_magit", "rebase failed: {e}");
190 }
191 }));
192 Some(Effect::KillBuffer)
193 }),
194 },
195 // abort (C-c C-k) — MG.12. No rebase has necessarily
196 // started yet (that only happens on confirm), and the two
197 // cases deserve different answers:
198 //
199 // nothing in progress → `C-c C-k` just closes a todo
200 // buffer nobody ran. Asking there would be pure noise,
201 // so it closes the pane outright.
202 // rebase in progress → `--abort` throws away everything
203 // the rebase has replayed so far, which is the same
204 // class of act as discard / branch-delete, so it asks.
205 //
206 // The in-progress check is a single `stat` against the
207 // gitdir resolved at activation — cheap enough to run on
208 // the actor thread in response to an explicit chord, and it
209 // *has* to run here because the confirm is the effect this
210 // handler returns.
211 ActionHandlerContribution {
212 action_name: "action:magit-rebase-abort",
213 handler: Arc::new(|ctx: &ActionContext<'_>| {
214 let s = state(ctx)?;
215 let gitdir = { s.lock().ok()?.gitdir.clone() };
216 if rebase_in_progress(&gitdir) {
217 Some(abort_rebase_confirm())
218 } else {
219 Some(Effect::KillBuffer)
220 }
221 }),
222 },
223 // abort, after confirmation.
224 ActionHandlerContribution {
225 action_name: "action:magit-rebase-abort-execute",
226 handler: Arc::new(|ctx: &ActionContext<'_>| {
227 let s = state(ctx)?;
228 let workdir = { s.lock().ok()?.workdir.clone() };
229 tokio::task::spawn(tokio::task::spawn_blocking(move || {
230 let Ok(repo) = Repository::discover(&workdir) else {
231 return;
232 };
233 // Nothing in progress is nothing to report: the
234 // buffer was already stale, and closing it is
235 // the whole outcome.
236 if !rebase_in_progress(repo.gitdir()) {
237 return;
238 }
239 // NC.5: reported, not discarded — an abort that
240 // failed left the user believing the rebase
241 // was gone.
242 let result = repo
243 .run_git(["rebase", "--abort"])
244 .map(|_| String::new())
245 .map_err(|e| e.to_string());
246 crate::magit_global_mode::finish_task(&workdir, "abort rebase", result);
247 }));
248 Some(Effect::KillBuffer)
249 }),
250 },
251 // <CR> — show commit detail for the todo line at cursor,
252 // matching magit-log/magit-blame's convention.
253 ActionHandlerContribution {
254 action_name: "action:magit-rebase-show-commit",
255 handler: Arc::new(|ctx: &ActionContext<'_>| {
256 let s = state(ctx)?;
257 let g = s.lock().ok()?;
258 let handle = g.store.handle_for(g.buffer_id)?;
259 let snap = handle.snapshot();
260 let line = snap.buffer.line(ctx.cursor.line)?;
261 let sha = extract_sha(&line)?;
262 Some(crate::magit_global_mode::open_repo_view_from_action_with(
263 ctx,
264 crate::magit_revision_mode::SHOW_VIEW,
265 "magit-revision-mode",
266 Some(sha),
267 ))
268 }),
269 },
270 ]
271 }
272
273 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
274 Box::pin(async move {
275 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
276 let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
277 let Some(store) = ctx.service::<BufferStoreHandle>() else {
278 return Ok(orphan());
279 };
280 let Some(handle) = store.handle_for(buffer_id) else {
281 return Ok(orphan());
282 };
283
284 // MR.5: this view read the PROCESS's repository, and the
285 // MR.3 sweep missed it because it spelled the discovery out
286 // rather than calling `magit_workdir()` — which is why the
287 // guard test greps for the discovery too.
288 let workdir =
289 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
290 let discovered = Repository::discover(&workdir).ok();
291 let gitdir = discovered
292 .as_ref()
293 .map(|r| r.gitdir().to_path_buf())
294 .unwrap_or_default();
295
296 // Which rebase the buffer name asks for — see
297 // [`RebaseTarget`]. Mirrors magit-blame's
298 // target-in-buffer-name pattern; an unrecognised name falls
299 // back to `@{upstream}`, which is what a bare
300 // `*magit:rebase*` has always meant.
301 let target = store
302 .name_for(buffer_id)
303 .as_deref()
304 .and_then(parse_target)
305 .unwrap_or(RebaseTarget::Onto(None));
306
307 // MG.14: the upstream is resolved below (it may come from
308 // `@{upstream}` rather than the buffer name), so the header
309 // fills in with the todo text.
310 let (hl, hl_registration) =
311 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
312 Some((h, reg)) => (Some(h), Some(reg)),
313 None => (None, None),
314 };
315 let rebase_running = rebase_in_progress(&gitdir);
316
317 // MG.13: publish BEFORE the first `.await`. `upstream` is
318 // not resolvable yet; it starts empty, and `confirm`
319 // already refuses on an empty upstream.
320 let Some(states) = ctx.service::<RebaseStatesHandle>() else {
321 return Ok(orphan());
322 };
323 let state = states.publish(
324 buffer_id,
325 RebaseState {
326 buffer_id,
327 store: store.clone(),
328 workdir: workdir.clone(),
329 upstream: String::new(),
330 gitdir,
331 },
332 );
333 let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
334 .with_headerline(hl_registration);
335 // MG.24c: publish the view, or `A` / `_` / `O` resolve no
336 // commit here and stay the dead keys they have been.
337 if let Some(views) = ctx.service::<crate::buffer_state::MagitViewsHandle>() {
338 views.publish(buffer_id, Arc::new(RebaseView(state.clone())));
339 guard = guard.with_views((*views).clone());
340 }
341
342 let wd = workdir.clone();
343 let (upstream, initial) =
344 tokio::task::spawn_blocking(move || build_rebase_buffer(&wd, &target))
345 .await
346 .unwrap_or_else(|_| (String::new(), "Failed to prepare rebase.\n".to_string()));
347
348 // Counted from the text just built, so no second
349 // `rev-list`. Keyed on the leading verb rather than "has a
350 // hex-looking token": the explanatory `#` footer is prose,
351 // and an ordinary English word made only of `abcdef`
352 // ("added", "faced") would otherwise count as a commit.
353 let commits = initial.lines().filter(|l| is_todo_line(l)).count();
354 headerline::publish(
355 &hl,
356 headerline::rebase_fields(&upstream, commits, rebase_running),
357 );
358 let spans = crate::highlight::rebase_styled_spans(&initial);
359 crate::buffer_io::replace_buffer_text(&handle, initial).await;
360 if let Some(ph) = ctx.service::<lattice_mode::PendingSyntheticHighlights>() {
361 ph.store_and_wake(buffer_id, spans);
362 }
363
364 // Late-resolved field, now that the upstream is known.
365 if let Ok(mut g) = state.lock() {
366 g.upstream = upstream;
367 }
368
369 Ok(guard)
370 })
371 }
372}
373
374/// Is a rebase actually mid-flight? `git` records one as a
375/// `rebase-merge` directory in the gitdir (`rebase-apply` for the
376/// legacy `--apply` backend and for `git am`). Both are checked
377/// because either means `--abort` has work to throw away.
378fn rebase_in_progress(gitdir: &Path) -> bool {
379 gitdir.join("rebase-merge").exists() || gitdir.join("rebase-apply").exists()
380}
381
382/// MG.12: the ask half of `C-c C-k`, reached only when a rebase is
383/// genuinely in progress.
384fn abort_rebase_confirm() -> Effect {
385 crate::confirm::ask(
386 "Abort this rebase?".to_string(),
387 "action:magit-rebase-abort-execute",
388 )
389}
390
391/// The verbs a rebase-todo line may lead with. Shared by the commit
392/// counter below and mirrored by `highlight::rebase_styled_spans`,
393/// which colours the same set.
394const TODO_VERBS: [&str; 6] = ["pick", "reword", "edit", "squash", "fixup", "drop"];
395
396/// MG.14: is this todo line a real commit row? `<verb> <sha> ...` —
397/// not a `#` comment and not the trailing blank.
398fn is_todo_line(line: &str) -> bool {
399 TODO_VERBS
400 .iter()
401 .any(|v| line.strip_prefix(v).is_some_and(|r| r.starts_with(' ')))
402}
403
404/// A rebase-todo line is `<verb> <sha> <subject>` (or a `#`-comment) —
405/// the sha is the first hex-looking whitespace-delimited token,
406/// mirroring `magit_log_mode::extract_sha`'s same "first hex token"
407/// scan (duplicated rather than shared: each mode's line format
408/// differs enough that a shared parser would need its own
409/// verb/graph-char skip logic anyway).
410fn extract_sha(line: &str) -> Option<&str> {
411 line.split_whitespace()
412 .find(|tok| tok.len() >= 4 && tok.chars().all(|c| c.is_ascii_hexdigit()))
413}
414
415/// MG.34: what a rebase buffer's name asks for.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub(crate) enum RebaseTarget {
418 /// `*magit:rebase*` / `*magit:rebase:<upstream>*` — rebase onto the
419 /// named ref, or onto `@{upstream}` when none is named.
420 Onto(Option<String>),
421 /// `*magit:rebase-edit:<line>:<path>*` — magit's
422 /// `magit-edit-line-commit`. Find the commit that last wrote line
423 /// `<line>` of `<path>`, and mark **that** commit `edit` so the
424 /// rebase stops on it.
425 ///
426 /// The blame is the reason this is a buffer-name form rather than a
427 /// resolved sha handed over by the action: finding the commit costs
428 /// a `git blame`, and the handler that fires the row is synchronous
429 /// and must not run `git` on the actor thread (MG.31). Same shape
430 /// `magit-revision-mode` uses for `*magit:merged:*`.
431 EditLine { line: u32, path: String },
432}
433
434/// MG.34: the buffer name that asks "amend whatever wrote this line".
435///
436/// Line first so the split is unambiguous — a path may contain `:`, a
437/// line number may not.
438pub(crate) fn edit_line_rest(line: u32, path: &str) -> String {
439 format!("{line}:{path}")
440}
441
442/// Which rebase a buffer name asks for. `None` for a name this mode does
443/// not own; the caller treats that as the bare `@{upstream}` form, which
444/// is what it has always meant.
445fn parse_target(name: &str) -> Option<RebaseTarget> {
446 let parsed = crate::workdir::parse_magit_name(name)?;
447 match parsed.view {
448 // MR.3b: `*magit:rebase-edit:<repo>:<line>:<path>*`. Line first
449 // so the split is unambiguous — a path may contain `:`, a line
450 // number may not.
451 "rebase-edit" => {
452 let (line, path) = parsed.rest?.split_once(':')?;
453 let line: u32 = line.parse().ok()?;
454 (!path.is_empty()).then(|| RebaseTarget::EditLine {
455 line,
456 path: path.to_string(),
457 })
458 }
459 // `*magit:rebase:<repo>*` is the bare `@{upstream}` form;
460 // `*magit:rebase:<repo>:<upstream>*` names one. Before MR.3b the
461 // upstream sat where the repository now does, which is exactly
462 // the collision the fixed position removes.
463 "rebase" => Some(RebaseTarget::Onto(parsed.rest.map(str::to_string))),
464 _ => None,
465 }
466}
467
468/// Resolve the upstream and build the todo-buffer text. Returns
469/// `(upstream, buffer_text)`; `upstream` is empty when resolution failed
470/// — `buffer_text` explains why, and the confirm handler refuses to run
471/// against an empty upstream.
472///
473/// Blocking; call on `spawn_blocking`.
474fn build_rebase_buffer(workdir: &Path, target: &RebaseTarget) -> (String, String) {
475 let repo = match Repository::discover(workdir) {
476 Ok(r) => r,
477 Err(_) => return (String::new(), "Not a git repository.\n".to_string()),
478 };
479 // MG.34: the edit-line form resolves to an ordinary upstream plus
480 // "which commit to stop on", so everything below is shared.
481 let (upstream, stop_at) = match target {
482 RebaseTarget::Onto(Some(u)) => (u.clone(), None),
483 RebaseTarget::Onto(None) => {
484 match repo.run_git_str(["rev-parse", "--abbrev-ref", "@{upstream}"]) {
485 Ok(s) => (s.trim().to_string(), None),
486 Err(_) => {
487 return (
488 String::new(),
489 "No upstream configured for this branch.\n\
490 Use `:magit-rebase <ref>` to rebase onto a specific ref.\n"
491 .to_string(),
492 );
493 }
494 }
495 }
496 RebaseTarget::EditLine { line, path } => match blame_line_commit(&repo, *line, path) {
497 Ok(sha) => (parent_or_root(&repo, &sha), Some(sha)),
498 Err(msg) => return (String::new(), msg),
499 },
500 };
501 let range = if upstream == ROOT {
502 // `--root` rebases from the first commit, so the log is the
503 // whole history rather than a range.
504 "HEAD".to_string()
505 } else {
506 format!("{upstream}..HEAD")
507 };
508 let log = repo
509 .run_git_str(["log", "--reverse", "--format=pick %h %s", &range])
510 .unwrap_or_default();
511 if log.trim().is_empty() {
512 return (
513 String::new(),
514 format!("Nothing to rebase — already up to date with {upstream}.\n"),
515 );
516 }
517 // MG.34: mark the blamed commit `edit` so the rebase stops there.
518 //
519 // Matched by sha rather than "the first row", because the first row
520 // is only the blamed commit when history is linear: with a merge in
521 // range, `--reverse` can put a side branch's older commits ahead of
522 // it. Marking the wrong row would stop the rebase on a commit the
523 // user never named — shape-identical to the right answer, which is
524 // the failure class this slice avoids elsewhere too.
525 let (log, note) = match &stop_at {
526 None => (log, String::new()),
527 Some(sha) => {
528 let short = repo
529 .run_git_str(["log", "-1", "--format=%h", sha])
530 .unwrap_or_default()
531 .trim()
532 .to_string();
533 match mark_edit(&log, &short) {
534 Some(marked) => (
535 marked,
536 format!(
537 "# {short} is marked `edit` — it is the commit that wrote that line.\n\
538 # The rebase will stop there; amend, then `:magit-rebase-continue`.\n"
539 ),
540 ),
541 // Unreachable in practice (the blamed commit is by
542 // construction in `<sha>^..HEAD`), but silently shipping
543 // an all-`pick` todo would replay history for no reason.
544 None => {
545 return (
546 String::new(),
547 format!(
548 "magit: {short} is not in the range being rebased — \
549 nothing to edit.\n"
550 ),
551 );
552 }
553 }
554 }
555 };
556 let text = format!(
557 "{log}\n\
558 {note}# Rebase onto {upstream} — edit the list above, then C-c C-c to run,\n\
559 # or C-c C-k to abort.\n\
560 # Commands: pick, reword, edit, squash, fixup, drop\n\
561 # (reword keeps the original message — no message-edit UI yet)\n"
562 );
563 (upstream, text)
564}
565
566/// The upstream that rebases a root commit. `git rebase -i --root`
567/// takes it in the same argument position an upstream ref would, so it
568/// travels through `RebaseState::upstream` and `run_rebase` unchanged.
569const ROOT: &str = "--root";
570
571/// Makes each rebase's scratch files unique. See
572/// `run_rebase_with_message` for the collision this prevents.
573static REBASE_TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
574
575/// `<sha>^`, or [`ROOT`] when `sha` is a root commit and has no parent.
576///
577/// Asked as "how many parents does it have" rather than "does `<sha>^`
578/// resolve", because the obvious spelling of the latter is a trap:
579/// `<sha>^{commit}` is peel-to-commit syntax, not first-parent, so it
580/// succeeds for *every* commit and quietly reports a root commit as
581/// having a parent. `rev-list --parents` prints `<sha> <parent>…`, so a
582/// single field means no parents and there is nothing to misread.
583fn parent_or_root(repo: &Repository, sha: &str) -> String {
584 let parents = repo
585 .run_git_str(["rev-list", "--parents", "-n", "1", sha])
586 .unwrap_or_default();
587 if parents.split_whitespace().count() > 1 {
588 format!("{sha}^")
589 } else {
590 ROOT.to_string()
591 }
592}
593
594/// The commit that last wrote `line` (1-based) of `path`.
595///
596/// `Err` carries the buffer text explaining why there is none — an
597/// uncommitted line is the case worth naming, since it is the one a user
598/// hits by asking about code they just typed.
599fn blame_line_commit(repo: &Repository, line: u32, path: &str) -> Result<String, String> {
600 let spec = format!("{line},{line}");
601 let out = repo
602 .run_git_str(["blame", "-L", &spec, "--porcelain", "--", path])
603 .map_err(|_| format!("magit: could not blame line {line} of {path} — is it tracked?\n"))?;
604 // Porcelain's first line is `<sha> <orig-line> <final-line> [<n>]`.
605 let sha = out
606 .split_whitespace()
607 .next()
608 .filter(|s| s.len() >= 7 && s.chars().all(|c| c.is_ascii_hexdigit()))
609 .ok_or_else(|| format!("magit: no blame for line {line} of {path}.\n"))?;
610 if sha.chars().all(|c| c == '0') {
611 return Err(format!(
612 "magit: line {line} of {path} is not committed yet.\n\
613 \n\
614 There is no commit to amend — commit it first.\n"
615 ));
616 }
617 Ok(sha.to_string())
618}
619
620/// Rewrite the `pick` on the row naming `short` to `edit`. `None` when
621/// no row names it.
622fn mark_edit(log: &str, short: &str) -> Option<String> {
623 mark_verb(log, short, "edit")
624}
625
626/// MG.43c: rewrite the `pick` on the row naming `short` to `verb`.
627///
628/// Generalises [`mark_edit`], which MG.34 needed only for `edit`.
629/// Magit's rebase `m` / `w` / `k` are the same operation with `edit`,
630/// `reword` and `drop` — the verb is the only thing that differs, so
631/// it is a parameter rather than three near-identical walks.
632///
633/// Matched by sha rather than "the first row", for the reason MG.34
634/// recorded: with a merge in range, `--reverse` can put a side
635/// branch's older commits ahead of the named one, and marking the
636/// wrong row is shape-identical to marking the right one.
637pub(crate) fn mark_verb(log: &str, short: &str, verb: &str) -> Option<String> {
638 let mut found = false;
639 let marked = log
640 .lines()
641 .map(|l| match l.strip_prefix("pick ") {
642 Some(rest) if !found && rest.split_whitespace().next() == Some(short) => {
643 found = true;
644 format!("{verb} {rest}")
645 }
646 _ => l.to_string(),
647 })
648 .collect::<Vec<_>>()
649 .join("\n");
650 found.then_some(marked)
651}
652
653/// MG.43c: run an interactive rebase that acts on ONE commit.
654///
655/// Builds the todo for `<commit>^..HEAD`, rewrites that commit's row
656/// to `verb`, and runs it. `message`, when given, is what git's
657/// `reword` step writes — see [`run_rebase_with_message`] for why that
658/// is what makes `w` work at all.
659///
660/// Returns the label-worthy error text on failure.
661pub(crate) fn rebase_one_commit(
662 workdir: &Path,
663 commit: &str,
664 verb: &str,
665 message: Option<&str>,
666) -> Result<(), String> {
667 // A commit that begins with `-` would be parsed as an OPTION by
668 // every `git` call below, not as a revision — `git log -1
669 // --format=%h --output=/tmp/x` writes a file rather than reporting
670 // a sha. The picker only ever supplies real shas, but this is also
671 // reachable from `:magit-rebase-edit-commit <arg>`, where the value
672 // is whatever was typed or pasted.
673 //
674 // Refused rather than escaped: no revision legitimately starts with
675 // `-`, so there is nothing to lose by declining, and `--` does not
676 // help for the calls that take the revision in option position.
677 if commit.starts_with('-') {
678 return Err(format!("`{commit}` is not a revision"));
679 }
680 let repo = Repository::discover(workdir).map_err(|e| e.to_string())?;
681 let upstream = parent_or_root(&repo, commit);
682 let range = if upstream == ROOT {
683 "HEAD".to_string()
684 } else {
685 format!("{upstream}..HEAD")
686 };
687 let log = repo
688 .run_git_str(["log", "--reverse", "--format=pick %h %s", &range])
689 .map_err(|e| e.to_string())?;
690 let short = repo
691 .run_git_str(["log", "-1", "--format=%h", commit])
692 .map_err(|e| e.to_string())?
693 .trim()
694 .to_string();
695 // A commit outside the range would otherwise produce an all-`pick`
696 // todo: a rebase that replays history and changes nothing, which
697 // looks like success and is not what the row promised.
698 let todo = mark_verb(&log, &short, verb)
699 .ok_or_else(|| format!("{short} is not in the range being rebased"))?;
700 run_rebase_with_message(workdir, &upstream, &todo, message)
701}
702
703fn run_rebase(workdir: &Path, upstream: &str, todo: &str) -> Result<(), String> {
704 run_rebase_with_message(workdir, upstream, todo, None)
705}
706
707/// `path` as a single word for the `sh -c` git runs `GIT_EDITOR` /
708/// `GIT_SEQUENCE_EDITOR` through. Quoting keeps a temp dir with spaces
709/// whole; forward slashes keep a Windows path intact, because
710/// Git-for-Windows' sh reads `\` as an escape and turns `C:\Users\…`
711/// into `C:Users…` — every interactive rebase then failed with
712/// "cp: cannot stat". `C:/Users/…` is a path both sh and Windows accept.
713fn sh_quoted_path(path: &Path) -> String {
714 let s = path.display().to_string();
715 let s = if cfg!(windows) {
716 s.replace('\\', "/")
717 } else {
718 s
719 };
720 format!("'{}'", s.replace('\'', r"'\''"))
721}
722
723/// MG.43c: `run_rebase`, plus the message a `reword` step will take.
724///
725/// **This is what makes rebase `w` possible.** `GIT_EDITOR=true`
726/// accepts a reword's message unchanged, which turns the operation
727/// into a no-op that reports success — the limitation this module's
728/// header records. Pointing `GIT_EDITOR` at `cp <file>` instead hands
729/// git a message we collected up front, exactly the way
730/// `GIT_SEQUENCE_EDITOR` already hands it a todo list.
731///
732/// With no message the old behaviour is unchanged: `true` accepts
733/// whatever git generated, which is correct for `edit` and `drop`
734/// because neither opens an editor.
735fn run_rebase_with_message(
736 workdir: &Path,
737 upstream: &str,
738 todo: &str,
739 message: Option<&str>,
740) -> Result<(), String> {
741 // Process id + upstream is NOT unique: two rebases can be in
742 // flight at once, and when they share an upstream they share the
743 // path — one overwrites the other's todo and git replays the wrong
744 // list. A monotonic counter makes each call's file its own.
745 //
746 // Found by two tests colliding: identical fixture repos built in
747 // the same second produce identical shas, so both named the same
748 // upstream. The tests exposed it; the race is real without them.
749 let seq = REBASE_TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
750 let tmp = std::env::temp_dir().join(format!(
751 "lattice-rebase-todo-{}-{seq}-{}",
752 std::process::id(),
753 upstream.replace(['/', ' '], "_")
754 ));
755 std::fs::write(&tmp, todo).map_err(|e| e.to_string())?;
756 let editor_cmd = format!("cp {}", sh_quoted_path(&tmp));
757 // Kept alive for the whole call: dropping it would remove the file
758 // before git's reword step reads it.
759 let msg_tmp = match message {
760 Some(m) => {
761 let path = std::env::temp_dir().join(format!(
762 "lattice-rebase-msg-{}-{seq}-{}",
763 std::process::id(),
764 upstream.replace(['/', ' '], "_")
765 ));
766 std::fs::write(&path, m).map_err(|e| e.to_string())?;
767 Some(path)
768 }
769 None => None,
770 };
771 let git_editor = match &msg_tmp {
772 Some(path) => format!("cp {}", sh_quoted_path(path)),
773 None => "true".to_string(),
774 };
775 let result = std::process::Command::new("git")
776 .args(["rebase", "-i", upstream])
777 .env("GIT_SEQUENCE_EDITOR", &editor_cmd)
778 .env("GIT_EDITOR", &git_editor)
779 .current_dir(workdir)
780 .output();
781 let _ = std::fs::remove_file(&tmp);
782 if let Some(path) = &msg_tmp {
783 let _ = std::fs::remove_file(path);
784 }
785 match result {
786 Ok(o) if o.status.success() => Ok(()),
787 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
788 Err(e) => Err(e.to_string()),
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 /// The editor path reaches git as one `sh` word, whatever it holds.
797 /// An unquoted path is split on a space and, on Windows, stripped of
798 /// its backslashes — interactive rebase then cannot find its todo.
799 #[test]
800 #[cfg(unix)]
801 fn an_editor_path_survives_the_shell_whole() {
802 let path = Path::new("/tmp/a dir/it's here");
803 let out = std::process::Command::new("sh")
804 .args(["-c", &format!("printf %s {}", sh_quoted_path(path))])
805 .output()
806 .expect("sh");
807 assert_eq!(String::from_utf8_lossy(&out.stdout), "/tmp/a dir/it's here");
808 }
809
810 /// MG.12: `C-c C-k` on a todo buffer that was never executed is
811 /// just "close this buffer" — there is nothing to throw away, so
812 /// it must not ask. This is why the confirm is gated rather than
813 /// unconditional.
814 #[test]
815 fn a_gitdir_with_no_rebase_state_is_not_in_progress() {
816 let dir = tempfile::tempdir().expect("temp dir");
817 assert!(!rebase_in_progress(dir.path()));
818 }
819
820 /// Both backends count: `rebase-merge` is the modern one,
821 /// `rebase-apply` the legacy `--apply` / `git am` one. Missing
822 /// either would abort real in-flight work without asking.
823 #[test]
824 fn either_rebase_state_directory_counts_as_in_progress() {
825 for marker in ["rebase-merge", "rebase-apply"] {
826 let dir = tempfile::tempdir().expect("temp dir");
827 std::fs::create_dir(dir.path().join(marker)).expect("create marker dir");
828 assert!(
829 rebase_in_progress(dir.path()),
830 "`{marker}` must count as a rebase in progress"
831 );
832 }
833 }
834
835 #[test]
836 fn abort_confirm_points_at_the_execute_action() {
837 match abort_rebase_confirm() {
838 Effect::Confirm {
839 prompt,
840 yes_action,
841 args: _,
842 } => {
843 assert_eq!(prompt, "Abort this rebase?");
844 assert_eq!(yes_action, "action:magit-rebase-abort-execute");
845 }
846 other => panic!("expected Confirm, got {other:?}"),
847 }
848 }
849
850 // ── MG.34: `e` edit-line-commit ─────────────────────────────────
851
852 fn git(dir: &Path, args: &[&str]) -> String {
853 let out = std::process::Command::new("git")
854 .args(args)
855 .current_dir(dir)
856 .output()
857 .expect("git");
858 assert!(
859 out.status.success(),
860 "git {args:?}: {}",
861 String::from_utf8_lossy(&out.stderr)
862 );
863 String::from_utf8_lossy(&out.stdout).trim().to_string()
864 }
865
866 /// A repository with three commits, each of which wrote one line of
867 /// `a.txt` — so blaming a line picks out a *specific* commit rather
868 /// than whichever one happens to be HEAD.
869 fn repo_with_a_line_per_commit() -> (tempfile::TempDir, [String; 3]) {
870 let dir = tempfile::tempdir().expect("tempdir");
871 let p = dir.path();
872 git(p, &["init", "-b", "main"]);
873 git(p, &["config", "user.email", "t@lattice.dev"]);
874 git(p, &["config", "user.name", "lattice-test"]);
875 let mut shas = Vec::new();
876 for (n, body) in [
877 ("one", "first\n"),
878 ("two", "second\n"),
879 ("three", "third\n"),
880 ] {
881 let mut text = std::fs::read_to_string(p.join("a.txt")).unwrap_or_default();
882 text.push_str(body);
883 std::fs::write(p.join("a.txt"), text).expect("write");
884 git(p, &["add", "a.txt"]);
885 git(p, &["commit", "-m", n]);
886 shas.push(git(p, &["rev-parse", "HEAD"]));
887 }
888 let shas: [String; 3] = shas.try_into().expect("three commits");
889 (dir, shas)
890 }
891
892 /// The three name forms, and that they do not bleed into each
893 /// other. `rebase-edit` and `rebase` are distinct VIEW WORDS under
894 /// the MR.3 grammar, which is what keeps them apart; before it they
895 /// shared a prefix up to the colon and a naive ordering would rebase
896 /// onto a ref named `-edit:12:src/a.rs`.
897 #[test]
898 fn the_three_buffer_name_forms_stay_distinct() {
899 // MR.3b: the bare form is `*magit:rebase:<repo>*` — no upstream,
900 // which has always meant `@{upstream}`. It used to fall out as
901 // `None` (an unowned name) and the caller read that as the same
902 // thing; now it says so directly.
903 assert_eq!(
904 parse_target(&crate::workdir::magit_buffer_name("rebase", "lattice")),
905 Some(RebaseTarget::Onto(None)),
906 "no upstream named means @{{upstream}}"
907 );
908 assert_eq!(parse_target("*messages*"), None, "not ours at all");
909 assert_eq!(
910 parse_target(&crate::workdir::magit_buffer_name_with(
911 "rebase",
912 "lattice",
913 "origin/main"
914 )),
915 Some(RebaseTarget::Onto(Some("origin/main".into())))
916 );
917 assert_eq!(
918 parse_target("*magit:rebase:*"),
919 Some(RebaseTarget::Onto(None))
920 );
921 assert_eq!(
922 parse_target(&crate::workdir::magit_buffer_name_with(
923 "rebase-edit",
924 "lattice",
925 &edit_line_rest(12, "src/a.rs")
926 )),
927 Some(RebaseTarget::EditLine {
928 line: 12,
929 path: "src/a.rs".into()
930 })
931 );
932 }
933
934 /// Line first, path second — because a path may contain a colon and
935 /// a line number may not. Splitting the other way round would break
936 /// on any such path, which is the reason for the ordering.
937 #[test]
938 fn a_path_containing_a_colon_still_parses() {
939 let name = crate::workdir::magit_buffer_name_with(
940 "rebase-edit",
941 "lattice",
942 &edit_line_rest(7, "weird:name.txt"),
943 );
944 assert_eq!(
945 parse_target(&name),
946 Some(RebaseTarget::EditLine {
947 line: 7,
948 path: "weird:name.txt".into()
949 })
950 );
951 }
952
953 /// The load-bearing reason `mark_edit` matches by sha instead of
954 /// taking row one: `--reverse` orders by commit date, so a merge in
955 /// range can put a side branch's older commits ahead of the one that
956 /// was blamed. Marking row one would stop the rebase on a commit the
957 /// user never named — and the resulting todo looks perfectly
958 /// plausible, which is what makes it worth pinning.
959 #[test]
960 fn the_marked_row_is_the_named_commit_not_the_first_one() {
961 let log = "pick aaaaaaa older side commit\n\
962 pick bbbbbbb the one that wrote the line\n\
963 pick ccccccc later";
964 let marked = mark_edit(log, "bbbbbbb").expect("bbbbbbb is in range");
965 assert_eq!(
966 marked,
967 "pick aaaaaaa older side commit\n\
968 edit bbbbbbb the one that wrote the line\n\
969 pick ccccccc later"
970 );
971 }
972
973 /// A commit outside the range is refused rather than silently
974 /// yielding an all-`pick` todo, which would replay history and
975 /// change nothing — a rebase the user did not ask for.
976 #[test]
977 fn a_commit_not_in_range_is_refused() {
978 assert_eq!(mark_edit("pick aaaaaaa only", "bbbbbbb"), None);
979 }
980
981 /// MG.43c: a value that would be read as an option is refused.
982 ///
983 /// The commit reaches `git log -1 --format=%h <commit>` in option
984 /// position, so `--output=/tmp/x` would write a file instead of
985 /// reporting a sha. The picker only supplies real shas, but
986 /// `:magit-rebase-edit-commit <arg>` takes whatever was typed.
987 #[test]
988 fn an_option_looking_commit_is_refused() {
989 let dir = tempfile::tempdir().expect("tempdir");
990 for bad in ["--output=/tmp/lattice-should-not-exist", "-n", "--help"] {
991 assert!(
992 rebase_one_commit(dir.path(), bad, "edit", None).is_err(),
993 "`{bad}` must be refused rather than passed to git",
994 );
995 }
996 assert!(
997 !std::path::Path::new("/tmp/lattice-should-not-exist").exists(),
998 "the refused value must not have reached git",
999 );
1000 }
1001
1002 /// MG.43c: the verb is the operation, and only the named row's
1003 /// verb changes.
1004 #[test]
1005 fn mark_verb_rewrites_only_the_named_row() {
1006 let log = "pick aaaaaaa one\npick bbbbbbb two\npick ccccccc three";
1007 for verb in ["edit", "reword", "drop"] {
1008 let marked = mark_verb(log, "bbbbbbb", verb).expect("in range");
1009 assert_eq!(
1010 marked,
1011 format!("pick aaaaaaa one\n{verb} bbbbbbb two\npick ccccccc three"),
1012 );
1013 }
1014 }
1015
1016 /// MG.43c: **`m` really does stop the rebase at the named commit.**
1017 ///
1018 /// The failure this guards is the quiet one: a todo whose verb
1019 /// never took would replay history unchanged and report success,
1020 /// so the row would look like it worked and do nothing.
1021 #[test]
1022 fn editing_a_commit_stops_the_rebase_there() {
1023 let (dir, shas) = repo_with_a_line_per_commit();
1024 let p = dir.path();
1025 rebase_one_commit(p, &shas[1], "edit", None).expect("rebase runs");
1026 assert!(
1027 rebase_in_progress(&p.join(".git")),
1028 "an `edit` verb must leave the rebase stopped",
1029 );
1030 assert_eq!(
1031 git(p, &["rev-parse", "HEAD"]),
1032 shas[1],
1033 "it must stop ON the named commit, not before or after it",
1034 );
1035 git(p, &["rebase", "--abort"]);
1036 }
1037
1038 /// MG.43c: `k` removes the named commit and keeps the rest.
1039 ///
1040 /// Each commit touches its OWN file. The shared-file fixture the
1041 /// other tests use would conflict here, and legitimately so —
1042 /// dropping a commit a later one builds on is a real conflict git
1043 /// stops on, not something this row should paper over.
1044 #[test]
1045 fn removing_a_commit_drops_only_that_one() {
1046 let dir = tempfile::tempdir().expect("tempdir");
1047 let p = dir.path();
1048 git(p, &["init", "-b", "main"]);
1049 git(p, &["config", "user.email", "t@lattice.dev"]);
1050 git(p, &["config", "user.name", "lattice-test"]);
1051 for (n, file) in [("one", "a.txt"), ("two", "b.txt"), ("three", "c.txt")] {
1052 std::fs::write(p.join(file), format!("{n}\n")).expect("write");
1053 git(p, &["add", file]);
1054 git(p, &["commit", "-m", n]);
1055 }
1056 let middle = git(p, &["rev-parse", "HEAD~1"]);
1057 rebase_one_commit(p, &middle, "drop", None).expect("rebase runs");
1058 let subjects = git(p, &["log", "--format=%s"]);
1059 assert!(
1060 !subjects.contains("two"),
1061 "`two` must be gone: {subjects:?}"
1062 );
1063 assert!(subjects.contains("one"), "`one` must survive: {subjects:?}");
1064 assert!(
1065 subjects.contains("three"),
1066 "`three` must survive: {subjects:?}"
1067 );
1068 }
1069
1070 /// MG.43c: **`w` actually applies the message — the whole reason
1071 /// `GIT_EDITOR` is pointed at `cp <file>` instead of `true`.**
1072 ///
1073 /// With `GIT_EDITOR=true` git accepts a reword's message
1074 /// unchanged, so the operation succeeds and changes nothing. That
1075 /// is precisely the limitation this module's header used to
1076 /// record, and it is invisible from the outside: the command exits
1077 /// 0 either way. Asserting on the resulting message is the only
1078 /// thing that tells the two apart.
1079 #[test]
1080 fn rewording_a_commit_applies_the_new_message() {
1081 let (dir, _) = repo_with_a_line_per_commit();
1082 let p = dir.path();
1083 let middle = git(p, &["rev-parse", "HEAD~1"]);
1084 rebase_one_commit(p, &middle, "reword", Some("a better subject")).expect("rebase runs");
1085
1086 let subjects = git(p, &["log", "--format=%s"]);
1087 assert!(
1088 subjects.contains("a better subject"),
1089 "the new message must reach the commit: {subjects:?}",
1090 );
1091 assert!(
1092 !subjects.contains("two"),
1093 "the old message must be gone: {subjects:?}",
1094 );
1095 // The other commits keep theirs — a reword rewrites one
1096 // message, not the branch's.
1097 assert!(
1098 subjects.contains("one") && subjects.contains("three"),
1099 "{subjects:?}"
1100 );
1101 }
1102
1103 /// Blame resolves the commit that wrote *that* line, not HEAD.
1104 #[test]
1105 fn blame_names_the_commit_that_wrote_the_line() {
1106 let (dir, shas) = repo_with_a_line_per_commit();
1107 let repo = Repository::discover(dir.path()).expect("discover");
1108 for (line, expected) in [(1, &shas[0]), (2, &shas[1]), (3, &shas[2])] {
1109 assert_eq!(
1110 blame_line_commit(&repo, line, "a.txt").as_deref(),
1111 Ok(expected.as_str()),
1112 "line {line} must blame to its own commit"
1113 );
1114 }
1115 }
1116
1117 /// An uncommitted line has no commit to amend. The message says so
1118 /// rather than the buffer being empty, because "I just typed this"
1119 /// is the common way to reach it.
1120 #[test]
1121 fn an_uncommitted_line_says_so_instead_of_blaming_zeros() {
1122 let (dir, _) = repo_with_a_line_per_commit();
1123 let p = dir.path();
1124 let mut text = std::fs::read_to_string(p.join("a.txt")).expect("read");
1125 text.push_str("fresh\n");
1126 std::fs::write(p.join("a.txt"), text).expect("write");
1127 let repo = Repository::discover(p).expect("discover");
1128 let err = blame_line_commit(&repo, 4, "a.txt").expect_err("line 4 is uncommitted");
1129 assert!(
1130 err.contains("not committed yet"),
1131 "must name the real reason, got: {err}"
1132 );
1133 }
1134
1135 /// `<sha>^` for a commit with a parent, `--root` for the first
1136 /// commit in the repository — which has none, so `git rebase -i
1137 /// <sha>^` would fail outright.
1138 #[test]
1139 fn the_root_commit_rebases_with_root_not_with_a_missing_parent() {
1140 let (dir, shas) = repo_with_a_line_per_commit();
1141 let repo = Repository::discover(dir.path()).expect("discover");
1142 assert_eq!(parent_or_root(&repo, &shas[0]), ROOT);
1143 assert_eq!(parent_or_root(&repo, &shas[1]), format!("{}^", shas[1]));
1144 }
1145
1146 /// End to end: asking about line 2 produces a todo whose `edit` row
1147 /// is the second commit, rebasing onto its parent.
1148 #[test]
1149 fn edit_line_builds_a_todo_that_stops_on_that_lines_commit() {
1150 let (dir, shas) = repo_with_a_line_per_commit();
1151 let p = dir.path();
1152 let (upstream, text) = build_rebase_buffer(
1153 p,
1154 &RebaseTarget::EditLine {
1155 line: 2,
1156 path: "a.txt".into(),
1157 },
1158 );
1159 assert_eq!(upstream, format!("{}^", shas[1]), "rebase onto its parent");
1160
1161 let short = git(p, &["log", "-1", "--format=%h", &shas[1]]);
1162 let edits: Vec<&str> = text.lines().filter(|l| l.starts_with("edit ")).collect();
1163 assert_eq!(edits.len(), 1, "exactly one commit is marked, got: {text}");
1164 assert!(
1165 edits[0].starts_with(&format!("edit {short} ")),
1166 "the marked commit must be the one that wrote line 2; got {:?}",
1167 edits[0]
1168 );
1169 // The third commit is still replayed after it, or the rebase
1170 // would silently drop it.
1171 let short3 = git(p, &["log", "-1", "--format=%h", &shas[2]]);
1172 assert!(
1173 text.contains(&format!("pick {short3} ")),
1174 "later commits must still be picked; got: {text}"
1175 );
1176 }
1177
1178 /// The whole point of the `edit` row is that the rebase stops and
1179 /// waits — so the buffer must say how to resume, or the user is left
1180 /// in a state with no visible exit.
1181 #[test]
1182 fn the_todo_names_the_command_that_resumes_the_rebase() {
1183 let (dir, _) = repo_with_a_line_per_commit();
1184 let (_, text) = build_rebase_buffer(
1185 dir.path(),
1186 &RebaseTarget::EditLine {
1187 line: 2,
1188 path: "a.txt".into(),
1189 },
1190 );
1191 assert!(
1192 text.contains(":magit-rebase-continue"),
1193 "the way out must be named in the buffer; got: {text}"
1194 );
1195 }
1196
1197 /// The pre-MG.34 path is unchanged: a named upstream still produces
1198 /// an all-`pick` todo with nothing marked.
1199 #[test]
1200 fn an_ordinary_rebase_marks_nothing() {
1201 let (dir, shas) = repo_with_a_line_per_commit();
1202 let (upstream, text) =
1203 build_rebase_buffer(dir.path(), &RebaseTarget::Onto(Some(shas[0].clone())));
1204 assert_eq!(upstream, shas[0]);
1205 assert!(
1206 !text.lines().any(|l| l.starts_with("edit ")),
1207 "a plain rebase must not mark any commit; got: {text}"
1208 );
1209 }
1210}