use std::collections::{HashMap, HashSet};
use std::io::{Cursor, ErrorKind, Read, Write};
use std::path::Path;
use crate::branch_tracking;
use crate::environment::RepositoryOptions;
use crate::error::{Error, Result};
use crate::fetch::Progress;
use crate::objects::{HashAlgo, ObjectId};
use crate::pack_objects::PackStats;
use crate::pkt_line::{self, Packet};
use crate::progress::{poll_cancel, Cancel, ProgressPhase, ProgressUnit, ReceivedObjects};
use crate::push_report::{PushRefResult, PushRefStatus};
use crate::repo::Repository;
use crate::sideband_lines::{RemoteLine, SidebandLines};
use crate::transfer::{PackBuildOptions, PrePushGate, PushOptions, PushOutcome, PushRefSpec};
use crate::transport::Connection;
fn finish_push_outcome(
local: &Repository,
opts: &PushOptions,
results: Vec<PushRefResult>,
remote_messages: Vec<String>,
) -> Result<PushOutcome> {
if !opts.dry_run {
if let Some(remote) = opts.tracking_remote.as_deref() {
branch_tracking::apply_push_remote_tracking_updates_in(local, remote, &results)?;
}
}
Ok(PushOutcome {
results,
remote_messages,
})
}
struct PushSidebandCollector<'a> {
inner: &'a mut dyn Progress,
remote_messages: Vec<String>,
pending: SidebandLines,
}
impl PushSidebandCollector<'_> {
fn record_remote_chunk(&mut self, bytes: &[u8]) {
let mut lines = Vec::new();
self.pending.feed(bytes, |line| lines.push(line));
for line in lines {
self.record_remote_line(line);
}
}
fn record_remote_line(&mut self, line: RemoteLine) {
if let RemoteLine::Line(text) = line {
if !text.is_empty() {
self.remote_messages.push(text);
}
}
}
fn finish_pending(&mut self) {
let mut lines = Vec::new();
self.pending.finish(|line| lines.push(line));
for line in lines {
self.record_remote_line(line);
}
}
}
impl crate::progress::ProgressSink for PushSidebandCollector<'_> {
fn start(&mut self, phase: ProgressPhase, total: Option<u64>) {
self.inner.start(phase, total);
}
fn start_with_unit(&mut self, phase: ProgressPhase, unit: ProgressUnit, total: Option<u64>) {
self.inner.start_with_unit(phase, unit, total);
}
fn inc(&mut self, units: u64) {
self.inner.inc(units);
}
fn set(&mut self, current: u64) {
self.inner.set(current);
}
fn finish(&mut self) {
self.inner.finish();
}
fn should_cancel(&self) -> bool {
self.inner.should_cancel()
}
}
impl Progress for PushSidebandCollector<'_> {
fn message(&mut self, bytes: &[u8]) {
self.inner.message(bytes);
self.record_remote_chunk(bytes);
}
fn remote_sideband_progress(&self) -> bool {
self.inner.remote_sideband_progress()
}
fn wants_received_objects(&self) -> bool {
self.inner.wants_received_objects()
}
fn received_objects(&mut self, counts: ReceivedObjects) {
self.inner.received_objects(counts);
}
}
const PUSH_CAPS_BASE: &str = "report-status report-status-v2 quiet";
pub fn push_remote(
local_git_dir: &Path,
conn: &mut dyn Connection,
refs: &[PushRefSpec],
opts: &PushOptions,
progress: &mut dyn Progress,
cancel: &dyn Cancel,
) -> Result<PushOutcome> {
let local = Repository::open(local_git_dir, &RepositoryOptions::empty())?;
push_remote_from(&local, conn, refs, opts, progress, cancel, None)
}
pub(crate) fn push_remote_from(
local: &Repository,
conn: &mut dyn Connection,
refs: &[PushRefSpec],
opts: &PushOptions,
progress: &mut dyn Progress,
cancel: &dyn Cancel,
pre_push: Option<&PrePushGate<'_>>,
) -> Result<PushOutcome> {
push_remote_with(local, conn, refs, opts, progress, cancel, None, pre_push)
}
pub(crate) struct BundlePushTarget<'a> {
pub(crate) client: &'a dyn crate::transport::http::HttpClient,
pub(crate) repo_url: &'a str,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn push_remote_with(
local: &Repository,
conn: &mut dyn Connection,
refs: &[PushRefSpec],
opts: &PushOptions,
progress: &mut dyn Progress,
cancel: &dyn Cancel,
bundle_target: Option<BundlePushTarget<'_>>,
pre_push: Option<&PrePushGate<'_>>,
) -> Result<PushOutcome> {
crate::trace::trace_span!("grit.push");
let local_git_dir = local.git_dir();
let mut sideband = PushSidebandCollector {
inner: progress,
remote_messages: Vec::new(),
pending: SidebandLines::new(),
};
poll_cancel(&mut sideband, cancel)?;
use crate::net_trace::net_trace;
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: begin — {} ref update(s), protocol v{}, {} push-option(s)",
refs.len(),
conn.protocol_version(),
opts.push_options.len()
);
if conn.protocol_version() >= 2 {
return Err(Error::Message(
"push_remote: protocol v2 not supported in this phase (use v0/v1)".to_owned(),
));
}
let algo = local.odb().hash_algo();
let adv = AdvertisedState::from_connection(conn);
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: remote advertised {} ref(s)",
adv.remote_refs.len()
);
require_push_capabilities(&adv, opts)?;
let planned = plan_push(refs, local, &adv, opts, pre_push);
if planned.is_err() && !opts.dry_run {
let _ = crate::transport::send_client_flush(conn);
}
let mut plan = match planned? {
PlanOutcome::Send(plan) => plan,
PlanOutcome::Done(results) => {
if !opts.dry_run {
crate::transport::send_client_flush(conn)?;
}
return finish_push_outcome(local, opts, results, Vec::new());
}
};
let commands = build_command_block(&plan, &adv, algo, opts)?;
let sent_refs: Vec<&str> = plan
.to_send
.iter()
.map(|&i| plan.decisions[i].result.remote_ref.as_str())
.collect();
let bundle_route = match (&bundle_target, adv.push_bundle.as_deref()) {
(Some(target), Some(endpoint)) if !opts.dry_run => {
crate::push_bundle::resolve_endpoint(target.repo_url, endpoint)
.map(|endpoint| (target, endpoint))
}
_ => None,
};
let pack = PushPack::for_plan(&plan, local, &adv)?;
let mut spooled_pack = None;
if let Some((target, endpoint)) = &bundle_route {
let session =
crate::push_bundle::Session::locate(local_git_dir, target.repo_url, &commands);
let ready = match &pack {
_ if session.has_bundle() => true,
None => false,
Some(pack) => {
let threshold = opts
.resumable_threshold
.unwrap_or(crate::push_bundle::DEFAULT_THRESHOLD);
let tips: Vec<(ObjectId, String)> = plan
.to_send
.iter()
.filter_map(|&i| {
let d = &plan.decisions[i];
Some((d.new_tip?, d.result.remote_ref.clone()))
})
.collect();
let mut haves: Vec<ObjectId> = adv.remote_refs.values().copied().collect();
haves.extend_from_slice(&adv.advertised_haves);
match session.write_bundle(local.odb(), &haves, &tips, threshold, |w| {
pack.write_to(local, w).map(drop)
})? {
crate::push_bundle::Spooled::Bundle => true,
crate::push_bundle::Spooled::Small(small) => {
spooled_pack = Some(small);
false
}
}
}
};
if ready {
let raw = crate::push_bundle::push(
target.client,
&session,
endpoint,
&commands,
&sent_refs,
adv.server_sideband,
&mut sideband,
)?;
let report = if adv.server_sideband {
demux_report_and_remote_messages(&raw, &mut sideband)?
} else {
raw
};
apply_report_status(&report, &mut plan.decisions);
let results: Vec<_> = plan.decisions.into_iter().map(|d| d.result).collect();
sideband.finish_pending();
return finish_push_outcome(local, opts, results, sideband.remote_messages);
}
}
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: sending {} command(s)…",
plan.decisions.len()
);
conn.writer().write_all(&commands)?;
conn.writer().flush()?;
if let Some(pack) = &pack {
poll_cancel(&mut sideband, cancel)?;
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: sending pack…"
);
let mut out = std::io::BufWriter::with_capacity(PACK_SEND_BUFFER, conn.writer());
let sent = match spooled_pack.take() {
Some(spooled) => spooled.copy_to(&mut out)?,
None => pack.write_to(local, &mut out)?.pack_bytes,
};
out.flush()?;
drop(out);
conn.writer().flush()?;
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: sent pack ({sent} bytes)"
);
} else {
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: no pack (deletion-only / up-to-date)"
);
}
let raw = read_receive_pack_response(conn.reader(), &sent_refs, adv.server_sideband)?;
let report = if adv.server_sideband {
demux_report_and_remote_messages(&raw, &mut sideband)?
} else {
raw
};
apply_report_status(&report, &mut plan.decisions);
let results: Vec<_> = plan.decisions.into_iter().map(|d| d.result).collect();
sideband.finish_pending();
let remote_messages = sideband.remote_messages;
net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"push_remote: done — {} result(s)",
results.len()
);
finish_push_outcome(local, opts, results, remote_messages)
}
pub fn push_http(
client: std::sync::Arc<dyn crate::transport::http::HttpClient>,
local_git_dir: &Path,
repo_url: &str,
refs: &[PushRefSpec],
opts: &PushOptions,
progress: &mut dyn Progress,
cancel: &dyn Cancel,
) -> Result<PushOutcome> {
use crate::transport::http::SmartHttpTransport;
use crate::transport::{ConnectOptions, Service, Transport};
let connect = ConnectOptions {
protocol_version: 0,
diagnostics: opts.diagnostics.clone(),
network_trace: opts.network_trace,
..Default::default()
};
let transport = SmartHttpTransport::new(client);
let mut conn = transport.connect(repo_url, Service::ReceivePack, &connect)?;
push_remote(local_git_dir, &mut *conn, refs, opts, progress, cancel)
}
struct AdvertisedState {
remote_refs: HashMap<String, ObjectId>,
advertised_haves: Vec<ObjectId>,
server_sideband: bool,
server_ofs_delta: bool,
server_push_options: bool,
server_atomic: bool,
push_bundle: Option<String>,
}
impl AdvertisedState {
fn from_connection(conn: &mut dyn Connection) -> Self {
let mut remote_refs: HashMap<String, ObjectId> = HashMap::new();
let mut advertised_haves: Vec<ObjectId> = Vec::new();
for (name, oid) in conn.advertised_refs() {
if name == ".have" {
advertised_haves.push(*oid);
} else {
remote_refs.insert(name.clone(), *oid);
}
}
let caps = conn.capabilities();
Self {
remote_refs,
advertised_haves,
server_sideband: caps
.iter()
.any(|c| c == "side-band-64k" || c == "side-band"),
server_ofs_delta: caps.iter().any(|c| c == "ofs-delta"),
server_push_options: caps.iter().any(|c| c == "push-options"),
server_atomic: caps.iter().any(|c| c == "atomic"),
push_bundle: caps.iter().find_map(|c| {
c.strip_prefix(crate::serve::PUSH_BUNDLE_CAPABILITY)?
.strip_prefix('=')
.map(str::to_owned)
}),
}
}
}
struct PushPlan {
decisions: Vec<PushDecision>,
to_send: Vec<usize>,
}
enum PlanOutcome {
Send(PushPlan),
Done(Vec<PushRefResult>),
}
fn plan_push(
refs: &[PushRefSpec],
local: &Repository,
adv: &AdvertisedState,
opts: &PushOptions,
pre_push: Option<&PrePushGate<'_>>,
) -> Result<PlanOutcome> {
let mut decisions: Vec<PushDecision> = Vec::with_capacity(refs.len());
for spec in refs {
decisions.push(decide_push_wire(spec, local, &adv.remote_refs)?);
}
if let Some(gate) = pre_push {
gate.check(refs.iter().zip(decisions.iter().map(|d| &d.result)))?;
}
let any_rejected = decisions.iter().any(|d| d.result.status.is_error());
if opts.atomic && any_rejected {
for d in &mut decisions {
if matches!(d.result.status, PushRefStatus::Ok) {
d.result.status = PushRefStatus::AtomicPushFailed;
d.send = false;
}
}
return Ok(PlanOutcome::Done(
decisions.into_iter().map(|d| d.result).collect(),
));
}
let to_send: Vec<usize> = decisions
.iter()
.enumerate()
.filter_map(|(i, d)| if d.send { Some(i) } else { None })
.collect();
if to_send.is_empty() || opts.dry_run {
return Ok(PlanOutcome::Done(
decisions.into_iter().map(|d| d.result).collect(),
));
}
Ok(PlanOutcome::Send(PushPlan { decisions, to_send }))
}
fn require_push_capabilities(adv: &AdvertisedState, opts: &PushOptions) -> Result<()> {
if opts.atomic && !adv.server_atomic {
return Err(Error::PushAtomicUnsupported);
}
if !opts.push_options.is_empty() && !adv.server_push_options {
return Err(Error::PushOptionsUnsupported);
}
Ok(())
}
fn build_command_block(
plan: &PushPlan,
adv: &AdvertisedState,
algo: HashAlgo,
opts: &PushOptions,
) -> Result<Vec<u8>> {
let zero_hex = "0".repeat(algo.hex_len());
let mut command_caps = PUSH_CAPS_BASE.to_owned();
if adv.server_sideband {
command_caps.push_str(" side-band-64k");
}
if !opts.push_options.is_empty() {
command_caps.push_str(" push-options");
}
if opts.atomic && adv.server_atomic {
command_caps.push_str(" atomic");
}
command_caps.push_str(&format!(" object-format={}", algo.name()));
let mut commands: Vec<u8> = Vec::new();
let mut first = true;
for &i in &plan.to_send {
let d = &plan.decisions[i];
let old_hex = d
.result
.old_oid
.map(|o| o.to_hex())
.unwrap_or_else(|| zero_hex.clone());
let new_hex = d
.result
.new_oid
.map(|o| o.to_hex())
.unwrap_or_else(|| zero_hex.clone());
let line = if first {
first = false;
format!(
"{old_hex} {new_hex} {}\0{command_caps}",
d.result.remote_ref
)
} else {
format!("{old_hex} {new_hex} {}", d.result.remote_ref)
};
pkt_line::write_line_to_vec(&mut commands, &line)?;
}
commands.extend_from_slice(b"0000");
if !opts.push_options.is_empty() {
for opt in &opts.push_options {
pkt_line::write_line_to_vec(&mut commands, opt)?;
}
commands.extend_from_slice(b"0000");
}
Ok(commands)
}
const PACK_SEND_BUFFER: usize = 64 * 1024;
struct PushPack {
wants: Vec<ObjectId>,
haves: Vec<ObjectId>,
opts: PackBuildOptions,
}
impl PushPack {
fn for_plan(
plan: &PushPlan,
local: &Repository,
adv: &AdvertisedState,
) -> Result<Option<Self>> {
let wants: Vec<ObjectId> = plan
.to_send
.iter()
.filter_map(|&i| plan.decisions[i].new_tip)
.collect();
if wants.is_empty() {
return Ok(None);
}
let haves = local.odb().present_oids(
adv.remote_refs
.values()
.chain(&adv.advertised_haves)
.copied(),
)?;
let local_cfg = crate::config::ConfigSet::load(
&crate::environment::Environment::empty(),
Some(local.git_dir()),
true,
)
.ok();
let mut opts = if haves.is_empty() {
PackBuildOptions::for_local_copy(local_cfg.as_ref())
} else {
PackBuildOptions::for_local_push(local_cfg.as_ref())
};
opts.use_ofs_delta = adv.server_ofs_delta;
Ok(Some(Self { wants, haves, opts }))
}
fn write_to(&self, local: &Repository, w: &mut dyn Write) -> Result<PackStats> {
crate::pack_objects::write_pack_for_push(
local.odb(),
&self.wants,
&self.haves,
&self.opts,
w,
)
}
}
struct PushDecision {
result: PushRefResult,
new_tip: Option<ObjectId>,
send: bool,
}
fn decide_push_wire(
spec: &PushRefSpec,
local: &Repository,
remote_refs: &HashMap<String, ObjectId>,
) -> Result<PushDecision> {
let local_odb = local.odb();
let remote_current = remote_refs.get(&spec.dst).copied();
let no_op = |status: PushRefStatus,
old: Option<ObjectId>,
new: Option<ObjectId>,
deletion: bool,
message: Option<String>| {
PushDecision {
result: PushRefResult {
local_ref: crate::transfer::push_spec_local_ref(spec),
remote_ref: spec.dst.clone(),
old_oid: old,
new_oid: new,
forced: false,
deletion,
status,
message,
},
new_tip: None,
send: false,
}
};
if !spec.delete {
if let Some(src) = spec.src {
if remote_current == Some(src) {
return Ok(no_op(
PushRefStatus::UpToDate,
remote_current,
Some(src),
false,
None,
));
}
}
}
if spec.expect_absent && remote_current.is_some() {
return Ok(no_op(
PushRefStatus::RejectStale,
remote_current,
spec.src,
spec.delete,
Some("stale info".to_owned()),
));
}
if let Some(expected) = spec.expected_old {
if remote_current != Some(expected) {
return Ok(no_op(
PushRefStatus::RejectStale,
remote_current,
spec.src,
spec.delete,
Some("stale info".to_owned()),
));
}
}
if spec.delete {
return Ok(match remote_current {
Some(_) => PushDecision {
result: PushRefResult {
local_ref: crate::transfer::push_spec_local_ref(spec),
remote_ref: spec.dst.clone(),
old_oid: remote_current,
new_oid: None,
forced: false,
deletion: true,
status: PushRefStatus::Ok,
message: None,
},
new_tip: None,
send: true,
},
None => no_op(
PushRefStatus::RejectRemoteRefMissing,
None,
None,
true,
Some("remote ref does not exist".to_owned()),
),
});
}
let Some(src) = spec.src else {
return Err(Error::Message(format!(
"push to '{}' has no source object and is not a deletion",
spec.dst
)));
};
if !local_odb.contains(&src)? {
return Err(Error::Message(format!(
"source object {src} for '{}' is missing from the local object store",
spec.dst
)));
}
let Some(old) = remote_current else {
return Ok(PushDecision {
result: PushRefResult {
local_ref: crate::transfer::push_spec_local_ref(spec),
remote_ref: spec.dst.clone(),
old_oid: None,
new_oid: Some(src),
forced: false,
deletion: false,
status: PushRefStatus::Ok,
message: None,
},
new_tip: Some(src),
send: true,
});
};
if !spec.force && !local_odb.contains(&old)? {
return Ok(no_op(
PushRefStatus::RejectFetchFirst,
Some(old),
Some(src),
false,
Some("fetch first".to_owned()),
));
}
let is_ff = if spec.force {
false
} else if crate::merge_base::is_ancestor_odb_walk_with_graph(
local_odb,
local_odb.commit_graph_chain().as_deref(),
old,
src,
)? {
true
} else {
crate::merge_base::is_ancestor(local, old, src).unwrap_or(false)
};
if is_ff {
Ok(PushDecision {
result: PushRefResult {
local_ref: crate::transfer::push_spec_local_ref(spec),
remote_ref: spec.dst.clone(),
old_oid: Some(old),
new_oid: Some(src),
forced: false,
deletion: false,
status: PushRefStatus::Ok,
message: None,
},
new_tip: Some(src),
send: true,
})
} else if spec.force {
Ok(PushDecision {
result: PushRefResult {
local_ref: crate::transfer::push_spec_local_ref(spec),
remote_ref: spec.dst.clone(),
old_oid: Some(old),
new_oid: Some(src),
forced: true,
deletion: false,
status: PushRefStatus::Ok,
message: None,
},
new_tip: Some(src),
send: true,
})
} else {
Ok(PushDecision {
result: PushRefResult {
local_ref: crate::transfer::push_spec_local_ref(spec),
remote_ref: spec.dst.clone(),
old_oid: Some(old),
new_oid: Some(src),
forced: false,
deletion: false,
status: PushRefStatus::RejectNonFastForward,
message: Some("non-fast-forward".to_owned()),
},
new_tip: None,
send: false,
})
}
}
pub(crate) fn read_receive_pack_response(
reader: &mut dyn Read,
sent_refs: &[&str],
server_sideband: bool,
) -> Result<Vec<u8>> {
let mut raw = Vec::new();
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => raw.extend_from_slice(&buf[..n]),
Err(e) if receive_pack_read_recoverable(&e) => {
if receive_pack_response_complete(&raw, sent_refs, server_sideband)? {
break;
}
return Err(Error::Io(e));
}
Err(e) => return Err(Error::Io(e)),
}
}
if !receive_pack_response_complete(&raw, sent_refs, server_sideband)? {
return Err(Error::Message(
"receive-pack response ended before a complete report-status".to_owned(),
));
}
Ok(raw)
}
fn receive_pack_read_recoverable(e: &std::io::Error) -> bool {
matches!(
e.kind(),
ErrorKind::ConnectionReset | ErrorKind::BrokenPipe | ErrorKind::UnexpectedEof
)
}
fn receive_pack_response_complete(
raw: &[u8],
sent_refs: &[&str],
server_sideband: bool,
) -> Result<bool> {
let report = if server_sideband {
demux_sideband_report(raw, None)?
} else {
raw.to_vec()
};
Ok(report_lines_complete(&report, sent_refs))
}
fn report_lines_complete(report: &[u8], sent_refs: &[&str]) -> bool {
let mut saw_unpack = false;
let mut per_ref = HashSet::<String>::new();
let mut cursor = Cursor::new(report);
while let Ok(Some(pkt)) = pkt_line::read_packet(&mut cursor) {
let Packet::Data(line) = pkt else {
continue;
};
let line = line.trim_end();
if line.starts_with("unpack ") {
saw_unpack = true;
} else if let Some(refname) = line.strip_prefix("ok ") {
per_ref.insert(refname.trim().to_owned());
} else if let Some(rest) = line.strip_prefix("ng ") {
let (refname, _) = rest.split_once(' ').unwrap_or((rest, ""));
per_ref.insert(refname.trim().to_owned());
}
}
saw_unpack && sent_refs.iter().all(|r| per_ref.contains(*r))
}
fn apply_report_status(report: &[u8], decisions: &mut [PushDecision]) {
let mut by_ref: HashMap<&str, usize> = HashMap::new();
for (i, d) in decisions.iter().enumerate() {
if d.send {
by_ref.insert(d.result.remote_ref.as_str(), i);
}
}
let mut unpack_error: Option<String> = None;
let mut updates: Vec<(usize, Option<String>)> = Vec::new();
let mut cursor = Cursor::new(report);
while let Ok(Some(pkt)) = pkt_line::read_packet(&mut cursor) {
let Packet::Data(line) = pkt else {
continue;
};
let line = line.trim_end();
if let Some(rest) = line.strip_prefix("unpack ") {
if rest.trim() != "ok" {
unpack_error = Some(rest.trim().to_owned());
}
} else if let Some(refname) = line.strip_prefix("ok ") {
let _ = by_ref.get(refname.trim());
} else if let Some(rest) = line.strip_prefix("ng ") {
let (refname, reason) = rest.split_once(' ').unwrap_or((rest, ""));
if let Some(&idx) = by_ref.get(refname.trim()) {
let msg = if reason.trim().is_empty() {
None
} else {
Some(reason.trim().to_owned())
};
updates.push((idx, msg));
}
}
}
for (idx, msg) in updates {
decisions[idx].result.status = PushRefStatus::RemoteRejected;
decisions[idx].result.message = msg;
}
if let Some(reason) = unpack_error {
for d in decisions.iter_mut() {
if d.send && !matches!(d.result.status, PushRefStatus::RemoteRejected) {
d.result.status = PushRefStatus::RemoteRejected;
d.result.message = Some(format!("unpack failed: {reason}"));
}
}
}
}
fn demux_sideband_report(input: &[u8], mut progress: Option<&mut dyn Progress>) -> Result<Vec<u8>> {
let mut report = Vec::new();
let mut i = 0usize;
while i + 4 <= input.len() {
let len = match pkt_line::parse_hex_len(&input[i..i + 4]) {
Ok(l) => l,
Err(_) => break,
};
i += 4;
if len == 0 {
continue;
}
if len < 4 || i + (len - 4) > input.len() {
break;
}
let payload = &input[i..i + (len - 4)];
i += len - 4;
if payload.is_empty() {
continue;
}
let band = payload[0];
let data = &payload[1..];
match band {
1 => report.extend_from_slice(data),
2 | 3 => {
if let Some(p) = progress.as_deref_mut() {
p.message(data);
}
}
_ => {}
}
}
Ok(report)
}
fn demux_report_and_remote_messages(input: &[u8], progress: &mut dyn Progress) -> Result<Vec<u8>> {
demux_sideband_report(input, Some(progress))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::objects::ObjectKind;
fn make_decision(refname: &str, send: bool) -> PushDecision {
PushDecision {
result: PushRefResult {
local_ref: None,
remote_ref: refname.to_owned(),
old_oid: None,
new_oid: None,
forced: false,
deletion: false,
status: PushRefStatus::Ok,
message: None,
},
new_tip: None,
send,
}
}
fn report_bytes(lines: &[&str]) -> Vec<u8> {
let mut buf = Vec::new();
for l in lines {
pkt_line::write_line_to_vec(&mut buf, l).unwrap();
}
buf.extend_from_slice(b"0000");
buf
}
fn adv_state(sideband: bool, ofs_delta: bool, push_options: bool) -> AdvertisedState {
AdvertisedState {
remote_refs: HashMap::new(),
advertised_haves: Vec::new(),
server_sideband: sideband,
server_ofs_delta: ofs_delta,
server_push_options: push_options,
server_atomic: false,
push_bundle: None,
}
}
#[derive(Default)]
struct TestConnection {
caps: Vec<String>,
refs: Vec<(String, ObjectId)>,
input: Cursor<Vec<u8>>,
output: Vec<u8>,
stateless: bool,
}
impl Connection for TestConnection {
fn reader(&mut self) -> &mut dyn Read {
&mut self.input
}
fn writer(&mut self) -> &mut dyn std::io::Write {
&mut self.output
}
fn advertised_refs(&self) -> &[(String, ObjectId)] {
&self.refs
}
fn capabilities(&self) -> &[String] {
&self.caps
}
fn head_symref(&self) -> Option<&str> {
None
}
fn protocol_version(&self) -> u8 {
0
}
fn stateless_rpc(&self) -> bool {
self.stateless
}
}
#[test]
fn advertisement_recognizes_atomic_capability_token() {
for caps in [
vec![],
vec!["atomic"],
vec!["atomic=false"],
vec!["atomic", "side-band-64k", "ofs-delta", "push-options"],
] {
let mut conn = TestConnection {
caps: caps.iter().map(|s| (*s).to_owned()).collect(),
..Default::default()
};
let adv = AdvertisedState::from_connection(&mut conn);
assert_eq!(adv.server_atomic, caps.contains(&"atomic"));
assert_eq!(adv.server_sideband, caps.contains(&"side-band-64k"));
assert_eq!(adv.server_ofs_delta, caps.contains(&"ofs-delta"));
assert_eq!(adv.server_push_options, caps.contains(&"push-options"));
}
}
#[test]
fn command_block_requests_atomic_only_on_first_command_when_supported_and_requested() {
let new = ObjectId::from_hex(&"1".repeat(40)).unwrap();
let plan = PushPlan {
decisions: vec![
send_decision("refs/heads/one", new),
send_decision("refs/heads/two", new),
],
to_send: vec![0, 1],
};
for supported in [false, true] {
for requested in [false, true] {
let mut adv = adv_state(true, true, true);
adv.server_atomic = supported;
let opts = PushOptions {
atomic: requested,
push_options: vec!["ci.skip".to_owned()],
..Default::default()
};
let block = build_command_block(&plan, &adv, HashAlgo::Sha1, &opts).unwrap();
let pkts = decode_block(&block);
let (command, caps) = pkts[0].as_deref().unwrap().split_once('\0').unwrap();
assert_eq!(
command,
format!("{} {} refs/heads/one", "0".repeat(40), new)
);
let caps: Vec<_> = caps.split_whitespace().collect();
assert_eq!(caps.contains(&"atomic"), supported && requested);
assert!(caps.contains(&"push-options"));
assert!(caps.contains(&"side-band-64k"));
assert!(caps.contains(&"object-format=sha1"));
assert_eq!(
pkts[1].as_deref().unwrap(),
format!("{} {} refs/heads/two", "0".repeat(40), new)
);
assert_eq!(&pkts[2..], &[None, Some("ci.skip".to_owned()), None]);
}
}
}
#[test]
fn unsupported_atomic_push_fails_before_any_update_or_pack_bytes() {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path(), &crate::repo::InitOptions::default()).unwrap();
let refs = [PushRefSpec {
src: Some(repo.odb().write(ObjectKind::Blob, b"payload").unwrap()),
dst: "refs/tags/payload".to_owned(),
..Default::default()
}];
for stateless in [false, true] {
for dry_run in [false, true] {
for refs in [&[][..], &refs[..]] {
let mut conn = TestConnection {
stateless,
..Default::default()
};
let opts = PushOptions {
atomic: true,
dry_run,
..Default::default()
};
let err = push_remote(
repo.git_dir(),
&mut conn,
refs,
&opts,
&mut crate::fetch::NoProgress,
&crate::progress::NeverCancel,
)
.unwrap_err();
assert!(matches!(err, Error::PushAtomicUnsupported), "{err:?}");
assert!(
conn.output.is_empty(),
"unsupported atomic push wrote bytes"
);
let mut adv = adv_state(false, false, false);
adv.server_atomic = true;
require_push_capabilities(&adv, &opts).unwrap();
}
}
}
}
fn decode_block(block: &[u8]) -> Vec<Option<String>> {
let mut cur = Cursor::new(block);
let mut out = Vec::new();
while let Ok(pkt) = pkt_line::read_packet(&mut cur) {
match pkt {
Some(Packet::Data(s)) => out.push(Some(s.trim_end_matches('\n').to_owned())),
Some(Packet::Flush) => out.push(None),
_ => break,
}
}
out
}
fn push_pack_bytes(
plan: &PushPlan,
repo: &Repository,
adv: &AdvertisedState,
) -> Result<Option<Vec<u8>>> {
PushPack::for_plan(plan, repo, adv)?
.map(|pack| {
let mut bytes = Vec::new();
pack.write_to(repo, &mut bytes)?;
Ok(bytes)
})
.transpose()
}
fn flaky_push_repo(
dir: &Path,
) -> (
Repository,
std::sync::Arc<crate::odb::store::flaky::FlakyStore>,
) {
let flaky = crate::odb::store::flaky::FlakyStore::new();
let git_dir = dir.join("repo.git");
let repo = crate::repo::RepositoryBuilder::new(RepositoryOptions::empty())
.odb_builder(
crate::odb::OdbBuilder::files(git_dir.join("objects"))
.primary(flaky.clone())
.alternates(false),
)
.ref_store(std::sync::Arc::new(
crate::refs::store::MemoryRefStore::new(),
))
.open(&git_dir, None)
.unwrap();
(repo, flaky)
}
#[test]
fn push_pack_haves_fail_on_backend_error_instead_of_being_dropped() {
let dir = tempfile::tempdir().unwrap();
let (repo, flaky) = flaky_push_repo(dir.path());
let odb = repo.odb();
let blob = odb.write(ObjectKind::Blob, b"to send").unwrap();
let remote_tip = odb.write(ObjectKind::Blob, b"remote has this").unwrap();
let plan = PushPlan {
decisions: vec![send_decision("refs/heads/main", blob)],
to_send: vec![0],
};
let mut adv = adv_state(false, true, false);
adv.remote_refs
.insert("refs/heads/main".to_owned(), remote_tip);
assert!(push_pack_bytes(&plan, &repo, &adv).unwrap().is_some());
flaky.fail_contains(true);
let err = push_pack_bytes(&plan, &repo, &adv).unwrap_err();
assert!(crate::odb::store::flaky::is_injected(&err), "{err:?}");
}
#[test]
fn push_source_check_fails_on_backend_error_instead_of_missing() {
let dir = tempfile::tempdir().unwrap();
let (repo, flaky) = flaky_push_repo(dir.path());
let src = repo.odb().write(ObjectKind::Blob, b"source").unwrap();
let spec = PushRefSpec {
src: Some(src),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
hook_src_ref: None,
};
let decision = decide_push_wire(&spec, &repo, &HashMap::new()).unwrap();
assert_eq!(decision.new_tip, Some(src));
flaky.fail_contains(true);
let err = decide_push_wire(&spec, &repo, &HashMap::new())
.err()
.unwrap();
assert!(crate::odb::store::flaky::is_injected(&err), "{err:?}");
}
fn send_decision(refname: &str, new_oid: ObjectId) -> PushDecision {
PushDecision {
result: PushRefResult {
local_ref: None,
remote_ref: refname.to_owned(),
old_oid: None,
new_oid: Some(new_oid),
forced: false,
deletion: false,
status: PushRefStatus::Ok,
message: None,
},
new_tip: Some(new_oid),
send: true,
}
}
#[test]
fn command_block_without_push_options_has_no_capability_or_lines() {
let new = ObjectId::from_hex(&"1".repeat(40)).unwrap();
let plan = PushPlan {
decisions: vec![send_decision("refs/heads/main", new)],
to_send: vec![0],
};
let block = build_command_block(
&plan,
&adv_state(false, false, true),
HashAlgo::Sha1,
&PushOptions::default(),
)
.unwrap();
let pkts = decode_block(&block);
assert_eq!(pkts.len(), 2);
let cmd = pkts[0].as_deref().unwrap();
assert!(
cmd.contains("refs/heads/main"),
"first line is the ref command, got {cmd:?}"
);
assert!(
!cmd.contains("push-options"),
"no push-options capability without options, got {cmd:?}"
);
assert_eq!(pkts[1], None, "single trailing flush");
}
#[test]
fn command_block_with_push_options_negotiates_cap_and_emits_lines() {
let new = ObjectId::from_hex(&"1".repeat(40)).unwrap();
let plan = PushPlan {
decisions: vec![send_decision("refs/heads/main", new)],
to_send: vec![0],
};
let opts = PushOptions {
push_options: vec!["ci.skip".to_owned(), "reviewer=alice".to_owned()],
..Default::default()
};
let block = build_command_block(&plan, &adv_state(true, true, true), HashAlgo::Sha1, &opts)
.unwrap();
let pkts = decode_block(&block);
assert_eq!(
pkts,
vec![
pkts[0].clone(),
None,
Some("ci.skip".to_owned()),
Some("reviewer=alice".to_owned()),
None,
],
"push-option lines must follow the command-list flush, then a flush"
);
let cmd = pkts[0].as_deref().unwrap();
assert!(
cmd.contains("push-options"),
"capability list must advertise push-options, got {cmd:?}"
);
assert!(cmd.contains("report-status"));
assert!(cmd.contains("side-band-64k"));
assert!(cmd.contains("object-format=sha1"));
}
#[test]
fn require_push_options_errors_typed_when_server_lacks_capability() {
let opts = PushOptions {
push_options: vec!["x".to_owned()],
..PushOptions::default()
};
let err = require_push_capabilities(&adv_state(true, true, false), &opts).unwrap_err();
assert!(
matches!(err, Error::PushOptionsUnsupported),
"expected PushOptionsUnsupported, got {err:?}"
);
assert_eq!(
err.to_string(),
"the receiving end does not support push options"
);
require_push_capabilities(&adv_state(true, true, true), &opts).unwrap();
require_push_capabilities(&adv_state(true, true, false), &PushOptions::default()).unwrap();
}
#[test]
fn report_ng_demotes_to_remote_rejected() {
let mut decisions = vec![
make_decision("refs/heads/main", true),
make_decision("refs/heads/topic", true),
];
let report = report_bytes(&[
"unpack ok",
"ok refs/heads/main",
"ng refs/heads/topic non-fast-forward",
]);
apply_report_status(&report, &mut decisions);
assert_eq!(decisions[0].result.status, PushRefStatus::Ok);
assert_eq!(decisions[1].result.status, PushRefStatus::RemoteRejected);
assert_eq!(
decisions[1].result.message.as_deref(),
Some("non-fast-forward")
);
}
#[test]
fn report_unpack_failure_rejects_all_sent() {
let mut decisions = vec![make_decision("refs/heads/main", true)];
let report = report_bytes(&["unpack index-pack abort"]);
apply_report_status(&report, &mut decisions);
assert_eq!(decisions[0].result.status, PushRefStatus::RemoteRejected);
assert!(decisions[0]
.result
.message
.as_deref()
.unwrap()
.starts_with("unpack failed:"));
}
#[test]
fn demux_separates_report_and_progress() {
struct Cap(Vec<u8>);
impl crate::progress::ProgressSink for Cap {}
impl Progress for Cap {
fn message(&mut self, bytes: &[u8]) {
self.0.extend_from_slice(bytes);
}
}
let mut wire = Vec::new();
let mut band1 = vec![1u8];
band1.extend_from_slice(b"unpack ok\n");
pkt_line::write_packet_raw(&mut wire, &band1).unwrap();
let mut band2 = vec![2u8];
band2.extend_from_slice(b"hello from hook\n");
pkt_line::write_packet_raw(&mut wire, &band2).unwrap();
wire.extend_from_slice(b"0000");
let mut cap = Cap(Vec::new());
let report = demux_report_and_remote_messages(&wire, &mut cap).unwrap();
assert_eq!(report, b"unpack ok\n");
assert_eq!(cap.0, b"hello from hook\n");
}
#[test]
fn report_lines_complete_requires_unpack_and_every_sent_ref() {
let sent = ["refs/heads/main"];
let full = report_bytes(&["unpack ok", "ok refs/heads/main"]);
assert!(report_lines_complete(&full, &sent));
let missing_ok = report_bytes(&["unpack ok"]);
assert!(!report_lines_complete(&missing_ok, &sent));
let missing_unpack = report_bytes(&["ok refs/heads/main"]);
assert!(!report_lines_complete(&missing_unpack, &sent));
let ng_ok = report_bytes(&["unpack ok", "ng refs/heads/main denied"]);
assert!(report_lines_complete(&ng_ok, &sent));
}
struct AbruptAfterRead {
payload: Vec<u8>,
pos: usize,
err_kind: ErrorKind,
}
impl Read for AbruptAfterRead {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos < self.payload.len() {
let n = std::cmp::min(buf.len(), self.payload.len() - self.pos);
buf[..n].copy_from_slice(&self.payload[self.pos..self.pos + n]);
self.pos += n;
return Ok(n);
}
Err(std::io::Error::new(self.err_kind, "forcibly closed"))
}
}
fn sideband_receive_pack_wire(hook_line: &[u8]) -> Vec<u8> {
let report_body = report_bytes(&["unpack ok", "ok refs/heads/main"]);
let mut band1 = vec![1u8];
band1.extend_from_slice(&report_body);
let mut wire = Vec::new();
pkt_line::write_packet_raw(&mut wire, &band1).unwrap();
let mut band2 = vec![2u8];
band2.extend_from_slice(hook_line);
pkt_line::write_packet_raw(&mut wire, &band2).unwrap();
wire.extend_from_slice(b"0000");
wire
}
struct EofAfterRead {
payload: Vec<u8>,
pos: usize,
}
impl Read for EofAfterRead {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos < self.payload.len() {
let n = std::cmp::min(buf.len(), self.payload.len() - self.pos);
buf[..n].copy_from_slice(&self.payload[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
} else {
Ok(0)
}
}
}
fn read_push_report(
reader: &mut dyn Read,
sent_refs: &[&str],
sideband: bool,
progress: &mut dyn Progress,
) -> Result<Vec<u8>> {
let raw = read_receive_pack_response(reader, sent_refs, sideband)?;
if sideband {
demux_report_and_remote_messages(&raw, progress)
} else {
Ok(raw)
}
}
#[test]
fn read_receive_pack_response_tolerates_reset_after_full_report() {
let report = report_bytes(&["unpack ok", "ok refs/heads/main"]);
let mut reader = AbruptAfterRead {
payload: report.clone(),
pos: 0,
err_kind: ErrorKind::ConnectionReset,
};
let out =
read_receive_pack_response(&mut reader, &["refs/heads/main"], false).expect("reset");
assert_eq!(out, report);
}
#[test]
fn read_receive_pack_response_fails_reset_on_incomplete_report() {
let partial = report_bytes(&["unpack ok"]);
let mut reader = AbruptAfterRead {
payload: partial,
pos: 0,
err_kind: ErrorKind::ConnectionReset,
};
let err = read_receive_pack_response(&mut reader, &["refs/heads/main"], false).unwrap_err();
assert!(
matches!(err, Error::Io(_)),
"incomplete report must stay an I/O error, got {err:?}"
);
}
#[test]
fn sideband_remote_messages_flush_unterminated_hook_text() {
struct Noop;
impl crate::progress::ProgressSink for Noop {}
impl Progress for Noop {}
let mut inner = Noop;
let mut sideband = PushSidebandCollector {
inner: &mut inner,
remote_messages: Vec::new(),
pending: SidebandLines::new(),
};
let hook = b"policy: no ticket";
let wire = sideband_receive_pack_wire(hook);
demux_report_and_remote_messages(&wire, &mut sideband).unwrap();
sideband.finish_pending();
assert_eq!(
sideband.remote_messages,
vec!["policy: no ticket".to_owned()]
);
}
#[test]
fn sideband_diagnostics_delivered_once_on_clean_eof() {
struct Cap(Vec<u8>);
impl crate::progress::ProgressSink for Cap {}
impl Progress for Cap {
fn message(&mut self, bytes: &[u8]) {
self.0.extend_from_slice(bytes);
}
}
let hook = b"hook output\n";
let wire = sideband_receive_pack_wire(hook);
let mut reader = EofAfterRead {
payload: wire,
pos: 0,
};
let mut cap = Cap(Vec::new());
read_push_report(&mut reader, &["refs/heads/main"], true, &mut cap)
.expect("clean eof side-band push report");
assert_eq!(cap.0.as_slice(), hook);
}
#[test]
fn sideband_diagnostics_delivered_once_on_connection_reset() {
struct Cap(Vec<u8>);
impl crate::progress::ProgressSink for Cap {}
impl Progress for Cap {
fn message(&mut self, bytes: &[u8]) {
self.0.extend_from_slice(bytes);
}
}
let hook = b"hook output\n";
let wire = sideband_receive_pack_wire(hook);
let mut reader = AbruptAfterRead {
payload: wire,
pos: 0,
err_kind: ErrorKind::ConnectionReset,
};
let mut cap = Cap(Vec::new());
read_push_report(&mut reader, &["refs/heads/main"], true, &mut cap)
.expect("reset after complete side-band report");
assert_eq!(cap.0.as_slice(), hook);
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn push_pack_initial_branch_push_uses_deltas() {
use std::process::Command;
fn pack_delta_count(bytes: &[u8], algo_len: usize) -> usize {
fn read_type_size(b: &[u8]) -> (u8, usize, usize) {
let type_code = (b[0] >> 4) & 7;
let mut size = (b[0] & 0x0f) as usize;
let mut shift = 4;
let mut i = 1;
while b[i - 1] & 0x80 != 0 {
size |= ((b[i] & 0x7f) as usize) << shift;
shift += 7;
i += 1;
}
(type_code, size, i)
}
fn zlib_consume(b: &[u8]) -> usize {
use std::io::Read as _;
let mut dec = flate2::bufread::ZlibDecoder::new(b);
let mut sink = Vec::new();
dec.read_to_end(&mut sink).expect("zlib");
dec.total_in() as usize
}
let nr = u32::from_be_bytes(bytes[8..12].try_into().unwrap()) as usize;
let mut pos = 12usize;
let mut deltas = 0usize;
for _ in 0..nr {
let (type_code, _, consumed) = read_type_size(&bytes[pos..]);
pos += consumed;
match type_code {
6 | 7 => {
deltas += 1;
if type_code == 7 {
pos += algo_len;
} else {
while bytes[pos] & 0x80 != 0 {
pos += 1;
}
pos += 1;
}
}
_ => {}
}
pos += zlib_consume(&bytes[pos..]);
}
deltas
}
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for i in 0..40 {
if i == 0 {
Command::new("git")
.current_dir(dir)
.args(["init", "-q", "-b", "main", "."])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.unwrap();
}
std::fs::write(dir.join(format!("f{i:03}.txt")), format!("content {i}\n")).unwrap();
std::fs::write(dir.join("shared.txt"), format!("shared rev {i}\n")).unwrap();
Command::new("git")
.current_dir(dir)
.args(["add", "-A"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.unwrap();
Command::new("git")
.current_dir(dir)
.args(["commit", "-q", "-m", &format!("commit {i}")])
.env("GIT_AUTHOR_NAME", "T")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_AUTHOR_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_COMMITTER_NAME", "T")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.env("GIT_COMMITTER_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.unwrap();
}
Command::new("git")
.current_dir(dir)
.args(["repack", "-adf"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.unwrap();
let git_dir = dir.join(".git");
let repo = Repository::open(&git_dir, &RepositoryOptions::empty()).unwrap();
let odb = repo.odb();
let tip = crate::refs::resolve_ref(&git_dir, "HEAD").expect("head");
let plan = PushPlan {
decisions: vec![send_decision("refs/heads/main", tip)],
to_send: vec![0],
};
let adv = adv_state(false, true, false);
let pack = push_pack_bytes(&plan, &repo, &adv)
.unwrap()
.expect("pack bytes");
let deltas = pack_delta_count(&pack, odb.hash_algo().len());
assert!(
deltas > 0,
"initial push pack must contain delta objects (got {deltas})"
);
}
}