wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What one `wsb update` run may look up more than once.
//!
//! `wsb update --recursive` is not one update. It is one update per repository
//! in the nesting tree — 34 of them in this worktree — and each of those,
//! today, starts from nothing: it walks the whole worktree for `.wsb/repo.json`
//! files, walks it again for `.wstemplate` files, reads and parses every
//! template to ask whether it names this project, counts the commits of every
//! repository under it, and asks the network for the tags of every project a
//! template pins. Thirty-four times over, for answers that were the same every
//! time.
//!
//! This is where those answers are kept for the length of a run.
//!
//! # What may be cached, and what may not
//!
//! The rule is the whole design: **an answer may be cached only if nothing an
//! update does can change it.** An update writes files. So:
//!
//! | Answer | Cached | Why |
//! |---|---|---|
//! | `.wsb/repo.json` alias map | yes | an update never writes a repository's settings |
//! | `.wstemplate` file lists | yes | an update writes a template's OUTPUT, never a template |
//! | template text and its references | yes | same reason |
//! | a repository's submodules | yes | an update does not add or remove one |
//! | commit counts, tree-change counts | yes | an update does not commit |
//! | tags, `git describe`, HEAD | yes | an update does not move a ref — unless `--tag` says to, and then [`RunCache::forget_repo`] is called |
//! | remotes, remote URLs, remote tags | yes | an update does not push |
//! | **whether a worktree is clean** | **no** | an update writes into worktrees; this is the one fact it changes |
//! | **a project's `version.txt`** | **no** | an update writes it, and a later repository in the run has to read what the earlier one wrote |
//!
//! The last two rows are why `--recursive` renders a template once for its
//! owner and again for every project it names: a manifest is rendered before
//! the project it pins has been updated, and rendered again afterwards against
//! the version.txt that update wrote. Caching either of them would collapse
//! that into one answer and change what lands on disk.
//!
//! The two claims that an update CAN break are not left as comments.
//! [`RunCache::forget_repo`] and [`RunCache::note_written`] drop what a claim
//! stops covering — so a worktree that breaks one gets the slow answer rather
//! than the wrong one.
//!
//! # Using one, or not
//!
//! A cache is not a mode. `RunCache::new()` handed to a single update is the
//! behaviour of a run that caches nothing, because nothing is ever asked twice;
//! that is exactly what the non-recursive `wsb update` does, and what
//! `--verify` re-runs each repository against to prove the shared one agrees.

use crate::prelude::*;
use crate::state::template_refs::TemplateReferences;
use std::collections::BTreeMap;
use std::collections::HashMap as Map;
use std::hash::Hash;
use std::sync::Mutex;

/// HEAD as one answer, so a repository is opened and read once per run.
///
/// The shape follows [`crate::state::project_facts`]'s use of it: whether HEAD
/// is detached decides whether there is a branch to name, and a repository with
/// no commits has no commit fields at all.
#[derive(Debug, Clone)]
pub struct HeadSnapshot {
    pub detached: bool,
    /// The branch name, when HEAD is attached to one.
    pub branch: Option<String>,
    /// `(full hex id, seconds since epoch, timezone offset in seconds)`, absent
    /// when the repository has no commits yet.
    pub commit: Option<(String, i64, i32)>,
}

