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_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
20pub 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
39pub struct PushPackRequest<'a> {
41 pub local_db: &'a FileObjectDatabase,
43 pub format: ObjectFormat,
45 pub commands: &'a [ReceivePackCommand],
47 pub pack_objects: &'a [ObjectId],
50 pub remote_advertisements: &'a [RefAdvertisement],
52 pub features: &'a ReceivePackFeatures,
54 pub options: ReceivePackPushRequestOptions,
56 pub thin: bool,
58}
59
60pub 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
175pub 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}