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