/// Everything one run has already answered, and the variables it renders
/// under.
///
/// Every field but `environment` is a memo keyed by the thing it describes,
/// and none of those is authoritative: dropping them changes how long a run
/// takes and nothing else. `environment` is what the memos were answered
/// UNDER — a render with a variable set produces different text — which is
/// why a run with different variables is a different cache.
#[derive(Default)]
pub struct RunCache {
    /// Variables templates see set for this run, over the process's own.
    ///
    /// How a render is produced as if a variable were set without setting it
    /// for the whole process: the environment is shared by every thread, and
    /// a read-only query that set and restored a variable changed what every
    /// concurrent render saw while it ran.
    environment: BTreeMap<String, String>,
    /// `scan_root` → `alias → project root`.
    project_roots: Mutex<Map<PathBuf, Arc<HashMap<String, PathBuf>>>>,
    /// `root` → the `.wstemplate` files under it, as the ignore rules there see them.
    templates: Mutex<Map<PathBuf, Arc<Vec<PathBuf>>>>,
    /// `template path` → its text.
    template_text: Mutex<Map<PathBuf, Arc<String>>>,
    /// `template path` → what that text refers to.
    template_refs: Mutex<Map<PathBuf, Arc<TemplateReferences>>>,
    /// `project root` → the absolute paths of its submodules.
    submodule_roots: Mutex<Map<PathBuf, Arc<Vec<PathBuf>>>>,
    /// `(repo root, major)` → its part of a version: (commits, changes since
    /// its last release), counted for the commit that holds its tree.
    version_counts: Mutex<Map<(PathBuf, u32), (u32, u32)>>,
    /// The repository whose commit this run renders for — the pre-commit
    /// hook's `wsb update --git-add` — whose version is the one it will have
    /// once the commit lands, whatever the commit holds.
    committing: Mutex<Option<PathBuf>>,
    /// `repo root` → tags in the clone, newest first, as the repository OPENED
    /// at that path reports them.
    tags: Mutex<Map<PathBuf, Arc<Vec<String>>>>,
    /// The same, for the version calculation, which DISCOVERS the repository
    /// containing a path instead of insisting the path is one. A separate memo
    /// because it is a separate question: for a directory that is not a
    /// repository the two have different answers, and one key serving both
    /// would hand a project its parent's tags.
    discovered_tags: Mutex<Map<PathBuf, Arc<Vec<String>>>>,
    /// `repo root` → the newest tag reachable from HEAD.
    nearest_tags: Mutex<Map<PathBuf, Arc<Option<String>>>>,
    /// `repo root` → HEAD.
    heads: Mutex<Map<PathBuf, Arc<HeadSnapshot>>>,
    /// `repo root` → configured remote names, or the reason there are none.
    remotes: Mutex<Map<PathBuf, Arc<std::result::Result<Vec<String>, String>>>>,
    /// `(repo root, remote)` → that remote's fetch URL.
    remote_urls: Mutex<Map<(PathBuf, String), Arc<String>>>,
    /// `(repo root, remote)` → the tags on that remote. The only entry a
    /// network round trip pays for, and the reason a run went to GitHub 48
    /// times for 7 projects.
    remote_tags: Mutex<Map<(PathBuf, String), Arc<Vec<String>>>>,
}

impl RunCache {
    /// A cache that has answered nothing yet.
    pub fn new() -> Arc<Self> {
        Arc::new(Self::default())
    }

    /// A cache that has answered nothing yet, for a run that renders with
    /// `variable` set to `value`.
    pub fn with_variable(variable: &str, value: &str) -> Arc<Self> {
        Arc::new(Self {
            environment: BTreeMap::from([(variable.to_string(), value.to_string())]),
            ..Self::default()
        })
    }

    /// The variables this run renders under, over the process's own.
    pub fn environment(&self) -> &BTreeMap<String, String> {
        &self.environment
    }

    // ── Reads ────────────────────────────────────────────────────────────────

    /// The `alias → project root` map for `scan_root`.
    pub fn project_roots(
        &self,
        scan_root: &Path,
        compute: impl FnOnce() -> Result<HashMap<String, PathBuf>>,
    ) -> Result<Arc<HashMap<String, PathBuf>>> {
        memo(&self.project_roots, scan_root.to_path_buf(), || {
            compute().map(Arc::new)
        })
    }

    /// The `.wstemplate` files under `root`.
    pub fn templates_under(
        &self,
        root: &Path,
        compute: impl FnOnce() -> Result<Vec<PathBuf>>,
    ) -> Result<Arc<Vec<PathBuf>>> {
        memo(&self.templates, root.to_path_buf(), || {
            compute().map(Arc::new)
        })
    }

