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