Skip to main content

majordomus_cli/
live.rs

1//! The repository as a long-lived process must see it: current, not as it was when the
2//! process started.
3//!
4//! # The defect this exists to end
5//!
6//! [`crate::app::App::load`] builds one [`Index`](crate::index::Index) and one
7//! [`Context`] and hands them out as `Arc`s. For a command that answers a question and
8//! exits, that is exactly right: the picture cannot go stale inside a process that lives
9//! for 40 ms. The shared server lives for hours, and it held the same two values for its
10//! whole life. A session would commit, ask `GET /api/v1/repository` for the head it had
11//! just written, and be told the head the server had read when it started — the same
12//! answer from MCP, from the Cockpit and from every capability that reads the index, with
13//! nothing in any of them saying the picture was old. Measured on `origin/master` at
14//! `faba8fdef`: head `A` before a commit, head `A` after it, a rule committed while the
15//! server ran absent from `objects`, and a working tree reported `clean` while it was
16//! dirty. Only restarting the process fixed it.
17//!
18//! # What is watched, and why it is these files
19//!
20//! Git announces every move of the repository by writing a small, fixed set of files:
21//! `HEAD`, the staging index, `packed-refs`, the reflog, and the in-progress markers a
22//! merge or a rebase leaves. A commit, a checkout, a reset, a merge, a rebase and a branch
23//! switch each write at least one of them. So the question "has the repository moved?"
24//! is [`Stamp`]: the size and modification time of those files, hashed — one `stat` per
25//! watched file, taken on the request's own thread before it is answered, which
26//! `the_stamp_is_cheap_enough_to_take_on_every_request` holds to well under the
27//! millisecond that would make it matter. No thread, no interval, no watcher to leak, and
28//! nothing over the network.
29//!
30//! The limit, named rather than hidden: an edit to a tracked file that is never staged
31//! changes no git control file, so this does not see it. Seeing it would mean a `stat` per
32//! declared object on every request — a number that grows with the layer, against an HTTP
33//! p50 the benchmark baselines record in the hundreds of microseconds — which is the
34//! per-request regression the performance doctrine refuses. What a long-lived session
35//! actually loses to the frozen picture is the commit it just made, and a commit always
36//! moves git. `git add` moves it too: the staging index is watched.
37//!
38//! One kind of unstaged edit is seen anyway: the ones this process makes. A capability whose
39//! effect is a repository mutation — `plan.transition` stamping an issue — writes a tracked
40//! file and moves no git control file, and a Cockpit page read right after the move showed
41//! the status from before it. The executor counts every such call that succeeds, whichever
42//! transport or execution made it, and outlives a reload; a generation records the count it
43//! was built after, and a count that has moved is a repository that has moved.
44//!
45//! # The obligation that comes with watching: this process must not write them
46//!
47//! `git status` and `git diff` refresh the staging index as a side effect, writing back
48//! stat data that has gone stale or is *racily* close to the index's own modification
49//! time. So a served page that asked git about the working tree moved the stamp itself,
50//! and the *next* request paid a whole [`crate::app::App::load`] for a repository that had
51//! not moved — an observer disturbing what it observes, and charging the following caller
52//! for it. Measured over the Cockpit page sweep against a committed fixture:
53//! `repository_scans` 1 → 2 → 4 across `/cockpit/continuity` and `/cockpit/health`, with
54//! `.git/index`'s modification time moving at exactly those two points and its size never
55//! changing. Every read this crate makes therefore goes through
56//! [`crate::git::read_only`], which is that one rule made into a constructor;
57//! `a_page_costs_no_rebuild_of_anything_canonical` holds it, page by page.
58//!
59//! # What a new generation costs, and who pays it
60//!
61//! Rebuilding is a whole [`crate::app::App::load`]: discovery, every declared file read
62//! and validated, the registry, the Why catalogue, the product model, the web topology.
63//! It is paid once per repository move, by the one request that noticed, and never
64//! concurrently — a second request arriving mid-rebuild waits for it and is answered from
65//! the generation it produced, because it has seen the move too and the previous
66//! generation is the stale answer it was asking about. Everything a generation carries is a projection of the
67//! index and is therefore rebuilt with it. What is *not* a projection of the index is
68//! carried across: the peer board, the executions this process is running and the
69//! capability executor, because a reload is not a restart and an attached peer must not
70//! lose its place on the board because somebody committed.
71//!
72//! ```
73//! use std::sync::Arc;
74//! use majordomus_cli::live::Live;
75//!
76//! // A pinned view never asks the filesystem anything: what a one-shot command,
77//! // a benchmark and a test want, and what they cost.
78//! # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
79//! let live = Live::pinned(ctx);
80//! assert_eq!(live.generation(), 0, "a pinned view has one generation, forever");
81//! let a = live.current();
82//! let b = live.current();
83//! assert!(Arc::ptr_eq(&a, &b), "and hands out the same context every time");
84//! # }
85//! ```
86
87use std::path::{Path, PathBuf};
88use std::sync::atomic::{AtomicU64, Ordering};
89use std::sync::{Arc, Mutex, RwLock};
90use std::time::{Duration, Instant};
91
92use crate::capability::Context;
93use crate::cli::RepoArgs;
94
95/// The git control files whose size and modification time say the repository has moved.
96///
97/// Relative to the *per-worktree* git directory (`git rev-parse --git-dir`): a linked
98/// worktree has its own `HEAD`, its own staging index and its own reflog, and watching the
99/// primary checkout's instead would make every worktree's server blind to its own commits.
100pub const WORKTREE_FILES: [&str; 6] = [
101    "HEAD",
102    "index",
103    "ORIG_HEAD",
104    "MERGE_HEAD",
105    "logs/HEAD",
106    "rebase-merge",
107];
108
109/// The git control files shared by every worktree of one repository
110/// (`git rev-parse --git-common-dir`): where a ref that is not this worktree's moves.
111///
112/// `refs` is the directory itself, which changes when a ref file is created, renamed or
113/// removed — the shape a ref update takes, since git writes a lock file and renames it
114/// over the target.
115pub const COMMON_FILES: [&str; 2] = ["packed-refs", "refs"];
116
117/// How long a stamp whose load failed is answered from the previous generation before the
118/// load is tried again. A failure is not retried on every request — a layer that will not
119/// load costs a whole load each time — and it is not given up on either: the change it
120/// failed on is still the repository's, so it is read once the interval has passed, or at
121/// once when git moves again.
122pub const RETRY_AFTER: Duration = Duration::from_secs(5);
123
124/// The salt the stamp is taken under, so that it can never collide with another
125/// fingerprint computed over the same files for another purpose.
126pub const SALT: &str = "live/repository-generation";
127
128/// A picture of the git control files: what the repository looked like when a generation
129/// was built.
130///
131/// Two stamps that are equal mean git has not been asked to move anything. They are
132/// compared, never interpreted: nothing reads a head or a branch out of one.
133///
134/// ```
135/// use majordomus_cli::live::Stamp;
136/// use std::process::Command;
137///
138/// let dir = tempfile::tempdir().expect("a temporary directory");
139/// let git = |args: &[&str]| {
140///     Command::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git")
141/// };
142/// git(&["init", "-q"]);
143/// git(&["config", "user.email", "t@example.com"]);
144/// git(&["config", "user.name", "t"]);
145/// std::fs::write(dir.path().join("a"), "a").expect("a file");
146/// git(&["add", "-A"]);
147/// git(&["commit", "-qm", "one"]);
148///
149/// let watch = Stamp::watching(dir.path()).expect("a work tree is watchable");
150/// let before = watch.take();
151/// assert_eq!(before, watch.take(), "nothing moved, nothing changed");
152///
153/// std::fs::write(dir.path().join("b"), "b").expect("a second file");
154/// git(&["add", "-A"]);
155/// git(&["commit", "-qm", "two"]);
156/// assert_ne!(before, watch.take(), "a commit moved the repository");
157/// ```
158#[derive(Debug, Clone)]
159pub struct Stamp {
160    root: PathBuf,
161    paths: Vec<String>,
162}
163
164impl Stamp {
165    /// The control files of the checkout at `root`, resolved once.
166    ///
167    /// `git rev-parse` is run exactly here — one subprocess for the life of the process —
168    /// because the two directories it names do not move while a checkout exists, and a
169    /// subprocess on the request path is the cost this whole module exists to avoid.
170    /// `None` when the directory is not a work tree, which is a repository of the layer
171    /// that is simply not version controlled: it has no moves to follow.
172    ///
173    /// ```
174    /// use majordomus_cli::live::Stamp;
175    ///
176    /// // a directory git knows nothing about has nothing to watch, and that is not a failure
177    /// let plain = tempfile::tempdir().expect("a temporary directory");
178    /// assert!(Stamp::watching(plain.path()).is_none());
179    /// ```
180    pub fn watching(root: &Path) -> Option<Stamp> {
181        let git_dir = resolve(root, "--git-dir")?;
182        let common = resolve(root, "--git-common-dir").unwrap_or_else(|| git_dir.clone());
183        let mut paths: Vec<String> = Vec::with_capacity(WORKTREE_FILES.len() + COMMON_FILES.len());
184        for name in WORKTREE_FILES {
185            paths.push(git_dir.join(name).to_string_lossy().into_owned());
186        }
187        for name in COMMON_FILES {
188            paths.push(common.join(name).to_string_lossy().into_owned());
189        }
190        Some(Stamp {
191            root: root.to_path_buf(),
192            paths,
193        })
194    }
195
196    /// The files this stamp is taken over, absolute. The hash sorts them, so this order
197    /// is the order they were collected in and carries no meaning of its own.
198    pub fn paths(&self) -> &[String] {
199        &self.paths
200    }
201
202    /// Take the stamp: one `stat` per watched file, hashed.
203    ///
204    /// A file that is not there contributes its absence, which is itself a move worth
205    /// noticing — `MERGE_HEAD` appearing and disappearing is how a merge begins and ends.
206    /// The work is [`crate::environment::cache::fingerprint_of`], which is the
207    /// fingerprint-over-files this crate already had; a second implementation of it would
208    /// be a second thing to keep correct.
209    ///
210    /// ```
211    /// use majordomus_cli::live::Stamp;
212    /// use std::process::Command;
213    ///
214    /// let dir = tempfile::tempdir().expect("a temporary directory");
215    /// Command::new("git").arg("-C").arg(dir.path()).args(["init", "-q"]).output().expect("git");
216    /// let watch = Stamp::watching(dir.path()).expect("a work tree");
217    /// // taking it twice over a repository nobody touched gives one value
218    /// assert_eq!(watch.take(), watch.take());
219    /// ```
220    pub fn take(&self) -> String {
221        crate::environment::cache::fingerprint_of(&self.root, &self.paths, &[SALT])
222    }
223}
224
225fn resolve(root: &Path, flag: &str) -> Option<PathBuf> {
226    let out = crate::git::read_only(root)
227        .args(["rev-parse", flag])
228        .output()
229        .ok()?;
230    if !out.status.success() {
231        return None;
232    }
233    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
234    if text.is_empty() {
235        return None;
236    }
237    let path = PathBuf::from(text);
238    Some(if path.is_absolute() {
239        path
240    } else {
241        root.join(path)
242    })
243}
244
245/// One generation of the repository: the context built from it and the stamp it was built
246/// at.
247struct Generation {
248    ctx: Arc<Context>,
249    stamp: String,
250    number: u64,
251    /// The executor's write count this generation was built after. A write to a tracked
252    /// file moves no git control file; a write this process made moves this.
253    writes: u64,
254}
255
256impl Generation {
257    /// Is this generation still the repository as it is: git has not moved it, and this
258    /// process has not written it since?
259    fn current(&self, taken: &str) -> bool {
260        self.stamp == taken && self.writes == self.ctx.executor.writes()
261    }
262}
263
264/// The repository a process reads, kept current.
265///
266/// Two forms, and the difference is whether anything is watched at all:
267///
268/// - [`Live::pinned`] — one context, forever, at no cost. A command that answers and
269///   exits, a benchmark, a test: the picture cannot go stale inside them, and making them
270///   pay a `stat` per call to prove it would be a regression bought with nothing.
271/// - [`Live::watching`] — the shared server's form. Every [`Live::current`] takes a
272///   [`Stamp`] first and rebuilds when it differs from the one the generation it holds was
273///   built at.
274///
275/// ```
276/// # use std::sync::Arc;
277/// # use majordomus_cli::live::Live;
278/// # use majordomus_cli::capability::Context;
279/// # fn example(ctx: Arc<Context>, args: majordomus_cli::cli::RepoArgs) {
280/// // the shared server follows the repository; a one-shot command does not
281/// let followed = Live::watching(args, Arc::clone(&ctx));
282/// let pinned = Live::pinned(ctx);
283/// assert_eq!(pinned.generation(), followed.generation(), "both start at the first");
284/// # }
285/// ```
286pub struct Live {
287    watch: Option<Watch>,
288    state: RwLock<Generation>,
289    /// Held by the one thread rebuilding. A request that finds it taken waits: it has
290    /// seen the repository move, so the generation that exists is not an answer to it.
291    rebuilding: Mutex<()>,
292}
293
294struct Watch {
295    args: RepoArgs,
296    stamp: Stamp,
297    /// The clock a failed load's retry interval is measured on: the process's own, or a
298    /// test's, so that the interval is asserted rather than slept through.
299    clock: Arc<dyn Fn() -> Instant + Send + Sync>,
300    /// The stamp and the write count the last load failed at, and when. The generation keeps
301    /// the stamp and the count it was built at, so the change stays unconsumed and is read
302    /// again.
303    failed: Mutex<Option<(String, u64, Instant)>>,
304    /// How many loads were attempted: what a test counts to hold a failing load to one
305    /// attempt per interval.
306    loads: AtomicU64,
307}
308
309impl std::fmt::Debug for Live {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        let state = read(&self.state);
312        f.debug_struct("Live")
313            .field("watching", &self.watch.is_some())
314            .field("generation", &state.number)
315            .finish()
316    }
317}
318
319impl Live {
320    /// One context that never changes: no stamp is taken and nothing is rebuilt.
321    ///
322    /// ```
323    /// # use std::sync::Arc;
324    /// # use majordomus_cli::live::Live;
325    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
326    /// let live = Live::pinned(Arc::clone(&ctx));
327    /// assert!(Arc::ptr_eq(&live.current(), &ctx), "the very context it was given");
328    /// # }
329    /// ```
330    pub fn pinned(ctx: Arc<Context>) -> Live {
331        let writes = ctx.executor.writes();
332        Live {
333            watch: None,
334            state: RwLock::new(Generation {
335                ctx,
336                stamp: String::new(),
337                number: 0,
338                writes,
339            }),
340            rebuilding: Mutex::new(()),
341        }
342    }
343
344    /// A context that follows the repository, reloaded with `args` when git moves it.
345    ///
346    /// `args` is how the process was pointed at the repository in the first place, kept so
347    /// that a reload discovers the same root, the same distribution and the same discovery
348    /// mode the first load did. Falls back to [`Live::pinned`] when the root is not a work
349    /// tree: there is nothing to follow, and pretending otherwise would take a stamp over
350    /// a handful of absent files on every request forever.
351    ///
352    /// ```
353    /// # use std::sync::Arc;
354    /// # use majordomus_cli::live::Live;
355    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>, args: majordomus_cli::cli::RepoArgs) {
356    /// let live = Live::watching(args, ctx);
357    /// // the first reading is the one it was handed; a commit makes the next one the second
358    /// assert_eq!(live.view().generation, 0);
359    /// # }
360    /// ```
361    pub fn watching(args: RepoArgs, ctx: Arc<Context>) -> Live {
362        Live::watching_on(args, ctx, Arc::new(Instant::now))
363    }
364
365    /// [`Live::watching`], measuring the retry interval of a failed load on `clock`.
366    fn watching_on(
367        args: RepoArgs,
368        ctx: Arc<Context>,
369        clock: Arc<dyn Fn() -> Instant + Send + Sync>,
370    ) -> Live {
371        let root = PathBuf::from(&ctx.index.repository.root);
372        // The root is pinned to the one already discovered rather than left to be
373        // rediscovered from `--repo`, which is `None` for a process started in the
374        // repository: a server is detached from the terminal that started it, and a
375        // reload that began from the current directory would rediscover whatever
376        // directory the process happens to be in — a different repository, or none.
377        let args = RepoArgs {
378            repo: Some(root.clone()),
379            ..args
380        };
381        let Some(stamp) = Stamp::watching(&root) else {
382            tracing::info!(
383                root = %root.display(),
384                "the repository is not a git work tree: this process serves the layer as it reads it now and has no moves to follow"
385            );
386            return Live::pinned(ctx);
387        };
388        let taken = stamp.take();
389        tracing::info!(
390            watched = stamp.paths().len(),
391            "following the repository: every request compares {} git control file(s) and reloads the layer when they move",
392            stamp.paths().len()
393        );
394        let writes = ctx.executor.writes();
395        Live {
396            watch: Some(Watch {
397                args,
398                stamp,
399                clock,
400                failed: Mutex::new(None),
401                loads: AtomicU64::new(0),
402            }),
403            state: RwLock::new(Generation {
404                ctx,
405                stamp: taken,
406                number: 0,
407                writes,
408            }),
409            rebuilding: Mutex::new(()),
410        }
411    }
412
413    /// Which generation is current, without taking a stamp.
414    ///
415    /// Only for a caller that already holds a [`View`] and wants to say which one it is; a
416    /// caller that is about to *use* the context must take [`Live::view`], because reading
417    /// the number and then the context is two reads with a reload possible between them —
418    /// the bug this whole module exists to end, one layer in. A projection memoised under
419    /// the number read before the reload is the previous generation's, handed out as the
420    /// current one.
421    ///
422    /// ```
423    /// # use std::sync::Arc;
424    /// # use majordomus_cli::live::Live;
425    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
426    /// let live = Live::pinned(ctx);
427    /// assert_eq!(live.generation(), live.view().generation);
428    /// # }
429    /// ```
430    pub fn generation(&self) -> u64 {
431        read(&self.state).number
432    }
433
434    /// The context as it is now: the one that was loaded, or a newly built one when the
435    /// repository has moved under this process.
436    ///
437    /// ```
438    /// # use std::sync::Arc;
439    /// # use majordomus_cli::live::Live;
440    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
441    /// let live = Live::pinned(ctx);
442    /// assert!(Arc::ptr_eq(&live.current(), &live.view().ctx));
443    /// # }
444    /// ```
445    pub fn current(&self) -> Arc<Context> {
446        self.view().ctx
447    }
448
449    /// The current generation and the context of it, as one reading.
450    ///
451    /// One call, so that a caller keying a memo on the number and filling it from the
452    /// context cannot key the new context under the old number.
453    ///
454    /// ```
455    /// # use std::sync::Arc;
456    /// # use majordomus_cli::live::{Live, Memo};
457    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
458    /// let live = Live::pinned(ctx);
459    /// let memo: Memo<usize> = Memo::default();
460    /// // the shape every projection uses: one reading, keyed and filled from itself
461    /// let view = live.view();
462    /// let objects = memo.get_or_init(view.generation, || view.ctx.index.objects.len());
463    /// assert_eq!(*objects, view.ctx.index.objects.len());
464    /// # }
465    /// ```
466    pub fn view(&self) -> View {
467        let Some(watch) = self.watch.as_ref() else {
468            let state = read(&self.state);
469            return View {
470                generation: state.number,
471                ctx: Arc::clone(&state.ctx),
472            };
473        };
474        let taken = watch.stamp.take();
475        {
476            let state = read(&self.state);
477            if state.current(&taken) || watch.waiting(&taken, state.ctx.executor.writes()) {
478                return View {
479                    generation: state.number,
480                    ctx: Arc::clone(&state.ctx),
481                };
482            }
483        }
484        self.reload(watch, taken)
485    }
486
487    /// The generation there is, at once, and whether the repository has moved past it: what
488    /// a page a person is looking at asks for (ADR 0142).
489    ///
490    /// [`Live::view`] waits for a reload, because an answer must be of the repository the
491    /// caller saw. A page need not be: it is drawn from the generation that exists, says that
492    /// the repository changed, and is read again once the new generation has loaded. So
493    /// when the repository has moved, this starts the reload on a thread of its own, unless
494    /// one is already running, and returns the existing generation with `behind` set.
495    /// Nothing about [`Live::view`] changes for any other caller.
496    ///
497    /// ```
498    /// use majordomus_cli::live::Live;
499    /// use majordomus_cli::synthetic::SyntheticRepository;
500    /// use std::sync::Arc;
501    ///
502    /// let repo = SyntheticRepository::small().unwrap();
503    /// let live = Arc::new(Live::pinned(repo.context().unwrap()));
504    /// // a pinned view follows nothing, so the generation there is is the one to show and
505    /// // the repository has never moved past it
506    /// let (view, behind) = live.view_without_waiting();
507    /// assert!(!behind);
508    /// assert_eq!(view.generation, live.view().generation);
509    /// ```
510    pub fn view_without_waiting(self: &Arc<Self>) -> (View, bool) {
511        let Some(watch) = self.watch.as_ref() else {
512            return (self.view(), false);
513        };
514        let taken = watch.stamp.take();
515        let existing = {
516            let state = read(&self.state);
517            let view = View {
518                generation: state.number,
519                ctx: Arc::clone(&state.ctx),
520            };
521            if state.current(&taken) {
522                return (view, false);
523            }
524            view
525        };
526        // one reload at a time: a caller that finds one running leaves it to finish
527        if let Ok(guard) = self.rebuilding.try_lock() {
528            drop(guard);
529            let live = Arc::clone(self);
530            let started = std::thread::Builder::new()
531                .name("mj-reload".into())
532                .spawn(move || {
533                    live.view();
534                });
535            if let Err(refused) = started {
536                tracing::warn!("no thread to reload the repository behind a page: {refused}");
537                return (self.view(), false);
538            }
539        }
540        (existing, true)
541    }
542
543    fn reload(&self, watch: &Watch, taken: String) -> View {
544        // A caller that arrives mid-rebuild has already seen the stamp move: the generation
545        // that exists is known to be stale, and answering from it is the frozen picture
546        // again for as long as a load takes. The server's own tick takes a view twice a
547        // second, so it is usually the one rebuilding, and a session that commits and asks
548        // at once was handed the commit before its own. It waits instead; the check below
549        // then finds the new generation, or rebuilds when the rebuilder's stamp was older
550        // than this caller's.
551        let _one_at_a_time = self
552            .rebuilding
553            .lock()
554            .unwrap_or_else(std::sync::PoisonError::into_inner);
555        let previous = {
556            let state = read(&self.state);
557            // Asked again, now that the rebuild this caller waited for is over. It finds
558            // the new generation when that rebuild read the stamp this caller saw — or it
559            // finds that the rebuild failed at that very stamp, and then it is answered
560            // from the previous generation like every caller inside the retry interval. A
561            // waiter that loaded here instead would try the failing load once per waiter:
562            // every request that queued behind one failure would repeat it, which is the
563            // cost the interval exists to bound.
564            if state.current(&taken) || watch.waiting(&taken, state.ctx.executor.writes()) {
565                return View {
566                    generation: state.number,
567                    ctx: Arc::clone(&state.ctx),
568                };
569            }
570            Arc::clone(&state.ctx)
571        };
572        // read before the load: a write that lands while the layer is being read is one the
573        // new generation may not carry, and it must leave the next reading stale
574        let writes = previous.executor.writes();
575        watch.loads.fetch_add(1, Ordering::SeqCst);
576        let built = crate::app::App::load(&watch.args);
577        let mut state = write(&self.state);
578        match built {
579            Ok(app) => {
580                *lock(&watch.failed) = None;
581                state.writes = writes;
582                state.ctx = Arc::new(app.context.continuing(&previous));
583                state.number += 1;
584                // the declaration may have changed with the repository: a key withdrawn from
585                // it loses its links now, not at the next restart (I2134)
586                warn_of_ended_links(&crate::capability::builtin::mesh::retrust(&state.ctx));
587                state.stamp = taken;
588                tracing::info!(
589                    generation = state.number,
590                    objects = state.ctx.index.objects.len(),
591                    head = %head_of(&state.ctx),
592                    "the repository moved; this process reads generation {} of it",
593                    state.number
594                );
595            }
596            Err(e) => {
597                // A repository caught mid-rebase, a manifest being edited, a checkout half
598                // written: the last generation that loaded is a better answer than an
599                // error. The generation keeps the stamp it was built at, so the change is
600                // not consumed by a load that failed to read it; the failure is recorded
601                // instead, and holds the retry off for RETRY_AFTER — never once per request,
602                // never given up on (Watch::waiting).
603                *lock(&watch.failed) = Some((taken, writes, (watch.clock)()));
604                tracing::warn!(
605                    error = %e,
606                    "the repository moved but the layer would not load; serving generation {} and reading it again in {}s, or at once when it moves again",
607                    state.number,
608                    RETRY_AFTER.as_secs()
609                );
610            }
611        }
612        View {
613            generation: state.number,
614            ctx: Arc::clone(&state.ctx),
615        }
616    }
617}
618
619/// One reading of the repository: which generation, and the context of it.
620///
621/// The pair is taken together because a memo keyed on the number and filled from the
622/// context must not mix two readings: taking the number first, then the context, lets a
623/// reload happen between them and files the new context under the old number, where the
624/// next request finds the *previous* generation and calls it current. That is the original
625/// defect in miniature, and it is why there is no way to ask for the two separately on the
626/// path that uses them.
627///
628/// ```
629/// # use std::sync::Arc;
630/// # use majordomus_cli::live::Live;
631/// # use majordomus_cli::live::View;
632/// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
633/// let view: View = Live::pinned(ctx).view();
634/// assert_eq!(view.generation, 0);
635/// assert!(view.ctx.registry.len() > 0, "the context of that generation, not another");
636/// # }
637/// ```
638#[derive(Clone)]
639pub struct View {
640    /// Which generation of the repository this is.
641    pub generation: u64,
642    /// The context built from it.
643    pub ctx: Arc<Context>,
644}
645
646impl std::fmt::Debug for View {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        f.debug_struct("View")
649            .field("generation", &self.generation)
650            .field("objects", &self.ctx.index.objects.len())
651            .finish()
652    }
653}
654
655/// What a projection is built over: something that can hand out the current context.
656///
657/// One trait so that the shared server passes the [`Live`] it watches with and every other
658/// caller — one-shot commands, benchmarks, the crate's own tests — passes the
659/// [`Arc<Context>`] it already has and gets a pinned view without saying so.
660///
661/// ```
662/// # use std::sync::Arc;
663/// # use majordomus_cli::live::{IntoLive, Live};
664/// # fn example(ctx: Arc<majordomus_cli::capability::Context>, live: Arc<Live>) {
665/// // a projection takes either, and neither caller has to say which it holds
666/// fn projection(over: impl IntoLive) -> u64 { over.into_live().generation() }
667/// assert_eq!(projection(ctx), 0);
668/// assert_eq!(projection(live), 0);
669/// # }
670/// ```
671pub trait IntoLive {
672    /// The live view this value stands for.
673    ///
674    /// ```
675    /// # use std::sync::Arc;
676    /// # use majordomus_cli::live::IntoLive;
677    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
678    /// // a plain context becomes a view that follows nothing and costs nothing
679    /// assert_eq!(ctx.into_live().generation(), 0);
680    /// # }
681    /// ```
682    fn into_live(self) -> Arc<Live>;
683}
684
685impl IntoLive for Arc<Live> {
686    fn into_live(self) -> Arc<Live> {
687        self
688    }
689}
690
691impl IntoLive for Arc<Context> {
692    fn into_live(self) -> Arc<Live> {
693        Arc::new(Live::pinned(self))
694    }
695}
696
697/// A value derived from one generation of the repository, kept until that generation ends.
698///
699/// The OpenAPI document, the MCP tool and resource listings and the resolved web surfaces
700/// are all expensive functions of the index and the registry, and all of them used to be
701/// `OnceLock`s — right while the index was immutable for the life of a process, and a
702/// second frozen picture the moment it was not. A memo answers from the generation it was
703/// built for and rebuilds for the next one.
704///
705/// ```
706/// use majordomus_cli::live::Memo;
707///
708/// let memo: Memo<String> = Memo::default();
709/// let a = memo.get_or_init(0, || "built once".to_string());
710/// let b = memo.get_or_init(0, || panic!("the same generation must not rebuild"));
711/// assert_eq!(*a, *b);
712/// let c = memo.get_or_init(1, || "built again".to_string());
713/// assert_eq!(*c, "built again", "a new generation is a new value");
714/// ```
715#[derive(Debug)]
716pub struct Memo<T> {
717    cell: Mutex<Option<(u64, Arc<T>)>>,
718}
719
720impl<T> Default for Memo<T> {
721    fn default() -> Self {
722        Memo {
723            cell: Mutex::new(None),
724        }
725    }
726}
727
728impl<T> Memo<T> {
729    /// The value for `generation`, building it once when this memo does not hold it.
730    ///
731    /// The lock is held across `build`, so two threads that arrive together on a new
732    /// generation do the work once rather than twice.
733    ///
734    /// ```
735    /// use majordomus_cli::live::Memo;
736    ///
737    /// let memo: Memo<Vec<u8>> = Memo::default();
738    /// let first = memo.get_or_init(3, || vec![1, 2, 3]);
739    /// let again = memo.get_or_init(3, || unreachable!("the same generation is not rebuilt"));
740    /// assert!(std::sync::Arc::ptr_eq(&first, &again));
741    /// assert_eq!(*memo.get_or_init(4, Vec::new), Vec::<u8>::new(), "a new generation rebuilds");
742    /// ```
743    pub fn get_or_init(&self, generation: u64, build: impl FnOnce() -> T) -> Arc<T> {
744        let mut cell = self.cell.lock().unwrap_or_else(|e| e.into_inner());
745        if let Some((held, value)) = cell.as_ref() {
746            if *held == generation {
747                return Arc::clone(value);
748            }
749        }
750        let value = Arc::new(build());
751        *cell = Some((generation, Arc::clone(&value)));
752        value
753    }
754}
755
756/// The head the index was built at, for the log line that announces a generation.
757fn head_of(ctx: &Context) -> String {
758    match &ctx.index.repository.git {
759        crate::git::GitState::Available(info) => info.head.clone().unwrap_or_else(|| "-".into()),
760        crate::git::GitState::Unavailable { .. } => "-".into(),
761    }
762}
763
764/// Say which mesh links a reload ended because the declaration no longer trusts their keys
765/// (I2134); `true` when there was one to name. A reload that ended none says nothing.
766fn warn_of_ended_links(ended: &[String]) -> bool {
767    if ended.is_empty() {
768        return false;
769    }
770    tracing::warn!(
771        peers = ?ended,
772        "mesh links ended: their keys are no longer trusted by the declaration"
773    );
774    true
775}
776
777impl Watch {
778    /// Whether `taken` and `writes` are the stamp and the write count the last load failed
779    /// at, inside the retry interval: the previous generation answers, and nothing is loaded.
780    /// A stamp or a count that moved since, or an interval that has passed, is read again.
781    fn waiting(&self, taken: &str, writes: u64) -> bool {
782        lock(&self.failed)
783            .as_ref()
784            .is_some_and(|(stamp, failed_at, at)| {
785                stamp == taken && *failed_at == writes && (self.clock)() < *at + RETRY_AFTER
786            })
787    }
788}
789
790fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
791    mutex
792        .lock()
793        .unwrap_or_else(std::sync::PoisonError::into_inner)
794}
795
796fn read<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
797    lock.read().unwrap_or_else(|e| e.into_inner())
798}
799
800fn write<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
801    lock.write().unwrap_or_else(|e| e.into_inner())
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use std::process::Command;
808    use std::time::Instant;
809
810    fn repo() -> tempfile::TempDir {
811        let dir = tempfile::tempdir().expect("a temporary directory");
812        let git = |args: &[&str]| {
813            Command::new("git")
814                .arg("-C")
815                .arg(dir.path())
816                .args(args)
817                .output()
818                .expect("git")
819        };
820        git(&["init", "-q"]);
821        git(&["config", "user.email", "t@example.com"]);
822        git(&["config", "user.name", "t"]);
823        std::fs::write(dir.path().join("a"), "a").expect("a file");
824        git(&["add", "-A"]);
825        git(&["commit", "-qm", "one"]);
826        dir
827    }
828
829    /// A synthetic layer in a git work tree, loaded once, with the arguments that reload it
830    /// and a clock the test moves by hand.
831    struct Layer {
832        repo: crate::synthetic::SyntheticRepository,
833        live: Live,
834        now: Arc<Mutex<Instant>>,
835    }
836
837    impl Layer {
838        fn new() -> Layer {
839            let repo = crate::synthetic::SyntheticRepository::small().expect("a synthetic layer");
840            let layer = |args: &[&str]| {
841                Command::new("git")
842                    .arg("-C")
843                    .arg(repo.root())
844                    .args(args)
845                    .output()
846                    .expect("git")
847            };
848            layer(&["init", "-q"]);
849            layer(&["config", "user.email", "t@example.com"]);
850            layer(&["config", "user.name", "t"]);
851            layer(&["add", "-A"]);
852            layer(&["commit", "-qm", "layer"]);
853            let args = RepoArgs {
854                repo: Some(repo.root().to_path_buf()),
855                share: Some(crate::synthetic::crate_share()),
856                discovery: crate::cli::DiscoveryMode::Filesystem,
857                ..Default::default()
858            };
859            let ctx = crate::app::App::load(&args)
860                .expect("the layer loads")
861                .context;
862            let now = Arc::new(Mutex::new(Instant::now()));
863            let clock = {
864                let now = Arc::clone(&now);
865                Arc::new(move || *lock(&now))
866            };
867            Layer {
868                live: Live::watching_on(args, ctx, clock),
869                repo,
870                now,
871            }
872        }
873
874        fn git(&self, args: &[&str]) {
875            let out = Command::new("git")
876                .arg("-C")
877                .arg(self.repo.root())
878                .args(args)
879                .output()
880                .expect("git");
881            assert!(out.status.success(), "git {args:?}: {out:?}");
882        }
883
884        fn manifest(&self) -> PathBuf {
885            self.repo.root().join(".ai/manifest.yaml")
886        }
887
888        /// The manifest gains a key nothing declares, so the layer will not load, and the
889        /// change is committed: git moves.
890        fn break_and_commit(&self) -> String {
891            let manifest = std::fs::read_to_string(self.manifest()).expect("a manifest");
892            std::fs::write(
893                self.manifest(),
894                format!("{manifest}a_key_nothing_declares: 1\n"),
895            )
896            .expect("the manifest is written");
897            self.git(&["commit", "-qam", "a manifest that will not load"]);
898            manifest
899        }
900
901        fn loads(&self) -> u64 {
902            self.live
903                .watch
904                .as_ref()
905                .expect("a watching view")
906                .loads
907                .load(Ordering::SeqCst)
908        }
909
910        fn advance(&self, by: Duration) {
911            let mut now = lock(&self.now);
912            *now += by;
913        }
914    }
915
916    #[test]
917    fn a_failed_load_consumes_no_change_and_is_read_again_after_the_interval() {
918        let l = Layer::new();
919        let good = l.break_and_commit();
920        assert_eq!(l.live.view().generation, 0, "the layer will not load");
921        assert_eq!(l.loads(), 1);
922        // the layer is mended in the work tree alone: no git control file moves, so only the
923        // retry can find it
924        std::fs::write(l.manifest(), good).expect("the manifest is written");
925        assert_eq!(
926            l.live.view().generation,
927            0,
928            "inside the interval, nothing is loaded"
929        );
930        assert_eq!(
931            l.loads(),
932            1,
933            "a failed load is not retried on every request"
934        );
935        l.advance(RETRY_AFTER);
936        assert_eq!(
937            l.live.view().generation,
938            1,
939            "the change the failed load did not read is read once the interval has passed"
940        );
941        assert_eq!(l.loads(), 2);
942        assert_eq!(l.live.view().generation, 1, "and is then current");
943        assert_eq!(l.loads(), 2);
944    }
945
946    #[test]
947    fn a_failure_is_read_again_at_once_when_the_repository_moves_again() {
948        let l = Layer::new();
949        let good = l.break_and_commit();
950        assert_eq!(l.live.view().generation, 0);
951        std::fs::write(l.manifest(), good).expect("the manifest is written");
952        l.git(&["commit", "-qam", "the manifest mended"]);
953        assert_eq!(
954            l.live.view().generation,
955            1,
956            "a new move is read at once, whatever the interval"
957        );
958        assert_eq!(l.loads(), 2);
959    }
960
961    #[test]
962    fn a_write_whose_reload_fails_is_read_again_after_the_interval() {
963        let l = Layer::new();
964        // the layer is broken in the work tree and this process writes: no git control file
965        // moves, so only the write count says the repository moved
966        let good = std::fs::read_to_string(l.manifest()).expect("a manifest");
967        std::fs::write(l.manifest(), format!("{good}a_key_nothing_declares: 1\n"))
968            .expect("the manifest is written");
969        l.live.current().executor.count_write();
970        assert_eq!(l.live.view().generation, 0, "the layer will not load");
971        assert_eq!(l.loads(), 1);
972        std::fs::write(l.manifest(), &good).expect("the manifest is written");
973        assert_eq!(
974            l.live.view().generation,
975            0,
976            "inside the interval, nothing is loaded"
977        );
978        assert_eq!(l.loads(), 1);
979        l.advance(RETRY_AFTER);
980        assert_eq!(
981            l.live.view().generation,
982            1,
983            "the write the failed load did not read is read once the interval has passed"
984        );
985        assert_eq!(l.loads(), 2);
986    }
987
988    #[test]
989    fn a_new_write_after_a_failed_load_is_read_at_once() {
990        let l = Layer::new();
991        let good = l.break_and_commit();
992        assert_eq!(l.live.view().generation, 0);
993        std::fs::write(l.manifest(), good).expect("the manifest is written");
994        l.live.current().executor.count_write();
995        assert_eq!(
996            l.live.view().generation,
997            1,
998            "a write is a move, read at once whatever the interval"
999        );
1000        assert_eq!(l.loads(), 2);
1001    }
1002
1003    #[test]
1004    fn a_load_that_keeps_failing_is_tried_once_per_interval() {
1005        let l = Layer::new();
1006        l.break_and_commit();
1007        for _ in 0..5 {
1008            assert_eq!(l.live.view().generation, 0);
1009        }
1010        assert_eq!(l.loads(), 1);
1011        l.advance(RETRY_AFTER - Duration::from_millis(1));
1012        assert_eq!(l.live.view().generation, 0);
1013        assert_eq!(l.loads(), 1, "not a moment before the interval");
1014        l.advance(Duration::from_millis(1));
1015        for _ in 0..3 {
1016            assert_eq!(l.live.view().generation, 0);
1017        }
1018        assert_eq!(l.loads(), 2, "once when it has passed, and not again");
1019    }
1020
1021    /// A caller that queued behind a rebuild which then failed is answered from the
1022    /// previous generation and loads nothing: the failure it waited for is the failure at
1023    /// the stamp it saw, and that is retried once per interval, not once per waiter.
1024    #[test]
1025    fn a_caller_that_waited_for_a_rebuild_that_failed_does_not_repeat_it() {
1026        let l = Layer::new();
1027        l.break_and_commit();
1028        let watch = l.live.watch.as_ref().expect("a watching view");
1029        // the rebuild somebody else is running
1030        let rebuilding = l.live.rebuilding.lock().expect("the rebuild");
1031        let (said, heard) = std::sync::mpsc::channel();
1032        std::thread::scope(|scope| {
1033            scope.spawn(|| {
1034                let _ = said.send(l.live.view().generation);
1035            });
1036            assert!(
1037                heard
1038                    .recv_timeout(std::time::Duration::from_millis(300))
1039                    .is_err(),
1040                "a caller that saw the move was answered while the rebuild was still running"
1041            );
1042            // that rebuild ends as a failed load does: the stamp and the write count it
1043            // failed at are recorded, and the generation is left where it was
1044            *lock(&watch.failed) = Some((
1045                watch.stamp.take(),
1046                read(&l.live.state).ctx.executor.writes(),
1047                (watch.clock)(),
1048            ));
1049            drop(rebuilding);
1050            let generation = heard
1051                .recv_timeout(std::time::Duration::from_secs(60))
1052                .expect("the caller is answered once the rebuild is over");
1053            assert_eq!(generation, 0, "from the generation that loaded");
1054        });
1055        assert_eq!(
1056            l.loads(),
1057            0,
1058            "the waiter repeated the load it had just waited to see fail"
1059        );
1060        // and the change is still read: once the interval has passed, by whoever asks
1061        l.advance(RETRY_AFTER);
1062        assert_eq!(l.live.view().generation, 0, "the layer still will not load");
1063        assert_eq!(l.loads(), 1, "tried once when the interval has passed");
1064    }
1065
1066    /// A synthetic layer in a git work tree with one commit, and a `Live` watching it.
1067    fn followed() -> (crate::synthetic::SyntheticRepository, Live) {
1068        let fixture = crate::synthetic::SyntheticRepository::small().expect("a layer");
1069        let git = |args: &[&str]| {
1070            Command::new("git")
1071                .arg("-C")
1072                .arg(fixture.root())
1073                .args(args)
1074                .output()
1075                .expect("git")
1076        };
1077        git(&["init", "-q"]);
1078        git(&["config", "user.email", "t@example.com"]);
1079        git(&["config", "user.name", "t"]);
1080        git(&["add", "-A"]);
1081        git(&["commit", "-qm", "one"]);
1082        let args = RepoArgs {
1083            repo: Some(fixture.root().to_path_buf()),
1084            discovery: crate::cli::DiscoveryMode::Filesystem,
1085            share: Some(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share")),
1086            ..Default::default()
1087        };
1088        let app = crate::app::App::load(&args).expect("the layer loads");
1089        let live = Live::watching(args, Arc::clone(&app.context));
1090        (fixture, live)
1091    }
1092
1093    fn head_in(root: &Path) -> String {
1094        let out = Command::new("git")
1095            .arg("-C")
1096            .arg(root)
1097            .args(["rev-parse", "HEAD"])
1098            .output()
1099            .expect("git");
1100        String::from_utf8_lossy(&out.stdout).trim().to_string()
1101    }
1102
1103    #[test]
1104    fn a_request_that_arrives_during_a_rebuild_waits_for_it() {
1105        let (fixture, live) = followed();
1106        let before = head_in(fixture.root());
1107        assert_eq!(head_of(&live.view().ctx), before, "it starts on HEAD");
1108
1109        // the rebuild somebody else is running: the server's tick, in the process this
1110        // was found in
1111        let rebuilding = live.rebuilding.lock().expect("the rebuild");
1112        Command::new("git")
1113            .arg("-C")
1114            .arg(fixture.root())
1115            .args(["commit", "-q", "--allow-empty", "-m", "two"])
1116            .output()
1117            .expect("git");
1118        let after = head_in(fixture.root());
1119        assert_ne!(before, after, "the commit moved HEAD");
1120
1121        let (said, heard) = std::sync::mpsc::channel();
1122        std::thread::scope(|scope| {
1123            scope.spawn(|| {
1124                let _ = said.send(head_of(&live.view().ctx));
1125            });
1126            assert!(
1127                heard
1128                    .recv_timeout(std::time::Duration::from_millis(300))
1129                    .is_err(),
1130                "a caller that saw the move was answered while the rebuild was still running"
1131            );
1132            drop(rebuilding);
1133            let answered = heard
1134                .recv_timeout(std::time::Duration::from_secs(60))
1135                .expect("the caller is answered once the rebuild is over");
1136            assert_eq!(answered, after, "and with the commit it asked about");
1137        });
1138    }
1139
1140    /// A page does not wait for a rebuild: it is handed the generation there is, told the
1141    /// repository has moved past it, and the rebuild runs behind it; once it is over, the
1142    /// next page is current. A caller of `view` still waits, as before.
1143    #[test]
1144    fn a_page_is_handed_the_generation_there_is_and_the_rebuild_runs_behind_it() {
1145        let (fixture, live) = followed();
1146        let live = Arc::new(live);
1147        let before = head_in(fixture.root());
1148        let (view, behind) = live.view_without_waiting();
1149        assert!(!behind, "nothing has moved");
1150        assert_eq!(head_of(&view.ctx), before);
1151
1152        // a rebuild already running: the page is not held by it
1153        let rebuilding = live.rebuilding.lock().expect("the rebuild");
1154        Command::new("git")
1155            .arg("-C")
1156            .arg(fixture.root())
1157            .args(["commit", "-q", "--allow-empty", "-m", "two"])
1158            .output()
1159            .expect("git");
1160        let after = head_in(fixture.root());
1161        let started = std::time::Instant::now();
1162        let (view, behind) = live.view_without_waiting();
1163        assert!(
1164            started.elapsed() < std::time::Duration::from_secs(1),
1165            "the page waited for the rebuild: {:?}",
1166            started.elapsed()
1167        );
1168        assert!(
1169            behind,
1170            "the page is told the repository moved past what it shows"
1171        );
1172        assert_eq!(head_of(&view.ctx), before, "as it was");
1173        drop(rebuilding);
1174
1175        // no rebuild running: the page starts one behind itself, and the generation catches up
1176        let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
1177        loop {
1178            let (view, behind) = live.view_without_waiting();
1179            if !behind {
1180                assert_eq!(
1181                    head_of(&view.ctx),
1182                    after,
1183                    "current once the rebuild is over"
1184                );
1185                break;
1186            }
1187            assert!(
1188                std::time::Instant::now() < until,
1189                "the rebuild never caught up"
1190            );
1191            std::thread::sleep(std::time::Duration::from_millis(20));
1192        }
1193        // a pinned view has nothing to follow, and is never behind
1194        let pinned = Arc::new(Live::pinned(Arc::clone(&live.view().ctx)));
1195        assert!(!pinned.view_without_waiting().1);
1196    }
1197
1198    #[test]
1199    fn a_directory_that_is_not_a_work_tree_has_nothing_to_watch() {
1200        let plain = tempfile::tempdir().expect("a temporary directory");
1201        assert!(
1202            Stamp::watching(plain.path()).is_none(),
1203            "there are no moves to follow where there is no git"
1204        );
1205    }
1206
1207    #[test]
1208    fn a_commit_moves_the_stamp_and_a_read_does_not() {
1209        let dir = repo();
1210        let watch = Stamp::watching(dir.path()).expect("a work tree");
1211        let before = watch.take();
1212        // reading the repository is not moving it
1213        let _ = std::fs::read_to_string(dir.path().join("a"));
1214        assert_eq!(before, watch.take());
1215        Command::new("git")
1216            .arg("-C")
1217            .arg(dir.path())
1218            .args(["commit", "-q", "--allow-empty", "-m", "two"])
1219            .output()
1220            .expect("git");
1221        assert_ne!(before, watch.take(), "a commit is a move");
1222    }
1223
1224    #[test]
1225    fn staging_a_file_moves_the_stamp() {
1226        let dir = repo();
1227        let watch = Stamp::watching(dir.path()).expect("a work tree");
1228        let before = watch.take();
1229        std::fs::write(dir.path().join("b"), "b").expect("a file");
1230        Command::new("git")
1231            .arg("-C")
1232            .arg(dir.path())
1233            .args(["add", "-A"])
1234            .output()
1235            .expect("git");
1236        assert_ne!(before, watch.take(), "the staging index is watched too");
1237    }
1238
1239    #[test]
1240    fn the_stamp_is_cheap_enough_to_take_on_every_request() {
1241        // The whole design rests on this: the check runs on the request's own thread,
1242        // before the request is answered, and the HTTP p50 this repository records is a
1243        // few hundred microseconds. A stamp that cost a millisecond would be the
1244        // regression the performance doctrine refuses, and the file set would have to
1245        // shrink. The bound is generous — this runs on loaded CI machines — and it still
1246        // fails long before the cost could matter.
1247        let dir = repo();
1248        let watch = Stamp::watching(dir.path()).expect("a work tree");
1249        let _warm = watch.take();
1250        let rounds = 200;
1251        let started = Instant::now();
1252        for _ in 0..rounds {
1253            let _ = watch.take();
1254        }
1255        let each = started.elapsed() / rounds;
1256        assert!(
1257            each < std::time::Duration::from_millis(2),
1258            "a stamp took {each:?}, which is too much to take on every request"
1259        );
1260    }
1261
1262    #[test]
1263    fn the_paths_are_the_worktree_files_then_the_common_ones() {
1264        let dir = repo();
1265        let watch = Stamp::watching(dir.path()).expect("a work tree");
1266        assert_eq!(
1267            watch.paths().len(),
1268            WORKTREE_FILES.len() + COMMON_FILES.len()
1269        );
1270        assert!(
1271            watch.paths().iter().all(|p| p.starts_with('/')),
1272            "absolute, so that a linked worktree's git directory outside the root is read"
1273        );
1274    }
1275
1276    #[test]
1277    fn a_memo_of_one_generation_builds_once() {
1278        let memo: Memo<u64> = Memo::default();
1279        let mut built = 0u64;
1280        for _ in 0..5 {
1281            memo.get_or_init(7, || {
1282                built += 1;
1283                7
1284            });
1285        }
1286        assert_eq!(built, 1);
1287    }
1288
1289    #[test]
1290    fn a_reload_names_the_links_it_ended_and_is_silent_when_it_ended_none() {
1291        assert!(!warn_of_ended_links(&[]));
1292        assert!(warn_of_ended_links(&["node-a".to_string()]));
1293    }
1294}