Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-client/src/mirror.rs

//! `gunnar mirror`: pull-mirror a remote into a local repository
//! — the pull-mirror verb.
//!
//! ```text
//! gunnar mirror add  <remote-url> <ns>/<repo>
//! gunnar mirror sync <ns>/<repo>
//! gunnar mirror status
//! ```
//!
//! **The verb that pays for the most in this project.** A one-command escape
//! hatch off any forge, built on a client that already works. `ls-remote` says
//! what moved, a fetch brings the deltas, and the refs are applied.
//!
//! # What a mirror is that a fetch is not
//!
//! A fetch adds. A mirror **converges**; a branch deleted upstream is deleted
//! locally, or the copy is not a mirror. `gix`'s fetch will never delete a ref,
//! so [`plan_ref_sync`] computes the difference and the deletions are applied as
//! a ref transaction afterwards.
//!
//! [`plan_ref_sync`] is a pure function over two ref lists, which is deliberate:
//! it is the one piece of mirroring whose correctness can be asserted without a
//! network, and the deletion arm is exactly the arm a test that only ever adds
//! refs would never reach.
//!
//! # The registry
//!
//! A single JSON file at `<root>/mirrors.json`, next to the repositories it
//! describes. No database (the no-database rule, one tier down): the state
//! is a handful of rows and the repositories on disk are the real data.

use std::collections::BTreeMap;
#[cfg(feature = "porcelain")]
use std::path::{Path, PathBuf};

use gix_hash::ObjectId;
#[cfg(feature = "porcelain")]
use serde::{Deserialize, Serialize};

#[cfg(feature = "porcelain")]
use crate::error::{Error, Result};
// The registry half only. The ref PLAN below (`plan_ref_sync`,
// `is_mirrored_ref`) is two `BTreeMap`s and a string prefix, and is what
// `gunnar-server`'s replication uses without a repository on disk.
#[cfg(feature = "porcelain")]
use crate::fetch::{open_or_init_bare, AdvertisedRef};
#[cfg(feature = "porcelain")]
use crate::ssh::SshOptions;

/// The refspecs a mirror fetches: branches and tags, one-to-one, forced.
///
/// Forced is right here and nowhere else. A mirror's job is to reproduce the
/// upstream, including a force-push someone made there.
#[cfg(feature = "porcelain")]
pub const MIRROR_REFSPECS: &[&str] = &["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"];

/// One registered mirror.
#[cfg(feature = "porcelain")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Mirror {
    /// `<namespace>/<repo>`, the same shape as a path on a gunnar server, so a
    /// mirror can be served without renaming anything (multi-tenancy from day 1).
    pub name: String,
    /// The upstream URL.
    pub url: String,
    /// Unix seconds of the last successful sync.
    #[serde(default)]
    pub last_sync_unix: Option<i64>,
    /// Refs present after the last sync.
    #[serde(default)]
    pub last_ref_count: usize,
}

/// What one ref needs to become.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefChange {
    /// Not present locally.
    Created {
        /// Full ref name.
        name: String,
        /// The upstream value.
        new: ObjectId,
    },
    /// Present locally at a different value.
    Updated {
        /// Full ref name.
        name: String,
        /// What the local copy had.
        old: ObjectId,
        /// The upstream value.
        new: ObjectId,
    },
    /// Present locally, gone upstream. **The arm a mirror exists for.**
    Deleted {
        /// Full ref name.
        name: String,
        /// What the local copy had.
        old: ObjectId,
    },
}

impl RefChange {
    /// The ref this change is about.
    pub fn name(&self) -> &str {
        match self {
            RefChange::Created { name, .. }
            | RefChange::Updated { name, .. }
            | RefChange::Deleted { name, .. } => name,
        }
    }
}

