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::{
27 remote_config_values, remote_exists, remote_names, rewrite_url_with_config,
28};
29use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
30use sley_odb::{
31 FileObjectDatabase, ObjectReader, collect_reachable_object_ids_excluding,
32 collect_reachable_object_ids_tolerating_promised_missing,
33};
34#[cfg(feature = "http")]
35use sley_protocol::ProtocolVersion;
36use sley_protocol::{
37 FetchHeadRecord, FetchRefUpdate, PromisorRemoteAdvertisement, RefAdvertisement, RefSpec,
38 encode_fetch_head, fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates,
39 refname_matches, refspec_map_source,
40};
41use sley_refs::{FileRefStore, Ref, RefTarget, RefUpdate, ReflogEntry};
42#[cfg(feature = "http")]
43use sley_transport::{HttpClient, UreqHttpClient};
44use sley_transport::{RemoteTransport, RemoteUrl};
45
46use crate::{CredentialProvider, PackGenerationProgress, ProgressSink};
47
48pub enum FetchSource {
53 Http(RemoteUrl),
55 Ssh(RemoteUrl),
58 Git {
60 remote: RemoteUrl,
61 protocol_v2: bool,
62 },
63 Local {
65 git_dir: PathBuf,
67 common_git_dir: PathBuf,
69 },
70}
71
72#[derive(Debug, Clone)]
74pub struct FetchOptions {
75 pub quiet: bool,
78 pub progress: Option<bool>,
82 pub auto_follow_tags: bool,
84 pub fetch_all_tags: bool,
86 pub prune: bool,
88 pub prune_tags: bool,
90 pub dry_run: bool,
92 pub force: bool,
95 pub append: bool,
97 pub write_fetch_head: bool,
99 pub tag_option_explicit: bool,
102 pub prune_option_explicit: bool,
105 pub prune_tags_option_explicit: bool,
108 pub refmap: Option<Vec<String>>,
112 pub depth: Option<u32>,
117 pub merge_srcs: Vec<String>,
124 pub filter: Option<sley_odb::PackObjectFilter>,
130 pub filter_auto: bool,
132 pub refetch: bool,
135 pub cloning: bool,
138 pub record_promisor_refs: bool,
142 pub update_shallow: bool,
145 pub reject_shallow: bool,
147 pub deepen_relative: bool,
150 pub update_head_ok: bool,
153 pub deepen_since: Option<i64>,
156 pub deepen_not: Vec<String>,
160 pub ssh_options: Option<crate::ssh::SshTransportOptions>,
164 pub upload_pack_command: Option<String>,
167 pub atomic: bool,
173 pub negotiation_restrict: Option<Vec<String>>,
177 pub negotiation_include: Option<Vec<String>>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct PrunedRef {
186 pub branch: String,
188 pub refname: String,
190}
191
192#[derive(Debug, Clone, Default)]
194pub struct FetchOutcome {
195 pub ref_updates: Vec<FetchRefUpdate>,
199 pub pruned: Vec<PrunedRef>,
202 pub head_symref: Option<String>,
205 pub wrote_fetch_head: bool,
207 pub accepted_promisor_remotes: Vec<PromisorRemoteAdvertisement>,
209 pub stored_promisor_fields: Vec<crate::PromisorRemoteFieldUpdate>,
211 pub pack_generation_progress: Option<PackGenerationProgress>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct FetchRepositoryPlan {
220 pub remote: String,
222 pub merge_srcs: Vec<String>,
224}
225
226pub fn plan_fetch_repository(
229 config: &GitConfig,
230 current_branch: Option<&str>,
231 requested_remote: Option<&str>,
232) -> FetchRepositoryPlan {
233 let remote = requested_remote.map(str::to_string).unwrap_or_else(|| {
234 current_branch
235 .and_then(|branch| config.get("branch", Some(branch), "remote"))
236 .map(str::to_string)
237 .unwrap_or_else(|| match remote_names(config).as_slice() {
238 [only] => only.clone(),
239 _ => "origin".to_string(),
240 })
241 });
242 let merge_srcs = current_branch
243 .filter(|branch| config.get("branch", Some(branch), "remote") == Some(remote.as_str()))
244 .map(|branch| {
245 config
246 .get_all("branch", Some(branch), "merge")
247 .into_iter()
248 .flatten()
249 .map(str::to_string)
250 .collect()
251 })
252 .unwrap_or_default();
253 FetchRepositoryPlan { remote, merge_srcs }
254}
255
256pub struct FetchRequest<'a> {
258 pub git_dir: &'a Path,
260 pub format: ObjectFormat,
262 pub config: &'a GitConfig,
264 pub remote_name: &'a str,
266 pub source: &'a FetchSource,
268 pub refspecs: &'a [String],
271 pub options: &'a FetchOptions,
273 pub validation: Option<&'a sley_fsck::FsckPolicy>,
277}
278
279pub struct FetchServices<'a> {
281 pub credentials: &'a mut dyn CredentialProvider,
283 pub progress: &'a mut dyn ProgressSink,
285 pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
290}
291
292pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
308 #[cfg(feature = "http")]
309 {
310 fetch_impl(request, services, None)
311 }
312 #[cfg(not(feature = "http"))]
313 {
314 fetch_impl(request, services)
315 }
316}
317
318#[cfg(feature = "http")]
327pub fn fetch_with_http_client(
328 request: FetchRequest<'_>,
329 services: FetchServices<'_>,
330 http_client: Option<&dyn HttpClient>,
331) -> Result<FetchOutcome> {
332 fetch_impl(request, services, http_client)
333}
334
335fn fetch_impl(
336 request: FetchRequest<'_>,
337 services: FetchServices<'_>,
338 #[cfg(feature = "http")] http_client: Option<&dyn HttpClient>,
339) -> Result<FetchOutcome> {
340 let ref_hook = services.ref_hook;
341 let mut options = request.options.clone();
342 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
343 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
344 normalize_relative_deepen_for_complete_repository(
345 request.git_dir,
346 request.format,
347 &mut options,
348 )?;
349 crate::protocol::check_transport_allowed(
350 scheme_for_fetch_source(request.source),
351 Some(request.config),
352 None,
353 )
354 .map_err(crate::protocol::transport_policy_git_error)?;
355 let promisor_remote = request
360 .config
361 .get_bool("remote", Some(request.remote_name), "promisor")
362 .unwrap_or(false)
363 || request.options.filter.is_some()
364 || request.options.filter_auto;
365 let max_input_size = fetch_max_input_size(request.config);
366 let configured_refspecs = if request.refspecs.is_empty() {
367 remote_config_values(request.config, request.remote_name, "fetch")
368 } else {
369 Vec::new()
370 };
371 let configured_refspecs_empty = configured_refspecs.is_empty();
372 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
378 let default_head_fetch =
379 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
380 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
381 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
382 let prune_refspecs =
383 prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
384 let mut effective_refspecs = fetch_refspecs_for_source(
385 configured_refspecs,
386 request.refspecs,
387 options.fetch_all_tags,
388 );
389 if options.prune_tags
390 && request.refspecs.is_empty()
391 && !effective_refspecs
392 .iter()
393 .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
394 {
395 effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
396 }
397 if has_merge_config {
398 if configured_refspecs_empty && request.refspecs.is_empty() {
401 effective_refspecs.retain(|spec| spec != "HEAD");
402 }
403 let configured_parsed = effective_refspecs
406 .iter()
407 .map(|refspec| parse_refspec(refspec))
408 .collect::<Result<Vec<_>>>()?;
409 for merge_src in &options.merge_srcs {
410 let covered = configured_parsed.iter().any(|refspec| {
414 refspec
415 .src
416 .as_deref()
417 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
418 });
419 if !covered {
420 effective_refspecs.push(merge_src.clone());
423 }
424 }
425 }
426 let mut parsed_refspecs = effective_refspecs
427 .iter()
428 .map(|refspec| parse_refspec(refspec))
429 .collect::<Result<Vec<_>>>()?;
430 if options.force {
431 for refspec in &mut parsed_refspecs {
432 refspec.force = true;
433 }
434 }
435 if options.refmap.is_some() && request.refspecs.is_empty() {
436 return Err(GitError::Command(
437 "--refmap option is only meaningful with command-line refspec(s)".into(),
438 ));
439 }
440 let tracking_refspec_strings = if request.refspecs.is_empty() {
441 Vec::new()
442 } else {
443 options.refmap.clone().unwrap_or_else(|| {
444 configured_refspecs_for_tracking(request.config, request.remote_name)
445 })
446 };
447 let tracking_refspecs = tracking_refspec_strings
448 .iter()
449 .map(|refspec| parse_refspec(refspec))
450 .collect::<Result<Vec<_>>>()?;
451 let parsed_prune_refspecs = prune_refspecs
452 .iter()
453 .map(|refspec| parse_refspec(refspec))
454 .collect::<Result<Vec<_>>>()?;
455
456 let store = FileRefStore::new(request.git_dir, request.format);
457 let negotiation_haves = custom_negotiation_haves(
458 request.git_dir,
459 request.format,
460 request.config,
461 request.remote_name,
462 &options,
463 )?;
464 let mut outcome = FetchOutcome::default();
465 let mut quarantine = request
466 .validation
467 .map(|_| {
468 sley_odb::IncomingPackQuarantine::new(request.git_dir, request.format).map(|incoming| {
469 incoming.with_promisor_remote_present(promisor_remote)
475 })
476 })
477 .transpose()?;
478 let transfer_git_dir = quarantine
479 .as_ref()
480 .map(sley_odb::IncomingPackQuarantine::git_dir)
481 .unwrap_or(request.git_dir);
482
483 let advertisements = match request.source {
487 #[cfg(not(feature = "http"))]
488 FetchSource::Http(_) => {
489 return Err(GitError::Unsupported(
490 "HTTP transport is not enabled in this build".into(),
491 ));
492 }
493 #[cfg(feature = "http")]
494 FetchSource::Http(remote) => {
495 let default_client;
498 let client: &dyn HttpClient = match http_client {
499 Some(client) => client,
500 None => {
501 default_client = UreqHttpClient::new();
502 &default_client
503 }
504 };
505 let git_protocol = crate::http::http_git_protocol_header_value(Some(request.config))?;
506 let discovered = crate::http::http_service_advertisements(
507 client,
508 remote,
509 request.format,
510 sley_protocol::GitService::UploadPack,
511 services.credentials,
512 Some(request.config),
513 )?;
514 let advertisements = discovered.set.refs;
515 let features = crate::http::http_upload_pack_features(
516 &advertisements,
517 discovered.handshake.as_ref(),
518 )?;
519 outcome.head_symref = head_symref_from_features(&features.symrefs);
520 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
521 advertisements: &advertisements,
522 refspecs: &parsed_refspecs,
523 options: &options,
524 store: &store,
525 reachable: None,
526 local_db: None,
527 deepen_excluded: None,
528 format: request.format,
529 configured_remote_fetch,
530 has_merge_config,
531 tracking_refspecs: &tracking_refspecs,
532 })?;
533 let wants = updates.iter().map(|update| update.oid).collect();
534 let existing_shallow =
538 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
539 let deepen_not = resolve_deepen_not_refs(&advertisements, &options.deepen_not)?;
540 let pack_request = crate::http::HttpFetchPackRequest {
541 client,
542 git_dir: transfer_git_dir,
543 format: request.format,
544 remote,
545 wants,
546 haves: negotiation_haves.clone(),
547 shallow: existing_shallow,
548 deepen: options.depth,
549 promisor: promisor_remote,
550 max_input_size,
551 filter: options.filter.clone(),
552 deepen_since: options.deepen_since,
553 deepen_not,
554 deepen_relative: options.deepen_relative,
555 git_protocol: git_protocol.as_deref(),
556 post_buffer: crate::http::http_post_buffer(Some(request.config)),
557 omit_haves: false,
558 };
559 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
560 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
561 GitError::InvalidFormat(
562 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
563 .into(),
564 )
565 })?;
566 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
567 pack_request,
568 handshake,
569 services.credentials,
570 services.progress,
571 )?
572 } else {
573 crate::http::install_fetch_pack_via_http_upload_pack(
574 pack_request,
575 services.credentials,
576 services.progress,
577 )?
578 };
579 reject_shallow_clone_fetch(&options, &shallow_info)?;
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 validation: request.validation,
592 quarantine: &mut quarantine,
593 shallow_info: &shallow_info,
594 },
595 &mut updates,
596 &mut outcome,
597 )?;
598 advertisements
599 }
600 FetchSource::Ssh(remote) => {
601 crate::ssh::validate_ssh_fetch_options(
602 options.filter.as_ref(),
603 options.deepen_since,
604 &options.deepen_not,
605 )?;
606 let ssh_options = options
610 .ssh_options
611 .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
612 let (advertisements, features) =
613 crate::ssh::ssh_upload_pack_advertisements_with_command(
614 remote,
615 request.format,
616 ssh_options,
617 options.upload_pack_command.as_deref(),
618 )?;
619 outcome.head_symref = head_symref_from_features(&features.symrefs);
620 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
621 advertisements: &advertisements,
622 refspecs: &parsed_refspecs,
623 options: &options,
624 store: &store,
625 reachable: None,
626 local_db: None,
627 deepen_excluded: None,
628 format: request.format,
629 configured_remote_fetch,
630 has_merge_config,
631 tracking_refspecs: &tracking_refspecs,
632 })?;
633 if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
634 append_missing_ext_advertised_tags(
635 &advertisements,
636 &parsed_refspecs,
637 &store,
638 &mut updates,
639 )?;
640 }
641 let wants = updates.iter().map(|update| update.oid).collect();
642 let existing_shallow =
645 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
646 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
647 crate::ssh::SshFetchPackRequest {
648 git_dir: transfer_git_dir,
649 format: request.format,
650 remote,
651 features: &features,
652 wants,
653 haves: negotiation_haves.clone(),
654 shallow: existing_shallow,
655 deepen: options.depth,
656 promisor: promisor_remote,
657 command_options: ssh_options,
658 upload_pack_command: options.upload_pack_command.as_deref(),
659 max_input_size,
660 },
661 services.progress,
662 )?;
663 reject_shallow_clone_fetch(&options, &shallow_info)?;
664 finalize_fetch(
665 FetchFinalize {
666 git_dir: request.git_dir,
667 format: request.format,
668 store: &store,
669 options: &options,
670 fetch_head_source: &fetch_head_source,
671 default_head_fetch,
672 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
673 ref_hook,
674 opportunistic_dsts: &opportunistic_dsts,
675 validation: request.validation,
676 quarantine: &mut quarantine,
677 shallow_info: &shallow_info,
678 },
679 &mut updates,
680 &mut outcome,
681 )?;
682 advertisements
683 }
684 FetchSource::Git {
685 remote,
686 protocol_v2,
687 } => {
688 let protocol_v2 =
689 *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
690 let exact_oid_only = parsed_refspecs.iter().any(|refspec| !refspec.negative)
691 && parsed_refspecs.iter().all(|refspec| {
692 refspec.negative
693 || (!refspec.pattern
694 && refspec.src.as_deref().is_some_and(|source| {
695 ObjectId::from_hex(request.format, source).is_ok()
696 }))
697 });
698 let discovered = if protocol_v2 && exact_oid_only && !options.auto_follow_tags {
699 crate::git::GitUploadPackAdvertisements {
704 refs: Vec::new(),
705 features: sley_protocol::UploadPackFeatures {
706 object_format: Some(request.format),
707 ..sley_protocol::UploadPackFeatures::default()
708 },
709 protocol_v2: true,
710 object_format: request.format,
711 }
712 } else {
713 crate::git::git_upload_pack_advertisements_with_protocol(
714 remote,
715 request.format,
716 protocol_v2,
717 Some(request.config),
718 )?
719 };
720 let advertisements = discovered.refs;
721 let features = discovered.features;
722 outcome.head_symref = head_symref_from_features(&features.symrefs);
723 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
724 advertisements: &advertisements,
725 refspecs: &parsed_refspecs,
726 options: &options,
727 store: &store,
728 reachable: None,
729 local_db: None,
730 deepen_excluded: None,
731 format: request.format,
732 configured_remote_fetch,
733 has_merge_config,
734 tracking_refspecs: &tracking_refspecs,
735 })?;
736 let wants = updates.iter().map(|update| update.oid).collect();
737 let existing_shallow =
738 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
739 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
740 crate::git::GitFetchPackRequest {
741 git_dir: transfer_git_dir,
742 format: request.format,
743 remote,
744 config: Some(request.config),
745 features: &features,
746 wants,
747 haves: negotiation_haves.clone(),
748 shallow: existing_shallow,
749 deepen: options.depth,
750 promisor: promisor_remote,
751 protocol_v2: discovered.protocol_v2,
752 max_input_size,
753 },
754 services.progress,
755 )?;
756 reject_shallow_clone_fetch(&options, &shallow_info)?;
757 finalize_fetch(
758 FetchFinalize {
759 git_dir: request.git_dir,
760 format: request.format,
761 store: &store,
762 options: &options,
763 fetch_head_source: &fetch_head_source,
764 default_head_fetch,
765 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
766 ref_hook,
767 opportunistic_dsts: &opportunistic_dsts,
768 validation: request.validation,
769 quarantine: &mut quarantine,
770 shallow_info: &shallow_info,
771 },
772 &mut updates,
773 &mut outcome,
774 )?;
775 advertisements
776 }
777 FetchSource::Local {
778 git_dir: remote_git_dir,
779 common_git_dir: remote_common_git_dir,
780 } => {
781 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
782 if remote_format != request.format {
783 return Err(GitError::InvalidObjectId(format!(
784 "remote repository uses {}, local repository uses {}",
785 remote_format.name(),
786 request.format.name()
787 )));
788 }
789 let advertisements =
790 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
791 let remote_config =
792 sley_config::read_repo_config(remote_common_git_dir, None).unwrap_or_default();
793 let mut promisor_decision = crate::PromisorRemoteDecision::default();
794 if let Some(capability) = crate::promisor_remote_server_capability(&remote_config)? {
795 let advertised = format!(
796 "promisor-remote={}\n",
797 capability.value.as_deref().unwrap_or("")
798 );
799 sley_protocol::trace_packet_read_payload(advertised.as_bytes());
800 promisor_decision =
801 crate::decide_promisor_remote_reply(request.config, &capability)?;
802 if let Some(remote) = promisor_decision.accepted.first() {
803 crate::promisor::trace_promisor_remote_contact(&remote.name);
804 }
805 if options.filter_auto {
806 options.filter = crate::promisor_remote_auto_filter(&promisor_decision);
807 }
808 outcome.accepted_promisor_remotes = promisor_decision.accepted.clone();
809 crate::apply_promisor_remote_field_updates(
810 request.git_dir,
811 &promisor_decision.stored_fields,
812 )?;
813 for update in &promisor_decision.stored_fields {
814 services.progress.diagnostic(&format!(
815 "Storing new {} from server for remote '{}'.",
816 update.field.display_name(),
817 update.remote_name
818 ));
819 services.progress.diagnostic(&format!(
820 " '{}' -> '{}'",
821 update.previous.as_deref().unwrap_or(""),
822 update.value
823 ));
824 }
825 outcome.stored_promisor_fields = promisor_decision.stored_fields.clone();
826 if let Some(reply) = promisor_decision.reply.as_deref() {
827 sley_protocol::trace_packet_write_payload(
828 format!("promisor-remote={reply}\n").as_bytes(),
829 );
830 }
831 }
832 if advertisements
836 .iter()
837 .any(|advertisement| advertisement.name == "HEAD")
838 && let Some(RefTarget::Symbolic(target)) =
839 FileRefStore::new_without_reference_backend_env(remote_git_dir, request.format)
840 .read_ref("HEAD")?
841 {
842 outcome.head_symref = Some(target);
843 }
844 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format)
845 .with_promisor_remote_present(crate::config_has_promisor_remote(&remote_config));
846 let remote_shallow =
858 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
859 let explicit_deepen = options.depth.is_some()
860 || options.deepen_since.is_some()
861 || !options.deepen_not.is_empty();
862 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
863 let mut deepen_not_oids = Vec::new();
866 for name in &options.deepen_not {
867 let resolved = advertisements.iter().find(|advertisement| {
868 advertisement.name == *name
869 || advertisement.name == format!("refs/tags/{name}")
870 || advertisement.name == format!("refs/heads/{name}")
871 || advertisement.name == format!("refs/{name}")
872 });
873 match resolved {
874 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
875 None => {
876 return Err(GitError::Command(format!(
877 "git upload-pack: deepen-not is not a ref: {name}"
878 )));
879 }
880 }
881 }
882 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
883 if !explicit_deepen && !implicit_deepen {
884 return Ok(None);
885 }
886 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
888 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
889 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
890 &remote_db,
891 request.format,
892 heads,
893 client_shallow,
894 options.deepen_since,
895 &deepen_not_oids,
896 )?));
897 }
898 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
899 Ok(Some(crate::local::compute_local_deepen(
900 &remote_db,
901 request.format,
902 heads,
903 client_shallow,
904 depth,
905 options.deepen_relative,
906 )?))
907 };
908 let primary_heads = {
909 let primary = plan_fetch_ref_updates(
910 request.format,
911 &advertisements,
912 &parsed_refspecs,
913 options.auto_follow_tags,
914 )?;
915 let mut seen = HashSet::new();
916 let mut heads = Vec::new();
917 for update in &primary {
918 if seen.insert(update.oid) {
919 heads.push(update.oid);
920 }
921 }
922 heads
923 };
924 let mut deepen_plan = plan_deepen(&primary_heads)?;
925 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
926 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
927 advertisements: &advertisements,
928 refspecs: &parsed_refspecs,
929 options: &options,
930 store: &store,
931 reachable: Some((&remote_db, &advertisements)),
932 local_db: Some(&local_db),
933 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
934 format: request.format,
935 configured_remote_fetch,
936 has_merge_config,
937 tracking_refspecs: &tracking_refspecs,
938 })?;
939 if implicit_deepen && !options.cloning && !options.update_shallow {
944 let client_shallow: HashSet<ObjectId> =
945 crate::shallow::read_shallow(request.git_dir, request.format)?
946 .into_iter()
947 .collect();
948 let new_points: HashSet<ObjectId> = deepen_plan
949 .as_ref()
950 .map(|plan| {
951 plan.shallow_info
952 .iter()
953 .filter_map(|entry| match entry {
954 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
955 if !client_shallow.contains(oid) =>
956 {
957 Some(*oid)
958 }
959 _ => None,
960 })
961 .collect()
962 })
963 .unwrap_or_default();
964 if !new_points.is_empty() {
965 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
966 let mut dirty = |tip: &ObjectId| -> Result<bool> {
967 if let Some(&cached) = dirty_cache.get(tip) {
968 return Ok(cached);
969 }
970 let result =
971 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
972 dirty_cache.insert(*tip, result);
973 Ok(result)
974 };
975 let mut kept = Vec::new();
976 for update in updates {
977 if dirty(&update.oid)? {
978 continue;
979 }
980 kept.push(update);
981 }
982 updates = kept;
983 let mut seen = HashSet::new();
986 let mut heads = Vec::new();
987 for update in &updates {
988 if seen.insert(update.oid) {
989 heads.push(update.oid);
990 }
991 }
992 deepen_plan = if heads.is_empty() {
993 None
994 } else {
995 plan_deepen(&heads)?
996 };
997 }
998 }
999 let starts: Vec<ObjectId> = if options.refetch {
1000 let mut seen = HashSet::new();
1001 updates
1002 .iter()
1003 .map(|update| update.oid)
1004 .chain(primary_heads.iter().copied())
1005 .filter(|oid| seen.insert(*oid))
1006 .collect()
1007 } else if deepen_plan.is_none() {
1008 let mut starts = Vec::new();
1009 for update in &updates {
1010 if !local_db.contains(&update.oid)? {
1011 starts.push(update.oid);
1012 }
1013 }
1014 starts
1015 } else {
1016 updates.iter().map(|update| update.oid).collect()
1017 };
1018 let use_ref_in_want = deepen_plan.is_none()
1019 && !promisor_remote
1020 && options.filter.is_none()
1021 && !options.refetch
1022 && request.config.get("protocol", None, "version") == Some("2")
1023 && sley_config::read_repo_config(remote_git_dir, None)
1024 .ok()
1025 .and_then(|config| config.get_bool("uploadpack", None, "allowrefinwant"))
1026 .unwrap_or(false);
1027 let mut seen_want_refs = HashSet::new();
1028 let want_refs: Vec<String> = if use_ref_in_want {
1029 updates
1030 .iter()
1031 .filter(|update| update.src.starts_with("refs/"))
1032 .filter(|update| seen_want_refs.insert(update.src.clone()))
1033 .map(|update| update.src.clone())
1034 .collect()
1035 } else {
1036 Vec::new()
1037 };
1038 let has_exact_oid_want = use_ref_in_want
1039 && updates
1040 .iter()
1041 .any(|update| ObjectId::from_hex(request.format, &update.src).is_ok());
1042 let (
1043 shallow_info,
1044 transferred_objects,
1045 transferred_compression_objects,
1046 transferred_deltas,
1047 ) = if !want_refs.is_empty() || has_exact_oid_want {
1048 let mut seen_exact_wants = HashSet::new();
1049 let exact_wants = updates
1050 .iter()
1051 .filter(|update| ObjectId::from_hex(request.format, &update.src).is_ok())
1052 .filter(|update| seen_exact_wants.insert(update.oid))
1053 .map(|update| update.oid)
1054 .collect();
1055 let ref_in_want = crate::local::install_fetch_pack_via_local_protocol_v2(
1056 crate::local::LocalProtocolV2FetchRequest {
1057 git_dir: request.git_dir,
1058 destination_git_dir: transfer_git_dir,
1059 remote_git_dir,
1060 format: request.format,
1061 wants: exact_wants,
1062 want_refs,
1063 haves: negotiation_haves.clone(),
1064 },
1065 )?;
1066 for wanted in ref_in_want.wanted_refs {
1067 for update in &mut updates {
1068 if update.src == wanted.name {
1069 update.oid = wanted.oid;
1070 }
1071 }
1072 }
1073 (ref_in_want.shallow_info, 0, 0, 0)
1074 } else if starts.is_empty() && deepen_plan.is_none() {
1075 if !updates.is_empty() {
1076 sley_protocol::trace_packet_write_payload(b"0000");
1077 }
1078 (Vec::new(), 0, 0, 0)
1079 } else {
1080 let transfer = crate::local::install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
1081 request.git_dir,
1082 transfer_git_dir,
1083 remote_git_dir,
1084 request.format,
1085 starts,
1086 deepen_plan.as_ref(),
1087 promisor_remote,
1088 options.record_promisor_refs,
1089 options.filter.clone(),
1090 negotiation_haves.clone(),
1091 options.refetch,
1092 local_fetch_unpack_limit(request.config, options.cloning, promisor_remote),
1093 &promisor_decision,
1094 crate::local::LocalFetchPackRequestMode::Traversal,
1095 )?;
1096 (
1097 transfer.shallow_info,
1098 transfer.object_count,
1099 transfer.compression_count,
1100 transfer.delta_count,
1101 )
1102 };
1103 outcome.pack_generation_progress =
1104 (transferred_objects > 0).then_some(PackGenerationProgress {
1105 total_objects: transferred_objects,
1106 compression_objects: transferred_compression_objects,
1107 delta_objects: transferred_deltas,
1108 });
1109 if options.progress == Some(true)
1110 && !options.quiet
1111 && let Some(progress) = outcome.pack_generation_progress.as_ref()
1112 {
1113 services.progress.pack_generation(progress);
1114 }
1115 finalize_fetch(
1116 FetchFinalize {
1117 git_dir: request.git_dir,
1118 format: request.format,
1119 store: &store,
1120 options: &options,
1121 fetch_head_source: &fetch_head_source,
1122 default_head_fetch,
1123 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
1124 ref_hook,
1125 opportunistic_dsts: &opportunistic_dsts,
1126 validation: request.validation,
1127 quarantine: &mut quarantine,
1128 shallow_info: &shallow_info,
1129 },
1130 &mut updates,
1131 &mut outcome,
1132 )?;
1133 advertisements
1134 }
1135 };
1136
1137 if options.prune && !parsed_prune_refspecs.is_empty() {
1138 outcome.pruned = prune_refs_from_advertisements(
1139 PruneRefsInput {
1140 config: request.config,
1141 store: &store,
1142 remote: request.remote_name,
1143 advertisements: &advertisements,
1144 refspecs: &parsed_prune_refspecs,
1145 dry_run: options.dry_run,
1146 quiet: options.quiet,
1147 },
1148 services.progress,
1149 )?;
1150 }
1151
1152 Ok(outcome)
1153}
1154
1155pub struct RemoteHelperFetchRequest<'a> {
1160 pub git_dir: &'a Path,
1161 pub format: ObjectFormat,
1162 pub config: &'a GitConfig,
1163 pub remote_name: &'a str,
1164 pub advertisements: &'a [RefAdvertisement],
1165 pub head_symref: Option<String>,
1166 pub refspecs: &'a [String],
1167 pub options: &'a FetchOptions,
1168}
1169
1170pub fn finalize_remote_helper_fetch(
1171 request: RemoteHelperFetchRequest<'_>,
1172 services: FetchServices<'_>,
1173) -> Result<FetchOutcome> {
1174 let ref_hook = services.ref_hook;
1175 let mut options = request.options.clone();
1176 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
1177 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
1178 let configured_refspecs = if request.refspecs.is_empty() {
1179 remote_config_values(request.config, request.remote_name, "fetch")
1180 } else {
1181 Vec::new()
1182 };
1183 let configured_refspecs_empty = configured_refspecs.is_empty();
1184 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
1185 let default_head_fetch =
1186 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
1187 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
1188 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
1189 let prune_refspecs =
1190 prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
1191 let mut effective_refspecs = fetch_refspecs_for_source(
1192 configured_refspecs,
1193 request.refspecs,
1194 options.fetch_all_tags,
1195 );
1196 if options.prune_tags
1197 && request.refspecs.is_empty()
1198 && !effective_refspecs
1199 .iter()
1200 .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
1201 {
1202 effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
1203 }
1204 if has_merge_config {
1205 if configured_refspecs_empty && request.refspecs.is_empty() {
1206 effective_refspecs.retain(|spec| spec != "HEAD");
1207 }
1208 let configured_parsed = effective_refspecs
1209 .iter()
1210 .map(|refspec| parse_refspec(refspec))
1211 .collect::<Result<Vec<_>>>()?;
1212 for merge_src in &options.merge_srcs {
1213 let covered = configured_parsed.iter().any(|refspec| {
1214 refspec
1215 .src
1216 .as_deref()
1217 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
1218 });
1219 if !covered {
1220 effective_refspecs.push(merge_src.clone());
1221 }
1222 }
1223 }
1224 let mut parsed_refspecs = effective_refspecs
1225 .iter()
1226 .map(|refspec| parse_refspec(refspec))
1227 .collect::<Result<Vec<_>>>()?;
1228 if options.force {
1229 for refspec in &mut parsed_refspecs {
1230 refspec.force = true;
1231 }
1232 }
1233 if options.refmap.is_some() && request.refspecs.is_empty() {
1234 return Err(GitError::Command(
1235 "--refmap option is only meaningful with command-line refspec(s)".into(),
1236 ));
1237 }
1238 let tracking_refspec_strings = if request.refspecs.is_empty() {
1239 Vec::new()
1240 } else {
1241 options.refmap.clone().unwrap_or_else(|| {
1242 configured_refspecs_for_tracking(request.config, request.remote_name)
1243 })
1244 };
1245 let tracking_refspecs = tracking_refspec_strings
1246 .iter()
1247 .map(|refspec| parse_refspec(refspec))
1248 .collect::<Result<Vec<_>>>()?;
1249 let parsed_prune_refspecs = prune_refspecs
1250 .iter()
1251 .map(|refspec| parse_refspec(refspec))
1252 .collect::<Result<Vec<_>>>()?;
1253
1254 let store = FileRefStore::new(request.git_dir, request.format);
1255 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
1256 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
1257 advertisements: request.advertisements,
1258 refspecs: &parsed_refspecs,
1259 options: &options,
1260 store: &store,
1261 reachable: Some((&local_db, request.advertisements)),
1262 local_db: Some(&local_db),
1263 deepen_excluded: None,
1264 format: request.format,
1265 configured_remote_fetch,
1266 has_merge_config,
1267 tracking_refspecs: &tracking_refspecs,
1268 })?;
1269 let mut outcome = FetchOutcome {
1270 head_symref: request.head_symref,
1271 ..FetchOutcome::default()
1272 };
1273 let mut quarantine = None;
1274 finalize_fetch(
1275 FetchFinalize {
1276 git_dir: request.git_dir,
1277 format: request.format,
1278 store: &store,
1279 options: &options,
1280 fetch_head_source: &fetch_head_source,
1281 default_head_fetch,
1282 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
1283 ref_hook,
1284 opportunistic_dsts: &opportunistic_dsts,
1285 validation: None,
1286 quarantine: &mut quarantine,
1287 shallow_info: &[],
1288 },
1289 &mut updates,
1290 &mut outcome,
1291 )?;
1292 if options.prune && !parsed_prune_refspecs.is_empty() {
1293 outcome.pruned = prune_refs_from_advertisements(
1294 PruneRefsInput {
1295 config: request.config,
1296 store: &store,
1297 remote: request.remote_name,
1298 advertisements: request.advertisements,
1299 refspecs: &parsed_prune_refspecs,
1300 dry_run: options.dry_run,
1301 quiet: options.quiet,
1302 },
1303 services.progress,
1304 )?;
1305 }
1306 Ok(outcome)
1307}
1308
1309fn scheme_for_fetch_source(source: &FetchSource) -> &str {
1310 match source {
1311 FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
1312 FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
1313 FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
1314 FetchSource::Local { .. } => "file",
1315 }
1316}
1317
1318fn local_fetch_unpack_limit(
1319 config: &GitConfig,
1320 cloning: bool,
1321 promisor_remote: bool,
1322) -> Option<usize> {
1323 if cloning || promisor_remote {
1329 return None;
1330 }
1331 let configured = config
1332 .get("fetch", None, "unpackLimit")
1333 .or_else(|| config.get("transfer", None, "unpackLimit"));
1334 match configured.and_then(sley_config::parse_config_int) {
1335 Some(limit) if limit > 0 => usize::try_from(limit).ok(),
1336 Some(_) => None,
1337 None if configured.is_some() => None,
1338 None => Some(100),
1339 }
1340}
1341
1342fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
1346 remote_db: &R,
1347 format: ObjectFormat,
1348 tip: &ObjectId,
1349 boundary: &HashSet<ObjectId>,
1350) -> Result<bool> {
1351 let mut seen: HashSet<ObjectId> = HashSet::new();
1352 let mut queue: Vec<ObjectId> = vec![*tip];
1353 while let Some(oid) = queue.pop() {
1354 if !seen.insert(oid) {
1355 continue;
1356 }
1357 let object = remote_db.read_object(&oid)?;
1358 let commit = match object.object_type {
1359 sley_object::ObjectType::Commit => {
1360 sley_object::Commit::parse_ref(format, &object.body)?
1361 }
1362 sley_object::ObjectType::Tag => {
1363 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
1364 queue.push(tag.object);
1365 continue;
1366 }
1367 _ => continue,
1368 };
1369 if boundary.contains(&oid) {
1370 return Ok(true);
1371 }
1372 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
1373 }
1374 Ok(false)
1375}
1376
1377fn shallow_boundary_for_request(
1382 git_dir: &Path,
1383 format: ObjectFormat,
1384 options: &FetchOptions,
1385) -> Result<Vec<ObjectId>> {
1386 if options.depth.is_none() && options.deepen_since.is_none() && options.deepen_not.is_empty() {
1387 return Ok(Vec::new());
1388 }
1389 crate::shallow::read_shallow(git_dir, format)
1390}
1391
1392fn normalize_relative_deepen_for_complete_repository(
1396 git_dir: &Path,
1397 format: ObjectFormat,
1398 options: &mut FetchOptions,
1399) -> Result<()> {
1400 if !options.deepen_relative || options.depth.is_none() {
1401 return Ok(());
1402 }
1403 if crate::shallow::read_shallow(git_dir, format)?.is_empty() {
1404 options.depth = None;
1405 options.deepen_relative = false;
1406 }
1407 Ok(())
1408}
1409
1410fn resolve_deepen_not_refs(
1411 advertisements: &[RefAdvertisement],
1412 deepen_not: &[String],
1413) -> Result<Vec<String>> {
1414 let mut resolved = Vec::with_capacity(deepen_not.len());
1415 for name in deepen_not {
1416 let found = advertisements.iter().any(|advertisement| {
1417 advertisement.name == *name
1418 || advertisement.name == format!("refs/tags/{name}")
1419 || advertisement.name == format!("refs/heads/{name}")
1420 || advertisement.name == format!("refs/{name}")
1421 });
1422 if found {
1423 resolved.push(name.clone());
1424 } else {
1425 return Err(GitError::Command(format!(
1426 "git upload-pack: deepen-not is not a ref: {name}"
1427 )));
1428 }
1429 }
1430 Ok(resolved)
1431}
1432
1433struct FetchPlanInput<'a> {
1439 advertisements: &'a [RefAdvertisement],
1440 refspecs: &'a [RefSpec],
1441 options: &'a FetchOptions,
1442 store: &'a FileRefStore,
1443 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
1444 local_db: Option<&'a FileObjectDatabase>,
1448 deepen_excluded: Option<&'a HashSet<ObjectId>>,
1449 format: ObjectFormat,
1450 configured_remote_fetch: bool,
1451 has_merge_config: bool,
1455 tracking_refspecs: &'a [RefSpec],
1457}
1458
1459fn plan_and_adjust_updates(
1460 input: FetchPlanInput<'_>,
1461) -> Result<(Vec<FetchRefUpdate>, HashSet<String>)> {
1462 let FetchPlanInput {
1463 advertisements,
1464 refspecs,
1465 options,
1466 store,
1467 reachable,
1468 local_db,
1469 deepen_excluded,
1470 format,
1471 configured_remote_fetch,
1472 has_merge_config,
1473 tracking_refspecs,
1474 } = input;
1475 let visible_advertisements = advertisements_without_peeled_refs(advertisements);
1476 let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
1477 advertisements
1478 } else {
1479 visible_advertisements.as_slice()
1480 };
1481 let mut updates = plan_fetch_ref_updates(
1482 format,
1483 planning_advertisements,
1484 refspecs,
1485 options.auto_follow_tags,
1486 )?;
1487 if options.fetch_all_tags {
1488 mark_tag_refspec_updates_not_for_merge(&mut updates);
1489 } else {
1490 if options.auto_follow_tags
1491 && let Some((remote_db, advertisements)) = reachable
1492 {
1493 let visible_reachable_advertisements =
1494 advertisements_without_peeled_refs(advertisements);
1495 let reachable_advertisements =
1496 if visible_reachable_advertisements.len() == advertisements.len() {
1497 advertisements
1498 } else {
1499 visible_reachable_advertisements.as_slice()
1500 };
1501 append_reachable_auto_follow_tags(
1502 reachable_advertisements,
1503 remote_db,
1504 local_db,
1505 format,
1506 refspecs,
1507 &mut updates,
1508 deepen_excluded,
1509 )?;
1510 }
1511 retain_missing_auto_follow_tags(store, &mut updates)?;
1512 }
1513 if configured_remote_fetch || has_merge_config {
1514 for update in &mut updates {
1515 update.not_for_merge = true;
1516 }
1517 if !options.merge_srcs.is_empty() {
1518 for update in &mut updates {
1523 if options
1524 .merge_srcs
1525 .iter()
1526 .any(|src| refname_matches(src, &update.src))
1527 {
1528 update.not_for_merge = false;
1529 }
1530 }
1531 } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
1532 && !first.pattern
1533 {
1534 if let Some(update) = updates.first_mut() {
1539 update.not_for_merge = false;
1540 }
1541 }
1542 updates.sort_by_key(|update| update.not_for_merge);
1546 }
1547 let opportunistic_dsts =
1548 append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
1549 ref_remove_duplicate_updates(&mut updates)?;
1550 Ok((updates, opportunistic_dsts))
1551}
1552
1553fn ref_remove_duplicate_updates(updates: &mut Vec<FetchRefUpdate>) -> Result<()> {
1558 let mut seen: BTreeMap<String, String> = BTreeMap::new();
1559 let mut error = None;
1560 updates.retain(|update| {
1561 let Some(dst) = update.dst.as_deref() else {
1562 return true;
1563 };
1564 match seen.get(dst) {
1565 Some(prev_src) if prev_src == &update.src => false,
1566 Some(prev_src) => {
1567 if error.is_none() {
1568 error = Some(GitError::Command(format!(
1569 "Cannot fetch both {} and {} to {dst}",
1570 prev_src, update.src
1571 )));
1572 }
1573 true
1574 }
1575 None => {
1576 seen.insert(dst.to_string(), update.src.clone());
1577 true
1578 }
1579 }
1580 });
1581 match error {
1582 Some(err) => Err(err),
1583 None => Ok(()),
1584 }
1585}
1586
1587fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
1588 if remote_exists(config, remote) {
1589 remote_config_values(config, remote, "fetch")
1590 } else {
1591 Vec::new()
1592 }
1593}
1594
1595fn append_opportunistic_tracking_updates(
1600 updates: &mut Vec<FetchRefUpdate>,
1601 tracking_refspecs: &[RefSpec],
1602) -> Result<HashSet<String>> {
1603 let mut opportunistic_dsts = HashSet::new();
1604 if tracking_refspecs.is_empty() {
1605 return Ok(opportunistic_dsts);
1606 }
1607 let mut seen_dsts = updates
1608 .iter()
1609 .filter_map(|update| update.dst.clone())
1610 .collect::<HashSet<_>>();
1611 let mut additions = Vec::new();
1612 for update in updates.iter() {
1613 if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1614 continue;
1615 }
1616 for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1617 let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1618 continue;
1619 };
1620 if !seen_dsts.insert(dst.clone()) {
1621 continue;
1622 }
1623 opportunistic_dsts.insert(dst.clone());
1624 additions.push(FetchRefUpdate {
1625 src: update.src.clone(),
1626 dst: Some(dst),
1627 oid: update.oid,
1628 not_for_merge: true,
1629 force: refspec.force,
1630 });
1631 }
1632 }
1633 updates.extend(additions);
1634 Ok(opportunistic_dsts)
1635}
1636
1637fn advertisements_without_peeled_refs(
1638 advertisements: &[RefAdvertisement],
1639) -> Vec<RefAdvertisement> {
1640 advertisements
1641 .iter()
1642 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1643 .cloned()
1644 .collect()
1645}
1646
1647fn append_missing_ext_advertised_tags(
1648 advertisements: &[RefAdvertisement],
1649 refspecs: &[RefSpec],
1650 store: &FileRefStore,
1651 updates: &mut Vec<FetchRefUpdate>,
1652) -> Result<()> {
1653 let mut seen = updates
1654 .iter()
1655 .map(|update| update.src.clone())
1656 .collect::<HashSet<_>>();
1657 let mut tags = Vec::new();
1658 for reference in advertisements {
1659 if !reference.name.starts_with("refs/tags/")
1660 || reference.name.ends_with("^{}")
1661 || !seen.insert(reference.name.clone())
1662 || fetch_refspec_excludes(refspecs, &reference.name)?
1663 || store.read_ref(&reference.name)?.is_some()
1664 {
1665 continue;
1666 }
1667 tags.push(FetchRefUpdate {
1668 src: reference.name.clone(),
1669 dst: Some(reference.name.clone()),
1670 oid: reference.oid,
1671 not_for_merge: true,
1672 force: false,
1673 });
1674 }
1675 tags.sort_by(|a, b| a.src.cmp(&b.src));
1676 updates.extend(tags);
1677 Ok(())
1678}
1679
1680struct FetchFinalize<'a> {
1684 git_dir: &'a Path,
1685 format: ObjectFormat,
1686 store: &'a FileRefStore,
1687 options: &'a FetchOptions,
1688 fetch_head_source: &'a str,
1689 default_head_fetch: bool,
1690 log_all_ref_updates: bool,
1691 ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
1692 opportunistic_dsts: &'a HashSet<String>,
1695 validation: Option<&'a sley_fsck::FsckPolicy>,
1696 quarantine: &'a mut Option<sley_odb::IncomingPackQuarantine>,
1697 shallow_info: &'a [sley_protocol::ProtocolV2FetchShallowInfo],
1701}
1702
1703fn downgrade_non_commit_for_merge(
1709 db: &FileObjectDatabase,
1710 format: ObjectFormat,
1711 updates: &mut [FetchRefUpdate],
1712) {
1713 if updates.iter().all(|update| update.not_for_merge) {
1714 return;
1715 }
1716 for update in updates.iter_mut() {
1717 if !update.not_for_merge && sley_rev::peel_to_commit(db, format, &update.oid).is_err() {
1718 update.not_for_merge = true;
1719 }
1720 }
1721}
1722
1723fn finalize_fetch(
1724 finalize: FetchFinalize<'_>,
1725 updates: &mut Vec<FetchRefUpdate>,
1726 outcome: &mut FetchOutcome,
1727) -> Result<()> {
1728 let FetchFinalize {
1729 git_dir,
1730 format,
1731 store,
1732 options,
1733 fetch_head_source,
1734 default_head_fetch,
1735 log_all_ref_updates,
1736 ref_hook,
1737 opportunistic_dsts,
1738 validation,
1739 quarantine,
1740 shallow_info,
1741 } = finalize;
1742 if let Some(incoming) = quarantine.as_ref() {
1747 crate::shallow::apply_shallow_info(incoming.git_dir(), format, shallow_info)?;
1748 }
1749 let main_db = FileObjectDatabase::from_git_dir(git_dir, format);
1750 let quarantine_db = quarantine
1751 .as_ref()
1752 .map(sley_odb::IncomingPackQuarantine::database);
1753 let validation_db = quarantine_db.as_ref().unwrap_or(&main_db);
1754 let unshallow_received = shallow_info.iter().any(|entry| {
1755 matches!(
1756 entry,
1757 sley_protocol::ProtocolV2FetchShallowInfo::Unshallow(_)
1758 )
1759 });
1760 validate_incoming_fetch_objects(
1761 validation_db,
1762 format,
1763 updates,
1764 validation,
1765 unshallow_received,
1766 )?;
1767 downgrade_non_commit_for_merge(validation_db, format, updates);
1768 if let Some(incoming) = quarantine.take() {
1773 incoming.promote()?;
1774 }
1775 if options.dry_run {
1776 outcome.ref_updates = std::mem::take(updates);
1777 return Ok(());
1778 }
1779 crate::shallow::apply_shallow_info(git_dir, format, shallow_info)?;
1783 drop_redundant_symref_alias_updates(store, updates)?;
1784 validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1785 if options.atomic {
1786 if let Some(reason) = atomic_non_fast_forward_rejection(git_dir, format, store, updates)? {
1794 return Err(GitError::Command(reason));
1795 }
1796 if options.write_fetch_head && !options.append {
1797 fs::write(git_dir.join("FETCH_HEAD"), b"")?;
1798 }
1799 apply_fetch_ref_updates(
1800 store,
1801 format,
1802 fetch_head_source,
1803 log_all_ref_updates,
1804 updates,
1805 ref_hook,
1806 )?;
1807 if options.write_fetch_head {
1808 write_finalized_fetch_head(
1811 git_dir,
1812 fetch_head_source,
1813 default_head_fetch,
1814 updates,
1815 opportunistic_dsts,
1816 true,
1817 )?;
1818 outcome.wrote_fetch_head = true;
1819 }
1820 outcome.ref_updates = std::mem::take(updates);
1821 return Ok(());
1822 }
1823 let (rejected, rejection) =
1824 non_atomic_non_fast_forward_rejections(git_dir, format, store, updates)?;
1825 let accepted = updates
1826 .iter()
1827 .enumerate()
1828 .filter(|(index, _)| !rejected.contains(index))
1829 .map(|(_, update)| update.clone())
1830 .collect::<Vec<_>>();
1831 if accepted.is_empty()
1832 && let Some(reason) = rejection.as_ref()
1833 {
1834 return Err(GitError::Command(reason.clone()));
1835 }
1836 if options.write_fetch_head {
1837 write_finalized_fetch_head(
1838 git_dir,
1839 fetch_head_source,
1840 default_head_fetch,
1841 updates,
1842 opportunistic_dsts,
1843 options.append,
1844 )?;
1845 outcome.wrote_fetch_head = true;
1846 }
1847 apply_fetch_ref_updates(
1848 store,
1849 format,
1850 fetch_head_source,
1851 log_all_ref_updates,
1852 &accepted,
1853 ref_hook,
1854 )?;
1855 if let Some(reason) = rejection {
1856 return Err(GitError::Command(reason));
1857 }
1858 outcome.ref_updates = std::mem::take(updates);
1859 Ok(())
1860}
1861
1862fn validate_incoming_fetch_objects(
1863 db: &FileObjectDatabase,
1864 format: ObjectFormat,
1865 updates: &[FetchRefUpdate],
1866 policy: Option<&sley_fsck::FsckPolicy>,
1867 require_connectivity: bool,
1868) -> Result<()> {
1869 if policy.is_none() && !require_connectivity {
1870 return Ok(());
1871 }
1872 let mut seen = HashSet::new();
1873 let roots = updates
1874 .iter()
1875 .map(|update| update.oid)
1876 .filter(|oid| seen.insert(*oid))
1877 .collect::<Vec<_>>();
1878 if roots.is_empty() {
1879 return Ok(());
1880 }
1881 let options = policy.map_or_else(
1882 || sley_fsck::FsckOptions {
1883 connectivity_only: true,
1884 check_content: false,
1885 ..Default::default()
1886 },
1887 |policy| policy.fsck_options(policy.enabled),
1888 );
1889 let report = sley_fsck::fsck_objects_with_options(db, format, roots, [], options);
1890 for issue in &report.issues {
1891 match issue.stream {
1892 sley_fsck::IssueStream::Stdout => println!("{}", issue.message),
1893 sley_fsck::IssueStream::Stderr => eprintln!("{}", issue.message),
1894 }
1895 }
1896 if report.is_ok() {
1897 Ok(())
1898 } else {
1899 Err(GitError::Exit(128))
1900 }
1901}
1902
1903fn drop_redundant_symref_alias_updates(
1909 store: &FileRefStore,
1910 updates: &mut Vec<FetchRefUpdate>,
1911) -> Result<()> {
1912 let target_updates = updates
1913 .iter()
1914 .filter_map(|update| update.dst.clone().map(|dst| (dst, update.oid)))
1915 .collect::<Vec<_>>();
1916 let mut keep = Vec::with_capacity(updates.len());
1917 for update in updates.drain(..) {
1918 let redundant = match update.dst.as_deref() {
1919 Some(dst) => match store.read_ref(dst)? {
1920 Some(RefTarget::Symbolic(target)) => target_updates
1921 .iter()
1922 .any(|(candidate, oid)| candidate == &target && *oid == update.oid),
1923 _ => false,
1924 },
1925 None => false,
1926 };
1927 if !redundant {
1928 keep.push(update);
1929 }
1930 }
1931 *updates = keep;
1932 Ok(())
1933}
1934
1935fn write_finalized_fetch_head(
1940 git_dir: &Path,
1941 fetch_head_source: &str,
1942 default_head_fetch: bool,
1943 updates: &[FetchRefUpdate],
1944 opportunistic_dsts: &HashSet<String>,
1945 append: bool,
1946) -> Result<()> {
1947 if default_head_fetch
1948 && updates.len() == 1
1949 && updates[0].src == "HEAD"
1950 && updates[0].dst.is_none()
1951 {
1952 return write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, append);
1953 }
1954 let records: Vec<FetchRefUpdate> = updates
1955 .iter()
1956 .filter(|update| {
1957 update
1958 .dst
1959 .as_deref()
1960 .is_none_or(|dst| !opportunistic_dsts.contains(dst))
1961 })
1962 .cloned()
1963 .collect();
1964 write_fetch_head(git_dir, fetch_head_source, &records, append)
1965}
1966
1967fn atomic_non_fast_forward_rejection(
1972 git_dir: &Path,
1973 format: ObjectFormat,
1974 store: &FileRefStore,
1975 updates: &[FetchRefUpdate],
1976) -> Result<Option<String>> {
1977 let mut db: Option<FileObjectDatabase> = None;
1978 for update in updates {
1979 let Some(dst) = update.dst.as_deref() else {
1980 continue;
1981 };
1982 if update.force {
1983 continue;
1984 }
1985 let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
1986 continue;
1987 };
1988 if old == update.oid || dst.starts_with("refs/tags/") {
1989 continue;
1990 }
1991 let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
1992 if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
1993 return Ok(Some(format!(
1994 "! [rejected] {} -> {} (non-fast-forward)",
1995 update.src, dst
1996 )));
1997 }
1998 }
1999 Ok(None)
2000}
2001
2002fn non_atomic_non_fast_forward_rejections(
2007 git_dir: &Path,
2008 format: ObjectFormat,
2009 store: &FileRefStore,
2010 updates: &[FetchRefUpdate],
2011) -> Result<(HashSet<usize>, Option<String>)> {
2012 let mut rejected = HashSet::new();
2013 let mut reason = None;
2014 let mut db: Option<FileObjectDatabase> = None;
2015 for (index, update) in updates.iter().enumerate() {
2016 let Some(dst) = update.dst.as_deref() else {
2017 continue;
2018 };
2019 if update.force || dst.starts_with("refs/tags/") {
2020 continue;
2021 }
2022 let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
2023 continue;
2024 };
2025 if old == update.oid {
2026 continue;
2027 }
2028 let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
2029 if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
2030 rejected.insert(index);
2031 reason.get_or_insert_with(|| {
2032 format!(
2033 "! [rejected] {} -> {} (non-fast-forward)",
2034 update.src, dst
2035 )
2036 });
2037 }
2038 }
2039 Ok((rejected, reason))
2040}
2041
2042fn apply_fetch_ref_updates(
2043 store: &FileRefStore,
2044 format: ObjectFormat,
2045 fetch_head_source: &str,
2046 log_all_ref_updates: bool,
2047 updates: &[FetchRefUpdate],
2048 ref_hook: Option<&dyn sley_refs::ReferenceTransactionHook>,
2049) -> Result<()> {
2050 let mut seen = BTreeSet::new();
2051 let mut tx = store.transaction();
2052 if let Some(hook) = ref_hook {
2053 tx = tx.with_hook(hook);
2054 }
2055 for update in updates {
2056 let Some(dst) = update.dst.as_deref() else {
2057 continue;
2058 };
2059 if !seen.insert(dst.to_string()) {
2060 return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
2061 }
2062 let old_oid = match store.read_ref(dst)? {
2063 Some(RefTarget::Direct(oid)) => Some(oid),
2064 Some(RefTarget::Symbolic(target)) => {
2065 return Err(GitError::Transaction(format!(
2066 "fetch ref {dst} would overwrite symbolic ref {target}"
2067 )));
2068 }
2069 None => None,
2070 };
2071 let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
2072 Some(ReflogEntry {
2073 old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
2074 new_oid: update.oid,
2075 committer: fetch_reflog_committer(),
2076 message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
2077 })
2078 } else {
2079 None
2080 };
2081 tx.update(RefUpdate {
2082 name: dst.to_string(),
2083 expected: old_oid.map(RefTarget::Direct),
2084 new: RefTarget::Direct(update.oid),
2085 reflog,
2086 });
2087 }
2088 tx.commit()
2089}
2090
2091pub fn fetch_max_input_size(config: &GitConfig) -> Option<u64> {
2094 config_max_input_size(config, "fetch", "maxInputSize")
2095 .or_else(|| config_max_input_size(config, "transfer", "maxSize"))
2096}
2097
2098fn config_max_input_size(config: &GitConfig, section: &str, key: &str) -> Option<u64> {
2099 let raw = config.get(section, None, key)?;
2100 match sley_config::parse_config_int(raw) {
2101 Some(limit) if limit > 0 => Some(limit as u64),
2102 _ => None,
2103 }
2104}
2105
2106fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
2107 match config.get("core", None, "logallrefupdates") {
2108 Some(value) => {
2109 let value = value.to_ascii_lowercase();
2110 matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
2111 }
2112 None => false,
2113 }
2114}
2115
2116fn fetch_should_write_reflog(refname: &str) -> bool {
2117 refname == "HEAD"
2118 || refname.starts_with("refs/heads/")
2119 || refname.starts_with("refs/remotes/")
2120 || refname.starts_with("refs/notes/")
2121}
2122
2123fn fetch_reflog_committer() -> Vec<u8> {
2124 let seconds = SystemTime::now()
2125 .duration_since(UNIX_EPOCH)
2126 .map(|duration| duration.as_secs())
2127 .unwrap_or(0);
2128 format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
2129}
2130
2131fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
2132 let src = fetch_reflog_short_ref(&update.src);
2133 let dst = update
2134 .dst
2135 .as_deref()
2136 .map(fetch_reflog_short_ref)
2137 .unwrap_or_else(|| update.src.clone());
2138 let action = if !old_exists {
2139 if update.src.starts_with("refs/tags/") {
2140 "storing tag"
2141 } else if update.src.starts_with("refs/heads/") {
2142 "storing head"
2143 } else {
2144 "storing ref"
2145 }
2146 } else if update.force {
2147 "forced-update"
2148 } else if update.src.starts_with("refs/tags/") {
2149 "updating tag"
2150 } else {
2151 "fast-forward"
2152 };
2153 format!("fetch {source} {src}:{dst}: {action}").into_bytes()
2154}
2155
2156fn fetch_reflog_short_ref(refname: &str) -> String {
2157 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
2158 if let Some(short) = refname.strip_prefix(prefix) {
2159 return short.to_string();
2160 }
2161 }
2162 refname.to_string()
2163}
2164
2165fn validate_fetch_ref_updates(
2166 git_dir: &Path,
2167 _format: ObjectFormat,
2168 store: &FileRefStore,
2169 update_head_ok: bool,
2170 updates: &[FetchRefUpdate],
2171) -> Result<()> {
2172 for update in updates {
2173 let Some(dst) = update.dst.as_deref() else {
2174 continue;
2175 };
2176 let old = match store.read_ref(dst)? {
2177 Some(RefTarget::Direct(oid)) => Some(oid),
2178 Some(RefTarget::Symbolic(target)) => {
2179 return Err(GitError::Transaction(format!(
2180 "ref {dst} would overwrite symbolic ref {target}"
2181 )));
2182 }
2183 None => None,
2184 };
2185 if old.is_some()
2186 && !update_head_ok
2187 && dst.starts_with("refs/heads/")
2188 && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
2189 {
2190 return Err(GitError::InvalidFormat(format!(
2191 "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
2192 worktree.path.display()
2193 )));
2194 }
2195 if old.is_some()
2196 && old != Some(update.oid)
2197 && dst.starts_with("refs/tags/")
2198 && !update.force
2199 {
2200 return Err(GitError::Command(format!(
2201 "! [rejected] {} -> {} (would clobber existing tag)",
2202 update.src, dst
2203 )));
2204 }
2205 }
2206 Ok(())
2207}
2208
2209fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
2211 symrefs
2212 .iter()
2213 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
2214}
2215
2216fn reject_shallow_clone_fetch(
2217 options: &FetchOptions,
2218 shallow_info: &[sley_protocol::ProtocolV2FetchShallowInfo],
2219) -> Result<()> {
2220 let deepening =
2225 options.depth.is_some() || options.deepen_since.is_some() || !options.deepen_not.is_empty();
2226 if options.reject_shallow && options.cloning && !deepening && !shallow_info.is_empty() {
2227 eprintln!("fatal: source repository is shallow, reject to clone.");
2228 return Err(GitError::Exit(128));
2229 }
2230 Ok(())
2231}
2232
2233fn custom_negotiation_haves(
2234 git_dir: &Path,
2235 format: ObjectFormat,
2236 config: &GitConfig,
2237 remote: &str,
2238 options: &FetchOptions,
2239) -> Result<Option<Vec<ObjectId>>> {
2240 if config
2245 .get("fetch", None, "negotiationalgorithm")
2246 .is_some_and(|value| value.eq_ignore_ascii_case("noop"))
2247 {
2248 return Ok(Some(Vec::new()));
2249 }
2250 let restrict = match &options.negotiation_restrict {
2251 Some(values) => values.clone(),
2252 None => configured_negotiation_values(config, remote, "negotiationrestrict"),
2253 };
2254 let include = match &options.negotiation_include {
2255 Some(values) => values.clone(),
2256 None => configured_negotiation_values(config, remote, "negotiationinclude"),
2257 };
2258 if restrict.is_empty() && include.is_empty() {
2259 return Ok(None);
2260 }
2261
2262 let store = FileRefStore::new(git_dir, format);
2263 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2264 let mut seen = HashSet::new();
2265 let mut haves = Vec::new();
2266 if restrict.is_empty() {
2267 for oid in crate::local::local_have_oids(git_dir, format)? {
2268 push_have_oid(&mut haves, &mut seen, oid);
2269 }
2270 } else {
2271 for value in &restrict {
2272 for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
2273 push_have_oid(&mut haves, &mut seen, oid);
2274 }
2275 }
2276 }
2277 for value in &include {
2278 for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
2279 push_have_oid(&mut haves, &mut seen, oid);
2280 }
2281 }
2282 Ok(Some(haves))
2283}
2284
2285fn configured_negotiation_values(config: &GitConfig, remote: &str, key: &str) -> Vec<String> {
2286 let mut values = Vec::new();
2287 if !remote_exists(config, remote) {
2288 return values;
2289 }
2290 for value in remote_config_values(config, remote, key) {
2291 if value.is_empty() {
2292 values.clear();
2293 } else {
2294 values.push(value);
2295 }
2296 }
2297 values
2298}
2299
2300fn push_have_oid(haves: &mut Vec<ObjectId>, seen: &mut HashSet<ObjectId>, oid: ObjectId) {
2301 if seen.insert(oid) {
2302 haves.push(oid);
2303 }
2304}
2305
2306fn resolve_negotiation_have_value(
2307 git_dir: &Path,
2308 format: ObjectFormat,
2309 store: &FileRefStore,
2310 db: &FileObjectDatabase,
2311 value: &str,
2312) -> Result<Vec<ObjectId>> {
2313 if has_glob(value) {
2314 let mut out = Vec::new();
2315 for reference in store.list_refs()? {
2316 let RefTarget::Direct(oid) = reference.target else {
2317 continue;
2318 };
2319 if negotiation_pattern_matches(value, &reference.name) {
2320 out.push(oid);
2321 }
2322 }
2323 out.sort();
2324 out.dedup();
2325 return Ok(out);
2326 }
2327 if is_full_hex_oid(format, value) {
2328 let oid = ObjectId::from_hex(format, value)?;
2329 return if db.contains(&oid)? {
2330 Ok(vec![oid])
2331 } else {
2332 Err(GitError::InvalidFormat(format!(
2333 "fatal: the object {oid} does not exist"
2334 )))
2335 };
2336 }
2337 match sley_rev::resolve_revision(git_dir, format, value) {
2338 Ok(oid) => Ok(vec![oid]),
2339 Err(_) => Ok(Vec::new()),
2340 }
2341}
2342
2343fn has_glob(value: &str) -> bool {
2344 value
2345 .as_bytes()
2346 .iter()
2347 .any(|byte| matches!(byte, b'*' | b'?'))
2348}
2349
2350fn is_full_hex_oid(format: ObjectFormat, value: &str) -> bool {
2351 value.len() == format.hex_len() && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
2352}
2353
2354fn negotiation_pattern_matches(pattern: &str, refname: &str) -> bool {
2355 glob_match(pattern, refname)
2356 || ["refs/heads/", "refs/tags/", "refs/remotes/"]
2357 .iter()
2358 .filter_map(|prefix| refname.strip_prefix(prefix))
2359 .any(|short| glob_match(pattern, short))
2360}
2361
2362fn glob_match(pattern: &str, text: &str) -> bool {
2363 glob_match_bytes(pattern.as_bytes(), text.as_bytes())
2364}
2365
2366fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
2367 let (mut p, mut t) = (0, 0);
2368 let mut star = None;
2369 let mut match_after_star = 0;
2370 while t < text.len() {
2371 if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
2372 p += 1;
2373 t += 1;
2374 } else if p < pattern.len() && pattern[p] == b'*' {
2375 star = Some(p);
2376 p += 1;
2377 match_after_star = t;
2378 } else if let Some(star_pos) = star {
2379 p = star_pos + 1;
2380 match_after_star += 1;
2381 t = match_after_star;
2382 } else {
2383 return false;
2384 }
2385 }
2386 while p < pattern.len() && pattern[p] == b'*' {
2387 p += 1;
2388 }
2389 p == pattern.len()
2390}
2391
2392pub fn apply_configured_partial_clone_filter(
2394 config: &GitConfig,
2395 remote: &str,
2396 options: &mut FetchOptions,
2397) {
2398 if config
2399 .get_bool("remote", Some(remote), "promisor")
2400 .unwrap_or(false)
2401 && let Some(filter) = config.get("remote", Some(remote), "partialclonefilter")
2402 {
2403 options.filter_auto = filter.eq_ignore_ascii_case("auto");
2404 options.filter = if options.filter_auto {
2405 None
2406 } else {
2407 crate::pack_filter_from_spec(filter)
2408 };
2409 }
2410}
2411
2412pub fn apply_configured_remote_tag_option(
2415 config: &GitConfig,
2416 source: &str,
2417 options: &mut FetchOptions,
2418) {
2419 if options.tag_option_explicit || !remote_exists(config, source) {
2420 return;
2421 }
2422 match remote_config_values(config, source, "tagopt")
2423 .into_iter()
2424 .last()
2425 .as_deref()
2426 {
2427 Some("--tags") => {
2428 options.auto_follow_tags = true;
2429 options.fetch_all_tags = true;
2430 }
2431 Some("--no-tags") => {
2432 options.auto_follow_tags = false;
2433 options.fetch_all_tags = false;
2434 }
2435 _ => {}
2436 }
2437}
2438
2439pub fn apply_configured_fetch_prune_option(
2442 config: &GitConfig,
2443 source: &str,
2444 options: &mut FetchOptions,
2445) {
2446 if !options.prune_option_explicit {
2447 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
2448 options.prune = prune;
2449 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
2450 options.prune = prune;
2451 }
2452 }
2453 if !options.prune_tags_option_explicit {
2454 if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
2455 options.prune_tags = prune_tags;
2456 } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
2457 options.prune_tags = prune_tags;
2458 }
2459 }
2460}
2461
2462pub fn fetch_refspecs_for_source(
2466 configured: Vec<String>,
2467 refspecs: &[String],
2468 fetch_all_tags: bool,
2469) -> Vec<String> {
2470 let mut effective = if !refspecs.is_empty() {
2471 refspecs.to_vec()
2472 } else if configured.is_empty() {
2473 vec!["HEAD".to_string()]
2474 } else {
2475 configured
2476 };
2477 if fetch_all_tags {
2478 effective.push("refs/tags/*:refs/tags/*".to_string());
2479 }
2480 effective
2481}
2482
2483fn prune_refspecs_for_source(
2484 configured: &[String],
2485 refspecs: &[String],
2486 prune_tags: bool,
2487) -> Vec<String> {
2488 let mut effective = if !refspecs.is_empty() {
2489 refspecs.to_vec()
2490 } else {
2491 configured.to_vec()
2492 };
2493 if prune_tags && refspecs.is_empty() {
2494 effective.push("refs/tags/*:refs/tags/*".to_string());
2495 }
2496 effective
2497}
2498
2499fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
2504 if refspec.pattern {
2505 let Some((prefix, suffix)) = src.split_once('*') else {
2506 return false;
2507 };
2508 let fits = |name: &str| {
2513 name.len() >= prefix.len() + suffix.len()
2514 && name.starts_with(prefix)
2515 && name.ends_with(suffix)
2516 };
2517 fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
2518 } else {
2519 refname_matches(merge_src, src) || refname_matches(src, merge_src)
2520 }
2521}
2522
2523pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
2525 for update in updates {
2526 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
2527 update.not_for_merge = true;
2528 }
2529 }
2530}
2531
2532pub fn retain_missing_auto_follow_tags(
2534 store: &FileRefStore,
2535 updates: &mut Vec<FetchRefUpdate>,
2536) -> Result<()> {
2537 let mut retained = Vec::with_capacity(updates.len());
2538 for update in updates.drain(..) {
2539 if update.not_for_merge
2540 && update.src.starts_with("refs/tags/")
2541 && update.dst.as_deref() == Some(&update.src)
2542 && store.read_ref(&update.src)?.is_some()
2543 {
2544 continue;
2545 }
2546 retained.push(update);
2547 }
2548 *updates = retained;
2549 Ok(())
2550}
2551
2552pub fn append_reachable_auto_follow_tags(
2555 advertisements: &[RefAdvertisement],
2556 remote_db: &FileObjectDatabase,
2557 local_db: Option<&FileObjectDatabase>,
2558 format: ObjectFormat,
2559 refspecs: &[RefSpec],
2560 updates: &mut Vec<FetchRefUpdate>,
2561 deepen_excluded: Option<&HashSet<ObjectId>>,
2562) -> Result<()> {
2563 if !updates.iter().any(|update| update.dst.is_some()) {
2564 return Ok(());
2565 }
2566 updates.retain(|update| {
2571 !(update.src.starts_with("refs/tags/")
2572 && update.dst.as_deref() == Some(update.src.as_str())
2573 && update.not_for_merge)
2574 });
2575 let mut starts = Vec::new();
2580 for update in updates.iter().filter(|update| update.dst.is_some()) {
2581 if update.src.starts_with("refs/tags/") {
2582 if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
2583 starts.push(target);
2584 } else {
2585 starts.push(update.oid);
2586 }
2587 } else {
2588 starts.push(update.oid);
2589 }
2590 }
2591 let reachable = match deepen_excluded {
2595 Some(excluded) => {
2596 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
2597 }
2598 None => {
2599 collect_reachable_object_ids_tolerating_promised_missing(remote_db, format, starts)?
2600 }
2601 };
2602 let fetched_srcs = updates
2603 .iter()
2604 .map(|update| update.src.clone())
2605 .collect::<HashSet<_>>();
2606 let mut followed = Vec::new();
2607 for reference in advertisements {
2608 if !reference.name.starts_with("refs/tags/")
2609 || fetched_srcs.contains(&reference.name)
2610 || fetch_refspec_excludes(refspecs, &reference.name)?
2611 {
2612 continue;
2613 }
2614 let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
2622 let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
2623 let present_locally = local_db
2624 .map(|db| db.contains(&target))
2625 .transpose()?
2626 .unwrap_or(false);
2627 if !fetched && !present_locally {
2628 continue;
2629 }
2630 followed.push(FetchRefUpdate {
2631 src: reference.name.clone(),
2632 dst: Some(reference.name.clone()),
2633 oid: reference.oid,
2634 not_for_merge: true,
2635 force: false,
2636 });
2637 }
2638 followed.sort_by(|a, b| a.src.cmp(&b.src));
2639 updates.extend(followed);
2640 Ok(())
2641}
2642
2643fn peel_tag_target(
2648 db: &FileObjectDatabase,
2649 format: ObjectFormat,
2650 oid: &ObjectId,
2651) -> Result<Option<ObjectId>> {
2652 let mut current = *oid;
2653 let mut peeled = None;
2654 loop {
2655 let Ok(object) = db.read_object(¤t) else {
2656 return Ok(peeled);
2657 };
2658 if object.object_type != sley_object::ObjectType::Tag {
2659 return Ok(peeled);
2660 }
2661 let tag = sley_object::Tag::parse(format, &object.body)?;
2662 current = tag.object;
2663 peeled = Some(current);
2664 }
2665}
2666
2667pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
2669 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
2670 if refspec.pattern {
2671 if refspec_map_source(refspec, name)?.is_some() {
2672 return Ok(true);
2673 }
2674 } else if refspec.src.as_deref() == Some(name) {
2675 return Ok(true);
2676 }
2677 }
2678 Ok(false)
2679}
2680
2681pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
2684 let followed_oids = updates
2685 .iter()
2686 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
2687 .map(|update| update.oid)
2688 .collect::<HashSet<_>>();
2689 if followed_oids.is_empty() {
2690 return;
2691 }
2692
2693 let mut non_tags = Vec::new();
2694 let mut followed_tags = Vec::new();
2695 let mut other_tags = Vec::new();
2696 for update in updates.drain(..) {
2697 if update.src.starts_with("refs/tags/") {
2698 if followed_oids.contains(&update.oid) {
2699 followed_tags.push(update);
2700 } else {
2701 other_tags.push(update);
2702 }
2703 } else {
2704 non_tags.push(update);
2705 }
2706 }
2707 updates.extend(non_tags);
2708 updates.extend(followed_tags);
2709 updates.extend(other_tags);
2710}
2711
2712pub fn write_default_fetch_head(
2714 git_dir: &Path,
2715 source: &str,
2716 oid: ObjectId,
2717 append: bool,
2718) -> Result<()> {
2719 let records = [FetchHeadRecord {
2720 oid,
2721 not_for_merge: false,
2722 description: source.to_string(),
2723 }];
2724 write_fetch_head_records(git_dir, &records, append)?;
2725 Ok(())
2726}
2727
2728pub fn write_fetch_head_records(
2730 git_dir: &Path,
2731 records: &[FetchHeadRecord],
2732 append: bool,
2733) -> Result<()> {
2734 let encoded = encode_fetch_head(records)?;
2735 if append {
2736 let mut file = fs::OpenOptions::new()
2737 .create(true)
2738 .append(true)
2739 .open(git_dir.join("FETCH_HEAD"))?;
2740 file.write_all(&encoded)?;
2741 } else {
2742 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
2743 }
2744 Ok(())
2745}
2746
2747pub fn write_fetch_head(
2749 git_dir: &Path,
2750 description: &str,
2751 fetched: &[FetchRefUpdate],
2752 append: bool,
2753) -> Result<()> {
2754 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
2755 write_fetch_head_records(git_dir, &records, append)?;
2756 Ok(())
2757}
2758
2759pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
2762 let url = remote_config_values(config, source, "url")
2763 .into_iter()
2764 .next()
2765 .map(|url| rewrite_url_with_config(config, &url, false))
2766 .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
2767 redact_url_for_display(&trim_fetch_head_display_url(&url))
2768}
2769
2770fn trim_fetch_head_display_url(url: &str) -> String {
2774 let bytes = url.as_bytes();
2775 let mut end = bytes.len();
2776 while end > 0 && bytes[end - 1] == b'/' {
2777 end -= 1;
2778 }
2779 if end > 5 && &bytes[end - 4..end] == b".git" {
2782 end -= 4;
2783 }
2784 String::from_utf8_lossy(&bytes[..end]).into_owned()
2785}
2786
2787pub struct PruneRefsInput<'a> {
2792 pub config: &'a GitConfig,
2793 pub store: &'a FileRefStore,
2794 pub remote: &'a str,
2795 pub advertisements: &'a [RefAdvertisement],
2796 pub refspecs: &'a [RefSpec],
2797 pub dry_run: bool,
2798 pub quiet: bool,
2799}
2800
2801pub fn prune_refs_from_advertisements(
2802 input: PruneRefsInput<'_>,
2803 progress: &mut dyn ProgressSink,
2804) -> Result<Vec<PrunedRef>> {
2805 let remote_refs = input
2806 .advertisements
2807 .iter()
2808 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
2809 .map(|advertisement| advertisement.name.as_str())
2810 .collect::<BTreeSet<_>>();
2811 let local_refs = input.store.list_refs()?;
2812 let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
2813 if stale_refs.is_empty() {
2814 return Ok(Vec::new());
2815 }
2816 let mut emit = |line: &str| {
2817 if !input.quiet {
2818 progress.message(line);
2819 }
2820 };
2821 let display_url = redact_url_for_display(
2822 &remote_config_values(input.config, input.remote, "url")
2823 .into_iter()
2824 .next()
2825 .unwrap_or_else(|| input.remote.into()),
2826 );
2827 emit(&format!("Pruning {}", input.remote));
2828 emit(&format!("URL: {display_url}"));
2829 let mut pruned = Vec::new();
2830 for refname in stale_refs {
2831 if !input.dry_run {
2832 match input.store.read_ref(&refname)? {
2833 Some(RefTarget::Symbolic(_)) => {
2834 let _ = input.store.delete_symbolic_ref(&refname)?;
2835 }
2836 Some(RefTarget::Direct(_)) => {
2837 let _ = input.store.delete_ref(&refname)?;
2838 }
2839 None => {}
2840 }
2841 }
2842 let display = prettify_pruned_ref(input.remote, &refname);
2843 let action = if input.dry_run {
2844 "would prune"
2845 } else {
2846 "pruned"
2847 };
2848 emit(&format!(" * [{action}] {display}"));
2849 let branch = display;
2850 pruned.push(PrunedRef { branch, refname });
2851 }
2852 Ok(pruned)
2853}
2854
2855fn stale_refs_for_prune(
2856 local_refs: &[Ref],
2857 refspecs: &[RefSpec],
2858 remote_refs: &BTreeSet<&str>,
2859) -> Result<Vec<String>> {
2860 let mut stale = Vec::new();
2861 for reference in local_refs {
2862 if matches!(reference.target, RefTarget::Symbolic(_)) {
2863 continue;
2864 }
2865 let sources = prune_sources_for_destination(refspecs, &reference.name)?;
2866 if sources.is_empty() {
2867 continue;
2868 }
2869 if sources
2870 .iter()
2871 .all(|source| !remote_refs.contains(source.as_str()))
2872 {
2873 stale.push(reference.name.clone());
2874 }
2875 }
2876 stale.sort();
2877 Ok(stale)
2878}
2879
2880fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
2881 let mut sources = Vec::new();
2882 for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
2883 let Some(src) = refspec.src.as_deref() else {
2884 continue;
2885 };
2886 let Some(dst) = refspec.dst.as_deref() else {
2887 continue;
2888 };
2889 if refspec.pattern {
2890 let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
2891 continue;
2892 };
2893 let Some(middle) = destination
2894 .strip_prefix(dst_prefix)
2895 .and_then(|value| value.strip_suffix(dst_suffix))
2896 else {
2897 continue;
2898 };
2899 let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
2900 GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
2901 })?;
2902 sources.push(format!("{src_prefix}{middle}{src_suffix}"));
2903 } else if dst == destination {
2904 sources.push(src.to_string());
2905 }
2906 }
2907 sources.sort();
2908 sources.dedup();
2909 Ok(sources)
2910}
2911
2912fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
2913 if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
2914 return format!("{remote}/{branch}");
2915 }
2916 if let Some(tag) = refname.strip_prefix("refs/tags/") {
2917 return tag.to_string();
2918 }
2919 refname.to_string()
2920}
2921
2922#[cfg(test)]
2923mod tests {
2924 use super::*;
2925 use std::sync::atomic::{AtomicU64, Ordering};
2926
2927 use sley_config::{ConfigEntry, ConfigSection};
2928 use sley_formats::RepositoryLayout;
2929 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2930 use sley_odb::{FileObjectDatabase, ObjectWriter};
2931 use sley_refs::{RefTarget, RefUpdate};
2932
2933 use crate::{NoCredentials, SilentProgress};
2934
2935 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2936
2937 #[test]
2938 fn repository_plan_uses_injected_branch_snapshot() {
2939 let config = GitConfig {
2940 preamble: Vec::new(),
2941 suffix: Vec::new(),
2942 sections: vec![
2943 ConfigSection::new(
2944 "remote",
2945 Some("upstream".into()),
2946 vec![ConfigEntry::new("url", Some("../upstream".into()))],
2947 ),
2948 ConfigSection::new(
2949 "branch",
2950 Some("topic".into()),
2951 vec![
2952 ConfigEntry::new("remote", Some("upstream".into())),
2953 ConfigEntry::new("merge", Some("refs/heads/topic".into())),
2954 ConfigEntry::new("merge", Some("refs/heads/next".into())),
2955 ],
2956 ),
2957 ],
2958 };
2959 let plan = plan_fetch_repository(&config, Some("topic"), None);
2960 assert_eq!(plan.remote, "upstream");
2961 assert_eq!(plan.merge_srcs, ["refs/heads/topic", "refs/heads/next"]);
2962
2963 let explicit = plan_fetch_repository(&config, Some("topic"), Some("other"));
2964 assert_eq!(explicit.remote, "other");
2965 assert!(explicit.merge_srcs.is_empty());
2966 }
2967
2968 fn temp_repo(name: &str) -> PathBuf {
2969 let dir = std::env::temp_dir().join(format!(
2970 "sley-remote-fetch-{name}-{}-{}",
2971 std::process::id(),
2972 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2973 ));
2974 let _ = fs::remove_dir_all(&dir);
2975 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2976 .expect("test repository should initialize");
2977 dir.join(".git")
2978 }
2979
2980 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
2981 let format = ObjectFormat::Sha1;
2982 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2983 let tree = db
2984 .write_object(EncodedObject::new(
2985 ObjectType::Tree,
2986 Tree { entries: vec![] }.write(),
2987 ))
2988 .expect("tree should write");
2989 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2990 let oid = db
2991 .write_object(EncodedObject::new(
2992 ObjectType::Commit,
2993 Commit {
2994 tree,
2995 parents: Vec::new(),
2996 author: identity.clone(),
2997 committer: identity,
2998 encoding: None,
2999 message: format!("{message}\n").into_bytes(),
3000 }
3001 .write(),
3002 ))
3003 .expect("commit should write");
3004 let store = FileRefStore::new(git_dir, format);
3005 let mut tx = store.transaction();
3006 tx.update(RefUpdate {
3007 name: format!("refs/heads/{branch}"),
3008 expected: None,
3009 new: RefTarget::Direct(oid),
3010 reflog: None,
3011 });
3012 tx.update(RefUpdate {
3013 name: "HEAD".into(),
3014 expected: None,
3015 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
3016 reflog: None,
3017 });
3018 tx.commit().expect("refs should update");
3019 oid
3020 }
3021
3022 fn commit_child_on(git_dir: &Path, branch: &str, parent: ObjectId, message: &str) -> ObjectId {
3023 let format = ObjectFormat::Sha1;
3024 let db = FileObjectDatabase::from_git_dir(git_dir, format);
3025 let tree = db
3026 .write_object(EncodedObject::new(
3027 ObjectType::Tree,
3028 Tree { entries: vec![] }.write(),
3029 ))
3030 .expect("tree should write");
3031 let identity = b"Test User <test@example.invalid> 2 +0000".to_vec();
3032 let oid = db
3033 .write_object(EncodedObject::new(
3034 ObjectType::Commit,
3035 Commit {
3036 tree,
3037 parents: vec![parent],
3038 author: identity.clone(),
3039 committer: identity,
3040 encoding: None,
3041 message: format!("{message}\n").into_bytes(),
3042 }
3043 .write(),
3044 ))
3045 .expect("child commit should write");
3046 let store = FileRefStore::new(git_dir, format);
3047 let mut tx = store.transaction();
3048 tx.update(RefUpdate {
3049 name: format!("refs/heads/{branch}"),
3050 expected: None,
3051 new: RefTarget::Direct(oid),
3052 reflog: None,
3053 });
3054 tx.commit().expect("branch should update");
3055 oid
3056 }
3057
3058 fn default_options() -> FetchOptions {
3059 FetchOptions {
3060 quiet: true,
3061 progress: None,
3062 auto_follow_tags: false,
3063 fetch_all_tags: false,
3064 prune: false,
3065 prune_tags: false,
3066 dry_run: false,
3067 force: false,
3068 append: false,
3069 write_fetch_head: true,
3070 tag_option_explicit: true,
3071 prune_option_explicit: true,
3072 prune_tags_option_explicit: true,
3073 refmap: None,
3074 depth: None,
3075 merge_srcs: Vec::new(),
3076 filter: None,
3077 filter_auto: false,
3078 refetch: false,
3079 cloning: false,
3080 record_promisor_refs: true,
3081 update_shallow: false,
3082 reject_shallow: false,
3083 deepen_relative: false,
3084 update_head_ok: false,
3085 deepen_since: None,
3086 deepen_not: Vec::new(),
3087 ssh_options: None,
3088 upload_pack_command: None,
3089 atomic: false,
3090 negotiation_restrict: None,
3091 negotiation_include: None,
3092 }
3093 }
3094
3095 #[derive(Default)]
3096 struct RecordingProgress {
3097 transfers: Vec<PackGenerationProgress>,
3098 }
3099
3100 impl ProgressSink for RecordingProgress {
3101 fn pack_generation(&mut self, progress: &PackGenerationProgress) {
3102 self.transfers.push(*progress);
3103 }
3104 }
3105
3106 #[test]
3107 fn local_fetch_returns_and_emits_truthful_transfer_counts() {
3108 let remote = temp_repo("remote-transfer-progress");
3109 let local = temp_repo("local-transfer-progress");
3110 let remote_tip = commit_on(&remote, "main", "remote tip");
3111 let source = FetchSource::Local {
3112 git_dir: remote.clone(),
3113 common_git_dir: remote.clone(),
3114 };
3115 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
3116 let mut options = default_options();
3117 options.quiet = false;
3118 options.progress = Some(true);
3119 let mut credentials = NoCredentials;
3120 let mut progress = RecordingProgress::default();
3121
3122 let outcome = fetch(
3123 FetchRequest {
3124 git_dir: &local,
3125 format: ObjectFormat::Sha1,
3126 config: &GitConfig::default(),
3127 remote_name: "origin",
3128 source: &source,
3129 refspecs: &refspecs,
3130 options: &options,
3131 validation: None,
3132 },
3133 FetchServices {
3134 credentials: &mut credentials,
3135 progress: &mut progress,
3136 ref_hook: None,
3137 },
3138 )
3139 .expect("fetch should succeed");
3140
3141 let transfer = outcome
3142 .pack_generation_progress
3143 .expect("non-empty local fetch should report equal-work counts");
3144 assert_eq!(
3145 transfer,
3146 PackGenerationProgress {
3147 total_objects: 2,
3148 compression_objects: 1,
3149 delta_objects: 0,
3150 }
3151 );
3152 assert_eq!(progress.transfers, vec![transfer]);
3153 assert!(
3154 FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1)
3155 .contains(&remote_tip)
3156 .expect("read fetched commit")
3157 );
3158 }
3159
3160 #[test]
3161 fn noop_negotiator_selects_an_explicit_empty_have_set() {
3162 let local = temp_repo("noop-negotiator");
3163 let config = GitConfig::parse(b"[fetch]\n\tnegotiationAlgorithm = noop\n")
3164 .expect("config should parse");
3165 assert_eq!(
3166 custom_negotiation_haves(
3167 &local,
3168 ObjectFormat::Sha1,
3169 &config,
3170 "origin",
3171 &default_options(),
3172 )
3173 .expect("negotiation haves"),
3174 Some(Vec::new())
3175 );
3176 }
3177
3178 #[test]
3179 fn non_atomic_fetch_rejects_non_fast_forward_and_force_updates_it() {
3180 let remote = temp_repo("remote-non-fast-forward");
3181 let local = temp_repo("local-non-fast-forward");
3182 let remote_tip = commit_on(&remote, "main", "remote tip");
3183 let local_tip = commit_on(&local, "side", "local side");
3184 let source = FetchSource::Local {
3185 git_dir: remote.clone(),
3186 common_git_dir: remote.clone(),
3187 };
3188 let refspecs = vec!["refs/heads/main:refs/heads/side".to_string()];
3189 let mut options = default_options();
3190 options.update_head_ok = true;
3191 let mut credentials = NoCredentials;
3192 let mut progress = SilentProgress;
3193
3194 let error = fetch(
3195 FetchRequest {
3196 git_dir: &local,
3197 format: ObjectFormat::Sha1,
3198 config: &GitConfig::default(),
3199 remote_name: ".",
3200 source: &source,
3201 refspecs: &refspecs,
3202 options: &options,
3203 validation: None,
3204 },
3205 FetchServices {
3206 credentials: &mut credentials,
3207 progress: &mut progress,
3208 ref_hook: None,
3209 },
3210 )
3211 .expect_err("divergent update must fail");
3212 assert!(error.to_string().contains("non-fast-forward"));
3213 let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3214 assert_eq!(
3215 refs.read_ref("refs/heads/side").expect("read side"),
3216 Some(RefTarget::Direct(local_tip))
3217 );
3218
3219 options.force = true;
3220 fetch(
3221 FetchRequest {
3222 git_dir: &local,
3223 format: ObjectFormat::Sha1,
3224 config: &GitConfig::default(),
3225 remote_name: ".",
3226 source: &source,
3227 refspecs: &refspecs,
3228 options: &options,
3229 validation: None,
3230 },
3231 FetchServices {
3232 credentials: &mut credentials,
3233 progress: &mut progress,
3234 ref_hook: None,
3235 },
3236 )
3237 .expect("forced fetch should update");
3238 assert_eq!(
3239 refs.read_ref("refs/heads/side").expect("read forced side"),
3240 Some(RefTarget::Direct(remote_tip))
3241 );
3242 }
3243
3244 #[test]
3245 fn redundant_symbolic_destination_is_preserved_when_target_is_updated() {
3246 let local = temp_repo("symref-alias");
3247 let tip = commit_on(&local, "main", "tip");
3248 let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3249 let mut tx = refs.transaction();
3250 tx.update(RefUpdate {
3251 name: "refs/remotes/origin/main".into(),
3252 expected: None,
3253 new: RefTarget::Direct(tip),
3254 reflog: None,
3255 });
3256 tx.update(RefUpdate {
3257 name: "refs/remotes/origin/HEAD".into(),
3258 expected: None,
3259 new: RefTarget::Symbolic("refs/remotes/origin/main".into()),
3260 reflog: None,
3261 });
3262 tx.commit().expect("remote refs");
3263 let mut updates = vec![
3264 FetchRefUpdate {
3265 src: "refs/remotes/origin/HEAD".into(),
3266 dst: Some("refs/remotes/origin/HEAD".into()),
3267 oid: tip,
3268 not_for_merge: false,
3269 force: false,
3270 },
3271 FetchRefUpdate {
3272 src: "refs/remotes/origin/main".into(),
3273 dst: Some("refs/remotes/origin/main".into()),
3274 oid: tip,
3275 not_for_merge: false,
3276 force: false,
3277 },
3278 ];
3279
3280 drop_redundant_symref_alias_updates(&refs, &mut updates).expect("dedupe alias");
3281 assert_eq!(updates.len(), 1);
3282 assert_eq!(updates[0].dst.as_deref(), Some("refs/remotes/origin/main"));
3283 assert_eq!(
3284 refs.read_ref("refs/remotes/origin/HEAD")
3285 .expect("read symref"),
3286 Some(RefTarget::Symbolic("refs/remotes/origin/main".into()))
3287 );
3288 }
3289
3290 #[test]
3291 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
3292 let remote = temp_repo("remote");
3293 let local = temp_repo("local");
3294 let tip = commit_on(&remote, "main", "remote tip");
3295 let source = FetchSource::Local {
3296 git_dir: remote.clone(),
3297 common_git_dir: remote.clone(),
3298 };
3299 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
3300 let options = default_options();
3301 let config = GitConfig::parse(b"[fetch]\n\tfsckObjects = true\n").expect("config");
3302 let policy = sley_fsck::FsckPolicy::from_config(
3303 &config,
3304 sley_fsck::FsckConfigKind::Fetch,
3305 ObjectFormat::Sha1,
3306 &local,
3307 false,
3308 )
3309 .expect("fetch policy");
3310 let mut credentials = NoCredentials;
3311 let mut progress = SilentProgress;
3312
3313 let outcome = fetch(
3314 FetchRequest {
3315 git_dir: &local,
3316 format: ObjectFormat::Sha1,
3317 config: &config,
3318 remote_name: "origin",
3319 source: &source,
3320 refspecs: &refspecs,
3321 options: &options,
3322 validation: Some(&policy),
3323 },
3324 FetchServices {
3325 credentials: &mut credentials,
3326 progress: &mut progress,
3327 ref_hook: None,
3328 },
3329 )
3330 .expect("fetch should succeed");
3331
3332 assert_eq!(outcome.ref_updates.len(), 1);
3333 assert!(outcome.wrote_fetch_head);
3334 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
3335 assert!(local_db.contains(&tip).expect("contains should read"));
3336 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3337 assert_eq!(
3338 local_refs
3339 .read_ref("refs/remotes/origin/main")
3340 .expect("ref should read"),
3341 Some(RefTarget::Direct(tip))
3342 );
3343 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
3344 assert!(fetch_head.contains("origin"));
3345 }
3346
3347 struct RecordingHttpClient {
3352 advertisement: Vec<u8>,
3353 content_type: String,
3354 get_calls: std::sync::atomic::AtomicUsize,
3355 post_calls: std::sync::atomic::AtomicUsize,
3356 }
3357
3358 impl HttpClient for RecordingHttpClient {
3359 fn get(
3360 &self,
3361 _url: &str,
3362 _headers: &[(&str, &str)],
3363 ) -> Result<sley_transport::HttpResponse> {
3364 self.get_calls
3365 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3366 Ok(sley_transport::HttpResponse {
3367 status: 200,
3368 content_type: Some(self.content_type.clone()),
3369 body: Box::new(std::io::Cursor::new(self.advertisement.clone())),
3370 })
3371 }
3372
3373 fn post(
3374 &self,
3375 _url: &str,
3376 _content_type: &str,
3377 _headers: &[(&str, &str)],
3378 _body: &[u8],
3379 ) -> Result<sley_transport::HttpResponse> {
3380 self.post_calls
3381 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3382 Err(GitError::Command(
3383 "recording client received an unexpected POST".into(),
3384 ))
3385 }
3386 }
3387
3388 #[test]
3389 fn fetch_with_http_client_dials_injected_client() {
3390 use sley_protocol::{
3391 GitService, ProtocolVersion, RefAdvertisement, RefAdvertisementSet,
3392 smart_http_advertisement_content_type,
3393 };
3394 use sley_transport::{
3395 ServiceAnnouncement, ServiceDiscoveryPayload, ServiceDiscoveryResponse,
3396 parse_remote_url, write_service_discovery_response,
3397 };
3398
3399 let local = temp_repo("http-inject");
3400 let tip = commit_on(&local, "main", "already-present tip");
3404
3405 let advertisement = {
3407 let response = ServiceDiscoveryResponse {
3408 announcement: ServiceAnnouncement {
3409 service: GitService::UploadPack,
3410 },
3411 payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
3412 protocol: ProtocolVersion::V0,
3413 refs: vec![RefAdvertisement {
3414 oid: tip.clone(),
3415 name: "refs/heads/main".into(),
3416 capabilities: Vec::new(),
3417 }],
3418 shallow: Vec::new(),
3419 }),
3420 };
3421 let mut body = Vec::new();
3422 write_service_discovery_response(&mut body, &response)
3423 .expect("advertisement should encode");
3424 body
3425 };
3426
3427 let client = RecordingHttpClient {
3428 advertisement,
3429 content_type: smart_http_advertisement_content_type(GitService::UploadPack)
3430 .expect("content type"),
3431 get_calls: std::sync::atomic::AtomicUsize::new(0),
3432 post_calls: std::sync::atomic::AtomicUsize::new(0),
3433 };
3434
3435 let source =
3436 FetchSource::Http(parse_remote_url("http://example.invalid/repo.git").expect("url"));
3437 let options = default_options();
3438 let mut credentials = NoCredentials;
3439 let mut progress = SilentProgress;
3440
3441 let outcome = fetch_with_http_client(
3442 FetchRequest {
3443 git_dir: &local,
3444 format: ObjectFormat::Sha1,
3445 config: &GitConfig::default(),
3446 remote_name: "origin",
3447 source: &source,
3448 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3449 options: &options,
3450 validation: None,
3451 },
3452 FetchServices {
3453 credentials: &mut credentials,
3454 progress: &mut progress,
3455 ref_hook: None,
3456 },
3457 Some(&client),
3458 )
3459 .expect("fetch via injected client should succeed");
3460
3461 assert_eq!(
3464 client.get_calls.load(std::sync::atomic::Ordering::Relaxed),
3465 1,
3466 "sley must dial through the injected client"
3467 );
3468 assert_eq!(
3469 client.post_calls.load(std::sync::atomic::Ordering::Relaxed),
3470 0,
3471 "no pack POST expected when the want is already present"
3472 );
3473 assert_eq!(outcome.ref_updates.len(), 1);
3475 let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3476 assert_eq!(
3477 refs.read_ref("refs/remotes/origin/main")
3478 .expect("ref should read"),
3479 Some(RefTarget::Direct(tip))
3480 );
3481 }
3482
3483 #[test]
3484 fn fetch_fsck_rejects_before_ref_or_fetch_head_mutation() {
3485 let remote = temp_repo("remote-fsck-reject");
3486 let local = temp_repo("local-fsck-reject");
3487 let format = ObjectFormat::Sha1;
3488 let remote_db = FileObjectDatabase::from_git_dir(&remote, format);
3489 let tree = remote_db
3490 .write_object(EncodedObject::new(
3491 ObjectType::Tree,
3492 Tree { entries: vec![] }.write(),
3493 ))
3494 .expect("tree");
3495 let malformed = format!(
3496 "tree {tree}\nauthor Bugs Bunny 1 +0000\ncommitter Bugs <bugs@example.invalid> 1 +0000\n\nbad\n"
3497 );
3498 let tip = remote_db
3499 .write_object(EncodedObject::new(
3500 ObjectType::Commit,
3501 malformed.into_bytes(),
3502 ))
3503 .expect("malformed commit");
3504 let remote_refs = FileRefStore::new(&remote, format);
3505 let mut tx = remote_refs.transaction();
3506 tx.update(RefUpdate {
3507 name: "refs/heads/main".into(),
3508 expected: None,
3509 new: RefTarget::Direct(tip),
3510 reflog: None,
3511 });
3512 tx.commit().expect("remote ref");
3513 let config = GitConfig::parse(b"[fetch]\n\tfsckObjects = true\n").expect("config");
3514 let policy = sley_fsck::FsckPolicy::from_config(
3515 &config,
3516 sley_fsck::FsckConfigKind::Fetch,
3517 format,
3518 Path::new("."),
3519 false,
3520 )
3521 .expect("policy");
3522 let source = FetchSource::Local {
3523 git_dir: remote.clone(),
3524 common_git_dir: remote,
3525 };
3526 let options = default_options();
3527 let mut credentials = NoCredentials;
3528 let mut progress = SilentProgress;
3529 let result = fetch(
3530 FetchRequest {
3531 git_dir: &local,
3532 format,
3533 config: &config,
3534 remote_name: "origin",
3535 source: &source,
3536 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3537 options: &options,
3538 validation: Some(&policy),
3539 },
3540 FetchServices {
3541 credentials: &mut credentials,
3542 progress: &mut progress,
3543 ref_hook: None,
3544 },
3545 );
3546 assert!(
3547 result.is_err(),
3548 "malformed incoming commit must be rejected"
3549 );
3550 assert_eq!(
3551 FileRefStore::new(&local, format)
3552 .read_ref("refs/remotes/origin/main")
3553 .expect("local ref read"),
3554 None
3555 );
3556 assert!(!local.join("FETCH_HEAD").exists());
3557 assert!(
3558 !FileObjectDatabase::from_git_dir(&local, format)
3559 .contains(&tip)
3560 .expect("rejected object lookup")
3561 );
3562 }
3563
3564 #[test]
3565 fn shallow_local_fetch_writes_depth_boundary_metadata() {
3566 let remote = temp_repo("remote-shallow");
3567 let local = temp_repo("local-shallow");
3568 let tip = commit_on(&remote, "main", "tip");
3569 let source = FetchSource::Local {
3570 git_dir: remote.clone(),
3571 common_git_dir: remote.clone(),
3572 };
3573 let mut options = default_options();
3574 options.depth = Some(1);
3575 let mut credentials = NoCredentials;
3576 let mut progress = SilentProgress;
3577
3578 fetch(
3579 FetchRequest {
3580 git_dir: &local,
3581 format: ObjectFormat::Sha1,
3582 config: &GitConfig::default(),
3583 remote_name: "origin",
3584 source: &source,
3585 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3586 options: &options,
3587 validation: None,
3588 },
3589 FetchServices {
3590 credentials: &mut credentials,
3591 progress: &mut progress,
3592 ref_hook: None,
3593 },
3594 )
3595 .expect("shallow fetch should succeed");
3596
3597 assert_eq!(
3598 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3599 .expect("shallow file should read"),
3600 vec![tip]
3601 );
3602 }
3603
3604 #[test]
3605 fn shallow_validation_uses_pending_boundary_inside_quarantine() {
3606 let remote = temp_repo("remote-shallow-validation");
3607 let local = temp_repo("local-shallow-validation");
3608 let parent = commit_on(&remote, "main", "parent");
3609 let tip = commit_child_on(&remote, "main", parent, "tip");
3610 let source = FetchSource::Local {
3611 git_dir: remote.clone(),
3612 common_git_dir: remote,
3613 };
3614 let config = GitConfig::default();
3615 let policy = sley_fsck::FsckPolicy::from_config(
3616 &config,
3617 sley_fsck::FsckConfigKind::Fetch,
3618 ObjectFormat::Sha1,
3619 Path::new("."),
3620 false,
3621 )
3622 .expect("fetch validation policy");
3623 let mut options = default_options();
3624 options.depth = Some(1);
3625 let mut credentials = NoCredentials;
3626 let mut progress = SilentProgress;
3627
3628 fetch(
3629 FetchRequest {
3630 git_dir: &local,
3631 format: ObjectFormat::Sha1,
3632 config: &config,
3633 remote_name: "origin",
3634 source: &source,
3635 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3636 options: &options,
3637 validation: Some(&policy),
3638 },
3639 FetchServices {
3640 credentials: &mut credentials,
3641 progress: &mut progress,
3642 ref_hook: None,
3643 },
3644 )
3645 .expect("pending shallow boundary should make quarantine connected");
3646
3647 assert_eq!(
3648 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3649 .expect("shallow file should read"),
3650 vec![tip]
3651 );
3652 assert!(
3653 !FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1)
3654 .contains(&parent)
3655 .expect("parent lookup should succeed"),
3656 "depth-one transfer should remain genuinely shallow"
3657 );
3658 }
3659
3660 #[test]
3661 fn invalid_unshallow_is_rejected_before_destination_shallow_mutation() {
3662 let remote = temp_repo("remote-invalid-unshallow");
3663 let local = temp_repo("local-invalid-unshallow");
3664 let parent = commit_on(&remote, "main", "parent");
3665 let tip = commit_child_on(&remote, "main", parent, "tip");
3666 let source = FetchSource::Local {
3667 git_dir: remote.clone(),
3668 common_git_dir: remote,
3669 };
3670 let mut shallow_options = default_options();
3671 shallow_options.depth = Some(1);
3672 let mut credentials = NoCredentials;
3673 let mut progress = SilentProgress;
3674
3675 fetch(
3676 FetchRequest {
3677 git_dir: &local,
3678 format: ObjectFormat::Sha1,
3679 config: &GitConfig::default(),
3680 remote_name: "origin",
3681 source: &source,
3682 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3683 options: &shallow_options,
3684 validation: None,
3685 },
3686 FetchServices {
3687 credentials: &mut credentials,
3688 progress: &mut progress,
3689 ref_hook: None,
3690 },
3691 )
3692 .expect("create depth-one destination");
3693 assert_eq!(
3694 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3695 .expect("read initial shallow boundary"),
3696 vec![tip]
3697 );
3698 assert!(
3699 !FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1)
3700 .contains(&parent)
3701 .expect("parent lookup"),
3702 "the omitted parent makes unshallowing the tip invalid"
3703 );
3704
3705 let store = FileRefStore::new(&local, ObjectFormat::Sha1);
3706 let options = default_options();
3707 let mut quarantine = Some(
3708 sley_odb::IncomingPackQuarantine::new(&local, ObjectFormat::Sha1)
3709 .expect("empty incoming quarantine"),
3710 );
3711 let mut updates = vec![FetchRefUpdate {
3712 src: "refs/heads/main".into(),
3713 dst: Some("refs/remotes/origin/main".into()),
3714 oid: tip,
3715 not_for_merge: false,
3716 force: false,
3717 }];
3718 let mut outcome = FetchOutcome::default();
3719 let shallow_info = [sley_protocol::ProtocolV2FetchShallowInfo::Unshallow(tip)];
3720
3721 let result = finalize_fetch(
3722 FetchFinalize {
3723 git_dir: &local,
3724 format: ObjectFormat::Sha1,
3725 store: &store,
3726 options: &options,
3727 fetch_head_source: "origin",
3728 default_head_fetch: false,
3729 log_all_ref_updates: true,
3730 ref_hook: None,
3731 opportunistic_dsts: &HashSet::new(),
3732 validation: None,
3733 quarantine: &mut quarantine,
3734 shallow_info: &shallow_info,
3735 },
3736 &mut updates,
3737 &mut outcome,
3738 );
3739
3740 assert!(result.is_err(), "missing parent must reject the unshallow");
3741 assert_eq!(
3742 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3743 .expect("destination shallow boundary survives rejection"),
3744 vec![tip]
3745 );
3746 }
3747
3748 #[test]
3749 fn relative_deepen_is_noop_for_complete_repository() {
3750 let remote = temp_repo("remote-complete-deepen");
3751 let local = temp_repo("local-complete-deepen");
3752 let parent = commit_on(&remote, "main", "parent");
3753 let tip = commit_child_on(&remote, "main", parent, "tip");
3754 let source = FetchSource::Local {
3755 git_dir: remote.clone(),
3756 common_git_dir: remote,
3757 };
3758 let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
3759 let mut credentials = NoCredentials;
3760 let mut progress = SilentProgress;
3761
3762 fetch(
3763 FetchRequest {
3764 git_dir: &local,
3765 format: ObjectFormat::Sha1,
3766 config: &GitConfig::default(),
3767 remote_name: "origin",
3768 source: &source,
3769 refspecs: &refspecs,
3770 options: &default_options(),
3771 validation: None,
3772 },
3773 FetchServices {
3774 credentials: &mut credentials,
3775 progress: &mut progress,
3776 ref_hook: None,
3777 },
3778 )
3779 .expect("full fetch should succeed");
3780
3781 let mut deepen = default_options();
3782 deepen.depth = Some(1);
3783 deepen.deepen_relative = true;
3784 fetch(
3785 FetchRequest {
3786 git_dir: &local,
3787 format: ObjectFormat::Sha1,
3788 config: &GitConfig::default(),
3789 remote_name: "origin",
3790 source: &source,
3791 refspecs: &refspecs,
3792 options: &deepen,
3793 validation: None,
3794 },
3795 FetchServices {
3796 credentials: &mut credentials,
3797 progress: &mut progress,
3798 ref_hook: None,
3799 },
3800 )
3801 .expect("relative deepen from a complete repository should be a full no-op");
3802
3803 assert!(
3804 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3805 .expect("shallow file should read")
3806 .is_empty()
3807 );
3808 let db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
3809 assert!(db.contains(&parent).expect("parent lookup"));
3810 assert!(db.contains(&tip).expect("tip lookup"));
3811 }
3812
3813 fn pack_file_count(git_dir: &Path) -> usize {
3814 fs::read_dir(git_dir.join("objects/pack"))
3815 .expect("pack directory should read")
3816 .filter_map(|entry| entry.ok())
3817 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
3818 .count()
3819 }
3820
3821 #[test]
3822 fn same_depth_shallow_local_fetch_does_not_install_pack() {
3823 let remote = temp_repo("remote-shallow-noop");
3824 let local = temp_repo("local-shallow-noop");
3825 let tip = commit_on(&remote, "main", "tip");
3826 let source = FetchSource::Local {
3827 git_dir: remote.clone(),
3828 common_git_dir: remote.clone(),
3829 };
3830 let mut options = default_options();
3831 options.depth = Some(1);
3832 let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
3833 let mut credentials = NoCredentials;
3834 let mut progress = SilentProgress;
3835
3836 fetch(
3837 FetchRequest {
3838 git_dir: &local,
3839 format: ObjectFormat::Sha1,
3840 config: &GitConfig::default(),
3841 remote_name: "origin",
3842 source: &source,
3843 refspecs: &refspecs,
3844 options: &options,
3845 validation: None,
3846 },
3847 FetchServices {
3848 credentials: &mut credentials,
3849 progress: &mut progress,
3850 ref_hook: None,
3851 },
3852 )
3853 .expect("initial shallow fetch should succeed");
3854 let pack_count = pack_file_count(&local);
3855 let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3856 .expect("shallow file should read");
3857
3858 fetch(
3859 FetchRequest {
3860 git_dir: &local,
3861 format: ObjectFormat::Sha1,
3862 config: &GitConfig::default(),
3863 remote_name: "origin",
3864 source: &source,
3865 refspecs: &refspecs,
3866 options: &options,
3867 validation: None,
3868 },
3869 FetchServices {
3870 credentials: &mut credentials,
3871 progress: &mut progress,
3872 ref_hook: None,
3873 },
3874 )
3875 .expect("same-depth shallow fetch should succeed");
3876
3877 assert_eq!(pack_file_count(&local), pack_count);
3878 assert_eq!(
3879 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3880 .expect("shallow file should read"),
3881 shallow
3882 );
3883 assert_eq!(shallow, vec![tip]);
3884 }
3885
3886 #[test]
3887 fn fetch_head_source_description_redacts_embedded_credentials() {
3888 let config = GitConfig {
3889 sections: vec![ConfigSection::new(
3890 "remote",
3891 Some("origin".into()),
3892 vec![ConfigEntry::new(
3893 "url",
3894 Some("https://user:pass@host/repo.git".into()),
3895 )],
3896 )],
3897 ..GitConfig::default()
3898 };
3899 assert_eq!(
3900 fetch_head_source_description(&config, "origin"),
3901 "https://<redacted>@host/repo"
3902 );
3903 }
3904
3905 #[test]
3906 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
3907 let remote = temp_repo("remote-missing");
3908 let local = temp_repo("local-missing");
3909 let old = commit_on(&local, "main", "old local");
3910 let bogus =
3911 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
3912 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3913 let mut tx = remote_refs.transaction();
3914 tx.update(RefUpdate {
3915 name: "refs/heads/main".into(),
3916 expected: None,
3917 new: RefTarget::Direct(bogus),
3918 reflog: None,
3919 });
3920 tx.update(RefUpdate {
3921 name: "HEAD".into(),
3922 expected: None,
3923 new: RefTarget::Symbolic("refs/heads/main".into()),
3924 reflog: None,
3925 });
3926 tx.commit().expect("remote bogus ref should write");
3927 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3928 let mut tx = local_refs.transaction();
3929 tx.update(RefUpdate {
3930 name: "refs/remotes/origin/main".into(),
3931 expected: None,
3932 new: RefTarget::Direct(old),
3933 reflog: None,
3934 });
3935 tx.commit().expect("local tracking ref should write");
3936 let source = FetchSource::Local {
3937 git_dir: remote.clone(),
3938 common_git_dir: remote.clone(),
3939 };
3940 let options = default_options();
3941 let mut credentials = NoCredentials;
3942 let mut progress = SilentProgress;
3943
3944 let err = fetch(
3945 FetchRequest {
3946 git_dir: &local,
3947 format: ObjectFormat::Sha1,
3948 config: &GitConfig::default(),
3949 remote_name: "origin",
3950 source: &source,
3951 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3952 options: &options,
3953 validation: None,
3954 },
3955 FetchServices {
3956 credentials: &mut credentials,
3957 progress: &mut progress,
3958 ref_hook: None,
3959 },
3960 )
3961 .expect_err("fetch should fail before finalizing refs");
3962
3963 assert!(err.to_string().contains("missing object"));
3964 assert_eq!(
3965 local_refs
3966 .read_ref("refs/remotes/origin/main")
3967 .expect("ref should read"),
3968 Some(RefTarget::Direct(old))
3969 );
3970 assert!(!local.join("FETCH_HEAD").exists());
3971 }
3972
3973 fn config_from_ini(ini: &str) -> GitConfig {
3974 GitConfig::parse(ini.as_bytes()).expect("config should parse")
3975 }
3976
3977 #[test]
3978 fn fetch_max_input_size_unset_means_unlimited() {
3979 assert_eq!(fetch_max_input_size(&GitConfig::default()), None);
3980 }
3981
3982 #[test]
3983 fn fetch_max_input_size_honors_fetch_section() {
3984 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n");
3985 assert_eq!(fetch_max_input_size(&cfg), Some(64));
3986 }
3987
3988 #[test]
3989 fn fetch_max_input_size_falls_back_to_transfer_max_size() {
3990 let cfg = config_from_ini("[transfer]\n\tmaxSize = 1k\n");
3991 assert_eq!(fetch_max_input_size(&cfg), Some(1024));
3992 }
3993
3994 #[test]
3995 fn fetch_max_input_size_prefers_fetch_over_transfer() {
3996 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n[transfer]\n\tmaxSize = 1k\n");
3997 assert_eq!(fetch_max_input_size(&cfg), Some(64));
3998 }
3999
4000 #[test]
4001 fn fetch_max_input_size_non_positive_means_unlimited() {
4002 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 0\n");
4003 assert_eq!(fetch_max_input_size(&cfg), None);
4004 }
4005
4006 #[test]
4007 fn local_fetch_unpack_limit_matches_git_defaults_and_clone_policy() {
4008 let cfg = GitConfig::default();
4009 assert_eq!(local_fetch_unpack_limit(&cfg, false, false), Some(100));
4010 assert_eq!(local_fetch_unpack_limit(&cfg, true, false), None);
4011 assert_eq!(local_fetch_unpack_limit(&cfg, false, true), None);
4012 }
4013
4014 #[test]
4015 fn local_fetch_unpack_limit_prefers_fetch_over_transfer() {
4016 let cfg = config_from_ini("[fetch]\n\tunpackLimit = 17\n[transfer]\n\tunpackLimit = 23\n");
4017 assert_eq!(local_fetch_unpack_limit(&cfg, false, false), Some(17));
4018 }
4019
4020 #[test]
4021 fn local_fetch_unpack_limit_falls_back_to_transfer_and_zero_disables_unpacking() {
4022 let transfer = config_from_ini("[transfer]\n\tunpackLimit = 23\n");
4023 assert_eq!(local_fetch_unpack_limit(&transfer, false, false), Some(23));
4024
4025 let disabled = config_from_ini("[fetch]\n\tunpackLimit = 0\n");
4026 assert_eq!(local_fetch_unpack_limit(&disabled, false, false), None);
4027 }
4028}