1use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
13use std::fs;
14use std::io::{Cursor, ErrorKind, Read};
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use sley_config::GitConfig;
19use sley_core::{
20 Capability, GitError, ObjectFormat, ObjectId, Result, UPSTREAM_GIT_COMPAT_VERSION,
21};
22use sley_object::{Commit, ObjectType, Tag};
23use sley_odb::{
24 FileObjectDatabase, ObjectReader, RawPackInstallOptions, ReachablePackMissingPolicy,
25 ReachablePackThinBaseCandidates, build_and_install_reachable_pack,
26 build_and_install_reachable_pack_filtered_with_thin_bases, build_reachable_pack,
27 build_reachable_pack_filtered, collect_reachable_object_ids,
28};
29use sley_protocol::{
30 PKT_LINE_MAX_PAYLOAD_LEN, PktLineFrame, ProtocolV2FetchAcknowledgment, ProtocolV2FetchFeatures,
31 ProtocolV2FetchRequest, ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo,
32 ProtocolV2FetchWantedRef, ProtocolV2LsRefsFeatures, ProtocolV2LsRefsRecord,
33 ProtocolV2LsRefsRef, ProtocolV2LsRefsRequest, ProtocolVersion, ReceivePackCommand,
34 ReceivePackCommandStatus, ReceivePackFeatures, ReceivePackPushRequest,
35 ReceivePackPushRequestHeader, ReceivePackReportStatus, ReceivePackRequest,
36 ReceivePackUnpackStatus, RefAdvertisement, SideBandChannel, SideBandPacket, TransportHandshake,
37 UploadPackAckStatus, UploadPackAcknowledgment, UploadPackFeatures,
38 UploadPackNegotiationRequest, UploadPackPackfileResponse, UploadPackRawPackfileResponse,
39 UploadPackRequest, apply_receive_pack_push_request, build_upload_pack_raw_packfile_response,
40 classify_protocol_v2_command_request, encode_protocol_v2_fetch_capability,
41 encode_protocol_v2_ls_refs_capability, encode_receive_pack_features,
42 encode_upload_pack_features, read_protocol_v2_command_request, read_upload_pack_acknowledgment,
43 read_upload_pack_negotiation_request, read_upload_pack_request,
44 validate_receive_pack_push_request_features, write_pkt_line_frame, write_pkt_line_payload,
45 write_protocol_v2_advertisement, write_protocol_v2_fetch_request,
46 write_protocol_v2_fetch_response, write_protocol_v2_ls_refs_response,
47 write_upload_pack_acknowledgment, write_upload_pack_negotiation_request,
48 write_upload_pack_request,
49};
50use sley_refs::{
51 DeleteRef, FileRefStore, Ref, RefDeletePrecondition, RefPrecondition, RefTarget, ReflogEntry,
52};
53
54use crate::install::install_protocol_v2_fetch_response_from_reader;
55
56fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
59 Ok(ObjectId::null(format))
60}
61
62fn resolve_for_each_ref_target(
65 store: &FileRefStore,
66 reference: &Ref,
67) -> Result<Option<(ObjectId, Option<String>)>> {
68 let mut target = reference.target.clone();
69 let mut symref = None;
70 for _ in 0..5 {
71 match target {
72 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
73 RefTarget::Symbolic(name) => {
74 symref.get_or_insert_with(|| name.clone());
75 let Some(next) = store.read_ref(&name)? else {
76 return Ok(None);
77 };
78 target = next;
79 }
80 }
81 }
82 Ok(None)
83}
84
85pub fn upload_pack_features(git_dir: &Path, format: ObjectFormat) -> Result<UploadPackFeatures> {
88 let store = FileRefStore::new(git_dir, format);
89 let mut symrefs = Vec::new();
90 if let Some(RefTarget::Symbolic(target)) = store.read_ref("HEAD")? {
91 symrefs.push(format!("HEAD:{target}"));
92 }
93 Ok(UploadPackFeatures {
94 object_format: Some(format),
95 side_band_64k: true,
96 symrefs,
97 ..UploadPackFeatures::default()
98 })
99}
100
101pub fn upload_pack_request_uses_sideband(request: &UploadPackRequest) -> bool {
103 request
104 .capabilities
105 .iter()
106 .any(|capability| matches!(capability.name.as_str(), "side-band" | "side-band-64k"))
107}
108
109pub fn upload_pack_sideband_response(
112 response: UploadPackRawPackfileResponse,
113) -> UploadPackPackfileResponse {
114 let mut sideband = Vec::new();
115 let chunk_len = PKT_LINE_MAX_PAYLOAD_LEN - 1;
116 for chunk in response.packfile.chunks(chunk_len) {
117 sideband.push(SideBandPacket {
118 channel: SideBandChannel::Data,
119 data: chunk.to_vec(),
120 });
121 }
122 UploadPackPackfileResponse {
123 acknowledgments: response.acknowledgments,
124 sideband,
125 }
126}
127
128pub fn attach_upload_pack_capabilities(
131 advertisements: &mut Vec<RefAdvertisement>,
132 format: ObjectFormat,
133 features: &UploadPackFeatures,
134) -> Result<()> {
135 let capabilities = encode_upload_pack_features(features)?;
136 if let Some(first) = advertisements.first_mut() {
137 first.capabilities = capabilities;
138 } else {
139 advertisements.push(RefAdvertisement {
140 oid: zero_oid(format)?,
141 name: "capabilities^{}".into(),
142 capabilities,
143 });
144 }
145 Ok(())
146}
147
148pub fn upload_pack_from_local_repository(
152 git_dir: &Path,
153 format: ObjectFormat,
154 features: &UploadPackFeatures,
155 request: UploadPackRequest,
156 haves: HashSet<ObjectId>,
157) -> Result<UploadPackRawPackfileResponse> {
158 let db = FileObjectDatabase::from_git_dir(git_dir, format);
159 build_upload_pack_raw_packfile_response(
160 features,
161 request,
162 haves,
163 |oid| db.contains(oid),
164 |wants, known_haves| {
165 let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
166 build_reachable_pack(&db, format, wants, &excluded)
167 .map(|pack| pack.map(|pack| pack.pack))
168 },
169 )
170}
171
172pub fn receive_pack_features(format: ObjectFormat) -> ReceivePackFeatures {
176 ReceivePackFeatures {
177 report_status: true,
178 report_status_v2: true,
179 delete_refs: true,
180 ofs_delta: true,
181 push_options: true,
182 quiet: true,
183 no_thin: true,
184 atomic: true,
185 side_band_64k: true,
186 object_format: Some(format),
187 ..ReceivePackFeatures::default()
188 }
189}
190
191pub fn receive_pack_request_uses_push_options(request: &ReceivePackRequest) -> bool {
194 request
195 .capabilities
196 .iter()
197 .any(|capability| capability.name == "push-options")
198}
199
200pub fn attach_receive_pack_capabilities(
203 advertisements: &mut Vec<RefAdvertisement>,
204 format: ObjectFormat,
205 features: &ReceivePackFeatures,
206) -> Result<()> {
207 let capabilities = encode_receive_pack_features(features)?;
208 if let Some(first) = advertisements.first_mut() {
209 first.capabilities = capabilities;
210 } else {
211 advertisements.push(RefAdvertisement {
212 oid: zero_oid(format)?,
213 name: "capabilities^{}".into(),
214 capabilities,
215 });
216 }
217 Ok(())
218}
219
220pub fn receive_pack_into_local_repository(
224 remote_git_dir: &Path,
225 format: ObjectFormat,
226 request: &ReceivePackPushRequest,
227) -> Result<ReceivePackReportStatus> {
228 let remote_store = FileRefStore::new(remote_git_dir, format);
229 let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
230 let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
231 apply_receive_pack_push_request(
232 &receive_pack_features(format),
233 request,
234 |name| match remote_store.read_ref(name)? {
235 Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
236 Some(RefTarget::Symbolic(_)) | None => Ok(None),
237 },
238 |packfile| {
239 let mut reader = packfile;
240 remote_db
241 .install_raw_pack_from_reader(&mut reader)
242 .map(|_| ())
243 },
244 |oid| remote_db.contains(oid),
245 |commands| {
246 let applied = apply_receive_pack_ref_transaction(
247 remote_git_dir,
248 format,
249 &remote_store,
250 commands,
251 &request.commands.commands,
252 )?;
253 deletes_applied_with_updates.borrow_mut().extend(applied);
254 Ok(())
255 },
256 |command| {
257 if deletes_applied_with_updates
258 .borrow()
259 .contains(command.name.as_str())
260 {
261 return Ok(());
262 }
263 remote_store
264 .delete_ref_checked(DeleteRef {
265 name: command.name.clone(),
266 expected_old: (!command.old_id.is_null()).then_some(command.old_id),
267 reflog: None,
268 })
269 .map(|_| ())
270 .map_err(|err| GitError::Transaction(err.to_string()))
271 },
272 )
273}
274
275pub fn receive_pack_stream_into_local_repository<R: Read>(
280 remote_git_dir: &Path,
281 format: ObjectFormat,
282 header: &ReceivePackPushRequestHeader,
283 pack_reader: &mut R,
284) -> Result<ReceivePackReportStatus> {
285 let remote_store = FileRefStore::new(remote_git_dir, format);
286 let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
287 let pack_prefix = read_optional_pack_prefix(pack_reader)?;
288 let validation_request = ReceivePackPushRequest {
289 commands: header.commands.clone(),
290 push_options: header.push_options.clone(),
291 packfile: pack_prefix.clone().unwrap_or_default(),
292 };
293 validate_receive_pack_push_request_features(
294 &receive_pack_features(format),
295 &validation_request,
296 )?;
297
298 let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
299 for command in header
300 .commands
301 .commands
302 .iter()
303 .filter(|command| command.new_id.is_null())
304 {
305 let current = match remote_store.read_ref(&command.name)? {
306 Some(RefTarget::Direct(oid)) => Some(oid),
307 Some(RefTarget::Symbolic(_)) | None => None,
308 };
309 if !command.old_id.is_null() && current != Some(command.old_id.clone()) {
310 return Err(GitError::Transaction(format!(
311 "expected ref {} to match",
312 command.name
313 )));
314 }
315 }
316
317 let updates = header
318 .commands
319 .commands
320 .iter()
321 .filter(|command| !command.new_id.is_null())
322 .cloned()
323 .collect::<Vec<_>>();
324 if !updates.is_empty() {
325 if let Some(prefix) = pack_prefix {
326 let mut stream = Cursor::new(prefix).chain(pack_reader);
327 remote_db
328 .install_raw_pack_from_reader(&mut stream)
329 .map(|_| ())?;
330 }
331 for command in &updates {
332 if !remote_db.contains(&command.new_id)? {
333 return Err(GitError::InvalidObject(format!(
334 "receive-pack packfile did not provide {}",
335 command.new_id
336 )));
337 }
338 }
339 let applied = apply_receive_pack_ref_transaction(
340 remote_git_dir,
341 format,
342 &remote_store,
343 &updates,
344 &header.commands.commands,
345 )?;
346 deletes_applied_with_updates.borrow_mut().extend(applied);
347 }
348
349 for command in header
350 .commands
351 .commands
352 .iter()
353 .filter(|command| command.new_id.is_null())
354 {
355 if deletes_applied_with_updates
356 .borrow()
357 .contains(command.name.as_str())
358 {
359 continue;
360 }
361 remote_store
362 .delete_ref_checked(DeleteRef {
363 name: command.name.clone(),
364 expected_old: (!command.old_id.is_null()).then_some(command.old_id),
365 reflog: None,
366 })
367 .map(|_| ())
368 .map_err(|err| GitError::Transaction(err.to_string()))?;
369 }
370
371 Ok(ReceivePackReportStatus {
372 unpack: ReceivePackUnpackStatus::Ok,
373 commands: header
374 .commands
375 .commands
376 .iter()
377 .map(|command| ReceivePackCommandStatus::Ok {
378 name: command.name.clone(),
379 })
380 .collect(),
381 })
382}
383
384fn read_optional_pack_prefix(reader: &mut impl Read) -> Result<Option<Vec<u8>>> {
385 let mut prefix = [0u8; 4];
386 loop {
387 match reader.read(&mut prefix[..1]) {
388 Ok(0) => return Ok(None),
389 Ok(1) => break,
390 Ok(_) => unreachable!("one-byte read returned more than one byte"),
391 Err(err) if err.kind() == ErrorKind::Interrupted => {}
392 Err(err) => return Err(err.into()),
393 }
394 }
395 reader.read_exact(&mut prefix[1..])?;
396 if &prefix != b"PACK" {
397 return Err(GitError::InvalidFormat(
398 "receive-pack packfile must start with PACK".into(),
399 ));
400 }
401 Ok(Some(prefix.to_vec()))
402}
403
404fn receive_pack_log_all_ref_updates(git_dir: &Path) -> bool {
405 let Ok(config) = fs::read_to_string(git_dir.join("config")) else {
406 return false;
407 };
408 let mut in_core = false;
409 for raw_line in config.lines() {
410 let line = raw_line.trim();
411 if line.starts_with('[') && line.ends_with(']') {
412 in_core = line.eq_ignore_ascii_case("[core]");
413 continue;
414 }
415 if !in_core || line.starts_with('#') || line.starts_with(';') {
416 continue;
417 }
418 let Some((name, value)) = line.split_once('=') else {
419 continue;
420 };
421 if name.trim().eq_ignore_ascii_case("logallrefupdates") {
422 return matches!(
423 value.trim().trim_matches('"').to_ascii_lowercase().as_str(),
424 "true" | "yes" | "on" | "1" | "always"
425 );
426 }
427 }
428 false
429}
430
431fn receive_pack_should_write_reflog(refname: &str) -> bool {
432 refname == "HEAD"
433 || refname.starts_with("refs/heads/")
434 || refname.starts_with("refs/remotes/")
435 || refname.starts_with("refs/notes/")
436}
437
438fn receive_pack_reflog_entry(
439 format: ObjectFormat,
440 old_oid: ObjectId,
441 new_oid: ObjectId,
442) -> ReflogEntry {
443 let old_oid = if old_oid.is_null() {
444 ObjectId::null(format)
445 } else {
446 old_oid
447 };
448 ReflogEntry {
449 old_oid,
450 new_oid,
451 committer: receive_pack_reflog_committer(),
452 message: b"push".to_vec(),
453 }
454}
455
456fn receive_pack_reflog_committer() -> Vec<u8> {
457 let seconds = SystemTime::now()
458 .duration_since(UNIX_EPOCH)
459 .map(|duration| duration.as_secs())
460 .unwrap_or(0);
461 format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
462}
463
464pub fn receive_pack_reachable_pack_into_local_repository(
471 remote_git_dir: &Path,
472 format: ObjectFormat,
473 request: &ReceivePackPushRequest,
474 source_db: &FileObjectDatabase,
475 starts: Vec<ObjectId>,
476 excluded: HashSet<ObjectId>,
477) -> Result<ReceivePackReportStatus> {
478 let remote_store = FileRefStore::new(remote_git_dir, format);
479 let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
480 let mut starts = Some(starts);
481 let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
482 apply_receive_pack_push_request(
483 &receive_pack_features(format),
484 request,
485 |name| match remote_store.read_ref(name)? {
486 Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
487 Some(RefTarget::Symbolic(_)) | None => Ok(None),
488 },
489 |_| {
490 let starts = starts.take().ok_or_else(|| {
491 GitError::InvalidFormat("receive-pack attempted to install pack twice".into())
492 })?;
493 build_and_install_reachable_pack(
494 source_db,
495 &remote_db,
496 format,
497 starts,
498 &excluded,
499 RawPackInstallOptions {
500 promisor: false,
501 ..Default::default()
502 },
503 )?;
504 Ok(())
505 },
506 |oid| remote_db.contains(oid),
507 |commands| {
508 let applied = apply_receive_pack_ref_transaction(
509 remote_git_dir,
510 format,
511 &remote_store,
512 commands,
513 &request.commands.commands,
514 )?;
515 deletes_applied_with_updates.borrow_mut().extend(applied);
516 Ok(())
517 },
518 |command| {
519 if deletes_applied_with_updates
520 .borrow()
521 .contains(command.name.as_str())
522 {
523 return Ok(());
524 }
525 remote_store
526 .delete_ref_checked(DeleteRef {
527 name: command.name.clone(),
528 expected_old: (!command.old_id.is_null()).then_some(command.old_id),
529 reflog: None,
530 })
531 .map(|_| ())
532 .map_err(|err| GitError::Transaction(err.to_string()))
533 },
534 )
535}
536
537pub(crate) fn apply_receive_pack_ref_transaction(
538 remote_git_dir: &Path,
539 format: ObjectFormat,
540 store: &FileRefStore,
541 updates: &[ReceivePackCommand],
542 all_commands: &[ReceivePackCommand],
543) -> Result<HashSet<String>> {
544 let updates = canonical_receive_pack_update_commands(store, updates)?;
545 let deletes = all_commands
546 .iter()
547 .filter(|command| command.new_id.is_null())
548 .collect::<Vec<_>>();
549 let mut tx = store.transaction();
550 for command in &deletes {
551 tx.delete_with_precondition(
552 command.name.clone(),
553 RefDeletePrecondition::Direct((!command.old_id.is_null()).then_some(command.old_id)),
554 None,
555 );
556 }
557 let log_updates = receive_pack_log_all_ref_updates(remote_git_dir);
558 for command in &updates {
559 let precondition = if command.old_id.is_null() {
560 RefPrecondition::MustNotExist
561 } else {
562 RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
563 };
564 let reflog = if log_updates && receive_pack_should_write_reflog(&command.name) {
565 Some(receive_pack_reflog_entry(
566 format,
567 command.old_id,
568 command.new_id,
569 ))
570 } else {
571 None
572 };
573 tx.update_to(
574 command.name.clone(),
575 RefTarget::Direct(command.new_id),
576 precondition,
577 reflog,
578 );
579 }
580 tx.commit()?;
581 Ok(deletes
582 .into_iter()
583 .map(|command| command.name.clone())
584 .collect())
585}
586
587fn canonical_receive_pack_update_commands(
588 store: &FileRefStore,
589 commands: &[ReceivePackCommand],
590) -> Result<Vec<ReceivePackCommand>> {
591 let mut by_actual = HashMap::<String, ObjectId>::new();
592 let mut canonical = Vec::with_capacity(commands.len());
593 for command in commands {
594 let name = match store.read_ref(&command.name)? {
595 Some(RefTarget::Symbolic(target)) => target,
596 Some(RefTarget::Direct(_)) | None => command.name.clone(),
597 };
598 if let Some(existing) = by_actual.get(&name) {
599 if existing != &command.new_id {
600 return Err(GitError::Command("refusing inconsistent update".into()));
601 }
602 } else {
603 by_actual.insert(name.clone(), command.new_id);
604 }
605 canonical.push(ReceivePackCommand {
606 old_id: command.old_id,
607 new_id: command.new_id,
608 name,
609 });
610 }
611 Ok(canonical)
612}
613
614pub fn local_fetch_advertisements(
617 git_dir: &Path,
618 format: ObjectFormat,
619) -> Result<Vec<RefAdvertisement>> {
620 let store = FileRefStore::new_without_reference_backend_env(git_dir, format);
621 let mut advertisements = Vec::new();
622 if let Some(target) = store.read_ref("HEAD")? {
623 let reference = Ref {
624 name: "HEAD".to_string(),
625 target,
626 };
627 if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
628 advertisements.push(RefAdvertisement {
629 oid,
630 name: reference.name,
631 capabilities: Vec::new(),
632 });
633 }
634 }
635 for reference in store.list_refs()? {
636 let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
637 continue;
638 };
639 advertisements.push(RefAdvertisement {
640 oid,
641 name: reference.name,
642 capabilities: Vec::new(),
643 });
644 }
645 Ok(advertisements)
646}
647
648pub fn local_have_oids(git_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
652 let mut seen = HashSet::new();
653 let mut haves = Vec::new();
654 for advertisement in local_fetch_advertisements(git_dir, format)? {
655 if seen.insert(advertisement.oid) {
656 haves.push(advertisement.oid);
657 }
658 }
659 let db = FileObjectDatabase::from_git_dir(git_dir, format);
660 for oid in db.object_ids()? {
661 if seen.insert(oid) {
662 haves.push(oid);
663 }
664 }
665 Ok(haves)
666}
667
668pub(crate) fn local_negotiation_have_oids(
674 git_dir: &Path,
675 format: ObjectFormat,
676) -> Result<Vec<ObjectId>> {
677 local_negotiation_have_oids_stopping_at(git_dir, format, &HashSet::new())
678}
679
680fn local_negotiation_have_oids_pruned_by_remote_tips(
686 git_dir: &Path,
687 remote_git_dir: &Path,
688 format: ObjectFormat,
689) -> Result<Vec<ObjectId>> {
690 let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format)
691 .with_promisor_remote_present(repo_has_promisor_remote(remote_git_dir));
692 let mut advertised_commits = HashSet::new();
693 for advertisement in local_fetch_advertisements(remote_git_dir, format)? {
694 if !remote_db.contains(&advertisement.oid)? {
698 continue;
699 }
700 if let Some(commit) =
701 peel_to_commit_for_negotiation(&remote_db, format, &advertisement.oid)?
702 {
703 advertised_commits.insert(commit);
704 }
705 }
706 local_negotiation_have_oids_stopping_at(git_dir, format, &advertised_commits)
707}
708
709fn local_negotiation_have_oids_stopping_at(
710 git_dir: &Path,
711 format: ObjectFormat,
712 stop_commits: &HashSet<ObjectId>,
713) -> Result<Vec<ObjectId>> {
714 let db = FileObjectDatabase::from_git_dir(git_dir, format)
715 .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
716 let mut seen = HashSet::new();
717 let mut queue = BinaryHeap::new();
718 let commit_time = |oid: &ObjectId| -> Result<i64> {
719 let object = db.read_object(oid)?;
720 Ok(Commit::parse_ref(format, &object.body)?
721 .committer_signature()
722 .map(|signature| signature.time.seconds)
723 .unwrap_or(0))
724 };
725 for advertisement in local_fetch_advertisements(git_dir, format)? {
726 if let Some(commit) = peel_to_commit_for_negotiation(&db, format, &advertisement.oid)?
727 && seen.insert(commit)
728 {
729 queue.push((commit_time(&commit)?, commit));
730 }
731 }
732
733 let mut haves = Vec::new();
734 while let Some((_time, oid)) = queue.pop() {
735 haves.push(oid);
736 if stop_commits.contains(&oid) {
737 continue;
738 }
739 let object = db.read_object(&oid)?;
740 for parent in
741 sley_odb::grafted_parents(&db, &oid, Commit::parse_ref(format, &object.body)?.parents)
742 {
743 if seen.insert(parent) {
744 queue.push((commit_time(&parent)?, parent));
745 }
746 }
747 }
748 Ok(haves)
749}
750
751#[derive(Debug, Clone)]
758pub struct LocalDeepenPlan {
759 pub depth: u32,
763 pub deepen_since: bool,
765 pub deepen_not: usize,
767 pub client_shallow: Vec<ObjectId>,
770 pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
773 pub excluded: HashSet<ObjectId>,
778 pub extra_wants: Vec<ObjectId>,
782}
783
784fn peel_to_commit<R: ObjectReader>(
788 remote_db: &R,
789 format: ObjectFormat,
790 oid: &ObjectId,
791) -> Result<Option<ObjectId>> {
792 let mut oid = *oid;
793 loop {
794 let object = remote_db.read_object(&oid)?;
795 match object.object_type {
796 ObjectType::Commit => return Ok(Some(oid)),
797 ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
798 _ => return Ok(None),
799 }
800 }
801}
802
803fn peel_to_commit_for_negotiation<R: ObjectReader>(
808 remote_db: &R,
809 format: ObjectFormat,
810 oid: &ObjectId,
811) -> Result<Option<ObjectId>> {
812 let mut oid = *oid;
813 loop {
814 let object = match remote_db.read_object(&oid) {
815 Ok(object) => object,
816 Err(GitError::NotFound(_)) if remote_db.is_promised_object(&oid) => return Ok(None),
817 Err(error) => return Err(error),
818 };
819 match object.object_type {
820 ObjectType::Commit => return Ok(Some(oid)),
821 ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
822 _ => return Ok(None),
823 }
824 }
825}
826
827pub fn compute_local_deepen<R: ObjectReader>(
840 remote_db: &R,
841 format: ObjectFormat,
842 heads: &[ObjectId],
843 client_shallow: Vec<ObjectId>,
844 depth: u32,
845 deepen_relative: bool,
846) -> Result<LocalDeepenPlan> {
847 let depth = if deepen_relative && depth < INFINITE_DEPTH {
850 depth.saturating_add(client_shallow_min_depth(
851 remote_db,
852 format,
853 heads,
854 &client_shallow,
855 )?)
856 } else {
857 depth
858 };
859 let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
860 let mut queue: VecDeque<ObjectId> = VecDeque::new();
861 for head in heads {
862 let Some(commit) = peel_to_commit(remote_db, format, head)? else {
863 continue;
864 };
865 if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
866 entry.insert(0);
867 queue.push_back(commit);
868 }
869 }
870 let mut boundary = Vec::new();
875 let mut boundary_parents = HashSet::new();
876 while let Some(oid) = queue.pop_front() {
877 let commit_depth = min_depth[&oid];
878 let object = remote_db.read_object(&oid)?;
879 let parents = sley_odb::grafted_parents(
880 remote_db,
881 &oid,
882 Commit::parse_ref(format, &object.body)?.parents,
883 );
884 if (depth != INFINITE_DEPTH && commit_depth + 1 >= depth)
888 || remote_db.is_shallow_graft(&oid)
889 {
890 boundary.push(oid);
891 boundary_parents.extend(parents);
892 continue;
893 }
894 for parent in parents {
895 if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
896 entry.insert(commit_depth + 1);
897 queue.push_back(parent);
898 }
899 }
900 }
901 let excluded = boundary_parents
905 .into_iter()
906 .filter(|parent| !min_depth.contains_key(parent))
907 .collect::<HashSet<_>>();
908
909 let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
910 let boundary_set: HashSet<ObjectId> = boundary.iter().copied().collect();
911 let mut shallow_info = Vec::new();
912 for oid in &boundary {
913 if !client.contains(oid) {
914 shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
915 }
916 }
917 let mut extra_wants = Vec::new();
918 for oid in &client_shallow {
919 let unshallowed = min_depth.contains_key(oid) && !boundary_set.contains(oid);
922 if !unshallowed {
923 continue;
924 }
925 shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
926 let object = remote_db.read_object(oid)?;
927 extra_wants.extend(sley_odb::grafted_parents(
928 remote_db,
929 oid,
930 Commit::parse_ref(format, &object.body)?.parents,
931 ));
932 }
933 Ok(LocalDeepenPlan {
934 depth,
935 deepen_since: false,
936 deepen_not: 0,
937 client_shallow,
938 shallow_info,
939 excluded,
940 extra_wants,
941 })
942}
943
944pub const INFINITE_DEPTH: u32 = 0x7fff_ffff;
947
948fn client_shallow_min_depth<R: ObjectReader>(
952 remote_db: &R,
953 format: ObjectFormat,
954 heads: &[ObjectId],
955 client_shallow: &[ObjectId],
956) -> Result<u32> {
957 if client_shallow.is_empty() {
958 return Ok(0);
959 }
960 let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
961 let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
962 let mut queue: VecDeque<ObjectId> = VecDeque::new();
963 for head in heads {
964 let Some(commit) = peel_to_commit(remote_db, format, head)? else {
965 continue;
966 };
967 if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
968 entry.insert(1);
969 queue.push_back(commit);
970 }
971 }
972 let mut best: u32 = 0;
973 while let Some(oid) = queue.pop_front() {
974 let commit_depth = min_depth[&oid];
975 if client.contains(&oid) && (best == 0 || commit_depth < best) {
976 best = commit_depth;
977 }
978 let object = remote_db.read_object(&oid)?;
979 let parents = sley_odb::grafted_parents(
980 remote_db,
981 &oid,
982 Commit::parse_ref(format, &object.body)?.parents,
983 );
984 for parent in parents {
985 if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
986 entry.insert(commit_depth + 1);
987 queue.push_back(parent);
988 }
989 }
990 }
991 Ok(best)
992}
993
994pub fn compute_local_deepen_by_rev_list<R: ObjectReader>(
1000 remote_db: &R,
1001 format: ObjectFormat,
1002 heads: &[ObjectId],
1003 client_shallow: Vec<ObjectId>,
1004 since: Option<i64>,
1005 deepen_not: &[ObjectId],
1006) -> Result<LocalDeepenPlan> {
1007 let mut excluded_not: HashSet<ObjectId> = HashSet::new();
1009 let mut queue: VecDeque<ObjectId> = VecDeque::new();
1010 for tip in deepen_not {
1011 if let Some(commit) = peel_to_commit(remote_db, format, tip)?
1012 && excluded_not.insert(commit)
1013 {
1014 queue.push_back(commit);
1015 }
1016 }
1017 while let Some(oid) = queue.pop_front() {
1018 let object = remote_db.read_object(&oid)?;
1019 for parent in sley_odb::grafted_parents(
1020 remote_db,
1021 &oid,
1022 Commit::parse_ref(format, &object.body)?.parents,
1023 ) {
1024 if excluded_not.insert(parent) {
1025 queue.push_back(parent);
1026 }
1027 }
1028 }
1029
1030 let commit_time = |oid: &ObjectId| -> Result<i64> {
1031 let object = remote_db.read_object(oid)?;
1032 Ok(Commit::parse_ref(format, &object.body)?
1033 .committer_signature()
1034 .map(|signature| signature.time.seconds)
1035 .unwrap_or(0))
1036 };
1037 let keeps = |oid: &ObjectId| -> Result<bool> {
1038 if excluded_not.contains(oid) {
1039 return Ok(false);
1040 }
1041 match since {
1042 Some(since) => Ok(commit_time(oid)? >= since),
1043 None => Ok(true),
1044 }
1045 };
1046
1047 let mut kept: HashSet<ObjectId> = HashSet::new();
1050 let mut kept_order: Vec<ObjectId> = Vec::new();
1051 let mut queue: VecDeque<ObjectId> = VecDeque::new();
1052 for head in heads {
1053 let Some(commit) = peel_to_commit(remote_db, format, head)? else {
1054 continue;
1055 };
1056 if keeps(&commit)? && kept.insert(commit) {
1057 kept_order.push(commit);
1058 queue.push_back(commit);
1059 }
1060 }
1061 while let Some(oid) = queue.pop_front() {
1062 let object = remote_db.read_object(&oid)?;
1063 for parent in sley_odb::grafted_parents(
1064 remote_db,
1065 &oid,
1066 Commit::parse_ref(format, &object.body)?.parents,
1067 ) {
1068 if !kept.contains(&parent) && keeps(&parent)? {
1069 kept.insert(parent);
1070 kept_order.push(parent);
1071 queue.push_back(parent);
1072 }
1073 }
1074 }
1075 if kept.is_empty() {
1076 return Err(GitError::Command(
1078 "no commits selected for shallow requests".into(),
1079 ));
1080 }
1081
1082 let mut boundary = Vec::new();
1084 let mut boundary_set: HashSet<ObjectId> = HashSet::new();
1085 let mut excluded: HashSet<ObjectId> = HashSet::new();
1086 for oid in &kept_order {
1087 let object = remote_db.read_object(oid)?;
1088 let parents = sley_odb::grafted_parents(
1089 remote_db,
1090 oid,
1091 Commit::parse_ref(format, &object.body)?.parents,
1092 );
1093 let mut is_boundary = false;
1094 for parent in parents {
1095 if !kept.contains(&parent) {
1096 is_boundary = true;
1097 excluded.insert(parent);
1098 }
1099 }
1100 if is_boundary && boundary_set.insert(*oid) {
1101 boundary.push(*oid);
1102 }
1103 }
1104
1105 let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
1106 let mut shallow_info = Vec::new();
1107 for oid in &boundary {
1108 if !client.contains(oid) {
1109 shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
1110 }
1111 }
1112 let mut extra_wants = Vec::new();
1113 for oid in &client_shallow {
1114 let unshallowed = kept.contains(oid) && !boundary_set.contains(oid);
1115 if !unshallowed {
1116 continue;
1117 }
1118 shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
1119 let object = remote_db.read_object(oid)?;
1120 extra_wants.extend(sley_odb::grafted_parents(
1121 remote_db,
1122 oid,
1123 Commit::parse_ref(format, &object.body)?.parents,
1124 ));
1125 }
1126 Ok(LocalDeepenPlan {
1127 depth: 0,
1128 deepen_since: since.is_some(),
1129 deepen_not: deepen_not.len(),
1130 client_shallow,
1131 shallow_info,
1132 excluded,
1133 extra_wants,
1134 })
1135}
1136
1137const INITIAL_NEGOTIATION_BATCH: usize = 16;
1138const MAX_IN_VAIN: usize = 256;
1139
1140#[derive(Debug, Default)]
1141struct LocalNegotiationOutcome {
1142 common_haves: Vec<ObjectId>,
1143 total_rounds: usize,
1144}
1145
1146fn negotiate_local_upload_pack(
1153 remote_db: &FileObjectDatabase,
1154 format: ObjectFormat,
1155 wants: &[ObjectId],
1156 haves: Vec<ObjectId>,
1157) -> Result<LocalNegotiationOutcome> {
1158 let wanted_commits = wants
1159 .iter()
1160 .map(|want| {
1161 if remote_db.contains(want)? {
1162 peel_to_commit(remote_db, format, want)
1163 } else {
1164 Ok(None)
1168 }
1169 })
1170 .collect::<Result<Vec<_>>>()?;
1171 let mut outcome = LocalNegotiationOutcome::default();
1172 let mut next_have = 0usize;
1173 let mut batch_size = INITIAL_NEGOTIATION_BATCH;
1174 let mut in_vain = 0usize;
1175 let mut seen_ack = false;
1176 let mut common = HashSet::new();
1177
1178 loop {
1179 outcome.total_rounds += 1;
1180 let end = next_have.saturating_add(batch_size).min(haves.len());
1181 let batch = haves[next_have..end].to_vec();
1182 next_have = end;
1183 in_vain = in_vain.saturating_add(batch.len());
1184 let done = batch.is_empty() || (seen_ack && in_vain >= MAX_IN_VAIN);
1185
1186 let mut request_bytes = Vec::new();
1187 write_upload_pack_negotiation_request(
1188 &mut request_bytes,
1189 &UploadPackNegotiationRequest { haves: batch, done },
1190 )?;
1191 let decoded = read_upload_pack_negotiation_request(format, &mut request_bytes.as_slice())?;
1192 if decoded.done {
1193 break;
1194 }
1195
1196 let mut round_common = Vec::new();
1197 for oid in &decoded.haves {
1198 if remote_db.contains(oid)? && common.insert(*oid) {
1199 round_common.push(*oid);
1200 }
1201 }
1202 let ready = !round_common.is_empty()
1203 && common_covers_wanted_commits(remote_db, format, &wanted_commits, &common)?;
1204 let mut acknowledgments = if round_common.is_empty() {
1205 vec![UploadPackAcknowledgment::Nak]
1206 } else {
1207 round_common
1208 .iter()
1209 .enumerate()
1210 .map(|(index, oid)| UploadPackAcknowledgment::Ack {
1211 oid: *oid,
1212 status: Some(if ready && index + 1 == round_common.len() {
1213 UploadPackAckStatus::Ready
1214 } else {
1215 UploadPackAckStatus::Common
1216 }),
1217 })
1218 .collect::<Vec<_>>()
1219 };
1220 let mut response_bytes = Vec::new();
1221 for acknowledgment in &acknowledgments {
1222 write_upload_pack_acknowledgment(&mut response_bytes, acknowledgment)?;
1223 }
1224 acknowledgments.clear();
1225 let mut response = response_bytes.as_slice();
1226 while !response.is_empty() {
1227 acknowledgments.push(read_upload_pack_acknowledgment(format, &mut response)?);
1228 }
1229 let mut received_ready = false;
1230 for acknowledgment in acknowledgments {
1231 if let UploadPackAcknowledgment::Ack { oid, status } = acknowledgment {
1232 seen_ack = true;
1233 in_vain = 0;
1234 outcome.common_haves.push(oid);
1235 received_ready |= status == Some(UploadPackAckStatus::Ready);
1236 }
1237 }
1238 if received_ready {
1239 break;
1240 }
1241 batch_size = batch_size.saturating_mul(2);
1242 }
1243 Ok(outcome)
1244}
1245
1246fn common_covers_wanted_commits(
1247 remote_db: &FileObjectDatabase,
1248 format: ObjectFormat,
1249 wanted_commits: &[Option<ObjectId>],
1250 common: &HashSet<ObjectId>,
1251) -> Result<bool> {
1252 for wanted in wanted_commits.iter().flatten() {
1253 let mut stack = vec![*wanted];
1254 let mut seen = HashSet::new();
1255 let mut covered = false;
1256 while let Some(oid) = stack.pop() {
1257 if !seen.insert(oid) {
1258 continue;
1259 }
1260 if common.contains(&oid) {
1261 covered = true;
1262 break;
1263 }
1264 let object = remote_db.read_object(&oid)?;
1265 stack.extend(sley_odb::grafted_parents(
1266 remote_db,
1267 &oid,
1268 Commit::parse_ref(format, &object.body)?.parents,
1269 ));
1270 }
1271 if !covered {
1272 return Ok(false);
1273 }
1274 }
1275 Ok(true)
1276}
1277
1278#[allow(clippy::too_many_arguments)]
1291pub fn install_fetch_pack_via_local_upload_pack(
1292 git_dir: &Path,
1293 remote_git_dir: &Path,
1294 format: ObjectFormat,
1295 wants: Vec<ObjectId>,
1296 deepen: Option<&LocalDeepenPlan>,
1297 promisor: bool,
1298 record_promisor_refs: bool,
1299 filter: Option<sley_odb::PackObjectFilter>,
1300 custom_haves: Option<Vec<ObjectId>>,
1301 refetch: bool,
1302 unpack_limit: Option<usize>,
1303) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
1304 install_fetch_pack_via_local_upload_pack_with_promisor_decision(
1305 git_dir,
1306 remote_git_dir,
1307 format,
1308 wants,
1309 deepen,
1310 promisor,
1311 record_promisor_refs,
1312 filter,
1313 custom_haves,
1314 refetch,
1315 unpack_limit,
1316 &crate::PromisorRemoteDecision::default(),
1317 )
1318}
1319
1320#[allow(clippy::too_many_arguments)]
1325pub fn install_fetch_pack_via_local_upload_pack_with_promisor_decision(
1326 git_dir: &Path,
1327 remote_git_dir: &Path,
1328 format: ObjectFormat,
1329 wants: Vec<ObjectId>,
1330 deepen: Option<&LocalDeepenPlan>,
1331 promisor: bool,
1332 record_promisor_refs: bool,
1333 filter: Option<sley_odb::PackObjectFilter>,
1334 custom_haves: Option<Vec<ObjectId>>,
1335 refetch: bool,
1336 unpack_limit: Option<usize>,
1337 promisor_decision: &crate::PromisorRemoteDecision,
1338) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
1339 install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
1340 git_dir,
1341 git_dir,
1342 remote_git_dir,
1343 format,
1344 wants,
1345 deepen,
1346 promisor,
1347 record_promisor_refs,
1348 filter,
1349 custom_haves,
1350 refetch,
1351 unpack_limit,
1352 promisor_decision,
1353 LocalFetchPackRequestMode::ExactObjects,
1354 )
1355 .map(|outcome| outcome.shallow_info)
1356}
1357
1358#[derive(Debug, Default)]
1359pub(crate) struct LocalFetchPackOutcome {
1360 pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
1361 pub object_count: usize,
1362 pub compression_count: usize,
1363 pub delta_count: u32,
1364}
1365
1366#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1367pub(crate) enum LocalFetchPackRequestMode {
1368 Traversal,
1370 ExactObjects,
1373}
1374
1375#[allow(clippy::too_many_arguments)]
1376pub(crate) fn install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
1377 git_dir: &Path,
1378 destination_git_dir: &Path,
1379 remote_git_dir: &Path,
1380 format: ObjectFormat,
1381 wants: Vec<ObjectId>,
1382 deepen: Option<&LocalDeepenPlan>,
1383 promisor: bool,
1384 record_promisor_refs: bool,
1385 filter: Option<sley_odb::PackObjectFilter>,
1386 custom_haves: Option<Vec<ObjectId>>,
1387 refetch: bool,
1388 unpack_limit: Option<usize>,
1389 promisor_decision: &crate::PromisorRemoteDecision,
1390 request_mode: LocalFetchPackRequestMode,
1391) -> Result<LocalFetchPackOutcome> {
1392 if wants.is_empty() {
1393 return Ok(LocalFetchPackOutcome::default());
1394 }
1395 let local_db = FileObjectDatabase::from_git_dir(git_dir, format)
1396 .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1397 let all_wants_present = wants
1398 .iter()
1399 .map(|want| local_db.contains(want))
1400 .collect::<Result<Vec<_>>>()?
1401 .into_iter()
1402 .all(|contains| contains);
1403 let deepen_noop = match deepen {
1404 Some(plan) => plan.shallow_info.is_empty() && plan.extra_wants.is_empty(),
1405 None => true,
1406 };
1407 if all_wants_present && deepen_noop && !refetch {
1408 sley_protocol::trace_packet_write_payload(b"0000");
1409 return Ok(LocalFetchPackOutcome::default());
1410 }
1411
1412 let direct_promisor_object_fetch = promisor
1416 && deepen.is_none()
1417 && !record_promisor_refs
1418 && request_mode == LocalFetchPackRequestMode::ExactObjects;
1419 let transfer_filter = if direct_promisor_object_fetch {
1420 None
1421 } else {
1422 filter
1423 };
1424 let request = UploadPackRequest {
1425 wants,
1426 filter: transfer_filter
1427 .as_ref()
1428 .and_then(upload_pack_filter_protocol_spec),
1429 capabilities: deepen
1432 .map(|_| {
1433 vec![Capability {
1434 name: "shallow".into(),
1435 value: None,
1436 }]
1437 })
1438 .unwrap_or_default(),
1439 shallow: deepen
1440 .map(|plan| plan.client_shallow.clone())
1441 .unwrap_or_default(),
1442 deepen: deepen.and_then(|plan| (plan.depth > 0).then_some(plan.depth)),
1443 ..UploadPackRequest::default()
1444 };
1445 let mut encoded_request = Vec::new();
1446 write_upload_pack_request(&mut encoded_request, Some(&request))?;
1447 let decoded_request = read_upload_pack_request(format, &mut encoded_request.as_slice())?
1448 .ok_or_else(|| GitError::InvalidFormat("encoded upload-pack request was empty".into()))?;
1449
1450 if direct_promisor_object_fetch && local_upload_pack_client_wants_v2(git_dir) {
1453 trace_local_upload_pack_v2_capabilities(remote_git_dir, format);
1454 }
1455 let use_default_haves = !refetch && !direct_promisor_object_fetch && custom_haves.is_none();
1456 let haves = if refetch || direct_promisor_object_fetch {
1457 Vec::new()
1458 } else if let Some(haves) = custom_haves {
1459 haves
1460 } else {
1461 local_negotiation_have_oids_pruned_by_remote_tips(git_dir, remote_git_dir, format)?
1462 };
1463 let remote_has_promisor = repo_has_promisor_remote(remote_git_dir);
1464 let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format)
1465 .with_promisor_remote_present(remote_has_promisor);
1466 let negotiation =
1467 negotiate_local_upload_pack(&remote_db, format, &decoded_request.wants, haves)?;
1468 sley_core::trace2::data("negotiation_v2", "total_rounds", negotiation.total_rounds);
1469 let mut known_haves = negotiation.common_haves;
1470 if use_default_haves {
1471 let mut seen = known_haves.iter().copied().collect::<HashSet<_>>();
1478 for oid in local_have_oids(git_dir, format)? {
1479 if seen.insert(oid) && remote_db.contains(&oid)? {
1480 known_haves.push(oid);
1481 }
1482 }
1483 }
1484 trace2_fetch_info(
1488 known_haves.len(),
1489 decoded_request.wants.len(),
1490 deepen.map(|plan| plan.depth).unwrap_or(0),
1491 deepen.map(|plan| plan.client_shallow.len()).unwrap_or(0),
1492 deepen.is_some_and(|plan| plan.deepen_since),
1493 deepen.map(|plan| plan.deepen_not).unwrap_or(0),
1494 transfer_filter.as_ref(),
1495 );
1496 let mut excluded = match deepen {
1501 Some(plan) => {
1502 let cut: HashSet<ObjectId> = plan.client_shallow.iter().copied().collect();
1503 let mut excluded = sley_odb::collect_reachable_object_ids_with_cut(
1504 &remote_db,
1505 format,
1506 known_haves,
1507 &cut,
1508 )?;
1509 excluded.extend(plan.client_shallow.iter().copied());
1515 excluded
1516 }
1517 None => {
1518 sley_odb::collect_reachable_object_ids_tolerating_promised_missing(
1523 &local_db,
1524 format,
1525 known_haves,
1526 )?
1527 }
1528 };
1529 let mut starts = decoded_request.wants;
1530 let promisor_ref_wants = starts.iter().copied().collect::<HashSet<_>>();
1531 if deepen.is_some() {
1532 let mut missing_starts = Vec::with_capacity(starts.len());
1537 for oid in starts {
1538 if !local_db.contains(&oid)? {
1539 missing_starts.push(oid);
1540 }
1541 }
1542 starts = missing_starts;
1543 }
1544 for want in &starts {
1545 excluded.remove(want);
1546 }
1547 if let Some(plan) = deepen {
1548 excluded.extend(plan.excluded.iter().copied());
1551 starts.extend(plan.extra_wants.iter().copied());
1552 }
1553 if remote_has_promisor && promisor_decision.accepted.is_empty() {
1554 hydrate_reachable_promised_objects(remote_git_dir, &remote_db, format, &starts, &excluded)?;
1555 }
1556 for want in &starts {
1557 if !remote_db.contains(want)?
1558 && (promisor_decision.accepted.is_empty() || !remote_db.is_promised_object(want))
1559 {
1560 return Err(GitError::InvalidObject(format!(
1561 "upload-pack requested missing object {want}"
1562 )));
1563 }
1564 }
1565 let missing_policy = if promisor_decision.accepted.is_empty() {
1566 ReachablePackMissingPolicy::RequireComplete
1567 } else {
1568 ReachablePackMissingPolicy::OmitPromised
1569 };
1570 let destination_db = FileObjectDatabase::from_git_dir(destination_git_dir, format)
1571 .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1572 let thin_base_candidates = if local_upload_pack_uses_bitmaps(remote_git_dir) {
1578 ReachablePackThinBaseCandidates::from_object_ids(&excluded)
1579 } else {
1580 ReachablePackThinBaseCandidates::default()
1581 };
1582 let transfer = build_and_install_reachable_pack_filtered_with_thin_bases(
1583 &remote_db,
1584 &destination_db,
1585 format,
1586 starts,
1587 &excluded,
1588 RawPackInstallOptions {
1589 promisor,
1590 ..Default::default()
1591 },
1592 transfer_filter,
1593 unpack_limit,
1594 missing_policy,
1595 thin_base_candidates,
1596 )?;
1597 if promisor
1598 && record_promisor_refs
1599 && let Some(result) = transfer.install.as_ref()
1600 && let Some(promisor_path) = result.promisor_path.as_ref()
1601 {
1602 append_promisor_ref_lines(promisor_path, remote_git_dir, format, &promisor_ref_wants)?;
1603 }
1604 Ok(LocalFetchPackOutcome {
1605 shallow_info: deepen
1606 .map(|plan| plan.shallow_info.clone())
1607 .unwrap_or_default(),
1608 object_count: transfer.object_count,
1609 compression_count: transfer.compression_count,
1610 delta_count: transfer.delta_count,
1611 })
1612}
1613
1614pub fn hydrate_objects_from_local_promisor_remotes(
1621 git_dir: &Path,
1622 format: ObjectFormat,
1623 objects: &[ObjectId],
1624) -> Result<Vec<ObjectId>> {
1625 let db = FileObjectDatabase::from_git_dir(git_dir, format)
1626 .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1627 let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
1628 let relative_base = if git_dir.file_name().is_some_and(|name| name == ".git") {
1629 git_dir.parent().unwrap_or(git_dir)
1630 } else {
1631 git_dir
1632 };
1633 let mut missing = objects
1634 .iter()
1635 .copied()
1636 .filter_map(|oid| match db.contains(&oid) {
1637 Ok(false) => Some(Ok(oid)),
1638 Ok(true) => None,
1639 Err(err) => Some(Err(err)),
1640 })
1641 .collect::<Result<Vec<_>>>()?;
1642 missing.sort_by_key(ObjectId::to_hex);
1643 missing.dedup();
1644 let requested = missing.clone();
1645
1646 for remote_name in crate::configured_promisor_remote_names(&config) {
1647 let Some(url) = config
1648 .get("remote", Some(&remote_name), "url")
1649 .filter(|url| !url.is_empty())
1650 else {
1651 continue;
1652 };
1653 let Ok(crate::fetch::FetchSource::Local {
1654 git_dir: promisor_git_dir,
1655 ..
1656 }) = crate::fetch_source_for_url(url, relative_base)
1657 else {
1658 continue;
1659 };
1660 crate::promisor::trace_promisor_remote_contact(&remote_name);
1661 for oid in missing.iter().copied() {
1662 let _ = install_fetch_pack_via_local_upload_pack(
1663 git_dir,
1664 &promisor_git_dir,
1665 format,
1666 vec![oid],
1667 None,
1668 true,
1669 false,
1670 Some(sley_odb::PackObjectFilter::BlobNone),
1671 Some(Vec::new()),
1672 false,
1673 None,
1674 );
1675 }
1676 db.refresh_read_cache();
1677 missing.retain(|oid| !db.contains(oid).unwrap_or(false));
1678 if missing.is_empty() {
1679 break;
1680 }
1681 }
1682
1683 Ok(requested
1684 .into_iter()
1685 .filter(|oid| db.contains(oid).unwrap_or(false))
1686 .collect())
1687}
1688
1689pub fn hydrate_reachable_from_local_promisor_remotes(
1696 remote_git_dir: &Path,
1697 format: ObjectFormat,
1698 starts: &[ObjectId],
1699) -> Result<()> {
1700 let remote_db =
1701 FileObjectDatabase::from_git_dir(remote_git_dir, format).with_promisor_remote_present(true);
1702 hydrate_reachable_promised_objects(remote_git_dir, &remote_db, format, starts, &HashSet::new())
1703}
1704
1705fn hydrate_reachable_promised_objects(
1706 remote_git_dir: &Path,
1707 remote_db: &FileObjectDatabase,
1708 format: ObjectFormat,
1709 starts: &[ObjectId],
1710 excluded: &HashSet<ObjectId>,
1711) -> Result<()> {
1712 let config = sley_config::read_repo_config(remote_git_dir, None).unwrap_or_default();
1713 let relative_base = if remote_git_dir
1714 .file_name()
1715 .is_some_and(|name| name == ".git")
1716 {
1717 remote_git_dir.parent().unwrap_or(remote_git_dir)
1718 } else {
1719 remote_git_dir
1720 };
1721
1722 loop {
1723 let reachable = sley_odb::collect_reachable_object_ids_tolerating_missing(
1724 remote_db,
1725 format,
1726 starts.iter().copied(),
1727 )?;
1728 let mut missing = reachable
1729 .into_iter()
1730 .filter(|oid| !excluded.contains(oid))
1731 .filter_map(|oid| match remote_db.contains(&oid) {
1732 Ok(false) => Some(Ok(oid)),
1733 Ok(_) => None,
1734 Err(err) => Some(Err(err)),
1735 })
1736 .collect::<Result<Vec<_>>>()?;
1737 missing.sort_by_key(ObjectId::to_hex);
1738 if missing.is_empty() {
1739 return Ok(());
1740 }
1741
1742 let before = missing.len();
1743 for remote_name in sley_config::remotes::remote_names(&config)
1744 .into_iter()
1745 .filter(|name| {
1746 config
1747 .get_bool("remote", Some(name), "promisor")
1748 .unwrap_or(false)
1749 || config
1750 .get("remote", Some(name), "partialCloneFilter")
1751 .is_some_and(|value| !value.is_empty())
1752 })
1753 {
1754 let Some(url) = config
1755 .get("remote", Some(&remote_name), "url")
1756 .filter(|url| !url.is_empty())
1757 else {
1758 continue;
1759 };
1760 let Ok(crate::fetch::FetchSource::Local {
1761 git_dir: promisor_git_dir,
1762 ..
1763 }) = crate::fetch_source_for_url(url, relative_base)
1764 else {
1765 continue;
1766 };
1767 crate::promisor::trace_promisor_remote_contact(&remote_name);
1768 let remote_before = missing.len();
1769 for oid in missing.iter().copied() {
1770 let _ = install_fetch_pack_via_local_upload_pack(
1771 remote_git_dir,
1772 &promisor_git_dir,
1773 format,
1774 vec![oid],
1775 None,
1776 true,
1777 false,
1778 None,
1779 Some(Vec::new()),
1780 false,
1781 None,
1782 );
1783 }
1784 remote_db.refresh_read_cache();
1785 missing.retain(|oid| !remote_db.contains(oid).unwrap_or(false));
1786 if missing.len() < remote_before
1787 && config.get("remote", Some(&remote_name), "partialCloneFilter")
1788 != Some("blob:none")
1789 {
1790 crate::apply_promisor_remote_field_updates(
1791 remote_git_dir,
1792 &[crate::PromisorRemoteFieldUpdate {
1793 remote_name: remote_name.clone(),
1794 field: crate::PromisorRemoteField::PartialCloneFilter,
1795 previous: config
1796 .get("remote", Some(&remote_name), "partialCloneFilter")
1797 .map(str::to_string),
1798 value: "blob:none".into(),
1799 }],
1800 )?;
1801 }
1802 if missing.is_empty() {
1803 break;
1804 }
1805 }
1806 if missing.len() == before {
1807 return Err(GitError::object_not_found(missing[0]));
1808 }
1809 }
1810}
1811
1812pub(crate) struct LocalProtocolV2FetchRequest<'a> {
1814 pub git_dir: &'a Path,
1815 pub destination_git_dir: &'a Path,
1816 pub remote_git_dir: &'a Path,
1817 pub format: ObjectFormat,
1818 pub wants: Vec<ObjectId>,
1819 pub want_refs: Vec<String>,
1820 pub haves: Option<Vec<ObjectId>>,
1821}
1822
1823#[derive(Debug, Default)]
1825pub(crate) struct LocalProtocolV2FetchOutcome {
1826 pub wanted_refs: Vec<ProtocolV2FetchWantedRef>,
1827 pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
1828}
1829
1830pub(crate) fn install_fetch_pack_via_local_protocol_v2(
1834 input: LocalProtocolV2FetchRequest<'_>,
1835) -> Result<LocalProtocolV2FetchOutcome> {
1836 if input.wants.is_empty() && input.want_refs.is_empty() {
1837 return Ok(LocalProtocolV2FetchOutcome::default());
1838 }
1839
1840 let config = sley_config::read_repo_config(input.remote_git_dir, None).unwrap_or_default();
1841 let handshake = TransportHandshake {
1842 protocol: ProtocolVersion::V2,
1843 capabilities: upload_pack_v2_capabilities(input.format, &config)?,
1844 };
1845 let haves = input
1846 .haves
1847 .map(Ok)
1848 .unwrap_or_else(|| local_have_oids(input.git_dir, input.format))?;
1849 let local_db = FileObjectDatabase::from_git_dir(input.git_dir, input.format);
1850 let destination_db = FileObjectDatabase::from_git_dir(input.destination_git_dir, input.format);
1851 let remote_db = FileObjectDatabase::from_git_dir(input.remote_git_dir, input.format);
1852 let negotiation_rounds = protocol_v2_negotiation_rounds(&local_db, &remote_db, &haves)?;
1853 let fetch = ProtocolV2FetchRequest {
1854 wants: input.wants,
1855 want_refs: input.want_refs,
1856 haves,
1857 thin_pack: true,
1858 include_tag: true,
1859 ofs_delta: true,
1860 done: true,
1861 wait_for_done: true,
1862 ..ProtocolV2FetchRequest::default()
1863 };
1864
1865 let mut request_bytes = Vec::new();
1869 write_protocol_v2_fetch_request(&mut request_bytes, &fetch)?;
1870 let command = read_protocol_v2_command_request(&mut request_bytes.as_slice())?;
1871 let decoded = match classify_protocol_v2_command_request(&handshake, input.format, &command)? {
1872 sley_protocol::ProtocolV2Command::Fetch(fetch) => fetch,
1873 _ => {
1874 return Err(GitError::InvalidFormat(
1875 "local protocol-v2 fetch decoded as a non-fetch command".into(),
1876 ));
1877 }
1878 };
1879
1880 let mut sections = local_fetch_v2_sections(input.remote_git_dir, input.format, &decoded)?;
1881 sections.retain(|section| {
1884 !matches!(section, ProtocolV2FetchResponseSection::Packfile(lines) if lines.is_empty())
1885 });
1886 let mut response_bytes = Vec::new();
1887 write_protocol_v2_fetch_response(&mut response_bytes, §ions)?;
1888
1889 let (header, _) = install_protocol_v2_fetch_response_from_reader(
1890 input.format,
1891 &mut response_bytes.as_slice(),
1892 false,
1893 &destination_db,
1894 None,
1895 )?;
1896 let mut outcome = LocalProtocolV2FetchOutcome::default();
1897 for section in header.sections {
1898 match section {
1899 ProtocolV2FetchResponseSection::WantedRefs(wanted) => {
1900 outcome.wanted_refs.extend(wanted);
1901 }
1902 ProtocolV2FetchResponseSection::ShallowInfo(shallow) => {
1903 outcome.shallow_info.extend(shallow);
1904 }
1905 _ => {}
1906 }
1907 }
1908 sley_core::trace2::data("negotiation_v2", "total_rounds", negotiation_rounds);
1909 Ok(outcome)
1910}
1911
1912fn protocol_v2_negotiation_rounds(
1913 local_db: &FileObjectDatabase,
1914 remote_db: &FileObjectDatabase,
1915 haves: &[ObjectId],
1916) -> Result<usize> {
1917 let mut missing_commits = 0usize;
1918 for oid in haves {
1919 if !remote_db.contains(oid)? && local_db.read_object(oid)?.object_type == ObjectType::Commit
1920 {
1921 missing_commits += 1;
1922 }
1923 }
1924 let mut rounds = 1usize;
1928 let mut batch = 16usize;
1929 while missing_commits > batch {
1930 missing_commits -= batch;
1931 batch = batch.saturating_mul(2);
1932 rounds += 1;
1933 }
1934 Ok(rounds)
1935}
1936
1937fn local_upload_pack_client_wants_v2(git_dir: &Path) -> bool {
1938 sley_config::read_repo_config(git_dir, None)
1939 .ok()
1940 .and_then(|config| config.get("protocol", None, "version").map(str::to_string))
1941 .as_deref()
1942 == Some("2")
1943}
1944
1945fn local_upload_pack_uses_bitmaps(git_dir: &Path) -> bool {
1946 let configured = sley_config::read_repo_config(git_dir, None)
1947 .ok()
1948 .and_then(|config| config.get_bool("pack", None, "usebitmaps"));
1949 configured.unwrap_or_else(|| {
1950 fs::read_dir(git_dir.join("objects/pack"))
1951 .ok()
1952 .into_iter()
1953 .flatten()
1954 .filter_map(std::result::Result::ok)
1955 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "bitmap"))
1956 })
1957}
1958
1959fn repo_has_promisor_remote(git_dir: &Path) -> bool {
1960 let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
1961 return false;
1962 };
1963 if config
1964 .get("extensions", None, "partialclone")
1965 .is_some_and(|value| !value.is_empty())
1966 {
1967 return true;
1968 }
1969 config.sections.iter().any(|section| {
1970 section.name.eq_ignore_ascii_case("remote")
1971 && section
1972 .subsection
1973 .as_deref()
1974 .is_some_and(|name| config.get_bool("remote", Some(name), "promisor") == Some(true))
1975 })
1976}
1977
1978fn trace_local_upload_pack_v2_capabilities(remote_git_dir: &Path, format: ObjectFormat) {
1979 sley_protocol::set_packet_trace_identity("fetch");
1980 let config = sley_config::read_repo_config(remote_git_dir, None).unwrap_or_default();
1981 sley_protocol::trace_packet_read_payload(b"version 2\n");
1982 sley_protocol::trace_packet_read_payload(
1983 format!("agent={UPSTREAM_GIT_COMPAT_VERSION}\n").as_bytes(),
1984 );
1985 sley_protocol::trace_packet_read_payload(b"ls-refs=unborn\n");
1986 let mut fetch = "fetch=shallow wait-for-done".to_string();
1987 if config
1988 .get_bool("uploadpack", None, "allowfilter")
1989 .unwrap_or(false)
1990 {
1991 fetch.push_str(" filter");
1992 }
1993 if config
1994 .get_bool("uploadpack", None, "allowrefinwant")
1995 .unwrap_or(false)
1996 {
1997 fetch.push_str(" ref-in-want");
1998 }
1999 fetch.push('\n');
2000 sley_protocol::trace_packet_read_payload(fetch.as_bytes());
2001 sley_protocol::trace_packet_read_payload(
2002 format!("object-format={}\n", format.name()).as_bytes(),
2003 );
2004 sley_protocol::trace_packet_read_payload(b"0000");
2005}
2006
2007pub(crate) fn upload_pack_filter_protocol_spec(
2008 filter: &sley_odb::PackObjectFilter,
2009) -> Option<String> {
2010 match filter {
2011 sley_odb::PackObjectFilter::BlobNone => Some("blob:none".to_string()),
2012 sley_odb::PackObjectFilter::BlobLimit(limit) => Some(format!("blob:limit={limit}")),
2013 sley_odb::PackObjectFilter::TreeDepth(depth) => Some(format!("tree:{depth}")),
2014 sley_odb::PackObjectFilter::SparsePathSet(_) => None,
2015 }
2016}
2017
2018fn append_promisor_ref_lines(
2019 promisor_path: &Path,
2020 remote_git_dir: &Path,
2021 format: ObjectFormat,
2022 wanted: &HashSet<ObjectId>,
2023) -> Result<()> {
2024 if wanted.is_empty() {
2025 return Ok(());
2026 }
2027 let store = FileRefStore::new(remote_git_dir, format);
2028 let mut lines = Vec::new();
2029 if let Some(head_target) = store.read_ref("HEAD")? {
2030 let head = Ref {
2031 name: "HEAD".into(),
2032 target: head_target,
2033 };
2034 if let Some((oid, _)) = resolve_for_each_ref_target(&store, &head)?
2035 && wanted.contains(&oid)
2036 {
2037 lines.push(format!("{oid} HEAD\n"));
2038 }
2039 }
2040 for reference in store.list_refs()? {
2041 let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
2042 continue;
2043 };
2044 if wanted.contains(&oid) {
2045 lines.push(format!("{oid} {}\n", reference.name));
2046 }
2047 }
2048 if lines.is_empty() {
2049 return Ok(());
2050 }
2051 lines.sort();
2052 let mut file = fs::OpenOptions::new().append(true).open(promisor_path)?;
2053 use std::io::Write as _;
2054 for line in lines {
2055 file.write_all(line.as_bytes())?;
2056 }
2057 Ok(())
2058}
2059
2060fn trace2_fetch_info(
2064 haves: usize,
2065 wants: usize,
2066 depth: u32,
2067 shallows: usize,
2068 deepen_since: bool,
2069 deepen_not: usize,
2070 filter: Option<&sley_odb::PackObjectFilter>,
2071) {
2072 let Some(path) = std::env::var_os("GIT_TRACE2_EVENT") else {
2073 return;
2074 };
2075 if path.is_empty() {
2076 return;
2077 }
2078 let filter_json = match filter {
2079 Some(sley_odb::PackObjectFilter::BlobNone) => "\"blob:none\"".to_string(),
2080 Some(sley_odb::PackObjectFilter::BlobLimit(_)) => "\"blob:limit\"".to_string(),
2083 Some(sley_odb::PackObjectFilter::TreeDepth(_)) => "\"tree\"".to_string(),
2084 Some(sley_odb::PackObjectFilter::SparsePathSet(_)) => "\"sparse:oid\"".to_string(),
2085 None => "null".to_string(),
2086 };
2087 let line = format!(
2088 "{{\"event\":\"data_json\",\"thread\":\"main\",\"category\":\"upload-pack\",\"key\":\"fetch-info\",\"value\":{{\"haves\":{haves},\"wants\":{wants},\"want-refs\":0,\"depth\":{depth},\"shallows\":{shallows},\"deepen-since\":{deepen_since},\"deepen-not\":{deepen_not},\"deepen-relative\":false,\"filter\":{filter_json}}}}}\n"
2089 );
2090 if let Ok(mut file) = std::fs::OpenOptions::new()
2091 .create(true)
2092 .append(true)
2093 .open(&path)
2094 {
2095 use std::io::Write as _;
2096 let _ = file.write_all(line.as_bytes());
2097 }
2098}
2099
2100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2111enum LsRefsUnbornConfig {
2112 Ignore,
2113 Allow,
2114 Advertise,
2115}
2116
2117fn lsrefs_unborn_config(config: &GitConfig) -> LsRefsUnbornConfig {
2118 match config.get("lsrefs", None, "unborn") {
2119 Some("ignore") => LsRefsUnbornConfig::Ignore,
2120 Some("allow") => LsRefsUnbornConfig::Allow,
2121 Some("advertise") | None => LsRefsUnbornConfig::Advertise,
2122 Some(_) => LsRefsUnbornConfig::Advertise,
2123 }
2124}
2125
2126fn upload_pack_blob_packfile_uri_configured(config: &GitConfig) -> bool {
2127 config
2128 .get_all("uploadpack", None, "blobpackfileuri")
2129 .into_iter()
2130 .any(|value| value.is_some_and(|value| !value.is_empty()))
2131}
2132
2133fn upload_pack_v2_capabilities(
2137 format: ObjectFormat,
2138 config: &GitConfig,
2139) -> Result<Vec<Capability>> {
2140 let mut capabilities = vec![
2141 Capability {
2142 name: "agent".into(),
2143 value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
2144 },
2145 encode_protocol_v2_ls_refs_capability(&ProtocolV2LsRefsFeatures {
2146 unborn: lsrefs_unborn_config(config) == LsRefsUnbornConfig::Advertise,
2147 unknown: Vec::new(),
2148 })?,
2149 encode_protocol_v2_fetch_capability(&ProtocolV2FetchFeatures {
2150 shallow: true,
2151 wait_for_done: true,
2152 filter: config
2153 .get_bool("uploadpack", None, "allowfilter")
2154 .unwrap_or(false),
2155 ref_in_want: config
2156 .get_bool("uploadpack", None, "allowrefinwant")
2157 .unwrap_or(false),
2158 packfile_uris: upload_pack_blob_packfile_uri_configured(config),
2159 ..ProtocolV2FetchFeatures::default()
2160 })?,
2161 Capability {
2162 name: "server-option".into(),
2163 value: None,
2164 },
2165 Capability {
2166 name: "object-format".into(),
2167 value: Some(format.name().into()),
2168 },
2169 ];
2170 if config
2171 .get_bool("transfer", None, "advertisesid")
2172 .unwrap_or(false)
2173 {
2174 capabilities.push(Capability {
2175 name: "session-id".into(),
2176 value: Some("sley".into()),
2177 });
2178 }
2179 if let Some(capability) = crate::promisor_remote_server_capability(config)? {
2180 capabilities.push(capability);
2181 }
2182 if config
2187 .get_bool("uploadpack", None, "advertisebundleuris")
2188 .unwrap_or(false)
2189 {
2190 capabilities.push(Capability {
2191 name: "bundle-uri".into(),
2192 value: None,
2193 });
2194 }
2195 Ok(capabilities)
2196}
2197
2198fn head_symref_target(store: &FileRefStore) -> Result<Option<String>> {
2202 match store.read_ref("HEAD")? {
2203 Some(RefTarget::Symbolic(name)) => Ok(Some(name)),
2204 _ => Ok(None),
2205 }
2206}
2207
2208fn local_ls_refs_v2_records(
2212 git_dir: &Path,
2213 format: ObjectFormat,
2214 request: &ProtocolV2LsRefsRequest,
2215 config: &GitConfig,
2216) -> Result<Vec<ProtocolV2LsRefsRecord>> {
2217 let store = FileRefStore::new(git_dir, format);
2218 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2219 let head_symref = head_symref_target(&store)?;
2220
2221 let mut entries: Vec<(String, ObjectId, Option<String>)> = Vec::new();
2224 if let Some(target) = store.read_ref("HEAD")? {
2225 let reference = Ref {
2226 name: "HEAD".to_string(),
2227 target,
2228 };
2229 if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
2230 entries.push(("HEAD".to_string(), oid, head_symref.clone()));
2231 } else if request.unborn && lsrefs_unborn_config(config) != LsRefsUnbornConfig::Ignore {
2232 entries.push((
2235 "HEAD".to_string(),
2236 ObjectId::null(format),
2237 head_symref.clone(),
2238 ));
2239 }
2240 }
2241 for reference in store.list_refs()? {
2242 let name = reference.name.clone();
2243 let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
2244 continue;
2245 };
2246 entries.push((name, oid, symref));
2247 }
2248
2249 let matches_prefix = |name: &str| -> bool {
2250 if request.ref_prefixes.is_empty() {
2251 return true;
2252 }
2253 request
2254 .ref_prefixes
2255 .iter()
2256 .any(|prefix| name.starts_with(prefix.as_str()))
2257 };
2258
2259 let mut records = Vec::new();
2260 for (name, oid, symref) in entries {
2261 if !matches_prefix(&name) {
2262 continue;
2263 }
2264 if name == "HEAD" && oid == ObjectId::null(format) {
2266 records.push(ProtocolV2LsRefsRecord::Unborn {
2267 name,
2268 symref_target: if request.symrefs { symref } else { None },
2269 attributes: Vec::new(),
2270 });
2271 continue;
2272 }
2273 let peeled = if request.peel {
2274 let object = db.read_object(&oid)?;
2275 if object.object_type == ObjectType::Tag {
2276 Some(sley_rev::peel_tags(&db, format, &oid)?)
2277 } else {
2278 None
2279 }
2280 } else {
2281 None
2282 };
2283 let symref_target = if request.symrefs { symref } else { None };
2284 records.push(ProtocolV2LsRefsRecord::Ref(ProtocolV2LsRefsRef {
2285 oid,
2286 name,
2287 peeled,
2288 symref_target,
2289 attributes: Vec::new(),
2290 }));
2291 }
2292 Ok(records)
2293}
2294
2295fn packfile_section_lines(pack: &[u8]) -> Vec<Vec<u8>> {
2301 let chunk = PKT_LINE_MAX_PAYLOAD_LEN - 1;
2302 let mut lines = Vec::new();
2303 for slice in pack.chunks(chunk) {
2304 let mut payload = Vec::with_capacity(slice.len() + 1);
2305 payload.push(1u8); payload.extend_from_slice(slice);
2307 lines.push(payload);
2308 }
2309 lines
2310}
2311
2312fn local_fetch_v2_sections(
2318 git_dir: &Path,
2319 format: ObjectFormat,
2320 request: &ProtocolV2FetchRequest,
2321) -> Result<Vec<ProtocolV2FetchResponseSection>> {
2322 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2323
2324 let mut sections = Vec::new();
2325
2326 if !request.done {
2332 let mut acks: Vec<ProtocolV2FetchAcknowledgment> = Vec::new();
2333 for have in &request.haves {
2334 if db.contains(have)? {
2335 acks.push(ProtocolV2FetchAcknowledgment::Ack(*have));
2336 }
2337 }
2338 if acks.is_empty() {
2339 acks.push(ProtocolV2FetchAcknowledgment::Nak);
2340 }
2341 let ready = acks
2342 .iter()
2343 .any(|ack| matches!(ack, ProtocolV2FetchAcknowledgment::Ack(_)));
2344 if ready {
2345 acks.push(ProtocolV2FetchAcknowledgment::Ready);
2346 }
2347 sections.push(ProtocolV2FetchResponseSection::Acknowledgments(acks));
2348 if !ready || request.wait_for_done {
2353 return Ok(sections);
2354 }
2355 }
2356
2357 if !request.want_refs.is_empty() {
2359 let store = FileRefStore::new(git_dir, format);
2360 let mut wanted = Vec::new();
2361 for name in &request.want_refs {
2362 let reference = Ref {
2363 name: name.clone(),
2364 target: store
2365 .read_ref(name)?
2366 .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?,
2367 };
2368 let (oid, _) = resolve_for_each_ref_target(&store, &reference)?
2369 .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?;
2370 wanted.push(sley_protocol::ProtocolV2FetchWantedRef {
2371 oid,
2372 name: name.clone(),
2373 });
2374 }
2375 sections.push(ProtocolV2FetchResponseSection::WantedRefs(wanted));
2376 }
2377
2378 let mut wants: Vec<ObjectId> = request.wants.clone();
2380 if !request.want_refs.is_empty()
2381 && let Some(ProtocolV2FetchResponseSection::WantedRefs(wanted)) = sections
2382 .iter()
2383 .find(|s| matches!(s, ProtocolV2FetchResponseSection::WantedRefs(_)))
2384 {
2385 for w in wanted {
2386 wants.push(w.oid);
2387 }
2388 }
2389 if wants.is_empty() {
2395 return Ok(sections);
2396 }
2397
2398 let deepen_plan = if request.deepen_since.is_some() || !request.deepen_not.is_empty() {
2403 let advertisements = local_fetch_advertisements(git_dir, format)?;
2404 let mut deepen_not = Vec::with_capacity(request.deepen_not.len());
2405 for name in &request.deepen_not {
2406 let advertisement = advertisements
2407 .iter()
2408 .find(|advertisement| {
2409 advertisement.name == *name
2410 || advertisement.name == format!("refs/tags/{name}")
2411 || advertisement.name == format!("refs/heads/{name}")
2412 || advertisement.name == format!("refs/{name}")
2413 })
2414 .ok_or_else(|| {
2415 GitError::Command(format!("git upload-pack: deepen-not is not a ref: {name}"))
2416 })?;
2417 deepen_not.push(advertisement.oid);
2418 }
2419 let since = request
2420 .deepen_since
2421 .map(|value| {
2422 i64::try_from(value).map_err(|_| {
2423 GitError::InvalidFormat(format!("invalid deepen-since timestamp {value}"))
2424 })
2425 })
2426 .transpose()?;
2427 Some(compute_local_deepen_by_rev_list(
2428 &db,
2429 format,
2430 &wants,
2431 request.shallow.clone(),
2432 since,
2433 &deepen_not,
2434 )?)
2435 } else if let Some(depth) = request.deepen {
2436 Some(compute_local_deepen(
2437 &db,
2438 format,
2439 &wants,
2440 request.shallow.clone(),
2441 depth,
2442 request.deepen_relative,
2443 )?)
2444 } else {
2445 None
2446 };
2447
2448 let request_is_deepening = request.deepen.is_some()
2456 || request.deepen_since.is_some()
2457 || !request.deepen_not.is_empty();
2458 if !request_is_deepening {
2459 let server_shallow = crate::shallow::read_shallow(git_dir, format)?;
2460 if !server_shallow.is_empty() {
2461 let reachable = collect_reachable_object_ids(&db, format, wants.clone())?;
2462 let shallow_lines: Vec<ProtocolV2FetchShallowInfo> = server_shallow
2463 .iter()
2464 .filter(|oid| reachable.contains(*oid))
2465 .map(|oid| ProtocolV2FetchShallowInfo::Shallow(*oid))
2466 .collect();
2467 if !shallow_lines.is_empty() {
2468 sections.push(ProtocolV2FetchResponseSection::ShallowInfo(shallow_lines));
2469 }
2470 }
2471 } else if let Some(plan) = deepen_plan.as_ref()
2472 && !plan.shallow_info.is_empty()
2473 {
2474 sections.push(ProtocolV2FetchResponseSection::ShallowInfo(
2475 plan.shallow_info.clone(),
2476 ));
2477 }
2478
2479 let mut known_haves: Vec<ObjectId> = Vec::new();
2481 for have in &request.haves {
2482 if db.contains(have)? {
2483 known_haves.push(*have);
2484 }
2485 }
2486 let mut excluded = collect_reachable_object_ids(&db, format, known_haves)?;
2487 if let Some(plan) = deepen_plan {
2488 wants.extend(plan.extra_wants);
2489 excluded.extend(plan.excluded);
2490 }
2491 let filter = request
2496 .filter
2497 .as_deref()
2498 .and_then(crate::pack_filter_from_spec);
2499 let pack = if filter.is_some() {
2500 build_reachable_pack_filtered(&db, format, wants, &excluded, filter)?
2501 } else {
2502 build_reachable_pack(&db, format, wants, &excluded)?
2503 }
2504 .map(|pack| pack.pack)
2505 .unwrap_or_default();
2506
2507 sections.push(ProtocolV2FetchResponseSection::Packfile(
2508 packfile_section_lines(&pack),
2509 ));
2510 Ok(sections)
2511}
2512
2513fn write_bundle_uri_command_response(
2524 config: &GitConfig,
2525 writer: &mut impl std::io::Write,
2526) -> Result<()> {
2527 for section in &config.sections {
2528 if !section.name.eq_ignore_ascii_case("bundle") {
2529 continue;
2530 }
2531 for entry in §ion.entries {
2532 let Some(value) = entry.value.as_deref() else {
2533 continue;
2534 };
2535 let key = match §ion.subsection {
2536 Some(subsection) => {
2537 format!("bundle.{subsection}.{}", entry.key.to_ascii_lowercase())
2538 }
2539 None => format!("bundle.{}", entry.key.to_ascii_lowercase()),
2540 };
2541 write_pkt_line_payload(writer, format!("{key}={value}").as_bytes())?;
2542 }
2543 }
2544 write_pkt_line_frame(writer, &PktLineFrame::Flush)?;
2545 Ok(())
2546}
2547
2548pub fn serve_upload_pack_v2(
2549 git_dir: &Path,
2550 format: ObjectFormat,
2551 reader: &mut impl std::io::Read,
2552 writer: &mut impl std::io::Write,
2553) -> Result<()> {
2554 let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
2555 serve_upload_pack_v2_with_config(git_dir, format, &config, reader, writer)
2556}
2557
2558pub fn serve_upload_pack_v2_with_config(
2559 git_dir: &Path,
2560 format: ObjectFormat,
2561 config: &GitConfig,
2562 reader: &mut impl std::io::Read,
2563 writer: &mut impl std::io::Write,
2564) -> Result<()> {
2565 serve_upload_pack_v2_inner(git_dir, format, config, reader, writer, true)
2566}
2567
2568pub fn serve_upload_pack_v2_stateless_with_config(
2573 git_dir: &Path,
2574 format: ObjectFormat,
2575 config: &GitConfig,
2576 reader: &mut impl std::io::Read,
2577 writer: &mut impl std::io::Write,
2578) -> Result<()> {
2579 serve_upload_pack_v2_inner(git_dir, format, config, reader, writer, false)
2580}
2581
2582fn serve_upload_pack_v2_inner(
2583 git_dir: &Path,
2584 format: ObjectFormat,
2585 config: &GitConfig,
2586 reader: &mut impl std::io::Read,
2587 writer: &mut impl std::io::Write,
2588 advertise: bool,
2589) -> Result<()> {
2590 let handshake = TransportHandshake {
2591 protocol: ProtocolVersion::V2,
2592 capabilities: upload_pack_v2_capabilities(format, config)?,
2593 };
2594 if advertise {
2595 write_protocol_v2_advertisement(writer, &handshake)?;
2596 writer.flush()?;
2597 }
2598
2599 loop {
2604 let request = match read_protocol_v2_command_request(reader) {
2605 Ok(request) => request,
2606 Err(GitError::InvalidFormat(message))
2607 if message == "pkt-line stream ended before control packet"
2608 || message == "protocol v2 command request must start with a command line" =>
2609 {
2610 break;
2611 }
2612 Err(err) => return Err(err),
2613 };
2614 if request.command == "bundle-uri" {
2618 write_bundle_uri_command_response(config, writer)?;
2619 writer.flush()?;
2620 continue;
2621 }
2622 match classify_protocol_v2_command_request(&handshake, format, &request)? {
2623 sley_protocol::ProtocolV2Command::LsRefs(ls_refs) => {
2624 let records = local_ls_refs_v2_records(git_dir, format, &ls_refs, config)?;
2625 write_protocol_v2_ls_refs_response(writer, &records)?;
2626 writer.flush()?;
2627 }
2628 sley_protocol::ProtocolV2Command::Fetch(fetch) => {
2629 let sections = local_fetch_v2_sections(git_dir, format, &fetch)?;
2630 if !sections.is_empty() {
2631 write_protocol_v2_fetch_response(writer, §ions)?;
2632 writer.flush()?;
2633 }
2634 }
2635 sley_protocol::ProtocolV2Command::ObjectInfo(_)
2636 | sley_protocol::ProtocolV2Command::Unknown(_) => {
2637 return Err(GitError::InvalidFormat(format!(
2638 "unsupported protocol v2 command {}",
2639 request.command
2640 )));
2641 }
2642 }
2643 }
2644 Ok(())
2645}
2646
2647#[cfg(test)]
2648mod tests {
2649 use super::*;
2650 use sley_object::{BString, EncodedObject, Tree, TreeEntry};
2651 use sley_odb::ObjectWriter;
2652
2653 #[test]
2654 fn filtered_clone_and_exact_checkout_hydration_install_separate_promisor_packs() {
2655 for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
2656 let root = unique_local_test_dir("split-partial-clone");
2657 let remote_git = root.join("remote.git");
2658 let client_git = root.join("client.git");
2659 for git_dir in [&remote_git, &client_git] {
2660 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2661 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2662 .expect("test repository HEAD");
2663 }
2664 let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
2665 let blob = write_test_object(
2666 &remote_db,
2667 &EncodedObject::new(ObjectType::Blob, b"checkout payload\n".to_vec()),
2668 );
2669 let tree = write_test_object(&remote_db, &test_tree(&[(0o100644, b"file", blob)]));
2670 let commit = write_test_object(&remote_db, &test_commit(tree, &[], b"tip\n"));
2671
2672 install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
2673 &client_git,
2674 &client_git,
2675 &remote_git,
2676 format,
2677 vec![commit],
2678 None,
2679 true,
2680 false,
2681 Some(sley_odb::PackObjectFilter::BlobNone),
2682 None,
2683 false,
2684 Some(1),
2685 &crate::PromisorRemoteDecision::default(),
2686 LocalFetchPackRequestMode::Traversal,
2687 )
2688 .expect("install filtered refs pack");
2689 let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
2690 assert!(client_db.contains(&commit).expect("read filtered commit"));
2691 assert!(client_db.contains(&tree).expect("read filtered tree"));
2692 assert!(!client_db.contains(&blob).expect("check filtered blob"));
2693
2694 install_fetch_pack_via_local_upload_pack(
2695 &client_git,
2696 &remote_git,
2697 format,
2698 vec![blob],
2699 None,
2700 true,
2701 false,
2702 None,
2703 None,
2704 false,
2705 Some(1),
2706 )
2707 .expect("install exact checkout blob pack");
2708 assert!(client_db.contains(&blob).expect("read hydrated blob"));
2709 let promisor_packs = fs::read_dir(client_git.join("objects/pack"))
2710 .expect("read client packs")
2711 .filter_map(std::result::Result::ok)
2712 .filter(|entry| {
2713 entry
2714 .path()
2715 .extension()
2716 .is_some_and(|ext| ext == "promisor")
2717 })
2718 .count();
2719 assert_eq!(promisor_packs, 2);
2720 fs::remove_dir_all(root).expect("remove test repositories");
2721 }
2722 }
2723
2724 #[test]
2725 fn multi_round_negotiation_resets_in_vain_on_unrelated_ack() {
2726 let git_dir = unique_local_test_dir("negotiation-in-vain-reset");
2727 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2728 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
2729 let format = ObjectFormat::Sha1;
2730 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2731 let tree_oid = write_test_object(&db, &test_tree(&[]));
2732 let side_common = write_test_object(&db, &test_commit(tree_oid, &[], b"side\n"));
2733 let main_common = write_test_object(&db, &test_commit(tree_oid, &[], b"main\n"));
2734 let want = write_test_object(&db, &test_commit(tree_oid, &[main_common], b"want\n"));
2735
2736 let missing = |value: usize| {
2737 ObjectId::from_hex(format, &format!("{value:040x}")).expect("synthetic negotiation oid")
2738 };
2739 let mut haves = (1..=255).map(missing).collect::<Vec<_>>();
2740 haves.push(side_common);
2741 haves.extend((256..=510).map(missing));
2742 haves.push(main_common);
2743
2744 let outcome = negotiate_local_upload_pack(&db, format, &[want], haves)
2745 .expect("multi-round negotiation");
2746 assert_eq!(outcome.total_rounds, 6);
2747 assert_eq!(outcome.common_haves, vec![side_common, main_common]);
2748
2749 fs::remove_dir_all(git_dir).expect("remove test repository");
2750 }
2751
2752 #[test]
2753 fn multi_round_negotiation_does_not_give_up_before_first_ack() {
2754 let git_dir = unique_local_test_dir("negotiation-first-ack");
2755 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2756 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
2757 let format = ObjectFormat::Sha1;
2758 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2759 let tree_oid = write_test_object(&db, &test_tree(&[]));
2760 let common = write_test_object(&db, &test_commit(tree_oid, &[], b"common\n"));
2761 let want = write_test_object(&db, &test_commit(tree_oid, &[common], b"want\n"));
2762 let mut haves = (1..=496)
2763 .map(|value| {
2764 ObjectId::from_hex(format, &format!("{value:040x}"))
2765 .expect("synthetic negotiation oid")
2766 })
2767 .collect::<Vec<_>>();
2768 haves.push(common);
2769
2770 let outcome = negotiate_local_upload_pack(&db, format, &[want], haves)
2771 .expect("multi-round negotiation");
2772 assert_eq!(outcome.total_rounds, 6);
2773 assert_eq!(outcome.common_haves, vec![common]);
2774
2775 fs::remove_dir_all(git_dir).expect("remove test repository");
2776 }
2777
2778 #[test]
2779 fn negotiation_haves_stop_behind_remote_advertised_commit_tips() {
2780 let root = unique_local_test_dir("negotiation-advertised-tip");
2781 let remote_git = root.join("remote.git");
2782 let client_git = root.join("client.git");
2783 for git_dir in [&remote_git, &client_git] {
2784 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2785 fs::create_dir_all(git_dir.join("refs/heads")).expect("test repository refs");
2786 fs::create_dir_all(git_dir.join("refs/tags")).expect("test repository tags");
2787 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2788 .expect("test repository HEAD");
2789 }
2790
2791 let format = ObjectFormat::Sha1;
2792 let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
2793 let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
2794 let tree = test_tree(&[]);
2795 let tree_oid = write_test_object(&client_db, &tree);
2796 write_test_object(&remote_db, &tree);
2797 let root_commit = test_commit(tree_oid, &[], b"root\n");
2798 let root_oid = write_test_object(&client_db, &root_commit);
2799 write_test_object(&remote_db, &root_commit);
2800 let common_commit = test_commit(tree_oid, &[root_oid], b"common\n");
2801 let common_oid = write_test_object(&client_db, &common_commit);
2802 write_test_object(&remote_db, &common_commit);
2803 let client_commit = test_commit(tree_oid, &[common_oid], b"client\n");
2804 let client_oid = write_test_object(&client_db, &client_commit);
2805 let remote_commit = test_commit(tree_oid, &[common_oid], b"remote\n");
2806 let remote_oid = write_test_object(&remote_db, &remote_commit);
2807 fs::write(
2808 client_git.join("refs/heads/main"),
2809 format!("{client_oid}\n"),
2810 )
2811 .expect("client main ref");
2812 fs::write(
2813 client_git.join("refs/tags/common"),
2814 format!("{common_oid}\n"),
2815 )
2816 .expect("client common tag");
2817 fs::write(
2818 remote_git.join("refs/heads/main"),
2819 format!("{remote_oid}\n"),
2820 )
2821 .expect("remote main ref");
2822 fs::write(
2823 remote_git.join("refs/tags/common"),
2824 format!("{common_oid}\n"),
2825 )
2826 .expect("remote common tag");
2827
2828 let haves =
2829 local_negotiation_have_oids_pruned_by_remote_tips(&client_git, &remote_git, format)
2830 .expect("plan negotiation haves");
2831 assert!(haves.contains(&client_oid));
2832 assert!(haves.contains(&common_oid));
2833 assert!(!haves.contains(&root_oid));
2834
2835 fs::remove_dir_all(root).expect("remove test repositories");
2836 }
2837
2838 #[test]
2839 fn negotiation_ignores_advertised_tags_with_promised_missing_targets() {
2840 for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
2841 let root = unique_local_test_dir("negotiation-promised-tag-target");
2842 let source_git = root.join("source.git");
2843 let remote_git = root.join("remote.git");
2844 let client_git = root.join("client.git");
2845 for git_dir in [&source_git, &remote_git, &client_git] {
2846 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2847 fs::create_dir_all(git_dir.join("refs/heads")).expect("test repository heads");
2848 fs::create_dir_all(git_dir.join("refs/tags")).expect("test repository tags");
2849 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2850 .expect("test repository HEAD");
2851 }
2852
2853 let source_db = FileObjectDatabase::from_git_dir(&source_git, format);
2854 let promised_blob = write_test_object(
2855 &source_db,
2856 &EncodedObject::new(ObjectType::Blob, b"promised tag target\n".to_vec()),
2857 );
2858 let tree = write_test_object(&source_db, &test_tree(&[]));
2859 let commit = write_test_object(&source_db, &test_commit(tree, &[], b"main\n"));
2860 let tag = write_test_object(
2861 &source_db,
2862 &EncodedObject::new(
2863 ObjectType::Tag,
2864 Tag {
2865 object: promised_blob,
2866 object_type: ObjectType::Blob,
2867 name: b"promised-blob".to_vec(),
2868 tagger: None,
2869 message: b"promised blob tag\n".to_vec(),
2870 raw_body: None,
2871 }
2872 .write(),
2873 ),
2874 );
2875
2876 sley_odb::build_and_install_reachable_pack_filtered(
2877 &source_db,
2878 &FileObjectDatabase::from_git_dir(&remote_git, format),
2879 format,
2880 [commit, tag],
2881 &HashSet::new(),
2882 RawPackInstallOptions {
2883 promisor: true,
2884 ..Default::default()
2885 },
2886 Some(sley_odb::PackObjectFilter::BlobNone),
2887 None,
2888 )
2889 .expect("build partial remote")
2890 .expect("install partial remote pack");
2891 sley_odb::build_and_install_reachable_pack_filtered(
2892 &source_db,
2893 &FileObjectDatabase::from_git_dir(&client_git, format),
2894 format,
2895 [tag],
2896 &HashSet::new(),
2897 RawPackInstallOptions {
2898 promisor: true,
2899 ..Default::default()
2900 },
2901 Some(sley_odb::PackObjectFilter::BlobNone),
2902 None,
2903 )
2904 .expect("build partial client")
2905 .expect("install partial client pack");
2906 fs::write(
2907 remote_git.join("config"),
2908 b"[extensions]\n\tpartialClone = origin\n[remote \"origin\"]\n\tpromisor = true\n",
2909 )
2910 .expect("partial remote config");
2911 fs::write(
2912 client_git.join("config"),
2913 b"[extensions]\n\tpartialClone = origin\n[remote \"origin\"]\n\tpromisor = true\n",
2914 )
2915 .expect("partial client config");
2916 fs::write(remote_git.join("refs/heads/main"), format!("{commit}\n"))
2917 .expect("remote main ref");
2918 fs::write(
2919 remote_git.join("refs/tags/promised-blob"),
2920 format!("{tag}\n"),
2921 )
2922 .expect("remote tag ref");
2923 fs::write(
2924 client_git.join("refs/tags/promised-blob"),
2925 format!("{tag}\n"),
2926 )
2927 .expect("client tag ref");
2928
2929 let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format)
2930 .with_promisor_remote_present(true);
2931 assert!(remote_db.contains(&commit).expect("remote commit lookup"));
2932 assert!(remote_db.contains(&tag).expect("remote tag lookup"));
2933 assert!(
2934 !remote_db
2935 .contains(&promised_blob)
2936 .expect("remote blob lookup")
2937 );
2938 assert!(remote_db.is_promised_object(&promised_blob));
2939 let client_db = FileObjectDatabase::from_git_dir(&client_git, format)
2940 .with_promisor_remote_present(true);
2941 assert!(client_db.contains(&tag).expect("client tag lookup"));
2942 assert!(
2943 !client_db
2944 .contains(&promised_blob)
2945 .expect("client blob lookup")
2946 );
2947 assert!(client_db.is_promised_object(&promised_blob));
2948
2949 install_fetch_pack_via_local_upload_pack(
2950 &client_git,
2951 &remote_git,
2952 format,
2953 vec![commit],
2954 None,
2955 false,
2956 false,
2957 None,
2958 None,
2959 false,
2960 None,
2961 )
2962 .expect("unrelated branch fetch ignores promised tag target");
2963 assert!(
2964 FileObjectDatabase::from_git_dir(&client_git, format)
2965 .contains(&commit)
2966 .expect("client commit lookup")
2967 );
2968
2969 fs::remove_dir_all(root).expect("remove test repositories");
2970 }
2971 }
2972
2973 #[test]
2974 fn receive_pack_advertises_no_thin_until_server_fixes_thin_packs() {
2975 let features = receive_pack_features(ObjectFormat::Sha1);
2976 assert!(features.no_thin);
2977
2978 let capabilities =
2979 encode_receive_pack_features(&features).expect("test operation should succeed");
2980 assert!(
2981 capabilities
2982 .iter()
2983 .any(|capability| capability.name == "no-thin")
2984 );
2985 }
2986
2987 #[test]
2988 fn protocol_v2_local_fetch_resolves_want_ref_and_installs_tip() {
2989 let root = unique_local_test_dir("protocol-v2-want-ref");
2990 let remote_git = root.join("remote.git");
2991 let client_git = root.join("client.git");
2992 for git_dir in [&remote_git, &client_git] {
2993 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2994 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2995 .expect("test repository HEAD");
2996 }
2997 fs::create_dir_all(remote_git.join("refs/heads")).expect("test repository refs");
2998 fs::write(
2999 remote_git.join("config"),
3000 b"[uploadpack]\n\tallowRefInWant = true\n",
3001 )
3002 .expect("test repository config");
3003
3004 let format = ObjectFormat::Sha1;
3005 let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
3006 let blob_oid = write_test_object(
3007 &remote_db,
3008 &EncodedObject::new(ObjectType::Blob, b"wanted\n".to_vec()),
3009 );
3010 let tree_oid = write_test_object(&remote_db, &test_tree(&[(0o100644, b"file", blob_oid)]));
3011 let commit_oid = write_test_object(&remote_db, &test_commit(tree_oid, &[], b"tip\n"));
3012 fs::write(
3013 remote_git.join("refs/heads/main"),
3014 format!("{commit_oid}\n"),
3015 )
3016 .expect("test repository main ref");
3017
3018 let outcome = install_fetch_pack_via_local_protocol_v2(LocalProtocolV2FetchRequest {
3019 git_dir: &client_git,
3020 destination_git_dir: &client_git,
3021 remote_git_dir: &remote_git,
3022 format,
3023 wants: Vec::new(),
3024 want_refs: vec!["refs/heads/main".into()],
3025 haves: Some(Vec::new()),
3026 })
3027 .expect("protocol-v2 want-ref fetch");
3028
3029 assert_eq!(outcome.wanted_refs.len(), 1);
3030 assert_eq!(outcome.wanted_refs[0].name, "refs/heads/main");
3031 assert_eq!(outcome.wanted_refs[0].oid, commit_oid);
3032 assert!(
3033 FileObjectDatabase::from_git_dir(&client_git, format)
3034 .contains(&commit_oid)
3035 .expect("client object lookup")
3036 );
3037 fs::remove_dir_all(root).expect("remove test repositories");
3038 }
3039
3040 #[test]
3041 fn protocol_v2_upload_pack_rejects_deepen_since_after_every_commit() {
3042 let git_dir = unique_local_test_dir("protocol-v2-empty-deepen-since");
3043 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3044 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3045
3046 let format = ObjectFormat::Sha1;
3047 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
3048 let tree_oid = write_test_object(&db, &test_tree(&[]));
3049 let commit_oid = write_test_object(&db, &test_commit(tree_oid, &[], b"tip\n"));
3050 let error = local_fetch_v2_sections(
3051 &git_dir,
3052 format,
3053 &ProtocolV2FetchRequest {
3054 wants: vec![commit_oid],
3055 deepen_since: Some(1),
3056 done: true,
3057 ..ProtocolV2FetchRequest::default()
3058 },
3059 )
3060 .expect_err("a cutoff after every commit must fail");
3061
3062 assert!(
3063 error
3064 .to_string()
3065 .contains("no commits selected for shallow requests")
3066 );
3067 fs::remove_dir_all(git_dir).expect("remove test repository");
3068 }
3069
3070 #[test]
3071 fn protocol_v2_upload_pack_negotiates_ready_before_pack() {
3072 let git_dir = unique_local_test_dir("protocol-v2-negotiation");
3073 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3074 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3075
3076 let format = ObjectFormat::Sha1;
3077 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
3078 let tree_oid = write_test_object(&db, &test_tree(&[]));
3079 let base = write_test_object(&db, &test_commit(tree_oid, &[], b"base\n"));
3080 let tip = write_test_object(&db, &test_commit(tree_oid, &[base], b"tip\n"));
3081 let sections = local_fetch_v2_sections(
3082 &git_dir,
3083 format,
3084 &ProtocolV2FetchRequest {
3085 wants: vec![tip],
3086 haves: vec![base],
3087 done: false,
3088 ..ProtocolV2FetchRequest::default()
3089 },
3090 )
3091 .expect("common have makes the server ready");
3092 assert_eq!(
3093 sections.first(),
3094 Some(&ProtocolV2FetchResponseSection::Acknowledgments(vec![
3095 ProtocolV2FetchAcknowledgment::Ack(base),
3096 ProtocolV2FetchAcknowledgment::Ready,
3097 ]))
3098 );
3099 assert!(matches!(
3100 sections.last(),
3101 Some(ProtocolV2FetchResponseSection::Packfile(_))
3102 ));
3103
3104 let unknown = ObjectId::from_hex(format, "1111111111111111111111111111111111111111")
3105 .expect("test object id");
3106 let sections = local_fetch_v2_sections(
3107 &git_dir,
3108 format,
3109 &ProtocolV2FetchRequest {
3110 wants: vec![tip],
3111 haves: vec![unknown],
3112 done: false,
3113 ..ProtocolV2FetchRequest::default()
3114 },
3115 )
3116 .expect("unknown have continues negotiation");
3117 assert_eq!(
3118 sections,
3119 vec![ProtocolV2FetchResponseSection::Acknowledgments(vec![
3120 ProtocolV2FetchAcknowledgment::Nak,
3121 ])]
3122 );
3123
3124 fs::remove_dir_all(git_dir).expect("remove test repository");
3125 }
3126
3127 #[test]
3128 fn protocol_v2_fetch_without_wants_emits_no_empty_packfile_section() {
3129 let git_dir = unique_local_test_dir("protocol-v2-no-wants");
3130 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3131 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3132
3133 let sections = local_fetch_v2_sections(
3134 &git_dir,
3135 ObjectFormat::Sha1,
3136 &ProtocolV2FetchRequest {
3137 done: true,
3138 packfile_uris: Some("https".into()),
3139 ..ProtocolV2FetchRequest::default()
3140 },
3141 )
3142 .expect("capability-only fetch request");
3143 assert!(sections.is_empty());
3144
3145 let config = GitConfig::parse(b"[uploadpack]\n\tblobpackfileuri = anything\n")
3146 .expect("packfile-uri config");
3147 let request = ProtocolV2FetchRequest {
3148 done: true,
3149 packfile_uris: Some("https".into()),
3150 ..ProtocolV2FetchRequest::default()
3151 };
3152 let mut request_bytes = Vec::new();
3153 write_protocol_v2_fetch_request(&mut request_bytes, &request)
3154 .expect("encode capability-only request");
3155
3156 let mut stateless_response = Vec::new();
3157 serve_upload_pack_v2_stateless_with_config(
3158 &git_dir,
3159 ObjectFormat::Sha1,
3160 &config,
3161 &mut request_bytes.as_slice(),
3162 &mut stateless_response,
3163 )
3164 .expect("serve stateless capability-only request");
3165 assert!(stateless_response.is_empty());
3166
3167 let mut long_lived_response = Vec::new();
3168 serve_upload_pack_v2_with_config(
3169 &git_dir,
3170 ObjectFormat::Sha1,
3171 &config,
3172 &mut request_bytes.as_slice(),
3173 &mut long_lived_response,
3174 )
3175 .expect("serve long-lived capability-only request");
3176 let handshake = TransportHandshake {
3177 protocol: ProtocolVersion::V2,
3178 capabilities: upload_pack_v2_capabilities(ObjectFormat::Sha1, &config)
3179 .expect("test capabilities"),
3180 };
3181 let mut advertisement = Vec::new();
3182 write_protocol_v2_advertisement(&mut advertisement, &handshake)
3183 .expect("encode test advertisement");
3184 assert_eq!(long_lived_response, advertisement);
3185 fs::remove_dir_all(git_dir).expect("remove test repository");
3186 }
3187
3188 #[test]
3189 fn protocol_v2_stateless_upload_pack_does_not_readvertise() {
3190 let git_dir = unique_local_test_dir("protocol-v2-stateless");
3191 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3192 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3193
3194 let format = ObjectFormat::Sha1;
3195 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
3196 let tree_oid = write_test_object(&db, &test_tree(&[]));
3197 let tip = write_test_object(&db, &test_commit(tree_oid, &[], b"tip\n"));
3198 let mut request = Vec::new();
3199 write_protocol_v2_fetch_request(
3200 &mut request,
3201 &ProtocolV2FetchRequest {
3202 wants: vec![tip],
3203 done: true,
3204 ..ProtocolV2FetchRequest::default()
3205 },
3206 )
3207 .expect("encode fetch request");
3208 let mut response = Vec::new();
3209 serve_upload_pack_v2_stateless_with_config(
3210 &git_dir,
3211 format,
3212 &GitConfig::default(),
3213 &mut request.as_slice(),
3214 &mut response,
3215 )
3216 .expect("serve stateless fetch");
3217
3218 assert_eq!(
3219 sley_protocol::read_pkt_line_frame(&mut response.as_slice())
3220 .expect("read response")
3221 .expect("response frame"),
3222 PktLineFrame::Data(b"packfile\n".to_vec())
3223 );
3224 fs::remove_dir_all(git_dir).expect("remove test repository");
3225 }
3226
3227 #[test]
3228 fn local_fetch_from_incomplete_remote_excludes_client_have_closure() {
3229 let root = unique_local_test_dir("incomplete-local-fetch");
3230 let base_git = root.join("base.git");
3231 let patch_git = root.join("patch.git");
3232 let user_git = root.join("user.git");
3233 let direct_git = root.join("direct.git");
3234 for git_dir in [&base_git, &patch_git, &user_git, &direct_git] {
3235 fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
3236 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3237 .expect("test operation should succeed");
3238 }
3239
3240 let format = ObjectFormat::Sha1;
3241 let base_db = FileObjectDatabase::from_git_dir(&base_git, format);
3242 let patch_db = FileObjectDatabase::from_git_dir(&patch_git, format);
3243
3244 let text_a = EncodedObject::new(ObjectType::Blob, b"a\nb\nc\nd\ne\nf\ng\nh\ni\n".to_vec());
3245 let text_a_oid = write_test_object(&base_db, &text_a);
3246 let side = EncodedObject::new(ObjectType::Blob, b"side\n".to_vec());
3247 let side_oid = write_test_object(&base_db, &side);
3248 let tree_a = test_tree(&[
3249 (0o100644, b"side", side_oid),
3250 (0o100644, b"text", text_a_oid),
3251 ]);
3252 let tree_a_oid = write_test_object(&base_db, &tree_a);
3253 let commit_a = test_commit(tree_a_oid, &[], b"A\n");
3254 let commit_a_oid = write_test_object(&base_db, &commit_a);
3255
3256 let text_b =
3257 EncodedObject::new(ObjectType::Blob, b"a\nb\nc\nd\ne\nf\ng\nh\ni\nm\n".to_vec());
3258 let text_b_oid = write_test_object(&base_db, &text_b);
3259 let tree_b = test_tree(&[
3260 (0o100644, b"side", side_oid),
3261 (0o100644, b"text", text_b_oid),
3262 ]);
3263 let tree_b_oid = write_test_object(&base_db, &tree_b);
3264 let commit_b = test_commit(tree_b_oid, &[commit_a_oid], b"B\n");
3265 let commit_b_oid = write_test_object(&base_db, &commit_b);
3266
3267 let text_c = EncodedObject::new(
3268 ObjectType::Blob,
3269 b"a\nb\nc\nd\ne\nf\ng\nh\ni\nm\nq\n".to_vec(),
3270 );
3271 let text_c_oid = write_test_object(&patch_db, &text_c);
3272 let tree_c = test_tree(&[
3273 (0o100644, b"side", side_oid),
3274 (0o100644, b"text", text_c_oid),
3275 ]);
3276 let tree_c_oid = write_test_object(&patch_db, &tree_c);
3277 let commit_c = test_commit(tree_c_oid, &[commit_b_oid], b"C\n");
3278 let commit_c_oid = write_test_object(&patch_db, &commit_c);
3279 write_test_object(&patch_db, &tree_b);
3280 write_test_object(&patch_db, &commit_b);
3281 assert!(
3282 !patch_db
3283 .contains(&text_b_oid)
3284 .expect("test operation should succeed"),
3285 "patch repo must be missing the best delta base"
3286 );
3287
3288 install_fetch_pack_via_local_upload_pack(
3289 &user_git,
3290 &base_git,
3291 format,
3292 vec![commit_b_oid],
3293 None,
3294 false,
3295 false,
3296 None,
3297 None,
3298 false,
3299 None,
3300 )
3301 .expect("base fetch should succeed");
3302 assert!(
3303 FileObjectDatabase::from_git_dir(&user_git, format)
3304 .contains(&text_b_oid)
3305 .expect("test operation should succeed"),
3306 "user clone should have the missing base before fetching C"
3307 );
3308
3309 install_fetch_pack_via_local_upload_pack(
3310 &user_git,
3311 &patch_git,
3312 format,
3313 vec![commit_c_oid],
3314 None,
3315 false,
3316 false,
3317 None,
3318 None,
3319 false,
3320 None,
3321 )
3322 .expect("fetch from incomplete remote should succeed when client has the base");
3323 assert!(
3324 FileObjectDatabase::from_git_dir(&user_git, format)
3325 .contains(&commit_c_oid)
3326 .expect("test operation should succeed")
3327 );
3328
3329 let direct = install_fetch_pack_via_local_upload_pack(
3330 &direct_git,
3331 &patch_git,
3332 format,
3333 vec![commit_c_oid],
3334 None,
3335 false,
3336 false,
3337 None,
3338 None,
3339 false,
3340 None,
3341 );
3342 assert!(
3343 direct.is_err(),
3344 "direct fetch from the incomplete patch repo must still fail"
3345 );
3346
3347 fs::remove_dir_all(root).expect("test operation should succeed");
3348 }
3349
3350 #[test]
3351 fn direct_promisor_object_fetch_ignores_blob_none_for_explicit_blob() {
3352 let root = unique_local_test_dir("direct-promisor-object-fetch");
3353 let remote_git = root.join("remote.git");
3354 let client_git = root.join("client.git");
3355 for git_dir in [&remote_git, &client_git] {
3356 fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
3357 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3358 .expect("test operation should succeed");
3359 }
3360
3361 let format = ObjectFormat::Sha1;
3362 let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
3363 let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
3364
3365 let blob = EncodedObject::new(ObjectType::Blob, b"promised\n".to_vec());
3366 let blob_oid = write_test_object(&remote_db, &blob);
3367 let tree = test_tree(&[(0o100644, b"file.txt", blob_oid)]);
3368 let tree_oid = write_test_object(&remote_db, &tree);
3369 let commit = test_commit(tree_oid, &[], b"main\n");
3370 let commit_oid = write_test_object(&remote_db, &commit);
3371
3372 write_test_object(&client_db, &tree);
3373 write_test_object(&client_db, &commit);
3374 assert!(
3375 !client_db
3376 .contains(&blob_oid)
3377 .expect("test operation should succeed"),
3378 "client starts with the promised blob missing"
3379 );
3380
3381 install_fetch_pack_via_local_upload_pack(
3382 &client_git,
3383 &remote_git,
3384 format,
3385 vec![blob_oid],
3386 None,
3387 true,
3388 false,
3389 Some(sley_odb::PackObjectFilter::BlobNone),
3390 None,
3391 false,
3392 None,
3393 )
3394 .expect("direct promisor blob fetch must ignore blob:none for the explicit want");
3395
3396 assert!(
3397 FileObjectDatabase::from_git_dir(&client_git, format)
3398 .contains(&blob_oid)
3399 .expect("test operation should succeed"),
3400 "directly wanted promised blob should be installed"
3401 );
3402 assert_eq!(
3403 FileObjectDatabase::from_git_dir(&client_git, format)
3404 .read_object(&commit_oid)
3405 .expect("test operation should succeed")
3406 .object_type,
3407 ObjectType::Commit
3408 );
3409
3410 fs::remove_dir_all(root).expect("test operation should succeed");
3411 }
3412
3413 #[test]
3414 fn exact_object_hydration_uses_configured_local_promisors() {
3415 let root = unique_local_test_dir("exact-promisor-hydration");
3416 let client_git = root.join("client.git");
3417 let origin_git = root.join("origin.git");
3418 let lop_git = root.join("lop.git");
3419 for git_dir in [&client_git, &origin_git, &lop_git] {
3420 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3421 fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3422 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3423 .expect("test repository HEAD");
3424 }
3425 fs::write(
3426 client_git.join("config"),
3427 format!(
3428 "[extensions]\n\tpartialClone = origin\n\
3429 [remote \"origin\"]\n\tpromisor = true\n\turl = {}\n\
3430 [remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3431 origin_git.display(),
3432 lop_git.display()
3433 ),
3434 )
3435 .expect("client promisor config");
3436
3437 let format = ObjectFormat::Sha1;
3438 let blob = EncodedObject::new(ObjectType::Blob, b"lazy payload\n".to_vec());
3439 let blob_oid =
3440 write_test_object(&FileObjectDatabase::from_git_dir(&lop_git, format), &blob);
3441 let hydrated =
3442 hydrate_objects_from_local_promisor_remotes(&client_git, format, &[blob_oid])
3443 .expect("hydrate exact object");
3444 assert_eq!(hydrated, vec![blob_oid]);
3445 assert!(
3446 FileObjectDatabase::from_git_dir(&client_git, format)
3447 .contains(&blob_oid)
3448 .expect("client object lookup")
3449 );
3450
3451 fs::remove_dir_all(root).expect("remove test repositories");
3452 }
3453
3454 #[test]
3455 fn accepted_promisor_omits_gap_while_rejection_hydrates_server() {
3456 let root = unique_local_test_dir("promisor-traversal-policy");
3457 let origin_git = root.join("origin.git");
3458 let server_git = root.join("server.git");
3459 let lop_git = root.join("lop.git");
3460 let accepted_git = root.join("accepted.git");
3461 let rejected_git = root.join("rejected.git");
3462 for git_dir in [
3463 &origin_git,
3464 &server_git,
3465 &lop_git,
3466 &accepted_git,
3467 &rejected_git,
3468 ] {
3469 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3470 fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3471 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3472 .expect("test repository HEAD");
3473 }
3474
3475 let format = ObjectFormat::Sha1;
3476 let origin_db = FileObjectDatabase::from_git_dir(&origin_git, format);
3477 let blob = EncodedObject::new(ObjectType::Blob, b"promised payload\n".to_vec());
3478 let blob_oid = write_test_object(&origin_db, &blob);
3479 let tree_oid =
3480 write_test_object(&origin_db, &test_tree(&[(0o100644, b"payload", blob_oid)]));
3481 let commit_oid = write_test_object(&origin_db, &test_commit(tree_oid, &[], b"tip\n"));
3482 write_test_object(&FileObjectDatabase::from_git_dir(&lop_git, format), &blob);
3483
3484 sley_odb::build_and_install_reachable_pack_filtered(
3485 &origin_db,
3486 &FileObjectDatabase::from_git_dir(&server_git, format),
3487 format,
3488 vec![commit_oid],
3489 &HashSet::new(),
3490 RawPackInstallOptions {
3491 promisor: true,
3492 ..Default::default()
3493 },
3494 Some(sley_odb::PackObjectFilter::BlobNone),
3495 None,
3496 )
3497 .expect("create incomplete promisor server")
3498 .expect("promisor pack");
3499 fs::write(
3500 server_git.join("config"),
3501 format!(
3502 "[remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3503 lop_git.display()
3504 ),
3505 )
3506 .expect("server promisor config");
3507
3508 let advertisement = sley_protocol::PromisorRemoteAdvertisement {
3509 name: "lop".into(),
3510 url: lop_git.to_string_lossy().into_owned(),
3511 partial_clone_filter: None,
3512 token: None,
3513 };
3514 install_fetch_pack_via_local_upload_pack_with_promisor_decision(
3515 &accepted_git,
3516 &server_git,
3517 format,
3518 vec![commit_oid],
3519 None,
3520 true,
3521 false,
3522 Some(sley_odb::PackObjectFilter::BlobNone),
3523 Some(Vec::new()),
3524 false,
3525 None,
3526 &crate::PromisorRemoteDecision {
3527 accepted: vec![advertisement],
3528 reply: Some("lop".into()),
3529 stored_fields: Vec::new(),
3530 },
3531 )
3532 .expect("accepted promisor transfer");
3533 let server_db = FileObjectDatabase::from_git_dir(&server_git, format);
3534 assert!(!server_db.contains(&blob_oid).expect("server object lookup"));
3535
3536 install_fetch_pack_via_local_upload_pack(
3537 &rejected_git,
3538 &server_git,
3539 format,
3540 vec![commit_oid],
3541 None,
3542 true,
3543 false,
3544 Some(sley_odb::PackObjectFilter::BlobNone),
3545 Some(Vec::new()),
3546 false,
3547 None,
3548 )
3549 .expect("rejected promisor transfer hydrates server");
3550 server_db.refresh_read_cache();
3551 assert!(
3552 server_db
3553 .contains(&blob_oid)
3554 .expect("hydrated server lookup")
3555 );
3556 fs::remove_dir_all(root).expect("remove test repositories");
3557 }
3558
3559 #[test]
3560 fn configured_local_promisor_hydrates_reachable_gap_without_sidecar() {
3561 let root = unique_local_test_dir("promisor-repack-hydration");
3562 let origin_git = root.join("origin.git");
3563 let server_git = root.join("server.git");
3564 let lop_git = root.join("lop.git");
3565 for git_dir in [&origin_git, &server_git, &lop_git] {
3566 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3567 fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3568 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3569 .expect("test repository HEAD");
3570 }
3571
3572 let format = ObjectFormat::Sha1;
3573 let origin_db = FileObjectDatabase::from_git_dir(&origin_git, format);
3574 let blob = EncodedObject::new(ObjectType::Blob, b"repack promised payload\n".to_vec());
3575 let blob_oid = write_test_object(&origin_db, &blob);
3576 let tree_oid =
3577 write_test_object(&origin_db, &test_tree(&[(0o100644, b"payload", blob_oid)]));
3578 let commit_oid = write_test_object(&origin_db, &test_commit(tree_oid, &[], b"tip\n"));
3579 write_test_object(&FileObjectDatabase::from_git_dir(&lop_git, format), &blob);
3580
3581 sley_odb::build_and_install_reachable_pack_filtered(
3582 &origin_db,
3583 &FileObjectDatabase::from_git_dir(&server_git, format),
3584 format,
3585 vec![commit_oid],
3586 &HashSet::new(),
3587 RawPackInstallOptions {
3588 promisor: true,
3589 ..Default::default()
3590 },
3591 Some(sley_odb::PackObjectFilter::BlobNone),
3592 None,
3593 )
3594 .expect("create incomplete promisor server")
3595 .expect("promisor pack");
3596 for entry in fs::read_dir(server_git.join("objects/pack")).expect("pack directory") {
3597 let path = entry.expect("pack entry").path();
3598 if path.extension().and_then(|ext| ext.to_str()) == Some("promisor") {
3599 fs::remove_file(path).expect("remove promisor classification");
3600 }
3601 }
3602 fs::write(
3603 server_git.join("config"),
3604 format!(
3605 "[remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3606 lop_git.display()
3607 ),
3608 )
3609 .expect("server promisor config");
3610
3611 let server_db = FileObjectDatabase::from_git_dir(&server_git, format);
3612 assert!(
3613 !server_db
3614 .contains(&blob_oid)
3615 .expect("missing before hydrate")
3616 );
3617 hydrate_reachable_from_local_promisor_remotes(&server_git, format, &[commit_oid])
3618 .expect("hydrate configured promisor gap");
3619 server_db.refresh_read_cache();
3620 assert!(server_db.contains(&blob_oid).expect("hydrated blob lookup"));
3621
3622 fs::remove_dir_all(root).expect("remove test repositories");
3623 }
3624
3625 #[test]
3626 fn promisor_hydration_skips_objects_excluded_by_client_haves() {
3627 let root = unique_local_test_dir("promisor-fetch-exclusions");
3628 let origin_git = root.join("origin.git");
3629 let server_git = root.join("server.git");
3630 let lop_git = root.join("lop.git");
3631 for git_dir in [&origin_git, &server_git, &lop_git] {
3632 fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3633 fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3634 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3635 .expect("test repository HEAD");
3636 }
3637
3638 let format = ObjectFormat::Sha1;
3639 let origin_db = FileObjectDatabase::from_git_dir(&origin_git, format);
3640 let old_blob = EncodedObject::new(ObjectType::Blob, b"old promised payload\n".to_vec());
3641 let new_blob = EncodedObject::new(ObjectType::Blob, b"new promised payload\n".to_vec());
3642 let old_blob_oid = write_test_object(&origin_db, &old_blob);
3643 let new_blob_oid = write_test_object(&origin_db, &new_blob);
3644 let tree_oid = write_test_object(
3645 &origin_db,
3646 &test_tree(&[
3647 (0o100644, b"old", old_blob_oid),
3648 (0o100644, b"new", new_blob_oid),
3649 ]),
3650 );
3651 let commit_oid = write_test_object(&origin_db, &test_commit(tree_oid, &[], b"tip\n"));
3652 let lop_db = FileObjectDatabase::from_git_dir(&lop_git, format);
3653 write_test_object(&lop_db, &old_blob);
3654 write_test_object(&lop_db, &new_blob);
3655
3656 sley_odb::build_and_install_reachable_pack_filtered(
3657 &origin_db,
3658 &FileObjectDatabase::from_git_dir(&server_git, format),
3659 format,
3660 vec![commit_oid],
3661 &HashSet::new(),
3662 RawPackInstallOptions {
3663 promisor: true,
3664 ..Default::default()
3665 },
3666 Some(sley_odb::PackObjectFilter::BlobNone),
3667 None,
3668 )
3669 .expect("create incomplete promisor server")
3670 .expect("promisor pack");
3671 fs::write(
3672 server_git.join("config"),
3673 format!(
3674 "[remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3675 lop_git.display()
3676 ),
3677 )
3678 .expect("server promisor config");
3679
3680 let server_db = FileObjectDatabase::from_git_dir(&server_git, format)
3681 .with_promisor_remote_present(true);
3682 hydrate_reachable_promised_objects(
3683 &server_git,
3684 &server_db,
3685 format,
3686 &[commit_oid],
3687 &HashSet::from([old_blob_oid]),
3688 )
3689 .expect("hydrate only non-excluded gap");
3690 server_db.refresh_read_cache();
3691 assert!(!server_db.contains(&old_blob_oid).expect("old blob lookup"));
3692 assert!(server_db.contains(&new_blob_oid).expect("new blob lookup"));
3693
3694 fs::remove_dir_all(root).expect("remove test repositories");
3695 }
3696
3697 fn unique_local_test_dir(name: &str) -> std::path::PathBuf {
3698 let nanos = SystemTime::now()
3699 .duration_since(UNIX_EPOCH)
3700 .expect("test operation should succeed")
3701 .as_nanos();
3702 let root =
3703 std::env::temp_dir().join(format!("sley-remote-{name}-{}-{nanos}", std::process::id()));
3704 fs::create_dir_all(&root).expect("test operation should succeed");
3705 root
3706 }
3707
3708 fn write_test_object(db: &FileObjectDatabase, object: &EncodedObject) -> ObjectId {
3709 db.write_object(object.clone())
3710 .expect("test operation should succeed")
3711 }
3712
3713 fn test_tree(entries: &[(u32, &[u8], ObjectId)]) -> EncodedObject {
3714 EncodedObject::new(
3715 ObjectType::Tree,
3716 Tree {
3717 entries: entries
3718 .iter()
3719 .map(|(mode, name, oid)| TreeEntry {
3720 mode: *mode,
3721 name: BString::from(*name),
3722 oid: *oid,
3723 })
3724 .collect(),
3725 }
3726 .write(),
3727 )
3728 }
3729
3730 fn test_commit(tree: ObjectId, parents: &[ObjectId], message: &[u8]) -> EncodedObject {
3731 let identity = b"Example <example@example.invalid> 0 +0000".to_vec();
3732 EncodedObject::new(
3733 ObjectType::Commit,
3734 Commit {
3735 tree,
3736 parents: parents.to_vec(),
3737 author: identity.clone(),
3738 committer: identity,
3739 encoding: None,
3740 message: message.to_vec(),
3741 }
3742 .write(),
3743 )
3744 }
3745}