1use crate::local::LocalDeepenPlan;
19use std::collections::{BTreeSet, HashMap, HashSet};
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24use sley_config::GitConfig;
25use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
26use sley_core::{GitError, ObjectFormat, ObjectId, Result};
27use sley_odb::{
28 FileObjectDatabase, ObjectReader, collect_reachable_object_ids,
29 collect_reachable_object_ids_excluding,
30};
31#[cfg(feature = "http")]
32use sley_protocol::ProtocolVersion;
33use sley_protocol::{
34 FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
35 fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refname_matches,
36 refspec_map_source,
37};
38use sley_refs::{BundleRefUpdate, FileRefStore, Ref, RefTarget};
39use sley_transport::RemoteUrl;
40
41use crate::{CredentialProvider, ProgressSink};
42
43pub enum FetchSource {
48 Http(RemoteUrl),
50 Ssh(RemoteUrl),
53 Git(RemoteUrl),
55 Local {
57 git_dir: PathBuf,
59 common_git_dir: PathBuf,
61 },
62}
63
64#[derive(Debug, Clone)]
66pub struct FetchOptions {
67 pub quiet: bool,
70 pub auto_follow_tags: bool,
72 pub fetch_all_tags: bool,
74 pub prune: bool,
76 pub dry_run: bool,
78 pub append: bool,
80 pub write_fetch_head: bool,
82 pub tag_option_explicit: bool,
85 pub prune_option_explicit: bool,
88 pub depth: Option<u32>,
93 pub merge_srcs: Vec<String>,
100 pub filter: Option<sley_odb::PackObjectFilter>,
106 pub cloning: bool,
109 pub update_shallow: bool,
112 pub deepen_relative: bool,
115 pub deepen_since: Option<i64>,
118 pub deepen_not: Vec<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct PrunedRef {
127 pub branch: String,
129 pub refname: String,
131}
132
133#[derive(Debug, Clone, Default)]
135pub struct FetchOutcome {
136 pub ref_updates: Vec<FetchRefUpdate>,
140 pub pruned: Vec<PrunedRef>,
143 pub head_symref: Option<String>,
146 pub wrote_fetch_head: bool,
148}
149
150pub struct FetchRequest<'a> {
152 pub git_dir: &'a Path,
154 pub format: ObjectFormat,
156 pub config: &'a GitConfig,
158 pub remote_name: &'a str,
160 pub source: &'a FetchSource,
162 pub refspecs: &'a [String],
165 pub options: &'a FetchOptions,
167}
168
169pub struct FetchServices<'a> {
171 pub credentials: &'a mut dyn CredentialProvider,
173 pub progress: &'a mut dyn ProgressSink,
175}
176
177pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
189 let mut options = request.options.clone();
190 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
191 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
192 let promisor_remote = request
193 .config
194 .get_bool("remote", Some(request.remote_name), "promisor")
195 .unwrap_or(false);
196 let configured_refspecs = if request.refspecs.is_empty() {
197 remote_config_values(request.config, request.remote_name, "fetch")
198 } else {
199 Vec::new()
200 };
201 let configured_refspecs_empty = configured_refspecs.is_empty();
202 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
208 let default_head_fetch =
209 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
210 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
211 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
212 let mut effective_refspecs = fetch_refspecs_for_source(
213 configured_refspecs,
214 request.refspecs,
215 options.fetch_all_tags,
216 );
217 if has_merge_config {
218 if configured_refspecs_empty && request.refspecs.is_empty() {
221 effective_refspecs.retain(|spec| spec != "HEAD");
222 }
223 let configured_parsed = effective_refspecs
226 .iter()
227 .map(|refspec| parse_refspec(refspec))
228 .collect::<Result<Vec<_>>>()?;
229 for merge_src in &options.merge_srcs {
230 let covered = configured_parsed.iter().any(|refspec| {
234 refspec
235 .src
236 .as_deref()
237 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
238 });
239 if !covered {
240 effective_refspecs.push(merge_src.clone());
243 }
244 }
245 }
246 let parsed_refspecs = effective_refspecs
247 .iter()
248 .map(|refspec| parse_refspec(refspec))
249 .collect::<Result<Vec<_>>>()?;
250
251 let store = FileRefStore::new(request.git_dir, request.format);
252 let mut outcome = FetchOutcome::default();
253
254 let advertisements = match request.source {
258 #[cfg(not(feature = "http"))]
259 FetchSource::Http(_) => {
260 return Err(GitError::Unsupported(
261 "HTTP transport is not enabled in this build".into(),
262 ));
263 }
264 #[cfg(feature = "http")]
265 FetchSource::Http(remote) => {
266 let client = crate::http::new_http_client();
267 let discovered = crate::http::http_service_advertisements(
268 &client,
269 remote,
270 request.format,
271 sley_protocol::GitService::UploadPack,
272 services.credentials,
273 )?;
274 let advertisements = discovered.set.refs;
275 let features = advertisements
276 .first()
277 .map(|advertisement| {
278 sley_protocol::parse_upload_pack_features(&advertisement.capabilities)
279 })
280 .transpose()?
281 .unwrap_or_default();
282 outcome.head_symref = head_symref_from_features(&features.symrefs);
283 let mut updates = plan_and_adjust_updates(FetchPlanInput {
284 advertisements: &advertisements,
285 refspecs: &parsed_refspecs,
286 options: &options,
287 store: &store,
288 reachable: None,
289 local_db: None,
290 deepen_excluded: None,
291 format: request.format,
292 configured_remote_fetch,
293 has_merge_config,
294 })?;
295 let wants = updates.iter().map(|update| update.oid).collect();
296 let existing_shallow =
300 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
301 let pack_request = crate::http::HttpFetchPackRequest {
302 client: &client,
303 git_dir: request.git_dir,
304 format: request.format,
305 remote,
306 wants,
307 shallow: existing_shallow,
308 deepen: options.depth,
309 promisor: promisor_remote,
310 };
311 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
312 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
313 GitError::InvalidFormat(
314 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
315 .into(),
316 )
317 })?;
318 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
319 pack_request,
320 handshake,
321 services.credentials,
322 )?
323 } else {
324 crate::http::install_fetch_pack_via_http_upload_pack(
325 pack_request,
326 services.credentials,
327 )?
328 };
329 if !options.dry_run {
330 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
331 }
332 finalize_fetch(
333 FetchFinalize {
334 git_dir: request.git_dir,
335 format: request.format,
336 store: &store,
337 options: &options,
338 fetch_head_source: &fetch_head_source,
339 default_head_fetch,
340 },
341 &mut updates,
342 &mut outcome,
343 )?;
344 advertisements
345 }
346 FetchSource::Ssh(remote) => {
347 let (advertisements, features) =
351 crate::ssh::ssh_upload_pack_advertisements(remote, request.format)?;
352 outcome.head_symref = head_symref_from_features(&features.symrefs);
353 let mut updates = plan_and_adjust_updates(FetchPlanInput {
354 advertisements: &advertisements,
355 refspecs: &parsed_refspecs,
356 options: &options,
357 store: &store,
358 reachable: None,
359 local_db: None,
360 deepen_excluded: None,
361 format: request.format,
362 configured_remote_fetch,
363 has_merge_config,
364 })?;
365 let wants = updates.iter().map(|update| update.oid).collect();
366 let existing_shallow =
369 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
370 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
371 crate::ssh::SshFetchPackRequest {
372 git_dir: request.git_dir,
373 format: request.format,
374 remote,
375 features: &features,
376 wants,
377 shallow: existing_shallow,
378 deepen: options.depth,
379 promisor: promisor_remote,
380 },
381 )?;
382 if !options.dry_run {
383 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
384 }
385 finalize_fetch(
386 FetchFinalize {
387 git_dir: request.git_dir,
388 format: request.format,
389 store: &store,
390 options: &options,
391 fetch_head_source: &fetch_head_source,
392 default_head_fetch,
393 },
394 &mut updates,
395 &mut outcome,
396 )?;
397 advertisements
398 }
399 FetchSource::Git(remote) => {
400 let (advertisements, features) =
401 crate::git::git_upload_pack_advertisements(remote, request.format)?;
402 outcome.head_symref = head_symref_from_features(&features.symrefs);
403 let mut updates = plan_and_adjust_updates(FetchPlanInput {
404 advertisements: &advertisements,
405 refspecs: &parsed_refspecs,
406 options: &options,
407 store: &store,
408 reachable: None,
409 local_db: None,
410 deepen_excluded: None,
411 format: request.format,
412 configured_remote_fetch,
413 has_merge_config,
414 })?;
415 let wants = updates.iter().map(|update| update.oid).collect();
416 let existing_shallow =
417 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
418 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
419 crate::git::GitFetchPackRequest {
420 git_dir: request.git_dir,
421 format: request.format,
422 remote,
423 features: &features,
424 wants,
425 shallow: existing_shallow,
426 deepen: options.depth,
427 promisor: promisor_remote,
428 },
429 )?;
430 if !options.dry_run {
431 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
432 }
433 finalize_fetch(
434 FetchFinalize {
435 git_dir: request.git_dir,
436 format: request.format,
437 store: &store,
438 options: &options,
439 fetch_head_source: &fetch_head_source,
440 default_head_fetch,
441 },
442 &mut updates,
443 &mut outcome,
444 )?;
445 advertisements
446 }
447 FetchSource::Local {
448 git_dir: remote_git_dir,
449 common_git_dir: remote_common_git_dir,
450 } => {
451 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
452 if remote_format != request.format {
453 return Err(GitError::InvalidObjectId(format!(
454 "remote repository uses {}, local repository uses {}",
455 remote_format.name(),
456 request.format.name()
457 )));
458 }
459 let advertisements =
460 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
461 if let Some(RefTarget::Symbolic(target)) =
465 FileRefStore::new(remote_git_dir, request.format).read_ref("HEAD")?
466 {
467 outcome.head_symref = Some(target);
468 }
469 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
470 let remote_shallow =
482 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
483 let explicit_deepen = options.depth.is_some()
484 || options.deepen_since.is_some()
485 || !options.deepen_not.is_empty();
486 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
487 let mut deepen_not_oids = Vec::new();
490 for name in &options.deepen_not {
491 let resolved = advertisements.iter().find(|advertisement| {
492 advertisement.name == *name
493 || advertisement.name == format!("refs/tags/{name}")
494 || advertisement.name == format!("refs/heads/{name}")
495 || advertisement.name == format!("refs/{name}")
496 });
497 match resolved {
498 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
499 None => {
500 return Err(GitError::Command(format!(
501 "git upload-pack: deepen-not is not a ref: {name}"
502 )));
503 }
504 }
505 }
506 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
507 if !explicit_deepen && !implicit_deepen {
508 return Ok(None);
509 }
510 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
512 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
513 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
514 &remote_db,
515 request.format,
516 heads,
517 client_shallow,
518 options.deepen_since,
519 &deepen_not_oids,
520 )?));
521 }
522 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
523 Ok(Some(crate::local::compute_local_deepen(
524 &remote_db,
525 request.format,
526 heads,
527 client_shallow,
528 depth,
529 options.deepen_relative,
530 )?))
531 };
532 let primary_heads = {
533 let primary = plan_fetch_ref_updates(
534 &advertisements,
535 &parsed_refspecs,
536 options.auto_follow_tags,
537 )?;
538 let mut seen = HashSet::new();
539 let mut heads = Vec::new();
540 for update in &primary {
541 if seen.insert(update.oid) {
542 heads.push(update.oid);
543 }
544 }
545 heads
546 };
547 let mut deepen_plan = plan_deepen(&primary_heads)?;
548 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
549 let mut updates = plan_and_adjust_updates(FetchPlanInput {
550 advertisements: &advertisements,
551 refspecs: &parsed_refspecs,
552 options: &options,
553 store: &store,
554 reachable: Some((&remote_db, &advertisements)),
555 local_db: Some(&local_db),
556 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
557 format: request.format,
558 configured_remote_fetch,
559 has_merge_config,
560 })?;
561 if implicit_deepen && !options.cloning && !options.update_shallow {
566 let client_shallow: HashSet<ObjectId> =
567 crate::shallow::read_shallow(request.git_dir, request.format)?
568 .into_iter()
569 .collect();
570 let new_points: HashSet<ObjectId> = deepen_plan
571 .as_ref()
572 .map(|plan| {
573 plan.shallow_info
574 .iter()
575 .filter_map(|entry| match entry {
576 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
577 if !client_shallow.contains(oid) =>
578 {
579 Some(*oid)
580 }
581 _ => None,
582 })
583 .collect()
584 })
585 .unwrap_or_default();
586 if !new_points.is_empty() {
587 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
588 let mut dirty = |tip: &ObjectId| -> Result<bool> {
589 if let Some(&cached) = dirty_cache.get(tip) {
590 return Ok(cached);
591 }
592 let result =
593 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
594 dirty_cache.insert(*tip, result);
595 Ok(result)
596 };
597 let mut kept = Vec::new();
598 for update in updates {
599 if dirty(&update.oid)? {
600 continue;
601 }
602 kept.push(update);
603 }
604 updates = kept;
605 let mut seen = HashSet::new();
608 let mut heads = Vec::new();
609 for update in &updates {
610 if seen.insert(update.oid) {
611 heads.push(update.oid);
612 }
613 }
614 deepen_plan = if heads.is_empty() {
615 None
616 } else {
617 plan_deepen(&heads)?
618 };
619 }
620 }
621 let starts: Vec<ObjectId> = updates.iter().map(|update| update.oid).collect();
622 let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
623 Vec::new()
624 } else {
625 crate::local::install_fetch_pack_via_local_upload_pack(
626 request.git_dir,
627 remote_git_dir,
628 request.format,
629 starts,
630 deepen_plan.as_ref(),
631 promisor_remote,
632 options.filter,
633 None,
634 )?
635 };
636 if !options.dry_run {
637 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
638 }
639 finalize_fetch(
640 FetchFinalize {
641 git_dir: request.git_dir,
642 format: request.format,
643 store: &store,
644 options: &options,
645 fetch_head_source: &fetch_head_source,
646 default_head_fetch,
647 },
648 &mut updates,
649 &mut outcome,
650 )?;
651 advertisements
652 }
653 };
654
655 if !options.dry_run && options.prune && remote_exists(request.config, request.remote_name) {
656 outcome.pruned = prune_remote_tracking_refs_from_advertisements(
657 request.config,
658 &store,
659 request.remote_name,
660 &advertisements,
661 options.quiet,
662 services.progress,
663 )?;
664 }
665
666 Ok(outcome)
667}
668
669fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
673 remote_db: &R,
674 format: ObjectFormat,
675 tip: &ObjectId,
676 boundary: &HashSet<ObjectId>,
677) -> Result<bool> {
678 let mut seen: HashSet<ObjectId> = HashSet::new();
679 let mut queue: Vec<ObjectId> = vec![*tip];
680 while let Some(oid) = queue.pop() {
681 if !seen.insert(oid) {
682 continue;
683 }
684 let object = remote_db.read_object(&oid)?;
685 let commit = match object.object_type {
686 sley_object::ObjectType::Commit => {
687 sley_object::Commit::parse_ref(format, &object.body)?
688 }
689 sley_object::ObjectType::Tag => {
690 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
691 queue.push(tag.object);
692 continue;
693 }
694 _ => continue,
695 };
696 if boundary.contains(&oid) {
697 return Ok(true);
698 }
699 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
700 }
701 Ok(false)
702}
703
704fn shallow_boundary_for_request(
709 git_dir: &Path,
710 format: ObjectFormat,
711 depth: Option<u32>,
712) -> Result<Vec<ObjectId>> {
713 if depth.is_none() {
714 return Ok(Vec::new());
715 }
716 crate::shallow::read_shallow(git_dir, format)
717}
718
719struct FetchPlanInput<'a> {
725 advertisements: &'a [RefAdvertisement],
726 refspecs: &'a [RefSpec],
727 options: &'a FetchOptions,
728 store: &'a FileRefStore,
729 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
730 local_db: Option<&'a FileObjectDatabase>,
734 deepen_excluded: Option<&'a HashSet<ObjectId>>,
735 format: ObjectFormat,
736 configured_remote_fetch: bool,
737 has_merge_config: bool,
741}
742
743fn plan_and_adjust_updates(input: FetchPlanInput<'_>) -> Result<Vec<FetchRefUpdate>> {
744 let FetchPlanInput {
745 advertisements,
746 refspecs,
747 options,
748 store,
749 reachable,
750 local_db,
751 deepen_excluded,
752 format,
753 configured_remote_fetch,
754 has_merge_config,
755 } = input;
756 let mut updates = plan_fetch_ref_updates(advertisements, refspecs, options.auto_follow_tags)?;
757 if options.fetch_all_tags {
758 mark_tag_refspec_updates_not_for_merge(&mut updates);
759 } else {
760 if options.auto_follow_tags
761 && let Some((remote_db, advertisements)) = reachable
762 {
763 append_reachable_auto_follow_tags(
764 advertisements,
765 remote_db,
766 local_db,
767 format,
768 refspecs,
769 &mut updates,
770 deepen_excluded,
771 )?;
772 }
773 retain_missing_auto_follow_tags(store, &mut updates)?;
774 }
775 if configured_remote_fetch || has_merge_config {
776 for update in &mut updates {
777 update.not_for_merge = true;
778 }
779 if !options.merge_srcs.is_empty() {
780 for update in &mut updates {
785 if options
786 .merge_srcs
787 .iter()
788 .any(|src| refname_matches(src, &update.src))
789 {
790 update.not_for_merge = false;
791 }
792 }
793 } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
794 && !first.pattern
795 {
796 if let Some(update) = updates.first_mut() {
801 update.not_for_merge = false;
802 }
803 }
804 updates.sort_by_key(|update| update.not_for_merge);
808 }
809 Ok(updates)
810}
811
812struct FetchFinalize<'a> {
816 git_dir: &'a Path,
817 format: ObjectFormat,
818 store: &'a FileRefStore,
819 options: &'a FetchOptions,
820 fetch_head_source: &'a str,
821 default_head_fetch: bool,
822}
823
824fn downgrade_non_commit_for_merge(
830 git_dir: &Path,
831 format: ObjectFormat,
832 updates: &mut [FetchRefUpdate],
833) {
834 if updates.iter().all(|update| update.not_for_merge) {
835 return;
836 }
837 let db = FileObjectDatabase::from_git_dir(git_dir, format);
838 for update in updates.iter_mut() {
839 if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
840 update.not_for_merge = true;
841 }
842 }
843}
844
845fn finalize_fetch(
846 finalize: FetchFinalize<'_>,
847 updates: &mut Vec<FetchRefUpdate>,
848 outcome: &mut FetchOutcome,
849) -> Result<()> {
850 let FetchFinalize {
851 git_dir,
852 format,
853 store,
854 options,
855 fetch_head_source,
856 default_head_fetch,
857 } = finalize;
858 if options.dry_run {
859 outcome.ref_updates = std::mem::take(updates);
860 return Ok(());
861 }
862 downgrade_non_commit_for_merge(git_dir, format, updates);
863 if options.write_fetch_head {
864 if default_head_fetch
865 && updates.len() == 1
866 && updates[0].src == "HEAD"
867 && updates[0].dst.is_none()
868 {
869 write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, options.append)?;
870 } else {
871 write_fetch_head(git_dir, fetch_head_source, updates, options.append)?;
872 }
873 outcome.wrote_fetch_head = true;
874 }
875 let ref_updates = updates
876 .iter()
877 .filter_map(|update| {
878 update.dst.as_ref().map(|dst| BundleRefUpdate {
879 name: dst.clone(),
880 oid: update.oid,
881 })
882 })
883 .collect::<Vec<_>>();
884 store.apply_bundle_ref_updates(&ref_updates, None)?;
885 outcome.ref_updates = std::mem::take(updates);
886 Ok(())
887}
888
889fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
891 symrefs
892 .iter()
893 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
894}
895
896pub fn apply_configured_remote_tag_option(
899 config: &GitConfig,
900 source: &str,
901 options: &mut FetchOptions,
902) {
903 if options.tag_option_explicit || !remote_exists(config, source) {
904 return;
905 }
906 match remote_config_values(config, source, "tagopt")
907 .into_iter()
908 .last()
909 .as_deref()
910 {
911 Some("--tags") => {
912 options.auto_follow_tags = true;
913 options.fetch_all_tags = true;
914 }
915 Some("--no-tags") => {
916 options.auto_follow_tags = false;
917 options.fetch_all_tags = false;
918 }
919 _ => {}
920 }
921}
922
923pub fn apply_configured_fetch_prune_option(
926 config: &GitConfig,
927 source: &str,
928 options: &mut FetchOptions,
929) {
930 if options.prune_option_explicit || !remote_exists(config, source) {
931 return;
932 }
933 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
934 options.prune = prune;
935 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
936 options.prune = prune;
937 }
938}
939
940pub fn fetch_refspecs_for_source(
944 configured: Vec<String>,
945 refspecs: &[String],
946 fetch_all_tags: bool,
947) -> Vec<String> {
948 let mut effective = if !refspecs.is_empty() {
949 refspecs.to_vec()
950 } else if configured.is_empty() {
951 vec!["HEAD".to_string()]
952 } else {
953 configured
954 };
955 if fetch_all_tags {
956 effective.push("refs/tags/*:refs/tags/*".to_string());
957 }
958 effective
959}
960
961fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
966 if refspec.pattern {
967 let Some((prefix, suffix)) = src.split_once('*') else {
968 return false;
969 };
970 let fits = |name: &str| {
975 name.len() >= prefix.len() + suffix.len()
976 && name.starts_with(prefix)
977 && name.ends_with(suffix)
978 };
979 fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
980 } else {
981 refname_matches(merge_src, src) || refname_matches(src, merge_src)
982 }
983}
984
985pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
987 for update in updates {
988 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
989 update.not_for_merge = true;
990 }
991 }
992}
993
994pub fn retain_missing_auto_follow_tags(
996 store: &FileRefStore,
997 updates: &mut Vec<FetchRefUpdate>,
998) -> Result<()> {
999 let mut retained = Vec::with_capacity(updates.len());
1000 for update in updates.drain(..) {
1001 if update.not_for_merge
1002 && update.src.starts_with("refs/tags/")
1003 && update.dst.as_deref() == Some(&update.src)
1004 && store.read_ref(&update.src)?.is_some()
1005 {
1006 continue;
1007 }
1008 retained.push(update);
1009 }
1010 *updates = retained;
1011 Ok(())
1012}
1013
1014pub fn append_reachable_auto_follow_tags(
1017 advertisements: &[RefAdvertisement],
1018 remote_db: &FileObjectDatabase,
1019 local_db: Option<&FileObjectDatabase>,
1020 format: ObjectFormat,
1021 refspecs: &[RefSpec],
1022 updates: &mut Vec<FetchRefUpdate>,
1023 deepen_excluded: Option<&HashSet<ObjectId>>,
1024) -> Result<()> {
1025 if !updates.iter().any(|update| update.dst.is_some()) {
1026 return Ok(());
1027 }
1028 updates.retain(|update| {
1033 !(update.src.starts_with("refs/tags/")
1034 && update.dst.as_deref() == Some(update.src.as_str())
1035 && update.not_for_merge)
1036 });
1037 let mut starts = Vec::new();
1042 for update in updates.iter().filter(|update| update.dst.is_some()) {
1043 if update.src.starts_with("refs/tags/") {
1044 if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1045 starts.push(target);
1046 } else {
1047 starts.push(update.oid);
1048 }
1049 } else {
1050 starts.push(update.oid);
1051 }
1052 }
1053 let reachable = match deepen_excluded {
1057 Some(excluded) => {
1058 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1059 }
1060 None => collect_reachable_object_ids(remote_db, format, starts)?,
1061 };
1062 let fetched_srcs = updates
1063 .iter()
1064 .map(|update| update.src.clone())
1065 .collect::<HashSet<_>>();
1066 let mut followed = Vec::new();
1067 for reference in advertisements {
1068 if !reference.name.starts_with("refs/tags/")
1069 || fetched_srcs.contains(&reference.name)
1070 || fetch_refspec_excludes(refspecs, &reference.name)?
1071 {
1072 continue;
1073 }
1074 let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
1082 let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
1083 let present_locally = local_db
1084 .map(|db| db.contains(&target))
1085 .transpose()?
1086 .unwrap_or(false);
1087 if !fetched && !present_locally {
1088 continue;
1089 }
1090 followed.push(FetchRefUpdate {
1091 src: reference.name.clone(),
1092 dst: Some(reference.name.clone()),
1093 oid: reference.oid,
1094 not_for_merge: true,
1095 });
1096 }
1097 followed.sort_by(|a, b| a.src.cmp(&b.src));
1098 updates.extend(followed);
1099 Ok(())
1100}
1101
1102fn peel_tag_target(
1107 db: &FileObjectDatabase,
1108 format: ObjectFormat,
1109 oid: &ObjectId,
1110) -> Result<Option<ObjectId>> {
1111 let mut current = *oid;
1112 let mut peeled = None;
1113 loop {
1114 let Ok(object) = db.read_object(¤t) else {
1115 return Ok(peeled);
1116 };
1117 if object.object_type != sley_object::ObjectType::Tag {
1118 return Ok(peeled);
1119 }
1120 let tag = sley_object::Tag::parse(format, &object.body)?;
1121 current = tag.object;
1122 peeled = Some(current);
1123 }
1124}
1125
1126pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
1128 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
1129 if refspec.pattern {
1130 if refspec_map_source(refspec, name)?.is_some() {
1131 return Ok(true);
1132 }
1133 } else if refspec.src.as_deref() == Some(name) {
1134 return Ok(true);
1135 }
1136 }
1137 Ok(false)
1138}
1139
1140pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
1143 let followed_oids = updates
1144 .iter()
1145 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
1146 .map(|update| update.oid)
1147 .collect::<HashSet<_>>();
1148 if followed_oids.is_empty() {
1149 return;
1150 }
1151
1152 let mut non_tags = Vec::new();
1153 let mut followed_tags = Vec::new();
1154 let mut other_tags = Vec::new();
1155 for update in updates.drain(..) {
1156 if update.src.starts_with("refs/tags/") {
1157 if followed_oids.contains(&update.oid) {
1158 followed_tags.push(update);
1159 } else {
1160 other_tags.push(update);
1161 }
1162 } else {
1163 non_tags.push(update);
1164 }
1165 }
1166 updates.extend(non_tags);
1167 updates.extend(followed_tags);
1168 updates.extend(other_tags);
1169}
1170
1171pub fn write_default_fetch_head(
1173 git_dir: &Path,
1174 source: &str,
1175 oid: ObjectId,
1176 append: bool,
1177) -> Result<()> {
1178 let records = [FetchHeadRecord {
1179 oid,
1180 not_for_merge: false,
1181 description: source.to_string(),
1182 }];
1183 write_fetch_head_records(git_dir, &records, append)?;
1184 Ok(())
1185}
1186
1187pub fn write_fetch_head_records(
1189 git_dir: &Path,
1190 records: &[FetchHeadRecord],
1191 append: bool,
1192) -> Result<()> {
1193 let encoded = encode_fetch_head(records)?;
1194 if append {
1195 let mut file = fs::OpenOptions::new()
1196 .create(true)
1197 .append(true)
1198 .open(git_dir.join("FETCH_HEAD"))?;
1199 file.write_all(&encoded)?;
1200 } else {
1201 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
1202 }
1203 Ok(())
1204}
1205
1206pub fn write_fetch_head(
1208 git_dir: &Path,
1209 description: &str,
1210 fetched: &[FetchRefUpdate],
1211 append: bool,
1212) -> Result<()> {
1213 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
1214 write_fetch_head_records(git_dir, &records, append)?;
1215 Ok(())
1216}
1217
1218pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
1221 let url = remote_config_values(config, source, "url")
1222 .into_iter()
1223 .next()
1224 .map(|url| rewrite_url_with_config(config, &url, false))
1225 .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
1226 trim_fetch_head_display_url(&url)
1227}
1228
1229fn trim_fetch_head_display_url(url: &str) -> String {
1233 let bytes = url.as_bytes();
1234 let mut end = bytes.len();
1235 while end > 0 && bytes[end - 1] == b'/' {
1236 end -= 1;
1237 }
1238 if end > 5 && &bytes[end - 4..end] == b".git" {
1241 end -= 4;
1242 }
1243 String::from_utf8_lossy(&bytes[..end]).into_owned()
1244}
1245
1246pub fn prune_remote_tracking_refs_from_advertisements(
1250 config: &GitConfig,
1251 store: &FileRefStore,
1252 remote: &str,
1253 advertisements: &[RefAdvertisement],
1254 quiet: bool,
1255 progress: &mut dyn ProgressSink,
1256) -> Result<Vec<PrunedRef>> {
1257 let remote_branches = advertisements
1258 .iter()
1259 .filter_map(|advertisement| advertisement.name.strip_prefix("refs/heads/"))
1260 .collect::<BTreeSet<_>>();
1261 let local_refs = store.list_refs()?;
1262 let stale_branches = remote_tracking_branch_names(&local_refs, remote)
1263 .into_iter()
1264 .filter(|branch| !remote_branches.contains(branch.as_str()))
1265 .collect::<Vec<_>>();
1266 if stale_branches.is_empty() {
1267 return Ok(Vec::new());
1268 }
1269 let mut emit = |line: &str| {
1270 if !quiet {
1271 progress.message(line);
1272 }
1273 };
1274 let display_url = remote_config_values(config, remote, "url")
1275 .into_iter()
1276 .next()
1277 .unwrap_or_else(|| remote.into());
1278 emit(&format!("Pruning {remote}"));
1279 emit(&format!("URL: {display_url}"));
1280 let remote_head = format!("refs/remotes/{remote}/HEAD");
1281 let remote_prefix = format!("refs/remotes/{remote}/");
1282 let head_target = match store.read_ref(&remote_head)? {
1283 Some(RefTarget::Symbolic(target)) => Some(target),
1284 Some(RefTarget::Direct(_)) | None => None,
1285 };
1286 let mut pruned = Vec::new();
1287 for branch in stale_branches {
1288 let refname = format!("{remote_prefix}{branch}");
1289 match store.read_ref(&refname)? {
1290 Some(RefTarget::Symbolic(_)) => {
1291 let _ = store.delete_symbolic_ref(&refname)?;
1292 }
1293 Some(RefTarget::Direct(_)) => {
1294 let _ = store.delete_ref(&refname)?;
1295 }
1296 None => {}
1297 }
1298 emit(&format!(" * [pruned] {remote}/{branch}"));
1299 if head_target.as_deref() == Some(refname.as_str()) {
1300 let _ = store.delete_symbolic_ref(&remote_head)?;
1301 emit(&format!(
1302 " refs/remotes/{remote}/HEAD has become dangling after {refname} was deleted"
1303 ));
1304 }
1305 pruned.push(PrunedRef { branch, refname });
1306 }
1307 Ok(pruned)
1308}
1309
1310fn remote_tracking_branch_names(refs: &[Ref], name: &str) -> Vec<String> {
1312 let prefix = format!("refs/remotes/{name}/");
1313 refs.iter()
1314 .filter_map(|reference| reference.name.strip_prefix(&prefix))
1315 .filter(|branch| *branch != "HEAD")
1316 .map(str::to_string)
1317 .collect::<BTreeSet<_>>()
1318 .into_iter()
1319 .collect()
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::*;
1325 use std::sync::atomic::{AtomicU64, Ordering};
1326
1327 use sley_formats::RepositoryLayout;
1328 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1329 use sley_odb::{FileObjectDatabase, ObjectWriter};
1330 use sley_refs::{RefTarget, RefUpdate};
1331
1332 use crate::{NoCredentials, SilentProgress};
1333
1334 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1335
1336 fn temp_repo(name: &str) -> PathBuf {
1337 let dir = std::env::temp_dir().join(format!(
1338 "sley-remote-fetch-{name}-{}-{}",
1339 std::process::id(),
1340 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1341 ));
1342 let _ = fs::remove_dir_all(&dir);
1343 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1344 .expect("test repository should initialize");
1345 dir.join(".git")
1346 }
1347
1348 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
1349 let format = ObjectFormat::Sha1;
1350 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1351 let tree = db
1352 .write_object(EncodedObject::new(
1353 ObjectType::Tree,
1354 Tree { entries: vec![] }.write(),
1355 ))
1356 .expect("tree should write");
1357 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1358 let oid = db
1359 .write_object(EncodedObject::new(
1360 ObjectType::Commit,
1361 Commit {
1362 tree,
1363 parents: Vec::new(),
1364 author: identity.clone(),
1365 committer: identity,
1366 encoding: None,
1367 message: format!("{message}\n").into_bytes(),
1368 }
1369 .write(),
1370 ))
1371 .expect("commit should write");
1372 let store = FileRefStore::new(git_dir, format);
1373 let mut tx = store.transaction();
1374 tx.update(RefUpdate {
1375 name: format!("refs/heads/{branch}"),
1376 expected: None,
1377 new: RefTarget::Direct(oid),
1378 reflog: None,
1379 });
1380 tx.update(RefUpdate {
1381 name: "HEAD".into(),
1382 expected: None,
1383 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
1384 reflog: None,
1385 });
1386 tx.commit().expect("refs should update");
1387 oid
1388 }
1389
1390 fn default_options() -> FetchOptions {
1391 FetchOptions {
1392 quiet: true,
1393 auto_follow_tags: false,
1394 fetch_all_tags: false,
1395 prune: false,
1396 dry_run: false,
1397 append: false,
1398 write_fetch_head: true,
1399 tag_option_explicit: true,
1400 prune_option_explicit: true,
1401 depth: None,
1402 merge_srcs: Vec::new(),
1403 filter: None,
1404 cloning: false,
1405 update_shallow: false,
1406 deepen_relative: false,
1407 deepen_since: None,
1408 deepen_not: Vec::new(),
1409 }
1410 }
1411
1412 #[test]
1413 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
1414 let remote = temp_repo("remote");
1415 let local = temp_repo("local");
1416 let tip = commit_on(&remote, "main", "remote tip");
1417 let source = FetchSource::Local {
1418 git_dir: remote.clone(),
1419 common_git_dir: remote.clone(),
1420 };
1421 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
1422 let options = default_options();
1423 let mut credentials = NoCredentials;
1424 let mut progress = SilentProgress;
1425
1426 let outcome = fetch(
1427 FetchRequest {
1428 git_dir: &local,
1429 format: ObjectFormat::Sha1,
1430 config: &GitConfig::default(),
1431 remote_name: "origin",
1432 source: &source,
1433 refspecs: &refspecs,
1434 options: &options,
1435 },
1436 FetchServices {
1437 credentials: &mut credentials,
1438 progress: &mut progress,
1439 },
1440 )
1441 .expect("fetch should succeed");
1442
1443 assert_eq!(outcome.ref_updates.len(), 1);
1444 assert!(outcome.wrote_fetch_head);
1445 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
1446 assert!(local_db.contains(&tip).expect("contains should read"));
1447 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1448 assert_eq!(
1449 local_refs
1450 .read_ref("refs/remotes/origin/main")
1451 .expect("ref should read"),
1452 Some(RefTarget::Direct(tip))
1453 );
1454 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
1455 assert!(fetch_head.contains("origin"));
1456 }
1457
1458 #[test]
1459 fn shallow_local_fetch_writes_depth_boundary_metadata() {
1460 let remote = temp_repo("remote-shallow");
1461 let local = temp_repo("local-shallow");
1462 let tip = commit_on(&remote, "main", "tip");
1463 let source = FetchSource::Local {
1464 git_dir: remote.clone(),
1465 common_git_dir: remote.clone(),
1466 };
1467 let mut options = default_options();
1468 options.depth = Some(1);
1469 let mut credentials = NoCredentials;
1470 let mut progress = SilentProgress;
1471
1472 fetch(
1473 FetchRequest {
1474 git_dir: &local,
1475 format: ObjectFormat::Sha1,
1476 config: &GitConfig::default(),
1477 remote_name: "origin",
1478 source: &source,
1479 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1480 options: &options,
1481 },
1482 FetchServices {
1483 credentials: &mut credentials,
1484 progress: &mut progress,
1485 },
1486 )
1487 .expect("shallow fetch should succeed");
1488
1489 assert_eq!(
1490 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
1491 .expect("shallow file should read"),
1492 vec![tip]
1493 );
1494 }
1495
1496 #[test]
1497 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
1498 let remote = temp_repo("remote-missing");
1499 let local = temp_repo("local-missing");
1500 let old = commit_on(&local, "main", "old local");
1501 let bogus =
1502 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
1503 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1504 let mut tx = remote_refs.transaction();
1505 tx.update(RefUpdate {
1506 name: "refs/heads/main".into(),
1507 expected: None,
1508 new: RefTarget::Direct(bogus),
1509 reflog: None,
1510 });
1511 tx.update(RefUpdate {
1512 name: "HEAD".into(),
1513 expected: None,
1514 new: RefTarget::Symbolic("refs/heads/main".into()),
1515 reflog: None,
1516 });
1517 tx.commit().expect("remote bogus ref should write");
1518 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1519 let mut tx = local_refs.transaction();
1520 tx.update(RefUpdate {
1521 name: "refs/remotes/origin/main".into(),
1522 expected: None,
1523 new: RefTarget::Direct(old),
1524 reflog: None,
1525 });
1526 tx.commit().expect("local tracking ref should write");
1527 let source = FetchSource::Local {
1528 git_dir: remote.clone(),
1529 common_git_dir: remote.clone(),
1530 };
1531 let options = default_options();
1532 let mut credentials = NoCredentials;
1533 let mut progress = SilentProgress;
1534
1535 let err = fetch(
1536 FetchRequest {
1537 git_dir: &local,
1538 format: ObjectFormat::Sha1,
1539 config: &GitConfig::default(),
1540 remote_name: "origin",
1541 source: &source,
1542 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1543 options: &options,
1544 },
1545 FetchServices {
1546 credentials: &mut credentials,
1547 progress: &mut progress,
1548 },
1549 )
1550 .expect_err("fetch should fail before finalizing refs");
1551
1552 assert!(err.to_string().contains("missing object"));
1553 assert_eq!(
1554 local_refs
1555 .read_ref("refs/remotes/origin/main")
1556 .expect("ref should read"),
1557 Some(RefTarget::Direct(old))
1558 );
1559 assert!(!local.join("FETCH_HEAD").exists());
1560 }
1561}