Skip to main content

lattice_magit/
repo_scope.rs

1//! MR.2: which repository each magit buffer is acting on.
2//!
3//! A magit buffer's name carries the repository's *basename* because
4//! that is what a user recognises in `:ls`. A basename cannot round-trip
5//! to a path and two checkouts can share one, so the name is not the
6//! source of truth — this is (design §3.1). The trigger resolves the
7//! repository and records it here; the view reads it back when it
8//! activates, and (MR.4) every action body in that buffer reads it
9//! instead of re-resolving.
10//!
11//! **Keyed by buffer name, not id.** The trigger runs *before* the
12//! buffer exists — that is the whole reason a side channel is needed at
13//! all (`on_activate` cannot see what the trigger saw) — so there is no
14//! id to key on yet. `BufferStore::name_for` then makes id → name →
15//! workdir a lookup rather than a second map to keep in sync.
16//!
17//! **Not one-shot.** `ViewArgsRequests` and `BlameRequests`, the two
18//! side channels this shape comes from, are *taken* on activation
19//! because a request is for one activation. This one is read for the
20//! buffer's whole life: `s` in a status buffer stages into the repo the
21//! buffer is showing, every time it is pressed, or the buffer is worse
22//! than it was before MR.2 (design §4).
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex};
27
28use lattice_protocol::ids::DocumentId;
29
30/// Buffer name → the repository that buffer acts on.
31///
32/// The `DocumentId` index is not redundant with the name map:
33/// `Event::DocumentClosed` carries a `DocumentId`, and the two are not
34/// interchangeable — the same reason `ProjectDiffService` keeps its own
35/// `by_document`.
36#[derive(Default)]
37pub struct RepoScopes {
38    by_name: Mutex<HashMap<String, PathBuf>>,
39    by_document: Mutex<HashMap<DocumentId, String>>,
40    /// PR.5: the editor's project resolver, for the step-3 fallback.
41    ///
42    /// Here rather than threaded through [`active_workdir`] /
43    /// [`workdir_or_cwd`] because this handle is already carried to
44    /// every one of their ~15 call sites — threading a second one
45    /// alongside it would spend fifteen edits restating what this type
46    /// already is. This widens `RepoScopes` from "which repository each
47    /// buffer acts on" to "the context magit resolves repositories in",
48    /// which is what the three-step resolution in
49    /// [`crate::workdir::repo_for_trigger`] has always described.
50    ///
51    /// `None` in a harness that registered no resolver; the fallback
52    /// then behaves exactly as it did before PR.5.
53    resolver: Mutex<Option<lattice_core::ProjectResolverHandle>>,
54}
55
56impl std::fmt::Debug for RepoScopes {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("RepoScopes")
59            .field("tracked", &self.tracked())
60            .finish_non_exhaustive()
61    }
62}
63
64impl RepoScopes {
65    /// PR.5: hand the resolver over at boot.
66    ///
67    /// Separate from construction because `RepoScopes` is built in
68    /// magit's `install`, and the resolver is a service looked up from
69    /// the same `boot` — a constructor argument would just move the
70    /// `Option` to the call site.
71    pub fn set_resolver(&self, resolver: lattice_core::ProjectResolverHandle) {
72        if let Ok(mut slot) = self.resolver.lock() {
73            *slot = Some(resolver);
74        }
75    }
76
77    /// PR.5: where step 3 starts discovering from.
78    ///
79    /// The bug this fixes: magit's fallback was
80    /// `Repository::discover(".")` — the **process's** working
81    /// directory. `:cd` sets `editor.current_dir` and never calls
82    /// `set_current_dir`, so after `:cd /other/repo` a fresh `C-x g`
83    /// still opened the repository the editor was *launched* in.
84    ///
85    /// Returns a directory to discover *from*, not an answer: magit
86    /// needs a git worktree specifically, and the project root may not
87    /// be one. `gix` walking up from here preserves "None when not in a
88    /// repository" exactly as before.
89    pub fn discovery_start(&self) -> Option<PathBuf> {
90        let resolver = self.resolver.lock().ok()?.clone()?;
91        Some(resolver.for_path(std::path::Path::new("")).root)
92    }
93
94    /// Record (or re-point) the repository `name` acts on.
95    ///
96    /// Overwrites rather than accumulating: re-triggering `C-x g` for a
97    /// repository must find the buffer you already have, not stack a
98    /// second record behind it.
99    pub fn record(&self, name: impl Into<String>, workdir: PathBuf) {
100        if let Ok(mut m) = self.by_name.lock() {
101            m.insert(name.into(), workdir);
102        }
103    }
104
105    /// The repository `name` acts on, if one was recorded.
106    ///
107    /// `None` is a real answer, not a bug: a magit buffer reopened by
108    /// `:b` after a restart has a name but no record, and the view falls
109    /// back to resolving from scratch.
110    pub fn workdir_for(&self, name: &str) -> Option<PathBuf> {
111        self.by_name.lock().ok()?.get(name).cloned()
112    }
113
114    /// MR.3b: the repository behind a *label*, recovered from any magit
115    /// buffer already recorded against it.
116    ///
117    /// This is what lets a view opened from *inside* another magit
118    /// buffer — `<CR>` on a commit in the log, a file at a revision —
119    /// name itself correctly without reaching for services it does not
120    /// have. Those producers sit in helpers holding only their own
121    /// buffer's state, so all they can carry across is the label their
122    /// own name already spells; this turns that label back into a path.
123    ///
124    /// Sound because labels are unique among *open* magit buffers by
125    /// construction: two checkouts sharing a basename qualify at the
126    /// trigger ([`RepoScopes::collides`]) precisely so that one label
127    /// never names two repositories at once.
128    pub fn workdir_for_label(&self, label: &str) -> Option<PathBuf> {
129        let map = self.by_name.lock().ok()?;
130        map.iter()
131            .find(|(name, _)| {
132                crate::workdir::parse_magit_name(name).and_then(|n| n.repo) == Some(label)
133            })
134            .map(|(_, workdir)| workdir.clone())
135    }
136
137    /// Is `name` already recorded against a *different* repository?
138    ///
139    /// The collision question, asked by the trigger before it settles on
140    /// a name. Merging two repositories into one buffer is the worst
141    /// outcome available here — the staging chords would act on whichever
142    /// was recorded last (design §3.1).
143    pub fn collides(&self, name: &str, workdir: &Path) -> bool {
144        self.workdir_for(name)
145            .is_some_and(|recorded| recorded != workdir)
146    }
147
148    /// Index the document behind `name`, so closing the buffer drops the
149    /// record. Called by the view when it activates — the first moment
150    /// the document exists.
151    pub fn index_document(&self, document: DocumentId, name: impl Into<String>) {
152        if let Ok(mut m) = self.by_document.lock() {
153            m.insert(document, name.into());
154        }
155    }
156
157    /// Cleanup entry point for the `DocumentClosed` subscriber.
158    ///
159    /// Returns whether anything was dropped, which is what makes the
160    /// wiring testable without reaching into the maps.
161    pub fn forget_by_document_id(&self, document: DocumentId) -> bool {
162        let name = match self.by_document.lock() {
163            Ok(mut m) => m.remove(&document),
164            Err(_) => None,
165        };
166        match name {
167            Some(name) => {
168                if let Ok(mut m) = self.by_name.lock() {
169                    m.remove(&name);
170                }
171                true
172            }
173            None => false,
174        }
175    }
176
177    /// How many buffers have a recorded repository. For tests and
178    /// `Debug`; the accumulation failure mode is only visible as a count.
179    pub fn tracked(&self) -> usize {
180        self.by_name.lock().map(|m| m.len()).unwrap_or(0)
181    }
182}
183
184/// Typed handle for `ServiceRegistry` lookup — register and look up
185/// under THIS alias (`feedback_servicesregistry_arc_typeid`).
186pub type RepoScopesHandle = Arc<RepoScopes>;
187
188/// PR.6: `RepoScopes` already answers "which repository is the buffer *called
189/// this* acting on" — which is exactly what the editor's generic project
190/// resolution needs from a buffer that has no path.
191///
192/// Implementing the trait rather than having the host read `RepoScopesHandle`
193/// keeps a magit-specific service out of generic host code: the host learns a
194/// directory and never learns whose, or that git was involved.
195///
196/// It works for every magit view at once — status, diff, log, stash, blame —
197/// because they all record through the same map.
198impl lattice_mode::BufferScopeSource for RepoScopes {
199    fn scope_dir_for_name(&self, buffer_name: &str) -> Option<PathBuf> {
200        self.workdir_for(buffer_name)
201    }
202}
203
204/// MR.2: the single path from "a magit trigger fired" to "the buffer it
205/// opens" — resolve the repository, name the buffer for it, record
206/// which repository that buffer acts on.
207///
208/// **Both surfaces call exactly this.** `C-x g` reaches it from an
209/// action handler (which has services and a buffer id) and
210/// `:magit-status` from an ex-command closure (which has a buffer id and
211/// a handle it captured at boot). They had different reach before MR.2
212/// and letting them diverge was never on the table: the same command
213/// meaning two things depending on how it was reached is worse than
214/// either meaning on its own.
215///
216/// `active` is the buffer the trigger fired in — `ExCommandContext::
217/// buffer_id` or `ActionContext::buffer_id`, which are the same fact
218/// from the same dispatch.
219pub fn open_repo_view(
220    view: &str,
221    mode_id: &str,
222    store: &lattice_mode::BufferStoreHandle,
223    scopes: &RepoScopes,
224    active: lattice_core::BufferId,
225) -> lattice_grammar::Effect {
226    open_repo_view_at(view, mode_id, store, scopes, active, None)
227}
228
229/// PC.3: [`open_repo_view`] for a repository named EXPLICITLY.
230///
231/// The form `magit-repo-scoping.md` deferred rather than rejected —
232/// "Rejected as the *primary* mechanism … **Worth having later as an explicit
233/// form.**" The implicit resolution in that document's §2 is untouched: `at =
234/// None` is `open_repo_view` exactly, and an argument-less `:magit-status`
235/// still resolves from the buffer.
236///
237/// This is complementary, not a replacement, and the distinction is the whole
238/// reason the original rejection stands: making the COMMON case (working
239/// across two checkouts) the one that needs an argument would be backwards.
240/// What needs an argument is the uncommon case — a project chosen from a
241/// picker, where the caller already knows which repository it means.
242pub fn open_repo_view_at(
243    view: &str,
244    mode_id: &str,
245    store: &lattice_mode::BufferStoreHandle,
246    scopes: &RepoScopes,
247    active: lattice_core::BufferId,
248    at: Option<&std::path::Path>,
249) -> lattice_grammar::Effect {
250    lattice_grammar::Effect::OpenSyntheticBuffer {
251        name: repo_view_name_at(view, store, scopes, active, at),
252        mode_id: mode_id.to_string(),
253        content: None,
254        cursor: None,
255        activate_minor: None,
256    }
257}
258
259/// MR.3: the repository a magit view acts on, read at activation — the
260/// other end of what [`open_repo_view`] wrote.
261///
262/// Every view's `on_activate` asks exactly this, and asks it the same
263/// way: the record under this buffer's name, else the working directory.
264/// The fallback is not defensive padding — a magit buffer reopened by
265/// `:b` after a restart has a name and no record, and the working
266/// directory is the answer magit gave for that buffer before MR.2.
267///
268/// Also indexes the document, so closing the buffer drops the record.
269/// Here rather than at the trigger because this is the first moment the
270/// document exists — and in the same helper as the read so a new view
271/// cannot pick up one half and forget the other.
272pub fn view_workdir(
273    ctx: &lattice_mode::ModeContext,
274    buffer: lattice_core::BufferId,
275    handle: &std::sync::Arc<dyn lattice_runtime::Document>,
276) -> Option<PathBuf> {
277    let name = ctx
278        .service::<lattice_mode::BufferStoreHandle>()
279        .and_then(|store| store.name_for(buffer));
280    let scopes = ctx.service::<RepoScopesHandle>();
281
282    if let (Some(scopes), Some(name)) = (scopes.as_ref(), name.as_ref()) {
283        scopes.index_document(handle.id(), name.clone());
284        if let Some(recorded) = scopes.workdir_for(name) {
285            return Some(recorded);
286        }
287        // MR.3b: no record, but the name carries a label — this buffer
288        // was opened from inside another magit buffer, by a producer
289        // that had the label and no way to record a path. Recover the
290        // path from whichever sibling IS recorded against that label,
291        // and record it here so the buffer's own actions (MR.4) can read
292        // it like any other.
293        if let Some(recovered) = crate::workdir::parse_magit_name(name)
294            .and_then(|n| n.repo)
295            .and_then(|label| scopes.workdir_for_label(label))
296        {
297            scopes.record(name.clone(), recovered.clone());
298            return Some(recovered);
299        }
300    }
301    crate::workdir::magit_workdir()
302}
303
304/// MR.3b: the repository label a magit buffer's own name carries, for a
305/// producer that has the buffer store but no services.
306///
307/// Empty when the buffer is not a magit buffer or carries no label —
308/// which composes correctly with the name producers, since an empty
309/// label is the outside-a-repository form.
310pub fn label_of_buffer(
311    store: &lattice_mode::BufferStoreHandle,
312    buffer: lattice_core::BufferId,
313) -> String {
314    store
315        .name_for(buffer)
316        .and_then(|name| {
317            crate::workdir::parse_magit_name(&name).and_then(|n| n.repo.map(str::to_string))
318        })
319        .unwrap_or_default()
320}
321
322/// MR.4: **the repository an action acts on** — the one question every
323/// magit action body asks, answered in one place.
324///
325/// Design §4 is emphatic about this and about why: the entry points and
326/// the action bodies are two populations, and fixing only the first is
327/// worse than fixing neither. A status buffer showing repo B whose `s`
328/// stages into repo A is data-loss-shaped, and it is exactly what a
329/// half-migration produces.
330///
331/// The three questions are design §2's, with the first one *read* rather
332/// than re-resolved:
333///
334/// 1. The active buffer is a magit buffer → the repository it was
335///    recorded against (or, for a buffer opened from inside another one,
336///    recovered from its label). **Never re-derived from the cwd**: the
337///    whole point is that this buffer's repository is not the process's.
338/// 2. The active buffer has a file → that file's repository. This is the
339///    `C-c g` -from-a-file case: the dispatch was opened over a file, so
340///    the operation belongs to that file's checkout.
341/// 3. Otherwise the working directory — unchanged, and still the answer
342///    for a fresh editor with nothing open.
343pub fn active_workdir(
344    store: &lattice_mode::BufferStoreHandle,
345    scopes: &RepoScopes,
346    active: lattice_core::BufferId,
347) -> Option<PathBuf> {
348    let from_magit_buffer = store
349        .name_for(active)
350        .filter(|name| crate::workdir::is_magit_buffer_name(name))
351        .and_then(|name| {
352            scopes.workdir_for(&name).or_else(|| {
353                crate::workdir::parse_magit_name(&name)
354                    .and_then(|n| n.repo)
355                    .and_then(|label| scopes.workdir_for_label(label))
356            })
357        });
358    crate::workdir::repo_for_trigger(
359        from_magit_buffer,
360        store.path_for(active).as_deref(),
361        scopes.discovery_start().as_deref(),
362    )
363}
364
365/// [`active_workdir`] with the working directory as the fall-back — the
366/// form the operation helpers want, since they need *a* directory to run
367/// git in and "not in a repository" is git's error to report, not ours.
368pub fn workdir_or_cwd(
369    store: &lattice_mode::BufferStoreHandle,
370    scopes: &RepoScopes,
371    active: lattice_core::BufferId,
372) -> PathBuf {
373    active_workdir(store, scopes, active)
374        .or_else(|| {
375            crate::workdir::magit_workdir_from(
376                scopes
377                    .discovery_start()
378                    .as_deref()
379                    .unwrap_or(std::path::Path::new(".")),
380            )
381        })
382        .unwrap_or_default()
383}
384
385/// [`active_workdir`] for an action handler, which carries the services
386/// rather than the handles.
387///
388/// Returns the working directory when either service is missing (a
389/// harness that wired neither), which is what magit did everywhere
390/// before MR.4 — the operation still runs, in the process's repository.
391pub fn action_workdir(ctx: &lattice_mode::ActionContext<'_>) -> PathBuf {
392    let resolved = ctx
393        .services
394        .get::<lattice_mode::BufferStoreHandle>()
395        .zip(ctx.services.get::<RepoScopesHandle>())
396        .and_then(|(store, scopes)| {
397            active_workdir(
398                &store,
399                &scopes,
400                lattice_core::BufferId(ctx.buffer_id.raw() as u32),
401            )
402        });
403    resolved
404        .or_else(crate::workdir::magit_workdir)
405        .unwrap_or_default()
406}
407
408/// MR.3: [`open_repo_view`] for a view that encodes parameters of its
409/// own — the commit family's target (`*magit:augment:<repo>:<sha>*`) and,
410/// from MR.3b, the path- and revision-scoped views.
411///
412/// `rest` is the view's own encoding, verbatim; this function only puts
413/// the repository in front of it.
414pub fn open_repo_view_with(
415    view: &str,
416    mode_id: &str,
417    rest: &str,
418    store: &lattice_mode::BufferStoreHandle,
419    scopes: &RepoScopes,
420    active: lattice_core::BufferId,
421) -> lattice_grammar::Effect {
422    lattice_grammar::Effect::OpenSyntheticBuffer {
423        name: repo_view_name_with(view, Some(rest), store, scopes, active),
424        mode_id: mode_id.to_string(),
425        content: None,
426        cursor: None,
427        activate_minor: None,
428    }
429}
430
431/// The naming half of [`open_repo_view`], split out so a test can assert
432/// which buffer a trigger lands on without an `Effect` in the way.
433pub fn repo_view_name(
434    view: &str,
435    store: &lattice_mode::BufferStoreHandle,
436    scopes: &RepoScopes,
437    active: lattice_core::BufferId,
438) -> String {
439    repo_view_name_with(view, None, store, scopes, active)
440}
441
442/// PC.3: [`repo_view_name`] for an explicitly-named repository.
443///
444/// `at` is resolved to a repository the same way every other path is —
445/// through `workdir_for_file`, so naming a file INSIDE a checkout works as
446/// well as naming its root. A path that is not in a repository falls back to
447/// the ordinary resolution rather than composing a name for a repo that is not
448/// there: the view then says "Not a git repository." exactly as it does when
449/// you trigger it from a non-repo buffer, which is one behaviour instead of
450/// two.
451pub fn repo_view_name_at(
452    view: &str,
453    store: &lattice_mode::BufferStoreHandle,
454    scopes: &RepoScopes,
455    active: lattice_core::BufferId,
456    at: Option<&std::path::Path>,
457) -> String {
458    let explicit = at.and_then(|p| {
459        // Discovery must START at a directory: `workdir_for_file` exists
460        // separately precisely because it takes the file's PARENT before
461        // discovering (see `workdir.rs`'s note), so handing it a directory
462        // would discover from that directory's parent and answer the wrong
463        // repository — or none. A file argument therefore goes through
464        // `workdir_for_file`, a directory straight to `magit_workdir_from`.
465        if p.is_file() {
466            crate::workdir::workdir_for_file(p).map(|(workdir, _rel)| workdir)
467        } else {
468            crate::workdir::magit_workdir_from(p)
469        }
470    });
471    repo_view_name_resolved(view, None, store, scopes, active, explicit)
472}
473
474/// Resolve the repository, compose the name, record what the buffer acts
475/// on. The single body under every magit trigger.
476pub fn repo_view_name_with(
477    view: &str,
478    rest: Option<&str>,
479    store: &lattice_mode::BufferStoreHandle,
480    scopes: &RepoScopes,
481    active: lattice_core::BufferId,
482) -> String {
483    repo_view_name_resolved(view, rest, store, scopes, active, None)
484}
485
486/// The one body under every magit trigger, with PC.3's explicit repository
487/// threaded in rather than copied.
488///
489/// `explicit` short-circuits the resolution chain and nothing else: the naming,
490/// the basename-collision qualifier and the scope record are all the same code
491/// they were, which is the point. A second copy of the collision rule would be
492/// the kind of duplication that goes wrong silently — one caller qualifying two
493/// same-named checkouts and the other not.
494fn repo_view_name_resolved(
495    view: &str,
496    rest: Option<&str>,
497    store: &lattice_mode::BufferStoreHandle,
498    scopes: &RepoScopes,
499    active: lattice_core::BufferId,
500    explicit: Option<std::path::PathBuf>,
501) -> String {
502    use crate::workdir;
503
504    let compose = |label: &str| match rest {
505        Some(rest) => workdir::magit_buffer_name_with(view, label, rest),
506        None => workdir::magit_buffer_name(view, label),
507    };
508
509    let Some(repo) = explicit.or_else(|| active_workdir(store, scopes, active)) else {
510        // Not in a repository from any of the three directions. The
511        // unqualified name is what magit always used, and the view says
512        // "Not a git repository." exactly as it did before.
513        return compose("");
514    };
515
516    let mut name = compose(&workdir::repo_label(&repo));
517    if scopes.collides(&name, &repo) {
518        // Two checkouts sharing a basename. Qualifying is the only
519        // outcome that is not "both repositories share one buffer".
520        name = compose(&workdir::qualified_repo_label(&repo));
521    }
522    scopes.record(name.clone(), repo);
523    name
524}
525
526/// A `BufferStore` that knows only what a trigger asks it: what the
527/// active buffer is called and which file it holds.
528///
529/// Those are the two questions [`repo_view_name`] puts to the store, so
530/// stubbing the rest keeps a trigger test about resolution rather than
531/// about standing up a buffer registry. Shared with the ex-command
532/// registration tests, which need *a* store handle and do not care what
533/// is in it.
534#[cfg(test)]
535pub(crate) mod test_support {
536    use std::path::PathBuf;
537    use std::sync::Arc;
538
539    use lattice_mode::BufferStoreHandle;
540
541    #[derive(Default)]
542    pub(crate) struct StubStore {
543        pub name: Option<String>,
544        pub path: Option<PathBuf>,
545    }
546
547    impl lattice_mode::BufferStore for StubStore {
548        fn find_by_name(&self, _name: &str) -> Option<lattice_core::BufferId> {
549            None
550        }
551        fn handle_for(
552            &self,
553            _id: lattice_core::BufferId,
554        ) -> Option<Arc<dyn lattice_runtime::Document>> {
555            None
556        }
557        fn name_for(&self, _id: lattice_core::BufferId) -> Option<String> {
558            self.name.clone()
559        }
560        fn path_for(&self, _id: lattice_core::BufferId) -> Option<PathBuf> {
561            self.path.clone()
562        }
563        fn insert_document_buffer(
564            &self,
565            _id: lattice_core::BufferId,
566            _kind: lattice_core::BufferKind,
567            _handle: Arc<dyn lattice_runtime::Document>,
568            _flags: lattice_core::BufferFlags,
569            _name: Option<String>,
570        ) {
571        }
572    }
573
574    /// A store holding nothing — the "no file, no name" buffer.
575    pub(crate) fn empty_store() -> BufferStoreHandle {
576        BufferStoreHandle::new(Arc::new(StubStore::default()))
577    }
578
579    pub(crate) fn store_showing(name: Option<&str>, path: Option<PathBuf>) -> BufferStoreHandle {
580        BufferStoreHandle::new(Arc::new(StubStore {
581            name: name.map(str::to_string),
582            path,
583        }))
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    fn doc(n: u64) -> DocumentId {
592        DocumentId::new(n)
593    }
594
595    /// The gap the record exists to cross: written by the trigger,
596    /// before the buffer exists; read by the view, after it does.
597    #[test]
598    fn the_record_survives_the_trigger_to_activation_gap() {
599        let scopes = RepoScopes::default();
600        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
601
602        assert_eq!(
603            scopes.workdir_for("*magit:status:api*"),
604            Some(PathBuf::from("/work/api"))
605        );
606    }
607
608    /// Re-triggering must re-point the buffer you have, not stack a
609    /// second record behind it — the accumulation is invisible except
610    /// as a count, which is why the count is asserted.
611    #[test]
612    fn a_second_trigger_for_the_same_buffer_overwrites() {
613        let scopes = RepoScopes::default();
614        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
615        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
616
617        assert_eq!(scopes.tracked(), 1, "one buffer, one record");
618    }
619
620    #[test]
621    fn closing_the_buffer_drops_the_record() {
622        let scopes = RepoScopes::default();
623        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
624        scopes.index_document(doc(7), "*magit:status:api*");
625
626        assert!(scopes.forget_by_document_id(doc(7)), "it was tracked");
627        assert_eq!(scopes.workdir_for("*magit:status:api*"), None);
628        assert_eq!(scopes.tracked(), 0);
629        assert!(
630            !scopes.forget_by_document_id(doc(7)),
631            "and a second close has nothing to drop"
632        );
633    }
634
635    /// A document nobody indexed — every non-magit buffer in the editor,
636    /// closed all the time — must not disturb the records that exist.
637    #[test]
638    fn closing_an_unrelated_document_drops_nothing() {
639        let scopes = RepoScopes::default();
640        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
641        scopes.index_document(doc(7), "*magit:status:api*");
642
643        assert!(!scopes.forget_by_document_id(doc(99)));
644        assert_eq!(scopes.tracked(), 1);
645    }
646
647    // ── MR.2: the trigger ────────────────────────────────────────
648
649    use std::process::Command;
650    use test_support::{empty_store, store_showing};
651
652    fn git_init(dir: &Path) {
653        let st = Command::new("git")
654            .args(["init"])
655            .current_dir(dir)
656            .status()
657            .expect("git");
658        assert!(st.success(), "git init failed");
659    }
660
661    fn active() -> lattice_core::BufferId {
662        lattice_core::BufferId(1)
663    }
664
665    /// The change, stated as the trigger sees it: a file from another
666    /// checkout opens THAT checkout's status buffer, named for it, with
667    /// the repository recorded against the name.
668    ///
669    /// The name and the record are asserted together because either one
670    /// alone is a half-fix: the right name over the wrong workdir is a
671    /// buffer that lies, and the right workdir under the shared name is
672    /// two repositories in one buffer.
673    #[test]
674    fn a_file_from_another_checkout_opens_that_checkouts_buffer() {
675        let dir = tempfile::tempdir().expect("tempdir");
676        let repo = dir.path().join("api");
677        std::fs::create_dir_all(repo.join("src")).unwrap();
678        git_init(&repo);
679        let file = repo.join("src").join("main.rs");
680        std::fs::write(&file, "fn main() {}\n").unwrap();
681
682        let scopes = RepoScopes::default();
683        let store = store_showing(Some("src/main.rs"), Some(file));
684        let name = repo_view_name("status", &store, &scopes, active());
685
686        assert_eq!(name, "*magit:status:api*");
687        assert_eq!(
688            scopes
689                .workdir_for(&name)
690                .and_then(|w| w.canonicalize().ok()),
691            repo.canonicalize().ok(),
692            "the buffer must be recorded against the file's repo, not the cwd"
693        );
694    }
695
696    /// PC.3: an EXPLICIT path opens that repository's status buffer while the
697    /// active buffer belongs to a different one — the whole point of the
698    /// explicit form, and the assertion a same-repo test would pass without
699    /// proving.
700    #[test]
701    fn an_explicit_path_opens_that_repositorys_buffer() {
702        let dir = tempfile::tempdir().expect("tempdir");
703        let here = dir.path().join("here");
704        let there = dir.path().join("there");
705        for r in [&here, &there] {
706            std::fs::create_dir_all(r.join("src")).unwrap();
707            git_init(r);
708        }
709        let here_file = here.join("src").join("main.rs");
710        std::fs::write(&here_file, "fn main() {}\n").unwrap();
711
712        let scopes = RepoScopes::default();
713        // The active buffer is in `here`; the argument names `there`.
714        let store = store_showing(Some("src/main.rs"), Some(here_file));
715        let name = repo_view_name_at("status", &store, &scopes, active(), Some(&there));
716
717        assert_eq!(name, "*magit:status:there*");
718        assert_eq!(
719            scopes
720                .workdir_for(&name)
721                .and_then(|w| w.canonicalize().ok()),
722            there.canonicalize().ok(),
723            "recorded against the NAMED repo — a name over the wrong workdir is \
724             a buffer that lies"
725        );
726    }
727
728    /// **And the bare form is untouched**, which is what keeps PC.3
729    /// complementary rather than a reversal of `magit-repo-scoping.md` §2.
730    /// That document rejected an argument as the PRIMARY mechanism because it
731    /// would make working across two checkouts the case that needs one; this
732    /// asserts it still does not.
733    #[test]
734    fn no_path_resolves_from_the_buffer_exactly_as_before() {
735        let dir = tempfile::tempdir().expect("tempdir");
736        let repo = dir.path().join("api");
737        std::fs::create_dir_all(repo.join("src")).unwrap();
738        git_init(&repo);
739        let file = repo.join("src").join("main.rs");
740        std::fs::write(&file, "fn main() {}\n").unwrap();
741
742        let scopes = RepoScopes::default();
743        let store = store_showing(Some("src/main.rs"), Some(file));
744        assert_eq!(
745            repo_view_name_at("status", &store, &scopes, active(), None),
746            repo_view_name("status", &store, &scopes, active()),
747        );
748    }
749
750    /// A path INSIDE a checkout resolves to the checkout — `gix::discover`
751    /// fails silently on a file path, so the file case is walked from its
752    /// parent rather than quietly answering the wrong repository.
753    #[test]
754    fn a_file_argument_resolves_to_its_repository() {
755        let dir = tempfile::tempdir().expect("tempdir");
756        let repo = dir.path().join("api");
757        std::fs::create_dir_all(repo.join("src")).unwrap();
758        git_init(&repo);
759        let file = repo.join("src").join("main.rs");
760        std::fs::write(&file, "fn main() {}\n").unwrap();
761
762        let scopes = RepoScopes::default();
763        let store = empty_store();
764        assert_eq!(
765            repo_view_name_at("status", &store, &scopes, active(), Some(&file)),
766            "*magit:status:api*"
767        );
768    }
769
770    /// Nothing open, or nothing with a file: the working directory, and
771    /// the name magit always had. A fresh editor still answers `C-x g`,
772    /// which is what keeps MR.2 a widening rather than a trade.
773    #[test]
774    fn with_no_file_the_trigger_falls_back_to_the_working_directory() {
775        let scopes = RepoScopes::default();
776        let store = empty_store();
777        let name = repo_view_name("status", &store, &scopes, active());
778
779        match crate::workdir::magit_workdir() {
780            // The test process runs inside lattice's own checkout, so
781            // this is the branch that fires here.
782            Some(cwd) => {
783                assert_eq!(
784                    name,
785                    crate::workdir::magit_buffer_name("status", &crate::workdir::repo_label(&cwd))
786                );
787                assert_eq!(scopes.workdir_for(&name), Some(cwd));
788            }
789            None => assert_eq!(name, "*magit:status*"),
790        }
791    }
792
793    /// A magit chord pressed inside magit must not change which
794    /// repository you are working on — question 1 of design §2, and the
795    /// one that would otherwise walk you back to the cwd repo from repo
796    /// B's own status buffer.
797    ///
798    /// The store here reports a file as well, and a file that resolves
799    /// somewhere else: the point is that the magit buffer's record wins
800    /// over it.
801    #[test]
802    fn a_trigger_inside_a_magit_buffer_stays_in_its_repository() {
803        let dir = tempfile::tempdir().expect("tempdir");
804        let other = dir.path().join("elsewhere");
805        std::fs::create_dir_all(&other).unwrap();
806        git_init(&other);
807        let file = other.join("a.rs");
808        std::fs::write(&file, "\n").unwrap();
809
810        let scopes = RepoScopes::default();
811        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
812        let store = store_showing(Some("*magit:status:api*"), Some(file));
813
814        let name = repo_view_name("status", &store, &scopes, active());
815        assert_eq!(
816            name, "*magit:status:api*",
817            "the buffer in front of you decides"
818        );
819        assert_eq!(scopes.workdir_for(&name), Some(PathBuf::from("/work/api")));
820    }
821
822    /// Re-triggering for the same repository must land on the buffer you
823    /// already have. Idempotence is not cosmetic here: a second name
824    /// would open a second status buffer for one repository, and `gr` in
825    /// either would refresh only itself.
826    #[test]
827    fn triggering_twice_for_one_repository_lands_on_one_buffer() {
828        let scopes = RepoScopes::default();
829        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
830        let store = store_showing(Some("*magit:status:api*"), None);
831
832        let first = repo_view_name("status", &store, &scopes, active());
833        let second = repo_view_name("status", &store, &scopes, active());
834
835        assert_eq!(first, second);
836        assert_eq!(scopes.tracked(), 1, "one repository, one record");
837    }
838
839    /// Two checkouts sharing a basename get two buffers, not one. The
840    /// merged outcome is the one that must not happen: `s` in the shared
841    /// buffer would stage into whichever repo was recorded last, which
842    /// is data-loss-shaped.
843    #[test]
844    fn a_second_repo_with_the_same_basename_gets_its_own_buffer() {
845        let dir = tempfile::tempdir().expect("tempdir");
846        let second = dir.path().join("oss").join("api");
847        std::fs::create_dir_all(second.join("src")).unwrap();
848        git_init(&second);
849        let file = second.join("src").join("lib.rs");
850        std::fs::write(&file, "\n").unwrap();
851
852        let scopes = RepoScopes::default();
853        // A *different* repository already holds the plain name.
854        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
855
856        let store = store_showing(Some("src/lib.rs"), Some(file));
857        let name = repo_view_name("status", &store, &scopes, active());
858
859        assert_ne!(name, "*magit:status:api*", "the two must not merge");
860        assert!(
861            name.starts_with("*magit:status:oss/"),
862            "the qualified name names its parent directory: {name}"
863        );
864        assert_eq!(scopes.tracked(), 2, "two repositories, two records");
865    }
866
867    /// MR.3: every view that has moved resolves from the buffer in front
868    /// of you, and each gets its OWN buffer per repository.
869    ///
870    /// Table-driven because the failure this guards is a view left
871    /// behind: a conversion that does eight of nine, and the ninth still
872    /// opening the working directory's repository — which looks correct
873    /// from inside the repository you happen to be in, and is invisible
874    /// until someone works across two.
875    #[test]
876    fn every_converted_view_resolves_from_the_buffer_it_was_triggered_in() {
877        let dir = tempfile::tempdir().expect("tempdir");
878        let repo = dir.path().join("api");
879        std::fs::create_dir_all(repo.join("src")).unwrap();
880        git_init(&repo);
881        let file = repo.join("src").join("main.rs");
882        std::fs::write(&file, "fn main() {}\n").unwrap();
883
884        let scopes = RepoScopes::default();
885        let store = store_showing(Some("src/main.rs"), Some(file));
886
887        for view in [
888            "status",
889            "commit",
890            "amend",
891            "reword",
892            "branch",
893            "remote",
894            "submodule",
895            "refs",
896        ] {
897            let name = repo_view_name(view, &store, &scopes, active());
898            assert_eq!(
899                name,
900                format!("*magit:{view}:api*"),
901                "`{view}` must open the file's repository"
902            );
903            assert_eq!(
904                scopes
905                    .workdir_for(&name)
906                    .and_then(|w| w.canonicalize().ok()),
907                repo.canonicalize().ok(),
908                "…and record it, or its `on_activate` reads the cwd back"
909            );
910        }
911    }
912
913    /// The commit family's targeted intents keep their target AND gain
914    /// the repository, in that order: `*magit:augment:<repo>:<sha>*`.
915    ///
916    /// Both halves matter and they fail differently — losing the repo
917    /// squashes into the wrong checkout, losing the target composes a
918    /// squash for nothing.
919    #[test]
920    fn a_targeted_commit_buffer_carries_both_repo_and_target() {
921        let dir = tempfile::tempdir().expect("tempdir");
922        let repo = dir.path().join("api");
923        std::fs::create_dir_all(&repo).unwrap();
924        git_init(&repo);
925        let file = repo.join("a.rs");
926        std::fs::write(&file, "\n").unwrap();
927
928        let scopes = RepoScopes::default();
929        let store = store_showing(Some("a.rs"), Some(file));
930
931        let name = repo_view_name_with("augment", Some("abc123"), &store, &scopes, active());
932        assert_eq!(name, "*magit:augment:api:abc123*");
933        assert_eq!(
934            crate::magit_commit_mode::CommitIntent::from_buffer_name(&name),
935            crate::magit_commit_mode::CommitIntent::Augment {
936                target: "abc123".to_string()
937            },
938            "the intent must survive the repository being in the name"
939        );
940        assert!(scopes.workdir_for(&name).is_some(), "…and be recorded");
941    }
942
943    // ── MR.4: what the action bodies act on ──────────────────────
944
945    /// **The slice's whole point.** An operation fired in a magit buffer
946    /// runs in the repository that buffer is showing — not the one the
947    /// editor was started in.
948    ///
949    /// Asserted with the working directory pointed somewhere else
950    /// entirely, because "it worked on my machine" here means "the two
951    /// repositories happened to be the same one". A status buffer
952    /// showing repo B whose `s` stages into repo A is the data-loss
953    /// shape design §4 names, and this is the assertion that fails if it
954    /// comes back.
955    #[test]
956    fn an_action_acts_on_the_repository_its_buffer_shows() {
957        let scopes = RepoScopes::default();
958        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
959        let store = store_showing(Some("*magit:status:api*"), None);
960
961        assert_eq!(
962            active_workdir(&store, &scopes, active()),
963            Some(PathBuf::from("/work/api")),
964            "the buffer's recorded repository, never the process's"
965        );
966    }
967
968    /// A magit buffer opened from inside another one has a label and no
969    /// record of its own — `<CR>` on a commit, a file at a revision. The
970    /// operation still belongs to that label's repository.
971    #[test]
972    fn an_action_in_a_buffer_opened_from_another_follows_its_label() {
973        let scopes = RepoScopes::default();
974        // The sibling that WAS recorded, by its own trigger.
975        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
976        let store = store_showing(Some("*magit:show:api:abc123*"), None);
977
978        assert_eq!(
979            active_workdir(&store, &scopes, active()),
980            Some(PathBuf::from("/work/api"))
981        );
982    }
983
984    /// Fired over a FILE — the `C-c g` -from-a-file case. The operation
985    /// belongs to that file's checkout, which is the same answer the
986    /// trigger would have given for opening a view over it.
987    #[test]
988    fn an_action_over_a_file_acts_on_that_files_repository() {
989        let dir = tempfile::tempdir().expect("tempdir");
990        let repo = dir.path().join("api");
991        std::fs::create_dir_all(&repo).unwrap();
992        git_init(&repo);
993        let file = repo.join("a.rs");
994        std::fs::write(&file, "\n").unwrap();
995
996        let scopes = RepoScopes::default();
997        let store = store_showing(Some("a.rs"), Some(file));
998
999        assert_eq!(
1000            active_workdir(&store, &scopes, active()).and_then(|w| w.canonicalize().ok()),
1001            repo.canonicalize().ok()
1002        );
1003    }
1004
1005    /// Nothing open: the working directory, unchanged. MR.4 narrows
1006    /// *which* repository an operation runs in; it does not remove the
1007    /// answer for an editor that has nothing to narrow from.
1008    #[test]
1009    fn with_no_buffer_to_go_on_an_action_still_has_a_repository() {
1010        let scopes = RepoScopes::default();
1011        let store = empty_store();
1012
1013        assert_eq!(
1014            active_workdir(&store, &scopes, active()),
1015            crate::workdir::magit_workdir()
1016        );
1017    }
1018
1019    /// `C-x g` and `:magit-status` must land on the same buffer from the
1020    /// same place. This is the requirement MR.2 was asked for, and the
1021    /// one a future slice can quietly break: converting the ex-command
1022    /// to repo scoping while leaving the chord on the fixed name (or the
1023    /// reverse) leaves a magit that behaves differently depending on how
1024    /// you reached it, and neither half looks wrong on its own.
1025    ///
1026    /// Both are fired against ONE store and ONE record, so a divergence
1027    /// can only come from the resolution path itself.
1028    #[test]
1029    fn the_chord_and_the_ex_command_open_the_same_buffer() {
1030        use lattice_grammar::{Args, CommandRegistry, Effect};
1031        use lattice_mode::Mode;
1032
1033        let dir = tempfile::tempdir().expect("tempdir");
1034        let repo = dir.path().join("api");
1035        std::fs::create_dir_all(repo.join("src")).unwrap();
1036        git_init(&repo);
1037        let file = repo.join("src").join("main.rs");
1038        std::fs::write(&file, "fn main() {}\n").unwrap();
1039
1040        let scopes: RepoScopesHandle = Arc::new(RepoScopes::default());
1041        let store = store_showing(Some("src/main.rs"), Some(file));
1042
1043        // The `:` surface, through the registry it is registered in.
1044        let mut registry = CommandRegistry::new();
1045        crate::register_ex_commands(
1046            &mut registry,
1047            Default::default(),
1048            store.clone(),
1049            scopes.clone(),
1050        );
1051        let id = registry
1052            .id_by_name("magit-status")
1053            .expect("`:magit-status` is registered");
1054        let spec = registry
1055            .ex_command_spec(id)
1056            .expect("`:magit-status` is an ex-command");
1057        let ex_ctx = lattice_grammar::ExCommandContext {
1058            bang: false,
1059            args: Args::None,
1060            range: None,
1061            register: Default::default(),
1062            count: Default::default(),
1063            buffer_id: active(),
1064            // OC.10 added these four so a PLUGIN ex-command could name the
1065            // buffer its `Effect::ApplyEdit` targets. `:magit-status` ignores
1066            // all of them — it resolves the repository from `buffer_id` — so
1067            // they are the empty defaults here rather than a fabricated cursor
1068            // into a buffer this test never builds.
1069            cursor: Default::default(),
1070            buffer: Default::default(),
1071            path: None,
1072            syntax: None,
1073            cancel: lattice_protocol::CancellationToken::never(),
1074        };
1075        let from_ex = (spec.apply)(&ex_ctx).expect("apply");
1076
1077        // The chord surface, through the services a handler reads.
1078        // Registered under the exact aliases the handler looks up —
1079        // an `Arc<BufferStoreHandle>` here would be filed under a type
1080        // nobody asks for and the handler would fall back to the fixed
1081        // name, which is a passing-looking failure.
1082        let mut services = lattice_mode::ServiceRegistry::new();
1083        services.register(store);
1084        services.register::<RepoScopesHandle>(scopes);
1085        let events = lattice_runtime::EventBus::new();
1086        let handler = crate::magit_global_mode::MagitGlobalMode
1087            .action_handlers()
1088            .into_iter()
1089            .find(|c| c.action_name == "action:magit-global-status")
1090            .expect("`C-x g`'s handler is contributed")
1091            .handler;
1092        let from_chord = handler(&lattice_mode::ActionContext {
1093            buffer_id: lattice_protocol::ids::BufferId::new(active().0 as u64),
1094            cursor: lattice_protocol::position::Position::new(0, 0),
1095            selection: None,
1096            services: &services,
1097            events: &events,
1098            prompt_value: None,
1099            args: Args::None,
1100            buffer_locals: None,
1101        })
1102        .expect("the chord opens something");
1103
1104        match (&from_ex, &from_chord) {
1105            (
1106                Effect::OpenSyntheticBuffer { name: ex, .. },
1107                Effect::OpenSyntheticBuffer { name: chord, .. },
1108            ) => {
1109                assert_eq!(ex, chord, "the two surfaces must not diverge");
1110                assert_eq!(ex, "*magit:status:api*", "…on the file's repository");
1111            }
1112            other => panic!("both surfaces must open a synthetic buffer, got {other:?}"),
1113        }
1114    }
1115
1116    /// The collision question is about the *path*, not the name: the
1117    /// same repository asked twice is not a collision (it is the
1118    /// idempotent re-trigger), two paths under one name is.
1119    #[test]
1120    fn a_collision_is_two_paths_under_one_name() {
1121        let scopes = RepoScopes::default();
1122        scopes.record("*magit:status:api*", PathBuf::from("/work/api"));
1123
1124        assert!(
1125            !scopes.collides("*magit:status:api*", Path::new("/work/api")),
1126            "the same repo asked twice is the buffer you already have"
1127        );
1128        assert!(
1129            scopes.collides("*magit:status:api*", Path::new("/oss/api")),
1130            "a different repo under the same name must qualify instead"
1131        );
1132        assert!(
1133            !scopes.collides("*magit:status:lattice*", Path::new("/src/lattice")),
1134            "an unrecorded name collides with nothing"
1135        );
1136    }
1137}