/// The difference between a local ref set and an upstream one.
///
/// Both sides are `(full ref name, oid)`. Only `refs/heads/*` and `refs/tags/*`
/// participate. `HEAD` is a symref and is handled separately, and a remote's
/// `refs/pull/*` or `refs/merge-requests/*` are that forge's private bookkeeping
/// rather than repository content.
pub fn plan_ref_sync(
    local: &[(String, ObjectId)],
    remote: &[(String, ObjectId)],
) -> Vec<RefChange> {
    let local_map: BTreeMap<&str, ObjectId> = local
        .iter()
        .filter(|(n, _)| is_mirrored_ref(n))
        .map(|(n, o)| (n.as_str(), *o))
        .collect();
    let remote_map: BTreeMap<&str, ObjectId> = remote
        .iter()
        .filter(|(n, _)| is_mirrored_ref(n))
        .map(|(n, o)| (n.as_str(), *o))
        .collect();

    let mut out = Vec::new();
    for (name, new) in &remote_map {
        match local_map.get(name) {
            None => out.push(RefChange::Created {
                name: (*name).to_string(),
                new: *new,
            }),
            Some(old) if old != new => out.push(RefChange::Updated {
                name: (*name).to_string(),
                old: *old,
                new: *new,
            }),
            Some(_) => {}
        }
    }
    for (name, old) in &local_map {
        if !remote_map.contains_key(name) {
            out.push(RefChange::Deleted {
                name: (*name).to_string(),
                old: *old,
            });
        }
    }
    out
}

/// Is this a ref a mirror carries?
pub fn is_mirrored_ref(name: &str) -> bool {
    name.starts_with("refs/heads/") || name.starts_with("refs/tags/")
}

/// What one [`MirrorRegistry::sync_with_io`] did.
#[cfg(feature = "porcelain")]
#[derive(Debug, Clone, Default)]
pub struct SyncReport {
    /// The mirror's name.
    pub name: String,
    /// Refs created locally.
    pub created: Vec<String>,
    /// Refs moved.
    pub updated: Vec<String>,
    /// Refs deleted locally because they are gone upstream.
    pub deleted: Vec<String>,
    /// Objects the fetch brought over.
    pub objects_received: u64,
    /// True when `ls-remote` showed nothing had moved, so no fetch was run.
    pub unchanged: bool,
}

#[cfg(feature = "porcelain")]
impl SyncReport {
    /// Total refs touched.
    pub fn changed_refs(&self) -> usize {
        self.created.len() + self.updated.len() + self.deleted.len()
    }
}

/// A mirror's state on disk right now.
#[cfg(feature = "porcelain")]
#[derive(Debug, Clone)]
pub struct MirrorStatus {
    /// The registered mirror.
    pub mirror: Mirror,
    /// Where its repository lives.
    pub path: PathBuf,
    /// Whether that repository exists yet.
    pub present: bool,
    /// Refs currently in it.
    pub refs: usize,
}

/// The set of mirrors under one root directory.
#[cfg(feature = "porcelain")]
#[derive(Debug, Clone)]
pub struct MirrorRegistry {
    root: PathBuf,
    mirrors: Vec<Mirror>,
}

#[cfg(feature = "porcelain")]
#[derive(Serialize, Deserialize, Default)]
struct RegistryFile {
    mirrors: Vec<Mirror>,
}

#[cfg(feature = "porcelain")]
impl MirrorRegistry {
    /// The registry file inside `root`.
    pub fn registry_path(root: &Path) -> PathBuf {
        root.join("mirrors.json")
    }

