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}