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 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
140pub 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}