    /// Open the registry under `root`, creating an empty one if absent.
    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
        let root = root.into();
        let path = Self::registry_path(&root);
        let mirrors = if path.exists() {
            let bytes = std::fs::read(&path)
                .map_err(|e| Error::Mirror(format!("read {}: {e}", path.display())))?;
            let file: RegistryFile = serde_json::from_slice(&bytes)
                .map_err(|e| Error::Mirror(format!("parse {}: {e}", path.display())))?;
            file.mirrors
        } else {
            Vec::new()
        };
        Ok(MirrorRegistry { root, mirrors })
    }

    /// The directory the mirrors live under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Every registered mirror.
    pub fn list(&self) -> &[Mirror] {
        &self.mirrors
    }

    /// One mirror by name.
    pub fn get(&self, name: &str) -> Option<&Mirror> {
        self.mirrors.iter().find(|m| m.name == name)
    }

    /// Where `<ns>/<repo>` is kept: `<root>/<ns>/<repo>.git`.
    ///
    /// The name is validated first. This is the same sandbox question the server
    /// answers for `git-upload-pack '<path>'` (the ACL model): a name that can
    /// climb out of the root turns "mirror a repo" into "write anywhere".
    pub fn path_for(&self, name: &str) -> Result<PathBuf> {
        let (ns, repo) = split_name(name)?;
        Ok(self.root.join(ns).join(format!("{repo}.git")))
    }

    /// Register a mirror. Re-registering the same name with the same URL is a
    /// no-op; with a different URL it is an error rather than a silent repoint,
    /// because the repository on disk holds the *old* remote's history.
    pub fn add(&mut self, name: &str, url: &str) -> Result<&Mirror> {
        split_name(name)?;
        if url.trim().is_empty() {
            return Err(Error::Mirror("a mirror needs a remote URL".to_string()));
        }
        if let Some(existing) = self.mirrors.iter().position(|m| m.name == name) {
            if self.mirrors[existing].url != url {
                return Err(Error::Mirror(format!(
                    "{name} is already mirrored from {}; remove it before pointing it at {url}",
                    self.mirrors[existing].url
                )));
            }
            return Ok(&self.mirrors[existing]);
        }
        self.mirrors.push(Mirror {
            name: name.to_string(),
            url: url.to_string(),
            last_sync_unix: None,
            last_ref_count: 0,
        });
        self.save()?;
        Ok(self.mirrors.last().expect("just pushed"))
    }

    /// Forget a mirror. The repository on disk is left alone: deleting a copy
    /// of someone's history is not something a registry edit should do quietly.
    pub fn remove(&mut self, name: &str) -> Result<()> {
        let before = self.mirrors.len();
        self.mirrors.retain(|m| m.name != name);
        if self.mirrors.len() == before {
            return Err(Error::Mirror(format!("{name} is not registered")));
        }
        self.save()
    }

    /// Write the registry out.
    pub fn save(&self) -> Result<()> {
        std::fs::create_dir_all(&self.root)
            .map_err(|e| Error::Mirror(format!("create {}: {e}", self.root.display())))?;
        let path = Self::registry_path(&self.root);
        let file = RegistryFile {
            mirrors: self.mirrors.clone(),
        };
        let bytes = serde_json::to_vec_pretty(&file)
            .map_err(|e| Error::Mirror(format!("encode registry: {e}")))?;
        // Write-then-rename: a registry truncated by a crash mid-write would
        // lose every mirror, and the failure would look like "nothing was ever
        // registered".
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, &bytes)
            .map_err(|e| Error::Mirror(format!("write {}: {e}", tmp.display())))?;
        std::fs::rename(&tmp, &path)
            .map_err(|e| Error::Mirror(format!("rename into {}: {e}", path.display())))?;
        Ok(())
    }

    /// What every mirror looks like on disk.
    pub fn status(&self) -> Vec<MirrorStatus> {
        self.mirrors
            .iter()
            .map(|m| {
                let path = self
                    .path_for(&m.name)
                    .unwrap_or_else(|_| self.root.join(&m.name));
                let (present, refs) = match gix::open(&path) {
                    Ok(repo) => (true, local_refs(&repo).map(|r| r.len()).unwrap_or(0)),
                    Err(_) => (false, 0),
                };
                MirrorStatus {
                    mirror: m.clone(),
                    path,
                    present,
                    refs,
                }
            })
            .collect()
    }

    /// Sync a mirror over SSH with an explicit private key.
    ///
    /// A thin shape over [`sync_with`](Self::sync_with); every line of the verb
    /// lives there.
    pub fn sync(&mut self, name: &str, ssh_key: &Path) -> Result<SyncReport> {
        self.sync_with(name, &SshOptions::new().with_key(ssh_key))
    }

    /// Sync a mirror over SSH under the **same** [`SshOptions`] a push takes:
    /// `ssh-agent` first, then the key ladder, under a host-key policy.
    ///
    /// `ls-remote` is the cheap poll and goes first, so an unchanged upstream
    /// costs one round trip and no pack.
    ///
    /// # An upstream with no refs does not get to choose the hash
    ///
    /// [`infer_hash_kind`] must guess SHA-1 for a remote that advertised
    /// nothing, and that guess used to be **written to disk**: this method
    /// called `open_or_init_bare` before the "nothing to do" return, so
    /// registering a brand-new, still-empty SHA-256 upstream created a SHA-1
    /// bare repository next to it. Every later sync — the ones with content —
    /// then failed for good with *"… is a Sha1 repository but the remote is
    /// Sha256"*, and nothing said the fix was to delete the directory. A
    /// gunnar server creates SHA-256 repositories by default and stock `git
    /// init` creates SHA-1 ones, so "mirror the repo I just made" is the exact
    /// first move that hit it. The local copy is now created only once a ref
    /// has been seen.
    pub fn sync_with(&mut self, name: &str, opts: &SshOptions) -> Result<SyncReport> {
        let mirror = self
            .get(name)
            .ok_or_else(|| Error::Mirror(format!("{name} is not registered")))?
            .clone();
        let remote_refs = crate::fetch::ls_remote_with(&mirror.url, opts)?;
        let path = self.path_for(name)?;
        if let Some(report) = nothing_to_mirror_yet(name, &remote_refs, &path) {
            return Ok(report);
        }
        let hash_kind = infer_hash_kind(&remote_refs);
        let repo = open_or_init_bare(&path, hash_kind)?;

        let plan = {
            let local = local_refs(&repo)?;
            let remote: Vec<(String, ObjectId)> = remote_refs
                .iter()
                .map(|r| (r.name.clone(), r.oid))
                .collect();
            plan_ref_sync(&local, &remote)
        };
        if plan.is_empty() {
            return Ok(SyncReport {
                name: name.to_string(),
                unchanged: true,
                ..Default::default()
            });
        }

        let loc = nornir_git::ssh::parse_ssh_url(&mirror.url).map_err(Error::Ssh)?;
        let mut up = crate::ssh::connect_upload_pack(&loc, opts)?;
        let remote_path = loc.path.clone();
        let (reader, writer) = up.io();
        let outcome = crate::fetch::fetch_with_io(
            &repo,
            reader,
            writer,
            &mirror.url,
            &remote_path,
            MIRROR_REFSPECS,
        )?;
        drop(up);

        self.finish_sync(name, &repo, plan, outcome.objects_received)
    }

    /// Sync a mirror over a transport the caller already has running
    /// `git-upload-pack`.
    ///
    /// The seam that makes mirroring testable against the real `git` binary with
    /// no network: point it at a pair of pipes to `git upload-pack <dir>` and the
    /// whole verb (ls-remote diff, fetch, deletions, registry update) runs
    /// exactly as it does over SSH.
    #[allow(clippy::too_many_arguments)]
    pub fn sync_with_io<R: std::io::Read, W: std::io::Write>(
        &mut self,
        name: &str,
        reader: R,
        writer: W,
        remote_refs: &[AdvertisedRef],
        remote_path: &str,
    ) -> Result<SyncReport> {
        let mirror = self
            .get(name)
            .ok_or_else(|| Error::Mirror(format!("{name} is not registered")))?
            .clone();
        let path = self.path_for(name)?;
        // The same guard as `sync_with`, through the same function rather than
        // a second copy of it: this seam is the one the tests drive, so a guard
        // it did not share would be a guard the tests cannot see.
        if let Some(report) = nothing_to_mirror_yet(name, remote_refs, &path) {
            return Ok(report);
        }
        let hash_kind = infer_hash_kind(remote_refs);
        let repo = open_or_init_bare(&path, hash_kind)?;

        let plan = {
            let local = local_refs(&repo)?;
            let remote: Vec<(String, ObjectId)> = remote_refs
                .iter()
                .map(|r| (r.name.clone(), r.oid))
                .collect();
            plan_ref_sync(&local, &remote)
        };

        let outcome = crate::fetch::fetch_with_io(
            &repo,
            reader,
            writer,
            &mirror.url,
            remote_path,
            MIRROR_REFSPECS,
        )?;
        self.finish_sync(name, &repo, plan, outcome.objects_received)
    }

    /// Apply the deletions the fetch could not, and record the sync.
    fn finish_sync(
        &mut self,
        name: &str,
        repo: &gix::Repository,
        plan: Vec<RefChange>,
        objects_received: u64,
    ) -> Result<SyncReport> {
        let mut report = SyncReport {
            name: name.to_string(),
            objects_received,
            ..Default::default()
        };
        let mut deletions = Vec::new();
        for change in plan {
            match change {
                RefChange::Created { name, .. } => report.created.push(name),
                RefChange::Updated { name, .. } => report.updated.push(name),
                RefChange::Deleted { name, old } => {
                    deletions.push((name.clone(), old));
                    report.deleted.push(name);
                }
            }
        }
        if !deletions.is_empty() {
            delete_refs(repo, &deletions)?;
        }

        let refs_now = local_refs(repo)?.len();
        if let Some(m) = self.mirrors.iter_mut().find(|m| m.name == name) {
            m.last_sync_unix = Some(now_unix());
            m.last_ref_count = refs_now;
        }
        self.save()?;
        Ok(report)
    }
}

