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/push.rs

//! `gunnar push` — refspec resolution, the fast-forward guard, and the driver
//! that ties [`crate::objects`] to [`crate::send_pack`].
//!
//! # The pack this sends carries deltas, and until 2026-08-03 it did not
//!
//! [`drive_push`] called `gunnar_pack::write_pack`, whose own doc says it
//! *"emits no deltas at all"*. That is the right shape for a store with nothing
//! to copy from, and it was the wrong shape here: a push is exactly the case
//! where the objects are loose, so no delta exists to be copied and none was
//! ever computed. MEASURED over 19 real nordisk repositories, same server, same
//! history, same wire, only the client differing — `gunnar.real_repo_clone`, and
//! `git verify-pack -v` on what the server stored:
//!
//! | client | stored | deltified |
//! |---|---|---|
//! | this one | 407.08 MiB | **0.0%** |
//! | stock git | 88.14 MiB | 74.0% |
//!
//! It is now `gunnar_pack::write_pack_deltified`, which composes gitoxide's
//! missing encoder half (`gunnar-delta`) onto the same pack writer. The
//! **applied output** is [`PushOutcome::objects_deltified`]: a byte count could
//! not tell the two writers apart, which is how this survived every green test
//! in the tree.
//!
//! The damage was not transient, either, and that is why it was worth a
//! dependency edge. The server's pack-copy faithfully copies whatever it
//! was pushed out to every later clone, so a single push from the old client
//! inflated that repository permanently.
//!
//! # A rejected push is not an error
//!
//! [`push`] returns `Ok` when the remote *answered*, even when the answer was
//! no. A non-fast-forward rejection is the protocol working, and collapsing it
//! into `Err` throws away the per-ref detail that is the only thing a user can
//! act on. Callers that want the other shape call
//! [`PushOutcome::ok_or_rejected`].
//!
//! # The fast-forward check is local and deliberately incomplete
//!
//! When the remote's current oid is present locally, this refuses a
//! non-fast-forward before a single object goes on the wire. When it is *not*
//! present locally — the remote moved somewhere the client has never fetched —
//! there is nothing to compute an ancestry from, and the check is left to the
//! remote. That gap is stated rather than papered over: a client-side guard that
//! silently passed whenever it could not evaluate would be a guard that can
//! never go red.

// `Read` is deliberately NOT imported: `wait_for_remote_to_finish` calls
// `read` on a `&mut dyn Read`, and method resolution on a trait object needs no
// import. Adding one only earns an `unused_imports` warning.
use std::io::Write;

use gix::hash::ObjectId;

use crate::error::{Error, Result};
use crate::mirror::{is_mirrored_ref, plan_ref_sync, RefChange};
use crate::objects::{plan_objects, ObjectPlan};
use crate::send_pack::{
    read_advertisement, send_pack, Advertisement, PushCommand, PushReport, SendPackOptions,
};
use crate::ssh::SshOptions;
use crate::transport::Transport;

/// One push refspec: `[+]<src>[:<dst>]`, or `:<dst>` to delete.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefSpec {
    /// A leading `+` — overwrite even when it is not a fast-forward.
    pub force: bool,
    /// The local side. `None` means "delete the destination".
    pub src: Option<String>,
    /// The remote side. `None` means "the same full ref name as the source".
    pub dst: Option<String>,
}

impl RefSpec {
    /// Parse one refspec.
    ///
    /// `main` · `main:main` · `+main:main` · `HEAD:refs/heads/main` ·
    /// `:refs/heads/gone` · `refs/tags/v1:refs/tags/v1`
    pub fn parse(spec: &str) -> Result<Self> {
        let (force, rest) = match spec.strip_prefix('+') {
            Some(r) => (true, r),
            None => (false, spec),
        };
        if rest.is_empty() {
            return Err(Error::Invalid(
                "an empty refspec pushes nothing".to_string(),
            ));
        }
        let (src, dst) = match rest.split_once(':') {
            Some((s, d)) => (
                (!s.is_empty()).then(|| s.to_string()),
                (!d.is_empty()).then(|| d.to_string()),
            ),
            None => (Some(rest.to_string()), None),
        };
        if src.is_none() && dst.is_none() {
            return Err(Error::Invalid(format!(
                "refspec {spec:?} names neither a source nor a destination"
            )));
        }
        if src.is_none() && dst.as_deref().is_some_and(|d| !d.starts_with("refs/")) {
            return Err(Error::Invalid(format!(
                "a delete refspec must name the FULL remote ref, e.g. `:refs/heads/{d}` \
                 rather than {spec:?} — a short name is ambiguous and would delete the \
                 wrong ref about as often as the right one",
                d = dst.as_deref().unwrap_or_default()
            )));
        }
        Ok(RefSpec { force, src, dst })
    }

