Skip to main content

lattice_magit/
magit_submodule_mode.rs

1//! MG.21i: magit-submodule major mode.
2//!
3//! Lists the configured submodules with git's own status marker, the
4//! recorded commit, and the path: `a` add, `u` update, `s` sync,
5//! `d` remove (asks).
6//!
7//! **A buffer, and here magit agrees.** Unlike remote management
8//! (§4.6b, where we diverge), magit itself renders submodules as a
9//! buffer — `magit-list-submodules` opens `*Modules*`. Its `o`
10//! transient carries the *operations*; the list is a buffer in both.
11//! So this is the UX-convention rule and paramount goal #3 pointing
12//! the same way, and the shape is `magit-remote-mode`'s.
13//!
14//! The one genuinely destructive row is `d`: removing a submodule
15//! deletes its whole working tree, including anything uncommitted
16//! inside it, and git keeps no copy. That is §12.13's ask/execute
17//! contract, unlike `magit-remote-mode`'s `d`, which is recoverable
18//! and therefore does not ask.
19
20use std::sync::{Arc, Mutex, OnceLock};
21
22use lattice_config;
23use lattice_grammar::{EchoLevel, Effect};
24use lattice_mode::{
25    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
26    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
27    keymap_entry,
28};
29use lattice_protocol::position::Position;
30use lattice_vcs::{Repository, Submodule, SubmoduleEntry};
31
32use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
33use crate::headerline::{self, Field, MagitHeaderlineHandle};
34
35pub struct MagitSubmoduleMode;
36
37impl MagitSubmoduleMode {
38    pub fn mode_id() -> ModeId {
39        ModeId::new("magit-submodule-mode")
40    }
41}
42
43/// The chords.
44///
45/// Magit's `o` transient uses `a` add, `u` update, `s` synchronize,
46/// `d` unpopulate and `k` remove, plus `p` populate and `r` register.
47/// Four carry over unchanged. Two changes, both for reasons already
48/// settled elsewhere:
49///
50/// - **`k` does not carry over** — it is the up-motion
51///   (`feedback_magit_keys_follow_evil_magit`). Removal lands on `d`,
52///   which is what magit-branch, magit-stash and magit-remote already
53///   mean by "remove the row under the cursor". Magit's own `d`
54///   (unpopulate) is not offered: it is `update`'s inverse and nobody
55///   reaches for it daily, and offering it *and* remove on adjacent
56///   keys would make the destructive one easy to hit by accident.
57/// - **`p` populate and `r` register are folded into `u`**, which runs
58///   `submodule update --init --recursive` — the command that subsumes
59///   both. Three keys for one intent is three chances to pick the
60///   wrong one.
61fn magit_submodule_keymap_entries() -> &'static [KeymapEntry] {
62    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
63    ENTRIES.get_or_init(|| {
64        vec![
65            keymap_entry! { mode: Normal, chord: "a", doc: "Add a submodule", cmd: "action:magit-submodule-add" },
66            keymap_entry! { mode: Normal, chord: "u", doc: "Update (init + checkout) the submodule at cursor", cmd: "action:magit-submodule-update" },
67            keymap_entry! { mode: Normal, chord: "s", doc: "Sync the submodule at cursor's URL", cmd: "action:magit-submodule-sync" },
68            keymap_entry! { mode: Normal, chord: "d", doc: "Remove the submodule at cursor (asks first)", cmd: "action:magit-submodule-remove" },
69        ]
70    })
71}
72
73pub struct SubmoduleState {
74    buffer_id: lattice_core::BufferId,
75    store: Arc<BufferStoreHandle>,
76    workdir: std::path::PathBuf,
77    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
78    headerline: Option<MagitHeaderlineHandle>,
79    /// The submodules as last rendered, in render order — the cursor
80    /// mapping reads this rather than re-parsing the line. Same
81    /// reasoning as `magit_remote_mode`: the heading row would
82    /// otherwise decode as a submodule named `Submodules`.
83    entries: Vec<SubmoduleEntry>,
84}
85
86pub type SubmoduleStatesHandle = Arc<BufferStates<SubmoduleState>>;
87
88fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<SubmoduleState>>> {
89    crate::buffer_state::state_for::<SubmoduleState>(ctx)
90}
91
92struct SubmoduleView(Arc<Mutex<SubmoduleState>>);
93
94impl MagitView for SubmoduleView {
95    fn refresh(&self) -> Option<Effect> {
96        refresh(self.0.clone())
97    }
98
99    fn workdir(&self) -> Option<std::path::PathBuf> {
100        self.0.lock().ok().map(|g| g.workdir.clone())
101    }
102}
103
104impl Mode for MagitSubmoduleMode {
105    type Guard = BufferStateGuard<SubmoduleState>;
106
107    fn id(&self) -> ModeId {
108        Self::mode_id()
109    }
110    fn kind(&self) -> ModeKind {
111        ModeKind::Major
112    }
113    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
114        None
115    }
116
117    fn options(&self) -> OptionOverrideSet {
118        lattice_config::overrides! {
119            lattice_config::ReadOnly = true,
120            lattice_config::NoFile = true,
121        }
122    }
123
124    /// MG.RO: `read-only-mode` is where the gate actually is.
125    ///
126    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
127    /// `read_only_edit_rejected`, which guards the insert-mode char path;
128    /// operators never reach it, because a `Document`'s grammar dispatch
129    /// applies its own edits and hands the host an already-applied
130    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
131    /// the buffer reported itself read-only — worse than not gating at all,
132    /// because it looks protected.
133    ///
134    /// `read-only-mode` carries the option AND the `invocation_runner`
135    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
136    /// letting motions, `:` and `/` through.
137    ///
138    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
139    /// mode is followed from the mode being ACTIVATED, and the majors are what
140    /// the host activates. Putting it on the shared minor looked right and was
141    /// verified not to fire.
142    fn implies(&self) -> &[lattice_mode::ModeId] {
143        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
144        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
145    }
146
147    fn required_capabilities(&self) -> CapabilitySet {
148        CapabilitySet::empty()
149    }
150    fn keymap(&self) -> Keymap {
151        Keymap::from_entries(magit_submodule_keymap_entries())
152    }
153
154    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
155        vec![
156            // NO `<CR>`, and the reason is a real limitation rather
157            // than an oversight. The obvious binding is "open this
158            // submodule's own magit-status" — but `workdir::magit_workdir`
159            // discovers from the PROCESS's current directory, so every
160            // magit buffer in the editor is bound to one repository.
161            // There is no way to point a status buffer at a
162            // subdirectory today, and inventing a per-buffer workdir
163            // belongs in its own slice (it is the same thing magit's
164            // `Z` worktree rows will need). A chord that opened the
165            // superproject's status while claiming to open the
166            // submodule's would be worse than no chord.
167            //
168            // add (a) — two prompts, git's own argument order: URL,
169            // then the path to put it at.
170            ActionHandlerContribution {
171                action_name: "action:magit-submodule-add",
172                handler: Arc::new(|ctx: &ActionContext<'_>| {
173                    let _ = state(ctx)?;
174                    Some(Effect::OpenPrompt {
175                        prompt: "Submodule URL: ".to_string(),
176                        initial: String::new(),
177                        on_submit_action: "action:magit-submodule-add-path".to_string(),
178                        buffer_name: None,
179                    })
180                }),
181            },
182            ActionHandlerContribution {
183                action_name: "action:magit-submodule-add-path",
184                handler: Arc::new(|ctx: &ActionContext<'_>| {
185                    let url = ctx.prompt_value?.trim().to_string();
186                    if url.is_empty() {
187                        return None;
188                    }
189                    Some(Effect::OpenPrompt {
190                        prompt: format!("Path for {url}: "),
191                        // The last path segment, minus `.git`, is what
192                        // git itself would default to.
193                        initial: default_path_for(&url),
194                        on_submit_action: "action:magit-submodule-add-finish".to_string(),
195                        buffer_name: Some(add_prompt_buffer_name(&url)),
196                    })
197                }),
198            },
199            ActionHandlerContribution {
200                action_name: "action:magit-submodule-add-finish",
201                handler: Arc::new(|ctx: &ActionContext<'_>| {
202                    let path = ctx.prompt_value?.trim().to_string();
203                    let url = carried_url(ctx)?;
204                    if path.is_empty() {
205                        return None;
206                    }
207                    let what = format!("add submodule {path}");
208                    spawn_submodule_mutation(ctx, what, move |repo| {
209                        Submodule::add(repo, &url, &path)
210                    })?;
211                    Some(Effect::Echo {
212                        level: EchoLevel::Info,
213                        text: "cloning submodule\u{2026}".to_string(),
214                    })
215                }),
216            },
217            // update (u) — reaches the network for a submodule that has
218            // never been cloned, so it echoes.
219            ActionHandlerContribution {
220                action_name: "action:magit-submodule-update",
221                handler: Arc::new(|ctx: &ActionContext<'_>| {
222                    let entry = entry_at_cursor(ctx)?;
223                    let path = entry.path.clone();
224                    let echo = format!("updating {path}\u{2026}");
225                    let what = format!("update submodule {path}");
226                    spawn_submodule_mutation(ctx, what, move |repo| {
227                        Submodule::update(repo, Some(&path))
228                    })?;
229                    Some(Effect::Echo {
230                        level: EchoLevel::Info,
231                        text: echo,
232                    })
233                }),
234            },
235            // sync (s) — local, so no echo; the list is the feedback.
236            ActionHandlerContribution {
237                action_name: "action:magit-submodule-sync",
238                handler: Arc::new(|ctx: &ActionContext<'_>| {
239                    let entry = entry_at_cursor(ctx)?;
240                    let path = entry.path.clone();
241                    let what = format!("sync submodule {path}");
242                    spawn_submodule_mutation(ctx, what, move |repo| {
243                        Submodule::sync(repo, Some(&path))
244                    })?;
245                    None
246                }),
247            },
248            // remove (d) — asks, and the ask half performs no git call
249            // at all. Unlike magit-remote's `d`, this one deletes a
250            // whole working tree that git keeps no copy of.
251            ActionHandlerContribution {
252                action_name: "action:magit-submodule-remove",
253                handler: Arc::new(|ctx: &ActionContext<'_>| {
254                    let entry = entry_at_cursor(ctx)?;
255                    Some(remove_confirm(&entry.path))
256                }),
257            },
258            ActionHandlerContribution {
259                action_name: "action:magit-submodule-remove-execute",
260                handler: Arc::new(|ctx: &ActionContext<'_>| {
261                    // IX.2: act on the submodule the prompt named. The
262                    // cursor is consulted only when nothing was
263                    // carried — a refresh can rebuild the list while
264                    // the dialog is open, and then the row means a
265                    // different submodule.
266                    let path = match crate::confirm::carried_target(ctx) {
267                        Some(carried) => carried,
268                        None => entry_at_cursor(ctx)?.path,
269                    };
270                    let what = format!("remove submodule {path}");
271                    spawn_submodule_mutation(ctx, what, move |repo| {
272                        Submodule::remove(repo, &path)
273                    })?;
274                    None
275                }),
276            },
277        ]
278    }
279
280    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
281        Box::pin(async move {
282            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
283            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
284            let Some(store) = ctx.service::<BufferStoreHandle>() else {
285                return Ok(orphan());
286            };
287            let Some(handle) = store.handle_for(buffer_id) else {
288                return Ok(orphan());
289            };
290            // MR.3: the repository the trigger resolved for THIS
291            // buffer, not the one the editor was started in.
292            let workdir =
293                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
294            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
295
296            let (hl, hl_registration) =
297                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
298                    Some((h, reg)) => (Some(h), Some(reg)),
299                    None => (None, None),
300                };
301
302            // MG.13: publish BEFORE the first `.await`.
303            let Some(states) = ctx.service::<SubmoduleStatesHandle>() else {
304                return Ok(orphan());
305            };
306            let state = states.publish(
307                buffer_id,
308                SubmoduleState {
309                    buffer_id,
310                    store: store.clone(),
311                    workdir: workdir.clone(),
312                    pending_highlights: pending_highlights.clone(),
313                    headerline: hl.clone(),
314                    entries: Vec::new(),
315                },
316            );
317            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
318                .with_headerline(hl_registration);
319            if let Some(views) = ctx.service::<MagitViewsHandle>() {
320                views.publish(buffer_id, Arc::new(SubmoduleView(state.clone())));
321                guard = guard.with_views((*views).clone());
322            }
323
324            let wd = workdir.clone();
325            let (text, header, entries) =
326                tokio::task::spawn_blocking(move || build_submodule_list(&wd))
327                    .await
328                    .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
329            if let Ok(mut g) = state.lock() {
330                g.entries = entries;
331            }
332            headerline::publish(&hl, header);
333            let spans = crate::highlight::submodule_styled_spans(&text);
334            crate::buffer_io::replace_buffer_text(&handle, text).await;
335            if let Some(ref ph) = pending_highlights {
336                ph.store_and_wake(buffer_id, spans);
337            }
338
339            Ok(guard)
340        })
341    }
342}
343
344fn refresh(s: Arc<Mutex<SubmoduleState>>) -> Option<Effect> {
345    let (handle, wd, pending, buffer_id, hl) = {
346        let g = s.lock().ok()?;
347        (
348            g.store.handle_for(g.buffer_id)?,
349            g.workdir.clone(),
350            g.pending_highlights.clone(),
351            g.buffer_id,
352            g.headerline.clone(),
353        )
354    };
355    // MG.27: the row says "refreshing" from here until the
356    // guard drops — including on every early exit inside the
357    // task, which is why it is a guard and not a matching pair.
358    let busy = headerline::busy(&hl);
359    tokio::task::spawn(async move {
360        let _busy = busy;
361        let (text, header, entries) =
362            tokio::task::spawn_blocking(move || build_submodule_list(&wd))
363                .await
364                .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
365        if let Ok(mut g) = s.lock() {
366            g.entries = entries;
367        }
368        headerline::publish(&hl, header);
369        let spans = crate::highlight::submodule_styled_spans(&text);
370        crate::buffer_io::replace_buffer_text(&handle, text).await;
371        if let Some(ph) = pending {
372            ph.store_and_wake(buffer_id, spans);
373        }
374    });
375    None
376}
377
378/// Run one submodule git call off the actor thread, then re-list every
379/// live submodule buffer.
380///
381/// Reached through the service rather than `ctx.buffer_id` for the same
382/// reason `magit_remote_mode` does it: the `-finish` halves fire in the
383/// PROMPT buffer, and the confirm's execute half fires wherever the
384/// confirm transient left focus.
385fn spawn_submodule_mutation(
386    ctx: &ActionContext<'_>,
387    what: String,
388    mutate: impl FnOnce(&Repository) -> lattice_vcs::Result<()> + Send + 'static,
389) -> Option<()> {
390    let states = ctx.services.get::<SubmoduleStatesHandle>()?;
391    let targets = states.all();
392    let workdir = crate::repo_scope::action_workdir(ctx);
393    // No guard here: this task's own refreshes are what the
394    // user sees, and each `refresh(target)` below raises and
395    // clears the busy flag on its OWN row. Marking busy here
396    // too would need a second guard per target and would clear
397    // on a different schedule.
398    tokio::task::spawn(async move {
399        let wd = workdir.clone();
400        let outcome = tokio::task::spawn_blocking(move || match Repository::discover(&wd) {
401            Ok(repo) => mutate(&repo),
402            Err(e) => Err(lattice_vcs::VcsError::Submodule(format!(
403                "no repository at {}: {e}",
404                wd.display()
405            ))),
406        })
407        .await;
408        // MG.54: publish, don't just log — same migration onto
409        // `finish_task` the remote list needed, and for the same reason:
410        // `a` / `u` / `s` / `d` here finish where only a log reader
411        // would see them.
412        crate::magit_global_mode::finish_task(
413            &workdir,
414            &what,
415            match outcome {
416                Ok(Ok(())) => Ok(String::new()),
417                Ok(Err(e)) => Err(e.to_string()),
418                Err(e) => Err(format!("panicked: {e}")),
419            },
420        );
421        for target in targets {
422            refresh(target);
423        }
424    });
425    Some(())
426}
427
428/// MG.21i: the ask half of `d`. Names the submodule and says what is
429/// lost, because the working tree it deletes may hold uncommitted work
430/// git has no copy of.
431pub(crate) fn remove_confirm(path: &str) -> Effect {
432    crate::confirm::ask_target(
433        format!("Remove submodule {path}? Its working tree is deleted."),
434        "action:magit-submodule-remove-execute",
435        path,
436    )
437}
438
439const ADD_PREFIX: &str = "*magit:submodule-add:";
440
441pub(crate) fn add_prompt_buffer_name(url: &str) -> String {
442    format!("{ADD_PREFIX}{url}*")
443}
444
445pub(crate) fn url_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
446    let s = buffer_name.strip_prefix(ADD_PREFIX)?;
447    let s = s.strip_suffix('*')?;
448    (!s.is_empty()).then(|| s.to_string())
449}
450
451fn carried_url(ctx: &ActionContext<'_>) -> Option<String> {
452    let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
453    ctx.services
454        .get::<BufferStoreHandle>()?
455        .name_for(buffer_id)
456        .and_then(|n| url_from_prompt_buffer_name(&n))
457}
458
459/// The path `git submodule add` would choose for `url` — its last
460/// segment with any `.git` suffix and trailing slash removed.
461pub(crate) fn default_path_for(url: &str) -> String {
462    url.trim_end_matches('/')
463        .rsplit(['/', ':'])
464        .next()
465        .unwrap_or("")
466        .trim_end_matches(".git")
467        .to_string()
468}
469
470fn entry_at_cursor(ctx: &ActionContext<'_>) -> Option<SubmoduleEntry> {
471    let s = state(ctx)?;
472    let g = s.lock().ok()?;
473    submodule_at_line(&g.entries, ctx.cursor)
474}
475
476/// Map a cursor line onto a rendered submodule. Line 0 is the heading,
477/// so row `i` is entry `i - 1`.
478pub(crate) fn submodule_at_line(
479    entries: &[SubmoduleEntry],
480    cursor: Position,
481) -> Option<SubmoduleEntry> {
482    let index = (cursor.line as usize).checked_sub(1)?;
483    entries.get(index).cloned()
484}
485
486fn build_submodule_list(workdir: &std::path::Path) -> (String, Vec<Field>, Vec<SubmoduleEntry>) {
487    let repo = match Repository::discover(workdir) {
488        Ok(r) => r,
489        Err(_) => {
490            return (
491                "Not a git repository.\n".to_string(),
492                Vec::new(),
493                Vec::new(),
494            );
495        }
496    };
497    let entries = match Submodule::list(&repo) {
498        Ok(e) => e,
499        Err(e) => {
500            tracing::error!("magit-submodule: listing submodules failed: {e}");
501            Vec::new()
502        }
503    };
504    let header = headerline::submodule_fields(&entries);
505    (render_submodule_list(&entries), header, entries)
506}
507
508/// The buffer text for `entries`.
509///
510/// `  <marker> <short-sha> <path><describe>` — git's own marker in
511/// git's own column, so a row reads the same here as in `git submodule
512/// status`. Pure, so the layout the cursor mapping and the styler both
513/// depend on is testable without a repository.
514pub(crate) fn render_submodule_list(entries: &[SubmoduleEntry]) -> String {
515    if entries.is_empty() {
516        return "No submodules.\n".to_string();
517    }
518    let width = entries
519        .iter()
520        .map(|e| e.path.chars().count())
521        .max()
522        .unwrap_or(0);
523    let mut out = format!("Submodules ({})\n", entries.len());
524    for e in entries {
525        let short: String = e.sha.chars().take(7).collect();
526        let pad = width.saturating_sub(e.path.chars().count());
527        out.push_str(&format!(
528            "  {} {}  {}{}",
529            e.state.marker(),
530            short,
531            e.path,
532            " ".repeat(pad)
533        ));
534        if !e.describe.is_empty() {
535            out.push_str(&format!("  ({})", e.describe));
536        }
537        // Trailing padding with no describe would be invisible
538        // whitespace; trim it back off.
539        while out.ends_with(' ') {
540            out.pop();
541        }
542        out.push('\n');
543    }
544    out.push('\n');
545    out
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use lattice_vcs::SubmoduleState as St;
552
553    fn entry(state: St, sha: &str, path: &str, describe: &str) -> SubmoduleEntry {
554        SubmoduleEntry {
555            state,
556            sha: sha.into(),
557            path: path.into(),
558            describe: describe.into(),
559        }
560    }
561
562    fn at(line: u32) -> Position {
563        Position { line, byte: 0 }
564    }
565
566    #[test]
567    fn the_heading_row_maps_to_no_submodule() {
568        let entries = vec![entry(St::InSync, "abc1234567", "vendor/x", "")];
569        assert!(submodule_at_line(&entries, at(0)).is_none());
570    }
571
572    #[test]
573    fn each_row_maps_to_its_own_submodule() {
574        let entries = vec![
575            entry(St::InSync, "aaa1111111", "vendor/a", ""),
576            entry(St::Modified, "bbb2222222", "vendor/b", ""),
577        ];
578        assert_eq!(submodule_at_line(&entries, at(1)).unwrap().path, "vendor/a");
579        assert_eq!(submodule_at_line(&entries, at(2)).unwrap().path, "vendor/b");
580    }
581
582    #[test]
583    fn out_of_range_rows_map_to_nothing() {
584        let entries = vec![entry(St::InSync, "abc1234567", "vendor/x", "")];
585        assert!(submodule_at_line(&entries, at(2)).is_none());
586        assert!(submodule_at_line(&[], at(1)).is_none());
587    }
588
589    #[test]
590    fn a_row_carries_gits_marker_the_short_sha_and_the_path() {
591        let text = render_submodule_list(&[
592            entry(St::Uninitialised, "abc1234567890", "vendor/a", ""),
593            entry(St::InSync, "def1234567890", "vendor/bb", "v1.2.3"),
594        ]);
595        let lines: Vec<&str> = text.lines().collect();
596        assert_eq!(lines[0], "Submodules (2)");
597        assert_eq!(lines[1], "  - abc1234  vendor/a");
598        assert_eq!(lines[2], "    def1234  vendor/bb  (v1.2.3)");
599    }
600
601    #[test]
602    fn no_submodules_renders_a_sentence_not_an_empty_buffer() {
603        assert_eq!(render_submodule_list(&[]), "No submodules.\n");
604    }
605
606    /// The rendered rows and the cursor mapping must agree, or a chord
607    /// acts on a submodule other than the one under the cursor — and
608    /// one of these chords deletes a working tree.
609    #[test]
610    fn the_rendered_row_index_matches_the_cursor_mapping() {
611        let entries = vec![
612            entry(St::InSync, "aaa1111111", "a", ""),
613            entry(St::Modified, "bbb2222222", "bb", "v1"),
614            entry(St::Conflicted, "ccc3333333", "ccc", ""),
615        ];
616        let text = render_submodule_list(&entries);
617        for (i, line) in text.lines().enumerate().skip(1).take(entries.len()) {
618            let mapped = submodule_at_line(&entries, at(i as u32)).expect("row maps");
619            assert!(
620                line.contains(&mapped.path),
621                "line {i} ({line:?}) does not name the mapped submodule {:?}",
622                mapped.path
623            );
624        }
625    }
626
627    #[test]
628    fn remove_asks_before_deleting_and_names_the_submodule() {
629        match remove_confirm("vendor/child") {
630            Effect::Confirm {
631                prompt, yes_action, ..
632            } => {
633                assert!(prompt.contains("vendor/child"), "{prompt}");
634                assert!(
635                    prompt.contains("working tree is deleted"),
636                    "the question must say what is lost: {prompt}"
637                );
638                assert_eq!(yes_action, "action:magit-submodule-remove-execute");
639            }
640            other => panic!("expected a confirm before a destructive remove, got {other:?}"),
641        }
642    }
643
644    #[test]
645    fn the_add_prompt_seeds_the_path_git_would_have_chosen() {
646        assert_eq!(default_path_for("https://example.com/foo/bar.git"), "bar");
647        assert_eq!(default_path_for("git@example.com:foo/bar.git"), "bar");
648        assert_eq!(default_path_for("https://example.com/foo/bar/"), "bar");
649        assert_eq!(default_path_for("../sibling"), "sibling");
650    }
651
652    #[test]
653    fn the_add_prompt_carries_its_url_and_nothing_elses() {
654        let name = add_prompt_buffer_name("https://example.com/x.git");
655        assert_eq!(
656            url_from_prompt_buffer_name(&name).as_deref(),
657            Some("https://example.com/x.git")
658        );
659        assert!(url_from_prompt_buffer_name("*magit:status*").is_none());
660        assert!(url_from_prompt_buffer_name(ADD_PREFIX).is_none());
661    }
662}