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);
}
}