Skip to main content

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}