    /// One template's text.
    pub fn template_text(
        &self,
        path: &Path,
        compute: impl FnOnce() -> Result<String>,
    ) -> Result<Arc<String>> {
        memo(&self.template_text, path.to_path_buf(), || {
            compute().map(Arc::new)
        })
    }

    /// What one template's text refers to.
    pub fn template_references(
        &self,
        path: &Path,
        compute: impl FnOnce() -> Result<TemplateReferences>,
    ) -> Result<Arc<TemplateReferences>> {
        memo(&self.template_refs, path.to_path_buf(), || {
            compute().map(Arc::new)
        })
    }

    /// The absolute paths of `project_root`'s submodules.
    ///
    /// Infallible at the call site — a directory that is not a repository has
    /// none — so this takes a plain closure.
    pub fn submodule_roots(
        &self,
        project_root: &Path,
        compute: impl FnOnce() -> Vec<PathBuf>,
    ) -> Arc<Vec<PathBuf>> {
        memo(&self.submodule_roots, project_root.to_path_buf(), || {
            Ok(Arc::new(compute()))
        })
        .expect("an infallible closure cannot fail")
    }

    /// Say that this run renders for a commit being made in `repo_root`.
    pub fn commit_is_being_made_in(&self, repo_root: &Path) {
        *self.committing.lock().expect("the run cache is unusable after a panic") = Some(place(repo_root));
    }

    /// Whether this run renders for a commit being made in `repo_root`.
    pub fn is_being_committed(&self, repo_root: &Path) -> bool {
        self.committing
            .lock()
            .expect("the run cache is unusable after a panic")
            .as_deref()
            .is_some_and(|committing| committing == place(repo_root))
    }

    /// A repository's part of a version: its commits and its changes since its
    /// last release, for the commit that holds its tree
    /// (`state_common::version_counts`).
    pub fn version_counts(
        &self,
        repo_root: &Path,
        major: u32,
        compute: impl FnOnce() -> Result<(u32, u32)>,
    ) -> Result<(u32, u32)> {
        memo(&self.version_counts, (repo_root.to_path_buf(), major), compute)
    }

    /// `repo_root`'s tags, newest first.
    pub fn tags(
        &self,
        repo_root: &Path,
        compute: impl FnOnce() -> Result<Vec<String>>,
    ) -> Result<Arc<Vec<String>>> {
        memo(&self.tags, repo_root.to_path_buf(), || compute().map(Arc::new))
    }

    /// The tags of the repository CONTAINING `path`, newest first.
    pub fn discovered_tags(
        &self,
        path: &Path,
        compute: impl FnOnce() -> Result<Vec<String>>,
    ) -> Result<Arc<Vec<String>>> {
        memo(&self.discovered_tags, path.to_path_buf(), || {
            compute().map(Arc::new)
        })
    }

    /// The newest tag reachable from `repo_root`'s HEAD.
    pub fn nearest_tag(
        &self,
        repo_root: &Path,
        compute: impl FnOnce() -> Result<Option<String>>,
    ) -> Result<Arc<Option<String>>> {
        memo(&self.nearest_tags, repo_root.to_path_buf(), || compute().map(Arc::new))
    }

    /// `repo_root`'s HEAD.
    pub fn head(
        &self,
        repo_root: &Path,
        compute: impl FnOnce() -> Result<HeadSnapshot>,
    ) -> Result<Arc<HeadSnapshot>> {
        memo(&self.heads, repo_root.to_path_buf(), || compute().map(Arc::new))
    }

    /// `repo_root`'s configured remotes, or the reason it has none usable.
    pub fn remotes(
        &self,
        repo_root: &Path,
        compute: impl FnOnce() -> std::result::Result<Vec<String>, String>,
    ) -> Arc<std::result::Result<Vec<String>, String>> {
        memo(&self.remotes, repo_root.to_path_buf(), || {
            Ok(Arc::new(compute()))
        })
        .expect("an infallible closure cannot fail")
    }