/// Every `refs/heads/*` and `refs/tags/*` in `repo`, peeled to the object the
/// ref file names (annotated tags are **not** peeled to their commit; a mirror
/// reproduces the tag object, not what it points at).
#[cfg(feature = "porcelain")]
pub fn local_refs(repo: &gix::Repository) -> Result<Vec<(String, ObjectId)>> {
    let platform = repo
        .references()
        .map_err(|e| Error::repo("open the ref store", e))?;
    let mut out = Vec::new();
    for r in platform.all().map_err(|e| Error::repo("iterate refs", e))? {
        let r = r.map_err(|e| Error::repo("read a ref", e))?;
        let name = r.name().as_bstr().to_string();
        if !is_mirrored_ref(&name) {
            continue;
        }
        if let Some(id) = r.target().try_id() {
            out.push((name, id.to_owned()));
        }
    }
    out.sort();
    Ok(out)
}

#[cfg(feature = "porcelain")]
fn delete_refs(repo: &gix::Repository, refs: &[(String, ObjectId)]) -> Result<()> {
    use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};

    let edits: Vec<RefEdit> = refs
        .iter()
        .map(|(name, old)| {
            Ok(RefEdit {
                change: Change::Delete {
                    // Compare-and-swap on the way out too: a ref that moved
                    // between the plan and the apply must not be deleted on the
                    // strength of a stale reading.
                    expected: PreviousValue::MustExistAndMatch(gix::refs::Target::Object(*old)),
                    log: RefLog::AndReference,
                },
                name: name
                    .as_str()
                    .try_into()
                    .map_err(|e| Error::repo_msg(format!("invalid ref name {name}: {e}")))?,
                deref: false,
            })
        })
        .collect::<Result<_>>()?;
    repo.edit_references(edits)
        .map_err(|e| Error::repo("delete refs that are gone upstream", e))?;
    Ok(())
}

