Skip to main content

lattice_magit/
magit_remote_mode.rs

1//! MG.21c: magit-remote major mode.
2//!
3//! Lists the configured remotes with their fetch and push URLs, and
4//! manages them from the row under the cursor: `a` add, `r` rename,
5//! `d` remove, `u` set-url, `p` prune.
6//!
7//! **Why a buffer and not a transient.** Magit puts remote management
8//! on the `M` transient, rendering `remote.<name>.url` as *variable
9//! rows* inside the menu. Lattice's transient has no variable-row
10//! concept, so a straight port would leave the URLs — the thing you
11//! actually open this for — invisible, and every operation would be a
12//! name typed blind. A buffer is what makes the list readable, `/`
13//! searchable, `y`-yankable and `gr`-refreshable, which is paramount
14//! goal #3's everything-is-a-buffer claim rather than a convenience.
15//! `M` on the root dispatch opens this buffer (MG.21d).
16//!
17//! Fetch / pull / push are NOT here. Those are `magit_global_mode`'s
18//! `RemoteOp` — long-running network operations with their own flag
19//! transients. Everything on this mode is a local config edit, except
20//! `p` prune, which is the one row that talks to the network.
21
22use std::sync::{Arc, Mutex, OnceLock};
23
24use lattice_config;
25use lattice_grammar::{EchoLevel, Effect};
26use lattice_mode::{
27    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
28    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
29    keymap_entry,
30};
31use lattice_protocol::position::Position;
32use lattice_vcs::{Remote, RemoteEntry, Repository};
33
34use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
35use crate::headerline::{self, Field, MagitHeaderlineHandle};
36
37pub struct MagitRemoteMode;
38
39impl MagitRemoteMode {
40    pub fn mode_id() -> ModeId {
41        ModeId::new("magit-remote-mode")
42    }
43}
44
45/// The chords, and why they are these chords.
46///
47/// Magit's own `magit-remote` transient uses `a` add, `r` rename, `k`
48/// remove, `p` prune, and `u` for the url variable row. Four of the
49/// five carry over unchanged. **`k` does not**: it is the up-motion,
50/// and evil-collection-magit moves every magit `k` off it for exactly
51/// that reason (`feedback_magit_keys_follow_evil_magit`). Removal lands
52/// on `d` instead — which is also what magit-branch's `d` (delete) and
53/// magit-stash's `d` (drop) already mean, so "d removes the row under
54/// the cursor" is one rule across all three list buffers rather than a
55/// third variant.
56///
57/// `<CR>` is deliberately unbound. There is no obvious thing to *open*
58/// a remote into, and a chord that does nothing is worse than an
59/// absent one.
60fn magit_remote_keymap_entries() -> &'static [KeymapEntry] {
61    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
62    ENTRIES.get_or_init(|| {
63        vec![
64            keymap_entry! { mode: Normal, chord: "a", doc: "Add a remote", cmd: "action:magit-remote-add" },
65            keymap_entry! { mode: Normal, chord: "r", doc: "Rename the remote at cursor", cmd: "action:magit-remote-rename" },
66            keymap_entry! { mode: Normal, chord: "d", doc: "Remove the remote at cursor", cmd: "action:magit-remote-remove" },
67            keymap_entry! { mode: Normal, chord: "u", doc: "Set the URL of the remote at cursor", cmd: "action:magit-remote-set-url" },
68            keymap_entry! { mode: Normal, chord: "p", doc: "Prune stale branches of the remote at cursor", cmd: "action:magit-remote-prune" },
69        ]
70    })
71}
72
73pub struct RemoteState {
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 remotes as last rendered, in render order.
80    ///
81    /// The cursor→remote mapping reads THIS rather than re-parsing the
82    /// line under the cursor. Re-parsing would make the header row
83    /// ("Remotes (2)") decode to a remote named `Remotes`, which is the
84    /// class of bug that reaches git before anything notices.
85    entries: Vec<RemoteEntry>,
86}
87
88/// MG.13: service alias for this mode's per-buffer state. Register and
89/// look up through this exact type (`feedback_servicesregistry_arc_typeid`).
90pub type RemoteStatesHandle = Arc<BufferStates<RemoteState>>;
91
92fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<RemoteState>>> {
93    crate::buffer_state::state_for::<RemoteState>(ctx)
94}
95
96/// `gr` for a remote buffer — see [`MagitView`].
97struct RemoteView(Arc<Mutex<RemoteState>>);
98
99impl MagitView for RemoteView {
100    fn refresh(&self) -> Option<Effect> {
101        refresh(self.0.clone())
102    }
103}
104
105impl Mode for MagitRemoteMode {
106    type Guard = BufferStateGuard<RemoteState>;
107
108    fn id(&self) -> ModeId {
109        Self::mode_id()
110    }
111    fn kind(&self) -> ModeKind {
112        ModeKind::Major
113    }
114    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
115        None
116    }
117
118    fn options(&self) -> OptionOverrideSet {
119        lattice_config::overrides! {
120            lattice_config::ReadOnly = true,
121            lattice_config::NoFile = true,
122        }
123    }
124
125    /// MG.RO: `read-only-mode` is where the gate actually is.
126    ///
127    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
128    /// `read_only_edit_rejected`, which guards the insert-mode char path;
129    /// operators never reach it, because a `Document`'s grammar dispatch
130    /// applies its own edits and hands the host an already-applied
131    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
132    /// the buffer reported itself read-only — worse than not gating at all,
133    /// because it looks protected.
134    ///
135    /// `read-only-mode` carries the option AND the `invocation_runner`
136    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
137    /// letting motions, `:` and `/` through.
138    ///
139    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
140    /// mode is followed from the mode being ACTIVATED, and the majors are what
141    /// the host activates. Putting it on the shared minor looked right and was
142    /// verified not to fire.
143    fn implies(&self) -> &[lattice_mode::ModeId] {
144        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
145        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
146    }
147
148    fn required_capabilities(&self) -> CapabilitySet {
149        CapabilitySet::empty()
150    }
151    fn keymap(&self) -> Keymap {
152        Keymap::from_entries(magit_remote_keymap_entries())
153    }
154
155    /// MG.13: registered once at boot, not per activation.
156    ///
157    /// The `-finish` halves are deliberately NOT gated on [`state`]:
158    /// they fire with the prompt buffer's id, where no remote state
159    /// exists. They reach their buffers through the service registry
160    /// instead ([`BufferStates::all`]), which is context-free.
161    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
162        vec![
163            // add (a) — two prompts, magit's own order: name, then URL.
164            // The name rides in the second prompt's buffer name because
165            // by submit time the prompt buffer is the active one and
166            // nothing else still knows it.
167            ActionHandlerContribution {
168                action_name: "action:magit-remote-add",
169                handler: Arc::new(|ctx: &ActionContext<'_>| {
170                    let _ = state(ctx)?;
171                    Some(Effect::OpenPrompt {
172                        prompt: "Remote name: ".to_string(),
173                        initial: String::new(),
174                        on_submit_action: "action:magit-remote-add-url".to_string(),
175                        buffer_name: None,
176                    })
177                }),
178            },
179            ActionHandlerContribution {
180                action_name: "action:magit-remote-add-url",
181                handler: Arc::new(|ctx: &ActionContext<'_>| {
182                    let name = ctx.prompt_value?.trim().to_string();
183                    // An empty name is a cancel, not a request to add a
184                    // remote called "".
185                    if name.is_empty() {
186                        return None;
187                    }
188                    Some(Effect::OpenPrompt {
189                        prompt: format!("URL for {name}: "),
190                        initial: String::new(),
191                        on_submit_action: "action:magit-remote-add-finish".to_string(),
192                        buffer_name: Some(add_prompt_buffer_name(&name)),
193                    })
194                }),
195            },
196            ActionHandlerContribution {
197                action_name: "action:magit-remote-add-finish",
198                handler: Arc::new(|ctx: &ActionContext<'_>| {
199                    let url = ctx.prompt_value?.trim().to_string();
200                    let name = carried_name(ctx, ADD_PREFIX)?;
201                    if url.is_empty() {
202                        return None;
203                    }
204                    let what = format!("add remote {name}");
205                    spawn_remote_mutation(ctx, what, move |repo| Remote::add(repo, &name, &url))?;
206                    None
207                }),
208            },
209            // rename (r) — seeded with the current name so a typo fix
210            // is an edit rather than a retype.
211            ActionHandlerContribution {
212                action_name: "action:magit-remote-rename",
213                handler: Arc::new(|ctx: &ActionContext<'_>| {
214                    let entry = entry_at_cursor(ctx)?;
215                    Some(Effect::OpenPrompt {
216                        prompt: format!("Rename {} to: ", entry.name),
217                        initial: entry.name.clone(),
218                        on_submit_action: "action:magit-remote-rename-finish".to_string(),
219                        buffer_name: Some(rename_prompt_buffer_name(&entry.name)),
220                    })
221                }),
222            },
223            ActionHandlerContribution {
224                action_name: "action:magit-remote-rename-finish",
225                handler: Arc::new(|ctx: &ActionContext<'_>| {
226                    let to = ctx.prompt_value?.trim().to_string();
227                    let from = carried_name(ctx, RENAME_PREFIX)?;
228                    // Submitting the seeded value unchanged is a
229                    // cancel; git would fail with "remote <x> already
230                    // exists", which reads like a bug rather than a
231                    // no-op.
232                    if to.is_empty() || to == from {
233                        return None;
234                    }
235                    let what = format!("rename remote {from} to {to}");
236                    spawn_remote_mutation(ctx, what, move |repo| Remote::rename(repo, &from, &to))?;
237                    None
238                }),
239            },
240            // set-url (u) — seeded with the current fetch URL.
241            ActionHandlerContribution {
242                action_name: "action:magit-remote-set-url",
243                handler: Arc::new(|ctx: &ActionContext<'_>| {
244                    let entry = entry_at_cursor(ctx)?;
245                    Some(Effect::OpenPrompt {
246                        prompt: format!("URL for {}: ", entry.name),
247                        initial: entry.fetch_url.clone(),
248                        on_submit_action: "action:magit-remote-set-url-finish".to_string(),
249                        buffer_name: Some(set_url_prompt_buffer_name(&entry.name)),
250                    })
251                }),
252            },
253            ActionHandlerContribution {
254                action_name: "action:magit-remote-set-url-finish",
255                handler: Arc::new(|ctx: &ActionContext<'_>| {
256                    let url = ctx.prompt_value?.trim().to_string();
257                    let name = carried_name(ctx, SET_URL_PREFIX)?;
258                    if url.is_empty() {
259                        return None;
260                    }
261                    let what = format!("change the URL of remote {name}");
262                    spawn_remote_mutation(ctx, what, move |repo| {
263                        Remote::set_url(repo, &name, &url)
264                    })?;
265                    None
266                }),
267            },
268            // remove (d) — no confirmation, deliberately. §12.13 routes
269            // *irrecoverable* operations through ask/execute; removing
270            // a remote drops config and tracking refs that `a` puts
271            // back in two prompts, and the URL it needs is on the row
272            // in front of you when you press it. Asking here would
273            // train the user to dismiss the prompt that does matter
274            // (`Oh`, reset --hard). Magit does not confirm it either.
275            ActionHandlerContribution {
276                action_name: "action:magit-remote-remove",
277                handler: Arc::new(|ctx: &ActionContext<'_>| {
278                    let entry = entry_at_cursor(ctx)?;
279                    let name = entry.name.clone();
280                    let what = format!("remove remote {name}");
281                    spawn_remote_mutation(ctx, what, move |repo| Remote::remove(repo, &name))?;
282                    None
283                }),
284            },
285            // prune (p) — the one row here that talks to the network,
286            // so it echoes: unlike the others there is nothing on
287            // screen to change until it returns, and it can take as
288            // long as a fetch.
289            ActionHandlerContribution {
290                action_name: "action:magit-remote-prune",
291                handler: Arc::new(|ctx: &ActionContext<'_>| {
292                    let entry = entry_at_cursor(ctx)?;
293                    let name = entry.name.clone();
294                    let echo = format!("pruning {name}\u{2026}");
295                    let what = format!("prune remote {name}");
296                    spawn_remote_mutation(ctx, what, move |repo| Remote::prune(repo, &name))?;
297                    Some(Effect::Echo {
298                        level: EchoLevel::Info,
299                        text: echo,
300                    })
301                }),
302            },
303        ]
304    }
305
306    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
307        Box::pin(async move {
308            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
309            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
310            let Some(store) = ctx.service::<BufferStoreHandle>() else {
311                return Ok(orphan());
312            };
313            let Some(handle) = store.handle_for(buffer_id) else {
314                return Ok(orphan());
315            };
316            // MR.3: the repository the trigger resolved for THIS
317            // buffer, not the one the editor was started in.
318            let workdir =
319                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
320            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
321
322            let (hl, hl_registration) =
323                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
324                    Some((h, reg)) => (Some(h), Some(reg)),
325                    None => (None, None),
326                };
327
328            // MG.13: publish BEFORE the first `.await` — see
329            // magit_branch_mode's note on the dead-chord window.
330            let Some(states) = ctx.service::<RemoteStatesHandle>() else {
331                return Ok(orphan());
332            };
333            let state = states.publish(
334                buffer_id,
335                RemoteState {
336                    buffer_id,
337                    store: store.clone(),
338                    workdir: workdir.clone(),
339                    pending_highlights: pending_highlights.clone(),
340                    headerline: hl.clone(),
341                    entries: Vec::new(),
342                },
343            );
344            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
345                .with_headerline(hl_registration);
346            if let Some(views) = ctx.service::<MagitViewsHandle>() {
347                views.publish(buffer_id, Arc::new(RemoteView(state.clone())));
348                guard = guard.with_views((*views).clone());
349            }
350
351            let wd = workdir.clone();
352            let (text, header, entries) =
353                tokio::task::spawn_blocking(move || build_remote_list(&wd))
354                    .await
355                    .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
356            if let Ok(mut g) = state.lock() {
357                g.entries = entries;
358            }
359            headerline::publish(&hl, header);
360            let spans = crate::highlight::remote_styled_spans(&text);
361            crate::buffer_io::replace_buffer_text(&handle, text).await;
362            if let Some(ref ph) = pending_highlights {
363                ph.store_and_wake(buffer_id, spans);
364            }
365
366            Ok(guard)
367        })
368    }
369}
370
371/// `gr` — re-list remotes without a prior mutation.
372fn refresh(s: Arc<Mutex<RemoteState>>) -> Option<Effect> {
373    let (handle, wd, pending, buffer_id, hl) = {
374        let g = s.lock().ok()?;
375        (
376            g.store.handle_for(g.buffer_id)?,
377            g.workdir.clone(),
378            g.pending_highlights.clone(),
379            g.buffer_id,
380            g.headerline.clone(),
381        )
382    };
383    // MG.27: the row says "refreshing" from here until the
384    // guard drops — including on every early exit inside the
385    // task, which is why it is a guard and not a matching pair.
386    let busy = headerline::busy(&hl);
387    tokio::task::spawn(async move {
388        let _busy = busy;
389        let (text, header, entries) = tokio::task::spawn_blocking(move || build_remote_list(&wd))
390            .await
391            .unwrap_or_else(|_| (String::new(), Vec::new(), Vec::new()));
392        if let Ok(mut g) = s.lock() {
393            g.entries = entries;
394        }
395        headerline::publish(&hl, header);
396        let spans = crate::highlight::remote_styled_spans(&text);
397        crate::buffer_io::replace_buffer_text(&handle, text).await;
398        if let Some(ph) = pending {
399            ph.store_and_wake(buffer_id, spans);
400        }
401    });
402    None
403}
404
405/// Run one remote-management git call off the actor thread, then
406/// re-list every live remote buffer.
407///
408/// Reaching the buffers through the service rather than through
409/// `ctx.buffer_id` is what lets the `-finish` handlers — which fire in
410/// the *prompt* buffer — put their result on screen. The chord-fired
411/// handlers go through the same path so there is one shape, not two.
412///
413/// Returns `Some(())` when the work was scheduled, so callers can `?`
414/// it and then decide what to echo.
415fn spawn_remote_mutation(
416    ctx: &ActionContext<'_>,
417    what: String,
418    mutate: impl FnOnce(&Repository) -> lattice_vcs::Result<()> + Send + 'static,
419) -> Option<()> {
420    let states = ctx.services.get::<RemoteStatesHandle>()?;
421    let targets = states.all();
422    let workdir = crate::repo_scope::action_workdir(ctx);
423    // No guard here: this task's own refreshes are what the
424    // user sees, and each `refresh(target)` below raises and
425    // clears the busy flag on its OWN row. Marking busy here
426    // too would need a second guard per target and would clear
427    // on a different schedule.
428    tokio::task::spawn(async move {
429        let wd = workdir.clone();
430        let outcome = tokio::task::spawn_blocking(move || match Repository::discover(&wd) {
431            Ok(repo) => mutate(&repo),
432            Err(e) => Err(lattice_vcs::VcsError::Remote(format!(
433                "no repository at {}: {e}",
434                wd.display()
435            ))),
436        })
437        .await;
438        // MG.54: publish, don't just log. The comment that used to sit
439        // here read "there is no synchronous path back to the echo area
440        // from a detached task (§4.6) — the log is the report", and
441        // that was true when it was written. MG.41g built exactly that
442        // path: `finish_task` logs AND publishes
443        // `BackgroundTaskFinished`, which the notification layer
444        // subscribes to. This helper was simply never migrated onto it,
445        // so `a` / `r` / `d` / `u` / `p` in the remote list finished
446        // where only a log reader would see it.
447        crate::magit_global_mode::finish_task(
448            &workdir,
449            &what,
450            match outcome {
451                Ok(Ok(())) => Ok(String::new()),
452                Ok(Err(e)) => Err(e.to_string()),
453                Err(e) => Err(format!("panicked: {e}")),
454            },
455        );
456        for target in targets {
457            refresh(target);
458        }
459    });
460    Some(())
461}
462
463const ADD_PREFIX: &str = "*magit:remote-add:";
464const RENAME_PREFIX: &str = "*magit:remote-rename:";
465const SET_URL_PREFIX: &str = "*magit:remote-set-url:";
466
467pub(crate) fn add_prompt_buffer_name(name: &str) -> String {
468    format!("{ADD_PREFIX}{name}*")
469}
470pub(crate) fn rename_prompt_buffer_name(name: &str) -> String {
471    format!("{RENAME_PREFIX}{name}*")
472}
473pub(crate) fn set_url_prompt_buffer_name(name: &str) -> String {
474    format!("{SET_URL_PREFIX}{name}*")
475}
476
477/// Decode the remote name a prompt was opened for out of the prompt
478/// buffer's own name — the carrier `magit_global_mode`'s rename and
479/// checkout prompts already use.
480pub(crate) fn name_from_prompt_buffer_name(buffer_name: &str, prefix: &str) -> Option<String> {
481    let s = buffer_name.strip_prefix(prefix)?;
482    let s = s.strip_suffix('*')?;
483    (!s.is_empty()).then(|| s.to_string())
484}
485
486fn carried_name(ctx: &ActionContext<'_>, prefix: &str) -> Option<String> {
487    let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
488    ctx.services
489        .get::<BufferStoreHandle>()?
490        .name_for(buffer_id)
491        .and_then(|n| name_from_prompt_buffer_name(&n, prefix))
492}
493
494fn entry_at_cursor(ctx: &ActionContext<'_>) -> Option<RemoteEntry> {
495    let s = state(ctx)?;
496    let g = s.lock().ok()?;
497    remote_at_line(&g.entries, ctx.cursor)
498}
499
500/// Map a cursor line onto a rendered remote.
501///
502/// Line 0 is the `Remotes (N)` heading, so row `i` is entry `i - 1`.
503/// Out of range — the heading, the trailing blank, an empty list — is
504/// `None`, and the handler no-ops.
505pub(crate) fn remote_at_line(entries: &[RemoteEntry], cursor: Position) -> Option<RemoteEntry> {
506    let index = (cursor.line as usize).checked_sub(1)?;
507    entries.get(index).cloned()
508}
509
510/// Render the remote list, its headerline fields, and the entries the
511/// cursor mapping reads — all from one `git remote -v`.
512fn build_remote_list(workdir: &std::path::Path) -> (String, Vec<Field>, Vec<RemoteEntry>) {
513    let repo = match Repository::discover(workdir) {
514        Ok(r) => r,
515        Err(_) => {
516            return (
517                "Not a git repository.\n".to_string(),
518                Vec::new(),
519                Vec::new(),
520            );
521        }
522    };
523    let entries = match Remote::list(&repo) {
524        Ok(e) => e,
525        Err(e) => {
526            tracing::error!("magit-remote: listing remotes failed: {e}");
527            Vec::new()
528        }
529    };
530    let header = headerline::remote_fields(entries.len());
531    (render_remote_list(&entries), header, entries)
532}
533
534/// The buffer text for `entries`.
535///
536/// Pure, so the layout the cursor mapping and the styler both depend on
537/// is testable without a repository.
538pub(crate) fn render_remote_list(entries: &[RemoteEntry]) -> String {
539    if entries.is_empty() {
540        return "No remotes.\n".to_string();
541    }
542    let width = entries
543        .iter()
544        .map(|e| e.name.chars().count())
545        .max()
546        .unwrap_or(0);
547    let mut out = format!("Remotes ({})\n", entries.len());
548    for e in entries {
549        let pad = width.saturating_sub(e.name.chars().count());
550        out.push_str(&format!("  {}{}  {}", e.name, " ".repeat(pad), e.fetch_url));
551        // The push URL is shown only when it differs — printing an
552        // identical URL twice on every row would bury the one case
553        // that matters.
554        if e.push_url != e.fetch_url {
555            out.push_str(&format!("  (push: {})", e.push_url));
556        }
557        out.push('\n');
558    }
559    out.push('\n');
560    out
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn entry(name: &str, fetch: &str, push: &str) -> RemoteEntry {
568        RemoteEntry {
569            name: name.into(),
570            fetch_url: fetch.into(),
571            push_url: push.into(),
572        }
573    }
574
575    fn at(line: u32) -> Position {
576        Position { line, byte: 0 }
577    }
578
579    #[test]
580    fn the_heading_row_maps_to_no_remote() {
581        // The bug this exists to prevent: parsing the line under the
582        // cursor would decode "Remotes (1)" as a remote called
583        // `Remotes` and hand it to `git remote remove`.
584        let entries = vec![entry("origin", "u", "u")];
585        assert!(remote_at_line(&entries, at(0)).is_none());
586    }
587
588    #[test]
589    fn each_row_maps_to_its_own_remote() {
590        let entries = vec![entry("origin", "u1", "u1"), entry("upstream", "u2", "u2")];
591        assert_eq!(remote_at_line(&entries, at(1)).unwrap().name, "origin");
592        assert_eq!(remote_at_line(&entries, at(2)).unwrap().name, "upstream");
593    }
594
595    #[test]
596    fn the_trailing_blank_line_maps_to_no_remote() {
597        let entries = vec![entry("origin", "u", "u")];
598        assert!(remote_at_line(&entries, at(2)).is_none());
599        assert!(remote_at_line(&entries, at(99)).is_none());
600    }
601
602    #[test]
603    fn an_empty_list_maps_nothing() {
604        assert!(remote_at_line(&[], at(1)).is_none());
605    }
606
607    #[test]
608    fn rows_line_up_and_only_a_differing_push_url_is_printed() {
609        let text = render_remote_list(&[
610            entry("origin", "https://a.git", "https://a.git"),
611            entry("up", "https://b.git", "git@b.git"),
612        ]);
613        let lines: Vec<&str> = text.lines().collect();
614        assert_eq!(lines[0], "Remotes (2)");
615        assert_eq!(lines[1], "  origin  https://a.git");
616        assert_eq!(lines[2], "  up      https://b.git  (push: git@b.git)");
617        assert!(
618            !lines[1].contains("push:"),
619            "an identical push URL is not printed twice"
620        );
621    }
622
623    #[test]
624    fn no_remotes_renders_a_sentence_not_an_empty_buffer() {
625        assert_eq!(render_remote_list(&[]), "No remotes.\n");
626    }
627
628    /// The rendered rows and the cursor mapping have to agree, or a
629    /// chord acts on a remote other than the one under the cursor.
630    #[test]
631    fn the_rendered_row_index_matches_the_cursor_mapping() {
632        let entries = vec![
633            entry("a", "ua", "ua"),
634            entry("bb", "ub", "ub"),
635            entry("ccc", "uc", "uc"),
636        ];
637        let text = render_remote_list(&entries);
638        for (i, line) in text.lines().enumerate().skip(1).take(entries.len()) {
639            let mapped = remote_at_line(&entries, at(i as u32)).expect("row maps");
640            assert!(
641                line.trim_start().starts_with(&mapped.name),
642                "line {i} ({line:?}) does not start with the mapped remote {:?}",
643                mapped.name
644            );
645        }
646    }
647
648    #[test]
649    fn prompt_buffer_names_round_trip_the_remote_they_carry() {
650        for (built, prefix) in [
651            (add_prompt_buffer_name("origin"), ADD_PREFIX),
652            (rename_prompt_buffer_name("origin"), RENAME_PREFIX),
653            (set_url_prompt_buffer_name("origin"), SET_URL_PREFIX),
654        ] {
655            assert_eq!(
656                name_from_prompt_buffer_name(&built, prefix).as_deref(),
657                Some("origin"),
658                "{built} did not round-trip"
659            );
660        }
661    }
662
663    /// The three prompts must not decode each other's carriers — a
664    /// set-url finish reading a rename prompt would repoint the wrong
665    /// remote silently.
666    #[test]
667    fn a_prompt_carrier_does_not_decode_under_another_prefix() {
668        let rename = rename_prompt_buffer_name("origin");
669        assert!(name_from_prompt_buffer_name(&rename, SET_URL_PREFIX).is_none());
670        assert!(name_from_prompt_buffer_name(&rename, ADD_PREFIX).is_none());
671    }
672
673    #[test]
674    fn an_unrelated_buffer_name_carries_nothing() {
675        assert!(name_from_prompt_buffer_name("*magit:status*", RENAME_PREFIX).is_none());
676        assert!(name_from_prompt_buffer_name(RENAME_PREFIX, RENAME_PREFIX).is_none());
677    }
678}