    /// Parse many.
    pub fn parse_all<S: AsRef<str>>(specs: &[S]) -> Result<Vec<RefSpec>> {
        specs.iter().map(|s| RefSpec::parse(s.as_ref())).collect()
    }
}

/// How to push.
#[derive(Debug, Clone, Default)]
pub struct PushOptions {
    /// SSH authentication and host-key policy. Ignored by [`push_over`].
    pub ssh: SshOptions,
    /// What to negotiate on the wire.
    pub send: SendPackOptions,
    /// How hard to search for deltas before sending, and the memory ceiling on
    /// that search. Defaults are git's own `--window=10 --depth=50`.
    pub delta: gunnar_pack::DeltaOptions,
    /// Force every refspec, as though each carried a leading `+`.
    pub force: bool,
    /// Resolve and plan, but send nothing.
    pub dry_run: bool,
}

impl PushOptions {
    /// The defaults: agent auth, TOFU host keys, `side-band-64k`,
    /// `report-status-v2`, no force.
    pub fn new() -> Self {
        PushOptions {
            ssh: SshOptions::new(),
            send: SendPackOptions::default(),
            delta: gunnar_pack::DeltaOptions::default(),
            force: false,
            dry_run: false,
        }
    }
}

/// What a push did.
#[derive(Debug, Clone)]
pub struct PushOutcome {
    /// The commands actually sent (empty for a dry run or an up-to-date push).
    pub commands: Vec<PushCommand>,
    /// Refs whose remote value already equalled the local one, so no command
    /// was generated. Not a failure, and not silence: a push that reports
    /// nothing at all is indistinguishable from a push that did nothing.
    pub up_to_date: Vec<String>,
    /// The remote's report, or `None` for a dry run or an up-to-date push.
    pub report: Option<PushReport>,
    /// Objects put in the pack.
    pub objects_sent: u32,
    /// How many of `objects_sent` went out as a **computed delta** rather than
    /// a whole object.
    ///
    /// The applied output of the fix described in the module docs, carried all
    /// the way up here because that is where a caller — a bench arm, a test, an
    /// operator reading `gunnar push -v` — can see it. `pack_bytes` cannot
    /// substitute: a pack of 407 MiB is a plausible size for this history and
    /// says nothing about whether one delta was computed.
    pub objects_deltified: usize,
    /// How many of `objects_sent` were **copied** out of the source's own packs, deltas and
    /// all (no inflate, no delta search): a PACKED source (a mirror, a `git gc`ed clone) is
    /// sent the way stock git sends it. Turn 2026-10-12: the kit's seed recomputed every
    /// delta of workspace_nordisk (78 MB) — 23 s optimised, 243 s in a dev build — where
    /// `git push` reused them in 7 s.
    pub objects_copied: usize,
    /// Bytes of packfile written to the wire.
    pub pack_bytes: u64,
    /// Nothing was sent.
    pub dry_run: bool,
}

impl PushOutcome {
    /// True when the remote accepted everything, or when there was nothing to
    /// do.
    pub fn is_ok(&self) -> bool {
        self.report.as_ref().map(PushReport::is_ok).unwrap_or(true)
    }

    /// Turn a rejection into an error, for callers that want that shape.
    pub fn ok_or_rejected(self) -> Result<Self> {
        match self.report.as_ref().and_then(PushReport::failure_summary) {
            Some(why) => Err(Error::Rejected(why)),
            None => Ok(self),
        }
    }
}

