pub mod daemon;
pub mod http;
pub mod lfs;
use alloc::vec::Vec;
use crate::error::Result;
use crate::odb::ObjectDatabase;
use crate::oid::{HashAlgo, ObjectId};
use crate::protocol::pktline::{self, Packet};
use crate::refs::RefStore;
use crate::repository::Repository;
use crate::transport::Service;
use crate::vfs::Vfs;
const THIN_PACK_BASE_CAP: usize = 2000;
pub fn advertise_refs(
repo: &Repository,
service: Service,
capabilities: &[&str],
) -> Result<Vec<u8>> {
advertise_refs_over(repo.objects(), repo.refs(), service, capabilities)
}
pub fn advertise_refs_over<D, V>(
odb: &D,
refs: &RefStore<V>,
service: Service,
capabilities: &[&str],
) -> Result<Vec<u8>>
where
D: ObjectDatabase,
V: Vfs,
{
let listed = refs.list()?;
let mut out = Vec::new();
let mut caps = capabilities.join(" ");
if odb.algo() == HashAlgo::Sha256 {
if !caps.is_empty() {
caps.push(' ');
}
caps.push_str("object-format=sha256");
}
let mut first = true;
if let Ok(head) = refs.resolve("HEAD") {
emit_ref(&mut out, &mut first, &head, "HEAD", &caps)?;
}
for (name, id) in &listed {
emit_ref(&mut out, &mut first, id, name, &caps)?;
}
if first {
let zero = ObjectId::zero(odb.algo());
let line = alloc::format!("{} capabilities^{{}}\0{}\n", zero.to_hex(), caps);
out.extend_from_slice(&pktline::encode_data(line.as_bytes())?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Flush));
let _ = service; Ok(out)
}
fn emit_ref(
out: &mut Vec<u8>,
first: &mut bool,
id: &ObjectId,
name: &str,
caps: &str,
) -> Result<()> {
let line = if *first {
*first = false;
alloc::format!("{} {}\0{}\n", id.to_hex(), name, caps)
} else {
alloc::format!("{} {}\n", id.to_hex(), name)
};
out.extend_from_slice(&pktline::encode_data(line.as_bytes())?);
Ok(())
}
pub fn upload_pack(repo: &Repository, request: &[u8]) -> Result<Vec<u8>> {
upload_pack_over(repo.objects(), request)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum AckMode {
Single,
Multi,
Detailed,
}
impl AckMode {
fn from_caps(caps: &crate::protocol::Capabilities) -> Self {
if caps.has("multi_ack_detailed") {
AckMode::Detailed
} else if caps.has("multi_ack") {
AckMode::Multi
} else {
AckMode::Single
}
}
}
fn encode_negotiation_acks<D: ObjectDatabase>(
out: &mut Vec<u8>,
odb: &D,
haves: &[ObjectId],
mode: AckMode,
) -> Result<()> {
let common: Vec<&ObjectId> = haves.iter().filter(|h| odb.contains(h)).collect();
if common.is_empty() {
out.extend_from_slice(&pktline::encode_data(b"NAK\n")?);
return Ok(());
}
for (i, oid) in common.iter().enumerate() {
let last = i == common.len() - 1;
let line = match mode {
AckMode::Detailed if last => alloc::format!("ACK {} ready\n", oid.to_hex()),
AckMode::Detailed => alloc::format!("ACK {} common\n", oid.to_hex()),
AckMode::Multi => alloc::format!("ACK {} continue\n", oid.to_hex()),
AckMode::Single => alloc::format!("ACK {}\n", oid.to_hex()),
};
out.extend_from_slice(&pktline::encode_data(line.as_bytes())?);
}
Ok(())
}
pub fn upload_pack_over<D: ObjectDatabase>(odb: &D, request: &[u8]) -> Result<Vec<u8>> {
use crate::pack::PackWriter;
use crate::protocol::FetchRequest;
use crate::protocol::pktline;
let algo = odb.algo();
let packets = pktline::decode_all(request)?;
let req = FetchRequest::parse(algo, &packets)?;
if req.wants.is_empty() {
return Err(crate::error::Error::Protocol(
"upload-pack: request has no wants".into(),
));
}
let mut response = Vec::new();
let to_send = if let Some(depth) = req.deepen {
let client_shallow: alloc::collections::BTreeSet<ObjectId> =
req.shallows.iter().copied().collect();
let plan = plan_deepen(odb, &req.wants, depth, &client_shallow)?;
for oid in &plan.shallow {
response.extend_from_slice(&pktline::encode_data(
alloc::format!("shallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
for oid in &plan.unshallow {
response.extend_from_slice(&pktline::encode_data(
alloc::format!("unshallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
response.extend_from_slice(&pktline::encode_control(&Packet::Flush));
if !req.done {
return Ok(response);
}
plan.objects
} else {
if !req.done {
encode_negotiation_acks(
&mut response,
odb,
&req.haves,
AckMode::from_caps(&req.capabilities),
)?;
return Ok(response);
}
let mut set = crate::walk::objects_to_send(odb, &req.wants, &req.haves)?;
set.extend(req.wants.iter().copied());
set
};
let filter = crate::object_filter::from_spec(req.filter.as_deref());
let wanted: alloc::collections::BTreeSet<ObjectId> = req.wants.iter().copied().collect();
let mut writer = PackWriter::new(algo);
for id in &to_send {
let (ty, payload) = odb.read(id)?;
if ty == crate::object::ObjectType::Blob
&& !filter.keeps_blob(payload.len())
&& !wanted.contains(id)
{
continue;
}
writer.add(ty, &payload);
}
if req.capabilities.has("thin-pack") && !req.haves.is_empty() && req.deepen.is_none() {
let have_closure = crate::walk::reachable_objects(odb, &req.haves)?;
for id in have_closure.iter().take(THIN_PACK_BASE_CAP) {
if let Ok((ty, payload)) = odb.read(id) {
writer.add_external_base(ty, &payload);
}
}
}
let pack = writer.finish()?;
let last_common = req.haves.iter().rev().find(|h| odb.contains(h));
match last_common {
Some(oid) => response.extend_from_slice(&pktline::encode_data(
alloc::format!("ACK {}\n", oid.to_hex()).as_bytes(),
)?),
None => response.extend_from_slice(&pktline::encode_data(b"NAK\n")?),
}
if req.capabilities.has("side-band-64k") {
encode_sideband_pack(&mut response, &pack.pack)?;
response.extend_from_slice(&pktline::encode_control(&Packet::Flush));
} else {
response.extend_from_slice(&pack.pack);
}
Ok(response)
}
pub fn v2_capability_advertisement(repo: &Repository) -> Result<Vec<u8>> {
crate::protocol::v2::capability_advertisement(repo.algo())
}
pub fn serve_v2_upload_pack(repo: &Repository, request: &[u8]) -> Result<Vec<u8>> {
use crate::protocol::v2;
let packets = pktline::decode_all(request)?;
let command = v2::Command::parse(&packets)?;
match command.name.as_str() {
"ls-refs" => v2::encode_ls_refs_response(&command, &v2_ref_list(repo)?),
"fetch" => v2_fetch(repo, &command),
other => Err(crate::error::Error::Protocol(alloc::format!(
"v2: unsupported command {other:?}"
))),
}
}
fn v2_ref_list(repo: &Repository) -> Result<Vec<crate::protocol::v2::LsRef>> {
use crate::refs::Reference;
let mut out = Vec::new();
if let (Ok(Reference::Symbolic(target)), Ok(id)) =
(repo.refs().lookup("HEAD"), repo.refs().resolve("HEAD"))
{
out.push(("HEAD".to_string(), id, Some(target), peel_tag(repo, &id)));
}
for (name, id) in repo.refs().list()? {
let peeled = peel_tag(repo, &id);
out.push((name, id, None, peeled));
}
Ok(out)
}
fn peel_tag(repo: &Repository, id: &ObjectId) -> Option<ObjectId> {
use crate::object::Object;
let mut cur = *id;
while let Ok(Object::Tag(tag)) = repo.read_object(&cur) {
cur = tag.object;
}
(cur != *id).then_some(cur)
}
fn v2_fetch(repo: &Repository, command: &crate::protocol::v2::Command) -> Result<Vec<u8>> {
use crate::protocol::v2;
let mut args = v2::FetchArgs::parse(repo.algo(), command)?;
let mut wanted_refs: Vec<(ObjectId, alloc::string::String)> = Vec::new();
for name in &args.want_refs {
if let Ok(id) = repo.refs().resolve(name) {
wanted_refs.push((id, name.clone()));
args.wants.push(id);
}
}
if args.wants.is_empty() {
return Err(crate::error::Error::Protocol(
"v2 fetch: request has no wants".into(),
));
}
let filter = crate::object_filter::from_spec(args.filter.as_deref());
let wanted: alloc::collections::BTreeSet<ObjectId> = args.wants.iter().copied().collect();
let plan = if let Some(depth) = args.deepen {
let client_shallow: alloc::collections::BTreeSet<ObjectId> =
args.shallows.iter().copied().collect();
plan_deepen(repo.objects(), &args.wants, depth, &client_shallow)?
} else {
let mut objects = crate::walk::objects_to_send(repo.objects(), &args.wants, &args.haves)?;
objects.extend(args.wants.iter().copied());
DeepenPlan {
objects,
shallow: Vec::new(),
unshallow: Vec::new(),
}
};
let pack = build_pack(repo, &plan.objects, filter, &wanted)?;
let ack_section = if args.done {
None
} else {
let common: Vec<ObjectId> = args
.haves
.iter()
.filter(|h| repo.objects().contains(h))
.copied()
.collect();
Some(common)
};
v2::encode_fetch_sections(
ack_section.as_deref(),
&plan.shallow,
&plan.unshallow,
&wanted_refs,
&[],
&pack,
)
}
struct DeepenPlan {
objects: alloc::collections::BTreeSet<ObjectId>,
shallow: Vec<ObjectId>,
unshallow: Vec<ObjectId>,
}
fn plan_deepen<D: crate::odb::ObjectDatabase>(
odb: &D,
wants: &[ObjectId],
depth: u32,
client_shallow: &alloc::collections::BTreeSet<ObjectId>,
) -> Result<DeepenPlan> {
let empty = alloc::collections::BTreeSet::new();
let target = crate::walk::shallow_objects_to_send(odb, wants, depth, &empty)?;
if client_shallow.is_empty() {
return Ok(DeepenPlan {
objects: target.objects,
shallow: target.shallow.into_iter().collect(),
unshallow: Vec::new(),
});
}
let have = crate::walk::shallow_objects_to_send(odb, wants, u32::MAX, client_shallow)?;
let objects = target.objects.difference(&have.objects).copied().collect();
let shallow = target.shallow.difference(client_shallow).copied().collect();
let unshallow = client_shallow
.iter()
.filter(|c| target.objects.contains(c) && !target.shallow.contains(c))
.copied()
.collect();
Ok(DeepenPlan {
objects,
shallow,
unshallow,
})
}
fn build_pack(
repo: &Repository,
ids: &alloc::collections::BTreeSet<ObjectId>,
filter: crate::object_filter::ObjectFilter,
wanted: &alloc::collections::BTreeSet<ObjectId>,
) -> Result<Vec<u8>> {
use crate::pack::PackWriter;
let mut writer = PackWriter::new(repo.algo());
for id in ids {
let (ty, payload) = repo.objects().read(id)?;
if ty == crate::object::ObjectType::Blob
&& !filter.keeps_blob(payload.len())
&& !wanted.contains(id)
{
continue;
}
writer.add(ty, &payload);
}
Ok(writer.finish()?.pack)
}
const SIDEBAND_PACK: u8 = 1;
const SIDEBAND_MAX_PAYLOAD: usize = crate::protocol::pktline::MAX_PKTLINE_LEN - 4 - 1;
fn encode_sideband_pack(out: &mut Vec<u8>, pack: &[u8]) -> Result<()> {
use crate::protocol::pktline;
for chunk in pack.chunks(SIDEBAND_MAX_PAYLOAD) {
let mut line = Vec::with_capacity(chunk.len() + 1);
line.push(SIDEBAND_PACK);
line.extend_from_slice(chunk);
out.extend_from_slice(&pktline::encode_data(&line)?);
}
Ok(())
}
pub fn serve_ssh_upload_pack<S, F>(command: &str, mut stream: S, resolve: F) -> Result<()>
where
S: std::io::Read + std::io::Write,
F: FnOnce(&str) -> Option<Repository>,
{
let (service, path) = parse_ssh_command(command)?;
if service != Service::UploadPack {
return Err(crate::error::Error::Protocol(
"ssh: only git-upload-pack is served here".into(),
));
}
let repo = resolve(&path).ok_or_else(|| {
crate::error::Error::Io(alloc::format!("ssh: repository {path:?} not found"))
})?;
let advert = advertise_refs(
&repo,
service,
&[
"multi_ack_detailed",
"multi_ack",
"ofs-delta",
"thin-pack",
"side-band-64k",
],
)?;
stream
.write_all(&advert)
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
stream
.flush()
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
let request = daemon::read_until_done(&mut stream)?;
if request.is_empty() {
return Ok(()); }
let response = upload_pack(&repo, &request)?;
stream
.write_all(&response)
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
stream
.flush()
.map_err(|e| crate::error::Error::Io(e.to_string()))
}
pub fn serve_ssh_receive_pack<S, F>(
command: &str,
mut stream: S,
resolve: F,
hook: &UpdateHook<'_>,
) -> Result<()>
where
S: std::io::Read + std::io::Write,
F: FnOnce(&str) -> Option<Repository>,
{
let (service, path) = parse_ssh_command(command)?;
if service != Service::ReceivePack {
return Err(crate::error::Error::Protocol(
"ssh: only git-receive-pack is served here".into(),
));
}
let repo = resolve(&path).ok_or_else(|| {
crate::error::Error::Io(alloc::format!("ssh: repository {path:?} not found"))
})?;
let advert = advertise_refs(
&repo,
service,
&["report-status", "delete-refs", "ofs-delta"],
)?;
stream
.write_all(&advert)
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
stream
.flush()
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
let request = read_receive_request(&mut stream, repo.algo())?;
if request.is_empty() {
return Ok(()); }
let response = receive_pack_with(&repo, &request, hook)?;
stream
.write_all(&response)
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
stream
.flush()
.map_err(|e| crate::error::Error::Io(e.to_string()))
}
const MAX_RECEIVE_PACK_BYTES: usize = 2 * 1024 * 1024 * 1024;
fn read_receive_request<S: std::io::Read>(
stream: &mut S,
algo: crate::oid::HashAlgo,
) -> Result<Vec<u8>> {
use crate::protocol::PushRequest;
let mut buf = Vec::new();
let mut packets = 0usize;
while let Ok((packet, raw)) = daemon::read_pktline(stream) {
packets += 1;
if packets > 100_000 {
return Err(crate::error::Error::Protocol(
"ssh: receive-pack command count exceeded".into(),
));
}
buf.extend_from_slice(&raw);
if matches!(packet, Packet::Flush) {
break;
}
}
if buf.is_empty() {
return Ok(buf);
}
let (command_bytes, _) = split_at_flush(&buf)?;
let req = PushRequest::parse(algo, &pktline::decode_all(command_bytes)?)?;
let expects_pack = req.commands.iter().any(|c| !c.new.is_zero());
if expects_pack {
read_full_pack(stream, algo, &mut buf)?;
}
Ok(buf)
}
fn read_full_pack<S: std::io::Read>(
stream: &mut S,
algo: crate::oid::HashAlgo,
buf: &mut Vec<u8>,
) -> Result<()> {
let start = buf.len();
let id_len = algo.raw_len();
let mut chunk = [0u8; 65536];
fill_to(stream, buf, start + 12, &mut chunk)?;
if &buf[start..start + 4] != b"PACK" {
return Err(crate::error::Error::Pack("ssh: expected a packfile".into()));
}
let count = u32::from_be_bytes([
buf[start + 8],
buf[start + 9],
buf[start + 10],
buf[start + 11],
]);
let mut off = 12usize; let mut parsed = 0u32;
while parsed < count {
match crate::pack::pack_frame_end(&buf[start..], off, algo) {
Ok(end) => {
off = end;
parsed += 1;
}
Err(_) => {
read_more(stream, buf, &mut chunk)?;
}
}
}
fill_to(stream, buf, start + off + id_len, &mut chunk)?;
buf.truncate(start + off + id_len);
Ok(())
}
fn fill_to<S: std::io::Read>(
stream: &mut S,
buf: &mut Vec<u8>,
target: usize,
chunk: &mut [u8],
) -> Result<()> {
while buf.len() < target {
read_more(stream, buf, chunk)?;
}
Ok(())
}
fn read_more<S: std::io::Read>(stream: &mut S, buf: &mut Vec<u8>, chunk: &mut [u8]) -> Result<()> {
if buf.len() > MAX_RECEIVE_PACK_BYTES {
return Err(crate::error::Error::Pack(
"ssh: receive-pack packfile too large".into(),
));
}
let n = stream
.read(chunk)
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
if n == 0 {
return Err(crate::error::Error::Pack(
"ssh: receive-pack packfile truncated".into(),
));
}
buf.extend_from_slice(&chunk[..n]);
Ok(())
}
fn parse_ssh_command(command: &str) -> Result<(Service, alloc::string::String)> {
let command = command.trim();
let (service, rest) = if let Some(r) = command.strip_prefix("git-upload-pack") {
(Service::UploadPack, r)
} else if let Some(r) = command.strip_prefix("git-receive-pack") {
(Service::ReceivePack, r)
} else {
return Err(crate::error::Error::Protocol(alloc::format!(
"ssh: unsupported command {command:?}"
)));
};
Ok((service, unquote_posix(rest.trim())))
}
fn unquote_posix(s: &str) -> alloc::string::String {
if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
s[1..s.len() - 1].replace("'\\''", "'")
} else {
s.to_string()
}
}
pub fn receive_pack(repo: &Repository, request: &[u8]) -> Result<Vec<u8>> {
receive_pack_with(repo, request, &|_| Ok(()))
}
pub type UpdateHook<'a> =
dyn Fn(&crate::protocol::RefUpdateCommand) -> core::result::Result<(), String> + 'a;
pub type PostReceiveHook<'a> =
dyn Fn(&[(alloc::string::String, core::result::Result<(), String>)]) + 'a;
pub fn receive_pack_with(
repo: &Repository,
request: &[u8],
hook: &UpdateHook<'_>,
) -> Result<Vec<u8>> {
receive_pack_with_hooks(repo, request, hook, &|_| {})
}
pub fn receive_pack_with_hooks(
repo: &Repository,
request: &[u8],
hook: &UpdateHook<'_>,
post: &PostReceiveHook<'_>,
) -> Result<Vec<u8>> {
receive_pack_over(repo.objects(), repo.refs(), request, hook, post)
}
pub fn receive_pack_over<D, V>(
odb: &D,
refs: &RefStore<V>,
request: &[u8],
hook: &UpdateHook<'_>,
post: &PostReceiveHook<'_>,
) -> Result<Vec<u8>>
where
D: ObjectDatabase,
V: Vfs,
{
use crate::protocol::{PushRequest, RefStatus, ReportStatus};
let (command_bytes, pack_start) = split_at_flush(request)?;
let packets = pktline::decode_all(command_bytes)?;
let req = PushRequest::parse(odb.algo(), &packets)?;
let pack = &request[pack_start..];
let unpack = if pack.starts_with(b"PACK") {
match ingest_into(odb, pack) {
Ok(_) => Ok(()),
Err(e) => Err(alloc::format!("{e}")),
}
} else {
Ok(())
};
let mut statuses = Vec::new();
for cmd in &req.commands {
let result = if unpack.is_err() {
Err("unpacker error".to_string())
} else if let Err(reason) = hook(cmd) {
Err(reason)
} else {
apply_command(odb, refs, cmd)
};
statuses.push(RefStatus::new(cmd.name.clone(), result));
}
if unpack.is_ok() {
let outcomes: Vec<(alloc::string::String, core::result::Result<(), String>)> = statuses
.iter()
.map(|s| (s.name.clone(), s.result.clone()))
.collect();
post(&outcomes);
}
ReportStatus {
unpack,
refs: statuses,
}
.encode()
}
fn ingest_into<D: ObjectDatabase>(odb: &D, pack: &[u8]) -> Result<Vec<ObjectId>> {
let resolver = |id: &ObjectId| odb.read(id);
let objects = crate::pack::explode_pack(pack, odb.algo(), &resolver)?;
let mut written = Vec::with_capacity(objects.len());
for (_, ty, payload) in &objects {
written.push(odb.write(*ty, payload)?);
}
Ok(written)
}
fn apply_command<D, V>(
odb: &D,
refs: &RefStore<V>,
cmd: &crate::protocol::RefUpdateCommand,
) -> core::result::Result<(), String>
where
D: ObjectDatabase,
V: Vfs,
{
use crate::refs::Reference;
if cmd.new.is_zero() {
if !cmd.old.is_zero() {
match refs.resolve(&cmd.name).ok() {
Some(cur) if cur == cmd.old => {}
Some(_) => return Err("stale info".to_string()),
None => return Err("ref does not exist".to_string()),
}
}
return refs.delete(&cmd.name).map_err(|e| alloc::format!("{e}"));
}
if !odb.contains(&cmd.new) {
return Err("missing necessary objects".to_string());
}
let current = refs.resolve(&cmd.name).ok();
match (cmd.old.is_zero(), current) {
(true, Some(_)) => return Err("ref already exists".to_string()),
(true, None) => {}
(false, Some(cur)) if cur == cmd.old => {
if cmd.name.starts_with("refs/heads/") {
let ff = crate::walk::is_ancestor(odb, &cmd.old, &cmd.new)
.map_err(|e| alloc::format!("{e}"))?;
if !ff {
return Err("non-fast-forward".to_string());
}
}
}
(false, Some(_)) => return Err("stale info".to_string()),
(false, None) => return Err("stale info".to_string()),
}
refs.update(&cmd.name, &Reference::Direct(cmd.new))
.map_err(|e| alloc::format!("{e}"))
}
fn split_at_flush(request: &[u8]) -> Result<(&[u8], usize)> {
let mut off = 0;
loop {
match pktline::decode(&request[off..])? {
Some((Packet::Flush, n)) => {
let end = off + n;
return Ok((&request[..end], end));
}
Some((_, n)) => off += n,
None => {
return Err(crate::error::Error::Protocol(
"receive-pack: command list not flush-terminated".into(),
));
}
}
}
}
pub struct LocalTransport<'a> {
remote: &'a Repository,
capabilities: Vec<&'static str>,
}
impl<'a> LocalTransport<'a> {
pub fn new(remote: &'a Repository) -> Self {
LocalTransport {
remote,
capabilities: alloc::vec!["ofs-delta", "report-status", "thin-pack", "side-band-64k"],
}
}
}
impl crate::transport::Transport for LocalTransport<'_> {
fn discover(&mut self, service: Service) -> Result<crate::protocol::RefAdvertisement> {
let bytes = advertise_refs(self.remote, service, &self.capabilities)?;
let packets = pktline::decode_all(&bytes)?;
crate::protocol::RefAdvertisement::parse(self.remote.algo(), &packets)
}
fn exchange(&mut self, service: Service, request: &[u8]) -> Result<Vec<u8>> {
match service {
Service::UploadPack => upload_pack(self.remote, request),
Service::ReceivePack => receive_pack(self.remote, request),
}
}
}
#[cfg(all(test, feature = "client"))]
mod tests {
use super::*;
use crate::object::{Object, Signature};
use crate::oid::HashAlgo;
fn sig() -> Signature {
Signature {
name: b"Tester".to_vec(),
email: b"t@example.com".to_vec(),
time: 1_700_000_000,
tz: b"+0000".to_vec(),
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(alloc::format!("puregit-srv-{name}-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn plan_deepen_extends_boundary_and_unshallows() {
use alloc::collections::BTreeSet;
let dir = scratch("deepen-plan");
let repo = Repository::init(&dir).unwrap();
std::fs::write(dir.join("f"), b"1\n").unwrap();
repo.add_path("f").unwrap();
let c1 = repo.commit(b"c1\n", sig(), sig()).unwrap();
std::fs::write(dir.join("f"), b"2\n").unwrap();
repo.add_path("f").unwrap();
let c2 = repo.commit(b"c2\n", sig(), sig()).unwrap();
std::fs::write(dir.join("f"), b"3\n").unwrap();
repo.add_path("f").unwrap();
let c3 = repo.commit(b"c3\n", sig(), sig()).unwrap();
let p1 = plan_deepen(repo.objects(), &[c3], 1, &BTreeSet::new()).unwrap();
assert!(p1.objects.contains(&c3) && !p1.objects.contains(&c2));
assert_eq!(p1.shallow, alloc::vec![c3]);
assert!(p1.unshallow.is_empty());
let client_shallow = BTreeSet::from([c3]);
let p2 = plan_deepen(repo.objects(), &[c3], 2, &client_shallow).unwrap();
assert!(p2.objects.contains(&c2), "the newly-reached commit is sent");
assert!(!p2.objects.contains(&c3), "the client already has c3");
assert!(!p2.objects.contains(&c1), "c1 is still beyond depth 2");
assert_eq!(p2.shallow, alloc::vec![c2]);
assert_eq!(p2.unshallow, alloc::vec![c3]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn loopback_deepen_extends_a_shallow_clone() {
let remote_dir = scratch("deepen-remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"1\n").unwrap();
remote.add_path("f.txt").unwrap();
let c1 = remote.commit(b"c1\n", sig(), sig()).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"2\n").unwrap();
remote.add_path("f.txt").unwrap();
let c2 = remote.commit(b"c2\n", sig(), sig()).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"3\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"c3\n", sig(), sig()).unwrap();
let dest = scratch("deepen-clone");
let mut transport = LocalTransport::new(&remote);
let mut cloned = crate::client::clone_with_depth(&dest, &mut transport, Some(1)).unwrap();
assert!(cloned.objects().contains(&tip) && !cloned.objects().contains(&c2));
assert_eq!(cloned.shallow().unwrap(), alloc::vec![tip]);
let mut transport = LocalTransport::new(&remote);
crate::client::fetch_with_depth(&mut cloned, &mut transport, Some(2)).unwrap();
assert!(cloned.objects().contains(&c2), "the deeper commit arrived");
assert!(!cloned.objects().contains(&c1), "still beyond depth 2");
assert_eq!(cloned.shallow().unwrap(), alloc::vec![c2]);
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn loopback_partial_clone_then_backfill() {
use crate::object::{Object, ObjectType};
let remote_dir = scratch("partial-remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"blob contents here\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"c\n", sig(), sig()).unwrap();
let blob = crate::walk::reachable_objects(remote.objects(), &[tip])
.unwrap()
.into_iter()
.find(|id| matches!(remote.objects().read(id), Ok((ObjectType::Blob, _))))
.unwrap();
let dest = scratch("partial-clone");
let mut repo = Repository::init(&dest).unwrap();
let mut transport = LocalTransport::new(&remote);
crate::client::fetch_with_filter(&mut repo, &mut transport, Some("blob:none".into()))
.unwrap();
assert!(repo.objects().contains(&tip));
assert!(!repo.objects().contains(&blob), "blob:none omits the blob");
let mut transport = LocalTransport::new(&remote);
let received = crate::client::fetch_objects(&mut repo, &mut transport, &[blob]).unwrap();
assert_eq!(received, 1);
assert!(repo.objects().contains(&blob));
match repo.read_object(&blob).unwrap() {
Object::Blob(b) => assert_eq!(b, b"blob contents here\n"),
_ => panic!("not a blob"),
}
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn loopback_partial_clone_transparent_checkout() {
use crate::object::Object;
let remote_dir = scratch("promisor-remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("top.txt"), b"top level\n").unwrap();
std::fs::create_dir_all(remote_dir.join("sub")).unwrap();
std::fs::write(remote_dir.join("sub/inner.txt"), b"nested content\n").unwrap();
remote.add_path("top.txt").unwrap();
remote.add_path("sub/inner.txt").unwrap();
let tip = remote.commit(b"c\n", sig(), sig()).unwrap();
let tree = match remote.read_object(&tip).unwrap() {
Object::Commit(c) => c.tree,
_ => panic!("tip is not a commit"),
};
let dest = scratch("promisor-clone");
let mut repo = Repository::init(&dest).unwrap();
let mut transport = LocalTransport::new(&remote);
crate::client::fetch_with_filter(&mut repo, &mut transport, Some("blob:none".into()))
.unwrap();
let missing = crate::walk::missing_tree_objects(repo.objects(), &tree).unwrap();
assert_eq!(missing.len(), 2, "both blobs are absent after blob:none");
let mut transport = LocalTransport::new(&remote);
crate::client::checkout_tree(&mut repo, &mut transport, &tree, &dest).unwrap();
assert_eq!(std::fs::read(dest.join("top.txt")).unwrap(), b"top level\n");
assert_eq!(
std::fs::read(dest.join("sub/inner.txt")).unwrap(),
b"nested content\n"
);
assert!(
crate::walk::missing_tree_objects(repo.objects(), &tree)
.unwrap()
.is_empty()
);
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn loopback_multi_round_negotiation_fetches_new_commits() {
let remote_dir = scratch("negotiate-remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"one\n").unwrap();
remote.add_path("f.txt").unwrap();
remote.commit(b"one\n", sig(), sig()).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"two\n").unwrap();
remote.add_path("f.txt").unwrap();
let c2 = remote.commit(b"two\n", sig(), sig()).unwrap();
let dest = scratch("negotiate-clone");
let mut transport = LocalTransport::new(&remote);
let mut repo = crate::client::clone(&dest, &mut transport).unwrap();
assert!(repo.objects().contains(&c2));
std::fs::write(remote_dir.join("f.txt"), b"three\n").unwrap();
remote.add_path("f.txt").unwrap();
let c3 = remote.commit(b"three\n", sig(), sig()).unwrap();
let mut transport = LocalTransport::new(&remote);
let before = crate::walk::reachable_objects(repo.objects(), &[c2])
.unwrap()
.len();
let outcome = crate::client::fetch_negotiated(&mut repo, &mut transport).unwrap();
assert!(repo.objects().contains(&c3), "the new commit arrived");
assert!(
outcome.received_objects < before,
"negotiation truncated the pack to the new objects ({} received, {} already present)",
outcome.received_objects,
before
);
assert_eq!(outcome.received_objects, 3, "new commit + tree + blob");
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn loopback_shallow_clone_truncates_history() {
let remote_dir = scratch("shallow-remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"one\n").unwrap();
remote.add_path("f.txt").unwrap();
let c1 = remote.commit(b"one\n", sig(), sig()).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"two\n").unwrap();
remote.add_path("f.txt").unwrap();
let c2 = remote.commit(b"two\n", sig(), sig()).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"three\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"three\n", sig(), sig()).unwrap();
let dest = scratch("shallow-clone");
let mut transport = LocalTransport::new(&remote);
let cloned = crate::client::clone_with_depth(&dest, &mut transport, Some(1)).unwrap();
assert!(cloned.objects().contains(&tip));
assert!(!cloned.objects().contains(&c2));
assert!(!cloned.objects().contains(&c1));
assert_eq!(cloned.shallow().unwrap(), alloc::vec![tip]);
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn loopback_clone_round_trip() {
let remote_dir = scratch("remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("a.txt"), b"hello\n").unwrap();
std::fs::create_dir_all(remote_dir.join("src")).unwrap();
std::fs::write(remote_dir.join("src/main.rs"), b"fn main() {}\n").unwrap();
remote.add_path("a.txt").unwrap();
remote.add_path("src/main.rs").unwrap();
remote.commit(b"first\n", sig(), sig()).unwrap();
std::fs::write(remote_dir.join("a.txt"), b"hello\nworld\n").unwrap();
remote.add_path("a.txt").unwrap();
let tip = remote.commit(b"second\n", sig(), sig()).unwrap();
let dest_dir = scratch("clone");
let mut transport = LocalTransport::new(&remote);
let cloned = crate::client::clone(&dest_dir, &mut transport).unwrap();
assert_eq!(cloned.algo(), HashAlgo::Sha1);
assert_eq!(cloned.head_id().unwrap(), tip);
assert!(cloned.objects().contains(&tip));
assert_eq!(
cloned.refs().resolve("refs/remotes/origin/main").unwrap(),
tip
);
assert_eq!(
std::fs::read(dest_dir.join("a.txt")).unwrap(),
b"hello\nworld\n"
);
assert_eq!(
std::fs::read(dest_dir.join("src/main.rs")).unwrap(),
b"fn main() {}\n"
);
match cloned.read_object(&tip).unwrap() {
Object::Commit(c) => assert_eq!(c.summary(), b"second"),
_ => panic!("tip is not a commit"),
}
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest_dir);
}
#[test]
fn partial_clone_filter_omits_blobs_but_keeps_wanted_ones() {
use crate::object::ObjectType;
use crate::protocol::{Capabilities, FetchRequest};
let remote_dir = scratch("filter");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"file contents\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"only\n", sig(), sig()).unwrap();
let blob = crate::walk::reachable_objects(remote.objects(), &[tip])
.unwrap()
.into_iter()
.find(|id| matches!(remote.objects().read(id), Ok((ObjectType::Blob, _))))
.unwrap();
let explode = |resp: &[u8]| {
let at = resp.windows(4).position(|w| w == b"PACK").unwrap();
let resolver = |id: &crate::oid::ObjectId| remote.objects().read(id);
crate::pack::explode_pack(&resp[at..], remote.algo(), &resolver).unwrap()
};
let mut req = FetchRequest {
wants: alloc::vec![tip],
..Default::default()
};
req.filter = Some("blob:none".into());
let resp = upload_pack(&remote, &req.encode().unwrap()).unwrap();
let types: Vec<ObjectType> = explode(&resp).iter().map(|(_, t, _)| *t).collect();
assert!(types.contains(&ObjectType::Commit));
assert!(types.contains(&ObjectType::Tree));
assert!(
!types.contains(&ObjectType::Blob),
"blob:none must omit blobs"
);
let mut req2 = FetchRequest {
wants: alloc::vec![blob],
haves: alloc::vec![tip],
..Default::default()
};
req2.filter = Some("blob:none".into());
req2.capabilities = Capabilities::new();
let resp2 = upload_pack(&remote, &req2.encode().unwrap()).unwrap();
assert!(
explode(&resp2)
.iter()
.any(|(id, ty, _)| *ty == ObjectType::Blob && *id == blob),
"an explicitly-wanted blob must be sent even under blob:none"
);
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn upload_pack_acknowledges_common_haves_in_a_negotiation_round() {
use crate::protocol::{Capabilities, FetchRequest};
let remote_dir = scratch("ack-remote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"x\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"c\n", sig(), sig()).unwrap();
let req = FetchRequest {
wants: alloc::vec![tip],
haves: alloc::vec![tip],
capabilities: Capabilities::parse("multi_ack_detailed"),
..Default::default()
};
let resp = upload_pack(&remote, &req.encode_negotiation().unwrap()).unwrap();
let text = String::from_utf8_lossy(&resp);
assert!(
text.contains(&alloc::format!("ACK {} ready", tip.to_hex())),
"the common have is acknowledged ready: {text:?}"
);
assert!(
!resp.windows(4).any(|w| w == b"PACK"),
"a negotiation round sends no pack"
);
let unknown = ObjectId::from_hex(remote.algo(), &"3".repeat(40)).unwrap();
let req = FetchRequest {
wants: alloc::vec![tip],
haves: alloc::vec![unknown],
capabilities: Capabilities::parse("multi_ack_detailed"),
..Default::default()
};
let resp = upload_pack(&remote, &req.encode_negotiation().unwrap()).unwrap();
assert!(
String::from_utf8_lossy(&resp).contains("NAK"),
"no common object yields NAK"
);
assert!(!resp.windows(4).any(|w| w == b"PACK"));
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn upload_pack_multiplexes_over_sideband_when_offered() {
use crate::protocol::pktline::{self, Packet};
use crate::protocol::{Capabilities, FetchRequest};
let remote_dir = scratch("sideband");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"hi\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"only\n", sig(), sig()).unwrap();
let base = FetchRequest {
wants: alloc::vec![tip],
haves: Vec::new(),
..Default::default()
};
let mut sb = base.clone();
sb.capabilities = Capabilities::new();
sb.capabilities.add_flag("side-band-64k");
let plain = upload_pack(&remote, &base.encode().unwrap()).unwrap();
let (first, n) = pktline::decode(&plain).unwrap().unwrap();
assert_eq!(first, Packet::Data(b"NAK\n".to_vec()));
assert!(plain[n..].starts_with(b"PACK"), "raw pack after NAK");
let muxed = upload_pack(&remote, &sb.encode().unwrap()).unwrap();
let mut off = pktline::decode(&muxed).unwrap().unwrap().1; let mut pack = Vec::new();
let mut saw_flush = false;
while off < muxed.len() {
let (packet, adv) = pktline::decode(&muxed[off..]).unwrap().unwrap();
off += adv;
match packet {
Packet::Data(p) => {
assert_eq!(p.first(), Some(&1u8), "pack must ride channel 1");
pack.extend_from_slice(&p[1..]);
}
Packet::Flush => {
saw_flush = true;
break;
}
other => panic!("unexpected packet: {other:?}"),
}
}
assert!(saw_flush, "sideband stream ends with a flush");
assert!(pack.starts_with(b"PACK"), "demuxed bytes are a packfile");
assert_eq!(pack, plain[n..], "sideband and raw packs are identical");
let _ = std::fs::remove_dir_all(&remote_dir);
}
struct MockChannel {
input: std::io::Cursor<Vec<u8>>,
output: Vec<u8>,
chunk_limit: usize,
}
impl MockChannel {
fn new(input: Vec<u8>) -> Self {
MockChannel {
input: std::io::Cursor::new(input),
output: Vec::new(),
chunk_limit: 0,
}
}
}
impl std::io::Read for MockChannel {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.chunk_limit == 0 {
std::io::Read::read(&mut self.input, buf)
} else {
let cap = self.chunk_limit.min(buf.len());
std::io::Read::read(&mut self.input, &mut buf[..cap])
}
}
}
impl std::io::Write for MockChannel {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn encode_push_request(
local: &Repository,
commands: Vec<crate::protocol::RefUpdateCommand>,
remote_haves: &[crate::oid::ObjectId],
) -> Vec<u8> {
use crate::pack::PackWriter;
use crate::protocol::PushRequest;
let wants: Vec<crate::oid::ObjectId> = commands
.iter()
.filter(|c| !c.new.is_zero())
.map(|c| c.new)
.collect();
let mut request = PushRequest {
commands,
..Default::default()
};
request.capabilities.add_flag("report-status");
let mut bytes = request.encode().unwrap();
if !wants.is_empty() {
let to_send =
crate::walk::objects_to_send(local.objects(), &wants, remote_haves).unwrap();
let mut writer = PackWriter::new(local.algo());
for id in &to_send {
let (ty, payload) = local.objects().read(id).unwrap();
writer.add(ty, &payload);
}
bytes.extend_from_slice(&writer.finish().unwrap().pack);
}
bytes
}
#[test]
fn v2_ref_in_want_resolves_and_echoes_the_ref() {
use crate::protocol::v2;
let remote_dir = scratch("refinwant");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("f.txt"), b"x\n").unwrap();
remote.add_path("f.txt").unwrap();
let tip = remote.commit(b"c\n", sig(), sig()).unwrap();
let request = v2::encode_command(
"fetch",
&[],
&["want-ref refs/heads/main".to_string(), "done".to_string()],
)
.unwrap();
let response = serve_v2_upload_pack(&remote, &request).unwrap();
let text = String::from_utf8_lossy(&response);
assert!(text.contains("wanted-refs"), "wanted-refs section present");
assert!(
text.contains(&alloc::format!("{} refs/heads/main", tip.to_hex())),
"the ref resolves to its tip"
);
assert!(response.windows(4).any(|w| w == b"PACK"), "pack present");
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn serves_fetch_from_a_non_repository_backend() {
use crate::odb::MemoryOdb;
use crate::protocol::{FetchRequest, RefAdvertisement};
use crate::refs::Reference;
use crate::vfs::StdFs;
let src_dir = scratch("generic-src");
let src = Repository::init(&src_dir).unwrap();
std::fs::write(src_dir.join("a.txt"), b"alpha\n").unwrap();
src.add_path("a.txt").unwrap();
let tip = src.commit(b"only\n", sig(), sig()).unwrap();
let mem = MemoryOdb::new(src.algo());
for id in crate::walk::reachable_objects(src.objects(), &[tip]).unwrap() {
let (ty, payload) = src.objects().read(&id).unwrap();
mem.write(ty, &payload).unwrap();
}
let refs_dir = scratch("generic-refs");
let refs = RefStore::new(StdFs::new(&refs_dir), src.algo());
refs.update("refs/heads/main", &Reference::Direct(tip))
.unwrap();
refs.update("HEAD", &Reference::Symbolic("refs/heads/main".into()))
.unwrap();
let advert_bytes =
advertise_refs_over(&mem, &refs, Service::UploadPack, &["ofs-delta"]).unwrap();
let advert =
RefAdvertisement::parse(mem.algo(), &pktline::decode_all(&advert_bytes).unwrap())
.unwrap();
assert!(
advert
.refs
.iter()
.any(|r| r.name == "refs/heads/main" && r.id == tip),
"advertisement must list the branch at tip"
);
let req = FetchRequest {
wants: alloc::vec![tip],
haves: Vec::new(),
..Default::default()
};
let response = upload_pack_over(&mem, &req.encode().unwrap()).unwrap();
let pack_at = response
.windows(4)
.position(|w| w == b"PACK")
.expect("response contains a packfile");
let pack = &response[pack_at..];
let resolver = |id: &crate::oid::ObjectId| mem.read(id);
let exploded = crate::pack::explode_pack(pack, mem.algo(), &resolver).unwrap();
assert!(
exploded.iter().any(|(id, _, _)| *id == tip),
"served pack must contain the requested commit"
);
let _ = std::fs::remove_dir_all(&src_dir);
let _ = std::fs::remove_dir_all(&refs_dir);
}
#[test]
fn ssh_receive_pack_applies_a_push() {
use crate::protocol::RefUpdateCommand;
let local_dir = scratch("sshpush-local");
let local = Repository::init(&local_dir).unwrap();
std::fs::write(local_dir.join("f.txt"), b"content\n").unwrap();
local.add_path("f.txt").unwrap();
let tip = local.commit(b"only\n", sig(), sig()).unwrap();
let remote_dir = scratch("sshpush-remote");
Repository::init(&remote_dir).unwrap();
let request = encode_push_request(
&local,
alloc::vec![RefUpdateCommand {
old: crate::oid::ObjectId::zero(local.algo()),
new: tip,
name: "refs/heads/main".into(),
}],
&[],
);
let remote_path = remote_dir.clone();
let mut ch = MockChannel::new(request);
serve_ssh_receive_pack(
"git-receive-pack 'repo.git'",
&mut ch,
|_path| Some(Repository::open(&remote_path).unwrap()),
&|_| Ok(()),
)
.unwrap();
assert!(
ch.output.windows(3).any(|w| w == b"ok "),
"report-status should contain an ok line: {:?}",
String::from_utf8_lossy(&ch.output)
);
let remote = Repository::open(&remote_dir).unwrap();
assert_eq!(remote.refs().resolve("refs/heads/main").unwrap(), tip);
assert!(remote.objects().contains(&tip));
let _ = std::fs::remove_dir_all(&local_dir);
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn ssh_receive_pack_reassembles_a_fragmented_pack() {
use crate::protocol::RefUpdateCommand;
let local_dir = scratch("sshfrag-local");
let local = Repository::init(&local_dir).unwrap();
for i in 0..8 {
std::fs::write(
local_dir.join(alloc::format!("f{i}.txt")),
alloc::format!("payload number {i}, padded for bulk ----------\n").repeat(4),
)
.unwrap();
local.add_path(&alloc::format!("f{i}.txt")).unwrap();
}
local.commit(b"bulk one\n", sig(), sig()).unwrap();
std::fs::write(local_dir.join("f0.txt"), b"changed\n").unwrap();
local.add_path("f0.txt").unwrap();
let tip = local.commit(b"bulk two\n", sig(), sig()).unwrap();
let remote_dir = scratch("sshfrag-remote");
Repository::init(&remote_dir).unwrap();
let request = encode_push_request(
&local,
alloc::vec![RefUpdateCommand {
old: crate::oid::ObjectId::zero(local.algo()),
new: tip,
name: "refs/heads/main".into(),
}],
&[],
);
let remote_path = remote_dir.clone();
let mut ch = MockChannel::new(request);
ch.chunk_limit = 7;
serve_ssh_receive_pack(
"git-receive-pack 'repo.git'",
&mut ch,
|_path| Some(Repository::open(&remote_path).unwrap()),
&|_| Ok(()),
)
.unwrap();
let remote = Repository::open(&remote_dir).unwrap();
assert_eq!(remote.refs().resolve("refs/heads/main").unwrap(), tip);
assert!(remote.objects().contains(&tip));
let _ = std::fs::remove_dir_all(&local_dir);
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn ssh_receive_pack_handles_delete_only_push_without_a_pack() {
use crate::protocol::RefUpdateCommand;
use crate::refs::Reference;
let local_dir = scratch("sshdel-local");
let local = Repository::init(&local_dir).unwrap();
std::fs::write(local_dir.join("f.txt"), b"x\n").unwrap();
local.add_path("f.txt").unwrap();
let tip = local.commit(b"c\n", sig(), sig()).unwrap();
let remote_dir = scratch("sshdel-remote");
let remote = Repository::init(&remote_dir).unwrap();
remote
.refs()
.update("refs/heads/doomed", &Reference::Direct(tip))
.unwrap();
let request = encode_push_request(
&local,
alloc::vec![RefUpdateCommand {
old: tip,
new: crate::oid::ObjectId::zero(local.algo()),
name: "refs/heads/doomed".into(),
}],
&[],
);
let remote_path = remote_dir.clone();
let mut ch = MockChannel::new(request);
serve_ssh_receive_pack(
"git-receive-pack 'repo.git'",
&mut ch,
|_path| Some(Repository::open(&remote_path).unwrap()),
&|_| Ok(()),
)
.unwrap();
let remote = Repository::open(&remote_dir).unwrap();
assert!(remote.refs().resolve("refs/heads/doomed").is_err());
let _ = std::fs::remove_dir_all(&local_dir);
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn loopback_push_round_trip() {
let local_dir = scratch("pushlocal");
let local = Repository::init(&local_dir).unwrap();
std::fs::write(local_dir.join("f.txt"), b"content\n").unwrap();
local.add_path("f.txt").unwrap();
let tip = local.commit(b"only\n", sig(), sig()).unwrap();
let remote_dir = scratch("pushremote");
let remote = Repository::init(&remote_dir).unwrap();
let mut transport = LocalTransport::new(&remote);
let report =
crate::client::push(&local, &mut transport, "refs/heads/main", "refs/heads/main")
.unwrap();
assert!(report.is_ok(), "push report not ok: {report:?}");
let remote2 = Repository::open(&remote_dir).unwrap();
assert_eq!(remote2.refs().resolve("refs/heads/main").unwrap(), tip);
assert!(remote2.objects().contains(&tip));
match remote2.read_object(&tip).unwrap() {
Object::Commit(c) => assert_eq!(c.summary(), b"only"),
_ => panic!("pushed tip is not a commit"),
}
let mut t2 = LocalTransport::new(&remote2);
let report2 =
crate::client::push(&local, &mut t2, "refs/heads/main", "refs/heads/main").unwrap();
assert!(report2.is_ok());
let _ = std::fs::remove_dir_all(&local_dir);
let _ = std::fs::remove_dir_all(&remote_dir);
}
#[test]
fn clone_adopts_remote_sha256_object_format() {
let remote_dir = scratch("sha256remote");
let remote = Repository::init_with(&remote_dir, HashAlgo::Sha256).unwrap();
std::fs::write(remote_dir.join("a.txt"), b"hello sha256\n").unwrap();
remote.add_path("a.txt").unwrap();
let tip = remote.commit(b"c\n", sig(), sig()).unwrap();
assert_eq!(remote.algo(), HashAlgo::Sha256);
assert_eq!(tip.to_hex().len(), 64);
let dest_dir = scratch("sha256clone");
let mut transport = LocalTransport::new(&remote);
let cloned = crate::client::clone(&dest_dir, &mut transport).unwrap();
assert_eq!(cloned.algo(), HashAlgo::Sha256);
assert_eq!(cloned.head_id().unwrap(), tip);
assert!(cloned.objects().contains(&tip));
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest_dir);
}
#[test]
fn ssh_upload_pack_serves_a_clone() {
use crate::protocol::FetchRequest;
let dir = scratch("sshserve");
let repo = Repository::init(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"hi\n").unwrap();
repo.add_path("a.txt").unwrap();
let tip = repo.commit(b"c\n", sig(), sig()).unwrap();
let request = FetchRequest {
wants: alloc::vec![tip],
..Default::default()
}
.encode()
.unwrap();
struct Pipe {
input: std::io::Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl std::io::Read for Pipe {
fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
std::io::Read::read(&mut self.input, b)
}
}
impl std::io::Write for Pipe {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.output.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut pipe = Pipe {
input: std::io::Cursor::new(request),
output: Vec::new(),
};
serve_ssh_upload_pack("git-upload-pack '/srv/repo'", &mut pipe, |_path| {
Repository::open(&dir).ok()
})
.unwrap();
let pack_pos = pipe
.output
.windows(4)
.position(|w| w == b"PACK")
.expect("response contains a pack");
let objs = crate::pack::explode_pack(
&pipe.output[pack_pos..],
HashAlgo::Sha1,
&|id: &ObjectId| {
Err(crate::error::Error::NotFound(
crate::error::ObjectKindHint::Object(id.to_hex()),
))
},
)
.unwrap();
assert!(objs.iter().any(|(id, _, _)| *id == tip));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn thin_pack_fetch_resolves_against_local_objects() {
let remote_dir = scratch("thinremote");
let remote = Repository::init(&remote_dir).unwrap();
let big = alloc::vec![b'a'; 5000];
std::fs::write(remote_dir.join("f.bin"), &big).unwrap();
remote.add_path("f.bin").unwrap();
remote.commit(b"first\n", sig(), sig()).unwrap();
let dest_dir = scratch("thinclone");
let mut t1 = LocalTransport::new(&remote);
let mut local = crate::client::clone(&dest_dir, &mut t1).unwrap();
let mut big2 = big.clone();
big2[2500] = b'b';
std::fs::write(remote_dir.join("f.bin"), &big2).unwrap();
remote.add_path("f.bin").unwrap();
let tip2 = remote.commit(b"second\n", sig(), sig()).unwrap();
let mut t2 = LocalTransport::new(&remote);
let got = crate::client::fetch_update(&mut local, &mut t2, "origin", false).unwrap();
assert!(got >= 1);
assert!(local.objects().contains(&tip2));
match local.read_object(&tip2).unwrap() {
Object::Commit(c) => {
let tree = match local.read_object(&c.tree).unwrap() {
Object::Tree(t) => t,
_ => panic!("not a tree"),
};
let blob_id = tree.get(b"f.bin").unwrap().id;
match local.read_object(&blob_id).unwrap() {
Object::Blob(b) => assert_eq!(b, big2),
_ => panic!("not a blob"),
}
}
_ => panic!("tip2 not a commit"),
}
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest_dir);
}
#[test]
fn fetch_update_prunes_deleted_remote_branches() {
let remote_dir = scratch("pruneremote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("a.txt"), b"x\n").unwrap();
remote.add_path("a.txt").unwrap();
remote.commit(b"c\n", sig(), sig()).unwrap();
remote.create_branch("feature", None).unwrap();
let dest_dir = scratch("pruneclone");
let mut transport = LocalTransport::new(&remote);
let mut local = crate::client::clone(&dest_dir, &mut transport).unwrap();
assert!(local.refs().resolve("refs/remotes/origin/feature").is_ok());
remote.refs().delete("refs/heads/feature").unwrap();
let mut t2 = LocalTransport::new(&remote);
crate::client::fetch_update(&mut local, &mut t2, "origin", true).unwrap();
assert!(local.refs().resolve("refs/remotes/origin/feature").is_err());
assert!(local.refs().resolve("refs/remotes/origin/main").is_ok());
let _ = std::fs::remove_dir_all(&remote_dir);
let _ = std::fs::remove_dir_all(&dest_dir);
}
#[test]
fn receive_pack_hook_can_reject_updates() {
use crate::odb::ObjectDatabase;
use crate::pack::PackWriter;
use crate::protocol::{PushRequest, RefUpdateCommand, ReportStatus};
let src_dir = scratch("hooksrc");
let src = Repository::init(&src_dir).unwrap();
std::fs::write(src_dir.join("a.txt"), b"x\n").unwrap();
src.add_path("a.txt").unwrap();
let tip = src.commit(b"c\n", sig(), sig()).unwrap();
let dst_dir = scratch("hookdst");
let dst = Repository::init(&dst_dir).unwrap();
let objs = crate::walk::reachable_objects(src.objects(), &[tip]).unwrap();
let mut w = PackWriter::new(dst.algo());
for id in &objs {
let (ty, p) = src.objects().read(id).unwrap();
w.add(ty, &p);
}
let pack = w.finish().unwrap();
let mut request = PushRequest {
commands: alloc::vec![RefUpdateCommand {
old: ObjectId::zero(dst.algo()),
new: tip,
name: "refs/heads/main".into(),
}],
..Default::default()
};
request.capabilities.add_flag("report-status");
let mut bytes = request.encode().unwrap();
bytes.extend_from_slice(&pack.pack);
let response =
receive_pack_with(&dst, &bytes, &|_| Err("blocked by policy".to_string())).unwrap();
let report = ReportStatus::parse(&pktline::decode_all(&response).unwrap()).unwrap();
assert!(!report.is_ok());
assert!(
report
.refs
.iter()
.any(|r| matches!(&r.result, Err(e) if e.contains("blocked by policy")))
);
assert!(dst.refs().resolve("refs/heads/main").is_err());
let notified = core::cell::RefCell::new(Vec::new());
let post = |outcomes: &[(alloc::string::String, core::result::Result<(), String>)]| {
notified.borrow_mut().extend_from_slice(outcomes);
};
let response = receive_pack_with_hooks(&dst, &bytes, &|_| Ok(()), &post).unwrap();
let report = ReportStatus::parse(&pktline::decode_all(&response).unwrap()).unwrap();
assert!(report.is_ok());
assert_eq!(dst.refs().resolve("refs/heads/main").unwrap(), tip);
let seen = notified.borrow();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, "refs/heads/main");
assert!(seen[0].1.is_ok());
let _ = std::fs::remove_dir_all(&src_dir);
let _ = std::fs::remove_dir_all(&dst_dir);
}
#[test]
fn push_rejects_non_fast_forward() {
let remote_dir = scratch("ffremote");
let remote = Repository::init(&remote_dir).unwrap();
std::fs::write(remote_dir.join("a.txt"), b"remote\n").unwrap();
remote.add_path("a.txt").unwrap();
remote.commit(b"remote work\n", sig(), sig()).unwrap();
let local_dir = scratch("fflocal");
let local = Repository::init(&local_dir).unwrap();
std::fs::write(local_dir.join("b.txt"), b"local\n").unwrap();
local.add_path("b.txt").unwrap();
local.commit(b"local work\n", sig(), sig()).unwrap();
let mut transport = LocalTransport::new(&remote);
let report =
crate::client::push(&local, &mut transport, "refs/heads/main", "refs/heads/main")
.unwrap();
assert!(!report.is_ok());
assert!(
report
.refs
.iter()
.any(|r| matches!(&r.result, Err(e) if e.contains("fast-forward")))
);
let _ = std::fs::remove_dir_all(&local_dir);
let _ = std::fs::remove_dir_all(&remote_dir);
}
}