crates/gunnar-client/src/fetch.rs
//! Clone and fetch: **almost entirely `nornir-git`'s** — reused, not written.
//!
//! The client half of git-over-SSH already exists and is proven against
//! codeberg/Forgejo and GitHub: protocol-v2 `ls-refs` with a v0 fallback,
//! SHA-256 oids, the russh→blocking-I/O bridge, and the in-runtime channel
//! teardown. gunnar **depends on it and does not copy it** (one
//! implementation). What this module adds is one thing `nornir-git` does not
//! expose:
//!
//! [`fetch_with_io`], a fetch driven over **any** blocking reader/writer pair
//! rather than only over a russh channel. That is what lets `gunnar mirror` run
//! over a transport the caller chose. The reason it exists, though, is that it
//! lets the test suite fetch from the **real `git upload-pack` binary** over a
//! pair of pipes, hermetically and with no network (`tests/real_git_fetch.rs`).
//!
//! Everything else here is a named delegation, kept so callers have one import
//! surface rather than two.
use std::io::{Read, Write};
use std::path::Path;
use gix::hash::ObjectId;
use crate::error::{Error, Result};
/// A ref as a remote advertised it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdvertisedRef {
/// Full ref name, or `HEAD`.
pub name: String,
/// The object the ref itself points at — for `refs/tags/v1` on an annotated
/// tag, the **tag object**, which is what `git ls-remote` prints on that
/// line and what a mirror must store.
///
/// Until 2026-08-05 this carried the *peeled* commit whenever the remote
/// sent one, because [`fetch_with_io`] read `peeled.or(target)`. Against a
/// stock-git server holding one annotated tag, `gunnar fetch` printed the
/// commit `7f84327c…` beside `refs/tags/v1` where `git ls-remote` prints
/// the tag object `3d25e004…` — a different object, under the same name,
/// with nothing to say which one it was. MEASURED over a real
/// `git upload-pack` on 2026-08-05.
pub oid: ObjectId,
/// The commit an **annotated tag** resolves to, when the remote peeled it.
///
/// `None` for a branch, for `HEAD`, and for a lightweight tag — a
/// lightweight tag *is* its commit and has nothing to peel.
///
/// This is the other half of [`AdvertisedRef::oid`]'s distinction and not a
/// convenience: `git ls-remote` prints it as its **own line**, under the name
/// `refs/tags/v1^{}`, and a script reading `ls-remote` output reads that line
/// to learn what a tag points at without fetching the tag object. Dropping it
/// is not a rounding of the answer, it is a missing one.
pub peeled: Option<ObjectId>,
}
impl AdvertisedRef {
/// A ref with nothing to peel: a branch, `HEAD`, or a lightweight tag.
pub fn new(name: impl Into<String>, oid: ObjectId) -> Self {
AdvertisedRef {
name: name.into(),
oid,
peeled: None,
}
}
/// This ref as `git ls-remote` prints it: one line, or **two** for an
/// annotated tag.
///
/// ```text
/// d320a605…\trefs/tags/v1
/// 10c95536…\trefs/tags/v1^{}
/// ```
///
/// Written here, once, because both `gunnar ls-remote` and the test that
/// diffs it against `git ls-remote` must agree on the bytes, and a second
/// copy of a format string is a second chance to get the tab wrong (one
/// implementation). MEASURED against git 2.53.0 on 2026-08-05: a single
/// `\t`, no padding, `\n` terminated, and the peeled line immediately
/// after its own ref.
pub fn ls_remote_lines(&self) -> String {
let mut out = format!("{}\t{}\n", self.oid, self.name);
if let Some(peeled) = self.peeled {
out.push_str(&format!("{peeled}\t{}^{{}}\n", self.name));
}
out
}
}
/// A whole advertisement as `git ls-remote` prints it, in the order the remote
/// sent — which for stock `upload-pack` is `HEAD` first, then refs sorted.
pub fn ls_remote_output(refs: &[AdvertisedRef]) -> String {
refs.iter().map(AdvertisedRef::ls_remote_lines).collect()
}
/// `ls-remote` over SSH with an explicit key.
///
/// Kept for callers that have a key path and nothing else;
/// [`ls_remote_with`] is the one that can also use `ssh-agent`.
pub fn ls_remote(url: &str, ssh_key: &Path) -> Result<Vec<AdvertisedRef>> {
ls_remote_with(url, &crate::ssh::SshOptions::new().with_key(ssh_key))
}
/// `ls-remote` over SSH under the **same** [`SshOptions`](crate::SshOptions) a
/// push takes: `ssh-agent` first, then the key ladder, under a host-key policy.
///
/// # This used to be `nornir-git`'s hand-rolled parser, and it dropped `peeled:`
///
/// `nornir_git::ssh::ls_remote_blocking_auth` frames the v2 `ls-refs` response
/// itself and keeps the first two tokens of each line, so
/// `<tag> refs/tags/v1 peeled:<commit>` arrived here as `(tag, refs/tags/v1)` with
/// the peeled commit thrown away. That is invisible until you print what `git
/// ls-remote` prints, and then it is a **missing line** — the `refs/tags/v1^{}`
/// one — in output scripts parse.
///
/// So this now runs over [`crate::ssh::connect_upload_pack`], the same
/// connector `fetch_ssh_with` and [`init_empty_clone`] already use, and reads
/// the advertisement with [`ls_remote_with_io`] — gix's own handshake and
/// `LsRefsCommand`, which sends `symrefs` and `peel` and parses both. One
/// ls-remote in this crate rather than two (one implementation), and the
/// `object-format` echo a SHA-256 remote needs is gix's rather than
/// hand-written.
pub fn ls_remote_with(url: &str, opts: &crate::ssh::SshOptions) -> Result<Vec<AdvertisedRef>> {
let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
let mut up = crate::ssh::connect_upload_pack(&loc, opts)?;
let path = loc.path.clone();
let (reader, writer) = up.io();
ls_remote_with_io(reader, writer, &path)
}
/// `ls-remote` over a transport already running `git-upload-pack`.
///
/// The seam that makes `gunnar ls-remote` provable against the **real `git
/// upload-pack` binary** over a pair of pipes, with no network and no `sshd` —
/// the same reason [`fetch_with_io`] exists.
///
/// Reads the whole advertisement through [`remote_identity_with_io`], so the ref
/// list `ls-remote` prints and the emptiness verdict `clone` acts on come from one
/// handshake and cannot disagree.
pub fn ls_remote_with_io<R: Read, W: Write>(
reader: R,
writer: W,
path: &str,
) -> Result<Vec<AdvertisedRef>> {
Ok(remote_identity_with_io(reader, writer, path)?.refs)
}
/// Refuse a fetch refspec stock git refuses, **before any transport is opened**.
///
/// # The one rule gix does not enforce, and what it cost
///
/// A refspec whose source is a glob and which names no destination — `refs/heads/*`
/// — means *"resolve these, write nothing"*. git refuses to parse it at all:
///
/// ```text
/// fatal: invalid refspec 'refs/heads/*'
/// ```
///
/// gix's parser accepted it up to and including 0.86. So gunnar opened the connection, negotiated, took the
/// pack, wrote **no reference**, reported `0 local refs updated` and exited 0,
/// leaving an unreferenced pack the next `gc` deletes. That string was
/// [`crate::fetch`]'s own default until 2026-08-05, which is what the original
/// `gunnar fetch` defect was; the default is fixed, but the string was still
/// accepted **from a caller**, so the same silent nothing was one `--refspec`
/// away.
///
/// # Where gix and git already agree, and why this is only three cells wide
///
/// MEASURED against git 2.53.0 and gix 0.86 on 2026-08-05, over thirty refspecs.
/// The two verdicts differ on exactly three, and all three are one shape:
///
/// | refspec | git | gix 0.86 | gix 0.87 |
/// |---|---|---|---|
/// | `refs/heads/*` | `invalid refspec` | accepted | refused, other wording |
/// | `+refs/heads/*` | `invalid refspec` | accepted | refused, other wording |
/// | `refs/heads/*:` | `invalid refspec` | accepted | refused, other wording |
///
/// Everything else — a bad ref name, two `*` on one side, a pattern facing a
/// non-pattern (`refs/heads/*:refs/remotes/o/x`), a negative spec with a
/// destination — gix already refuses, with a different sentence. That is the
/// measurement trap in this fix and the reason the rule below is written as *"a pattern
/// source needs a pattern destination"* rather than only *"a glob needs a colon"*:
/// stated in full, this function is the authority for the whole law and does not
/// silently become decorative if gix's half ever changes. Its own wording,
/// [`Error::InvalidRefspec`], is what
/// `tests/fetch_refspec_refusal.rs` asserts on, so the guard that fires is
/// provably this one and not gix's.
///
/// **The fourth column arrived on 2026-08-30 with the gix 0.87 port**, and it is
/// the case the paragraph above was written for. This function did not become
/// decorative: it runs BEFORE `Remote::with_refspecs`, so it is still what
/// decides the sentence, and the sentence is git's rather than gix's. What it
/// stopped doing is changing the *outcome* — on 0.87 all three would be refused
/// without it, before a byte reached the wire. Both halves are measured in
/// `tests/fetch_refspec_refusal.rs`, and the count of the three that gix refuses
/// is pinned there so a gix upgrade moving it back is visible.
///
/// # What must still be accepted
///
/// A source-only spec that is **not** a pattern is legal and git takes it:
/// `git fetch <url> refs/heads/main` writes `FETCH_HEAD` and no other ref. So
/// does `refs/heads/main:` and so does a bare `:`. Refusing those would be a
/// second wrong answer wearing this one's clothes.
pub fn check_fetch_refspec(spec: &str) -> Result<()> {
let invalid = || Error::InvalidRefspec(spec.to_string());
// git's `parse_refspec`: one leading `+` (force) or `^` (negative), then the
// sides split on the LAST colon — `strrchr`, not the first, which is why
// `a:b:` is a bad ref name rather than an empty destination.
let body = match spec.strip_prefix('+') {
Some(rest) => rest,
None => match spec.strip_prefix('^') {
// A negative spec excludes sources and writes nowhere by design, so
// "writes nothing" is not a defect in it. gix enforces the rest
// (non-empty, full ref name, no destination).
Some(_) => return Ok(()),
None => spec,
},
};
let (src, dst) = match body.rsplit_once(':') {
Some((src, dst)) => (src, Some(dst)),
None => (body, None),
};
let is_pattern = |side: &str| side.contains('*');
if is_pattern(src) && !dst.is_some_and(is_pattern) {
return Err(invalid());
}
Ok(())
}
/// [`check_fetch_refspec`] over a list, refusing on the first bad one.
///
/// Named separately so every caller refuses at the same point — the top of the
/// verb, before a connection — rather than each writing its own loop (one
/// implementation).
pub fn check_fetch_refspecs(specs: &[&str]) -> Result<()> {
specs.iter().try_for_each(|spec| check_fetch_refspec(spec))
}
/// Clone `url` into `dest`, or fetch if it is already a checkout. Returns the
/// fetched `HEAD` sha, or the empty string when the remote has no commits yet.
///
/// **Delegates to `nornir_git::gitio::clone_or_fetch`**, which handles the parts
/// that took a session to get right: matching the *remote's* object format when
/// initialising (a SHA-1 init against a SHA-256 remote fails on every 64-char
/// oid), the steady-state early return when `HEAD` has not moved, and pointing
/// `HEAD` at the fetched branch before materialising the worktree.
///
/// # The one case it adds: a remote with no commits
///
/// `clone_or_fetch` reads the advertisement for a `HEAD` and fails with
/// *"remote … advertised no HEAD"* when there is none, leaving **no directory
/// behind**. That is the very first thing a new user does — create a repository
/// on the server, clone it, make the first commit, push — and `git clone` does
/// it happily ("warning: You appear to have cloned an empty repository").
/// MEASURED against a real `sshd` and a real `git upload-pack` on 2026-08-05:
/// stock git produced a working SHA-256 checkout, `gunnar clone` produced
/// nothing and exit 1.
///
/// [`init_empty_clone`] is that case and only that case: it takes the object
/// format and the unborn `HEAD` from the handshake rather than guessing, because
/// a guess here is the same permanent wedge as
/// [`crate::mirror::infer_hash_kind`]'s.
pub fn clone_or_fetch(url: &str, dest: &Path, ssh_key: Option<&Path>) -> Result<String> {
let opts = match ssh_key {
Some(key) => crate::ssh::SshOptions::new().with_key(key),
None => crate::ssh::SshOptions::new(),
};
clone_or_fetch_with(url, dest, &opts)
}
/// [`clone_or_fetch`] under the same [`SshOptions`](crate::SshOptions) a push
/// takes.
///
/// # The checkout is guarded, because a filter may not have filled it in
///
/// Until nornir-git 0.1.11 `nornir_git::gitio::materialize_worktree` wrote each
/// index blob's bytes and nothing else: no `smudge`, no eol conversion, no
/// `ident`. A repository using Git-LFS therefore checked out as 131-byte pointer
/// files, and until this guard existed it did so **silently** — a wrong checkout
/// that a build links against. Since 0.1.11 the writer runs the configured smudge
/// driver; the pointer still lands where no driver is configured or the driver
/// hands the stored blob back (`GIT_LFS_SKIP_SMUDGE=1`).
/// [`crate::attributes::guard_checkout`] is the single writer of what that
/// means, and it runs here rather than in the CLI so a library caller is not
/// the one code path left silent.
/// # The identity goes through the same resolver as every other verb
///
/// `nornir-git`'s `clone_or_fetch` takes **one** key path and means *exactly
/// that key* (`SshAuth::key`: no agent, no ladder). That is the behaviour the audit
/// found `gunnar clone` had and `gunnar ls-remote` did not, on 2026-08-06 —
/// and the reason was that this line handed `opts.key_path` down raw while
/// everything else expanded it through a fallback ladder.
///
/// It now goes through [`crate::ssh::resolve_identity`] like the rest (one
/// implementation), so a named key that is missing is a refusal here too
/// instead of a silent demotion to `nornir-git`'s deploy-key search, and an
/// unnamed one picks gunnar's ladder rather than a second, differently-ordered
/// one.
pub fn clone_or_fetch_with(
url: &str,
dest: &Path,
opts: &crate::ssh::SshOptions,
) -> Result<String> {
verify_host_key(url, opts)?;
let identity = crate::ssh::resolve_identity(opts.key_path.as_deref())?;
// `clone_or_fetch` takes at most one key; `None` lets nornir-git report its
// own "no deploy key was found, looked at …" error, which names every rung.
let key = identity.into_keys().into_iter().next();
let head = match nornir_git::gitio::clone_or_fetch(url, dest, key.as_deref()) {
Ok(head) => head,
Err(e) if remote_is_unborn(&e) => {
init_empty_clone(url, dest, opts)?;
// An unborn remote has no tree, so there is nothing to filter.
return Ok(String::new());
}
Err(e) => return Err(Error::Ssh(e)),
};
crate::attributes::guard_checkout(dest)?;
Ok(head)
}
/// **Check the server's host key before a single byte of the clone is fetched.**
///
/// GUN-002. `SshOptions` carries `host_key` and `known_hosts`, and
/// `SshOptions::auth()` is the ONE place that expands them — which is why
/// `push`, `fetch`, `ls-remote` and `clone --depth/--filter` all check
/// known_hosts and stop hard on a changed key. This path never called `auth()`.
/// It took the key path alone and handed it to
/// `nornir_git::gitio::clone_or_fetch`, whose `ssh_sync` builds its own
/// `SshAuth::key(path)` — and `SshAuth::key` sets `HostKeyPolicy::AcceptAny`,
/// so `Client::check` returns `Ok(true)` for ANY server key without opening
/// known_hosts. The policy vanished with no warning, no error and no log line.
///
/// The asymmetry is what made it serious. A user whose known_hosts already
/// holds the true key — put there by gunnar's own trust-on-first-use — got a
/// hard stop from every other verb and a silent pass from the one verb they run
/// first, into a working tree they are about to build.
///
/// So the connection is made HERE, through `opts.auth()`, before the fetch:
/// same resolver, same policy, same known_hosts file, same refusal. A server
/// whose key does not match is refused before `clone_or_fetch` is called at
/// all, and nothing is written to disk.
///
/// # Why a probe and not the right fix
///
/// The right fix is an entry point in `nornir-git` that takes an `SshAuth` —
/// `connect_upload_pack_auth` is the shape, and `gitio::clone_or_fetch` has no
/// equivalent. That is a change to a published sibling crate, and gunnar does
/// not `[patch]` a published dependency onto a sibling checkout (see
/// `gunnar-cli/src/client.rs`'s note on `--depth`). Until it exists, this gives
/// the clone path exactly the guarantee the other four verbs have. It costs one
/// extra SSH handshake on a verb that already makes two.
///
/// Non-SSH remotes are skipped because they have no host key. `https_to_ssh` is
/// applied first for the same reason `clone_or_fetch` applies it: an
/// `https://host/owner/repo` remote is COERCED to its SSH form down there, so
/// checking the URL as written would skip exactly the remotes that end up on a
/// host key after all.
fn verify_host_key(url: &str, opts: &crate::ssh::SshOptions) -> Result<()> {
let coerced = nornir_git::gitio::https_to_ssh(url);
let url = coerced.as_deref().unwrap_or(url);
if !nornir_git::gitio::is_ssh_url(url) {
return Ok(());
}
let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
// Opened and dropped. `connect_upload_pack` goes through `opts.auth()`,
// which is where `host_key` and `known_hosts` are expanded, so reaching
// the next line means the server presented a key this policy accepts.
let _probe = crate::ssh::connect_upload_pack(&loc, opts)?;
Ok(())
}
/// Does this failure mean "the remote exists and has no commits"?
///
/// Matched on the message because that is all the upstream error carries; it is
/// deliberately narrow, so an unreachable host or a missing repository still
/// fails rather than silently producing an empty checkout.
fn remote_is_unborn(err: &anyhow::Error) -> bool {
format!("{err:#}").contains("advertised no HEAD")
}
/// Create the local half of a clone from a remote that has no commits yet.
///
/// Returns the branch `HEAD` was pointed at.
pub fn init_empty_clone(url: &str, dest: &Path, opts: &crate::ssh::SshOptions) -> Result<String> {
let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
let mut up = crate::ssh::connect_upload_pack(&loc, opts)?;
let path = loc.path.clone();
let (reader, writer) = up.io();
let remote = remote_identity_with_io(reader, writer, &path)?;
drop(up);
if remote.has_refs {
return Err(Error::repo_msg(format!(
"{url} advertised references after all, so this is not an empty remote; \
the clone failed for another reason"
)));
}
let branch = remote
.unborn_head
.unwrap_or_else(|| "refs/heads/main".to_string());
init_clone_dir(dest, url, remote.hash_kind, &branch)?;
Ok(branch)
}
/// **The remote's answer to "which branch is HEAD on", checked before it is
/// written into `.git/HEAD`.**
///
/// `RemoteIdentity::head_target` and `unborn_head` are strings the SERVER
/// chose: the v0 advertisement's `symref=HEAD:<target>` and v2's `ls-refs`
/// symref line. `init_clone_dir` wrote `ref: {branch}` into `.git/HEAD`
/// verbatim, so whatever the remote said became the contents of a file in the
/// user's new working tree.
///
/// `ref:` targets are resolved relative to `$GIT_DIR`, so a remote answering
/// `../../../../tmp/x` points the clone's HEAD at a path outside the
/// repository, which the next commit then writes through — and a target
/// carrying a newline puts a second line into the file that nothing in the
/// repository put there. Neither needs a malicious server to be reached: a
/// compromised or hostile remote is a threat model this client already takes
/// seriously enough to check host keys over.
///
/// gix's own `FullName` is the rule, not a private one. It is the same
/// `check-ref-format` reduced to fully-qualified names that
/// `gunnar_store::refname::validate` applies on the server side — `refs/…` or
/// `HEAD`, no `..`, no control characters, no empty components — and it is
/// already in this crate's graph, so the check costs no dependency. A private
/// copy would be a second rule to drift.
///
/// The refusal names what the remote sent, because a clone that fails with
/// "invalid reference" and no value leaves a user with nothing to report.
fn checked_head_target(branch: &str) -> Result<&str> {
use std::convert::TryFrom as _;
gix::refs::FullName::try_from(branch).map_err(|err| {
Error::repo_msg(format!(
"the remote says its HEAD points at {branch:?}, which is not a reference name \
({err}). Refusing to write it into .git/HEAD"
))
})?;
Ok(branch)
}
/// Create the local half of a clone: the directory, the repository in the
/// remote's object format, `HEAD` on the remote's default branch, and
/// `remote.origin`.
///
/// # Why this is its own function
///
/// It is the **one writer** (one implementation) for "a freshly initialised
/// clone", used by both callers that have to build one before any pack
/// arrives: [`init_empty_clone`], for a remote with no commits at all, and
/// [`crate::clone::clone_with_io`], for a shallow or filtered clone, which
/// cannot go through `nornir-git`'s `clone_or_fetch` because that takes no
/// depth. Before the split the config block existed once and the second caller
/// would have copied thirty lines of it — including the `[branch]` section,
/// which is the part a hand-written copy forgets and whose absence makes `git
/// pull` in the clone say *"There is no tracking information for the current
/// branch"*.
///
/// `branch` is the **full** name (`refs/heads/main`).
///
/// Its one hostile input goes through [`checked_head_target`] first.
pub fn init_clone_dir(
dest: &Path,
url: &str,
hash_kind: gix::hash::Kind,
branch: &str,
) -> Result<gix::Repository> {
// **First, before a directory exists.** See [`checked_head_target`]: this
// is the one input here the remote chose, and a clone that refuses it must
// leave nothing behind — a half-initialised `.git` with somebody else's
// idea of HEAD in it is worse than no clone.
let branch = checked_head_target(branch)?;
std::fs::create_dir_all(dest)
.map_err(|e| Error::repo(format!("create {}", dest.display()), e))?;
let mut repo = gix::ThreadSafeRepository::init_opts(
dest,
gix::create::Kind::WithWorktree,
gix::create::Options {
destination_must_be_empty: Some(false),
fs_capabilities: None,
object_hash: Some(hash_kind),
},
gix::open::Options::default(),
)
.map_err(|e| Error::repo(format!("init {}", dest.display()), e))?
.to_thread_local();
let _ = repo.committer_or_set_generic_fallback();
// `HEAD` at the branch the remote said it will be on, so the first commit
// lands where a later push expects it.
std::fs::write(dest.join(".git").join("HEAD"), format!("ref: {branch}\n"))
.map_err(|e| Error::repo("point HEAD at the remote's default branch", e))?;
// `remote.origin.*`, so `gunnar fetch` and `git fetch` both work in the
// clone without the user retyping the URL.
let git_config = dest.join(".git").join("config");
let existing = std::fs::read_to_string(&git_config).unwrap_or_default();
let short = branch.strip_prefix("refs/heads/").unwrap_or(branch);
let addition = format!(
"[remote \"origin\"]\n\turl = {url}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\
[branch \"{short}\"]\n\tremote = origin\n\tmerge = {branch}\n"
);
std::fs::write(&git_config, format!("{existing}{addition}"))
.map_err(|e| Error::repo("record remote.origin in the new clone", e))?;
// Re-open, so the caller's handle sees the config that was just written
// rather than the one `init_opts` snapshotted before it existed. The
// committer fallback is set again on the new handle: without it the first
// ref update in the clone fails on a machine with no `~/.gitconfig`, after
// the pack has already been transferred.
let mut reopened =
gix::open(dest).map_err(|e| Error::repo(format!("re-open {}", dest.display()), e))?;
let _ = reopened.committer_or_set_generic_fallback();
Ok(reopened)
}
/// What a remote says about itself before any ref is fetched.
#[derive(Debug, Clone)]
pub struct RemoteIdentity {
/// The object format it announced. **Read, never guessed**: a local
/// repository created with the wrong one can never be corrected.
pub hash_kind: gix::hash::Kind,
/// The ref an unborn `HEAD` points at, when the remote said so.
pub unborn_head: Option<String>,
/// The ref `HEAD` symbolically points at on a remote that **does** have
/// commits, e.g. `refs/heads/main`.
///
/// Read from the advertisement's `symref=HEAD:…` (v0) or the `ls-refs`
/// symref line (v2), never guessed: a clone that assumes `main` checks out
/// the wrong branch on every repository whose default is something else, and
/// silently, because the checkout still succeeds.
pub head_target: Option<String>,
/// True when the remote advertised at least one reference.
pub has_refs: bool,
/// Every reference the remote advertised, in the order it sent them, with
/// annotated tags carrying their peeled commit.
///
/// This is what [`ls_remote_with_io`] returns, and it is read off the **same
/// handshake** the object format and the unborn `HEAD` come from. One
/// round-trip, one answer: a `gunnar ls-remote` and a `gunnar clone` against
/// the same remote cannot report different ref sets because they asked
/// separately.
pub refs: Vec<AdvertisedRef>,
}
/// Read a remote's object format and unborn `HEAD` over an existing transport.
///
/// `result_large_err` is allowed for one closure and one reason: gix's
/// handshake takes an authenticator whose error type is
/// `gix_credentials::protocol::Error`, which is large and not ours to shrink.
/// The closure is never called — SSH has already authenticated by the time this
/// runs, and a credential prompt here would hang a non-interactive clone rather
/// than fail it — so the size is paid nowhere.
#[allow(clippy::result_large_err)]
pub fn remote_identity_with_io<R: Read, W: Write>(
reader: R,
writer: W,
path: &str,
) -> Result<RemoteIdentity> {
use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
use gix::protocol::transport::client::git::ConnectMode;
use gix::protocol::transport::{Protocol, Service};
let mut conn = GitConnection::new(
reader,
writer,
Protocol::V2,
gix::bstr::BString::from(path),
Option::<(String, Option<u16>)>::None,
ConnectMode::Process,
false,
);
let handshake = gix::protocol::handshake(
&mut conn,
Service::UploadPack,
// No credential helper: SSH already authenticated, and a prompt here
// would hang a non-interactive clone rather than fail it.
// gix 0.88: credentials report a `gix_error` exception (a `gix::Error` since
// 0.90); "no identity" is class NotFound, as gix-credentials' own
// `identity_missing` raises it.
|_| {
Err(gix::error::ErrorExt::raise(gix::error::not_found(
"no credential helper: this transport is already authenticated by SSH",
)))
},
Vec::new(),
&mut gix::progress::Discard,
)
// The whole chain: gix's own sentence is "An IO error occurred when
// talking to the server", and the server's `ERR` line (gunnar's
// "repository not found, or you do not have access") is its source.
// gix 0.88: the handshake reports a `gix_error` exception, which is not a
// `std::error::Error`; `gix_error::Error` is, and keeps the chain as sources.
.map_err(|e| Error::Protocol(format!("handshake with {path}: {}", with_sources(&e))))?;
let hash_kind = match handshake
.capabilities
.capability("object-format")
.and_then(|c| c.value().map(|v| v.to_string()))
.as_deref()
{
Some("sha256") => gix::hash::Kind::Sha256,
// A remote that names no `object-format` is SHA-1 by definition of the
// capability; this is not the guess the mirror's was.
_ => gix::hash::Kind::Sha1,
};
// Protocol v0 lists refs in the handshake; v2 does not, so ask.
let listed = match handshake.refs.as_ref() {
Some(refs) => refs.clone(),
None => {
// `LsRefsCommand::new` adds `unborn` itself when the server offers
// it, which is the whole point of asking: an empty repository has
// no refs to list and only the unborn line names its branch.
gix::protocol::LsRefsCommand::new(
None,
&handshake.capabilities,
gix::protocol::command::Feature::from(("agent", Some(crate::agent()))),
)
.invoke_blocking(&mut conn, &mut gix::progress::Discard, false)
.map_err(|e| Error::Protocol(format!("ls-refs against {path}: {e}")))?
}
};
let unborn_head = listed.iter().find_map(|r| match r {
gix::protocol::handshake::Ref::Unborn { target, .. } => Some(target.to_string()),
_ => None,
});
let head_target = listed.iter().find_map(|r| match r {
gix::protocol::handshake::Ref::Symbolic {
full_ref_name,
target,
..
} if full_ref_name == "HEAD" => Some(target.to_string()),
_ => None,
});
// An `unborn` line IS a listed ref and is NOT a reference the remote holds.
// Counting it made the emptiness test always false, which is how the first
// cut of this failed on the very repositories it was written for.
let has_refs = listed
.iter()
.any(|r| !matches!(r, gix::protocol::handshake::Ref::Unborn { .. }));
let refs = listed.iter().filter_map(advertised_ref).collect();
Ok(RemoteIdentity {
hash_kind,
unborn_head,
head_target,
has_refs,
refs,
})
}
/// One advertised ref, from gix's handshake shape into [`AdvertisedRef`].
///
/// **`target` first, `peeled` only as a fallback.** For `refs/tags/v1` on an
/// annotated tag `target` is the *tag object* and `peeled` is the commit under it
/// — two different objects — and `git ls-remote` prints the tag object on that
/// name's line. Reading `peeled.or(target)` printed the commit instead, silently,
/// which was the bug fixed here on 2026-08-05; this is the one place the choice is
/// made, so [`fetch_with_io`] and [`ls_remote_with_io`] cannot make it differently
/// (one implementation).
///
/// `None` for a ref with neither, which is only ever the `unborn` line: a name
/// with no object is not something to record as though it had one.
fn advertised_ref(r: &gix::protocol::handshake::Ref) -> Option<AdvertisedRef> {
let (name, target, peeled) = r.unpack();
Some(AdvertisedRef {
name: name.to_string(),
oid: target.or(peeled)?.to_owned(),
// Only meaningful when the ref has a target of its own: for the fallback
// shape above, `peeled` IS the oid and repeating it as a `^{}` line would
// invent one git does not print.
peeled: target.and(peeled).map(gix::hash::oid::to_owned),
})
}
/// One local reference a fetch actually wrote.
///
/// **Applied output, not intent** (measure, never assert). A fetch reports
/// what the remote advertised whether or not a single local ref moved, so a
/// caller that printed only [`FetchOutcome::refs`] would print the remote's
/// new oids beside a repository that had not changed at all — which is exactly
/// what `gunnar fetch` did until 2026-08-05.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedRefUpdate {
/// The **local** ref name, e.g. `refs/remotes/origin/main`.
pub name: String,
/// What it now points at, or `None` when the edit deleted it.
pub new: Option<ObjectId>,
}
/// What a [`fetch_with_io`] did.
#[derive(Debug, Clone)]
pub struct FetchOutcome {
/// Every `(remote ref, oid)` the remote advertised for the given refspecs.
pub refs: Vec<AdvertisedRef>,
/// The local refs this fetch **wrote**. Empty is a legitimate outcome (a
/// fetch that changed nothing) and also the symptom of a refspec with no
/// destination, which is why it is reported rather than inferred.
pub updated_refs: Vec<AppliedRefUpdate>,
/// Objects received in the pack. `0` when the local side was already
/// complete, which is a legal and common outcome, not a failure.
pub objects_received: u64,
/// True when a pack was transferred at all.
pub received_pack: bool,
/// Where history is cut **after** this fetch, read back from
/// `.git/shallow`. Empty means the repository is complete.
///
/// Applied output (measure, never assert), and the only field that can
/// tell a working `--depth 1` from one that quietly fetched the whole
/// history: the request looks the same either way, and so does a clone
/// that works.
pub shallow_boundary: Vec<ObjectId>,
}
/// What one fetch asks for beyond its refspecs.
///
/// Grown rather than added as a second entry point, so there is one fetch
/// writer (one implementation): [`fetch_with_io`] is [`fetch_with_io_opts`]
/// with the default, and the default is "change nothing about the boundary".
#[derive(Debug, Clone, Default)]
pub struct FetchOptions {
/// `--depth`, `--deepen`, `--shallow-since`, `--shallow-exclude`,
/// `--unshallow`. Build it with [`crate::DepthArgs::to_shallow`].
pub shallow: crate::shallow::Shallow,
/// `--filter=blob:none` and friends: partial clone. Build it with
/// [`crate::FilterSpec::parse`].
pub filter: Option<crate::filter::FilterSpec>,
}
impl FetchOptions {
/// A fetch that leaves the shallow boundary exactly where it is and asks
/// for every object.
pub fn new() -> Self {
Self::default()
}
/// Set the shallow request.
pub fn with_shallow(mut self, shallow: crate::shallow::Shallow) -> Self {
self.shallow = shallow;
self
}
/// Set the partial-clone filter.
pub fn with_filter(mut self, filter: crate::filter::FilterSpec) -> Self {
self.filter = Some(filter);
self
}
}
/// Fetch into `repo` over an arbitrary blocking transport already running
/// `git-upload-pack`.
///
/// `url` is only used to build the in-memory remote (it never opens a
/// connection of its own); `path` is the repository path the remote's
/// `upload-pack` was started on, which protocol v2 carries in its handshake.
///
/// The blocking reads and writes happen on the calling thread. If the transport
/// is bridged from an async runtime, this must not be called from one of that
/// runtime's workers.
pub fn fetch_with_io<R: Read, W: Write>(
repo: &gix::Repository,
reader: R,
writer: W,
url: &str,
path: &str,
refspecs: &[&str],
) -> Result<FetchOutcome> {
fetch_with_io_opts(
repo,
reader,
writer,
url,
path,
refspecs,
&FetchOptions::default(),
)
}
/// [`fetch_with_io`] plus a [`FetchOptions`]: the one fetch writer.
///
/// The shallow request reaches the wire through **gix's**
/// `gix_protocol::fetch::add_shallow_args`, which is what turns
/// [`crate::Shallow`] into
/// `deepen`/`deepen-relative`/`deepen-since`/`deepen-not` lines and what
/// writes the server's `shallow`/`unshallow` reply into `.git/shallow`.
/// Nothing here re-encodes a wire argument (one implementation); what is new
/// is that gunnar can now ask.
///
/// Protocol version is **not** pinned by the option: the transport asks for v2
/// and gix downgrades to v0 against a server that answers v0, and the shallow
/// arguments differ between the two only in framing, which is gix's job. Both
/// arms are proven in `tests/real_git_shallow.rs`.
#[allow(clippy::too_many_arguments)]
pub fn fetch_with_io_opts<R: Read, W: Write>(
repo: &gix::Repository,
reader: R,
writer: W,
url: &str,
path: &str,
refspecs: &[&str],
options: &FetchOptions,
) -> Result<FetchOutcome> {
use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
use gix::protocol::transport::client::git::ConnectMode;
use gix::protocol::transport::Protocol;
// `RequestEdit` does the two things gunnar cannot ask gix to do: it corrects
// gix's `deepen-relative` on protocol v0, one half of which turns `--depth 1`
// into a full clone with no error, and it injects the `filter` argument that
// `gix::Repository`'s high-level fetch has no builder for. Inert when neither
// is being asked for. See `crate::wire` for both measurements.
let writer = crate::wire::RequestEdit::new(
writer,
&options.shallow,
options.filter.as_ref().map(|f| f.to_spec()),
);
// The backstop, at the one point every fetch in this crate funnels through:
// `fetch_ssh`, `fetch_ssh_with` and both of `MirrorRegistry`'s seams. The SSH
// entry points check first so no connection is dialled at all; nothing can
// reach a wire negotiation without passing here.
check_fetch_refspecs(refspecs)?;
let transport = GitConnection::new(
reader,
writer,
Protocol::V2,
gix::bstr::BString::from(path),
Option::<(String, Option<u16>)>::None,
// `Process` is right because the remote command is already running and
// its stdio IS this transport. The alternative (`Daemon`) would prepend
// a git-daemon handshake line the far end never asked for.
ConnectMode::Process,
false,
);
// Say who we are on the wire. gix otherwise sends its OWN `agent=`, so a
// gunnar fetch reached a server's log as `git/oxide-0.86.0` while a gunnar
// push reached it as `gunnar/<version>` — the same client, two identities,
// and no way for an operator to attribute a fetch to gunnar at all.
// MEASURED against a real `git upload-pack` on 2026-08-05.
//
// `gitoxide.userAgent` is the knob `Connection::ref_map` reads. gix prepends
// `git/` to it, following git's own `agent=git/2.53.0`, so the fetch says
// `git/gunnar/<version>` where the push says `gunnar/<version>`. That prefix
// is gix's and not ours to strip; what matters is that both name gunnar.
//
// The repository is borrowed, so this sets the key on a clone that lives
// exactly as long as the fetch and never touches the caller's config.
let mut repo = repo.clone();
{
let mut config = repo.config_snapshot_mut();
config
.set_raw_value_by("gitoxide", None, "userAgent", crate::agent().as_str())
.map_err(|e| Error::repo("set gitoxide.userAgent for the fetch", e))?;
}
let remote = repo
.remote_at(url)
.map_err(|e| Error::repo(format!("build an in-memory remote for {url}"), e))?
.with_refspecs(refspecs.iter().copied(), gix::remote::Direction::Fetch)
.map_err(|e| Error::repo("set fetch refspecs", e))?;
let outcome = remote
.to_connection_with_transport(transport)
.prepare_fetch(gix::progress::Discard, Default::default())
.map_err(|e| Error::repo_chain("prepare fetch", &e))?
.with_shallow(options.shallow.clone())
.receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.map_err(|e| Error::repo_chain("fetch (pack transfer)", &e))?;
// `advertised_ref` and nothing local: the `target`-before-`peeled` rule an
// annotated tag turns on is decided in one place for this crate.
let refs: Vec<AdvertisedRef> = outcome
.ref_map
.remote_refs
.iter()
.filter_map(advertised_ref)
.collect();
let (received_pack, objects_received, update_refs) = match &outcome.status {
gix::remote::fetch::Status::Change {
write_pack_bundle,
update_refs,
..
} => (
true,
write_pack_bundle.index.num_objects as u64,
update_refs,
),
gix::remote::fetch::Status::NoPackReceived { update_refs, .. } => (false, 0, update_refs),
};
// Read back the edits gix applied, so the caller can distinguish "nothing
// changed" from "nothing was written because the refspec named no
// destination".
let updated_refs = update_refs
.edits
.iter()
.map(|edit| {
let new = match &edit.change {
gix::refs::transaction::Change::Update { new, .. } => match new {
gix::refs::Target::Object(id) => Some(*id),
gix::refs::Target::Symbolic(_) => None,
},
gix::refs::transaction::Change::Delete { .. } => None,
};
AppliedRefUpdate {
name: edit.name.as_bstr().to_string(),
new,
}
})
.collect();
write_fetch_head(&repo, &refs, url)?;
// Read back where history is cut, from the file the fetch just wrote. Not
// from the request, and not from `outcome.last_response`: a server is free
// to answer a `deepen` with no `shallow` line at all (it does exactly that
// when the depth reaches a root), and only the file says what the repository
// now is.
let shallow_boundary = crate::shallow::boundary(&repo)?;
if let Some(filter) = &options.filter {
record_promisor(&repo, filter)?;
}
Ok(FetchOutcome {
refs,
updated_refs,
objects_received,
received_pack,
shallow_boundary,
})
}
/// Mark the repository as a **promisor** one, which is what makes a partial
/// clone usable rather than merely small.
///
/// # Why a filtered fetch is not finished when the pack lands
///
/// A partial clone is a repository that is deliberately missing objects. Nothing
/// in the pack says so, so without this git treats the gaps as corruption:
/// `git cat-file`, `git log -p` and any checkout of a filtered path fail with
/// *unable to read <oid>* instead of fetching what they need. Three settings
/// turn "objects are missing" into "objects are missing **on purpose**, and this
/// remote will supply them":
///
/// ```text
/// extensions.partialClone = origin the repository is a partial clone
/// remote.origin.promisor = true this remote answers for the gaps
/// remote.origin.partialclonefilter = … what was filtered, so a later fetch matches
/// ```
///
/// MEASURED 2026-08-05 against git 2.53.0: without them, `git rev-list
/// --objects --missing=allow-any` still walks, but `git cat-file -p <blob>` on
/// an omitted blob fails and no lazy fetch is attempted; with them, the same
/// command fetches the blob from the promisor and prints it.
///
/// `extensions.partialClone` needs `core.repositoryformatversion = 1`, because
/// version 0 ignores the whole `extensions` section — a repository written with
/// the extension and version 0 is one git silently treats as complete, which is
/// the wrong answer wearing the right config.
fn record_promisor(repo: &gix::Repository, filter: &crate::filter::FilterSpec) -> Result<()> {
let config = repo.git_dir().join("config");
let existing = std::fs::read_to_string(&config).unwrap_or_default();
// The remote a fetch answers for is `origin`: it is the only one
// `init_clone_dir` writes and the only one gunnar's own fetch path names.
let addition = format!(
"[extensions]\n\tpartialClone = origin\n\
[remote \"origin\"]\n\tpromisor = true\n\tpartialclonefilter = {}\n",
filter.to_spec()
);
let mut text = if existing.contains("partialClone = origin") {
existing
} else {
format!("{existing}{addition}")
};
// Raise the format version in place rather than appending a second
// `[core]`: git reads the FIRST value it finds, so an appended override
// would be ignored and the extension with it.
if text.contains("repositoryformatversion = 0") {
text = text.replace("repositoryformatversion = 0", "repositoryformatversion = 1");
} else if !text.contains("repositoryformatversion = 1") {
text.push_str("[core]\n\trepositoryformatversion = 1\n");
}
std::fs::write(&config, text)
.map_err(|e| Error::repo("record the promisor remote of a partial clone", e))?;
Ok(())
}
/// Write `FETCH_HEAD`, the way `git fetch` does.
///
/// # Why this is here at all
///
/// `git fetch` writes this file on every fetch and a great deal of ordinary use
/// reads it: `git merge FETCH_HEAD`, `git pull`'s second half, `git log
/// FETCH_HEAD`, and every script that does `$(git rev-parse FETCH_HEAD)`. gix's
/// `receive` applies ref edits and does not write it, so until 2026-08-05 a
/// `gunnar fetch` left no `FETCH_HEAD` at all and every one of those failed with
/// *unknown revision*.
///
/// # The format, byte for byte
///
/// ```text
/// <oid>\t\tbranch 'main' of <url>
/// <oid>\tnot-for-merge\ttag 'v1' of <url>
/// ```
///
/// Three tab-separated fields: the object, the string `not-for-merge` or nothing
/// at all, and a description. The middle field being *empty* rather than absent
/// is what produces the doubled tab, and a reader that splits on `\t` depends on
/// it.
///
/// The oid is the one the remote advertised **for that ref name**, which for an
/// annotated tag is the tag object and not the commit it peels to — the same
/// distinction [`AdvertisedRef::oid`] documents, and for the same reason: `git
/// rev-parse FETCH_HEAD` after fetching a tag must name the tag.
///
/// # Which line is for merge
///
/// `not-for-merge` marks a ref that arrived because a *configured* refspec asked
/// for it. A ref named by an **explicit** refspec — `git fetch <url> <refspec>`
/// on the command line — is for merge, all of them, and git writes an empty
/// middle field for every such line.
///
/// [`fetch_with_io`] is always given its refspecs by the caller, so every ref it
/// fetches is the explicit case and no line is marked. MEASURED against git
/// 2.53.0 on 2026-08-05: the first cut of this marked everything but the current
/// branch, and git marked nothing — including an annotated tag, which is what
/// made the difference visible.
fn write_fetch_head(repo: &gix::Repository, refs: &[AdvertisedRef], url: &str) -> Result<()> {
let mut body = String::new();
for r in refs {
let (what, short) = match r.name.strip_prefix("refs/heads/") {
Some(short) => ("branch", short),
None => match r.name.strip_prefix("refs/tags/") {
Some(short) => ("tag", short),
None => ("ref", r.name.as_str()),
},
};
// The middle field is EMPTY, not absent: that is what produces the
// doubled tab, and a reader splitting on `\t` depends on it.
body.push_str(&format!("{}\t\t{} '{}' of {}\n", r.oid, what, short, url));
}
let path = repo.common_dir().join("FETCH_HEAD");
std::fs::write(&path, body).map_err(|e| Error::repo(format!("write {}", path.display()), e))?;
Ok(())
}
/// Fetch into `repo` from an SSH remote, authenticating with the key at
/// `ssh_key`.
///
/// The upload-pack channel comes from **`nornir_git::ssh::connect_upload_pack`**,
/// including its `GIT_PROTOCOL=version=2` negotiation and the `Drop` that tears
/// the channel down inside the runtime.
///
/// For `ssh-agent`, a key ladder, or a host-key policy, use
/// [`fetch_ssh_with`]. The asymmetry where only [`crate::push`] could reach the
/// agent is gone as of `nornir-git 0.1.6`.
pub fn fetch_ssh(
repo: &gix::Repository,
url: &str,
ssh_key: &Path,
refspecs: &[&str],
) -> Result<FetchOutcome> {
// Before the dial, not after: a refspec git would refuse must cost no
// connection, no authentication and no pack.
check_fetch_refspecs(refspecs)?;
let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
let mut up = nornir_git::ssh::connect_upload_pack(&loc, ssh_key).map_err(Error::Ssh)?;
let path = loc.path.clone();
let (reader, writer) = up.io();
fetch_with_io(repo, reader, writer, url, &path, refspecs)
}
/// [`fetch_ssh`] with the **same** [`SshOptions`](crate::SshOptions) a push
/// takes: `ssh-agent` first, then the key ladder, under a host-key policy.
///
/// This is the half of gap 3 that was visible from here. `nornir-git 0.1.5`'s
/// connector took a private-key *path* and nothing else, so a fetch could not use
/// the agent while a push could, an asymmetry with no reason behind it. Both
/// halves now go through the same connector and the same [`SshAuth`].
///
/// [`SshAuth`]: nornir_git::ssh::SshAuth
pub fn fetch_ssh_with(
repo: &gix::Repository,
url: &str,
opts: &crate::ssh::SshOptions,
refspecs: &[&str],
) -> Result<FetchOutcome> {
fetch_ssh_with_opts(repo, url, opts, refspecs, &FetchOptions::default())
}
/// [`fetch_ssh_with`] plus a [`FetchOptions`], which is how `gunnar fetch
/// --depth` reaches a real server.
pub fn fetch_ssh_with_opts(
repo: &gix::Repository,
url: &str,
opts: &crate::ssh::SshOptions,
refspecs: &[&str],
options: &FetchOptions,
) -> Result<FetchOutcome> {
// Before the dial, as in `fetch_ssh`: a refspec git would refuse must cost no
// connection, no authentication and no pack. Placed in the `_opts` body so
// both entry points are guarded by the one call.
check_fetch_refspecs(refspecs)?;
let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
let mut up = crate::ssh::connect_upload_pack(&loc, opts)?;
let path = loc.path.clone();
let (reader, writer) = up.io();
fetch_with_io_opts(repo, reader, writer, url, &path, refspecs, options)
}
/// Open a **bare** repository at `path`, creating it with `hash_kind` if it is
/// not there yet.
///
/// The hash kind is required rather than defaulted because a mirror's local copy
/// must match its upstream: initialising SHA-1 for a SHA-256 remote produces a
/// repository into which not one of that remote's oids can be written, and the
/// first symptom is an unrelated-looking parse failure much later.
///
/// # Why the slot table is sized here
///
/// `gix::open`'s default is `AsNeededByDiskState { multiplier: 1.1, minimum:
/// 32 }`, computed **once** from the packs on disk at open, and `gix_odb` never
/// grows the map afterwards. A mirror is exactly the case that breaks: a
/// process that opens one and then fetches into it repeatedly gains a pack per
/// fetch, and on the 33rd the next fetch dies with
///
/// ```text
/// fetch (pack transfer): The slotmap turned out to be too small with
/// 32 entries, would need 1 more
/// ```
///
/// Found by `gunnar.year` on 2026-08-04, at **week 32** of a fifty-two week
/// run, on the warm mirror the arm re-fetches every week. It is the same defect
/// as the server-side one `gunnar.endurance` hit at 33 packs
/// (`gunnar_store::odb::OdbPool::grow_slots_if_full`) and it fails **loudly**
/// here, which is the one mercy: the server's copy of it reported the objects
/// absent instead.
///
/// [`MIRROR_SLOTS`] is **headroom, not a cure**, and the difference matters:
/// gix's slot map cannot grow, so a long-lived fetcher still has a ceiling —
/// it is now a thousand fetches into one open repository rather than
/// thirty-two. A process that expects to exceed that must re-open, and
/// re-opening re-sizes the table from disk. There is no way to do better
/// through `gix::open`.
pub fn open_or_init_bare(path: &Path, hash_kind: gix::hash::Kind) -> Result<gix::Repository> {
if path.join("HEAD").exists() || path.join(".git").exists() {
let repo = gix::open_opts(path, mirror_open_options(path))
.map_err(|e| Error::repo(format!("open {}", path.display()), e))?;
if repo.object_hash() != hash_kind {
return Err(Error::repo_msg(format!(
"{} is a {:?} repository but the remote is {hash_kind:?}",
path.display(),
repo.object_hash()
)));
}
return Ok(repo);
}
std::fs::create_dir_all(path)
.map_err(|e| Error::repo(format!("create {}", path.display()), e))?;
let opts = gix::create::Options {
destination_must_be_empty: Some(false),
fs_capabilities: None,
object_hash: Some(hash_kind),
};
let mut repo = gix::ThreadSafeRepository::init_opts(
path,
gix::create::Kind::Bare,
opts,
mirror_open_options(path),
)
.map_err(|e| Error::repo(format!("init bare {}", path.display()), e))?
.to_thread_local();
// A freshly initialised repository has no identity, and gix needs a committer
// to write the reflog entries a fetch's ref updates produce. Without this the
// first mirror sync on a machine with no `~/.gitconfig` fails at the very last
// step, after the pack has already been transferred.
let _ = repo.committer_or_set_generic_fallback();
Ok(repo)
}
/// Slots for a mirror's object database. See [`open_or_init_bare`].
///
/// One thousand, because the failure it prevents is one fetch per slot and a
/// mirroring process is the only thing that reaches the number at all. The cost
/// is the slot map itself — an `ArcSwap` and a couple of atomics each, so tens
/// of kilobytes — paid once per opened repository, against a hard failure at
/// thirty-two.
const MIRROR_SLOTS: u16 = 1024;
/// `gix::open::Options` with a slot table big enough for a mirror that is
/// fetched into many times without being re-opened.
///
/// Sized from what is on disk *and* the headroom, so opening an existing
/// mirror that already holds more than [`MIRROR_SLOTS`] packs is not made worse
/// than gix's own default would have been.
fn mirror_open_options(path: &Path) -> gix::open::Options {
let on_disk = std::fs::read_dir(path.join("objects").join("pack"))
.map(|rd| {
rd.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.count()
})
.unwrap_or(0);
// `u16`, and gix refuses more than `2^15 - 1`.
let want = (on_disk.saturating_mul(2) + MIRROR_SLOTS as usize).min((1 << 15) - 1) as u16;
// `Options::default()`, not `isolated()`: the only thing being changed here
// is the slot count, and `isolated()` would also stop honouring the
// environment, which is a behaviour change wearing a bug fix's clothes.
gix::open::Options::default().object_store_slots(gix::odb::store::init::Slots::Given(want))
}
/// `e` and every source under it, `: `-joined.
fn with_sources(e: &dyn std::error::Error) -> String {
let mut s = e.to_string();
let mut at = e.source();
while let Some(x) = at {
let next = x.to_string();
if !s.contains(&next) {
s.push_str(": ");
s.push_str(&next);
}
at = x.source();
}
s
}