/// Resolve refspecs against the local repository and the remote's advertisement.
///
/// Returns the command list plus the names that were already up to date.
pub fn resolve_commands(
    repo: &gix::Repository,
    adv: &Advertisement,
    specs: &[RefSpec],
    force_all: bool,
) -> Result<(Vec<PushCommand>, Vec<String>)> {
    let mut commands = Vec::new();
    let mut up_to_date = Vec::new();

    for spec in specs {
        let force = force_all || spec.force;
        let Some(src) = spec.src.as_deref() else {
            // Delete.
            let dst = spec
                .dst
                .as_deref()
                .expect("RefSpec::parse rejects a spec with neither side");
            if !adv.has_ref(dst) {
                return Err(Error::Invalid(format!(
                    "cannot delete {dst}: the remote does not have it"
                )));
            }
            commands.push(PushCommand::delete(dst, adv.oid_of(dst)));
            continue;
        };

        let (src_full, new) = resolve_source(repo, src)?;
        let dst = match spec.dst.as_deref() {
            Some(d) => full_ref_name(d)?,
            None => src_full.clone().ok_or_else(|| {
                Error::Invalid(format!(
                    "refspec {src:?} resolves to an object rather than a ref, so the \
                     destination cannot be inferred; write it explicitly as \
                     `{src}:refs/heads/<name>`"
                ))
            })?,
        };

        let old = adv.oid_of(&dst);
        if old == new {
            up_to_date.push(dst);
            continue;
        }
        if !force && !old.is_null() {
            check_fast_forward(repo, &dst, old, new)?;
        }
        commands.push(PushCommand {
            name: dst.into(),
            old,
            new,
        });
    }

    Ok((commands, up_to_date))
}

/// Resolve the local side of a refspec to `(full ref name, oid)`.
///
/// The ref name is `None` when the source was a raw revision (`HEAD~2`, an oid)
/// rather than a ref, which is exactly the case where a destination cannot be
/// inferred.
fn resolve_source(repo: &gix::Repository, src: &str) -> Result<(Option<String>, ObjectId)> {
    if repo.find_reference(src).is_ok() {
        let (name, id) = follow_symrefs(repo, src)?;
        return Ok((Some(name), id));
    }
    // `HEAD` on an unborn branch has no object, and pushing it is a real
    // mistake with a confusing default error. Name it.
    if src == "HEAD" && repo.head_id().is_err() {
        return Err(Error::Invalid(
            "HEAD is unborn — there is no commit to push yet".to_string(),
        ));
    }
    let id = repo
        .rev_parse_single(src)
        .map_err(|e| Error::repo(format!("resolve {src:?}"), e))?
        .detach();
    Ok((None, id))
}

/// Resolve a ref to `(full name, oid)`, following **symbolic** refs but **not
/// peeling tag objects**.
///
/// This distinction is the whole of it. `Reference::peel_to_id` peels an
/// annotated tag down to the commit it names — which is right for `git log` and
/// catastrophically wrong for a push: `refs/tags/v1.0:refs/tags/v1.0` would then
/// put the *commit's* oid on the remote's tag ref, and the remote would end up
/// with a tag that is not a tag. Nothing about the push fails; the tag simply
/// arrives as a lightweight one, and `git fsck` on the source and the copy both
/// pass. It was caught by comparing `rev-parse refs/tags/v1.0` across the two
/// repositories, which is the only assertion that can see it.
///
/// Following symrefs, by contrast, IS wanted: `git push origin HEAD` pushes to
/// the branch `HEAD` points at, which is also why the *name* returned is the one
/// followed to rather than `HEAD`.
fn follow_symrefs(repo: &gix::Repository, start: &str) -> Result<(String, ObjectId)> {
    use gix::refs::TargetRef;

    let mut name = start.to_string();
    for _ in 0..8 {
        let r = repo
            .find_reference(name.as_str())
            .map_err(|e| Error::repo(format!("find {name}"), e))?;
        let full = r.name().as_bstr().to_string();
        match r.target() {
            TargetRef::Object(id) => return Ok((full, id.to_owned())),
            TargetRef::Symbolic(next) => name = next.as_bstr().to_string(),
        }
    }
    Err(Error::Invalid(format!(
        "{start} is a chain of symbolic refs more than 8 deep"
    )))
}

