1use crate::local::LocalDeepenPlan;
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use sley_config::GitConfig;
26use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
27use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
28use sley_odb::{
29 FileObjectDatabase, ObjectReader, collect_reachable_object_ids,
30 collect_reachable_object_ids_excluding,
31};
32#[cfg(feature = "http")]
33use sley_protocol::ProtocolVersion;
34use sley_protocol::{
35 FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
36 fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refname_matches,
37 refspec_map_source,
38};
39use sley_refs::{FileRefStore, Ref, RefTarget, RefUpdate, ReflogEntry};
40use sley_transport::{RemoteTransport, RemoteUrl};
41#[cfg(feature = "http")]
42use sley_transport::{HttpClient, UreqHttpClient};
43
44use crate::{CredentialProvider, ProgressSink};
45
46pub enum FetchSource {
51 Http(RemoteUrl),
53 Ssh(RemoteUrl),
56 Git {
58 remote: RemoteUrl,
59 protocol_v2: bool,
60 },
61 Local {
63 git_dir: PathBuf,
65 common_git_dir: PathBuf,
67 },
68}
69
70#[derive(Debug, Clone)]
72pub struct FetchOptions {
73 pub quiet: bool,
76 pub auto_follow_tags: bool,
78 pub fetch_all_tags: bool,
80 pub prune: bool,
82 pub prune_tags: bool,
84 pub dry_run: bool,
86 pub force: bool,
89 pub append: bool,
91 pub write_fetch_head: bool,
93 pub tag_option_explicit: bool,
96 pub prune_option_explicit: bool,
99 pub prune_tags_option_explicit: bool,
102 pub refmap: Option<Vec<String>>,
106 pub depth: Option<u32>,
111 pub merge_srcs: Vec<String>,
118 pub filter: Option<sley_odb::PackObjectFilter>,
124 pub refetch: bool,
127 pub cloning: bool,
130 pub record_promisor_refs: bool,
134 pub update_shallow: bool,
137 pub reject_shallow: bool,
139 pub deepen_relative: bool,
142 pub update_head_ok: bool,
145 pub deepen_since: Option<i64>,
148 pub deepen_not: Vec<String>,
152 pub ssh_options: Option<crate::ssh::SshTransportOptions>,
156 pub atomic: bool,
162 pub negotiation_restrict: Option<Vec<String>>,
166 pub negotiation_include: Option<Vec<String>>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PrunedRef {
175 pub branch: String,
177 pub refname: String,
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct FetchOutcome {
184 pub ref_updates: Vec<FetchRefUpdate>,
188 pub pruned: Vec<PrunedRef>,
191 pub head_symref: Option<String>,
194 pub wrote_fetch_head: bool,
196}
197
198pub struct FetchRequest<'a> {
200 pub git_dir: &'a Path,
202 pub format: ObjectFormat,
204 pub config: &'a GitConfig,
206 pub remote_name: &'a str,
208 pub source: &'a FetchSource,
210 pub refspecs: &'a [String],
213 pub options: &'a FetchOptions,
215}
216
217pub struct FetchServices<'a> {
219 pub credentials: &'a mut dyn CredentialProvider,
221 pub progress: &'a mut dyn ProgressSink,
223 pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
228}
229
230pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
246 #[cfg(feature = "http")]
247 {
248 fetch_impl(request, services, None)
249 }
250 #[cfg(not(feature = "http"))]
251 {
252 fetch_impl(request, services)
253 }
254}
255
256#[cfg(feature = "http")]
265pub fn fetch_with_http_client(
266 request: FetchRequest<'_>,
267 services: FetchServices<'_>,
268 http_client: Option<&dyn HttpClient>,
269) -> Result<FetchOutcome> {
270 fetch_impl(request, services, http_client)
271}
272
273fn fetch_impl(
274 request: FetchRequest<'_>,
275 services: FetchServices<'_>,
276 #[cfg(feature = "http")] http_client: Option<&dyn HttpClient>,
277) -> Result<FetchOutcome> {
278 let ref_hook = services.ref_hook;
279 let mut options = request.options.clone();
280 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
281 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
282 crate::protocol::check_transport_allowed(
283 scheme_for_fetch_source(request.source),
284 Some(request.config),
285 None,
286 )
287 .map_err(crate::protocol::transport_policy_git_error)?;
288 let promisor_remote = request
293 .config
294 .get_bool("remote", Some(request.remote_name), "promisor")
295 .unwrap_or(false)
296 || request.options.filter.is_some();
297 let max_input_size = fetch_max_input_size(request.config);
298 let configured_refspecs = if request.refspecs.is_empty() {
299 remote_config_values(request.config, request.remote_name, "fetch")
300 } else {
301 Vec::new()
302 };
303 let configured_refspecs_empty = configured_refspecs.is_empty();
304 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
310 let default_head_fetch =
311 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
312 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
313 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
314 let prune_refspecs =
315 prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
316 let mut effective_refspecs = fetch_refspecs_for_source(
317 configured_refspecs,
318 request.refspecs,
319 options.fetch_all_tags,
320 );
321 if options.prune_tags
322 && request.refspecs.is_empty()
323 && !effective_refspecs
324 .iter()
325 .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
326 {
327 effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
328 }
329 if has_merge_config {
330 if configured_refspecs_empty && request.refspecs.is_empty() {
333 effective_refspecs.retain(|spec| spec != "HEAD");
334 }
335 let configured_parsed = effective_refspecs
338 .iter()
339 .map(|refspec| parse_refspec(refspec))
340 .collect::<Result<Vec<_>>>()?;
341 for merge_src in &options.merge_srcs {
342 let covered = configured_parsed.iter().any(|refspec| {
346 refspec
347 .src
348 .as_deref()
349 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
350 });
351 if !covered {
352 effective_refspecs.push(merge_src.clone());
355 }
356 }
357 }
358 let mut parsed_refspecs = effective_refspecs
359 .iter()
360 .map(|refspec| parse_refspec(refspec))
361 .collect::<Result<Vec<_>>>()?;
362 if options.force {
363 for refspec in &mut parsed_refspecs {
364 refspec.force = true;
365 }
366 }
367 if options.refmap.is_some() && request.refspecs.is_empty() {
368 return Err(GitError::Command(
369 "--refmap option is only meaningful with command-line refspec(s)".into(),
370 ));
371 }
372 let tracking_refspec_strings = if request.refspecs.is_empty() {
373 Vec::new()
374 } else {
375 options.refmap.clone().unwrap_or_else(|| {
376 configured_refspecs_for_tracking(request.config, request.remote_name)
377 })
378 };
379 let tracking_refspecs = tracking_refspec_strings
380 .iter()
381 .map(|refspec| parse_refspec(refspec))
382 .collect::<Result<Vec<_>>>()?;
383 let parsed_prune_refspecs = prune_refspecs
384 .iter()
385 .map(|refspec| parse_refspec(refspec))
386 .collect::<Result<Vec<_>>>()?;
387
388 let store = FileRefStore::new(request.git_dir, request.format);
389 let negotiation_haves = custom_negotiation_haves(
390 request.git_dir,
391 request.format,
392 request.config,
393 request.remote_name,
394 &options,
395 )?;
396 let mut outcome = FetchOutcome::default();
397
398 let advertisements = match request.source {
402 #[cfg(not(feature = "http"))]
403 FetchSource::Http(_) => {
404 return Err(GitError::Unsupported(
405 "HTTP transport is not enabled in this build".into(),
406 ));
407 }
408 #[cfg(feature = "http")]
409 FetchSource::Http(remote) => {
410 let default_client;
413 let client: &dyn HttpClient = match http_client {
414 Some(client) => client,
415 None => {
416 default_client = UreqHttpClient::new();
417 &default_client
418 }
419 };
420 let git_protocol =
421 crate::http::http_git_protocol_header_value(Some(request.config))?;
422 let discovered = crate::http::http_service_advertisements(
423 client,
424 remote,
425 request.format,
426 sley_protocol::GitService::UploadPack,
427 services.credentials,
428 Some(request.config),
429 )?;
430 let advertisements = discovered.set.refs;
431 let features = crate::http::http_upload_pack_features(
432 &advertisements,
433 discovered.handshake.as_ref(),
434 )?;
435 outcome.head_symref = head_symref_from_features(&features.symrefs);
436 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
437 advertisements: &advertisements,
438 refspecs: &parsed_refspecs,
439 options: &options,
440 store: &store,
441 reachable: None,
442 local_db: None,
443 deepen_excluded: None,
444 format: request.format,
445 configured_remote_fetch,
446 has_merge_config,
447 tracking_refspecs: &tracking_refspecs,
448 })?;
449 let wants = updates.iter().map(|update| update.oid).collect();
450 let existing_shallow =
454 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
455 let deepen_not = resolve_deepen_not_refs(&advertisements, &options.deepen_not)?;
456 let pack_request = crate::http::HttpFetchPackRequest {
457 client,
458 git_dir: request.git_dir,
459 format: request.format,
460 remote,
461 wants,
462 haves: negotiation_haves.clone(),
463 shallow: existing_shallow,
464 deepen: options.depth,
465 promisor: promisor_remote,
466 max_input_size,
467 filter: options.filter.clone(),
468 deepen_since: options.deepen_since,
469 deepen_not,
470 deepen_relative: options.deepen_relative,
471 git_protocol: git_protocol.as_deref(),
472 omit_haves: false,
473 };
474 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
475 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
476 GitError::InvalidFormat(
477 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
478 .into(),
479 )
480 })?;
481 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
482 pack_request,
483 handshake,
484 services.credentials,
485 services.progress,
486 )?
487 } else {
488 crate::http::install_fetch_pack_via_http_upload_pack(
489 pack_request,
490 services.credentials,
491 services.progress,
492 )?
493 };
494 reject_shallow_clone_fetch(&options, &shallow_info)?;
495 if !options.dry_run {
496 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
497 }
498 finalize_fetch(
499 FetchFinalize {
500 git_dir: request.git_dir,
501 format: request.format,
502 store: &store,
503 options: &options,
504 fetch_head_source: &fetch_head_source,
505 default_head_fetch,
506 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
507 ref_hook,
508 opportunistic_dsts: &opportunistic_dsts,
509 },
510 &mut updates,
511 &mut outcome,
512 )?;
513 advertisements
514 }
515 FetchSource::Ssh(remote) => {
516 crate::ssh::validate_ssh_fetch_options(
517 options.filter.as_ref(),
518 options.deepen_since,
519 &options.deepen_not,
520 )?;
521 let ssh_options = options
525 .ssh_options
526 .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
527 let (advertisements, features) =
528 crate::ssh::ssh_upload_pack_advertisements_with_options(
529 remote,
530 request.format,
531 ssh_options,
532 )?;
533 outcome.head_symref = head_symref_from_features(&features.symrefs);
534 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
535 advertisements: &advertisements,
536 refspecs: &parsed_refspecs,
537 options: &options,
538 store: &store,
539 reachable: None,
540 local_db: None,
541 deepen_excluded: None,
542 format: request.format,
543 configured_remote_fetch,
544 has_merge_config,
545 tracking_refspecs: &tracking_refspecs,
546 })?;
547 if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
548 append_missing_ext_advertised_tags(
549 &advertisements,
550 &parsed_refspecs,
551 &store,
552 &mut updates,
553 )?;
554 }
555 let wants = updates.iter().map(|update| update.oid).collect();
556 let existing_shallow =
559 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
560 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
561 crate::ssh::SshFetchPackRequest {
562 git_dir: request.git_dir,
563 format: request.format,
564 remote,
565 features: &features,
566 wants,
567 haves: negotiation_haves.clone(),
568 shallow: existing_shallow,
569 deepen: options.depth,
570 promisor: promisor_remote,
571 command_options: ssh_options,
572 max_input_size,
573 },
574 services.progress,
575 )?;
576 reject_shallow_clone_fetch(&options, &shallow_info)?;
577 if !options.dry_run {
578 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
579 }
580 finalize_fetch(
581 FetchFinalize {
582 git_dir: request.git_dir,
583 format: request.format,
584 store: &store,
585 options: &options,
586 fetch_head_source: &fetch_head_source,
587 default_head_fetch,
588 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
589 ref_hook,
590 opportunistic_dsts: &opportunistic_dsts,
591 },
592 &mut updates,
593 &mut outcome,
594 )?;
595 advertisements
596 }
597 FetchSource::Git {
598 remote,
599 protocol_v2,
600 } => {
601 let protocol_v2 =
602 *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
603 let discovered = crate::git::git_upload_pack_advertisements_with_protocol(
604 remote,
605 request.format,
606 protocol_v2,
607 Some(request.config),
608 )?;
609 let advertisements = discovered.refs;
610 let features = discovered.features;
611 outcome.head_symref = head_symref_from_features(&features.symrefs);
612 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
613 advertisements: &advertisements,
614 refspecs: &parsed_refspecs,
615 options: &options,
616 store: &store,
617 reachable: None,
618 local_db: None,
619 deepen_excluded: None,
620 format: request.format,
621 configured_remote_fetch,
622 has_merge_config,
623 tracking_refspecs: &tracking_refspecs,
624 })?;
625 let wants = updates.iter().map(|update| update.oid).collect();
626 let existing_shallow =
627 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
628 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
629 crate::git::GitFetchPackRequest {
630 git_dir: request.git_dir,
631 format: request.format,
632 remote,
633 config: Some(request.config),
634 features: &features,
635 wants,
636 haves: negotiation_haves.clone(),
637 shallow: existing_shallow,
638 deepen: options.depth,
639 promisor: promisor_remote,
640 protocol_v2: discovered.protocol_v2,
641 max_input_size,
642 },
643 services.progress,
644 )?;
645 reject_shallow_clone_fetch(&options, &shallow_info)?;
646 if !options.dry_run {
647 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
648 }
649 finalize_fetch(
650 FetchFinalize {
651 git_dir: request.git_dir,
652 format: request.format,
653 store: &store,
654 options: &options,
655 fetch_head_source: &fetch_head_source,
656 default_head_fetch,
657 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
658 ref_hook,
659 opportunistic_dsts: &opportunistic_dsts,
660 },
661 &mut updates,
662 &mut outcome,
663 )?;
664 advertisements
665 }
666 FetchSource::Local {
667 git_dir: remote_git_dir,
668 common_git_dir: remote_common_git_dir,
669 } => {
670 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
671 if remote_format != request.format {
672 return Err(GitError::InvalidObjectId(format!(
673 "remote repository uses {}, local repository uses {}",
674 remote_format.name(),
675 request.format.name()
676 )));
677 }
678 let advertisements =
679 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
680 if advertisements
684 .iter()
685 .any(|advertisement| advertisement.name == "HEAD")
686 && let Some(RefTarget::Symbolic(target)) =
687 FileRefStore::new_without_reference_backend_env(remote_git_dir, request.format)
688 .read_ref("HEAD")?
689 {
690 outcome.head_symref = Some(target);
691 }
692 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
693 let remote_shallow =
705 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
706 let explicit_deepen = options.depth.is_some()
707 || options.deepen_since.is_some()
708 || !options.deepen_not.is_empty();
709 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
710 let mut deepen_not_oids = Vec::new();
713 for name in &options.deepen_not {
714 let resolved = advertisements.iter().find(|advertisement| {
715 advertisement.name == *name
716 || advertisement.name == format!("refs/tags/{name}")
717 || advertisement.name == format!("refs/heads/{name}")
718 || advertisement.name == format!("refs/{name}")
719 });
720 match resolved {
721 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
722 None => {
723 return Err(GitError::Command(format!(
724 "git upload-pack: deepen-not is not a ref: {name}"
725 )));
726 }
727 }
728 }
729 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
730 if !explicit_deepen && !implicit_deepen {
731 return Ok(None);
732 }
733 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
735 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
736 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
737 &remote_db,
738 request.format,
739 heads,
740 client_shallow,
741 options.deepen_since,
742 &deepen_not_oids,
743 )?));
744 }
745 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
746 Ok(Some(crate::local::compute_local_deepen(
747 &remote_db,
748 request.format,
749 heads,
750 client_shallow,
751 depth,
752 options.deepen_relative,
753 )?))
754 };
755 let primary_heads = {
756 let primary = plan_fetch_ref_updates(
757 &advertisements,
758 &parsed_refspecs,
759 options.auto_follow_tags,
760 )?;
761 let mut seen = HashSet::new();
762 let mut heads = Vec::new();
763 for update in &primary {
764 if seen.insert(update.oid) {
765 heads.push(update.oid);
766 }
767 }
768 heads
769 };
770 let mut deepen_plan = plan_deepen(&primary_heads)?;
771 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
772 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
773 advertisements: &advertisements,
774 refspecs: &parsed_refspecs,
775 options: &options,
776 store: &store,
777 reachable: Some((&remote_db, &advertisements)),
778 local_db: Some(&local_db),
779 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
780 format: request.format,
781 configured_remote_fetch,
782 has_merge_config,
783 tracking_refspecs: &tracking_refspecs,
784 })?;
785 if implicit_deepen && !options.cloning && !options.update_shallow {
790 let client_shallow: HashSet<ObjectId> =
791 crate::shallow::read_shallow(request.git_dir, request.format)?
792 .into_iter()
793 .collect();
794 let new_points: HashSet<ObjectId> = deepen_plan
795 .as_ref()
796 .map(|plan| {
797 plan.shallow_info
798 .iter()
799 .filter_map(|entry| match entry {
800 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
801 if !client_shallow.contains(oid) =>
802 {
803 Some(*oid)
804 }
805 _ => None,
806 })
807 .collect()
808 })
809 .unwrap_or_default();
810 if !new_points.is_empty() {
811 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
812 let mut dirty = |tip: &ObjectId| -> Result<bool> {
813 if let Some(&cached) = dirty_cache.get(tip) {
814 return Ok(cached);
815 }
816 let result =
817 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
818 dirty_cache.insert(*tip, result);
819 Ok(result)
820 };
821 let mut kept = Vec::new();
822 for update in updates {
823 if dirty(&update.oid)? {
824 continue;
825 }
826 kept.push(update);
827 }
828 updates = kept;
829 let mut seen = HashSet::new();
832 let mut heads = Vec::new();
833 for update in &updates {
834 if seen.insert(update.oid) {
835 heads.push(update.oid);
836 }
837 }
838 deepen_plan = if heads.is_empty() {
839 None
840 } else {
841 plan_deepen(&heads)?
842 };
843 }
844 }
845 let starts: Vec<ObjectId> = if options.refetch {
846 let mut seen = HashSet::new();
847 updates
848 .iter()
849 .map(|update| update.oid)
850 .chain(primary_heads.iter().copied())
851 .filter(|oid| seen.insert(*oid))
852 .collect()
853 } else if deepen_plan.is_none() {
854 let mut starts = Vec::new();
855 for update in &updates {
856 if !local_db.contains(&update.oid)? {
857 starts.push(update.oid);
858 }
859 }
860 starts
861 } else {
862 updates.iter().map(|update| update.oid).collect()
863 };
864 let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
865 if !updates.is_empty() {
866 sley_protocol::trace_packet_write_payload(b"0000");
867 }
868 Vec::new()
869 } else {
870 crate::local::install_fetch_pack_via_local_upload_pack(
871 request.git_dir,
872 remote_git_dir,
873 request.format,
874 starts,
875 deepen_plan.as_ref(),
876 promisor_remote,
877 options.record_promisor_refs,
878 options.filter.clone(),
879 negotiation_haves.clone(),
880 options.refetch,
881 local_fetch_unpack_limit(request.git_dir, promisor_remote),
882 )?
883 };
884 if !options.dry_run {
885 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
886 }
887 finalize_fetch(
888 FetchFinalize {
889 git_dir: request.git_dir,
890 format: request.format,
891 store: &store,
892 options: &options,
893 fetch_head_source: &fetch_head_source,
894 default_head_fetch,
895 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
896 ref_hook,
897 opportunistic_dsts: &opportunistic_dsts,
898 },
899 &mut updates,
900 &mut outcome,
901 )?;
902 advertisements
903 }
904 };
905
906 if options.prune && !parsed_prune_refspecs.is_empty() {
907 outcome.pruned = prune_refs_from_advertisements(
908 PruneRefsInput {
909 config: request.config,
910 store: &store,
911 remote: request.remote_name,
912 advertisements: &advertisements,
913 refspecs: &parsed_prune_refspecs,
914 dry_run: options.dry_run,
915 quiet: options.quiet,
916 },
917 services.progress,
918 )?;
919 }
920
921 Ok(outcome)
922}
923
924fn scheme_for_fetch_source(source: &FetchSource) -> &'static str {
925 match source {
926 FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
927 FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
928 FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
929 FetchSource::Local { .. } => "file",
930 }
931}
932
933fn local_fetch_unpack_limit(git_dir: &Path, promisor_remote: bool) -> Option<usize> {
934 if promisor_remote {
935 return None;
936 }
937 git_dir
938 .join("objects")
939 .join("info")
940 .join("alternates")
941 .exists()
942 .then_some(100)
943}
944
945fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
949 remote_db: &R,
950 format: ObjectFormat,
951 tip: &ObjectId,
952 boundary: &HashSet<ObjectId>,
953) -> Result<bool> {
954 let mut seen: HashSet<ObjectId> = HashSet::new();
955 let mut queue: Vec<ObjectId> = vec![*tip];
956 while let Some(oid) = queue.pop() {
957 if !seen.insert(oid) {
958 continue;
959 }
960 let object = remote_db.read_object(&oid)?;
961 let commit = match object.object_type {
962 sley_object::ObjectType::Commit => {
963 sley_object::Commit::parse_ref(format, &object.body)?
964 }
965 sley_object::ObjectType::Tag => {
966 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
967 queue.push(tag.object);
968 continue;
969 }
970 _ => continue,
971 };
972 if boundary.contains(&oid) {
973 return Ok(true);
974 }
975 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
976 }
977 Ok(false)
978}
979
980fn shallow_boundary_for_request(
985 git_dir: &Path,
986 format: ObjectFormat,
987 options: &FetchOptions,
988) -> Result<Vec<ObjectId>> {
989 if options.depth.is_none()
990 && options.deepen_since.is_none()
991 && options.deepen_not.is_empty()
992 {
993 return Ok(Vec::new());
994 }
995 crate::shallow::read_shallow(git_dir, format)
996}
997
998fn resolve_deepen_not_refs(
999 advertisements: &[RefAdvertisement],
1000 deepen_not: &[String],
1001) -> Result<Vec<String>> {
1002 let mut resolved = Vec::with_capacity(deepen_not.len());
1003 for name in deepen_not {
1004 let found = advertisements.iter().any(|advertisement| {
1005 advertisement.name == *name
1006 || advertisement.name == format!("refs/tags/{name}")
1007 || advertisement.name == format!("refs/heads/{name}")
1008 || advertisement.name == format!("refs/{name}")
1009 });
1010 if found {
1011 resolved.push(name.clone());
1012 } else {
1013 return Err(GitError::Command(format!(
1014 "git upload-pack: deepen-not is not a ref: {name}"
1015 )));
1016 }
1017 }
1018 Ok(resolved)
1019}
1020
1021struct FetchPlanInput<'a> {
1027 advertisements: &'a [RefAdvertisement],
1028 refspecs: &'a [RefSpec],
1029 options: &'a FetchOptions,
1030 store: &'a FileRefStore,
1031 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
1032 local_db: Option<&'a FileObjectDatabase>,
1036 deepen_excluded: Option<&'a HashSet<ObjectId>>,
1037 format: ObjectFormat,
1038 configured_remote_fetch: bool,
1039 has_merge_config: bool,
1043 tracking_refspecs: &'a [RefSpec],
1045}
1046
1047fn plan_and_adjust_updates(
1048 input: FetchPlanInput<'_>,
1049) -> Result<(Vec<FetchRefUpdate>, HashSet<String>)> {
1050 let FetchPlanInput {
1051 advertisements,
1052 refspecs,
1053 options,
1054 store,
1055 reachable,
1056 local_db,
1057 deepen_excluded,
1058 format,
1059 configured_remote_fetch,
1060 has_merge_config,
1061 tracking_refspecs,
1062 } = input;
1063 let visible_advertisements = advertisements_without_peeled_refs(advertisements);
1064 let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
1065 advertisements
1066 } else {
1067 visible_advertisements.as_slice()
1068 };
1069 let mut updates =
1070 plan_fetch_ref_updates(planning_advertisements, refspecs, options.auto_follow_tags)?;
1071 if options.fetch_all_tags {
1072 mark_tag_refspec_updates_not_for_merge(&mut updates);
1073 } else {
1074 if options.auto_follow_tags
1075 && let Some((remote_db, advertisements)) = reachable
1076 {
1077 let visible_reachable_advertisements =
1078 advertisements_without_peeled_refs(advertisements);
1079 let reachable_advertisements =
1080 if visible_reachable_advertisements.len() == advertisements.len() {
1081 advertisements
1082 } else {
1083 visible_reachable_advertisements.as_slice()
1084 };
1085 append_reachable_auto_follow_tags(
1086 reachable_advertisements,
1087 remote_db,
1088 local_db,
1089 format,
1090 refspecs,
1091 &mut updates,
1092 deepen_excluded,
1093 )?;
1094 }
1095 retain_missing_auto_follow_tags(store, &mut updates)?;
1096 }
1097 if configured_remote_fetch || has_merge_config {
1098 for update in &mut updates {
1099 update.not_for_merge = true;
1100 }
1101 if !options.merge_srcs.is_empty() {
1102 for update in &mut updates {
1107 if options
1108 .merge_srcs
1109 .iter()
1110 .any(|src| refname_matches(src, &update.src))
1111 {
1112 update.not_for_merge = false;
1113 }
1114 }
1115 } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
1116 && !first.pattern
1117 {
1118 if let Some(update) = updates.first_mut() {
1123 update.not_for_merge = false;
1124 }
1125 }
1126 updates.sort_by_key(|update| update.not_for_merge);
1130 }
1131 let opportunistic_dsts =
1132 append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
1133 ref_remove_duplicate_updates(&mut updates)?;
1134 Ok((updates, opportunistic_dsts))
1135}
1136
1137fn ref_remove_duplicate_updates(updates: &mut Vec<FetchRefUpdate>) -> Result<()> {
1142 let mut seen: BTreeMap<String, String> = BTreeMap::new();
1143 let mut error = None;
1144 updates.retain(|update| {
1145 let Some(dst) = update.dst.as_deref() else {
1146 return true;
1147 };
1148 match seen.get(dst) {
1149 Some(prev_src) if prev_src == &update.src => false,
1150 Some(prev_src) => {
1151 if error.is_none() {
1152 error = Some(GitError::Command(format!(
1153 "Cannot fetch both {} and {} to {dst}",
1154 prev_src, update.src
1155 )));
1156 }
1157 true
1158 }
1159 None => {
1160 seen.insert(dst.to_string(), update.src.clone());
1161 true
1162 }
1163 }
1164 });
1165 match error {
1166 Some(err) => Err(err),
1167 None => Ok(()),
1168 }
1169}
1170
1171fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
1172 if remote_exists(config, remote) {
1173 remote_config_values(config, remote, "fetch")
1174 } else {
1175 Vec::new()
1176 }
1177}
1178
1179fn append_opportunistic_tracking_updates(
1184 updates: &mut Vec<FetchRefUpdate>,
1185 tracking_refspecs: &[RefSpec],
1186) -> Result<HashSet<String>> {
1187 let mut opportunistic_dsts = HashSet::new();
1188 if tracking_refspecs.is_empty() {
1189 return Ok(opportunistic_dsts);
1190 }
1191 let mut seen_dsts = updates
1192 .iter()
1193 .filter_map(|update| update.dst.clone())
1194 .collect::<HashSet<_>>();
1195 let mut additions = Vec::new();
1196 for update in updates.iter() {
1197 if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1198 continue;
1199 }
1200 for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1201 let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1202 continue;
1203 };
1204 if !seen_dsts.insert(dst.clone()) {
1205 continue;
1206 }
1207 opportunistic_dsts.insert(dst.clone());
1208 additions.push(FetchRefUpdate {
1209 src: update.src.clone(),
1210 dst: Some(dst),
1211 oid: update.oid,
1212 not_for_merge: true,
1213 force: refspec.force,
1214 });
1215 }
1216 }
1217 updates.extend(additions);
1218 Ok(opportunistic_dsts)
1219}
1220
1221fn advertisements_without_peeled_refs(
1222 advertisements: &[RefAdvertisement],
1223) -> Vec<RefAdvertisement> {
1224 advertisements
1225 .iter()
1226 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1227 .cloned()
1228 .collect()
1229}
1230
1231fn append_missing_ext_advertised_tags(
1232 advertisements: &[RefAdvertisement],
1233 refspecs: &[RefSpec],
1234 store: &FileRefStore,
1235 updates: &mut Vec<FetchRefUpdate>,
1236) -> Result<()> {
1237 let mut seen = updates
1238 .iter()
1239 .map(|update| update.src.clone())
1240 .collect::<HashSet<_>>();
1241 let mut tags = Vec::new();
1242 for reference in advertisements {
1243 if !reference.name.starts_with("refs/tags/")
1244 || reference.name.ends_with("^{}")
1245 || !seen.insert(reference.name.clone())
1246 || fetch_refspec_excludes(refspecs, &reference.name)?
1247 || store.read_ref(&reference.name)?.is_some()
1248 {
1249 continue;
1250 }
1251 tags.push(FetchRefUpdate {
1252 src: reference.name.clone(),
1253 dst: Some(reference.name.clone()),
1254 oid: reference.oid,
1255 not_for_merge: true,
1256 force: false,
1257 });
1258 }
1259 tags.sort_by(|a, b| a.src.cmp(&b.src));
1260 updates.extend(tags);
1261 Ok(())
1262}
1263
1264struct FetchFinalize<'a> {
1268 git_dir: &'a Path,
1269 format: ObjectFormat,
1270 store: &'a FileRefStore,
1271 options: &'a FetchOptions,
1272 fetch_head_source: &'a str,
1273 default_head_fetch: bool,
1274 log_all_ref_updates: bool,
1275 ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
1276 opportunistic_dsts: &'a HashSet<String>,
1279}
1280
1281fn downgrade_non_commit_for_merge(
1287 git_dir: &Path,
1288 format: ObjectFormat,
1289 updates: &mut [FetchRefUpdate],
1290) {
1291 if updates.iter().all(|update| update.not_for_merge) {
1292 return;
1293 }
1294 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1295 for update in updates.iter_mut() {
1296 if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
1297 update.not_for_merge = true;
1298 }
1299 }
1300}
1301
1302fn finalize_fetch(
1303 finalize: FetchFinalize<'_>,
1304 updates: &mut Vec<FetchRefUpdate>,
1305 outcome: &mut FetchOutcome,
1306) -> Result<()> {
1307 let FetchFinalize {
1308 git_dir,
1309 format,
1310 store,
1311 options,
1312 fetch_head_source,
1313 default_head_fetch,
1314 log_all_ref_updates,
1315 ref_hook,
1316 opportunistic_dsts,
1317 } = finalize;
1318 if options.dry_run {
1319 outcome.ref_updates = std::mem::take(updates);
1320 return Ok(());
1321 }
1322 downgrade_non_commit_for_merge(git_dir, format, updates);
1323 validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1324 if options.atomic {
1325 if options.write_fetch_head && !options.append {
1333 fs::write(git_dir.join("FETCH_HEAD"), b"")?;
1334 }
1335 if let Some(reason) = atomic_non_fast_forward_rejection(git_dir, format, store, updates)? {
1336 return Err(GitError::Command(reason));
1337 }
1338 apply_fetch_ref_updates(
1339 store,
1340 format,
1341 fetch_head_source,
1342 log_all_ref_updates,
1343 updates,
1344 ref_hook,
1345 )?;
1346 if options.write_fetch_head {
1347 write_finalized_fetch_head(
1350 git_dir,
1351 fetch_head_source,
1352 default_head_fetch,
1353 updates,
1354 opportunistic_dsts,
1355 true,
1356 )?;
1357 outcome.wrote_fetch_head = true;
1358 }
1359 outcome.ref_updates = std::mem::take(updates);
1360 return Ok(());
1361 }
1362 if options.write_fetch_head {
1363 write_finalized_fetch_head(
1364 git_dir,
1365 fetch_head_source,
1366 default_head_fetch,
1367 updates,
1368 opportunistic_dsts,
1369 options.append,
1370 )?;
1371 outcome.wrote_fetch_head = true;
1372 }
1373 apply_fetch_ref_updates(
1374 store,
1375 format,
1376 fetch_head_source,
1377 log_all_ref_updates,
1378 updates,
1379 ref_hook,
1380 )?;
1381 outcome.ref_updates = std::mem::take(updates);
1382 Ok(())
1383}
1384
1385fn write_finalized_fetch_head(
1390 git_dir: &Path,
1391 fetch_head_source: &str,
1392 default_head_fetch: bool,
1393 updates: &[FetchRefUpdate],
1394 opportunistic_dsts: &HashSet<String>,
1395 append: bool,
1396) -> Result<()> {
1397 if default_head_fetch
1398 && updates.len() == 1
1399 && updates[0].src == "HEAD"
1400 && updates[0].dst.is_none()
1401 {
1402 return write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, append);
1403 }
1404 let records: Vec<FetchRefUpdate> = updates
1405 .iter()
1406 .filter(|update| {
1407 update
1408 .dst
1409 .as_deref()
1410 .is_none_or(|dst| !opportunistic_dsts.contains(dst))
1411 })
1412 .cloned()
1413 .collect();
1414 write_fetch_head(git_dir, fetch_head_source, &records, append)
1415}
1416
1417fn atomic_non_fast_forward_rejection(
1422 git_dir: &Path,
1423 format: ObjectFormat,
1424 store: &FileRefStore,
1425 updates: &[FetchRefUpdate],
1426) -> Result<Option<String>> {
1427 let mut db: Option<FileObjectDatabase> = None;
1428 for update in updates {
1429 let Some(dst) = update.dst.as_deref() else {
1430 continue;
1431 };
1432 if update.force {
1433 continue;
1434 }
1435 let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
1436 continue;
1437 };
1438 if old == update.oid || dst.starts_with("refs/tags/") {
1439 continue;
1440 }
1441 let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
1442 if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
1443 return Ok(Some(format!(
1444 "! [rejected] {} -> {} (non-fast-forward)",
1445 update.src, dst
1446 )));
1447 }
1448 }
1449 Ok(None)
1450}
1451
1452fn apply_fetch_ref_updates(
1453 store: &FileRefStore,
1454 format: ObjectFormat,
1455 fetch_head_source: &str,
1456 log_all_ref_updates: bool,
1457 updates: &[FetchRefUpdate],
1458 ref_hook: Option<&dyn sley_refs::ReferenceTransactionHook>,
1459) -> Result<()> {
1460 let mut seen = BTreeSet::new();
1461 let mut tx = store.transaction();
1462 if let Some(hook) = ref_hook {
1463 tx = tx.with_hook(hook);
1464 }
1465 for update in updates {
1466 let Some(dst) = update.dst.as_deref() else {
1467 continue;
1468 };
1469 if !seen.insert(dst.to_string()) {
1470 return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
1471 }
1472 let old_oid = match store.read_ref(dst)? {
1473 Some(RefTarget::Direct(oid)) => Some(oid),
1474 Some(RefTarget::Symbolic(target)) => {
1475 return Err(GitError::Transaction(format!(
1476 "fetch ref {dst} would overwrite symbolic ref {target}"
1477 )));
1478 }
1479 None => None,
1480 };
1481 let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
1482 Some(ReflogEntry {
1483 old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
1484 new_oid: update.oid,
1485 committer: fetch_reflog_committer(),
1486 message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
1487 })
1488 } else {
1489 None
1490 };
1491 tx.update(RefUpdate {
1492 name: dst.to_string(),
1493 expected: old_oid.map(RefTarget::Direct),
1494 new: RefTarget::Direct(update.oid),
1495 reflog,
1496 });
1497 }
1498 tx.commit()
1499}
1500
1501pub fn fetch_max_input_size(config: &GitConfig) -> Option<u64> {
1504 config_max_input_size(config, "fetch", "maxInputSize")
1505 .or_else(|| config_max_input_size(config, "transfer", "maxSize"))
1506}
1507
1508fn config_max_input_size(config: &GitConfig, section: &str, key: &str) -> Option<u64> {
1509 let raw = config.get(section, None, key)?;
1510 match sley_config::parse_config_int(raw) {
1511 Some(limit) if limit > 0 => Some(limit as u64),
1512 _ => None,
1513 }
1514}
1515
1516fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
1517 match config.get("core", None, "logallrefupdates") {
1518 Some(value) => {
1519 let value = value.to_ascii_lowercase();
1520 matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
1521 }
1522 None => false,
1523 }
1524}
1525
1526fn fetch_should_write_reflog(refname: &str) -> bool {
1527 refname == "HEAD"
1528 || refname.starts_with("refs/heads/")
1529 || refname.starts_with("refs/remotes/")
1530 || refname.starts_with("refs/notes/")
1531}
1532
1533fn fetch_reflog_committer() -> Vec<u8> {
1534 let seconds = SystemTime::now()
1535 .duration_since(UNIX_EPOCH)
1536 .map(|duration| duration.as_secs())
1537 .unwrap_or(0);
1538 format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
1539}
1540
1541fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
1542 let src = fetch_reflog_short_ref(&update.src);
1543 let dst = update
1544 .dst
1545 .as_deref()
1546 .map(fetch_reflog_short_ref)
1547 .unwrap_or_else(|| update.src.clone());
1548 let action = if !old_exists {
1549 if update.src.starts_with("refs/tags/") {
1550 "storing tag"
1551 } else if update.src.starts_with("refs/heads/") {
1552 "storing head"
1553 } else {
1554 "storing ref"
1555 }
1556 } else if update.force {
1557 "forced-update"
1558 } else if update.src.starts_with("refs/tags/") {
1559 "updating tag"
1560 } else {
1561 "fast-forward"
1562 };
1563 format!("fetch {source} {src}:{dst}: {action}").into_bytes()
1564}
1565
1566fn fetch_reflog_short_ref(refname: &str) -> String {
1567 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
1568 if let Some(short) = refname.strip_prefix(prefix) {
1569 return short.to_string();
1570 }
1571 }
1572 refname.to_string()
1573}
1574
1575fn validate_fetch_ref_updates(
1576 git_dir: &Path,
1577 _format: ObjectFormat,
1578 store: &FileRefStore,
1579 update_head_ok: bool,
1580 updates: &[FetchRefUpdate],
1581) -> Result<()> {
1582 for update in updates {
1583 let Some(dst) = update.dst.as_deref() else {
1584 continue;
1585 };
1586 let old = match store.read_ref(dst)? {
1587 Some(RefTarget::Direct(oid)) => Some(oid),
1588 Some(RefTarget::Symbolic(target)) => {
1589 return Err(GitError::Transaction(format!(
1590 "ref {dst} would overwrite symbolic ref {target}"
1591 )));
1592 }
1593 None => None,
1594 };
1595 if old.is_some()
1596 && !update_head_ok
1597 && dst.starts_with("refs/heads/")
1598 && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
1599 {
1600 return Err(GitError::InvalidFormat(format!(
1601 "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
1602 worktree.path.display()
1603 )));
1604 }
1605 if old.is_some()
1606 && old != Some(update.oid)
1607 && dst.starts_with("refs/tags/")
1608 && !update.force
1609 {
1610 return Err(GitError::Command(format!(
1611 "! [rejected] {} -> {} (would clobber existing tag)",
1612 update.src, dst
1613 )));
1614 }
1615 }
1616 Ok(())
1617}
1618
1619fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
1621 symrefs
1622 .iter()
1623 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
1624}
1625
1626fn reject_shallow_clone_fetch(
1627 options: &FetchOptions,
1628 shallow_info: &[sley_protocol::ProtocolV2FetchShallowInfo],
1629) -> Result<()> {
1630 let deepening = options.depth.is_some()
1635 || options.deepen_since.is_some()
1636 || !options.deepen_not.is_empty();
1637 if options.reject_shallow && options.cloning && !deepening && !shallow_info.is_empty() {
1638 eprintln!("fatal: source repository is shallow, reject to clone.");
1639 return Err(GitError::Exit(128));
1640 }
1641 Ok(())
1642}
1643
1644fn custom_negotiation_haves(
1645 git_dir: &Path,
1646 format: ObjectFormat,
1647 config: &GitConfig,
1648 remote: &str,
1649 options: &FetchOptions,
1650) -> Result<Option<Vec<ObjectId>>> {
1651 let restrict = match &options.negotiation_restrict {
1652 Some(values) => values.clone(),
1653 None => configured_negotiation_values(config, remote, "negotiationrestrict"),
1654 };
1655 let include = match &options.negotiation_include {
1656 Some(values) => values.clone(),
1657 None => configured_negotiation_values(config, remote, "negotiationinclude"),
1658 };
1659 if restrict.is_empty() && include.is_empty() {
1660 return Ok(None);
1661 }
1662
1663 let store = FileRefStore::new(git_dir, format);
1664 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1665 let mut seen = HashSet::new();
1666 let mut haves = Vec::new();
1667 if restrict.is_empty() {
1668 for oid in crate::local::local_have_oids(git_dir, format)? {
1669 push_have_oid(&mut haves, &mut seen, oid);
1670 }
1671 } else {
1672 for value in &restrict {
1673 for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
1674 push_have_oid(&mut haves, &mut seen, oid);
1675 }
1676 }
1677 }
1678 for value in &include {
1679 for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
1680 push_have_oid(&mut haves, &mut seen, oid);
1681 }
1682 }
1683 Ok(Some(haves))
1684}
1685
1686fn configured_negotiation_values(config: &GitConfig, remote: &str, key: &str) -> Vec<String> {
1687 let mut values = Vec::new();
1688 if !remote_exists(config, remote) {
1689 return values;
1690 }
1691 for value in remote_config_values(config, remote, key) {
1692 if value.is_empty() {
1693 values.clear();
1694 } else {
1695 values.push(value);
1696 }
1697 }
1698 values
1699}
1700
1701fn push_have_oid(haves: &mut Vec<ObjectId>, seen: &mut HashSet<ObjectId>, oid: ObjectId) {
1702 if seen.insert(oid) {
1703 haves.push(oid);
1704 }
1705}
1706
1707fn resolve_negotiation_have_value(
1708 git_dir: &Path,
1709 format: ObjectFormat,
1710 store: &FileRefStore,
1711 db: &FileObjectDatabase,
1712 value: &str,
1713) -> Result<Vec<ObjectId>> {
1714 if has_glob(value) {
1715 let mut out = Vec::new();
1716 for reference in store.list_refs()? {
1717 let RefTarget::Direct(oid) = reference.target else {
1718 continue;
1719 };
1720 if negotiation_pattern_matches(value, &reference.name) {
1721 out.push(oid);
1722 }
1723 }
1724 out.sort();
1725 out.dedup();
1726 return Ok(out);
1727 }
1728 if is_full_hex_oid(format, value) {
1729 let oid = ObjectId::from_hex(format, value)?;
1730 return if db.contains(&oid)? {
1731 Ok(vec![oid])
1732 } else {
1733 Err(GitError::InvalidFormat(format!(
1734 "fatal: the object {oid} does not exist"
1735 )))
1736 };
1737 }
1738 match sley_rev::resolve_revision(git_dir, format, value) {
1739 Ok(oid) => Ok(vec![oid]),
1740 Err(_) => Ok(Vec::new()),
1741 }
1742}
1743
1744fn has_glob(value: &str) -> bool {
1745 value.as_bytes().iter().any(|byte| matches!(byte, b'*' | b'?'))
1746}
1747
1748fn is_full_hex_oid(format: ObjectFormat, value: &str) -> bool {
1749 value.len() == format.hex_len() && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
1750}
1751
1752fn negotiation_pattern_matches(pattern: &str, refname: &str) -> bool {
1753 glob_match(pattern, refname)
1754 || ["refs/heads/", "refs/tags/", "refs/remotes/"]
1755 .iter()
1756 .filter_map(|prefix| refname.strip_prefix(prefix))
1757 .any(|short| glob_match(pattern, short))
1758}
1759
1760fn glob_match(pattern: &str, text: &str) -> bool {
1761 glob_match_bytes(pattern.as_bytes(), text.as_bytes())
1762}
1763
1764fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
1765 let (mut p, mut t) = (0, 0);
1766 let mut star = None;
1767 let mut match_after_star = 0;
1768 while t < text.len() {
1769 if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
1770 p += 1;
1771 t += 1;
1772 } else if p < pattern.len() && pattern[p] == b'*' {
1773 star = Some(p);
1774 p += 1;
1775 match_after_star = t;
1776 } else if let Some(star_pos) = star {
1777 p = star_pos + 1;
1778 match_after_star += 1;
1779 t = match_after_star;
1780 } else {
1781 return false;
1782 }
1783 }
1784 while p < pattern.len() && pattern[p] == b'*' {
1785 p += 1;
1786 }
1787 p == pattern.len()
1788}
1789
1790pub fn apply_configured_partial_clone_filter(
1792 config: &GitConfig,
1793 remote: &str,
1794 options: &mut FetchOptions,
1795) {
1796 if config
1797 .get_bool("remote", Some(remote), "promisor")
1798 .unwrap_or(false)
1799 && let Some(filter) = config.get("remote", Some(remote), "partialclonefilter")
1800 {
1801 options.filter = crate::pack_filter_from_spec(filter);
1802 }
1803}
1804
1805pub fn apply_configured_remote_tag_option(
1808 config: &GitConfig,
1809 source: &str,
1810 options: &mut FetchOptions,
1811) {
1812 if options.tag_option_explicit || !remote_exists(config, source) {
1813 return;
1814 }
1815 match remote_config_values(config, source, "tagopt")
1816 .into_iter()
1817 .last()
1818 .as_deref()
1819 {
1820 Some("--tags") => {
1821 options.auto_follow_tags = true;
1822 options.fetch_all_tags = true;
1823 }
1824 Some("--no-tags") => {
1825 options.auto_follow_tags = false;
1826 options.fetch_all_tags = false;
1827 }
1828 _ => {}
1829 }
1830}
1831
1832pub fn apply_configured_fetch_prune_option(
1835 config: &GitConfig,
1836 source: &str,
1837 options: &mut FetchOptions,
1838) {
1839 if !options.prune_option_explicit {
1840 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
1841 options.prune = prune;
1842 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
1843 options.prune = prune;
1844 }
1845 }
1846 if !options.prune_tags_option_explicit {
1847 if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
1848 options.prune_tags = prune_tags;
1849 } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
1850 options.prune_tags = prune_tags;
1851 }
1852 }
1853}
1854
1855pub fn fetch_refspecs_for_source(
1859 configured: Vec<String>,
1860 refspecs: &[String],
1861 fetch_all_tags: bool,
1862) -> Vec<String> {
1863 let mut effective = if !refspecs.is_empty() {
1864 refspecs.to_vec()
1865 } else if configured.is_empty() {
1866 vec!["HEAD".to_string()]
1867 } else {
1868 configured
1869 };
1870 if fetch_all_tags {
1871 effective.push("refs/tags/*:refs/tags/*".to_string());
1872 }
1873 effective
1874}
1875
1876fn prune_refspecs_for_source(
1877 configured: &[String],
1878 refspecs: &[String],
1879 prune_tags: bool,
1880) -> Vec<String> {
1881 let mut effective = if !refspecs.is_empty() {
1882 refspecs.to_vec()
1883 } else {
1884 configured.to_vec()
1885 };
1886 if prune_tags && refspecs.is_empty() {
1887 effective.push("refs/tags/*:refs/tags/*".to_string());
1888 }
1889 effective
1890}
1891
1892fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
1897 if refspec.pattern {
1898 let Some((prefix, suffix)) = src.split_once('*') else {
1899 return false;
1900 };
1901 let fits = |name: &str| {
1906 name.len() >= prefix.len() + suffix.len()
1907 && name.starts_with(prefix)
1908 && name.ends_with(suffix)
1909 };
1910 fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
1911 } else {
1912 refname_matches(merge_src, src) || refname_matches(src, merge_src)
1913 }
1914}
1915
1916pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
1918 for update in updates {
1919 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
1920 update.not_for_merge = true;
1921 }
1922 }
1923}
1924
1925pub fn retain_missing_auto_follow_tags(
1927 store: &FileRefStore,
1928 updates: &mut Vec<FetchRefUpdate>,
1929) -> Result<()> {
1930 let mut retained = Vec::with_capacity(updates.len());
1931 for update in updates.drain(..) {
1932 if update.not_for_merge
1933 && update.src.starts_with("refs/tags/")
1934 && update.dst.as_deref() == Some(&update.src)
1935 && store.read_ref(&update.src)?.is_some()
1936 {
1937 continue;
1938 }
1939 retained.push(update);
1940 }
1941 *updates = retained;
1942 Ok(())
1943}
1944
1945pub fn append_reachable_auto_follow_tags(
1948 advertisements: &[RefAdvertisement],
1949 remote_db: &FileObjectDatabase,
1950 local_db: Option<&FileObjectDatabase>,
1951 format: ObjectFormat,
1952 refspecs: &[RefSpec],
1953 updates: &mut Vec<FetchRefUpdate>,
1954 deepen_excluded: Option<&HashSet<ObjectId>>,
1955) -> Result<()> {
1956 if !updates.iter().any(|update| update.dst.is_some()) {
1957 return Ok(());
1958 }
1959 updates.retain(|update| {
1964 !(update.src.starts_with("refs/tags/")
1965 && update.dst.as_deref() == Some(update.src.as_str())
1966 && update.not_for_merge)
1967 });
1968 let mut starts = Vec::new();
1973 for update in updates.iter().filter(|update| update.dst.is_some()) {
1974 if update.src.starts_with("refs/tags/") {
1975 if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1976 starts.push(target);
1977 } else {
1978 starts.push(update.oid);
1979 }
1980 } else {
1981 starts.push(update.oid);
1982 }
1983 }
1984 let reachable = match deepen_excluded {
1988 Some(excluded) => {
1989 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1990 }
1991 None => collect_reachable_object_ids(remote_db, format, starts)?,
1992 };
1993 let fetched_srcs = updates
1994 .iter()
1995 .map(|update| update.src.clone())
1996 .collect::<HashSet<_>>();
1997 let mut followed = Vec::new();
1998 for reference in advertisements {
1999 if !reference.name.starts_with("refs/tags/")
2000 || fetched_srcs.contains(&reference.name)
2001 || fetch_refspec_excludes(refspecs, &reference.name)?
2002 {
2003 continue;
2004 }
2005 let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
2013 let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
2014 let present_locally = local_db
2015 .map(|db| db.contains(&target))
2016 .transpose()?
2017 .unwrap_or(false);
2018 if !fetched && !present_locally {
2019 continue;
2020 }
2021 followed.push(FetchRefUpdate {
2022 src: reference.name.clone(),
2023 dst: Some(reference.name.clone()),
2024 oid: reference.oid,
2025 not_for_merge: true,
2026 force: false,
2027 });
2028 }
2029 followed.sort_by(|a, b| a.src.cmp(&b.src));
2030 updates.extend(followed);
2031 Ok(())
2032}
2033
2034fn peel_tag_target(
2039 db: &FileObjectDatabase,
2040 format: ObjectFormat,
2041 oid: &ObjectId,
2042) -> Result<Option<ObjectId>> {
2043 let mut current = *oid;
2044 let mut peeled = None;
2045 loop {
2046 let Ok(object) = db.read_object(¤t) else {
2047 return Ok(peeled);
2048 };
2049 if object.object_type != sley_object::ObjectType::Tag {
2050 return Ok(peeled);
2051 }
2052 let tag = sley_object::Tag::parse(format, &object.body)?;
2053 current = tag.object;
2054 peeled = Some(current);
2055 }
2056}
2057
2058pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
2060 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
2061 if refspec.pattern {
2062 if refspec_map_source(refspec, name)?.is_some() {
2063 return Ok(true);
2064 }
2065 } else if refspec.src.as_deref() == Some(name) {
2066 return Ok(true);
2067 }
2068 }
2069 Ok(false)
2070}
2071
2072pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
2075 let followed_oids = updates
2076 .iter()
2077 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
2078 .map(|update| update.oid)
2079 .collect::<HashSet<_>>();
2080 if followed_oids.is_empty() {
2081 return;
2082 }
2083
2084 let mut non_tags = Vec::new();
2085 let mut followed_tags = Vec::new();
2086 let mut other_tags = Vec::new();
2087 for update in updates.drain(..) {
2088 if update.src.starts_with("refs/tags/") {
2089 if followed_oids.contains(&update.oid) {
2090 followed_tags.push(update);
2091 } else {
2092 other_tags.push(update);
2093 }
2094 } else {
2095 non_tags.push(update);
2096 }
2097 }
2098 updates.extend(non_tags);
2099 updates.extend(followed_tags);
2100 updates.extend(other_tags);
2101}
2102
2103pub fn write_default_fetch_head(
2105 git_dir: &Path,
2106 source: &str,
2107 oid: ObjectId,
2108 append: bool,
2109) -> Result<()> {
2110 let records = [FetchHeadRecord {
2111 oid,
2112 not_for_merge: false,
2113 description: source.to_string(),
2114 }];
2115 write_fetch_head_records(git_dir, &records, append)?;
2116 Ok(())
2117}
2118
2119pub fn write_fetch_head_records(
2121 git_dir: &Path,
2122 records: &[FetchHeadRecord],
2123 append: bool,
2124) -> Result<()> {
2125 let encoded = encode_fetch_head(records)?;
2126 if append {
2127 let mut file = fs::OpenOptions::new()
2128 .create(true)
2129 .append(true)
2130 .open(git_dir.join("FETCH_HEAD"))?;
2131 file.write_all(&encoded)?;
2132 } else {
2133 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
2134 }
2135 Ok(())
2136}
2137
2138pub fn write_fetch_head(
2140 git_dir: &Path,
2141 description: &str,
2142 fetched: &[FetchRefUpdate],
2143 append: bool,
2144) -> Result<()> {
2145 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
2146 write_fetch_head_records(git_dir, &records, append)?;
2147 Ok(())
2148}
2149
2150pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
2153 let url = remote_config_values(config, source, "url")
2154 .into_iter()
2155 .next()
2156 .map(|url| rewrite_url_with_config(config, &url, false))
2157 .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
2158 redact_url_for_display(&trim_fetch_head_display_url(&url))
2159}
2160
2161fn trim_fetch_head_display_url(url: &str) -> String {
2165 let bytes = url.as_bytes();
2166 let mut end = bytes.len();
2167 while end > 0 && bytes[end - 1] == b'/' {
2168 end -= 1;
2169 }
2170 if end > 5 && &bytes[end - 4..end] == b".git" {
2173 end -= 4;
2174 }
2175 String::from_utf8_lossy(&bytes[..end]).into_owned()
2176}
2177
2178pub struct PruneRefsInput<'a> {
2183 pub config: &'a GitConfig,
2184 pub store: &'a FileRefStore,
2185 pub remote: &'a str,
2186 pub advertisements: &'a [RefAdvertisement],
2187 pub refspecs: &'a [RefSpec],
2188 pub dry_run: bool,
2189 pub quiet: bool,
2190}
2191
2192pub fn prune_refs_from_advertisements(
2193 input: PruneRefsInput<'_>,
2194 progress: &mut dyn ProgressSink,
2195) -> Result<Vec<PrunedRef>> {
2196 let remote_refs = input
2197 .advertisements
2198 .iter()
2199 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
2200 .map(|advertisement| advertisement.name.as_str())
2201 .collect::<BTreeSet<_>>();
2202 let local_refs = input.store.list_refs()?;
2203 let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
2204 if stale_refs.is_empty() {
2205 return Ok(Vec::new());
2206 }
2207 let mut emit = |line: &str| {
2208 if !input.quiet {
2209 progress.message(line);
2210 }
2211 };
2212 let display_url = redact_url_for_display(
2213 &remote_config_values(input.config, input.remote, "url")
2214 .into_iter()
2215 .next()
2216 .unwrap_or_else(|| input.remote.into()),
2217 );
2218 emit(&format!("Pruning {}", input.remote));
2219 emit(&format!("URL: {display_url}"));
2220 let mut pruned = Vec::new();
2221 for refname in stale_refs {
2222 if !input.dry_run {
2223 match input.store.read_ref(&refname)? {
2224 Some(RefTarget::Symbolic(_)) => {
2225 let _ = input.store.delete_symbolic_ref(&refname)?;
2226 }
2227 Some(RefTarget::Direct(_)) => {
2228 let _ = input.store.delete_ref(&refname)?;
2229 }
2230 None => {}
2231 }
2232 }
2233 let display = prettify_pruned_ref(input.remote, &refname);
2234 let action = if input.dry_run {
2235 "would prune"
2236 } else {
2237 "pruned"
2238 };
2239 emit(&format!(" * [{action}] {display}"));
2240 let branch = display;
2241 pruned.push(PrunedRef { branch, refname });
2242 }
2243 Ok(pruned)
2244}
2245
2246fn stale_refs_for_prune(
2247 local_refs: &[Ref],
2248 refspecs: &[RefSpec],
2249 remote_refs: &BTreeSet<&str>,
2250) -> Result<Vec<String>> {
2251 let mut stale = Vec::new();
2252 for reference in local_refs {
2253 if matches!(reference.target, RefTarget::Symbolic(_)) {
2254 continue;
2255 }
2256 let sources = prune_sources_for_destination(refspecs, &reference.name)?;
2257 if sources.is_empty() {
2258 continue;
2259 }
2260 if sources
2261 .iter()
2262 .all(|source| !remote_refs.contains(source.as_str()))
2263 {
2264 stale.push(reference.name.clone());
2265 }
2266 }
2267 stale.sort();
2268 Ok(stale)
2269}
2270
2271fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
2272 let mut sources = Vec::new();
2273 for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
2274 let Some(src) = refspec.src.as_deref() else {
2275 continue;
2276 };
2277 let Some(dst) = refspec.dst.as_deref() else {
2278 continue;
2279 };
2280 if refspec.pattern {
2281 let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
2282 continue;
2283 };
2284 let Some(middle) = destination
2285 .strip_prefix(dst_prefix)
2286 .and_then(|value| value.strip_suffix(dst_suffix))
2287 else {
2288 continue;
2289 };
2290 let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
2291 GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
2292 })?;
2293 sources.push(format!("{src_prefix}{middle}{src_suffix}"));
2294 } else if dst == destination {
2295 sources.push(src.to_string());
2296 }
2297 }
2298 sources.sort();
2299 sources.dedup();
2300 Ok(sources)
2301}
2302
2303fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
2304 if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
2305 return format!("{remote}/{branch}");
2306 }
2307 if let Some(tag) = refname.strip_prefix("refs/tags/") {
2308 return tag.to_string();
2309 }
2310 refname.to_string()
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315 use super::*;
2316 use std::sync::atomic::{AtomicU64, Ordering};
2317
2318 use sley_config::{ConfigEntry, ConfigSection};
2319 use sley_formats::RepositoryLayout;
2320 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2321 use sley_odb::{FileObjectDatabase, ObjectWriter};
2322 use sley_refs::{RefTarget, RefUpdate};
2323
2324 use crate::{NoCredentials, SilentProgress};
2325
2326 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2327
2328 fn temp_repo(name: &str) -> PathBuf {
2329 let dir = std::env::temp_dir().join(format!(
2330 "sley-remote-fetch-{name}-{}-{}",
2331 std::process::id(),
2332 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2333 ));
2334 let _ = fs::remove_dir_all(&dir);
2335 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2336 .expect("test repository should initialize");
2337 dir.join(".git")
2338 }
2339
2340 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
2341 let format = ObjectFormat::Sha1;
2342 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2343 let tree = db
2344 .write_object(EncodedObject::new(
2345 ObjectType::Tree,
2346 Tree { entries: vec![] }.write(),
2347 ))
2348 .expect("tree should write");
2349 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2350 let oid = db
2351 .write_object(EncodedObject::new(
2352 ObjectType::Commit,
2353 Commit {
2354 tree,
2355 parents: Vec::new(),
2356 author: identity.clone(),
2357 committer: identity,
2358 encoding: None,
2359 message: format!("{message}\n").into_bytes(),
2360 }
2361 .write(),
2362 ))
2363 .expect("commit should write");
2364 let store = FileRefStore::new(git_dir, format);
2365 let mut tx = store.transaction();
2366 tx.update(RefUpdate {
2367 name: format!("refs/heads/{branch}"),
2368 expected: None,
2369 new: RefTarget::Direct(oid),
2370 reflog: None,
2371 });
2372 tx.update(RefUpdate {
2373 name: "HEAD".into(),
2374 expected: None,
2375 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
2376 reflog: None,
2377 });
2378 tx.commit().expect("refs should update");
2379 oid
2380 }
2381
2382 fn default_options() -> FetchOptions {
2383 FetchOptions {
2384 quiet: true,
2385 auto_follow_tags: false,
2386 fetch_all_tags: false,
2387 prune: false,
2388 prune_tags: false,
2389 dry_run: false,
2390 force: false,
2391 append: false,
2392 write_fetch_head: true,
2393 tag_option_explicit: true,
2394 prune_option_explicit: true,
2395 prune_tags_option_explicit: true,
2396 refmap: None,
2397 depth: None,
2398 merge_srcs: Vec::new(),
2399 filter: None,
2400 refetch: false,
2401 cloning: false,
2402 record_promisor_refs: true,
2403 update_shallow: false,
2404 reject_shallow: false,
2405 deepen_relative: false,
2406 update_head_ok: false,
2407 deepen_since: None,
2408 deepen_not: Vec::new(),
2409 ssh_options: None,
2410 atomic: false,
2411 negotiation_restrict: None,
2412 negotiation_include: None,
2413 }
2414 }
2415
2416 #[test]
2417 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
2418 let remote = temp_repo("remote");
2419 let local = temp_repo("local");
2420 let tip = commit_on(&remote, "main", "remote tip");
2421 let source = FetchSource::Local {
2422 git_dir: remote.clone(),
2423 common_git_dir: remote.clone(),
2424 };
2425 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
2426 let options = default_options();
2427 let mut credentials = NoCredentials;
2428 let mut progress = SilentProgress;
2429
2430 let outcome = fetch(
2431 FetchRequest {
2432 git_dir: &local,
2433 format: ObjectFormat::Sha1,
2434 config: &GitConfig::default(),
2435 remote_name: "origin",
2436 source: &source,
2437 refspecs: &refspecs,
2438 options: &options,
2439 },
2440 FetchServices {
2441 credentials: &mut credentials,
2442 progress: &mut progress,
2443 ref_hook: None,
2444 },
2445 )
2446 .expect("fetch should succeed");
2447
2448 assert_eq!(outcome.ref_updates.len(), 1);
2449 assert!(outcome.wrote_fetch_head);
2450 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
2451 assert!(local_db.contains(&tip).expect("contains should read"));
2452 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2453 assert_eq!(
2454 local_refs
2455 .read_ref("refs/remotes/origin/main")
2456 .expect("ref should read"),
2457 Some(RefTarget::Direct(tip))
2458 );
2459 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
2460 assert!(fetch_head.contains("origin"));
2461 }
2462
2463 struct RecordingHttpClient {
2468 advertisement: Vec<u8>,
2469 content_type: String,
2470 get_calls: std::sync::atomic::AtomicUsize,
2471 post_calls: std::sync::atomic::AtomicUsize,
2472 }
2473
2474 impl HttpClient for RecordingHttpClient {
2475 fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<sley_transport::HttpResponse> {
2476 self.get_calls
2477 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2478 Ok(sley_transport::HttpResponse {
2479 status: 200,
2480 content_type: Some(self.content_type.clone()),
2481 body: Box::new(std::io::Cursor::new(self.advertisement.clone())),
2482 })
2483 }
2484
2485 fn post(
2486 &self,
2487 _url: &str,
2488 _content_type: &str,
2489 _headers: &[(&str, &str)],
2490 _body: &[u8],
2491 ) -> Result<sley_transport::HttpResponse> {
2492 self.post_calls
2493 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2494 Err(GitError::Command(
2495 "recording client received an unexpected POST".into(),
2496 ))
2497 }
2498 }
2499
2500 #[test]
2501 fn fetch_with_http_client_dials_injected_client() {
2502 use sley_protocol::{
2503 smart_http_advertisement_content_type, GitService, ProtocolVersion, RefAdvertisement,
2504 RefAdvertisementSet,
2505 };
2506 use sley_transport::{
2507 parse_remote_url, write_service_discovery_response, ServiceAnnouncement,
2508 ServiceDiscoveryPayload, ServiceDiscoveryResponse,
2509 };
2510
2511 let local = temp_repo("http-inject");
2512 let tip = commit_on(&local, "main", "already-present tip");
2516
2517 let advertisement = {
2519 let response = ServiceDiscoveryResponse {
2520 announcement: ServiceAnnouncement {
2521 service: GitService::UploadPack,
2522 },
2523 payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
2524 protocol: ProtocolVersion::V0,
2525 refs: vec![RefAdvertisement {
2526 oid: tip.clone(),
2527 name: "refs/heads/main".into(),
2528 capabilities: Vec::new(),
2529 }],
2530 shallow: Vec::new(),
2531 }),
2532 };
2533 let mut body = Vec::new();
2534 write_service_discovery_response(&mut body, &response)
2535 .expect("advertisement should encode");
2536 body
2537 };
2538
2539 let client = RecordingHttpClient {
2540 advertisement,
2541 content_type: smart_http_advertisement_content_type(GitService::UploadPack)
2542 .expect("content type"),
2543 get_calls: std::sync::atomic::AtomicUsize::new(0),
2544 post_calls: std::sync::atomic::AtomicUsize::new(0),
2545 };
2546
2547 let source =
2548 FetchSource::Http(parse_remote_url("http://example.invalid/repo.git").expect("url"));
2549 let options = default_options();
2550 let mut credentials = NoCredentials;
2551 let mut progress = SilentProgress;
2552
2553 let outcome = fetch_with_http_client(
2554 FetchRequest {
2555 git_dir: &local,
2556 format: ObjectFormat::Sha1,
2557 config: &GitConfig::default(),
2558 remote_name: "origin",
2559 source: &source,
2560 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2561 options: &options,
2562 },
2563 FetchServices {
2564 credentials: &mut credentials,
2565 progress: &mut progress,
2566 ref_hook: None,
2567 },
2568 Some(&client),
2569 )
2570 .expect("fetch via injected client should succeed");
2571
2572 assert_eq!(
2575 client.get_calls.load(std::sync::atomic::Ordering::Relaxed),
2576 1,
2577 "sley must dial through the injected client"
2578 );
2579 assert_eq!(
2580 client.post_calls.load(std::sync::atomic::Ordering::Relaxed),
2581 0,
2582 "no pack POST expected when the want is already present"
2583 );
2584 assert_eq!(outcome.ref_updates.len(), 1);
2586 let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2587 assert_eq!(
2588 refs.read_ref("refs/remotes/origin/main")
2589 .expect("ref should read"),
2590 Some(RefTarget::Direct(tip))
2591 );
2592 }
2593
2594 #[test]
2595 fn shallow_local_fetch_writes_depth_boundary_metadata() {
2596 let remote = temp_repo("remote-shallow");
2597 let local = temp_repo("local-shallow");
2598 let tip = commit_on(&remote, "main", "tip");
2599 let source = FetchSource::Local {
2600 git_dir: remote.clone(),
2601 common_git_dir: remote.clone(),
2602 };
2603 let mut options = default_options();
2604 options.depth = Some(1);
2605 let mut credentials = NoCredentials;
2606 let mut progress = SilentProgress;
2607
2608 fetch(
2609 FetchRequest {
2610 git_dir: &local,
2611 format: ObjectFormat::Sha1,
2612 config: &GitConfig::default(),
2613 remote_name: "origin",
2614 source: &source,
2615 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2616 options: &options,
2617 },
2618 FetchServices {
2619 credentials: &mut credentials,
2620 progress: &mut progress,
2621 ref_hook: None,
2622 },
2623 )
2624 .expect("shallow fetch should succeed");
2625
2626 assert_eq!(
2627 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2628 .expect("shallow file should read"),
2629 vec![tip]
2630 );
2631 }
2632
2633 fn pack_file_count(git_dir: &Path) -> usize {
2634 fs::read_dir(git_dir.join("objects/pack"))
2635 .expect("pack directory should read")
2636 .filter_map(|entry| entry.ok())
2637 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
2638 .count()
2639 }
2640
2641 #[test]
2642 fn same_depth_shallow_local_fetch_does_not_install_pack() {
2643 let remote = temp_repo("remote-shallow-noop");
2644 let local = temp_repo("local-shallow-noop");
2645 let tip = commit_on(&remote, "main", "tip");
2646 let source = FetchSource::Local {
2647 git_dir: remote.clone(),
2648 common_git_dir: remote.clone(),
2649 };
2650 let mut options = default_options();
2651 options.depth = Some(1);
2652 let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
2653 let mut credentials = NoCredentials;
2654 let mut progress = SilentProgress;
2655
2656 fetch(
2657 FetchRequest {
2658 git_dir: &local,
2659 format: ObjectFormat::Sha1,
2660 config: &GitConfig::default(),
2661 remote_name: "origin",
2662 source: &source,
2663 refspecs: &refspecs,
2664 options: &options,
2665 },
2666 FetchServices {
2667 credentials: &mut credentials,
2668 progress: &mut progress,
2669 ref_hook: None,
2670 },
2671 )
2672 .expect("initial shallow fetch should succeed");
2673 let pack_count = pack_file_count(&local);
2674 let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2675 .expect("shallow file should read");
2676
2677 fetch(
2678 FetchRequest {
2679 git_dir: &local,
2680 format: ObjectFormat::Sha1,
2681 config: &GitConfig::default(),
2682 remote_name: "origin",
2683 source: &source,
2684 refspecs: &refspecs,
2685 options: &options,
2686 },
2687 FetchServices {
2688 credentials: &mut credentials,
2689 progress: &mut progress,
2690 ref_hook: None,
2691 },
2692 )
2693 .expect("same-depth shallow fetch should succeed");
2694
2695 assert_eq!(pack_file_count(&local), pack_count);
2696 assert_eq!(
2697 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2698 .expect("shallow file should read"),
2699 shallow
2700 );
2701 assert_eq!(shallow, vec![tip]);
2702 }
2703
2704 #[test]
2705 fn fetch_head_source_description_redacts_embedded_credentials() {
2706 let config = GitConfig {
2707 sections: vec![ConfigSection::new(
2708 "remote",
2709 Some("origin".into()),
2710 vec![ConfigEntry::new(
2711 "url",
2712 Some("https://user:pass@host/repo.git".into()),
2713 )],
2714 )],
2715 ..GitConfig::default()
2716 };
2717 assert_eq!(
2718 fetch_head_source_description(&config, "origin"),
2719 "https://<redacted>@host/repo"
2720 );
2721 }
2722
2723 #[test]
2724 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
2725 let remote = temp_repo("remote-missing");
2726 let local = temp_repo("local-missing");
2727 let old = commit_on(&local, "main", "old local");
2728 let bogus =
2729 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
2730 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2731 let mut tx = remote_refs.transaction();
2732 tx.update(RefUpdate {
2733 name: "refs/heads/main".into(),
2734 expected: None,
2735 new: RefTarget::Direct(bogus),
2736 reflog: None,
2737 });
2738 tx.update(RefUpdate {
2739 name: "HEAD".into(),
2740 expected: None,
2741 new: RefTarget::Symbolic("refs/heads/main".into()),
2742 reflog: None,
2743 });
2744 tx.commit().expect("remote bogus ref should write");
2745 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2746 let mut tx = local_refs.transaction();
2747 tx.update(RefUpdate {
2748 name: "refs/remotes/origin/main".into(),
2749 expected: None,
2750 new: RefTarget::Direct(old),
2751 reflog: None,
2752 });
2753 tx.commit().expect("local tracking ref should write");
2754 let source = FetchSource::Local {
2755 git_dir: remote.clone(),
2756 common_git_dir: remote.clone(),
2757 };
2758 let options = default_options();
2759 let mut credentials = NoCredentials;
2760 let mut progress = SilentProgress;
2761
2762 let err = fetch(
2763 FetchRequest {
2764 git_dir: &local,
2765 format: ObjectFormat::Sha1,
2766 config: &GitConfig::default(),
2767 remote_name: "origin",
2768 source: &source,
2769 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2770 options: &options,
2771 },
2772 FetchServices {
2773 credentials: &mut credentials,
2774 progress: &mut progress,
2775 ref_hook: None,
2776 },
2777 )
2778 .expect_err("fetch should fail before finalizing refs");
2779
2780 assert!(err.to_string().contains("missing object"));
2781 assert_eq!(
2782 local_refs
2783 .read_ref("refs/remotes/origin/main")
2784 .expect("ref should read"),
2785 Some(RefTarget::Direct(old))
2786 );
2787 assert!(!local.join("FETCH_HEAD").exists());
2788 }
2789
2790 fn config_from_ini(ini: &str) -> GitConfig {
2791 GitConfig::parse(ini.as_bytes()).expect("config should parse")
2792 }
2793
2794 #[test]
2795 fn fetch_max_input_size_unset_means_unlimited() {
2796 assert_eq!(fetch_max_input_size(&GitConfig::default()), None);
2797 }
2798
2799 #[test]
2800 fn fetch_max_input_size_honors_fetch_section() {
2801 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n");
2802 assert_eq!(fetch_max_input_size(&cfg), Some(64));
2803 }
2804
2805 #[test]
2806 fn fetch_max_input_size_falls_back_to_transfer_max_size() {
2807 let cfg = config_from_ini("[transfer]\n\tmaxSize = 1k\n");
2808 assert_eq!(fetch_max_input_size(&cfg), Some(1024));
2809 }
2810
2811 #[test]
2812 fn fetch_max_input_size_prefers_fetch_over_transfer() {
2813 let cfg = config_from_ini(
2814 "[fetch]\n\tmaxInputSize = 64\n[transfer]\n\tmaxSize = 1k\n",
2815 );
2816 assert_eq!(fetch_max_input_size(&cfg), Some(64));
2817 }
2818
2819 #[test]
2820 fn fetch_max_input_size_non_positive_means_unlimited() {
2821 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 0\n");
2822 assert_eq!(fetch_max_input_size(&cfg), None);
2823 }
2824}