    /// The fetch URL of one remote.
    pub fn remote_url(
        &self,
        repo_root: &Path,
        remote: &str,
        compute: impl FnOnce() -> Result<String>,
    ) -> Result<Arc<String>> {
        let key = (repo_root.to_path_buf(), remote.to_string());
        memo(&self.remote_urls, key, || compute().map(Arc::new))
    }

    /// The tags on one remote — the answer a network round trip pays for.
    pub fn remote_tags(
        &self,
        repo_root: &Path,
        remote: &str,
        compute: impl FnOnce() -> Result<Vec<String>>,
    ) -> Result<Arc<Vec<String>>> {
        let key = (repo_root.to_path_buf(), remote.to_string());
        memo(&self.remote_tags, key, || compute().map(Arc::new))
    }

    // ── What stops being true ────────────────────────────────────────────────

    /// Forget everything remembered about one repository.
    ///
    /// Called where a run moves a ref — `wsb update --tag` creates one, and
    /// every tag fact of that repository is a different answer from the moment
    /// it does. What is dropped here is asked again, and the answer kept is the
    /// one taken after the ref moved.
    pub fn forget_repo(&self, repo_root: &Path) {
        let root = repo_root.to_path_buf();
        drop_key(&self.tags, &root);
        drop_key(&self.discovered_tags, &root);
        drop_key(&self.nearest_tags, &root);
        drop_key(&self.heads, &root);
        drop_key(&self.remotes, &root);
        if let Ok(mut map) = self.version_counts.lock() {
            map.retain(|(path, _), _| path != &root);
        }
        if let Ok(mut map) = self.remote_urls.lock() {
            map.retain(|(path, _), _| path != &root);
        }
        if let Ok(mut map) = self.remote_tags.lock() {
            map.retain(|(path, _), _| path != &root);
        }
    }

    /// Take note that a render wrote `path`.
    ///
    /// One output can stop a claim from holding: a template that renders
    /// another template. Nothing in this worktree does it, and the answer to a
    /// worktree that does is to forget the template lists rather than to serve
    /// the ones read before the file existed.
    pub fn note_written(&self, path: &Path) {
        let is_template = path
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.ends_with(".wstemplate"));
        if !is_template {
            return;
        }
        clear(&self.templates);
        clear(&self.template_text);
        clear(&self.template_refs);
    }



}

/// Read a memo, or compute and keep the answer.
///
/// The lock is never held across `compute`: a closure here opens repositories
/// and talks to the network, and holding a cache lock through that is how a
/// cache becomes the thing that is slow.
///
/// An error is not kept. A run that fails to read a repository fails the same
/// way the next time it asks, which is what it did before there was a cache.
fn memo<K, V>(slot: &Mutex<Map<K, V>>, key: K, compute: impl FnOnce() -> Result<V>) -> Result<V>
where
    K: Eq + Hash + Clone,
    V: Clone,
{
    // A poisoned lock means a panic somewhere that held it. Nothing here is
    // authoritative, so the answer is to compute rather than to guess — and the
    // run is correct, just no faster than it was.
    if let Ok(map) = slot.lock() {
        if let Some(hit) = map.get(&key) {
            return Ok(hit.clone());
        }
    }
    let value = compute()?;
    if let Ok(mut map) = slot.lock() {
        map.insert(key, value.clone());
    }
    Ok(value)
}

fn drop_key<V>(slot: &Mutex<Map<PathBuf, V>>, key: &Path) {
    if let Ok(mut map) = slot.lock() {
        map.remove(key);
    }
}

fn clear<K, V>(slot: &Mutex<Map<K, V>>) {
    if let Ok(mut map) = slot.lock() {
        map.clear();
    }
}

/// A path as the place it names, so two spellings of one repository agree.
fn place(path: &Path) -> PathBuf {
    crate::platform::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}