/// Expand a short destination to a full ref name the way git does.
fn full_ref_name(dst: &str) -> Result<String> {
    if dst.starts_with("refs/") {
        return Ok(dst.to_string());
    }
    if dst.contains(' ') || dst.contains('\0') || dst.contains('\n') {
        return Err(Error::Invalid(format!("{dst:?} is not a usable ref name")));
    }
    Ok(format!("refs/heads/{dst}"))
}

/// Refuse a non-fast-forward we can prove is one.
fn check_fast_forward(
    repo: &gix::Repository,
    name: &str,
    old: ObjectId,
    new: ObjectId,
) -> Result<()> {
    use gix::objs::Exists;
    if !repo.objects.exists(&old) {
        // Not evaluable. Say so in the log rather than passing silently — this
        // is the branch that would otherwise make the guard look green when it
        // simply did not run.
        tracing::debug!(
            ref_name = name,
            %old,
            "remote tip is not present locally; leaving the fast-forward check to the remote"
        );
        return Ok(());
    }
    // gix 0.90: no common ancestor is `None` rather than an error; unrelated
    // histories are no fast-forward either.
    let Some(base) = repo
        .merge_base(old, new)
        .map_err(|e| Error::repo(format!("merge-base for {name}"), e))?
    else {
        return Err(Error::Invalid(format!(
            "{name}: pushing {new} over {old} is not a fast-forward \
             (they share no history); use a `+` refspec or `--force` to overwrite"
        )));
    };
    let base = base.detach();
    if base != old {
        return Err(Error::Invalid(format!(
            "{name}: pushing {new} over {old} is not a fast-forward \
             (their merge base is {base}); use a `+` refspec or `--force` to overwrite"
        )));
    }
    Ok(())
}

// ── the driver ──────────────────────────────────────────────────────────────

/// Push over an already-open transport running `git-receive-pack`.
///
/// This is the whole of push with the SSH connection lifted out, which is what
/// makes it provable against the real `git receive-pack` binary over a pair of
/// pipes — no sshd, no network (`tests/real_git_push.rs`).
pub fn push_over<T: Transport>(
    repo: &gix::Repository,
    transport: &mut T,
    specs: &[RefSpec],
    opts: &PushOptions,
) -> Result<PushOutcome> {
    let adv = read_and_check_advertisement(repo, transport)?;
    let (commands, up_to_date) = resolve_commands(repo, &adv, specs, opts.force)?;
    drive_push(repo, transport, &adv, commands, up_to_date, opts)
}

/// **Mirror-push** over an already-open `git-receive-pack`: make the remote's
/// `refs/heads/*` and `refs/tags/*` equal this repository's, including the
/// deletions.
///
/// This is what replication sends, and it is deliberately not
/// expressible as a refspec list: [`RefSpec`] resolves one *named* ref at a
/// time, so "everything, and delete what I no longer have" would need a glob
/// language on one side and a second `ls-remote` on the other. The command list
/// is computed instead from the advertisement that `receive-pack` has already
/// sent — one connection, one round trip, and the deletions come from the same
/// comparison as the updates.
///
/// The comparison itself is [`plan_ref_sync`], **reused rather than twinned**
/// (one implementation). Note the argument order: that function converges its *first*
/// argument onto its second, and here the side being converged is the remote.
/// `gunnar mirror` calls it the other way round, which is the whole difference
/// between a pull-mirror and a push-mirror.
///
/// Every update is sent as `old → new` with the `old` the remote advertised, so
/// a compare-and-swap on the far side still refuses a racing writer. A remote
/// tip that is not an ancestor of ours is sent anyway: a mirror's job is to
/// reproduce this repository, force-pushes included, and the far end's own
/// branch protection is what decides whether it may.
pub fn push_mirror_over<T: Transport>(
    repo: &gix::Repository,
    transport: &mut T,
    opts: &PushOptions,
) -> Result<PushOutcome> {
    let adv = read_and_check_advertisement(repo, transport)?;
    let (commands, up_to_date) = converge_commands(repo, &adv)?;
    drive_push(repo, transport, &adv, commands, up_to_date, opts)
}

