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/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
}