wsb 0.251.6311

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What belongs to the continuity set, and what may never enter it.
//!
//! Git already answers "what is work in progress": the tracked files, plus the
//! untracked files it does not ignore. Build output, caches and dependency
//! trees are ignored, and they are exactly the things nobody wants copied
//! between machines — they are large, they are machine-specific, and they are
//! reproducible.
//!
//! ```text
//! tracked files
//! + untracked, non-ignored files
//! = the continuity set
//! ```
//!
//! # What is refused whatever anyone declares
//!
//! `.git` and `.wsb`, always, at any depth, in both directions. Not because
//! they are large but because of what they are:
//!
//! * `.git` is the repository's own state. Continuity synchronises working
//!   trees against a shared git base; carrying git's administrative data would
//!   BE reconciling git, which this feature never does. It also reaches
//!   outside the directory it appears in — a submodule's `.git` is a FILE
//!   pointing at `.git/modules/...` in the superproject — so it is refused as
//!   a name rather than as a directory.
//! * `.wsb` is machine-local state: a database, logs, a daemon's files. They
//!   describe this machine, and copied to another they would describe it
//!   wrongly there.
//!
//! Neither can be re-enabled by workspace declaration. Everything else about
//! the set is declarable (see [`Policy`]), and these two are not.

use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::converge::Entry;

/// Names that never synchronise, at any depth.
pub const REFUSED: [&str; 2] = [".git", ".wsb"];

/// What a workspace declares about its own continuity set.
///
/// Declared rather than written into the daemon, because "which files matter"
/// is a fact about a workspace and this codebase keeps those in the model. The
/// hard exclusions above are not part of it and cannot be overridden.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
    /// Ignored paths to carry anyway, as repository-relative path prefixes.
    ///
    /// For the rare generated file a peer genuinely needs — a local `.env`
    /// that is the same on both machines, a fixture nobody commits.
    #[serde(default)]
    pub include_ignored: Vec<String>,
    /// Non-ignored paths to leave alone, as repository-relative path prefixes.
    #[serde(default)]
    pub exclude: Vec<String>,
}

impl Policy {
    /// Whether the two sides declare the same policy.
    ///
    /// Compared rather than merged: a union would let one machine's rule move
    /// files the other never agreed to move, and an intersection would
    /// silently drop a rule somebody wrote. A difference is a state to report,
    /// not to resolve.
    pub fn agrees_with(&self, other: &Self) -> Result<(), String> {
        let mine = self.normalised();
        let theirs = other.normalised();
        if mine == theirs {
            return Ok(());
        }
        let (a, b) = (mine, theirs);
        let mut differences = Vec::new();
        for (name, here, there) in [
            ("include_ignored", &a.include_ignored, &b.include_ignored),
            ("exclude", &a.exclude, &b.exclude),
        ] {
            if here != there {
                differences.push(format!(
                    "{name}: here [{}], on the peer [{}]",
                    here.join(" "),
                    there.join(" ")
                ));
            }
        }
        Err(differences.join("; "))
    }

    /// Sorted, deduplicated, with separators normalised — so two spellings of
    /// one policy are one policy.
    fn normalised(&self) -> Self {
        let clean = |values: &Vec<String>| -> Vec<String> {
            let mut out: Vec<String> = values
                .iter()
                .map(|v| v.trim().trim_matches('/').replace('\\', "/"))
                .filter(|v| !v.is_empty())
                .collect();
            out.sort();
            out.dedup();
            out
        };
        Self {
            include_ignored: clean(&self.include_ignored),
            exclude: clean(&self.exclude),
        }
    }

    fn covers(prefixes: &[String], relative: &str) -> bool {
        prefixes.iter().any(|prefix| {
            let prefix = prefix.trim_matches('/');
            relative == prefix
                || relative.starts_with(&format!("{prefix}/"))
        })
    }
}

/// Whether a path may ever be carried, whatever git and the policy say.
///
/// A name check at every component, so `a/.git/config`, `.wsb/secrets.wsbs` and
/// a submodule's `.git` FILE are all refused by the same rule.
pub fn is_refused(relative: &Path) -> bool {
    relative.components().any(|component| match component {
        Component::Normal(name) => REFUSED.iter().any(|refused| name == *refused),
        // Anything that is not a plain name inside the tree — an absolute
        // root, a `..`, a Windows prefix — is refused outright rather than
        // interpreted.
        Component::CurDir => false,
        _ => true,
    })
}

/// The continuity set of one repository, as entries keyed by repository-
/// relative path.
///
/// Paths use `/` on every platform: they cross machines, and a set keyed by
/// `src\lib.rs` on one side and `src/lib.rs` on the other is two sets.
pub type Set = BTreeMap<String, Entry>;

/// Read one repository's continuity set from disk.
///
/// Git decides membership; this reads what membership means. The scan is the
/// authority on content — watcher events are hints that something may have
/// changed, never the truth about what it now is.
pub fn read(workdir: &Path, policy: &Policy) -> Result<Set, String> {
    let mut set = Set::new();
    for relative in members(workdir, policy)? {
        let absolute = workdir.join(&relative);
        let Some(entry) = Entry::read(&absolute)? else {
            // Named by git and gone by the time it was read. An ordinary race
            // with the person typing; the next scan sees the deletion.
            continue;
        };
        set.insert(relative, entry);
    }
    Ok(set)
}