/// [`push_mirror_over`] to an SSH remote.
pub fn push_mirror(repo: &gix::Repository, url: &str, opts: &PushOptions) -> Result<PushOutcome> {
    let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
    let mut channel = crate::ssh::connect_receive_pack(&loc, &opts.ssh)?;
    tracing::debug!(
        url,
        authenticated_with = %channel.authenticated_with,
        "opened git-receive-pack for a mirror push"
    );
    push_mirror_over(repo, &mut channel, opts)
}

/// Read the ref advertisement and refuse a hash-kind mismatch.
fn read_and_check_advertisement<T: Transport>(
    repo: &gix::Repository,
    transport: &mut T,
) -> Result<Advertisement> {
    let adv = {
        let (reader, _) = transport.io();
        read_advertisement(reader)?
    };
    let local_kind = repo.object_hash();
    if local_kind != adv.hash_kind {
        return Err(Error::Protocol(format!(
            "local repository is {local_kind:?} but the remote is {:?}; \
             git cannot push between object formats",
            adv.hash_kind
        )));
    }
    Ok(adv)
}

/// This repository's mirrored refs as `(full name, oid)`.
///
/// Symbolic references are skipped rather than followed: the only one a bare
/// repository normally has is `HEAD`, which [`is_mirrored_ref`] already excludes,
/// and a symref under `refs/heads/` is not a thing to copy onto a mirror as an
/// object id. Tag objects are **not** peeled — pushing the commit an annotated
/// tag names would silently turn it into a lightweight tag on the mirror, and
/// both copies would still pass `git fsck` (see [`follow_symrefs`]).
pub fn mirrored_refs(repo: &gix::Repository) -> Result<Vec<(String, ObjectId)>> {
    use gix::refs::TargetRef;

    let platform = repo
        .references()
        .map_err(|e| Error::repo("open the reference database", e))?;
    let iter = platform
        .all()
        .map_err(|e| Error::repo("iterate references", e))?;
    let mut out = Vec::new();
    for reference in iter {
        let reference = reference.map_err(|e| Error::repo("read a reference", e))?;
        let name = reference.name().as_bstr().to_string();
        if !is_mirrored_ref(&name) {
            continue;
        }
        if let TargetRef::Object(id) = reference.target() {
            out.push((name, id.to_owned()));
        }
    }
    out.sort();
    Ok(out)
}

/// The command list that makes `adv`'s mirrored refs equal this repository's.
fn converge_commands(
    repo: &gix::Repository,
    adv: &Advertisement,
) -> Result<(Vec<PushCommand>, Vec<String>)> {
    let ours = mirrored_refs(repo)?;
    let theirs: Vec<(String, ObjectId)> = adv
        .refs
        .iter()
        // The advertisement carries reference names as BYTES, because git's do
        // not have to be UTF-8 (`gunnar_sendpack::command`). A mirror plan is
        // computed over `String`, so a remote reference whose name is not UTF-8
        // is SKIPPED here rather than lossily converted: converting would let
        // two distinct remote names collapse onto one plan entry, and the
        // mirror would then delete whichever it did not pick.
        .filter_map(|r| {
            let name = std::str::from_utf8(r.name.as_slice()).ok()?;
            is_mirrored_ref(name).then(|| (name.to_owned(), r.oid))
        })
        .collect();

    let commands = plan_ref_sync(&theirs, &ours)
        .into_iter()
        .map(|change| match change {
            RefChange::Created { name, new } => PushCommand::create(name, new),
            RefChange::Updated { name, old, new } => PushCommand::update(name, old, new),
            RefChange::Deleted { name, old } => PushCommand::delete(name, old),
        })
        .collect::<Vec<_>>();

    // Named explicitly, so a mirror that is already current reports "these N
    // refs were already there" rather than an empty outcome indistinguishable
    // from one that computed nothing.
    let moving: std::collections::BTreeSet<&[u8]> =
        commands.iter().map(|c| c.name.as_slice()).collect();
    let up_to_date = ours
        .iter()
        .map(|(name, _)| name.clone())
        .filter(|name| !moving.contains(name.as_bytes()))
        .collect();

    Ok((commands, up_to_date))
}

