1use std::collections::{HashMap, HashSet};
8use std::io::Write;
9
10use sley_core::{ObjectFormat, ObjectId, Result};
11use sley_odb::{
12 FileObjectDatabase, ObjectReader, collect_reachable_object_ids, write_object_id_pack_to_writer,
13 write_reachable_pack_to_writer,
14};
15use sley_pack::{PackFile, PackInput, PackWriteOptions};
16use sley_protocol::{
17 ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
18 build_receive_pack_push_request_header, write_receive_pack_push_request_header,
19};
20
21const THIN_PUSH_EXTERNAL_BASE_LIMIT: usize = 64;
22#[cfg(test)]
23const THIN_PUSH_STREAMING_MIN_OBJECTS: usize = 32;
24#[cfg(not(test))]
25const THIN_PUSH_STREAMING_MIN_OBJECTS: usize = 4096;
26
27pub fn remote_advertisement_tips_known_to_local(
30 local_db: &FileObjectDatabase,
31 advertisements: &[RefAdvertisement],
32) -> Result<Vec<ObjectId>> {
33 let mut tips = Vec::new();
34 let mut seen = HashSet::new();
35 for advertisement in advertisements {
36 if advertisement.oid.is_null() || !seen.insert(advertisement.oid) {
37 continue;
38 }
39 if local_db.contains(&advertisement.oid)? {
40 tips.push(advertisement.oid);
41 }
42 }
43 Ok(tips)
44}
45
46pub struct PushPackRequest<'a> {
48 pub local_db: &'a FileObjectDatabase,
50 pub format: ObjectFormat,
52 pub commands: &'a [ReceivePackCommand],
54 pub pack_objects: &'a [ObjectId],
57 pub remote_advertisements: &'a [RefAdvertisement],
59 pub features: &'a ReceivePackFeatures,
61 pub options: ReceivePackPushRequestOptions,
63 pub thin: bool,
65}
66
67pub fn build_push_packfile(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
74 let mut packfile = Vec::new();
75 write_push_packfile(req, &mut packfile)?;
76 Ok(packfile)
77}
78
79pub fn write_push_packfile<W>(req: &PushPackRequest<'_>, writer: &mut W) -> Result<()>
80where
81 W: Write,
82{
83 let remote_excluded_tips =
84 remote_advertisement_tips_known_to_local(req.local_db, req.remote_advertisements)?;
85 let remote_excluded =
86 collect_reachable_object_ids(req.local_db, req.format, remote_excluded_tips)?;
87 let starts = push_pack_roots(req.commands, req.pack_objects);
88 if starts.is_empty() {
89 return Ok(());
90 }
91
92 if req.thin && !req.features.no_thin {
93 write_thin_push_packfile(req, starts, &remote_excluded, writer)
94 } else {
95 if write_reachable_pack_to_writer(
96 req.local_db,
97 req.format,
98 starts,
99 &remote_excluded,
100 writer,
101 )?
102 .is_none()
103 {
104 write_empty_packfile(req.format, writer)?;
105 }
106 Ok(())
107 }
108}
109
110fn write_empty_packfile<W>(format: ObjectFormat, writer: &mut W) -> Result<()>
111where
112 W: Write,
113{
114 let inputs: Vec<PackInput<'_>> = Vec::new();
115 PackFile::write_packed_with_known_ids_to_writer(
116 &inputs,
117 format,
118 &PackWriteOptions::new(),
119 writer,
120 )?;
121 Ok(())
122}
123
124pub(crate) fn push_pack_roots(
125 commands: &[ReceivePackCommand],
126 pack_objects: &[ObjectId],
127) -> Vec<ObjectId> {
128 if !pack_objects.is_empty() {
129 return pack_objects.to_vec();
130 }
131 commands
132 .iter()
133 .filter(|command| !command.new_id.is_null())
134 .map(|command| command.new_id)
135 .collect()
136}
137
138fn write_thin_push_packfile<W>(
139 req: &PushPackRequest<'_>,
140 starts: Vec<sley_core::ObjectId>,
141 remote_excluded: &HashSet<sley_core::ObjectId>,
142 writer: &mut W,
143) -> Result<()>
144where
145 W: Write,
146{
147 let reachable = collect_reachable_object_ids(req.local_db, req.format, starts)?;
148 let mut to_send = reachable
149 .into_iter()
150 .filter(|oid| !remote_excluded.contains(oid))
151 .collect::<Vec<_>>();
152 to_send.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
153 if to_send.is_empty() {
154 return write_empty_packfile(req.format, writer);
155 }
156 if to_send.len() >= THIN_PUSH_STREAMING_MIN_OBJECTS {
157 write_object_id_pack_to_writer(req.local_db, req.format, &to_send, writer)?;
158 return Ok(());
159 }
160
161 let mut remote_base_oids = remote_excluded.iter().copied().collect::<Vec<_>>();
162 remote_base_oids.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
163
164 let mut thin_bases =
165 HashMap::with_capacity(remote_base_oids.len().min(THIN_PUSH_EXTERNAL_BASE_LIMIT));
166 for oid in remote_base_oids
167 .into_iter()
168 .take(THIN_PUSH_EXTERNAL_BASE_LIMIT)
169 {
170 let object = req.local_db.read_object(&oid)?;
171 thin_bases.insert(oid, (*object).clone());
172 }
173
174 let mut oids = Vec::with_capacity(to_send.len());
175 let mut owned_objects = Vec::with_capacity(to_send.len());
176 for oid in to_send {
177 let object = req.local_db.read_object(&oid)?;
178 owned_objects.push((*object).clone());
179 oids.push(oid);
180 }
181 let inputs = oids
182 .iter()
183 .zip(&owned_objects)
184 .map(|(oid, object)| PackInput { oid, object })
185 .collect::<Vec<_>>();
186
187 let options = PackWriteOptions::new()
188 .with_thin_bases(thin_bases)
189 .with_prefer_ofs_delta(req.options.ofs_delta);
190 PackFile::write_packed_with_known_ids_to_writer(&inputs, req.format, &options, writer)?;
191 Ok(())
192}
193
194pub fn build_receive_pack_body(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
197 let mut body = Vec::new();
198 write_receive_pack_body(req, &mut body)?;
199 Ok(body)
200}
201
202pub fn write_receive_pack_body<W>(req: &PushPackRequest<'_>, writer: &mut W) -> Result<()>
203where
204 W: Write,
205{
206 let header = build_receive_pack_push_request_header(
207 req.features,
208 req.commands.to_vec(),
209 req.options.clone(),
210 )?;
211 write_receive_pack_push_request_header(writer, &header)?;
212 write_push_packfile(req, writer)
213}
214
215#[cfg(test)]
216mod tests {
217 use std::fs;
218 use std::sync::atomic::{AtomicU64, Ordering};
219
220 use sley_core::ObjectId;
221 use sley_object::{EncodedObject, ObjectType};
222 use sley_odb::{FileObjectDatabase, ObjectWriter};
223 use sley_protocol::{
224 ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
225 parse_receive_pack_push_request,
226 };
227
228 use super::*;
229
230 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
231
232 fn temp_git_dir() -> std::path::PathBuf {
233 let dir = std::env::temp_dir().join(format!(
234 "sley-remote-pack-{}-{}",
235 std::process::id(),
236 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
237 ));
238 let _ = fs::remove_dir_all(&dir);
239 fs::create_dir_all(dir.join("objects")).expect("create objects dir");
240 dir
241 }
242
243 fn write_blob(db: &mut FileObjectDatabase, body: &[u8]) -> ObjectId {
244 db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))
245 .expect("write blob")
246 }
247
248 fn advertisement(oid: &ObjectId, name: &str) -> RefAdvertisement {
249 RefAdvertisement {
250 oid: *oid,
251 name: name.into(),
252 capabilities: Vec::new(),
253 }
254 }
255
256 fn push_command(old_id: &ObjectId, new_id: &ObjectId) -> ReceivePackCommand {
257 ReceivePackCommand {
258 old_id: old_id.clone(),
259 new_id: new_id.clone(),
260 name: "refs/heads/main".into(),
261 }
262 }
263
264 fn default_features() -> ReceivePackFeatures {
265 ReceivePackFeatures {
266 report_status: true,
267 ofs_delta: true,
268 ..ReceivePackFeatures::default()
269 }
270 }
271
272 fn default_options() -> ReceivePackPushRequestOptions {
273 ReceivePackPushRequestOptions {
274 report_status: true,
275 ofs_delta: true,
276 ..ReceivePackPushRequestOptions::default()
277 }
278 }
279
280 #[test]
281 fn build_receive_pack_body_round_trips_via_parse_receive_pack_push_request() {
282 let git_dir = temp_git_dir();
283 let format = ObjectFormat::Sha1;
284 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
285
286 let base_oid = write_blob(&mut db, b"shared base payload\n");
287 let new_oid = write_blob(&mut db, b"brand new payload for push\n");
288 let req = PushPackRequest {
289 local_db: &db,
290 format,
291 commands: &[push_command(&base_oid, &new_oid)],
292 pack_objects: &[],
293 remote_advertisements: &[advertisement(&base_oid, "refs/heads/main")],
294 features: &default_features(),
295 options: default_options(),
296 thin: false,
297 };
298
299 let body = build_receive_pack_body(&req).expect("build receive-pack body");
300 let parsed = parse_receive_pack_push_request(format, &body, false).expect("parse body");
301
302 assert_eq!(parsed.commands.commands, req.commands);
303 assert!(
304 parsed
305 .commands
306 .capabilities
307 .iter()
308 .any(|cap| cap.name == "report-status")
309 );
310 assert!(parsed.packfile.starts_with(b"PACK"));
311 assert_eq!(parsed.push_options, None);
312
313 let _ = fs::remove_dir_all(git_dir);
314 }
315
316 #[test]
317 fn streamed_receive_pack_body_matches_buffered_body() {
318 let git_dir = temp_git_dir();
319 let format = ObjectFormat::Sha1;
320 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
321
322 let base_oid = write_blob(&mut db, b"streamed shared base\n");
323 let new_oid = write_blob(&mut db, b"streamed receive-pack body\n");
324 let req = PushPackRequest {
325 local_db: &db,
326 format,
327 commands: &[push_command(&base_oid, &new_oid)],
328 pack_objects: &[],
329 remote_advertisements: &[advertisement(&base_oid, "refs/heads/main")],
330 features: &default_features(),
331 options: default_options(),
332 thin: false,
333 };
334
335 let buffered = build_receive_pack_body(&req).expect("buffered body");
336 let mut streamed = Vec::new();
337 write_receive_pack_body(&req, &mut streamed).expect("streamed body");
338
339 assert_eq!(streamed, buffered);
340 let _ = fs::remove_dir_all(git_dir);
341 }
342
343 fn pack_request<'a>(
344 local_db: &'a FileObjectDatabase,
345 format: ObjectFormat,
346 commands: &'a [ReceivePackCommand],
347 remote_advertisements: &'a [RefAdvertisement],
348 features: &'a ReceivePackFeatures,
349 thin: bool,
350 ) -> PushPackRequest<'a> {
351 PushPackRequest {
352 local_db,
353 format,
354 commands,
355 pack_objects: &[],
356 remote_advertisements,
357 features,
358 options: default_options(),
359 thin,
360 }
361 }
362
363 #[test]
364 fn thin_push_packfile_omits_known_remote_bases_and_round_trips() {
365 let git_dir = temp_git_dir();
366 let format = ObjectFormat::Sha1;
367 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
368
369 let base_oid = write_blob(&mut db, b"0123456789abcdef repeated base\n");
370 let similar_oid = write_blob(&mut db, b"0123456789abcdef repeated base with extra tail\n");
371 let commands = [push_command(&base_oid, &similar_oid)];
372 let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
373 let features = default_features();
374
375 let thin_pack = build_push_packfile(&pack_request(
376 &db,
377 format,
378 &commands,
379 &remote_advertisements,
380 &features,
381 true,
382 ))
383 .expect("thin pack");
384 let full_pack = build_push_packfile(&pack_request(
385 &db,
386 format,
387 &commands,
388 &remote_advertisements,
389 &features,
390 false,
391 ))
392 .expect("full pack");
393 assert!(thin_pack.starts_with(b"PACK"));
394 assert!(full_pack.starts_with(b"PACK"));
395 assert!(
396 thin_pack.len() <= full_pack.len(),
397 "thin pack should not be larger than a self-contained pack"
398 );
399
400 let body = build_receive_pack_body(&pack_request(
401 &db,
402 format,
403 &commands,
404 &remote_advertisements,
405 &features,
406 true,
407 ))
408 .expect("thin receive-pack body");
409 let parsed =
410 parse_receive_pack_push_request(format, &body, false).expect("parse thin body");
411 assert_eq!(parsed.packfile, thin_pack);
412 assert_eq!(parsed.commands.commands, commands);
413
414 let _ = fs::remove_dir_all(git_dir);
415 }
416
417 #[test]
418 fn thin_push_respects_remote_no_thin_capability() {
419 let git_dir = temp_git_dir();
420 let format = ObjectFormat::Sha1;
421 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
422
423 let base_oid = write_blob(&mut db, b"base\n");
424 let new_oid = write_blob(&mut db, b"new\n");
425
426 let no_thin_features = ReceivePackFeatures {
427 no_thin: true,
428 ..default_features()
429 };
430 let commands = [push_command(&base_oid, &new_oid)];
431 let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
432
433 let thin_pack = build_push_packfile(&pack_request(
434 &db,
435 format,
436 &commands,
437 &remote_advertisements,
438 &no_thin_features,
439 true,
440 ))
441 .expect("no-thin fallback pack");
442 let full_pack = build_push_packfile(&pack_request(
443 &db,
444 format,
445 &commands,
446 &remote_advertisements,
447 &no_thin_features,
448 false,
449 ))
450 .expect("full pack");
451 assert_eq!(thin_pack, full_pack);
452
453 let _ = fs::remove_dir_all(git_dir);
454 }
455
456 #[test]
457 fn push_writes_empty_pack_when_remote_already_has_target_object() {
458 let git_dir = temp_git_dir();
459 let format = ObjectFormat::Sha1;
460 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
461
462 let existing_oid = write_blob(&mut db, b"already on the remote\n");
463 let commands = [push_command(&ObjectId::null(format), &existing_oid)];
464 let remote_advertisements = [advertisement(&existing_oid, "refs/heads/main")];
465 let no_thin_features = ReceivePackFeatures {
466 no_thin: true,
467 ..default_features()
468 };
469
470 let no_thin_pack = build_push_packfile(&pack_request(
471 &db,
472 format,
473 &commands,
474 &remote_advertisements,
475 &no_thin_features,
476 false,
477 ))
478 .expect("no-thin no-op object pack");
479 assert!(no_thin_pack.starts_with(b"PACK"));
480 let parsed = PackFile::parse(&no_thin_pack, format).expect("parse empty pack");
481 assert!(parsed.entries.is_empty());
482
483 let thin_pack = build_push_packfile(&pack_request(
484 &db,
485 format,
486 &commands,
487 &remote_advertisements,
488 &default_features(),
489 true,
490 ))
491 .expect("thin empty pack");
492 assert_eq!(thin_pack, no_thin_pack);
493
494 let body = build_receive_pack_body(&pack_request(
495 &db,
496 format,
497 &commands,
498 &remote_advertisements,
499 &no_thin_features,
500 true,
501 ))
502 .expect("no-thin receive-pack body");
503 let parsed =
504 parse_receive_pack_push_request(format, &body, false).expect("parse receive-pack body");
505 assert_eq!(parsed.commands.commands, commands);
506 assert_eq!(parsed.packfile, no_thin_pack);
507
508 let _ = fs::remove_dir_all(git_dir);
509 }
510}