1use crate::local::LocalDeepenPlan;
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use sley_config::GitConfig;
26use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
27use sley_core::{GitError, ObjectFormat, ObjectId, Result};
28use sley_odb::{
29 FileObjectDatabase, ObjectReader, collect_reachable_object_ids,
30 collect_reachable_object_ids_excluding,
31};
32#[cfg(feature = "http")]
33use sley_protocol::ProtocolVersion;
34use sley_protocol::{
35 FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
36 fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refname_matches,
37 refspec_map_source,
38};
39use sley_refs::{FileRefStore, Ref, RefTarget, RefUpdate, ReflogEntry};
40use sley_transport::{RemoteTransport, RemoteUrl};
41
42use crate::{CredentialProvider, ProgressSink};
43
44pub enum FetchSource {
49 Http(RemoteUrl),
51 Ssh(RemoteUrl),
54 Git {
56 remote: RemoteUrl,
57 protocol_v2: bool,
58 },
59 Local {
61 git_dir: PathBuf,
63 common_git_dir: PathBuf,
65 },
66}
67
68#[derive(Debug, Clone)]
70pub struct FetchOptions {
71 pub quiet: bool,
74 pub auto_follow_tags: bool,
76 pub fetch_all_tags: bool,
78 pub prune: bool,
80 pub prune_tags: bool,
82 pub dry_run: bool,
84 pub force: bool,
87 pub append: bool,
89 pub write_fetch_head: bool,
91 pub tag_option_explicit: bool,
94 pub prune_option_explicit: bool,
97 pub prune_tags_option_explicit: bool,
100 pub refmap: Option<Vec<String>>,
104 pub depth: Option<u32>,
109 pub merge_srcs: Vec<String>,
116 pub filter: Option<sley_odb::PackObjectFilter>,
122 pub refetch: bool,
125 pub cloning: bool,
128 pub record_promisor_refs: bool,
132 pub update_shallow: bool,
135 pub deepen_relative: bool,
138 pub update_head_ok: bool,
141 pub deepen_since: Option<i64>,
144 pub deepen_not: Vec<String>,
148 pub ssh_options: Option<crate::ssh::SshTransportOptions>,
152 pub atomic: bool,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct PrunedRef {
163 pub branch: String,
165 pub refname: String,
167}
168
169#[derive(Debug, Clone, Default)]
171pub struct FetchOutcome {
172 pub ref_updates: Vec<FetchRefUpdate>,
176 pub pruned: Vec<PrunedRef>,
179 pub head_symref: Option<String>,
182 pub wrote_fetch_head: bool,
184}
185
186pub struct FetchRequest<'a> {
188 pub git_dir: &'a Path,
190 pub format: ObjectFormat,
192 pub config: &'a GitConfig,
194 pub remote_name: &'a str,
196 pub source: &'a FetchSource,
198 pub refspecs: &'a [String],
201 pub options: &'a FetchOptions,
203}
204
205pub struct FetchServices<'a> {
207 pub credentials: &'a mut dyn CredentialProvider,
209 pub progress: &'a mut dyn ProgressSink,
211 pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
216}
217
218pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
230 let ref_hook = services.ref_hook;
231 let mut options = request.options.clone();
232 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
233 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
234 crate::protocol::check_transport_allowed(
235 scheme_for_fetch_source(request.source),
236 Some(request.config),
237 None,
238 )
239 .map_err(crate::protocol::transport_policy_git_error)?;
240 let promisor_remote = request
245 .config
246 .get_bool("remote", Some(request.remote_name), "promisor")
247 .unwrap_or(false)
248 || request.options.filter.is_some();
249 let configured_refspecs = if request.refspecs.is_empty() {
250 remote_config_values(request.config, request.remote_name, "fetch")
251 } else {
252 Vec::new()
253 };
254 let configured_refspecs_empty = configured_refspecs.is_empty();
255 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
261 let default_head_fetch =
262 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
263 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
264 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
265 let prune_refspecs =
266 prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
267 let mut effective_refspecs = fetch_refspecs_for_source(
268 configured_refspecs,
269 request.refspecs,
270 options.fetch_all_tags,
271 );
272 if options.prune_tags
273 && request.refspecs.is_empty()
274 && !effective_refspecs
275 .iter()
276 .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
277 {
278 effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
279 }
280 if has_merge_config {
281 if configured_refspecs_empty && request.refspecs.is_empty() {
284 effective_refspecs.retain(|spec| spec != "HEAD");
285 }
286 let configured_parsed = effective_refspecs
289 .iter()
290 .map(|refspec| parse_refspec(refspec))
291 .collect::<Result<Vec<_>>>()?;
292 for merge_src in &options.merge_srcs {
293 let covered = configured_parsed.iter().any(|refspec| {
297 refspec
298 .src
299 .as_deref()
300 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
301 });
302 if !covered {
303 effective_refspecs.push(merge_src.clone());
306 }
307 }
308 }
309 let mut parsed_refspecs = effective_refspecs
310 .iter()
311 .map(|refspec| parse_refspec(refspec))
312 .collect::<Result<Vec<_>>>()?;
313 if options.force {
314 for refspec in &mut parsed_refspecs {
315 refspec.force = true;
316 }
317 }
318 if options.refmap.is_some() && request.refspecs.is_empty() {
319 return Err(GitError::Command(
320 "--refmap option is only meaningful with command-line refspec(s)".into(),
321 ));
322 }
323 let tracking_refspec_strings = if request.refspecs.is_empty() {
324 Vec::new()
325 } else {
326 options.refmap.clone().unwrap_or_else(|| {
327 configured_refspecs_for_tracking(request.config, request.remote_name)
328 })
329 };
330 let tracking_refspecs = tracking_refspec_strings
331 .iter()
332 .map(|refspec| parse_refspec(refspec))
333 .collect::<Result<Vec<_>>>()?;
334 let parsed_prune_refspecs = prune_refspecs
335 .iter()
336 .map(|refspec| parse_refspec(refspec))
337 .collect::<Result<Vec<_>>>()?;
338
339 let store = FileRefStore::new(request.git_dir, request.format);
340 let mut outcome = FetchOutcome::default();
341
342 let advertisements = match request.source {
346 #[cfg(not(feature = "http"))]
347 FetchSource::Http(_) => {
348 return Err(GitError::Unsupported(
349 "HTTP transport is not enabled in this build".into(),
350 ));
351 }
352 #[cfg(feature = "http")]
353 FetchSource::Http(remote) => {
354 let client = crate::http::new_http_client();
355 let discovered = crate::http::http_service_advertisements(
356 &client,
357 remote,
358 request.format,
359 sley_protocol::GitService::UploadPack,
360 services.credentials,
361 )?;
362 let advertisements = discovered.set.refs;
363 let features = advertisements
364 .first()
365 .map(|advertisement| {
366 sley_protocol::parse_upload_pack_features(&advertisement.capabilities)
367 })
368 .transpose()?
369 .unwrap_or_default();
370 outcome.head_symref = head_symref_from_features(&features.symrefs);
371 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
372 advertisements: &advertisements,
373 refspecs: &parsed_refspecs,
374 options: &options,
375 store: &store,
376 reachable: None,
377 local_db: None,
378 deepen_excluded: None,
379 format: request.format,
380 configured_remote_fetch,
381 has_merge_config,
382 tracking_refspecs: &tracking_refspecs,
383 })?;
384 let wants = updates.iter().map(|update| update.oid).collect();
385 let existing_shallow =
389 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
390 let pack_request = crate::http::HttpFetchPackRequest {
391 client: &client,
392 git_dir: request.git_dir,
393 format: request.format,
394 remote,
395 wants,
396 shallow: existing_shallow,
397 deepen: options.depth,
398 promisor: promisor_remote,
399 };
400 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
401 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
402 GitError::InvalidFormat(
403 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
404 .into(),
405 )
406 })?;
407 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
408 pack_request,
409 handshake,
410 services.credentials,
411 )?
412 } else {
413 crate::http::install_fetch_pack_via_http_upload_pack(
414 pack_request,
415 services.credentials,
416 )?
417 };
418 if !options.dry_run {
419 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
420 }
421 finalize_fetch(
422 FetchFinalize {
423 git_dir: request.git_dir,
424 format: request.format,
425 store: &store,
426 options: &options,
427 fetch_head_source: &fetch_head_source,
428 default_head_fetch,
429 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
430 ref_hook,
431 opportunistic_dsts: &opportunistic_dsts,
432 },
433 &mut updates,
434 &mut outcome,
435 )?;
436 advertisements
437 }
438 FetchSource::Ssh(remote) => {
439 let ssh_options = options
443 .ssh_options
444 .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
445 let (advertisements, features) =
446 crate::ssh::ssh_upload_pack_advertisements_with_options(
447 remote,
448 request.format,
449 ssh_options,
450 )?;
451 outcome.head_symref = head_symref_from_features(&features.symrefs);
452 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
453 advertisements: &advertisements,
454 refspecs: &parsed_refspecs,
455 options: &options,
456 store: &store,
457 reachable: None,
458 local_db: None,
459 deepen_excluded: None,
460 format: request.format,
461 configured_remote_fetch,
462 has_merge_config,
463 tracking_refspecs: &tracking_refspecs,
464 })?;
465 if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
466 append_missing_ext_advertised_tags(
467 &advertisements,
468 &parsed_refspecs,
469 &store,
470 &mut updates,
471 )?;
472 }
473 let wants = updates.iter().map(|update| update.oid).collect();
474 let existing_shallow =
477 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
478 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
479 crate::ssh::SshFetchPackRequest {
480 git_dir: request.git_dir,
481 format: request.format,
482 remote,
483 features: &features,
484 wants,
485 shallow: existing_shallow,
486 deepen: options.depth,
487 promisor: promisor_remote,
488 command_options: ssh_options,
489 },
490 )?;
491 if !options.dry_run {
492 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
493 }
494 finalize_fetch(
495 FetchFinalize {
496 git_dir: request.git_dir,
497 format: request.format,
498 store: &store,
499 options: &options,
500 fetch_head_source: &fetch_head_source,
501 default_head_fetch,
502 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
503 ref_hook,
504 opportunistic_dsts: &opportunistic_dsts,
505 },
506 &mut updates,
507 &mut outcome,
508 )?;
509 advertisements
510 }
511 FetchSource::Git {
512 remote,
513 protocol_v2,
514 } => {
515 let protocol_v2 =
516 *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
517 let discovered = crate::git::git_upload_pack_advertisements_with_protocol(
518 remote,
519 request.format,
520 protocol_v2,
521 )?;
522 let advertisements = discovered.refs;
523 let features = discovered.features;
524 outcome.head_symref = head_symref_from_features(&features.symrefs);
525 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
526 advertisements: &advertisements,
527 refspecs: &parsed_refspecs,
528 options: &options,
529 store: &store,
530 reachable: None,
531 local_db: None,
532 deepen_excluded: None,
533 format: request.format,
534 configured_remote_fetch,
535 has_merge_config,
536 tracking_refspecs: &tracking_refspecs,
537 })?;
538 let wants = updates.iter().map(|update| update.oid).collect();
539 let existing_shallow =
540 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
541 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
542 crate::git::GitFetchPackRequest {
543 git_dir: request.git_dir,
544 format: request.format,
545 remote,
546 features: &features,
547 wants,
548 shallow: existing_shallow,
549 deepen: options.depth,
550 promisor: promisor_remote,
551 protocol_v2: discovered.protocol_v2,
552 },
553 )?;
554 if !options.dry_run {
555 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
556 }
557 finalize_fetch(
558 FetchFinalize {
559 git_dir: request.git_dir,
560 format: request.format,
561 store: &store,
562 options: &options,
563 fetch_head_source: &fetch_head_source,
564 default_head_fetch,
565 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
566 ref_hook,
567 opportunistic_dsts: &opportunistic_dsts,
568 },
569 &mut updates,
570 &mut outcome,
571 )?;
572 advertisements
573 }
574 FetchSource::Local {
575 git_dir: remote_git_dir,
576 common_git_dir: remote_common_git_dir,
577 } => {
578 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
579 if remote_format != request.format {
580 return Err(GitError::InvalidObjectId(format!(
581 "remote repository uses {}, local repository uses {}",
582 remote_format.name(),
583 request.format.name()
584 )));
585 }
586 let advertisements =
587 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
588 if advertisements
592 .iter()
593 .any(|advertisement| advertisement.name == "HEAD")
594 && let Some(RefTarget::Symbolic(target)) =
595 FileRefStore::new(remote_git_dir, request.format).read_ref("HEAD")?
596 {
597 outcome.head_symref = Some(target);
598 }
599 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
600 let remote_shallow =
612 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
613 let explicit_deepen = options.depth.is_some()
614 || options.deepen_since.is_some()
615 || !options.deepen_not.is_empty();
616 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
617 let mut deepen_not_oids = Vec::new();
620 for name in &options.deepen_not {
621 let resolved = advertisements.iter().find(|advertisement| {
622 advertisement.name == *name
623 || advertisement.name == format!("refs/tags/{name}")
624 || advertisement.name == format!("refs/heads/{name}")
625 || advertisement.name == format!("refs/{name}")
626 });
627 match resolved {
628 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
629 None => {
630 return Err(GitError::Command(format!(
631 "git upload-pack: deepen-not is not a ref: {name}"
632 )));
633 }
634 }
635 }
636 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
637 if !explicit_deepen && !implicit_deepen {
638 return Ok(None);
639 }
640 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
642 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
643 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
644 &remote_db,
645 request.format,
646 heads,
647 client_shallow,
648 options.deepen_since,
649 &deepen_not_oids,
650 )?));
651 }
652 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
653 Ok(Some(crate::local::compute_local_deepen(
654 &remote_db,
655 request.format,
656 heads,
657 client_shallow,
658 depth,
659 options.deepen_relative,
660 )?))
661 };
662 let primary_heads = {
663 let primary = plan_fetch_ref_updates(
664 &advertisements,
665 &parsed_refspecs,
666 options.auto_follow_tags,
667 )?;
668 let mut seen = HashSet::new();
669 let mut heads = Vec::new();
670 for update in &primary {
671 if seen.insert(update.oid) {
672 heads.push(update.oid);
673 }
674 }
675 heads
676 };
677 let mut deepen_plan = plan_deepen(&primary_heads)?;
678 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
679 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
680 advertisements: &advertisements,
681 refspecs: &parsed_refspecs,
682 options: &options,
683 store: &store,
684 reachable: Some((&remote_db, &advertisements)),
685 local_db: Some(&local_db),
686 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
687 format: request.format,
688 configured_remote_fetch,
689 has_merge_config,
690 tracking_refspecs: &tracking_refspecs,
691 })?;
692 if implicit_deepen && !options.cloning && !options.update_shallow {
697 let client_shallow: HashSet<ObjectId> =
698 crate::shallow::read_shallow(request.git_dir, request.format)?
699 .into_iter()
700 .collect();
701 let new_points: HashSet<ObjectId> = deepen_plan
702 .as_ref()
703 .map(|plan| {
704 plan.shallow_info
705 .iter()
706 .filter_map(|entry| match entry {
707 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
708 if !client_shallow.contains(oid) =>
709 {
710 Some(*oid)
711 }
712 _ => None,
713 })
714 .collect()
715 })
716 .unwrap_or_default();
717 if !new_points.is_empty() {
718 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
719 let mut dirty = |tip: &ObjectId| -> Result<bool> {
720 if let Some(&cached) = dirty_cache.get(tip) {
721 return Ok(cached);
722 }
723 let result =
724 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
725 dirty_cache.insert(*tip, result);
726 Ok(result)
727 };
728 let mut kept = Vec::new();
729 for update in updates {
730 if dirty(&update.oid)? {
731 continue;
732 }
733 kept.push(update);
734 }
735 updates = kept;
736 let mut seen = HashSet::new();
739 let mut heads = Vec::new();
740 for update in &updates {
741 if seen.insert(update.oid) {
742 heads.push(update.oid);
743 }
744 }
745 deepen_plan = if heads.is_empty() {
746 None
747 } else {
748 plan_deepen(&heads)?
749 };
750 }
751 }
752 let starts: Vec<ObjectId> = if options.refetch {
753 let mut seen = HashSet::new();
754 updates
755 .iter()
756 .map(|update| update.oid)
757 .chain(primary_heads.iter().copied())
758 .filter(|oid| seen.insert(*oid))
759 .collect()
760 } else if deepen_plan.is_none() {
761 let mut starts = Vec::new();
762 for update in &updates {
763 if !local_db.contains(&update.oid)? {
764 starts.push(update.oid);
765 }
766 }
767 starts
768 } else {
769 updates.iter().map(|update| update.oid).collect()
770 };
771 let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
772 if !updates.is_empty() {
773 sley_protocol::trace_packet_write_payload(b"0000");
774 }
775 Vec::new()
776 } else {
777 crate::local::install_fetch_pack_via_local_upload_pack(
778 request.git_dir,
779 remote_git_dir,
780 request.format,
781 starts,
782 deepen_plan.as_ref(),
783 promisor_remote,
784 options.record_promisor_refs,
785 options.filter.clone(),
786 options.refetch,
787 local_fetch_unpack_limit(request.git_dir, promisor_remote),
788 )?
789 };
790 if !options.dry_run {
791 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
792 }
793 finalize_fetch(
794 FetchFinalize {
795 git_dir: request.git_dir,
796 format: request.format,
797 store: &store,
798 options: &options,
799 fetch_head_source: &fetch_head_source,
800 default_head_fetch,
801 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
802 ref_hook,
803 opportunistic_dsts: &opportunistic_dsts,
804 },
805 &mut updates,
806 &mut outcome,
807 )?;
808 advertisements
809 }
810 };
811
812 if options.prune && !parsed_prune_refspecs.is_empty() {
813 outcome.pruned = prune_refs_from_advertisements(
814 PruneRefsInput {
815 config: request.config,
816 store: &store,
817 remote: request.remote_name,
818 advertisements: &advertisements,
819 refspecs: &parsed_prune_refspecs,
820 dry_run: options.dry_run,
821 quiet: options.quiet,
822 },
823 services.progress,
824 )?;
825 }
826
827 Ok(outcome)
828}
829
830fn scheme_for_fetch_source(source: &FetchSource) -> &'static str {
831 match source {
832 FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
833 FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
834 FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
835 FetchSource::Local { .. } => "file",
836 }
837}
838
839fn local_fetch_unpack_limit(git_dir: &Path, promisor_remote: bool) -> Option<usize> {
840 if promisor_remote {
841 return None;
842 }
843 git_dir
844 .join("objects")
845 .join("info")
846 .join("alternates")
847 .exists()
848 .then_some(100)
849}
850
851fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
855 remote_db: &R,
856 format: ObjectFormat,
857 tip: &ObjectId,
858 boundary: &HashSet<ObjectId>,
859) -> Result<bool> {
860 let mut seen: HashSet<ObjectId> = HashSet::new();
861 let mut queue: Vec<ObjectId> = vec![*tip];
862 while let Some(oid) = queue.pop() {
863 if !seen.insert(oid) {
864 continue;
865 }
866 let object = remote_db.read_object(&oid)?;
867 let commit = match object.object_type {
868 sley_object::ObjectType::Commit => {
869 sley_object::Commit::parse_ref(format, &object.body)?
870 }
871 sley_object::ObjectType::Tag => {
872 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
873 queue.push(tag.object);
874 continue;
875 }
876 _ => continue,
877 };
878 if boundary.contains(&oid) {
879 return Ok(true);
880 }
881 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
882 }
883 Ok(false)
884}
885
886fn shallow_boundary_for_request(
891 git_dir: &Path,
892 format: ObjectFormat,
893 depth: Option<u32>,
894) -> Result<Vec<ObjectId>> {
895 if depth.is_none() {
896 return Ok(Vec::new());
897 }
898 crate::shallow::read_shallow(git_dir, format)
899}
900
901struct FetchPlanInput<'a> {
907 advertisements: &'a [RefAdvertisement],
908 refspecs: &'a [RefSpec],
909 options: &'a FetchOptions,
910 store: &'a FileRefStore,
911 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
912 local_db: Option<&'a FileObjectDatabase>,
916 deepen_excluded: Option<&'a HashSet<ObjectId>>,
917 format: ObjectFormat,
918 configured_remote_fetch: bool,
919 has_merge_config: bool,
923 tracking_refspecs: &'a [RefSpec],
925}
926
927fn plan_and_adjust_updates(
928 input: FetchPlanInput<'_>,
929) -> Result<(Vec<FetchRefUpdate>, HashSet<String>)> {
930 let FetchPlanInput {
931 advertisements,
932 refspecs,
933 options,
934 store,
935 reachable,
936 local_db,
937 deepen_excluded,
938 format,
939 configured_remote_fetch,
940 has_merge_config,
941 tracking_refspecs,
942 } = input;
943 let visible_advertisements = advertisements_without_peeled_refs(advertisements);
944 let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
945 advertisements
946 } else {
947 visible_advertisements.as_slice()
948 };
949 let mut updates =
950 plan_fetch_ref_updates(planning_advertisements, refspecs, options.auto_follow_tags)?;
951 if options.fetch_all_tags {
952 mark_tag_refspec_updates_not_for_merge(&mut updates);
953 } else {
954 if options.auto_follow_tags
955 && let Some((remote_db, advertisements)) = reachable
956 {
957 let visible_reachable_advertisements =
958 advertisements_without_peeled_refs(advertisements);
959 let reachable_advertisements =
960 if visible_reachable_advertisements.len() == advertisements.len() {
961 advertisements
962 } else {
963 visible_reachable_advertisements.as_slice()
964 };
965 append_reachable_auto_follow_tags(
966 reachable_advertisements,
967 remote_db,
968 local_db,
969 format,
970 refspecs,
971 &mut updates,
972 deepen_excluded,
973 )?;
974 }
975 retain_missing_auto_follow_tags(store, &mut updates)?;
976 }
977 if configured_remote_fetch || has_merge_config {
978 for update in &mut updates {
979 update.not_for_merge = true;
980 }
981 if !options.merge_srcs.is_empty() {
982 for update in &mut updates {
987 if options
988 .merge_srcs
989 .iter()
990 .any(|src| refname_matches(src, &update.src))
991 {
992 update.not_for_merge = false;
993 }
994 }
995 } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
996 && !first.pattern
997 {
998 if let Some(update) = updates.first_mut() {
1003 update.not_for_merge = false;
1004 }
1005 }
1006 updates.sort_by_key(|update| update.not_for_merge);
1010 }
1011 let opportunistic_dsts =
1012 append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
1013 ref_remove_duplicate_updates(&mut updates)?;
1014 Ok((updates, opportunistic_dsts))
1015}
1016
1017fn ref_remove_duplicate_updates(updates: &mut Vec<FetchRefUpdate>) -> Result<()> {
1022 let mut seen: BTreeMap<String, String> = BTreeMap::new();
1023 let mut error = None;
1024 updates.retain(|update| {
1025 let Some(dst) = update.dst.as_deref() else {
1026 return true;
1027 };
1028 match seen.get(dst) {
1029 Some(prev_src) if prev_src == &update.src => false,
1030 Some(prev_src) => {
1031 if error.is_none() {
1032 error = Some(GitError::Command(format!(
1033 "Cannot fetch both {} and {} to {dst}",
1034 prev_src, update.src
1035 )));
1036 }
1037 true
1038 }
1039 None => {
1040 seen.insert(dst.to_string(), update.src.clone());
1041 true
1042 }
1043 }
1044 });
1045 match error {
1046 Some(err) => Err(err),
1047 None => Ok(()),
1048 }
1049}
1050
1051fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
1052 if remote_exists(config, remote) {
1053 remote_config_values(config, remote, "fetch")
1054 } else {
1055 Vec::new()
1056 }
1057}
1058
1059fn append_opportunistic_tracking_updates(
1064 updates: &mut Vec<FetchRefUpdate>,
1065 tracking_refspecs: &[RefSpec],
1066) -> Result<HashSet<String>> {
1067 let mut opportunistic_dsts = HashSet::new();
1068 if tracking_refspecs.is_empty() {
1069 return Ok(opportunistic_dsts);
1070 }
1071 let mut seen_dsts = updates
1072 .iter()
1073 .filter_map(|update| update.dst.clone())
1074 .collect::<HashSet<_>>();
1075 let mut additions = Vec::new();
1076 for update in updates.iter() {
1077 if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1078 continue;
1079 }
1080 for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1081 let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1082 continue;
1083 };
1084 if !seen_dsts.insert(dst.clone()) {
1085 continue;
1086 }
1087 opportunistic_dsts.insert(dst.clone());
1088 additions.push(FetchRefUpdate {
1089 src: update.src.clone(),
1090 dst: Some(dst),
1091 oid: update.oid,
1092 not_for_merge: true,
1093 force: refspec.force,
1094 });
1095 }
1096 }
1097 updates.extend(additions);
1098 Ok(opportunistic_dsts)
1099}
1100
1101fn advertisements_without_peeled_refs(
1102 advertisements: &[RefAdvertisement],
1103) -> Vec<RefAdvertisement> {
1104 advertisements
1105 .iter()
1106 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1107 .cloned()
1108 .collect()
1109}
1110
1111fn append_missing_ext_advertised_tags(
1112 advertisements: &[RefAdvertisement],
1113 refspecs: &[RefSpec],
1114 store: &FileRefStore,
1115 updates: &mut Vec<FetchRefUpdate>,
1116) -> Result<()> {
1117 let mut seen = updates
1118 .iter()
1119 .map(|update| update.src.clone())
1120 .collect::<HashSet<_>>();
1121 let mut tags = Vec::new();
1122 for reference in advertisements {
1123 if !reference.name.starts_with("refs/tags/")
1124 || reference.name.ends_with("^{}")
1125 || !seen.insert(reference.name.clone())
1126 || fetch_refspec_excludes(refspecs, &reference.name)?
1127 || store.read_ref(&reference.name)?.is_some()
1128 {
1129 continue;
1130 }
1131 tags.push(FetchRefUpdate {
1132 src: reference.name.clone(),
1133 dst: Some(reference.name.clone()),
1134 oid: reference.oid,
1135 not_for_merge: true,
1136 force: false,
1137 });
1138 }
1139 tags.sort_by(|a, b| a.src.cmp(&b.src));
1140 updates.extend(tags);
1141 Ok(())
1142}
1143
1144struct FetchFinalize<'a> {
1148 git_dir: &'a Path,
1149 format: ObjectFormat,
1150 store: &'a FileRefStore,
1151 options: &'a FetchOptions,
1152 fetch_head_source: &'a str,
1153 default_head_fetch: bool,
1154 log_all_ref_updates: bool,
1155 ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
1156 opportunistic_dsts: &'a HashSet<String>,
1159}
1160
1161fn downgrade_non_commit_for_merge(
1167 git_dir: &Path,
1168 format: ObjectFormat,
1169 updates: &mut [FetchRefUpdate],
1170) {
1171 if updates.iter().all(|update| update.not_for_merge) {
1172 return;
1173 }
1174 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1175 for update in updates.iter_mut() {
1176 if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
1177 update.not_for_merge = true;
1178 }
1179 }
1180}
1181
1182fn finalize_fetch(
1183 finalize: FetchFinalize<'_>,
1184 updates: &mut Vec<FetchRefUpdate>,
1185 outcome: &mut FetchOutcome,
1186) -> Result<()> {
1187 let FetchFinalize {
1188 git_dir,
1189 format,
1190 store,
1191 options,
1192 fetch_head_source,
1193 default_head_fetch,
1194 log_all_ref_updates,
1195 ref_hook,
1196 opportunistic_dsts,
1197 } = finalize;
1198 if options.dry_run {
1199 outcome.ref_updates = std::mem::take(updates);
1200 return Ok(());
1201 }
1202 downgrade_non_commit_for_merge(git_dir, format, updates);
1203 validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1204 if options.atomic {
1205 if options.write_fetch_head && !options.append {
1213 fs::write(git_dir.join("FETCH_HEAD"), b"")?;
1214 }
1215 if let Some(reason) = atomic_non_fast_forward_rejection(git_dir, format, store, updates)? {
1216 return Err(GitError::Command(reason));
1217 }
1218 apply_fetch_ref_updates(
1219 store,
1220 format,
1221 fetch_head_source,
1222 log_all_ref_updates,
1223 updates,
1224 ref_hook,
1225 )?;
1226 if options.write_fetch_head {
1227 write_finalized_fetch_head(
1230 git_dir,
1231 fetch_head_source,
1232 default_head_fetch,
1233 updates,
1234 opportunistic_dsts,
1235 true,
1236 )?;
1237 outcome.wrote_fetch_head = true;
1238 }
1239 outcome.ref_updates = std::mem::take(updates);
1240 return Ok(());
1241 }
1242 if options.write_fetch_head {
1243 write_finalized_fetch_head(
1244 git_dir,
1245 fetch_head_source,
1246 default_head_fetch,
1247 updates,
1248 opportunistic_dsts,
1249 options.append,
1250 )?;
1251 outcome.wrote_fetch_head = true;
1252 }
1253 apply_fetch_ref_updates(
1254 store,
1255 format,
1256 fetch_head_source,
1257 log_all_ref_updates,
1258 updates,
1259 ref_hook,
1260 )?;
1261 outcome.ref_updates = std::mem::take(updates);
1262 Ok(())
1263}
1264
1265fn write_finalized_fetch_head(
1270 git_dir: &Path,
1271 fetch_head_source: &str,
1272 default_head_fetch: bool,
1273 updates: &[FetchRefUpdate],
1274 opportunistic_dsts: &HashSet<String>,
1275 append: bool,
1276) -> Result<()> {
1277 if default_head_fetch
1278 && updates.len() == 1
1279 && updates[0].src == "HEAD"
1280 && updates[0].dst.is_none()
1281 {
1282 return write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, append);
1283 }
1284 let records: Vec<FetchRefUpdate> = updates
1285 .iter()
1286 .filter(|update| {
1287 update
1288 .dst
1289 .as_deref()
1290 .is_none_or(|dst| !opportunistic_dsts.contains(dst))
1291 })
1292 .cloned()
1293 .collect();
1294 write_fetch_head(git_dir, fetch_head_source, &records, append)
1295}
1296
1297fn atomic_non_fast_forward_rejection(
1302 git_dir: &Path,
1303 format: ObjectFormat,
1304 store: &FileRefStore,
1305 updates: &[FetchRefUpdate],
1306) -> Result<Option<String>> {
1307 let mut db: Option<FileObjectDatabase> = None;
1308 for update in updates {
1309 let Some(dst) = update.dst.as_deref() else {
1310 continue;
1311 };
1312 if update.force {
1313 continue;
1314 }
1315 let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
1316 continue;
1317 };
1318 if old == update.oid || dst.starts_with("refs/tags/") {
1319 continue;
1320 }
1321 let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
1322 if !crate::push::is_fast_forward(db, format, &old, &update.oid)? {
1323 return Ok(Some(format!(
1324 "! [rejected] {} -> {} (non-fast-forward)",
1325 update.src, dst
1326 )));
1327 }
1328 }
1329 Ok(None)
1330}
1331
1332fn apply_fetch_ref_updates(
1333 store: &FileRefStore,
1334 format: ObjectFormat,
1335 fetch_head_source: &str,
1336 log_all_ref_updates: bool,
1337 updates: &[FetchRefUpdate],
1338 ref_hook: Option<&dyn sley_refs::ReferenceTransactionHook>,
1339) -> Result<()> {
1340 let mut seen = BTreeSet::new();
1341 let mut tx = store.transaction();
1342 if let Some(hook) = ref_hook {
1343 tx = tx.with_hook(hook);
1344 }
1345 for update in updates {
1346 let Some(dst) = update.dst.as_deref() else {
1347 continue;
1348 };
1349 if !seen.insert(dst.to_string()) {
1350 return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
1351 }
1352 let old_oid = match store.read_ref(dst)? {
1353 Some(RefTarget::Direct(oid)) => Some(oid),
1354 Some(RefTarget::Symbolic(target)) => {
1355 return Err(GitError::Transaction(format!(
1356 "fetch ref {dst} would overwrite symbolic ref {target}"
1357 )));
1358 }
1359 None => None,
1360 };
1361 let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
1362 Some(ReflogEntry {
1363 old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
1364 new_oid: update.oid,
1365 committer: fetch_reflog_committer(),
1366 message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
1367 })
1368 } else {
1369 None
1370 };
1371 tx.update(RefUpdate {
1372 name: dst.to_string(),
1373 expected: old_oid.map(RefTarget::Direct),
1374 new: RefTarget::Direct(update.oid),
1375 reflog,
1376 });
1377 }
1378 tx.commit()
1379}
1380
1381fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
1382 match config.get("core", None, "logallrefupdates") {
1383 Some(value) => {
1384 let value = value.to_ascii_lowercase();
1385 matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
1386 }
1387 None => false,
1388 }
1389}
1390
1391fn fetch_should_write_reflog(refname: &str) -> bool {
1392 refname == "HEAD"
1393 || refname.starts_with("refs/heads/")
1394 || refname.starts_with("refs/remotes/")
1395 || refname.starts_with("refs/notes/")
1396}
1397
1398fn fetch_reflog_committer() -> Vec<u8> {
1399 let seconds = SystemTime::now()
1400 .duration_since(UNIX_EPOCH)
1401 .map(|duration| duration.as_secs())
1402 .unwrap_or(0);
1403 format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
1404}
1405
1406fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
1407 let src = fetch_reflog_short_ref(&update.src);
1408 let dst = update
1409 .dst
1410 .as_deref()
1411 .map(fetch_reflog_short_ref)
1412 .unwrap_or_else(|| update.src.clone());
1413 let action = if !old_exists {
1414 if update.src.starts_with("refs/tags/") {
1415 "storing tag"
1416 } else if update.src.starts_with("refs/heads/") {
1417 "storing head"
1418 } else {
1419 "storing ref"
1420 }
1421 } else if update.force {
1422 "forced-update"
1423 } else if update.src.starts_with("refs/tags/") {
1424 "updating tag"
1425 } else {
1426 "fast-forward"
1427 };
1428 format!("fetch {source} {src}:{dst}: {action}").into_bytes()
1429}
1430
1431fn fetch_reflog_short_ref(refname: &str) -> String {
1432 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
1433 if let Some(short) = refname.strip_prefix(prefix) {
1434 return short.to_string();
1435 }
1436 }
1437 refname.to_string()
1438}
1439
1440fn validate_fetch_ref_updates(
1441 git_dir: &Path,
1442 _format: ObjectFormat,
1443 store: &FileRefStore,
1444 update_head_ok: bool,
1445 updates: &[FetchRefUpdate],
1446) -> Result<()> {
1447 for update in updates {
1448 let Some(dst) = update.dst.as_deref() else {
1449 continue;
1450 };
1451 let old = match store.read_ref(dst)? {
1452 Some(RefTarget::Direct(oid)) => Some(oid),
1453 Some(RefTarget::Symbolic(target)) => {
1454 return Err(GitError::Transaction(format!(
1455 "ref {dst} would overwrite symbolic ref {target}"
1456 )));
1457 }
1458 None => None,
1459 };
1460 if old.is_some()
1461 && !update_head_ok
1462 && dst.starts_with("refs/heads/")
1463 && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
1464 {
1465 return Err(GitError::InvalidFormat(format!(
1466 "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
1467 worktree.path.display()
1468 )));
1469 }
1470 if old.is_some()
1471 && old != Some(update.oid)
1472 && dst.starts_with("refs/tags/")
1473 && !update.force
1474 {
1475 return Err(GitError::Command(format!(
1476 "! [rejected] {} -> {} (would clobber existing tag)",
1477 update.src, dst
1478 )));
1479 }
1480 }
1481 Ok(())
1482}
1483
1484fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
1486 symrefs
1487 .iter()
1488 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
1489}
1490
1491pub fn apply_configured_remote_tag_option(
1494 config: &GitConfig,
1495 source: &str,
1496 options: &mut FetchOptions,
1497) {
1498 if options.tag_option_explicit || !remote_exists(config, source) {
1499 return;
1500 }
1501 match remote_config_values(config, source, "tagopt")
1502 .into_iter()
1503 .last()
1504 .as_deref()
1505 {
1506 Some("--tags") => {
1507 options.auto_follow_tags = true;
1508 options.fetch_all_tags = true;
1509 }
1510 Some("--no-tags") => {
1511 options.auto_follow_tags = false;
1512 options.fetch_all_tags = false;
1513 }
1514 _ => {}
1515 }
1516}
1517
1518pub fn apply_configured_fetch_prune_option(
1521 config: &GitConfig,
1522 source: &str,
1523 options: &mut FetchOptions,
1524) {
1525 if !options.prune_option_explicit {
1526 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
1527 options.prune = prune;
1528 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
1529 options.prune = prune;
1530 }
1531 }
1532 if !options.prune_tags_option_explicit {
1533 if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
1534 options.prune_tags = prune_tags;
1535 } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
1536 options.prune_tags = prune_tags;
1537 }
1538 }
1539}
1540
1541pub fn fetch_refspecs_for_source(
1545 configured: Vec<String>,
1546 refspecs: &[String],
1547 fetch_all_tags: bool,
1548) -> Vec<String> {
1549 let mut effective = if !refspecs.is_empty() {
1550 refspecs.to_vec()
1551 } else if configured.is_empty() {
1552 vec!["HEAD".to_string()]
1553 } else {
1554 configured
1555 };
1556 if fetch_all_tags {
1557 effective.push("refs/tags/*:refs/tags/*".to_string());
1558 }
1559 effective
1560}
1561
1562fn prune_refspecs_for_source(
1563 configured: &[String],
1564 refspecs: &[String],
1565 prune_tags: bool,
1566) -> Vec<String> {
1567 let mut effective = if !refspecs.is_empty() {
1568 refspecs.to_vec()
1569 } else {
1570 configured.to_vec()
1571 };
1572 if prune_tags && refspecs.is_empty() {
1573 effective.push("refs/tags/*:refs/tags/*".to_string());
1574 }
1575 effective
1576}
1577
1578fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
1583 if refspec.pattern {
1584 let Some((prefix, suffix)) = src.split_once('*') else {
1585 return false;
1586 };
1587 let fits = |name: &str| {
1592 name.len() >= prefix.len() + suffix.len()
1593 && name.starts_with(prefix)
1594 && name.ends_with(suffix)
1595 };
1596 fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
1597 } else {
1598 refname_matches(merge_src, src) || refname_matches(src, merge_src)
1599 }
1600}
1601
1602pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
1604 for update in updates {
1605 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
1606 update.not_for_merge = true;
1607 }
1608 }
1609}
1610
1611pub fn retain_missing_auto_follow_tags(
1613 store: &FileRefStore,
1614 updates: &mut Vec<FetchRefUpdate>,
1615) -> Result<()> {
1616 let mut retained = Vec::with_capacity(updates.len());
1617 for update in updates.drain(..) {
1618 if update.not_for_merge
1619 && update.src.starts_with("refs/tags/")
1620 && update.dst.as_deref() == Some(&update.src)
1621 && store.read_ref(&update.src)?.is_some()
1622 {
1623 continue;
1624 }
1625 retained.push(update);
1626 }
1627 *updates = retained;
1628 Ok(())
1629}
1630
1631pub fn append_reachable_auto_follow_tags(
1634 advertisements: &[RefAdvertisement],
1635 remote_db: &FileObjectDatabase,
1636 local_db: Option<&FileObjectDatabase>,
1637 format: ObjectFormat,
1638 refspecs: &[RefSpec],
1639 updates: &mut Vec<FetchRefUpdate>,
1640 deepen_excluded: Option<&HashSet<ObjectId>>,
1641) -> Result<()> {
1642 if !updates.iter().any(|update| update.dst.is_some()) {
1643 return Ok(());
1644 }
1645 updates.retain(|update| {
1650 !(update.src.starts_with("refs/tags/")
1651 && update.dst.as_deref() == Some(update.src.as_str())
1652 && update.not_for_merge)
1653 });
1654 let mut starts = Vec::new();
1659 for update in updates.iter().filter(|update| update.dst.is_some()) {
1660 if update.src.starts_with("refs/tags/") {
1661 if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1662 starts.push(target);
1663 } else {
1664 starts.push(update.oid);
1665 }
1666 } else {
1667 starts.push(update.oid);
1668 }
1669 }
1670 let reachable = match deepen_excluded {
1674 Some(excluded) => {
1675 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1676 }
1677 None => collect_reachable_object_ids(remote_db, format, starts)?,
1678 };
1679 let fetched_srcs = updates
1680 .iter()
1681 .map(|update| update.src.clone())
1682 .collect::<HashSet<_>>();
1683 let mut followed = Vec::new();
1684 for reference in advertisements {
1685 if !reference.name.starts_with("refs/tags/")
1686 || fetched_srcs.contains(&reference.name)
1687 || fetch_refspec_excludes(refspecs, &reference.name)?
1688 {
1689 continue;
1690 }
1691 let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
1699 let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
1700 let present_locally = local_db
1701 .map(|db| db.contains(&target))
1702 .transpose()?
1703 .unwrap_or(false);
1704 if !fetched && !present_locally {
1705 continue;
1706 }
1707 followed.push(FetchRefUpdate {
1708 src: reference.name.clone(),
1709 dst: Some(reference.name.clone()),
1710 oid: reference.oid,
1711 not_for_merge: true,
1712 force: false,
1713 });
1714 }
1715 followed.sort_by(|a, b| a.src.cmp(&b.src));
1716 updates.extend(followed);
1717 Ok(())
1718}
1719
1720fn peel_tag_target(
1725 db: &FileObjectDatabase,
1726 format: ObjectFormat,
1727 oid: &ObjectId,
1728) -> Result<Option<ObjectId>> {
1729 let mut current = *oid;
1730 let mut peeled = None;
1731 loop {
1732 let Ok(object) = db.read_object(¤t) else {
1733 return Ok(peeled);
1734 };
1735 if object.object_type != sley_object::ObjectType::Tag {
1736 return Ok(peeled);
1737 }
1738 let tag = sley_object::Tag::parse(format, &object.body)?;
1739 current = tag.object;
1740 peeled = Some(current);
1741 }
1742}
1743
1744pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
1746 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
1747 if refspec.pattern {
1748 if refspec_map_source(refspec, name)?.is_some() {
1749 return Ok(true);
1750 }
1751 } else if refspec.src.as_deref() == Some(name) {
1752 return Ok(true);
1753 }
1754 }
1755 Ok(false)
1756}
1757
1758pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
1761 let followed_oids = updates
1762 .iter()
1763 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
1764 .map(|update| update.oid)
1765 .collect::<HashSet<_>>();
1766 if followed_oids.is_empty() {
1767 return;
1768 }
1769
1770 let mut non_tags = Vec::new();
1771 let mut followed_tags = Vec::new();
1772 let mut other_tags = Vec::new();
1773 for update in updates.drain(..) {
1774 if update.src.starts_with("refs/tags/") {
1775 if followed_oids.contains(&update.oid) {
1776 followed_tags.push(update);
1777 } else {
1778 other_tags.push(update);
1779 }
1780 } else {
1781 non_tags.push(update);
1782 }
1783 }
1784 updates.extend(non_tags);
1785 updates.extend(followed_tags);
1786 updates.extend(other_tags);
1787}
1788
1789pub fn write_default_fetch_head(
1791 git_dir: &Path,
1792 source: &str,
1793 oid: ObjectId,
1794 append: bool,
1795) -> Result<()> {
1796 let records = [FetchHeadRecord {
1797 oid,
1798 not_for_merge: false,
1799 description: source.to_string(),
1800 }];
1801 write_fetch_head_records(git_dir, &records, append)?;
1802 Ok(())
1803}
1804
1805pub fn write_fetch_head_records(
1807 git_dir: &Path,
1808 records: &[FetchHeadRecord],
1809 append: bool,
1810) -> Result<()> {
1811 let encoded = encode_fetch_head(records)?;
1812 if append {
1813 let mut file = fs::OpenOptions::new()
1814 .create(true)
1815 .append(true)
1816 .open(git_dir.join("FETCH_HEAD"))?;
1817 file.write_all(&encoded)?;
1818 } else {
1819 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
1820 }
1821 Ok(())
1822}
1823
1824pub fn write_fetch_head(
1826 git_dir: &Path,
1827 description: &str,
1828 fetched: &[FetchRefUpdate],
1829 append: bool,
1830) -> Result<()> {
1831 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
1832 write_fetch_head_records(git_dir, &records, append)?;
1833 Ok(())
1834}
1835
1836pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
1839 let url = remote_config_values(config, source, "url")
1840 .into_iter()
1841 .next()
1842 .map(|url| rewrite_url_with_config(config, &url, false))
1843 .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
1844 trim_fetch_head_display_url(&url)
1845}
1846
1847fn trim_fetch_head_display_url(url: &str) -> String {
1851 let bytes = url.as_bytes();
1852 let mut end = bytes.len();
1853 while end > 0 && bytes[end - 1] == b'/' {
1854 end -= 1;
1855 }
1856 if end > 5 && &bytes[end - 4..end] == b".git" {
1859 end -= 4;
1860 }
1861 String::from_utf8_lossy(&bytes[..end]).into_owned()
1862}
1863
1864pub struct PruneRefsInput<'a> {
1869 pub config: &'a GitConfig,
1870 pub store: &'a FileRefStore,
1871 pub remote: &'a str,
1872 pub advertisements: &'a [RefAdvertisement],
1873 pub refspecs: &'a [RefSpec],
1874 pub dry_run: bool,
1875 pub quiet: bool,
1876}
1877
1878pub fn prune_refs_from_advertisements(
1879 input: PruneRefsInput<'_>,
1880 progress: &mut dyn ProgressSink,
1881) -> Result<Vec<PrunedRef>> {
1882 let remote_refs = input
1883 .advertisements
1884 .iter()
1885 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1886 .map(|advertisement| advertisement.name.as_str())
1887 .collect::<BTreeSet<_>>();
1888 let local_refs = input.store.list_refs()?;
1889 let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
1890 if stale_refs.is_empty() {
1891 return Ok(Vec::new());
1892 }
1893 let mut emit = |line: &str| {
1894 if !input.quiet {
1895 progress.message(line);
1896 }
1897 };
1898 let display_url = remote_config_values(input.config, input.remote, "url")
1899 .into_iter()
1900 .next()
1901 .unwrap_or_else(|| input.remote.into());
1902 emit(&format!("Pruning {}", input.remote));
1903 emit(&format!("URL: {display_url}"));
1904 let mut pruned = Vec::new();
1905 for refname in stale_refs {
1906 if !input.dry_run {
1907 match input.store.read_ref(&refname)? {
1908 Some(RefTarget::Symbolic(_)) => {
1909 let _ = input.store.delete_symbolic_ref(&refname)?;
1910 }
1911 Some(RefTarget::Direct(_)) => {
1912 let _ = input.store.delete_ref(&refname)?;
1913 }
1914 None => {}
1915 }
1916 }
1917 let display = prettify_pruned_ref(input.remote, &refname);
1918 let action = if input.dry_run {
1919 "would prune"
1920 } else {
1921 "pruned"
1922 };
1923 emit(&format!(" * [{action}] {display}"));
1924 let branch = display;
1925 pruned.push(PrunedRef { branch, refname });
1926 }
1927 Ok(pruned)
1928}
1929
1930fn stale_refs_for_prune(
1931 local_refs: &[Ref],
1932 refspecs: &[RefSpec],
1933 remote_refs: &BTreeSet<&str>,
1934) -> Result<Vec<String>> {
1935 let mut stale = Vec::new();
1936 for reference in local_refs {
1937 if matches!(reference.target, RefTarget::Symbolic(_)) {
1938 continue;
1939 }
1940 let sources = prune_sources_for_destination(refspecs, &reference.name)?;
1941 if sources.is_empty() {
1942 continue;
1943 }
1944 if sources
1945 .iter()
1946 .all(|source| !remote_refs.contains(source.as_str()))
1947 {
1948 stale.push(reference.name.clone());
1949 }
1950 }
1951 stale.sort();
1952 Ok(stale)
1953}
1954
1955fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
1956 let mut sources = Vec::new();
1957 for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
1958 let Some(src) = refspec.src.as_deref() else {
1959 continue;
1960 };
1961 let Some(dst) = refspec.dst.as_deref() else {
1962 continue;
1963 };
1964 if refspec.pattern {
1965 let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
1966 continue;
1967 };
1968 let Some(middle) = destination
1969 .strip_prefix(dst_prefix)
1970 .and_then(|value| value.strip_suffix(dst_suffix))
1971 else {
1972 continue;
1973 };
1974 let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
1975 GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
1976 })?;
1977 sources.push(format!("{src_prefix}{middle}{src_suffix}"));
1978 } else if dst == destination {
1979 sources.push(src.to_string());
1980 }
1981 }
1982 sources.sort();
1983 sources.dedup();
1984 Ok(sources)
1985}
1986
1987fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
1988 if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
1989 return format!("{remote}/{branch}");
1990 }
1991 if let Some(tag) = refname.strip_prefix("refs/tags/") {
1992 return tag.to_string();
1993 }
1994 refname.to_string()
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999 use super::*;
2000 use std::sync::atomic::{AtomicU64, Ordering};
2001
2002 use sley_formats::RepositoryLayout;
2003 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2004 use sley_odb::{FileObjectDatabase, ObjectWriter};
2005 use sley_refs::{RefTarget, RefUpdate};
2006
2007 use crate::{NoCredentials, SilentProgress};
2008
2009 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2010
2011 fn temp_repo(name: &str) -> PathBuf {
2012 let dir = std::env::temp_dir().join(format!(
2013 "sley-remote-fetch-{name}-{}-{}",
2014 std::process::id(),
2015 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2016 ));
2017 let _ = fs::remove_dir_all(&dir);
2018 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2019 .expect("test repository should initialize");
2020 dir.join(".git")
2021 }
2022
2023 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
2024 let format = ObjectFormat::Sha1;
2025 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2026 let tree = db
2027 .write_object(EncodedObject::new(
2028 ObjectType::Tree,
2029 Tree { entries: vec![] }.write(),
2030 ))
2031 .expect("tree should write");
2032 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2033 let oid = db
2034 .write_object(EncodedObject::new(
2035 ObjectType::Commit,
2036 Commit {
2037 tree,
2038 parents: Vec::new(),
2039 author: identity.clone(),
2040 committer: identity,
2041 encoding: None,
2042 message: format!("{message}\n").into_bytes(),
2043 }
2044 .write(),
2045 ))
2046 .expect("commit should write");
2047 let store = FileRefStore::new(git_dir, format);
2048 let mut tx = store.transaction();
2049 tx.update(RefUpdate {
2050 name: format!("refs/heads/{branch}"),
2051 expected: None,
2052 new: RefTarget::Direct(oid),
2053 reflog: None,
2054 });
2055 tx.update(RefUpdate {
2056 name: "HEAD".into(),
2057 expected: None,
2058 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
2059 reflog: None,
2060 });
2061 tx.commit().expect("refs should update");
2062 oid
2063 }
2064
2065 fn default_options() -> FetchOptions {
2066 FetchOptions {
2067 quiet: true,
2068 auto_follow_tags: false,
2069 fetch_all_tags: false,
2070 prune: false,
2071 prune_tags: false,
2072 dry_run: false,
2073 force: false,
2074 append: false,
2075 write_fetch_head: true,
2076 tag_option_explicit: true,
2077 prune_option_explicit: true,
2078 prune_tags_option_explicit: true,
2079 refmap: None,
2080 depth: None,
2081 merge_srcs: Vec::new(),
2082 filter: None,
2083 refetch: false,
2084 cloning: false,
2085 record_promisor_refs: true,
2086 update_shallow: false,
2087 deepen_relative: false,
2088 update_head_ok: false,
2089 deepen_since: None,
2090 deepen_not: Vec::new(),
2091 ssh_options: None,
2092 atomic: false,
2093 }
2094 }
2095
2096 #[test]
2097 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
2098 let remote = temp_repo("remote");
2099 let local = temp_repo("local");
2100 let tip = commit_on(&remote, "main", "remote tip");
2101 let source = FetchSource::Local {
2102 git_dir: remote.clone(),
2103 common_git_dir: remote.clone(),
2104 };
2105 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
2106 let options = default_options();
2107 let mut credentials = NoCredentials;
2108 let mut progress = SilentProgress;
2109
2110 let outcome = fetch(
2111 FetchRequest {
2112 git_dir: &local,
2113 format: ObjectFormat::Sha1,
2114 config: &GitConfig::default(),
2115 remote_name: "origin",
2116 source: &source,
2117 refspecs: &refspecs,
2118 options: &options,
2119 },
2120 FetchServices {
2121 credentials: &mut credentials,
2122 progress: &mut progress,
2123 ref_hook: None,
2124 },
2125 )
2126 .expect("fetch should succeed");
2127
2128 assert_eq!(outcome.ref_updates.len(), 1);
2129 assert!(outcome.wrote_fetch_head);
2130 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
2131 assert!(local_db.contains(&tip).expect("contains should read"));
2132 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2133 assert_eq!(
2134 local_refs
2135 .read_ref("refs/remotes/origin/main")
2136 .expect("ref should read"),
2137 Some(RefTarget::Direct(tip))
2138 );
2139 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
2140 assert!(fetch_head.contains("origin"));
2141 }
2142
2143 #[test]
2144 fn shallow_local_fetch_writes_depth_boundary_metadata() {
2145 let remote = temp_repo("remote-shallow");
2146 let local = temp_repo("local-shallow");
2147 let tip = commit_on(&remote, "main", "tip");
2148 let source = FetchSource::Local {
2149 git_dir: remote.clone(),
2150 common_git_dir: remote.clone(),
2151 };
2152 let mut options = default_options();
2153 options.depth = Some(1);
2154 let mut credentials = NoCredentials;
2155 let mut progress = SilentProgress;
2156
2157 fetch(
2158 FetchRequest {
2159 git_dir: &local,
2160 format: ObjectFormat::Sha1,
2161 config: &GitConfig::default(),
2162 remote_name: "origin",
2163 source: &source,
2164 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2165 options: &options,
2166 },
2167 FetchServices {
2168 credentials: &mut credentials,
2169 progress: &mut progress,
2170 ref_hook: None,
2171 },
2172 )
2173 .expect("shallow fetch should succeed");
2174
2175 assert_eq!(
2176 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2177 .expect("shallow file should read"),
2178 vec![tip]
2179 );
2180 }
2181
2182 fn pack_file_count(git_dir: &Path) -> usize {
2183 fs::read_dir(git_dir.join("objects/pack"))
2184 .expect("pack directory should read")
2185 .filter_map(|entry| entry.ok())
2186 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
2187 .count()
2188 }
2189
2190 #[test]
2191 fn same_depth_shallow_local_fetch_does_not_install_pack() {
2192 let remote = temp_repo("remote-shallow-noop");
2193 let local = temp_repo("local-shallow-noop");
2194 let tip = commit_on(&remote, "main", "tip");
2195 let source = FetchSource::Local {
2196 git_dir: remote.clone(),
2197 common_git_dir: remote.clone(),
2198 };
2199 let mut options = default_options();
2200 options.depth = Some(1);
2201 let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
2202 let mut credentials = NoCredentials;
2203 let mut progress = SilentProgress;
2204
2205 fetch(
2206 FetchRequest {
2207 git_dir: &local,
2208 format: ObjectFormat::Sha1,
2209 config: &GitConfig::default(),
2210 remote_name: "origin",
2211 source: &source,
2212 refspecs: &refspecs,
2213 options: &options,
2214 },
2215 FetchServices {
2216 credentials: &mut credentials,
2217 progress: &mut progress,
2218 ref_hook: None,
2219 },
2220 )
2221 .expect("initial shallow fetch should succeed");
2222 let pack_count = pack_file_count(&local);
2223 let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2224 .expect("shallow file should read");
2225
2226 fetch(
2227 FetchRequest {
2228 git_dir: &local,
2229 format: ObjectFormat::Sha1,
2230 config: &GitConfig::default(),
2231 remote_name: "origin",
2232 source: &source,
2233 refspecs: &refspecs,
2234 options: &options,
2235 },
2236 FetchServices {
2237 credentials: &mut credentials,
2238 progress: &mut progress,
2239 ref_hook: None,
2240 },
2241 )
2242 .expect("same-depth shallow fetch should succeed");
2243
2244 assert_eq!(pack_file_count(&local), pack_count);
2245 assert_eq!(
2246 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2247 .expect("shallow file should read"),
2248 shallow
2249 );
2250 assert_eq!(shallow, vec![tip]);
2251 }
2252
2253 #[test]
2254 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
2255 let remote = temp_repo("remote-missing");
2256 let local = temp_repo("local-missing");
2257 let old = commit_on(&local, "main", "old local");
2258 let bogus =
2259 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
2260 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2261 let mut tx = remote_refs.transaction();
2262 tx.update(RefUpdate {
2263 name: "refs/heads/main".into(),
2264 expected: None,
2265 new: RefTarget::Direct(bogus),
2266 reflog: None,
2267 });
2268 tx.update(RefUpdate {
2269 name: "HEAD".into(),
2270 expected: None,
2271 new: RefTarget::Symbolic("refs/heads/main".into()),
2272 reflog: None,
2273 });
2274 tx.commit().expect("remote bogus ref should write");
2275 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2276 let mut tx = local_refs.transaction();
2277 tx.update(RefUpdate {
2278 name: "refs/remotes/origin/main".into(),
2279 expected: None,
2280 new: RefTarget::Direct(old),
2281 reflog: None,
2282 });
2283 tx.commit().expect("local tracking ref should write");
2284 let source = FetchSource::Local {
2285 git_dir: remote.clone(),
2286 common_git_dir: remote.clone(),
2287 };
2288 let options = default_options();
2289 let mut credentials = NoCredentials;
2290 let mut progress = SilentProgress;
2291
2292 let err = fetch(
2293 FetchRequest {
2294 git_dir: &local,
2295 format: ObjectFormat::Sha1,
2296 config: &GitConfig::default(),
2297 remote_name: "origin",
2298 source: &source,
2299 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2300 options: &options,
2301 },
2302 FetchServices {
2303 credentials: &mut credentials,
2304 progress: &mut progress,
2305 ref_hook: None,
2306 },
2307 )
2308 .expect_err("fetch should fail before finalizing refs");
2309
2310 assert!(err.to_string().contains("missing object"));
2311 assert_eq!(
2312 local_refs
2313 .read_ref("refs/remotes/origin/main")
2314 .expect("ref should read"),
2315 Some(RefTarget::Direct(old))
2316 );
2317 assert!(!local.join("FETCH_HEAD").exists());
2318 }
2319}