/// Plan the objects, write the pack and read the report — the half of a push
/// that does not care where the command list came from.
fn drive_push<T: Transport>(
    repo: &gix::Repository,
    transport: &mut T,
    adv: &Advertisement,
    commands: Vec<PushCommand>,
    up_to_date: Vec<String>,
    opts: &PushOptions,
) -> Result<PushOutcome> {
    let local_kind = repo.object_hash();

    if commands.is_empty() {
        // The remote is waiting for a command list. A flush-pkt is how a client
        // says "never mind" — closing the channel instead makes receive-pack log
        // a broken pipe for a push that was simply unnecessary.
        {
            let (_, mut writer) = transport.io();
            gunnar_pktline::write_flush(&mut writer)?;
            writer.flush()?;
        }
        wait_for_remote_to_finish(transport);
        return Ok(PushOutcome {
            commands,
            up_to_date,
            report: None,
            objects_sent: 0,
            objects_deltified: 0,
            objects_copied: 0,
            pack_bytes: 0,
            dry_run: opts.dry_run,
        });
    }

    let all_deletes = commands.iter().all(PushCommand::is_delete);
    let plan: ObjectPlan = if all_deletes {
        ObjectPlan::default()
    } else {
        let wants: Vec<ObjectId> = commands
            .iter()
            .filter(|c| !c.is_delete())
            .map(|c| c.new)
            .collect();
        // Everything the remote advertised is a `have`. That is the whole of
        // what a v0 push knows about the far end — there is no negotiation
        // round in receive-pack.
        let haves: Vec<ObjectId> = adv.refs.iter().map(|r| r.oid).collect();
        plan_objects(repo, &wants, &haves)?
    };

    if opts.dry_run {
        {
            let (_, mut writer) = transport.io();
            gunnar_pktline::write_flush(&mut writer)?;
            writer.flush()?;
        }
        wait_for_remote_to_finish(transport);
        return Ok(PushOutcome {
            commands,
            up_to_date,
            report: None,
            objects_sent: plan.ids.len() as u32,
            // A dry run computes no deltas, because it writes no pack. Zero
            // here is "nothing was done", not "nothing was found".
            objects_deltified: 0,
            objects_copied: 0,
            pack_bytes: 0,
            dry_run: true,
        });
    }

    let mut pack_bytes: u64 = 0;
    let mut objects_sent: u32 = 0;
    let mut objects_deltified: usize = 0;
    let mut objects_copied: usize = 0;
    // The entry header comes from the same predicate the capability line does
    // (`gunnar_sendpack::options::ofs_delta_agreed`), rather than from
    // `WriteOptions`' `true` default. Before 2026-08-05 this was
    // `WriteOptions::new(local_kind)` and the client wrote `OBJ_OFS_DELTA`
    // entries into every push while never sending `ofs-delta` — an encoding
    // used that the wire never declared. `write_pack_deltified` already
    // honoured the field (`delta.rs`, the `OBJ_REF_DELTA` arm); nothing ever
    // set it.
    let write_opts = gunnar_pack::WriteOptions::new(local_kind)
        .ofs_delta(gunnar_sendpack::options::ofs_delta_agreed(adv));
    let report = {
        let pack_fn = (!all_deletes).then_some(|w: &mut dyn Write| -> Result<()> {
            let mut counting = CountingWriter { inner: w, bytes: 0 };
            // A PACKED source (a mirror, a gc'ed clone): its entries are copied, deltas and
            // all, as stock git does — computing them again cost the seed 3x git's time.
            if mostly_packed(repo, plan.ids.len()) {
                let outcome = gunnar_pack::write_pack_copy(
                    &plan.ids,
                    &packed_objects(repo),
                    &mut counting,
                    &write_opts,
                )?;
                pack_bytes = counting.bytes;
                objects_sent = outcome.num_objects;
                objects_copied = outcome.objects_copied_from_pack;
                return Ok(());
            }
            // `write_pack_deltified`, NOT `write_pack`. See the module docs.
            let outcome = gunnar_pack::write_pack_deltified(
                &plan.ids,
                // `repo` is a gix-0.87 value; `gunnar-pack` speaks gix-object
                // 0.63. See `pack_source` for why the bridge is here and not a
                // version bump one layer down.
                &crate::pack_source::PlumbingObjects(repo),
                &mut counting,
                &write_opts,
                &opts.delta,
            )?;
            pack_bytes = counting.bytes;
            objects_sent = outcome.num_objects;
            objects_deltified = outcome.objects_deltified;
            Ok(())
        });
        send_pack(transport, adv, &commands, local_kind, pack_fn, &opts.send)?
    };

    // The report is parsed; the remote has not necessarily finished. See the
    // function's docs — this is what stops the client's channel close from
    // racing the server's `exit-status`.
    wait_for_remote_to_finish(transport);

    Ok(PushOutcome {
        commands,
        up_to_date,
        report: Some(report),
        objects_sent,
        objects_deltified,
        objects_copied,
        pack_bytes,
        dry_run: false,
    })
}

