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        Ok(
79            build_reachable_pack(req.local_db, req.format, starts, &remote_excluded)?
80                .map(|pack| pack.pack)
81                .unwrap_or_default(),
82        )
83    }
84}
85
86pub(crate) fn push_pack_roots(
87    commands: &[ReceivePackCommand],
88    pack_objects: &[ObjectId],
89) -> Vec<ObjectId> {
90    if !pack_objects.is_empty() {
91        return pack_objects.to_vec();
92    }
93    commands
94        .iter()
95        .filter(|command| !command.new_id.is_null())
96        .map(|command| command.new_id)
97        .collect()
98}
99
100fn build_thin_push_packfile(
101    req: &PushPackRequest<'_>,
102    starts: Vec<sley_core::ObjectId>,
103    remote_excluded: &HashSet<sley_core::ObjectId>,
104) -> Result<Vec<u8>> {
105    let reachable = collect_reachable_object_ids(req.local_db, req.format, starts)?;
106    let to_send = reachable
107        .into_iter()
108        .filter(|oid| !remote_excluded.contains(oid))
109        .collect::<Vec<_>>();
110    if to_send.is_empty() {
111        return Ok(Vec::new());
112    }
113
114    let mut thin_bases = HashMap::with_capacity(remote_excluded.len());
115    for oid in remote_excluded {
116        let object = req.local_db.read_object(oid)?;
117        thin_bases.insert(*oid, (*object).clone());
118    }
119
120    let mut oids = Vec::with_capacity(to_send.len());
121    let mut owned_objects = Vec::with_capacity(to_send.len());
122    for oid in to_send {
123        let object = req.local_db.read_object(&oid)?;
124        owned_objects.push((*object).clone());
125        oids.push(oid);
126    }
127    let inputs = oids
128        .iter()
129        .zip(&owned_objects)
130        .map(|(oid, object)| PackInput { oid, object })
131        .collect::<Vec<_>>();
132
133    let options = PackWriteOptions::new()
134        .with_thin_bases(thin_bases)
135        .with_prefer_ofs_delta(req.options.ofs_delta);
136    let pack = PackFile::write_packed_with_known_ids_and_options(&inputs, req.format, &options)?;
137    Ok(pack.pack)
138}
139
140/// Build a complete receive-pack push request body: planned commands, negotiated
141/// capabilities, optional push-options, and the packfile from [`build_push_packfile`].
142pub fn build_receive_pack_body(req: &PushPackRequest<'_>) -> Result<Vec<u8>> {
143    let packfile = build_push_packfile(req)?;
144    let request = build_receive_pack_push_request(
145        req.features,
146        req.commands.to_vec(),
147        packfile,
148        req.options.clone(),
149    )?;
150    encode_receive_pack_push_request(&request)
151}
152
153#[cfg(test)]
154mod tests {
155    use std::fs;
156    use std::sync::atomic::{AtomicU64, Ordering};
157
158    use sley_core::ObjectId;
159    use sley_object::{EncodedObject, ObjectType};
160    use sley_odb::{FileObjectDatabase, ObjectWriter};
161    use sley_protocol::{
162        ReceivePackCommand, ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement,
163        parse_receive_pack_push_request,
164    };
165
166    use super::*;
167
168    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
169
170    fn temp_git_dir() -> std::path::PathBuf {
171        let dir = std::env::temp_dir().join(format!(
172            "sley-remote-pack-{}-{}",
173            std::process::id(),
174            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
175        ));
176        let _ = fs::remove_dir_all(&dir);
177        fs::create_dir_all(dir.join("objects")).expect("create objects dir");
178        dir
179    }
180
181    fn write_blob(db: &mut FileObjectDatabase, body: &[u8]) -> ObjectId {
182        db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))
183            .expect("write blob")
184    }
185
186    fn advertisement(oid: &ObjectId, name: &str) -> RefAdvertisement {
187        RefAdvertisement {
188            oid: *oid,
189            name: name.into(),
190            capabilities: Vec::new(),
191        }
192    }
193
194    fn push_command(old_id: &ObjectId, new_id: &ObjectId) -> ReceivePackCommand {
195        ReceivePackCommand {
196            old_id: old_id.clone(),
197            new_id: new_id.clone(),
198            name: "refs/heads/main".into(),
199        }
200    }
201
202    fn default_features() -> ReceivePackFeatures {
203        ReceivePackFeatures {
204            report_status: true,
205            ofs_delta: true,
206            ..ReceivePackFeatures::default()
207        }
208    }
209
210    fn default_options() -> ReceivePackPushRequestOptions {
211        ReceivePackPushRequestOptions {
212            report_status: true,
213            ofs_delta: true,
214            ..ReceivePackPushRequestOptions::default()
215        }
216    }
217
218    #[test]
219    fn build_receive_pack_body_round_trips_via_parse_receive_pack_push_request() {
220        let git_dir = temp_git_dir();
221        let format = ObjectFormat::Sha1;
222        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
223
224        let base_oid = write_blob(&mut db, b"shared base payload\n");
225        let new_oid = write_blob(&mut db, b"brand new payload for push\n");
226        let req = PushPackRequest {
227            local_db: &db,
228            format,
229            commands: &[push_command(&base_oid, &new_oid)],
230            pack_objects: &[],
231            remote_advertisements: &[advertisement(&base_oid, "refs/heads/main")],
232            features: &default_features(),
233            options: default_options(),
234            thin: false,
235        };
236
237        let body = build_receive_pack_body(&req).expect("build receive-pack body");
238        let parsed = parse_receive_pack_push_request(format, &body, false).expect("parse body");
239
240        assert_eq!(parsed.commands.commands, req.commands);
241        assert!(
242            parsed
243                .commands
244                .capabilities
245                .iter()
246                .any(|cap| cap.name == "report-status")
247        );
248        assert!(parsed.packfile.starts_with(b"PACK"));
249        assert_eq!(parsed.push_options, None);
250
251        let _ = fs::remove_dir_all(git_dir);
252    }
253
254    fn pack_request<'a>(
255        local_db: &'a FileObjectDatabase,
256        format: ObjectFormat,
257        commands: &'a [ReceivePackCommand],
258        remote_advertisements: &'a [RefAdvertisement],
259        features: &'a ReceivePackFeatures,
260        thin: bool,
261    ) -> PushPackRequest<'a> {
262        PushPackRequest {
263            local_db,
264            format,
265            commands,
266            pack_objects: &[],
267            remote_advertisements,
268            features,
269            options: default_options(),
270            thin,
271        }
272    }
273
274    #[test]
275    fn thin_push_packfile_omits_known_remote_bases_and_round_trips() {
276        let git_dir = temp_git_dir();
277        let format = ObjectFormat::Sha1;
278        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
279
280        let base_oid = write_blob(&mut db, b"0123456789abcdef repeated base\n");
281        let similar_oid = write_blob(&mut db, b"0123456789abcdef repeated base with extra tail\n");
282        let commands = [push_command(&base_oid, &similar_oid)];
283        let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
284        let features = default_features();
285
286        let thin_pack = build_push_packfile(&pack_request(
287            &db,
288            format,
289            &commands,
290            &remote_advertisements,
291            &features,
292            true,
293        ))
294        .expect("thin pack");
295        let full_pack = build_push_packfile(&pack_request(
296            &db,
297            format,
298            &commands,
299            &remote_advertisements,
300            &features,
301            false,
302        ))
303        .expect("full pack");
304        assert!(thin_pack.starts_with(b"PACK"));
305        assert!(full_pack.starts_with(b"PACK"));
306        assert!(
307            thin_pack.len() <= full_pack.len(),
308            "thin pack should not be larger than a self-contained pack"
309        );
310
311        let body = build_receive_pack_body(&pack_request(
312            &db,
313            format,
314            &commands,
315            &remote_advertisements,
316            &features,
317            true,
318        ))
319        .expect("thin receive-pack body");
320        let parsed =
321            parse_receive_pack_push_request(format, &body, false).expect("parse thin body");
322        assert_eq!(parsed.packfile, thin_pack);
323        assert_eq!(parsed.commands.commands, commands);
324
325        let _ = fs::remove_dir_all(git_dir);
326    }
327
328    #[test]
329    fn thin_push_respects_remote_no_thin_capability() {
330        let git_dir = temp_git_dir();
331        let format = ObjectFormat::Sha1;
332        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
333
334        let base_oid = write_blob(&mut db, b"base\n");
335        let new_oid = write_blob(&mut db, b"new\n");
336
337        let no_thin_features = ReceivePackFeatures {
338            no_thin: true,
339            ..default_features()
340        };
341        let commands = [push_command(&base_oid, &new_oid)];
342        let remote_advertisements = [advertisement(&base_oid, "refs/heads/main")];
343
344        let thin_pack = build_push_packfile(&pack_request(
345            &db,
346            format,
347            &commands,
348            &remote_advertisements,
349            &no_thin_features,
350            true,
351        ))
352        .expect("no-thin fallback pack");
353        let full_pack = build_push_packfile(&pack_request(
354            &db,
355            format,
356            &commands,
357            &remote_advertisements,
358            &no_thin_features,
359            false,
360        ))
361        .expect("full pack");
362        assert_eq!(thin_pack, full_pack);
363
364        let _ = fs::remove_dir_all(git_dir);
365    }
366}