use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Cursor;
use std::path::Path;
use crate::error::{Error, Result};
use crate::fetch::Progress;
use crate::objects::{parse_tag, HashAlgo, ObjectId, ObjectKind};
use crate::pkt_line::{self, Packet};
use crate::push_report::{PushRefResult, PushRefStatus};
use crate::transfer::{
build_pack, open_odb, PackBuildOptions, PushOptions, PushOutcome, PushRefSpec,
};
use crate::transport::Connection;
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,
) -> Result<PushOutcome> {
if conn.protocol_version() >= 2 {
return Err(Error::Message(
"push_remote: protocol v2 not supported in this phase (use v0/v1)".to_owned(),
));
}
let local_odb = open_odb(local_git_dir);
let algo = local_odb.hash_algo();
let adv = AdvertisedState::from_connection(conn);
require_push_options_supported(&adv, opts)?;
let mut plan = match plan_push(refs, &local_odb, local_git_dir, &adv, opts)? {
PlanOutcome::Send(plan) => plan,
PlanOutcome::Done(results) => return Ok(PushOutcome { results }),
};
let commands = build_command_block(&plan, &adv, algo, &opts.push_options)?;
conn.writer().write_all(&commands)?;
conn.writer().flush()?;
if let Some(pack) = build_push_pack(&plan, &local_odb, &adv)? {
conn.writer().write_all(&pack)?;
conn.writer().flush()?;
}
let mut raw = Vec::new();
conn.reader().read_to_end(&mut raw)?;
let report = if adv.server_sideband {
demux_report_and_remote_messages(&raw, progress)?
} else {
raw
};
apply_report_status(&report, &mut plan.decisions);
Ok(PushOutcome {
results: plan.decisions.into_iter().map(|d| d.result).collect(),
})
}
pub fn push_http(
client: &dyn crate::transport::http::HttpClient,
local_git_dir: &Path,
repo_url: &str,
refs: &[PushRefSpec],
opts: &PushOptions,
progress: &mut dyn Progress,
) -> Result<PushOutcome> {
let local_odb = open_odb(local_git_dir);
let algo = local_odb.hash_algo();
let adv = discover_receive_pack(client, repo_url)?;
if adv.protocol_version >= 2 {
return Err(Error::Message(
"push_http: protocol v2 receive-pack not supported in this phase (use v0/v1)"
.to_owned(),
));
}
require_push_options_supported(&adv.state, opts)?;
let mut plan = match plan_push(refs, &local_odb, local_git_dir, &adv.state, opts)? {
PlanOutcome::Send(plan) => plan,
PlanOutcome::Done(results) => return Ok(PushOutcome { results }),
};
let mut body = build_command_block(&plan, &adv.state, algo, &opts.push_options)?;
if let Some(pack) = build_push_pack(&plan, &local_odb, &adv.state)? {
body.extend_from_slice(&pack);
}
let service_url = receive_pack_url(repo_url);
let content_type = format!("application/x-{RECEIVE_PACK}-request");
let accept = format!("application/x-{RECEIVE_PACK}-result");
let resp = client.post(&service_url, &content_type, &accept, &body, None)?;
let report = if adv.state.server_sideband {
demux_report_and_remote_messages(&resp, progress)?
} else {
resp
};
apply_report_status(&report, &mut plan.decisions);
Ok(PushOutcome {
results: plan.decisions.into_iter().map(|d| d.result).collect(),
})
}
const RECEIVE_PACK: &str = "git-receive-pack";
struct AdvertisedState {
remote_refs: HashMap<String, ObjectId>,
advertised_haves: Vec<ObjectId>,
server_sideband: bool,
server_ofs_delta: bool,
server_push_options: bool,
}
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"),
}
}
}
struct ReceivePackAdvertisement {
protocol_version: u8,
state: AdvertisedState,
}
fn discover_receive_pack(
client: &dyn crate::transport::http::HttpClient,
repo_url: &str,
) -> Result<ReceivePackAdvertisement> {
let base = repo_url.trim_end_matches('/');
let mut refs_url = format!("{base}/info/refs");
refs_url.push_str(if refs_url.contains('?') { "&" } else { "?" });
refs_url.push_str("service=");
refs_url.push_str(RECEIVE_PACK);
let body = client.get(&refs_url, None)?;
let pkt_body = strip_service_advertisement(&body)?;
parse_receive_pack_advertisement(pkt_body)
}
fn receive_pack_url(repo_url: &str) -> String {
let base = repo_url.trim_end_matches('/');
format!("{base}/{RECEIVE_PACK}")
}
fn strip_service_advertisement(body: &[u8]) -> Result<&[u8]> {
let mut cur = Cursor::new(body);
match pkt_line::read_packet(&mut cur)? {
Some(Packet::Data(line)) if line.starts_with("# service=") => {
match pkt_line::read_packet(&mut cur)? {
Some(Packet::Flush) | None => {}
_ => return Ok(body),
}
let pos = cur.position() as usize;
Ok(&body[pos..])
}
_ => Ok(body),
}
}
fn parse_receive_pack_advertisement(body: &[u8]) -> Result<ReceivePackAdvertisement> {
let mut cur = Cursor::new(body);
let first = match pkt_line::read_packet(&mut cur)? {
None | Some(Packet::Flush) => {
return Ok(ReceivePackAdvertisement {
protocol_version: 0,
state: AdvertisedState {
remote_refs: HashMap::new(),
advertised_haves: Vec::new(),
server_sideband: false,
server_ofs_delta: false,
server_push_options: false,
},
});
}
Some(Packet::Data(s)) => s,
Some(other) => {
return Err(Error::Message(format!(
"unexpected first receive-pack advertisement packet: {other:?}"
)))
}
};
if first.trim_end() == "version 2" {
let mut caps: HashSet<String> = HashSet::new();
loop {
match pkt_line::read_packet(&mut cur)? {
None | Some(Packet::Flush) => break,
Some(Packet::Data(s)) => {
caps.insert(s.trim_end().to_owned());
}
Some(_) => break,
}
}
return Ok(ReceivePackAdvertisement {
protocol_version: 2,
state: AdvertisedState {
remote_refs: HashMap::new(),
advertised_haves: Vec::new(),
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"),
},
});
}
cur.set_position(0);
let mut remote_refs: HashMap<String, ObjectId> = HashMap::new();
let mut advertised_haves: Vec<ObjectId> = Vec::new();
let mut caps: HashSet<String> = HashSet::new();
let mut first_ref_line = true;
let mut protocol_version = 0u8;
loop {
match pkt_line::read_packet(&mut cur)? {
None | Some(Packet::Flush) => break,
Some(Packet::Data(line)) => {
let line = line.trim_end_matches('\n');
if line == "version 1" {
protocol_version = 1;
continue;
}
if line.starts_with("version ") || line.starts_with("shallow ") {
continue;
}
let (payload, cap_part) = match line.split_once('\0') {
Some((p, c)) => (p.trim(), Some(c)),
None => (line.trim(), None),
};
let Some((oid_hex, refname)) =
payload.split_once('\t').or_else(|| payload.split_once(' '))
else {
continue;
};
let oid_hex = oid_hex.trim();
let refname = refname.trim();
if first_ref_line {
if let Some(raw_caps) = cap_part {
for cap in raw_caps.split_whitespace() {
caps.insert(cap.to_owned());
}
}
first_ref_line = false;
}
if refname.is_empty() {
continue;
}
if oid_hex.bytes().all(|b| b == b'0') {
continue;
}
let oid = ObjectId::from_hex(oid_hex).map_err(|e| {
Error::Message(format!("bad oid in receive-pack advertisement: {oid_hex}: {e}"))
})?;
if refname == ".have" {
advertised_haves.push(oid);
} else {
remote_refs.insert(refname.to_owned(), oid);
}
}
Some(other) => {
return Err(Error::Message(format!(
"unexpected packet in receive-pack advertisement: {other:?}"
)))
}
}
}
Ok(ReceivePackAdvertisement {
protocol_version,
state: AdvertisedState {
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"),
},
})
}
struct PushPlan {
decisions: Vec<PushDecision>,
to_send: Vec<usize>,
}
enum PlanOutcome {
Send(PushPlan),
Done(Vec<PushRefResult>),
}
fn plan_push(
refs: &[PushRefSpec],
local_odb: &crate::odb::Odb,
local_git_dir: &Path,
adv: &AdvertisedState,
opts: &PushOptions,
) -> Result<PlanOutcome> {
let local_repo = crate::repo::Repository::open(local_git_dir, None).ok();
let mut decisions: Vec<PushDecision> = Vec::with_capacity(refs.len());
for spec in refs {
decisions.push(decide_push_wire(
spec,
local_odb,
&adv.remote_refs,
local_repo.as_ref(),
)?);
}
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_options_supported(adv: &AdvertisedState, opts: &PushOptions) -> Result<()> {
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,
push_options: &[String],
) -> 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 !push_options.is_empty() {
command_caps.push_str(" push-options");
}
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 !push_options.is_empty() {
for opt in push_options {
pkt_line::write_line_to_vec(&mut commands, opt)?;
}
commands.extend_from_slice(b"0000");
}
Ok(commands)
}
fn build_push_pack(
plan: &PushPlan,
local_odb: &crate::odb::Odb,
adv: &AdvertisedState,
) -> Result<Option<Vec<u8>>> {
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 mut haves: Vec<ObjectId> = adv.remote_refs.values().copied().collect();
haves.extend_from_slice(&adv.advertised_haves);
build_pack(
local_odb,
&wants,
&haves,
&PackBuildOptions {
thin: true,
delta: true,
use_ofs_delta: adv.server_ofs_delta,
..PackBuildOptions::default()
},
)
.map(Some)
}
struct PushDecision {
result: PushRefResult,
new_tip: Option<ObjectId>,
send: bool,
}
fn decide_push_wire(
spec: &PushRefSpec,
local_odb: &crate::odb::Odb,
remote_refs: &HashMap<String, ObjectId>,
local_repo: Option<&crate::repo::Repository>,
) -> Result<PushDecision> {
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: None,
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: None,
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::UpToDate, None, None, true, None),
});
}
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.exists(&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: None,
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,
});
};
let is_ff = local_repo
.map(|r| crate::merge_base::is_ancestor(r, old, src).unwrap_or(false))
.unwrap_or(false);
if is_ff {
Ok(PushDecision {
result: PushRefResult {
local_ref: None,
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: None,
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: None,
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,
})
}
}
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_report_and_remote_messages(
input: &[u8],
progress: &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 => progress.message(data),
_ => {}
}
}
Ok(report)
}
#[allow(dead_code)]
fn peel_to_commit(odb: &crate::odb::Odb, oid: ObjectId) -> Option<ObjectId> {
let mut current = oid;
for _ in 0..16 {
let obj = odb.read(¤t).ok()?;
match obj.kind {
ObjectKind::Commit => return Some(current),
ObjectKind::Tag => current = parse_tag(&obj.data).ok()?.object,
_ => return None,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
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,
}
}
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 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, &[]).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 = vec!["ci.skip".to_owned(), "reviewer=alice".to_owned()];
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_options_supported(&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_options_supported(&adv_state(true, true, true), &opts).unwrap();
require_push_options_supported(&adv_state(true, true, false), &PushOptions::default())
.unwrap();
}
#[test]
fn receive_pack_url_and_strip_preamble() {
assert_eq!(
receive_pack_url("http://h/r.git/"),
"http://h/r.git/git-receive-pack"
);
let mut tail = Vec::new();
pkt_line::write_line_to_vec(&mut tail, &format!("{} refs/heads/main", "1".repeat(40)))
.unwrap();
tail.extend_from_slice(b"0000");
let mut body = Vec::new();
pkt_line::write_line_to_vec(&mut body, "# service=git-receive-pack\n").unwrap();
body.extend_from_slice(b"0000");
body.extend_from_slice(&tail);
assert_eq!(strip_service_advertisement(&body).unwrap(), tail.as_slice());
assert_eq!(strip_service_advertisement(&tail).unwrap(), tail.as_slice());
}
#[test]
fn parses_v0_receive_pack_advertisement_with_caps_and_have() {
let main = "1".repeat(40);
let have = "2".repeat(40);
let mut body = Vec::new();
pkt_line::write_line_to_vec(
&mut body,
&format!(
"{main} refs/heads/main\0report-status report-status-v2 side-band-64k ofs-delta object-format=sha1"
),
)
.unwrap();
pkt_line::write_line_to_vec(&mut body, &format!("{have} .have")).unwrap();
body.extend_from_slice(b"0000");
let adv = parse_receive_pack_advertisement(&body).unwrap();
assert_eq!(adv.protocol_version, 0);
assert!(adv.state.server_sideband);
assert!(adv.state.server_ofs_delta);
assert_eq!(
adv.state.remote_refs.get("refs/heads/main").map(|o| o.to_hex()),
Some(main.clone())
);
assert_eq!(adv.state.advertised_haves.len(), 1);
assert_eq!(adv.state.advertised_haves[0].to_hex(), have);
assert!(!adv.state.remote_refs.contains_key(".have"));
}
#[test]
fn parses_empty_repo_capabilities_carrier() {
let zero = "0".repeat(40);
let mut body = Vec::new();
pkt_line::write_line_to_vec(
&mut body,
&format!("{zero} capabilities^{{}}\0report-status delete-refs ofs-delta"),
)
.unwrap();
body.extend_from_slice(b"0000");
let adv = parse_receive_pack_advertisement(&body).unwrap();
assert_eq!(adv.protocol_version, 0);
assert!(adv.state.remote_refs.is_empty());
assert!(adv.state.advertised_haves.is_empty());
assert!(adv.state.server_ofs_delta);
assert!(!adv.state.server_sideband);
}
#[test]
fn detects_v2_receive_pack_advertisement() {
let mut body = Vec::new();
pkt_line::write_line_to_vec(&mut body, "version 2").unwrap();
pkt_line::write_line_to_vec(&mut body, "agent=grit/test").unwrap();
pkt_line::write_line_to_vec(&mut body, "object-format=sha1").unwrap();
body.extend_from_slice(b"0000");
let adv = parse_receive_pack_advertisement(&body).unwrap();
assert_eq!(adv.protocol_version, 2);
}
#[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 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");
}
}