1use 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
19pub 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
38pub struct PushPackRequest<'a> {
40 pub local_db: &'a FileObjectDatabase,
42 pub format: ObjectFormat,
44 pub commands: &'a [ReceivePackCommand],
46 pub pack_objects: &'a [ObjectId],
49 pub remote_advertisements: &'a [RefAdvertisement],
51 pub features: &'a ReceivePackFeatures,
53 pub options: ReceivePackPushRequestOptions,
55 pub thin: bool,
57}
58
59pub 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
144pub 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}