/// **Is the source PACKED?** At least one pack, and no more loose objects than a tenth of
/// what this push sends: then copying its packed entries (git's deltas) beats computing new
/// ones, and the few loose objects go whole. A source of mostly loose objects (a fresh
/// working clone) keeps the computed deltas the module docs are about.
fn mostly_packed(repo: &gix::Repository, sending: usize) -> bool {
    let objects = repo.objects.store_ref().path().to_path_buf();
    let packs = std::fs::read_dir(objects.join("pack"))
        .map(|d| {
            d.flatten()
                .any(|e| e.path().extension().is_some_and(|x| x == "pack"))
        })
        .unwrap_or(false);
    if !packs {
        return false;
    }
    let mut loose = 0usize;
    for d in std::fs::read_dir(&objects).into_iter().flatten().flatten() {
        let name = d.file_name();
        let n = name.to_string_lossy();
        if n.len() == 2 && n.bytes().all(|b| b.is_ascii_hexdigit()) {
            loose += std::fs::read_dir(d.path()).map(|f| f.count()).unwrap_or(0);
            if loose * 10 > sending {
                return false;
            }
        }
    }
    true
}

/// The source's packs as `gunnar-pack` copies out of them: a fresh pack-aware handle per
/// call, its packs kept mapped (`entry_by_location` hands out slices into them).
fn packed_objects(repo: &gix::Repository) -> gunnar_pack::PackedObjects {
    let store = repo.objects.store();
    gunnar_pack::PackedObjects::new(move || {
        let mut handle = store.to_handle_arc();
        handle.prevent_pack_unload();
        Box::new(handle) as Box<dyn gix::odb::pack::Find + Send>
    })
}

/// Push to an SSH remote.
///
/// `url` is `git@host:ns/repo.git` or `ssh://[user@]host[:port]/path` — the two
/// forms `nornir_git::ssh::parse_ssh_url` accepts, reused rather than reparsed.
pub fn push(
    repo: &gix::Repository,
    url: &str,
    specs: &[RefSpec],
    opts: &PushOptions,
) -> Result<PushOutcome> {
    let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
    let mut channel = crate::ssh::connect_receive_pack(&loc, &opts.ssh)?;
    tracing::debug!(
        url,
        authenticated_with = %channel.authenticated_with,
        "opened git-receive-pack"
    );
    push_over(repo, &mut channel, specs, opts)
}

/// How many stray bytes a remote may still send after its report before this
/// stops waiting for it.
///
/// A well-behaved `receive-pack` sends nothing at all here; the read below
/// returns 0 on the first call. The cap exists so that a remote which keeps
/// talking cannot hold a push open forever, and 64 KiB is one sideband
/// pkt-line's worth — enough for a late band-2 progress line, far short of
/// anything that could be a pack.
const TRAILING_BYTES_TOLERATED: u64 = 64 * 1024;

