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