Skip to main content

sley_remote/
pack.rs

1//! Pack building for push (receive-pack body generation).
2//!
3//! [`build_push_packfile`] and [`build_receive_pack_body`] lift the pack-planning
4//! logic shared by HTTP, SSH, and local push paths so embedders can assemble a
5//! receive-pack request without running the full [`crate::push::push`] flow.
6
7use 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
27/// The advertised tips the local repository already has, deduplicated and
28/// excluding the all-zero sentinel — the safe negotiation base for the push pack.
29pub 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
46/// Inputs for building a push packfile or full receive-pack request body.
47pub struct PushPackRequest<'a> {
48    /// Local object database supplying objects to pack.
49    pub local_db: &'a FileObjectDatabase,
50    /// Object format of [`PushPackRequest::local_db`].
51    pub format: ObjectFormat,
52    /// Planned receive-pack ref updates (only non-null `new_id` roots are packed).
53    pub commands: &'a [ReceivePackCommand],
54    /// Optional explicit pack roots supplied by an embedder-authored push plan.
55    /// When empty, non-null command `new_id`s are used.
56    pub pack_objects: &'a [ObjectId],
57    /// Remote ref advertisements used to exclude objects the remote already has.
58    pub remote_advertisements: &'a [RefAdvertisement],
59    /// Negotiated receive-pack features (honours [`ReceivePackFeatures::no_thin`]).
60    pub features: &'a ReceivePackFeatures,
61    /// Receive-pack request options (capabilities, push-options, etc.).
62    pub options: ReceivePackPushRequestOptions,
63    /// When `true`, omit delta bases the remote is assumed to already have.
64    pub thin: bool,
65}
66
67/// Build the packfile bytes for a push, excluding objects reachable from remote
68/// tips the local repository also holds.
69///
70/// When [`PushPackRequest::thin`] is `true` and the remote did not advertise
71/// `no-thin`, reachable objects are deltified against those remote tips using
72/// [`PackWriteOptions::with_thin_bases`].
73pub 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
194/// Build a complete receive-pack push request body: planned commands, negotiated
195/// capabilities, optional push-options, and the packfile from [`build_push_packfile`].
196pub 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}