/// The upstream's object format, from the width of the oids it advertised.
///
/// A remote with no refs at all is taken as SHA-1 only because something must be
/// chosen. That guess **must not reach the disk**: a local copy created under it
/// can never be corrected, because [`open_or_init_bare`] refuses to reopen a
/// SHA-1 repository for a SHA-256 remote. [`nothing_to_mirror_yet`] is what
/// keeps it off the disk, and the two are only correct together.
#[cfg(feature = "porcelain")]
pub fn infer_hash_kind(refs: &[AdvertisedRef]) -> gix_hash::Kind {
    refs.first()
        .map(|r| r.oid.kind())
        .unwrap_or(gix::hash::Kind::Sha1)
}

/// `Some(report)` when a sync should stop before creating anything: the
/// upstream advertised no refs **and** there is no local copy yet.
///
/// Only that pair is refused. An upstream that has emptied itself is a real
/// event and still runs the deletion half against the local copy that exists,
/// so this cannot silence a repository going to zero refs.
#[cfg(feature = "porcelain")]
fn nothing_to_mirror_yet(
    name: &str,
    remote_refs: &[AdvertisedRef],
    path: &Path,
) -> Option<SyncReport> {
    let local_exists = path.join("HEAD").exists() || path.join(".git").exists();
    (remote_refs.is_empty() && !local_exists).then(|| SyncReport {
        name: name.to_string(),
        unchanged: true,
        ..Default::default()
    })
}