/// The repository-relative paths git says are work in progress, plus whatever
/// the policy adds, minus whatever it removes.
fn members(workdir: &Path, policy: &Policy) -> Result<Vec<String>, String> {
    let policy = policy.normalised();
    let mut out = Vec::new();

    // Tracked files, from the index: the set of things git is looking after,
    // whether or not they have been touched.
    for relative in crate::git_backend::tracked_paths(workdir)? {
        out.push(relative);
    }

    // Untracked files that are not ignored, plus — where the policy says so —
    // ignored ones. One walk, respecting the same ignore rules git does.
    let mut walker = ignore::WalkBuilder::new(workdir);
    walker
        .hidden(false)
        .follow_links(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        // Ignored entries are yielded so the policy can opt one in; without
        // this the walk would not even see them.
        .filter_entry(|entry| {
            let refused_name = entry
                .path()
                .file_name()
                .map(|name| REFUSED.iter().any(|refused| name == *refused))
                .unwrap_or(false);
            if refused_name {
                return false;
            }
            // A nested repository is another repository's business, and this
            // walk stops at its edge.
            //
            // A submodule is a plain directory on disk with a `.git` FILE in
            // it, so a walk that only refuses the name `.git` descends
            // straight into it and lists its files as the parent's. Continuity
            // then carries every one of them TWICE — once as
            // `floom-uikit/src/x.ts` belonging to the superproject and once as
            // `src/x.ts` belonging to floom-uikit — under two baselines that
            // advance independently. When they disagree, one of them plans a
            // deletion for a file the other has just created, each machine
            // applies the other's deletion, and a newly written file is gone
            // within seconds with nothing but a log line to say why.
            //
            // Git draws this boundary itself; ownership follows git, not the
            // walk's convenience.
            if entry.depth() > 0 && entry.file_type().is_some_and(|kind| kind.is_dir()) {
                return !entry.path().join(".git").exists();
            }
            true
        });
    for entry in walker.build() {
        let entry = entry.map_err(|e| format!("cannot walk {}: {e}", workdir.display()))?;
        if entry.depth() == 0 || entry.file_type().is_some_and(|t| t.is_dir()) {
            continue;
        }
        let Ok(relative) = entry.path().strip_prefix(workdir) else {
            continue;
        };
        out.push(to_slash(relative));
    }

    // The ignored ones the policy asks for, which the walk above skipped.
    for prefix in &policy.include_ignored {
        collect_ignored(workdir, prefix, &mut out)?;
    }

    out.sort();
    out.dedup();
    out.retain(|relative| {
        !is_refused(Path::new(relative)) && !Policy::covers(&policy.exclude, relative)
    });
    Ok(out)
}

/// Walk one explicitly included prefix, ignore rules and all — but never past
/// a refused name.
fn collect_ignored(workdir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<(), String> {
    let root = workdir.join(prefix);
    if !root.exists() {
        // Declared and not present is ordinary: the file is one this machine
        // has not made yet.
        return Ok(());
    }
    if root.is_file() {
        out.push(prefix.to_string());
        return Ok(());
    }
    for entry in walkdir::WalkDir::new(&root)
        .follow_links(false)
        .into_iter()
        .filter_entry(|entry| {
            entry
                .file_name()
                .to_str()
                .map(|name| !REFUSED.contains(&name))
                .unwrap_or(false)
        })
    {
        let entry = entry.map_err(|e| format!("cannot walk {}: {e}", root.display()))?;
        if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
            continue;
        }
        let Ok(relative) = entry.path().strip_prefix(workdir) else {
            continue;
        };
        out.push(to_slash(relative));
    }
    Ok(())
}

/// A repository-relative path as it travels: `/` separators, always.
pub fn to_slash(path: &Path) -> String {
    path.components()
        .filter_map(|component| match component {
            Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("/")
}

/// The local path a wire path names, refusing anything that would leave the
/// repository.
///
/// The one place a path from a peer becomes a path on this filesystem. A peer
/// is authenticated, not trusted: a compromised or buggy one must not be able
/// to write outside the tree it is synchronising, so this refuses absolute
/// paths, `..`, Windows prefixes and the refused names rather than sanitising
/// them into something that looks safe.
pub fn local_path(workdir: &Path, wire: &str) -> Result<PathBuf, String> {
    if wire.is_empty() {
        return Err("an empty path is not a file".to_string());
    }
    if wire.contains('\\') {
        return Err(format!("'{wire}' is not a portable path"));
    }
    let relative = Path::new(wire);
    if is_refused(relative) || relative.is_absolute() {
        return Err(format!("'{wire}' may not be synchronised"));
    }
    let mut out = workdir.to_path_buf();
    for component in relative.components() {
        match component {
            Component::Normal(name) => out.push(name),
            _ => return Err(format!("'{wire}' is not a path inside the repository")),
        }
    }
    Ok(out)
}

/// What a repository declares its continuity set to be, for the peer to
/// compare against. Ordering is normalised so two spellings compare equal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyDigest {
    pub include_ignored: Vec<String>,
    pub exclude: Vec<String>,
}

impl From<&Policy> for PolicyDigest {
    fn from(policy: &Policy) -> Self {
        let normalised = policy.normalised();
        Self {
            include_ignored: normalised.include_ignored,
            exclude: normalised.exclude,
        }
    }
}

#[cfg(test)]
#[path = "scan_tests.rs"]
mod tests;