use std::collections::{HashMap, HashSet};
use sley_core::{ObjectFormat, ObjectId, Result};
use sley_odb::{
FileObjectDatabase, ObjectReader, build_reachable_pack, collect_reachable_object_ids,
};
use sley_pack::{PackFile, PackInput, PackWriteOptions};
use sley_protocol::{
ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
build_receive_pack_push_request, encode_receive_pack_push_request,
};
pub fn remote_advertisement_tips_known_to_local(
local_db: &FileObjectDatabase,
advertisements: &[RefAdvertisement],
) -> Result<Vec<ObjectId>> {
let mut tips = Vec::new();
let mut seen = HashSet::new();
for advertisement in advertisements {
if advertisement.oid.is_null() || !seen.insert(advertisement.oid) {
continue;
}
if local_db.contains(&advertisement.oid)? {
tips.push(advertisement.oid);
}
}
Ok(tips)
}
pub struct PushPackRequest<'a> {
pub local_db: &'a FileObjectDatabase,
pub format: ObjectFormat,
pub commands: &'a [ReceivePackCommand],
pub pack_objects: &'a [ObjectId],
pub remote_advertisements: &'a [RefAdvertisement],
pub features: &'a ReceivePackFeatures,
pub options: ReceivePackPushRequestOptions,
pub thin: bool,
}
pub fn build_push_packfile(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
let remote_excluded_tips =
remote_advertisement_tips_known_to_local(req.local_db, req.remote_advertisements)?;
let remote_excluded =
collect_reachable_object_ids(req.local_db, req.format, remote_excluded_tips)?;
let starts = push_pack_roots(req.commands, req.pack_objects);
if starts.is_empty() {
return Ok(Vec::new());
}
if req.thin && !req.features.no_thin {
build_thin_push_packfile(req, starts, &remote_excluded)
} else {
match build_reachable_pack(req.local_db, req.format, starts, &remote_excluded)? {
Some(pack) => Ok(pack.pack),
None => empty_packfile(req.format),
}
}
}
fn empty_packfile(format: ObjectFormat) -> Result<Vec<u8>> {
let inputs: Vec<PackInput<'_>> = Vec::new();
PackFile::write_packed_with_known_ids(&inputs, format).map(|pack| pack.pack)
}
pub(crate) fn push_pack_roots(
commands: &[ReceivePackCommand],
pack_objects: &[ObjectId],
) -> Vec<ObjectId> {
if !pack_objects.is_empty() {
return pack_objects.to_vec();
}
commands
.iter()
.filter(|command| !command.new_id.is_null())
.map(|command| command.new_id)
.collect()
}
fn build_thin_push_packfile(
req: &PushPackRequest<'_>,
starts: Vec<sley_core::ObjectId>,
remote_excluded: &HashSet<sley_core::ObjectId>,
) -> Result<Vec<u8>> {
let reachable = collect_reachable_object_ids(req.local_db, req.format, starts)?;
let to_send = reachable
.into_iter()
.filter(|oid| !remote_excluded.contains(oid))
.collect::<Vec<_>>();
if to_send.is_empty() {
return Ok(Vec::new());
}
let mut thin_bases = HashMap::with_capacity(remote_excluded.len());
for oid in remote_excluded {
let object = req.local_db.read_object(oid)?;
thin_bases.insert(*oid, (*object).clone());
}
let mut oids = Vec::with_capacity(to_send.len());
let mut owned_objects = Vec::with_capacity(to_send.len());
for oid in to_send {
let object = req.local_db.read_object(&oid)?;
owned_objects.push((*object).clone());
oids.push(oid);
}
let inputs = oids
.iter()
.zip(&owned_objects)
.map(|(oid, object)| PackInput { oid, object })
.collect::<Vec<_>>();
let options = PackWriteOptions::new()
.with_thin_bases(thin_bases)
.with_prefer_ofs_delta(req.options.ofs_delta);
let pack = PackFile::write_packed_with_known_ids_and_options(&inputs, req.format, &options)?;
Ok(pack.pack)
}
pub fn build_receive_pack_body(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
let packfile = build_push_packfile(req)?;
let request = build_receive_pack_push_request(
req.features,
req.commands.to_vec(),
packfile,
req.options.clone(),
)?;
encode_receive_pack_push_request(&request)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use sley_core::ObjectId;
use sley_object::{EncodedObject, ObjectType};
use sley_odb::{FileObjectDatabase, ObjectWriter};
use sley_protocol::{
ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
parse_receive_pack_push_request,
};
use super::*;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_git_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"sley-remote-pack-{}-{}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("objects")).expect("create objects dir");
dir
}
fn write_blob(db: &mut FileObjectDatabase, body: &[u8]) -> ObjectId {
db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))
.expect("write blob")
}
fn advertisement(oid: &ObjectId, name: &str) -> RefAdvertisement {
RefAdvertisement {
oid: *oid,
name: name.into(),
capabilities: Vec::new(),
}
}
fn push_command(old_id: &ObjectId, new_id: &ObjectId) -> ReceivePackCommand {
ReceivePackCommand {
old_id: old_id.clone(),
new_id: new_id.clone(),
name: "refs/heads/main".into(),
}
}
fn default_features() -> ReceivePackFeatures {
ReceivePackFeatures {
report_status: true,
ofs_delta: true,
..ReceivePackFeatures::default()
}
}
fn default_options() -> ReceivePackPushRequestOptions {
ReceivePackPushRequestOptions {
report_status: true,
ofs_delta: true,
..ReceivePackPushRequestOptions::default()
}
}
#[test]
fn build_receive_pack_body_round_trips_via_parse_receive_pack_push_request() {
let git_dir = temp_git_dir();
let format = ObjectFormat::Sha1;
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let base_oid = write_blob(&mut db, b"shared base payload\n");
let new_oid = write_blob(&mut db, b"brand new payload for push\n");
let req = PushPackRequest {
local_db: &db,
format,
commands: &[push_command(&base_oid, &new_oid)],
pack_objects: &[],
remote_advertisements: &[advertisement(&base_oid, "refs/heads/main")],
features: &default_features(),
options: default_options(),
thin: false,
};
let body = build_receive_pack_body(&req).expect("build receive-pack body");
let parsed = parse_receive_pack_push_request(format, &body, false).expect("parse body");
assert_eq!(parsed.commands.commands, req.commands);
assert!(
parsed
.commands
.capabilities
.iter()
.any(|cap| cap.name == "report-status")
);
assert!(parsed.packfile.starts_with(b"PACK"));
assert_eq!(parsed.push_options, None);
let _ = fs::remove_dir_all(git_dir);
}
fn pack_request<'a>(
local_db: &'a FileObjectDatabase,
format: ObjectFormat,
commands: &'a [ReceivePackCommand],
remote_advertisements: &'a [RefAdvertisement],
features: &'a ReceivePackFeatures,
thin: bool,
) -> PushPackRequest<'a> {
PushPackRequest {
local_db,
format,
commands,
pack_objects: &[],
remote_advertisements,
features,
options: default_options(),
thin,
}
}
#[test]
fn thin_push_packfile_omits_known_remote_bases_and_round_trips() {
let git_dir = temp_git_dir();
let format = ObjectFormat::Sha1;
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let base_oid = write_blob(&mut db, b"0123456789abcdef repeated base\n");
let similar_oid = write_blob(&mut db, b"0123456789abcdef repeated base with extra tail\n");
let commands = [push_command(&base_oid, &similar_oid)];
let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
let features = default_features();
let thin_pack = build_push_packfile(&pack_request(
&db,
format,
&commands,
&remote_advertisements,
&features,
true,
))
.expect("thin pack");
let full_pack = build_push_packfile(&pack_request(
&db,
format,
&commands,
&remote_advertisements,
&features,
false,
))
.expect("full pack");
assert!(thin_pack.starts_with(b"PACK"));
assert!(full_pack.starts_with(b"PACK"));
assert!(
thin_pack.len() <= full_pack.len(),
"thin pack should not be larger than a self-contained pack"
);
let body = build_receive_pack_body(&pack_request(
&db,
format,
&commands,
&remote_advertisements,
&features,
true,
))
.expect("thin receive-pack body");
let parsed =
parse_receive_pack_push_request(format, &body, false).expect("parse thin body");
assert_eq!(parsed.packfile, thin_pack);
assert_eq!(parsed.commands.commands, commands);
let _ = fs::remove_dir_all(git_dir);
}
#[test]
fn thin_push_respects_remote_no_thin_capability() {
let git_dir = temp_git_dir();
let format = ObjectFormat::Sha1;
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let base_oid = write_blob(&mut db, b"base\n");
let new_oid = write_blob(&mut db, b"new\n");
let no_thin_features = ReceivePackFeatures {
no_thin: true,
..default_features()
};
let commands = [push_command(&base_oid, &new_oid)];
let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
let thin_pack = build_push_packfile(&pack_request(
&db,
format,
&commands,
&remote_advertisements,
&no_thin_features,
true,
))
.expect("no-thin fallback pack");
let full_pack = build_push_packfile(&pack_request(
&db,
format,
&commands,
&remote_advertisements,
&no_thin_features,
false,
))
.expect("full pack");
assert_eq!(thin_pack, full_pack);
let _ = fs::remove_dir_all(git_dir);
}
}