/// Validate `<namespace>/<repo>` and split it.
///
/// The rejections are the ones that turn a name into a path escape, plus the
/// Windows-and-macOS-only ones that would make a mirror name mean something
/// different on a different machine.
#[cfg(feature = "porcelain")]
fn split_name(name: &str) -> Result<(&str, &str)> {
    let Some((ns, repo)) = name.split_once('/') else {
        return Err(Error::Mirror(format!(
            "{name:?} is not a `<namespace>/<repo>` name; multi-tenancy is not optional \
             (namespaces are first class)"
        )));
    };
    if ns.is_empty() || repo.is_empty() {
        return Err(Error::Mirror(format!("{name:?} has an empty half")));
    }
    if repo.contains('/') {
        return Err(Error::Mirror(format!(
            "{name:?} has more than two components"
        )));
    }
    for part in [ns, repo] {
        if part == "." || part == ".." {
            return Err(Error::Mirror(format!("{name:?} contains a path traversal")));
        }
        if part.starts_with('.') {
            return Err(Error::Mirror(format!(
                "{name:?} has a component starting with a dot"
            )));
        }
        if part.contains('\\') || part.contains('\0') || part.contains(':') {
            return Err(Error::Mirror(format!(
                "{name:?} contains a character that is a path separator or stream \
                 marker on some filesystem"
            )));
        }
    }
    Ok((ns, repo))
}

#[cfg(feature = "porcelain")]
fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

#[cfg(feature = "porcelain")]
#[cfg(test)]
mod tests {
    use super::*;

    fn oid(hex_char: char) -> ObjectId {
        ObjectId::from_hex(hex_char.to_string().repeat(40).as_bytes()).unwrap()
    }

    #[test]
    fn plan_reports_creates_updates_and_deletes() {
        let local = vec![
            ("refs/heads/main".to_string(), oid('1')),
            ("refs/heads/gone".to_string(), oid('2')),
            ("refs/heads/same".to_string(), oid('3')),
        ];
        let remote = vec![
            ("refs/heads/main".to_string(), oid('4')),
            ("refs/heads/same".to_string(), oid('3')),
            ("refs/tags/v1".to_string(), oid('5')),
        ];
        let plan = plan_ref_sync(&local, &remote);
        assert_eq!(plan.len(), 3, "same→same produces nothing: {plan:?}");
        assert!(plan.contains(&RefChange::Updated {
            name: "refs/heads/main".into(),
            old: oid('1'),
            new: oid('4')
        }));
        assert!(plan.contains(&RefChange::Created {
            name: "refs/tags/v1".into(),
            new: oid('5')
        }));
        assert!(plan.contains(&RefChange::Deleted {
            name: "refs/heads/gone".into(),
            old: oid('2')
        }));
    }

    /// The arm that separates a mirror from a fetch. A plan that only ever adds
    /// would pass every "did we get the new commits" test ever written.
    #[test]
    fn a_ref_gone_upstream_is_planned_for_deletion() {
        let local = vec![("refs/heads/old".to_string(), oid('a'))];
        let plan = plan_ref_sync(&local, &[]);
        assert_eq!(
            plan,
            vec![RefChange::Deleted {
                name: "refs/heads/old".into(),
                old: oid('a')
            }]
        );
    }

    #[test]
    fn an_identical_ref_set_plans_nothing() {
        let refs = vec![("refs/heads/main".to_string(), oid('1'))];
        assert!(plan_ref_sync(&refs, &refs).is_empty());
    }

