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_reachable_pack_to_writer,
13};
14use sley_pack::{PackFile, PackInput, PackWriteOptions};
15use sley_protocol::{
16    ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
17    build_receive_pack_push_request_header, write_receive_pack_push_request_header,
18};
19
20/// The advertised tips the local repository already has, deduplicated and
21/// excluding the all-zero sentinel — the safe negotiation base for the push pack.
22pub fn remote_advertisement_tips_known_to_local(
23    local_db: &FileObjectDatabase,
24    advertisements: &[RefAdvertisement],
25) -> Result<Vec<ObjectId>> {
26    let mut tips = Vec::new();
27    let mut seen = HashSet::new();
28    for advertisement in advertisements {
29        if advertisement.oid.is_null() || !seen.insert(advertisement.oid) {
30            continue;
31        }
32        if local_db.contains(&advertisement.oid)? {
33            tips.push(advertisement.oid);
34        }
35    }
36    Ok(tips)
37}
38
39/// Inputs for building a push packfile or full receive-pack request body.
40pub struct PushPackRequest<'a> {
41    /// Local object database supplying objects to pack.
42    pub local_db: &'a FileObjectDatabase,
43    /// Object format of [`PushPackRequest::local_db`].
44    pub format: ObjectFormat,
45    /// Planned receive-pack ref updates (only non-null `new_id` roots are packed).
46    pub commands: &'a [ReceivePackCommand],
47    /// Optional explicit pack roots supplied by an embedder-authored push plan.
48    /// When empty, non-null command `new_id`s are used.
49    pub pack_objects: &'a [ObjectId],
50    /// Remote ref advertisements used to exclude objects the remote already has.
51    pub remote_advertisements: &'a [RefAdvertisement],
52    /// Negotiated receive-pack features (honours [`ReceivePackFeatures::no_thin`]).
53    pub features: &'a ReceivePackFeatures,
54    /// Receive-pack request options (capabilities, push-options, etc.).
55    pub options: ReceivePackPushRequestOptions,
56    /// When `true`, omit delta bases the remote is assumed to already have.
57    pub thin: bool,
58}
59
60/// Build the packfile bytes for a push, excluding objects reachable from remote
61/// tips the local repository also holds.
62///
63/// When [`PushPackRequest::thin`] is `true` and the remote did not advertise
64/// `no-thin`, reachable objects are deltified against those remote tips using
65/// [`PackWriteOptions::with_thin_bases`].
66pub fn build_push_packfile(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
67    let mut packfile = Vec::new();
68    write_push_packfile(req, &mut packfile)?;
69    Ok(packfile)
70}
71
72pub fn write_push_packfile<W>(req: &PushPackRequest<'_>, writer: &mut W) -> Result<()>
73where
74    W: Write,
75{
76    let remote_excluded_tips =
77        remote_advertisement_tips_known_to_local(req.local_db, req.remote_advertisements)?;
78    let remote_excluded =
79        collect_reachable_object_ids(req.local_db, req.format, remote_excluded_tips)?;
80    let starts = push_pack_roots(req.commands, req.pack_objects);
81    if starts.is_empty() {
82        return Ok(());
83    }
84
85    if req.thin && !req.features.no_thin {
86        write_thin_push_packfile(req, starts, &remote_excluded, writer)
87    } else {
88        if write_reachable_pack_to_writer(
89            req.local_db,
90            req.format,
91            starts,
92            &remote_excluded,
93            writer,
94        )?
95        .is_none()
96        {
97            write_empty_packfile(req.format, writer)?;
98        }
99        Ok(())
100    }
101}
102
103fn write_empty_packfile<W>(format: ObjectFormat, writer: &mut W) -> Result<()>
104where
105    W: Write,
106{
107    let inputs: Vec<PackInput<'_>> = Vec::new();
108    PackFile::write_packed_with_known_ids_to_writer(
109        &inputs,
110        format,
111        &PackWriteOptions::new(),
112        writer,
113    )?;
114    Ok(())
115}
116
117pub(crate) fn push_pack_roots(
118    commands: &[ReceivePackCommand],
119    pack_objects: &[ObjectId],
120) -> Vec<ObjectId> {
121    if !pack_objects.is_empty() {
122        return pack_objects.to_vec();
123    }
124    commands
125        .iter()
126        .filter(|command| !command.new_id.is_null())
127        .map(|command| command.new_id)
128        .collect()
129}
130
131fn write_thin_push_packfile<W>(
132    req: &PushPackRequest<'_>,
133    starts: Vec<sley_core::ObjectId>,
134    remote_excluded: &HashSet<sley_core::ObjectId>,
135    writer: &mut W,
136) -> Result<()>
137where
138    W: Write,
139{
140    let reachable = collect_reachable_object_ids(req.local_db, req.format, starts)?;
141    let to_send = reachable
142        .into_iter()
143        .filter(|oid| !remote_excluded.contains(oid))
144        .collect::<Vec<_>>();
145    if to_send.is_empty() {
146        return Ok(());
147    }
148
149    let mut thin_bases = HashMap::with_capacity(remote_excluded.len());
150    for oid in remote_excluded {
151        let object = req.local_db.read_object(oid)?;
152        thin_bases.insert(*oid, (*object).clone());
153    }
154
155    let mut oids = Vec::with_capacity(to_send.len());
156    let mut owned_objects = Vec::with_capacity(to_send.len());
157    for oid in to_send {
158        let object = req.local_db.read_object(&oid)?;
159        owned_objects.push((*object).clone());
160        oids.push(oid);
161    }
162    let inputs = oids
163        .iter()
164        .zip(&owned_objects)
165        .map(|(oid, object)| PackInput { oid, object })
166        .collect::<Vec<_>>();
167
168    let options = PackWriteOptions::new()
169        .with_thin_bases(thin_bases)
170        .with_prefer_ofs_delta(req.options.ofs_delta);
171    PackFile::write_packed_with_known_ids_to_writer(&inputs, req.format, &options, writer)?;
172    Ok(())
173}
174
175/// Build a complete receive-pack push request body: planned commands, negotiated
176/// capabilities, optional push-options, and the packfile from [`build_push_packfile`].
177pub fn build_receive_pack_body(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
178    let mut body = Vec::new();
179    write_receive_pack_body(req, &mut body)?;
180    Ok(body)
181}
182
183pub fn write_receive_pack_body<W>(req: &PushPackRequest<'_>, writer: &mut W) -> Result<()>
184where
185    W: Write,
186{
187    let header = build_receive_pack_push_request_header(
188        req.features,
189        req.commands.to_vec(),
190        req.options.clone(),
191    )?;
192    write_receive_pack_push_request_header(writer, &header)?;
193    write_push_packfile(req, writer)
194}
195
196#[cfg(test)]
197mod tests {
198    use std::fs;
199    use std::sync::atomic::{AtomicU64, Ordering};
200
201    use sley_core::ObjectId;
202    use sley_object::{EncodedObject, ObjectType};
203    use sley_odb::{FileObjectDatabase, ObjectWriter};
204    use sley_protocol::{
205        ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
206        parse_receive_pack_push_request,
207    };
208
209    use super::*;
210
211    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
212
213    fn temp_git_dir() -> std::path::PathBuf {
214        let dir = std::env::temp_dir().join(format!(
215            "sley-remote-pack-{}-{}",
216            std::process::id(),
217            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
218        ));
219        let _ = fs::remove_dir_all(&dir);
220        fs::create_dir_all(dir.join("objects")).expect("create objects dir");
221        dir
222    }
223
224    fn write_blob(db: &mut FileObjectDatabase, body: &[u8]) -> ObjectId {
225        db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))
226            .expect("write blob")
227    }
228
229    fn advertisement(oid: &ObjectId, name: &str) -> RefAdvertisement {
230        RefAdvertisement {
231            oid: *oid,
232            name: name.into(),
233            capabilities: Vec::new(),
234        }
235    }
236
237    fn push_command(old_id: &ObjectId, new_id: &ObjectId) -> ReceivePackCommand {
238        ReceivePackCommand {
239            old_id: old_id.clone(),
240            new_id: new_id.clone(),
241            name: "refs/heads/main".into(),
242        }
243    }
244
245    fn default_features() -> ReceivePackFeatures {
246        ReceivePackFeatures {
247            report_status: true,
248            ofs_delta: true,
249            ..ReceivePackFeatures::default()
250        }
251    }
252
253    fn default_options() -> ReceivePackPushRequestOptions {
254        ReceivePackPushRequestOptions {
255            report_status: true,
256            ofs_delta: true,
257            ..ReceivePackPushRequestOptions::default()
258        }
259    }
260
261    #[test]
262    fn build_receive_pack_body_round_trips_via_parse_receive_pack_push_request() {
263        let git_dir = temp_git_dir();
264        let format = ObjectFormat::Sha1;
265        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
266
267        let base_oid = write_blob(&mut db, b"shared base payload\n");
268        let new_oid = write_blob(&mut db, b"brand new payload for push\n");
269        let req = PushPackRequest {
270            local_db: &db,
271            format,
272            commands: &[push_command(&base_oid, &new_oid)],
273            pack_objects: &[],
274            remote_advertisements: &[advertisement(&base_oid, "refs/heads/main")],
275            features: &default_features(),
276            options: default_options(),
277            thin: false,
278        };
279
280        let body = build_receive_pack_body(&req).expect("build receive-pack body");
281        let parsed = parse_receive_pack_push_request(format, &body, false).expect("parse body");
282
283        assert_eq!(parsed.commands.commands, req.commands);
284        assert!(
285            parsed
286                .commands
287                .capabilities
288                .iter()
289                .any(|cap| cap.name == "report-status")
290        );
291        assert!(parsed.packfile.starts_with(b"PACK"));
292        assert_eq!(parsed.push_options, None);
293
294        let _ = fs::remove_dir_all(git_dir);
295    }
296
297    #[test]
298    fn streamed_receive_pack_body_matches_buffered_body() {
299        let git_dir = temp_git_dir();
300        let format = ObjectFormat::Sha1;
301        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
302
303        let base_oid = write_blob(&mut db, b"streamed shared base\n");
304        let new_oid = write_blob(&mut db, b"streamed receive-pack body\n");
305        let req = PushPackRequest {
306            local_db: &db,
307            format,
308            commands: &[push_command(&base_oid, &new_oid)],
309            pack_objects: &[],
310            remote_advertisements: &[advertisement(&base_oid, "refs/heads/main")],
311            features: &default_features(),
312            options: default_options(),
313            thin: false,
314        };
315
316        let buffered = build_receive_pack_body(&req).expect("buffered body");
317        let mut streamed = Vec::new();
318        write_receive_pack_body(&req, &mut streamed).expect("streamed body");
319
320        assert_eq!(streamed, buffered);
321        let _ = fs::remove_dir_all(git_dir);
322    }
323
324    fn pack_request<'a>(
325        local_db: &'a FileObjectDatabase,
326        format: ObjectFormat,
327        commands: &'a [ReceivePackCommand],
328        remote_advertisements: &'a [RefAdvertisement],
329        features: &'a ReceivePackFeatures,
330        thin: bool,
331    ) -> PushPackRequest<'a> {
332        PushPackRequest {
333            local_db,
334            format,
335            commands,
336            pack_objects: &[],
337            remote_advertisements,
338            features,
339            options: default_options(),
340            thin,
341        }
342    }
343
344    #[test]
345    fn thin_push_packfile_omits_known_remote_bases_and_round_trips() {
346        let git_dir = temp_git_dir();
347        let format = ObjectFormat::Sha1;
348        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
349
350        let base_oid = write_blob(&mut db, b"0123456789abcdef repeated base\n");
351        let similar_oid = write_blob(&mut db, b"0123456789abcdef repeated base with extra tail\n");
352        let commands = [push_command(&base_oid, &similar_oid)];
353        let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
354        let features = default_features();
355
356        let thin_pack = build_push_packfile(&pack_request(
357            &db,
358            format,
359            &commands,
360            &remote_advertisements,
361            &features,
362            true,
363        ))
364        .expect("thin pack");
365        let full_pack = build_push_packfile(&pack_request(
366            &db,
367            format,
368            &commands,
369            &remote_advertisements,
370            &features,
371            false,
372        ))
373        .expect("full pack");
374        assert!(thin_pack.starts_with(b"PACK"));
375        assert!(full_pack.starts_with(b"PACK"));
376        assert!(
377            thin_pack.len() <= full_pack.len(),
378            "thin pack should not be larger than a self-contained pack"
379        );
380
381        let body = build_receive_pack_body(&pack_request(
382            &db,
383            format,
384            &commands,
385            &remote_advertisements,
386            &features,
387            true,
388        ))
389        .expect("thin receive-pack body");
390        let parsed =
391            parse_receive_pack_push_request(format, &body, false).expect("parse thin body");
392        assert_eq!(parsed.packfile, thin_pack);
393        assert_eq!(parsed.commands.commands, commands);
394
395        let _ = fs::remove_dir_all(git_dir);
396    }
397
398    #[test]
399    fn thin_push_respects_remote_no_thin_capability() {
400        let git_dir = temp_git_dir();
401        let format = ObjectFormat::Sha1;
402        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
403
404        let base_oid = write_blob(&mut db, b"base\n");
405        let new_oid = write_blob(&mut db, b"new\n");
406
407        let no_thin_features = ReceivePackFeatures {
408            no_thin: true,
409            ..default_features()
410        };
411        let commands = [push_command(&base_oid, &new_oid)];
412        let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
413
414        let thin_pack = build_push_packfile(&pack_request(
415            &db,
416            format,
417            &commands,
418            &remote_advertisements,
419            &no_thin_features,
420            true,
421        ))
422        .expect("no-thin fallback pack");
423        let full_pack = build_push_packfile(&pack_request(
424            &db,
425            format,
426            &commands,
427            &remote_advertisements,
428            &no_thin_features,
429            false,
430        ))
431        .expect("full pack");
432        assert_eq!(thin_pack, full_pack);
433
434        let _ = fs::remove_dir_all(git_dir);
435    }
436}