/// **Wait for the remote to finish before letting the channel go.**
///
/// The last thing `git-receive-pack` does after its report is exit, and the
/// last thing the SSH server does is send `exit-status` and then close the
/// channel. Returning from a push the instant the report has been parsed races
/// those two messages: the client's own `Close` (russh spawns it from `Drop`)
/// can reach the server first, and the server's `exit_status` write then fails.
///
/// MEASURED 2026-08-03: **3 of 19** `gunnar-client` pushes into a real
/// `gunnar serve` logged *"the client was gone before the exit status"*, with
/// `status=0` — a push that fully succeeded, reported by the server as one whose
/// result it could not deliver. Benign today because nothing reads that status;
/// not benign the day something does, and not a thing to leave in a race.
///
/// Stock git does not have the bug because it does not have the shape: `git
/// push` waits for its `ssh` subprocess to exit, and `ssh` does not exit until
/// it has seen `exit-status`. Reading to EOF is that same wait, expressed
/// through the only thing this crate has — a blocking reader. russh delivers
/// `Eof`/`Close` as a zero-length read (`ChannelRx::poll_read`), so this returns
/// exactly when the far end is done and not before.
///
/// It is deliberately **infallible and quiet**. Every byte that decides whether
/// the push worked has already been read and parsed; an error here is
/// information about the teardown, not about the push, and turning a successful
/// push into an `Err` because the connection dropped a millisecond early would
/// be a worse bug than the one this fixes.
fn wait_for_remote_to_finish<T: Transport>(transport: &mut T) {
    let (reader, _) = transport.io();
    let mut scratch = [0u8; 4096];
    let mut seen: u64 = 0;
    loop {
        match reader.read(&mut scratch) {
            Ok(0) => return,
            Ok(n) => {
                seen += n as u64;
                if seen > TRAILING_BYTES_TOLERATED {
                    tracing::debug!(
                        trailing_bytes = seen,
                        "the remote kept sending after its report; not waiting for its exit"
                    );
                    return;
                }
                tracing::trace!(bytes = n, "discarding bytes sent after the report");
            }
            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
            Err(err) => {
                tracing::debug!(%err, "the connection ended while waiting for the remote to finish");
                return;
            }
        }
    }
}

/// Counts what passes through, so the outcome can report pack size without
/// buffering the pack in memory.
struct CountingWriter<'a> {
    inner: &'a mut dyn Write,
    bytes: u64,
}

impl Write for CountingWriter<'_> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        let n = self.inner.write(buf)?;
        self.bytes += n as u64;
        Ok(n)
    }
    fn flush(&mut self) -> std::io::Result<()> {
        self.inner.flush()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn refspec_forms() {
        let s = RefSpec::parse("main").unwrap();
        assert_eq!(s.src.as_deref(), Some("main"));
        assert_eq!(s.dst, None);
        assert!(!s.force);

        let s = RefSpec::parse("+HEAD:refs/heads/main").unwrap();
        assert!(s.force);
        assert_eq!(s.src.as_deref(), Some("HEAD"));
        assert_eq!(s.dst.as_deref(), Some("refs/heads/main"));

        let s = RefSpec::parse(":refs/heads/gone").unwrap();
        assert_eq!(s.src, None);
        assert_eq!(s.dst.as_deref(), Some("refs/heads/gone"));
    }

    /// A delete written with a short name is refused. `:main` is not "delete the
    /// branch main" to git either, and guessing would delete `refs/heads/main`
    /// on a remote where the user meant `refs/tags/main` about as often as not.
    #[test]
    fn a_short_delete_refspec_is_refused() {
        assert!(RefSpec::parse(":main").is_err());
        assert!(RefSpec::parse(":refs/heads/main").is_ok());
    }

    #[test]
    fn an_empty_refspec_is_refused() {
        assert!(RefSpec::parse("").is_err());
        assert!(RefSpec::parse("+").is_err());
        assert!(RefSpec::parse(":").is_err());
    }

    #[test]
    fn short_destinations_expand_to_refs_heads() {
        assert_eq!(full_ref_name("main").unwrap(), "refs/heads/main");
        assert_eq!(full_ref_name("refs/tags/v1").unwrap(), "refs/tags/v1");
        assert!(full_ref_name("has a space").is_err());
    }
}