    /// `HEAD` and a forge's private refs are not repository content and must not
    /// drive a deletion. A mirror that "deleted" the upstream's `refs/pull/*`
    /// would churn on every sync.
    #[test]
    fn only_heads_and_tags_are_mirrored() {
        let local = vec![
            ("HEAD".to_string(), oid('1')),
            ("refs/pull/7/head".to_string(), oid('2')),
            ("refs/remotes/origin/main".to_string(), oid('3')),
        ];
        assert!(plan_ref_sync(&local, &[]).is_empty());
        assert!(is_mirrored_ref("refs/heads/main"));
        assert!(is_mirrored_ref("refs/tags/v1"));
        assert!(!is_mirrored_ref("HEAD"));
        assert!(!is_mirrored_ref("refs/notes/commits"));
    }

    #[test]
    fn names_that_escape_the_root_are_refused() {
        let dir = tempfile::tempdir().unwrap();
        let reg = MirrorRegistry::open(dir.path()).unwrap();
        for bad in [
            "../etc",
            "ns/../../etc",
            "..",
            "ns",
            "/abs/path",
            "ns/repo/extra",
            "ns/",
            "/repo",
            ".hidden/repo",
            "ns/.hidden",
            "ns/re\\po",
            "c:/windows",
        ] {
            assert!(
                reg.path_for(bad).is_err(),
                "{bad:?} must not resolve to a path"
            );
        }
        let good = reg.path_for("nordisk/gunnar").unwrap();
        assert_eq!(good, dir.path().join("nordisk").join("gunnar.git"));
        assert!(
            good.starts_with(dir.path()),
            "a legitimate nested name must still resolve; a sandbox that \
             rejects everything is the identity-value trap"
        );
    }

    #[test]
    fn registry_round_trips_through_disk() {
        let dir = tempfile::tempdir().unwrap();
        let mut reg = MirrorRegistry::open(dir.path()).unwrap();
        assert!(reg.list().is_empty());
        reg.add(
            "nordisk/gunnar",
            "ssh://git@git.gunnar.rs:2222/nordisk/gunnar.git",
        )
        .unwrap();
        reg.add("vetra/korp", "git@codeberg.org:vetra/korp.git")
            .unwrap();

        let reopened = MirrorRegistry::open(dir.path()).unwrap();
        assert_eq!(reopened.list().len(), 2);
        assert_eq!(
            reopened.get("vetra/korp").unwrap().url,
            "git@codeberg.org:vetra/korp.git"
        );
        assert!(reopened.get("nope/nope").is_none());
    }

    #[test]
    fn repointing_a_mirror_at_a_different_url_is_refused() {
        let dir = tempfile::tempdir().unwrap();
        let mut reg = MirrorRegistry::open(dir.path()).unwrap();
        reg.add("ns/repo", "git@a:ns/repo.git").unwrap();
        // Idempotent for the same URL…
        assert!(reg.add("ns/repo", "git@a:ns/repo.git").is_ok());
        // …but never a silent repoint.
        assert!(reg.add("ns/repo", "git@b:ns/repo.git").is_err());
        assert_eq!(reg.list().len(), 1);
    }

    #[test]
    fn removing_an_unregistered_mirror_is_an_error() {
        let dir = tempfile::tempdir().unwrap();
        let mut reg = MirrorRegistry::open(dir.path()).unwrap();
        assert!(reg.remove("ns/repo").is_err());
        reg.add("ns/repo", "git@a:ns/repo.git").unwrap();
        assert!(reg.remove("ns/repo").is_ok());
        assert!(reg.list().is_empty());
    }

    #[test]
    fn hash_kind_comes_from_the_advertised_oid_width() {
        let sha1 = AdvertisedRef::new("refs/heads/main", oid('1'));
        let sha256 = AdvertisedRef::new(
            "refs/heads/main",
            ObjectId::from_hex("a".repeat(64).as_bytes()).unwrap(),
        );
        assert_eq!(infer_hash_kind(&[sha1]), gix::hash::Kind::Sha1);
        assert_eq!(infer_hash_kind(&[sha256]), gix::hash::Kind::Sha256);
    }
}