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, collect_reachable_object_ids, collect_reachable_object_ids_excluding,
29};
30#[cfg(feature = "http")]
31use sley_protocol::ProtocolVersion;
32use sley_protocol::{
33 FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
34 fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refspec_map_source,
35};
36use sley_refs::{BundleRefUpdate, FileRefStore, Ref, RefTarget};
37use sley_transport::RemoteUrl;
38
39use crate::{CredentialProvider, ProgressSink};
40
41pub enum FetchSource {
46 Http(RemoteUrl),
48 Ssh(RemoteUrl),
51 Git(RemoteUrl),
53 Local {
55 git_dir: PathBuf,
57 common_git_dir: PathBuf,
59 },
60}
61
62#[derive(Debug, Clone)]
64pub struct FetchOptions {
65 pub quiet: bool,
68 pub auto_follow_tags: bool,
70 pub fetch_all_tags: bool,
72 pub prune: bool,
74 pub dry_run: bool,
76 pub append: bool,
78 pub write_fetch_head: bool,
80 pub tag_option_explicit: bool,
83 pub prune_option_explicit: bool,
86 pub depth: Option<u32>,
91 pub merge_src: Option<String>,
94 pub filter: Option<sley_odb::PackObjectFilter>,
100 pub cloning: bool,
103 pub update_shallow: bool,
106 pub deepen_relative: bool,
109 pub deepen_since: Option<i64>,
112 pub deepen_not: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct PrunedRef {
121 pub branch: String,
123 pub refname: String,
125}
126
127#[derive(Debug, Clone, Default)]
129pub struct FetchOutcome {
130 pub ref_updates: Vec<FetchRefUpdate>,
134 pub pruned: Vec<PrunedRef>,
137 pub head_symref: Option<String>,
140 pub wrote_fetch_head: bool,
142}
143
144pub struct FetchRequest<'a> {
146 pub git_dir: &'a Path,
148 pub format: ObjectFormat,
150 pub config: &'a GitConfig,
152 pub remote_name: &'a str,
154 pub source: &'a FetchSource,
156 pub refspecs: &'a [String],
159 pub options: &'a FetchOptions,
161}
162
163pub struct FetchServices<'a> {
165 pub credentials: &'a mut dyn CredentialProvider,
167 pub progress: &'a mut dyn ProgressSink,
169}
170
171pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
183 let mut options = request.options.clone();
184 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
185 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
186 let promisor_remote = request
187 .config
188 .get_bool("remote", Some(request.remote_name), "promisor")
189 .unwrap_or(false);
190 let configured_refspecs = if request.refspecs.is_empty() {
191 remote_config_values(request.config, request.remote_name, "fetch")
192 } else {
193 Vec::new()
194 };
195 let default_head_fetch = request.refspecs.is_empty() && configured_refspecs.is_empty();
196 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs.is_empty();
197 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
198 let effective_refspecs = fetch_refspecs_for_source(
199 configured_refspecs,
200 request.refspecs,
201 options.fetch_all_tags,
202 );
203 let parsed_refspecs = effective_refspecs
204 .iter()
205 .map(|refspec| parse_refspec(refspec))
206 .collect::<Result<Vec<_>>>()?;
207
208 let store = FileRefStore::new(request.git_dir, request.format);
209 let mut outcome = FetchOutcome::default();
210
211 let advertisements = match request.source {
215 #[cfg(not(feature = "http"))]
216 FetchSource::Http(_) => {
217 return Err(GitError::Unsupported(
218 "HTTP transport is not enabled in this build".into(),
219 ));
220 }
221 #[cfg(feature = "http")]
222 FetchSource::Http(remote) => {
223 let client = crate::http::new_http_client();
224 let discovered = crate::http::http_service_advertisements(
225 &client,
226 remote,
227 request.format,
228 sley_protocol::GitService::UploadPack,
229 services.credentials,
230 )?;
231 let advertisements = discovered.set.refs;
232 let features = advertisements
233 .first()
234 .map(|advertisement| {
235 sley_protocol::parse_upload_pack_features(&advertisement.capabilities)
236 })
237 .transpose()?
238 .unwrap_or_default();
239 outcome.head_symref = head_symref_from_features(&features.symrefs);
240 let mut updates = plan_and_adjust_updates(FetchPlanInput {
241 advertisements: &advertisements,
242 refspecs: &parsed_refspecs,
243 options: &options,
244 store: &store,
245 reachable: None,
246 deepen_excluded: None,
247 format: request.format,
248 configured_remote_fetch,
249 })?;
250 let wants = updates.iter().map(|update| update.oid).collect();
251 let existing_shallow =
255 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
256 let pack_request = crate::http::HttpFetchPackRequest {
257 client: &client,
258 git_dir: request.git_dir,
259 format: request.format,
260 remote,
261 wants,
262 shallow: existing_shallow,
263 deepen: options.depth,
264 promisor: promisor_remote,
265 };
266 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
267 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
268 GitError::InvalidFormat(
269 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
270 .into(),
271 )
272 })?;
273 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
274 pack_request,
275 handshake,
276 services.credentials,
277 )?
278 } else {
279 crate::http::install_fetch_pack_via_http_upload_pack(
280 pack_request,
281 services.credentials,
282 )?
283 };
284 if !options.dry_run {
285 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
286 }
287 finalize_fetch(
288 FetchFinalize {
289 git_dir: request.git_dir,
290 store: &store,
291 options: &options,
292 remote_name: request.remote_name,
293 fetch_head_source: &fetch_head_source,
294 default_head_fetch,
295 },
296 &mut updates,
297 &mut outcome,
298 )?;
299 advertisements
300 }
301 FetchSource::Ssh(remote) => {
302 let (advertisements, features) =
306 crate::ssh::ssh_upload_pack_advertisements(remote, request.format)?;
307 outcome.head_symref = head_symref_from_features(&features.symrefs);
308 let mut updates = plan_and_adjust_updates(FetchPlanInput {
309 advertisements: &advertisements,
310 refspecs: &parsed_refspecs,
311 options: &options,
312 store: &store,
313 reachable: None,
314 deepen_excluded: None,
315 format: request.format,
316 configured_remote_fetch,
317 })?;
318 let wants = updates.iter().map(|update| update.oid).collect();
319 let existing_shallow =
322 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
323 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
324 crate::ssh::SshFetchPackRequest {
325 git_dir: request.git_dir,
326 format: request.format,
327 remote,
328 features: &features,
329 wants,
330 shallow: existing_shallow,
331 deepen: options.depth,
332 promisor: promisor_remote,
333 },
334 )?;
335 if !options.dry_run {
336 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
337 }
338 finalize_fetch(
339 FetchFinalize {
340 git_dir: request.git_dir,
341 store: &store,
342 options: &options,
343 remote_name: request.remote_name,
344 fetch_head_source: &fetch_head_source,
345 default_head_fetch,
346 },
347 &mut updates,
348 &mut outcome,
349 )?;
350 advertisements
351 }
352 FetchSource::Git(remote) => {
353 let (advertisements, features) =
354 crate::git::git_upload_pack_advertisements(remote, request.format)?;
355 outcome.head_symref = head_symref_from_features(&features.symrefs);
356 let mut updates = plan_and_adjust_updates(FetchPlanInput {
357 advertisements: &advertisements,
358 refspecs: &parsed_refspecs,
359 options: &options,
360 store: &store,
361 reachable: None,
362 deepen_excluded: None,
363 format: request.format,
364 configured_remote_fetch,
365 })?;
366 let wants = updates.iter().map(|update| update.oid).collect();
367 let existing_shallow =
368 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
369 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
370 crate::git::GitFetchPackRequest {
371 git_dir: request.git_dir,
372 format: request.format,
373 remote,
374 features: &features,
375 wants,
376 shallow: existing_shallow,
377 deepen: options.depth,
378 promisor: promisor_remote,
379 },
380 )?;
381 if !options.dry_run {
382 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
383 }
384 finalize_fetch(
385 FetchFinalize {
386 git_dir: request.git_dir,
387 store: &store,
388 options: &options,
389 remote_name: request.remote_name,
390 fetch_head_source: &fetch_head_source,
391 default_head_fetch,
392 },
393 &mut updates,
394 &mut outcome,
395 )?;
396 advertisements
397 }
398 FetchSource::Local {
399 git_dir: remote_git_dir,
400 common_git_dir: remote_common_git_dir,
401 } => {
402 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
403 if remote_format != request.format {
404 return Err(GitError::InvalidObjectId(format!(
405 "remote repository uses {}, local repository uses {}",
406 remote_format.name(),
407 request.format.name()
408 )));
409 }
410 let advertisements =
411 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
412 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
413 let remote_shallow =
425 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
426 let explicit_deepen = options.depth.is_some()
427 || options.deepen_since.is_some()
428 || !options.deepen_not.is_empty();
429 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
430 let mut deepen_not_oids = Vec::new();
433 for name in &options.deepen_not {
434 let resolved = advertisements.iter().find(|advertisement| {
435 advertisement.name == *name
436 || advertisement.name == format!("refs/tags/{name}")
437 || advertisement.name == format!("refs/heads/{name}")
438 || advertisement.name == format!("refs/{name}")
439 });
440 match resolved {
441 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
442 None => {
443 return Err(GitError::Command(format!(
444 "git upload-pack: deepen-not is not a ref: {name}"
445 )));
446 }
447 }
448 }
449 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
450 if !explicit_deepen && !implicit_deepen {
451 return Ok(None);
452 }
453 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
455 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
456 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
457 &remote_db,
458 request.format,
459 heads,
460 client_shallow,
461 options.deepen_since,
462 &deepen_not_oids,
463 )?));
464 }
465 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
466 Ok(Some(crate::local::compute_local_deepen(
467 &remote_db,
468 request.format,
469 heads,
470 client_shallow,
471 depth,
472 options.deepen_relative,
473 )?))
474 };
475 let primary_heads = {
476 let primary = plan_fetch_ref_updates(
477 &advertisements,
478 &parsed_refspecs,
479 options.auto_follow_tags,
480 )?;
481 let mut seen = HashSet::new();
482 let mut heads = Vec::new();
483 for update in &primary {
484 if seen.insert(update.oid) {
485 heads.push(update.oid);
486 }
487 }
488 heads
489 };
490 let mut deepen_plan = plan_deepen(&primary_heads)?;
491 let mut updates = plan_and_adjust_updates(FetchPlanInput {
492 advertisements: &advertisements,
493 refspecs: &parsed_refspecs,
494 options: &options,
495 store: &store,
496 reachable: Some((&remote_db, &advertisements)),
497 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
498 format: request.format,
499 configured_remote_fetch,
500 })?;
501 if implicit_deepen && !options.cloning && !options.update_shallow {
506 let client_shallow: HashSet<ObjectId> =
507 crate::shallow::read_shallow(request.git_dir, request.format)?
508 .into_iter()
509 .collect();
510 let new_points: HashSet<ObjectId> = deepen_plan
511 .as_ref()
512 .map(|plan| {
513 plan.shallow_info
514 .iter()
515 .filter_map(|entry| match entry {
516 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
517 if !client_shallow.contains(oid) =>
518 {
519 Some(*oid)
520 }
521 _ => None,
522 })
523 .collect()
524 })
525 .unwrap_or_default();
526 if !new_points.is_empty() {
527 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
528 let mut dirty = |tip: &ObjectId| -> Result<bool> {
529 if let Some(&cached) = dirty_cache.get(tip) {
530 return Ok(cached);
531 }
532 let result =
533 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
534 dirty_cache.insert(*tip, result);
535 Ok(result)
536 };
537 let mut kept = Vec::new();
538 for update in updates {
539 if dirty(&update.oid)? {
540 continue;
541 }
542 kept.push(update);
543 }
544 updates = kept;
545 let mut seen = HashSet::new();
548 let mut heads = Vec::new();
549 for update in &updates {
550 if seen.insert(update.oid) {
551 heads.push(update.oid);
552 }
553 }
554 deepen_plan = if heads.is_empty() {
555 None
556 } else {
557 plan_deepen(&heads)?
558 };
559 }
560 }
561 let starts: Vec<ObjectId> = updates.iter().map(|update| update.oid).collect();
562 let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
563 Vec::new()
564 } else {
565 crate::local::install_fetch_pack_via_local_upload_pack(
566 request.git_dir,
567 remote_git_dir,
568 request.format,
569 starts,
570 deepen_plan.as_ref(),
571 promisor_remote,
572 options.filter,
573 None,
574 )?
575 };
576 if !options.dry_run {
577 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
578 }
579 finalize_fetch(
580 FetchFinalize {
581 git_dir: request.git_dir,
582 store: &store,
583 options: &options,
584 remote_name: request.remote_name,
585 fetch_head_source: &fetch_head_source,
586 default_head_fetch,
587 },
588 &mut updates,
589 &mut outcome,
590 )?;
591 advertisements
592 }
593 };
594
595 if !options.dry_run && options.prune && remote_exists(request.config, request.remote_name) {
596 outcome.pruned = prune_remote_tracking_refs_from_advertisements(
597 request.config,
598 &store,
599 request.remote_name,
600 &advertisements,
601 options.quiet,
602 services.progress,
603 )?;
604 }
605
606 Ok(outcome)
607}
608
609fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
613 remote_db: &R,
614 format: ObjectFormat,
615 tip: &ObjectId,
616 boundary: &HashSet<ObjectId>,
617) -> Result<bool> {
618 let mut seen: HashSet<ObjectId> = HashSet::new();
619 let mut queue: Vec<ObjectId> = vec![*tip];
620 while let Some(oid) = queue.pop() {
621 if !seen.insert(oid) {
622 continue;
623 }
624 let object = remote_db.read_object(&oid)?;
625 let commit = match object.object_type {
626 sley_object::ObjectType::Commit => {
627 sley_object::Commit::parse_ref(format, &object.body)?
628 }
629 sley_object::ObjectType::Tag => {
630 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
631 queue.push(tag.object);
632 continue;
633 }
634 _ => continue,
635 };
636 if boundary.contains(&oid) {
637 return Ok(true);
638 }
639 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
640 }
641 Ok(false)
642}
643
644fn shallow_boundary_for_request(
649 git_dir: &Path,
650 format: ObjectFormat,
651 depth: Option<u32>,
652) -> Result<Vec<ObjectId>> {
653 if depth.is_none() {
654 return Ok(Vec::new());
655 }
656 crate::shallow::read_shallow(git_dir, format)
657}
658
659struct FetchPlanInput<'a> {
665 advertisements: &'a [RefAdvertisement],
666 refspecs: &'a [RefSpec],
667 options: &'a FetchOptions,
668 store: &'a FileRefStore,
669 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
670 deepen_excluded: Option<&'a HashSet<ObjectId>>,
671 format: ObjectFormat,
672 configured_remote_fetch: bool,
673}
674
675fn plan_and_adjust_updates(input: FetchPlanInput<'_>) -> Result<Vec<FetchRefUpdate>> {
676 let FetchPlanInput {
677 advertisements,
678 refspecs,
679 options,
680 store,
681 reachable,
682 deepen_excluded,
683 format,
684 configured_remote_fetch,
685 } = input;
686 let mut updates = plan_fetch_ref_updates(advertisements, refspecs, options.auto_follow_tags)?;
687 if options.fetch_all_tags {
688 mark_tag_refspec_updates_not_for_merge(&mut updates);
689 } else {
690 if options.auto_follow_tags
691 && let Some((remote_db, advertisements)) = reachable
692 {
693 append_reachable_auto_follow_tags(
694 advertisements,
695 remote_db,
696 format,
697 refspecs,
698 &mut updates,
699 deepen_excluded,
700 )?;
701 }
702 retain_missing_auto_follow_tags(store, &mut updates)?;
703 }
704 if configured_remote_fetch {
705 for update in &mut updates {
706 update.not_for_merge = true;
707 }
708 if let Some(merge_src) = &options.merge_src {
709 for update in &mut updates {
710 if update.src == *merge_src {
711 update.not_for_merge = false;
712 }
713 }
714 }
715 }
716 Ok(updates)
717}
718
719struct FetchFinalize<'a> {
723 git_dir: &'a Path,
724 store: &'a FileRefStore,
725 options: &'a FetchOptions,
726 remote_name: &'a str,
727 fetch_head_source: &'a str,
728 default_head_fetch: bool,
729}
730
731fn finalize_fetch(
732 finalize: FetchFinalize<'_>,
733 updates: &mut Vec<FetchRefUpdate>,
734 outcome: &mut FetchOutcome,
735) -> Result<()> {
736 let FetchFinalize {
737 git_dir,
738 store,
739 options,
740 remote_name,
741 fetch_head_source,
742 default_head_fetch,
743 } = finalize;
744 if options.dry_run {
745 outcome.ref_updates = std::mem::take(updates);
746 return Ok(());
747 }
748 if options.write_fetch_head {
749 if default_head_fetch
750 && updates.len() == 1
751 && updates[0].src == "HEAD"
752 && updates[0].dst.is_none()
753 {
754 write_default_fetch_head(git_dir, remote_name, updates[0].oid, options.append)?;
755 } else {
756 write_fetch_head(git_dir, fetch_head_source, updates, options.append)?;
757 }
758 outcome.wrote_fetch_head = true;
759 }
760 let ref_updates = updates
761 .iter()
762 .filter_map(|update| {
763 update.dst.as_ref().map(|dst| BundleRefUpdate {
764 name: dst.clone(),
765 oid: update.oid,
766 })
767 })
768 .collect::<Vec<_>>();
769 store.apply_bundle_ref_updates(&ref_updates, None)?;
770 outcome.ref_updates = std::mem::take(updates);
771 Ok(())
772}
773
774fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
776 symrefs
777 .iter()
778 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
779}
780
781pub fn apply_configured_remote_tag_option(
784 config: &GitConfig,
785 source: &str,
786 options: &mut FetchOptions,
787) {
788 if options.tag_option_explicit || !remote_exists(config, source) {
789 return;
790 }
791 match remote_config_values(config, source, "tagopt")
792 .into_iter()
793 .last()
794 .as_deref()
795 {
796 Some("--tags") => {
797 options.auto_follow_tags = true;
798 options.fetch_all_tags = true;
799 }
800 Some("--no-tags") => {
801 options.auto_follow_tags = false;
802 options.fetch_all_tags = false;
803 }
804 _ => {}
805 }
806}
807
808pub fn apply_configured_fetch_prune_option(
811 config: &GitConfig,
812 source: &str,
813 options: &mut FetchOptions,
814) {
815 if options.prune_option_explicit || !remote_exists(config, source) {
816 return;
817 }
818 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
819 options.prune = prune;
820 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
821 options.prune = prune;
822 }
823}
824
825pub fn fetch_refspecs_for_source(
829 configured: Vec<String>,
830 refspecs: &[String],
831 fetch_all_tags: bool,
832) -> Vec<String> {
833 let mut effective = if !refspecs.is_empty() {
834 refspecs.to_vec()
835 } else if configured.is_empty() {
836 vec!["HEAD".to_string()]
837 } else {
838 configured
839 };
840 if fetch_all_tags {
841 effective.push("refs/tags/*:refs/tags/*".to_string());
842 }
843 effective
844}
845
846pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
848 for update in updates {
849 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
850 update.not_for_merge = true;
851 }
852 }
853}
854
855pub fn retain_missing_auto_follow_tags(
857 store: &FileRefStore,
858 updates: &mut Vec<FetchRefUpdate>,
859) -> Result<()> {
860 let mut retained = Vec::with_capacity(updates.len());
861 for update in updates.drain(..) {
862 if update.not_for_merge
863 && update.src.starts_with("refs/tags/")
864 && update.dst.as_deref() == Some(&update.src)
865 && store.read_ref(&update.src)?.is_some()
866 {
867 continue;
868 }
869 retained.push(update);
870 }
871 *updates = retained;
872 Ok(())
873}
874
875pub fn append_reachable_auto_follow_tags(
878 advertisements: &[RefAdvertisement],
879 remote_db: &FileObjectDatabase,
880 format: ObjectFormat,
881 refspecs: &[RefSpec],
882 updates: &mut Vec<FetchRefUpdate>,
883 deepen_excluded: Option<&HashSet<ObjectId>>,
884) -> Result<()> {
885 if !updates.iter().any(|update| update.dst.is_some()) {
886 return Ok(());
887 }
888 let starts = updates
889 .iter()
890 .filter(|update| update.dst.is_some() && !update.src.starts_with("refs/tags/"))
891 .map(|update| update.oid);
892 let reachable = match deepen_excluded {
896 Some(excluded) => {
897 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
898 }
899 None => collect_reachable_object_ids(remote_db, format, starts)?,
900 };
901 let mut fetched_srcs = updates
902 .iter()
903 .map(|update| update.src.clone())
904 .collect::<HashSet<_>>();
905 for reference in advertisements {
906 if !reference.name.starts_with("refs/tags/")
907 || fetched_srcs.contains(&reference.name)
908 || !reachable.contains(&reference.oid)
909 || fetch_refspec_excludes(refspecs, &reference.name)?
910 {
911 continue;
912 }
913 fetched_srcs.insert(reference.name.clone());
914 updates.push(FetchRefUpdate {
915 src: reference.name.clone(),
916 dst: Some(reference.name.clone()),
917 oid: reference.oid,
918 not_for_merge: true,
919 });
920 }
921 Ok(())
922}
923
924pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
926 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
927 if refspec.pattern {
928 if refspec_map_source(refspec, name)?.is_some() {
929 return Ok(true);
930 }
931 } else if refspec.src.as_deref() == Some(name) {
932 return Ok(true);
933 }
934 }
935 Ok(false)
936}
937
938pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
941 let followed_oids = updates
942 .iter()
943 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
944 .map(|update| update.oid)
945 .collect::<HashSet<_>>();
946 if followed_oids.is_empty() {
947 return;
948 }
949
950 let mut non_tags = Vec::new();
951 let mut followed_tags = Vec::new();
952 let mut other_tags = Vec::new();
953 for update in updates.drain(..) {
954 if update.src.starts_with("refs/tags/") {
955 if followed_oids.contains(&update.oid) {
956 followed_tags.push(update);
957 } else {
958 other_tags.push(update);
959 }
960 } else {
961 non_tags.push(update);
962 }
963 }
964 updates.extend(non_tags);
965 updates.extend(followed_tags);
966 updates.extend(other_tags);
967}
968
969pub fn write_default_fetch_head(
971 git_dir: &Path,
972 source: &str,
973 oid: ObjectId,
974 append: bool,
975) -> Result<()> {
976 let records = [FetchHeadRecord {
977 oid,
978 not_for_merge: false,
979 description: source.to_string(),
980 }];
981 write_fetch_head_records(git_dir, &records, append)?;
982 Ok(())
983}
984
985pub fn write_fetch_head_records(
987 git_dir: &Path,
988 records: &[FetchHeadRecord],
989 append: bool,
990) -> Result<()> {
991 let encoded = encode_fetch_head(records)?;
992 if append {
993 let mut file = fs::OpenOptions::new()
994 .create(true)
995 .append(true)
996 .open(git_dir.join("FETCH_HEAD"))?;
997 file.write_all(&encoded)?;
998 } else {
999 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
1000 }
1001 Ok(())
1002}
1003
1004pub fn write_fetch_head(
1006 git_dir: &Path,
1007 description: &str,
1008 fetched: &[FetchRefUpdate],
1009 append: bool,
1010) -> Result<()> {
1011 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
1012 write_fetch_head_records(git_dir, &records, append)?;
1013 Ok(())
1014}
1015
1016pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
1019 remote_config_values(config, source, "url")
1020 .into_iter()
1021 .next()
1022 .map(|url| rewrite_url_with_config(config, &url, false))
1023 .unwrap_or_else(|| rewrite_url_with_config(config, source, false))
1024}
1025
1026pub fn prune_remote_tracking_refs_from_advertisements(
1030 config: &GitConfig,
1031 store: &FileRefStore,
1032 remote: &str,
1033 advertisements: &[RefAdvertisement],
1034 quiet: bool,
1035 progress: &mut dyn ProgressSink,
1036) -> Result<Vec<PrunedRef>> {
1037 let remote_branches = advertisements
1038 .iter()
1039 .filter_map(|advertisement| advertisement.name.strip_prefix("refs/heads/"))
1040 .collect::<BTreeSet<_>>();
1041 let local_refs = store.list_refs()?;
1042 let stale_branches = remote_tracking_branch_names(&local_refs, remote)
1043 .into_iter()
1044 .filter(|branch| !remote_branches.contains(branch.as_str()))
1045 .collect::<Vec<_>>();
1046 if stale_branches.is_empty() {
1047 return Ok(Vec::new());
1048 }
1049 let mut emit = |line: &str| {
1050 if !quiet {
1051 progress.message(line);
1052 }
1053 };
1054 let display_url = remote_config_values(config, remote, "url")
1055 .into_iter()
1056 .next()
1057 .unwrap_or_else(|| remote.into());
1058 emit(&format!("Pruning {remote}"));
1059 emit(&format!("URL: {display_url}"));
1060 let remote_head = format!("refs/remotes/{remote}/HEAD");
1061 let remote_prefix = format!("refs/remotes/{remote}/");
1062 let head_target = match store.read_ref(&remote_head)? {
1063 Some(RefTarget::Symbolic(target)) => Some(target),
1064 Some(RefTarget::Direct(_)) | None => None,
1065 };
1066 let mut pruned = Vec::new();
1067 for branch in stale_branches {
1068 let refname = format!("{remote_prefix}{branch}");
1069 match store.read_ref(&refname)? {
1070 Some(RefTarget::Symbolic(_)) => {
1071 let _ = store.delete_symbolic_ref(&refname)?;
1072 }
1073 Some(RefTarget::Direct(_)) => {
1074 let _ = store.delete_ref(&refname)?;
1075 }
1076 None => {}
1077 }
1078 emit(&format!(" * [pruned] {remote}/{branch}"));
1079 if head_target.as_deref() == Some(refname.as_str()) {
1080 let _ = store.delete_symbolic_ref(&remote_head)?;
1081 emit(&format!(
1082 " refs/remotes/{remote}/HEAD has become dangling after {refname} was deleted"
1083 ));
1084 }
1085 pruned.push(PrunedRef { branch, refname });
1086 }
1087 Ok(pruned)
1088}
1089
1090fn remote_tracking_branch_names(refs: &[Ref], name: &str) -> Vec<String> {
1092 let prefix = format!("refs/remotes/{name}/");
1093 refs.iter()
1094 .filter_map(|reference| reference.name.strip_prefix(&prefix))
1095 .filter(|branch| *branch != "HEAD")
1096 .map(str::to_string)
1097 .collect::<BTreeSet<_>>()
1098 .into_iter()
1099 .collect()
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105 use std::sync::atomic::{AtomicU64, Ordering};
1106
1107 use sley_formats::RepositoryLayout;
1108 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1109 use sley_odb::{FileObjectDatabase, ObjectWriter};
1110 use sley_refs::{RefTarget, RefUpdate};
1111
1112 use crate::{NoCredentials, SilentProgress};
1113
1114 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1115
1116 fn temp_repo(name: &str) -> PathBuf {
1117 let dir = std::env::temp_dir().join(format!(
1118 "sley-remote-fetch-{name}-{}-{}",
1119 std::process::id(),
1120 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1121 ));
1122 let _ = fs::remove_dir_all(&dir);
1123 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1124 .expect("test repository should initialize");
1125 dir.join(".git")
1126 }
1127
1128 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
1129 let format = ObjectFormat::Sha1;
1130 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1131 let tree = db
1132 .write_object(EncodedObject::new(
1133 ObjectType::Tree,
1134 Tree { entries: vec![] }.write(),
1135 ))
1136 .expect("tree should write");
1137 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1138 let oid = db
1139 .write_object(EncodedObject::new(
1140 ObjectType::Commit,
1141 Commit {
1142 tree,
1143 parents: Vec::new(),
1144 author: identity.clone(),
1145 committer: identity,
1146 encoding: None,
1147 message: format!("{message}\n").into_bytes(),
1148 }
1149 .write(),
1150 ))
1151 .expect("commit should write");
1152 let store = FileRefStore::new(git_dir, format);
1153 let mut tx = store.transaction();
1154 tx.update(RefUpdate {
1155 name: format!("refs/heads/{branch}"),
1156 expected: None,
1157 new: RefTarget::Direct(oid),
1158 reflog: None,
1159 });
1160 tx.update(RefUpdate {
1161 name: "HEAD".into(),
1162 expected: None,
1163 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
1164 reflog: None,
1165 });
1166 tx.commit().expect("refs should update");
1167 oid
1168 }
1169
1170 fn default_options() -> FetchOptions {
1171 FetchOptions {
1172 quiet: true,
1173 auto_follow_tags: false,
1174 fetch_all_tags: false,
1175 prune: false,
1176 dry_run: false,
1177 append: false,
1178 write_fetch_head: true,
1179 tag_option_explicit: true,
1180 prune_option_explicit: true,
1181 depth: None,
1182 merge_src: None,
1183 filter: None,
1184 cloning: false,
1185 update_shallow: false,
1186 deepen_relative: false,
1187 deepen_since: None,
1188 deepen_not: Vec::new(),
1189 }
1190 }
1191
1192 #[test]
1193 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
1194 let remote = temp_repo("remote");
1195 let local = temp_repo("local");
1196 let tip = commit_on(&remote, "main", "remote tip");
1197 let source = FetchSource::Local {
1198 git_dir: remote.clone(),
1199 common_git_dir: remote.clone(),
1200 };
1201 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
1202 let options = default_options();
1203 let mut credentials = NoCredentials;
1204 let mut progress = SilentProgress;
1205
1206 let outcome = fetch(
1207 FetchRequest {
1208 git_dir: &local,
1209 format: ObjectFormat::Sha1,
1210 config: &GitConfig::default(),
1211 remote_name: "origin",
1212 source: &source,
1213 refspecs: &refspecs,
1214 options: &options,
1215 },
1216 FetchServices {
1217 credentials: &mut credentials,
1218 progress: &mut progress,
1219 },
1220 )
1221 .expect("fetch should succeed");
1222
1223 assert_eq!(outcome.ref_updates.len(), 1);
1224 assert!(outcome.wrote_fetch_head);
1225 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
1226 assert!(local_db.contains(&tip).expect("contains should read"));
1227 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1228 assert_eq!(
1229 local_refs
1230 .read_ref("refs/remotes/origin/main")
1231 .expect("ref should read"),
1232 Some(RefTarget::Direct(tip))
1233 );
1234 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
1235 assert!(fetch_head.contains("origin"));
1236 }
1237
1238 #[test]
1239 fn shallow_local_fetch_writes_depth_boundary_metadata() {
1240 let remote = temp_repo("remote-shallow");
1241 let local = temp_repo("local-shallow");
1242 let tip = commit_on(&remote, "main", "tip");
1243 let source = FetchSource::Local {
1244 git_dir: remote.clone(),
1245 common_git_dir: remote.clone(),
1246 };
1247 let mut options = default_options();
1248 options.depth = Some(1);
1249 let mut credentials = NoCredentials;
1250 let mut progress = SilentProgress;
1251
1252 fetch(
1253 FetchRequest {
1254 git_dir: &local,
1255 format: ObjectFormat::Sha1,
1256 config: &GitConfig::default(),
1257 remote_name: "origin",
1258 source: &source,
1259 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1260 options: &options,
1261 },
1262 FetchServices {
1263 credentials: &mut credentials,
1264 progress: &mut progress,
1265 },
1266 )
1267 .expect("shallow fetch should succeed");
1268
1269 assert_eq!(
1270 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
1271 .expect("shallow file should read"),
1272 vec![tip]
1273 );
1274 }
1275
1276 #[test]
1277 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
1278 let remote = temp_repo("remote-missing");
1279 let local = temp_repo("local-missing");
1280 let old = commit_on(&local, "main", "old local");
1281 let bogus =
1282 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
1283 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1284 let mut tx = remote_refs.transaction();
1285 tx.update(RefUpdate {
1286 name: "refs/heads/main".into(),
1287 expected: None,
1288 new: RefTarget::Direct(bogus),
1289 reflog: None,
1290 });
1291 tx.update(RefUpdate {
1292 name: "HEAD".into(),
1293 expected: None,
1294 new: RefTarget::Symbolic("refs/heads/main".into()),
1295 reflog: None,
1296 });
1297 tx.commit().expect("remote bogus ref should write");
1298 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1299 let mut tx = local_refs.transaction();
1300 tx.update(RefUpdate {
1301 name: "refs/remotes/origin/main".into(),
1302 expected: None,
1303 new: RefTarget::Direct(old),
1304 reflog: None,
1305 });
1306 tx.commit().expect("local tracking ref should write");
1307 let source = FetchSource::Local {
1308 git_dir: remote.clone(),
1309 common_git_dir: remote.clone(),
1310 };
1311 let options = default_options();
1312 let mut credentials = NoCredentials;
1313 let mut progress = SilentProgress;
1314
1315 let err = fetch(
1316 FetchRequest {
1317 git_dir: &local,
1318 format: ObjectFormat::Sha1,
1319 config: &GitConfig::default(),
1320 remote_name: "origin",
1321 source: &source,
1322 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1323 options: &options,
1324 },
1325 FetchServices {
1326 credentials: &mut credentials,
1327 progress: &mut progress,
1328 },
1329 )
1330 .expect_err("fetch should fail before finalizing refs");
1331
1332 assert!(err.to_string().contains("missing object"));
1333 assert_eq!(
1334 local_refs
1335 .read_ref("refs/remotes/origin/main")
1336 .expect("ref should read"),
1337 Some(RefTarget::Direct(old))
1338 );
1339 assert!(!local.join("FETCH_HEAD").exists());
1340 }
1341}