1use crate::local::LocalDeepenPlan;
19use std::collections::{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}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct PrunedRef {
154 pub branch: String,
156 pub refname: String,
158}
159
160#[derive(Debug, Clone, Default)]
162pub struct FetchOutcome {
163 pub ref_updates: Vec<FetchRefUpdate>,
167 pub pruned: Vec<PrunedRef>,
170 pub head_symref: Option<String>,
173 pub wrote_fetch_head: bool,
175}
176
177pub struct FetchRequest<'a> {
179 pub git_dir: &'a Path,
181 pub format: ObjectFormat,
183 pub config: &'a GitConfig,
185 pub remote_name: &'a str,
187 pub source: &'a FetchSource,
189 pub refspecs: &'a [String],
192 pub options: &'a FetchOptions,
194}
195
196pub struct FetchServices<'a> {
198 pub credentials: &'a mut dyn CredentialProvider,
200 pub progress: &'a mut dyn ProgressSink,
202}
203
204pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
216 let mut options = request.options.clone();
217 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
218 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
219 crate::protocol::check_transport_allowed(
220 scheme_for_fetch_source(request.source),
221 Some(request.config),
222 None,
223 )
224 .map_err(crate::protocol::transport_policy_git_error)?;
225 let promisor_remote = request
226 .config
227 .get_bool("remote", Some(request.remote_name), "promisor")
228 .unwrap_or(false);
229 let configured_refspecs = if request.refspecs.is_empty() {
230 remote_config_values(request.config, request.remote_name, "fetch")
231 } else {
232 Vec::new()
233 };
234 let configured_refspecs_empty = configured_refspecs.is_empty();
235 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
241 let default_head_fetch =
242 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
243 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
244 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
245 let prune_refspecs =
246 prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
247 let mut effective_refspecs = fetch_refspecs_for_source(
248 configured_refspecs,
249 request.refspecs,
250 options.fetch_all_tags,
251 );
252 if options.prune_tags
253 && request.refspecs.is_empty()
254 && !effective_refspecs
255 .iter()
256 .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
257 {
258 effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
259 }
260 if has_merge_config {
261 if configured_refspecs_empty && request.refspecs.is_empty() {
264 effective_refspecs.retain(|spec| spec != "HEAD");
265 }
266 let configured_parsed = effective_refspecs
269 .iter()
270 .map(|refspec| parse_refspec(refspec))
271 .collect::<Result<Vec<_>>>()?;
272 for merge_src in &options.merge_srcs {
273 let covered = configured_parsed.iter().any(|refspec| {
277 refspec
278 .src
279 .as_deref()
280 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
281 });
282 if !covered {
283 effective_refspecs.push(merge_src.clone());
286 }
287 }
288 }
289 let parsed_refspecs = effective_refspecs
290 .iter()
291 .map(|refspec| parse_refspec(refspec))
292 .collect::<Result<Vec<_>>>()?;
293 if options.refmap.is_some() && request.refspecs.is_empty() {
294 return Err(GitError::Command(
295 "--refmap option is only meaningful with command-line refspec(s)".into(),
296 ));
297 }
298 let tracking_refspec_strings = if request.refspecs.is_empty() {
299 Vec::new()
300 } else {
301 options.refmap.clone().unwrap_or_else(|| {
302 configured_refspecs_for_tracking(request.config, request.remote_name)
303 })
304 };
305 let tracking_refspecs = tracking_refspec_strings
306 .iter()
307 .map(|refspec| parse_refspec(refspec))
308 .collect::<Result<Vec<_>>>()?;
309 let parsed_prune_refspecs = prune_refspecs
310 .iter()
311 .map(|refspec| parse_refspec(refspec))
312 .collect::<Result<Vec<_>>>()?;
313
314 let store = FileRefStore::new(request.git_dir, request.format);
315 let mut outcome = FetchOutcome::default();
316
317 let advertisements = match request.source {
321 #[cfg(not(feature = "http"))]
322 FetchSource::Http(_) => {
323 return Err(GitError::Unsupported(
324 "HTTP transport is not enabled in this build".into(),
325 ));
326 }
327 #[cfg(feature = "http")]
328 FetchSource::Http(remote) => {
329 let client = crate::http::new_http_client();
330 let discovered = crate::http::http_service_advertisements(
331 &client,
332 remote,
333 request.format,
334 sley_protocol::GitService::UploadPack,
335 services.credentials,
336 )?;
337 let advertisements = discovered.set.refs;
338 let features = advertisements
339 .first()
340 .map(|advertisement| {
341 sley_protocol::parse_upload_pack_features(&advertisement.capabilities)
342 })
343 .transpose()?
344 .unwrap_or_default();
345 outcome.head_symref = head_symref_from_features(&features.symrefs);
346 let mut updates = plan_and_adjust_updates(FetchPlanInput {
347 advertisements: &advertisements,
348 refspecs: &parsed_refspecs,
349 options: &options,
350 store: &store,
351 reachable: None,
352 local_db: None,
353 deepen_excluded: None,
354 format: request.format,
355 configured_remote_fetch,
356 has_merge_config,
357 tracking_refspecs: &tracking_refspecs,
358 })?;
359 let wants = updates.iter().map(|update| update.oid).collect();
360 let existing_shallow =
364 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
365 let pack_request = crate::http::HttpFetchPackRequest {
366 client: &client,
367 git_dir: request.git_dir,
368 format: request.format,
369 remote,
370 wants,
371 shallow: existing_shallow,
372 deepen: options.depth,
373 promisor: promisor_remote,
374 };
375 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
376 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
377 GitError::InvalidFormat(
378 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
379 .into(),
380 )
381 })?;
382 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
383 pack_request,
384 handshake,
385 services.credentials,
386 )?
387 } else {
388 crate::http::install_fetch_pack_via_http_upload_pack(
389 pack_request,
390 services.credentials,
391 )?
392 };
393 if !options.dry_run {
394 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
395 }
396 finalize_fetch(
397 FetchFinalize {
398 git_dir: request.git_dir,
399 format: request.format,
400 store: &store,
401 options: &options,
402 fetch_head_source: &fetch_head_source,
403 default_head_fetch,
404 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
405 },
406 &mut updates,
407 &mut outcome,
408 )?;
409 advertisements
410 }
411 FetchSource::Ssh(remote) => {
412 let ssh_options = options
416 .ssh_options
417 .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
418 let (advertisements, features) =
419 crate::ssh::ssh_upload_pack_advertisements_with_options(
420 remote,
421 request.format,
422 ssh_options,
423 )?;
424 outcome.head_symref = head_symref_from_features(&features.symrefs);
425 let mut updates = plan_and_adjust_updates(FetchPlanInput {
426 advertisements: &advertisements,
427 refspecs: &parsed_refspecs,
428 options: &options,
429 store: &store,
430 reachable: None,
431 local_db: None,
432 deepen_excluded: None,
433 format: request.format,
434 configured_remote_fetch,
435 has_merge_config,
436 tracking_refspecs: &tracking_refspecs,
437 })?;
438 if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
439 append_missing_ext_advertised_tags(
440 &advertisements,
441 &parsed_refspecs,
442 &store,
443 &mut updates,
444 )?;
445 }
446 let wants = updates.iter().map(|update| update.oid).collect();
447 let existing_shallow =
450 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
451 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
452 crate::ssh::SshFetchPackRequest {
453 git_dir: request.git_dir,
454 format: request.format,
455 remote,
456 features: &features,
457 wants,
458 shallow: existing_shallow,
459 deepen: options.depth,
460 promisor: promisor_remote,
461 command_options: ssh_options,
462 },
463 )?;
464 if !options.dry_run {
465 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
466 }
467 finalize_fetch(
468 FetchFinalize {
469 git_dir: request.git_dir,
470 format: request.format,
471 store: &store,
472 options: &options,
473 fetch_head_source: &fetch_head_source,
474 default_head_fetch,
475 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
476 },
477 &mut updates,
478 &mut outcome,
479 )?;
480 advertisements
481 }
482 FetchSource::Git {
483 remote,
484 protocol_v2,
485 } => {
486 let protocol_v2 =
487 *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
488 let discovered = crate::git::git_upload_pack_advertisements_with_protocol(
489 remote,
490 request.format,
491 protocol_v2,
492 )?;
493 let advertisements = discovered.refs;
494 let features = discovered.features;
495 outcome.head_symref = head_symref_from_features(&features.symrefs);
496 let mut updates = plan_and_adjust_updates(FetchPlanInput {
497 advertisements: &advertisements,
498 refspecs: &parsed_refspecs,
499 options: &options,
500 store: &store,
501 reachable: None,
502 local_db: None,
503 deepen_excluded: None,
504 format: request.format,
505 configured_remote_fetch,
506 has_merge_config,
507 tracking_refspecs: &tracking_refspecs,
508 })?;
509 let wants = updates.iter().map(|update| update.oid).collect();
510 let existing_shallow =
511 shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
512 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
513 crate::git::GitFetchPackRequest {
514 git_dir: request.git_dir,
515 format: request.format,
516 remote,
517 features: &features,
518 wants,
519 shallow: existing_shallow,
520 deepen: options.depth,
521 promisor: promisor_remote,
522 protocol_v2: discovered.protocol_v2,
523 },
524 )?;
525 if !options.dry_run {
526 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
527 }
528 finalize_fetch(
529 FetchFinalize {
530 git_dir: request.git_dir,
531 format: request.format,
532 store: &store,
533 options: &options,
534 fetch_head_source: &fetch_head_source,
535 default_head_fetch,
536 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
537 },
538 &mut updates,
539 &mut outcome,
540 )?;
541 advertisements
542 }
543 FetchSource::Local {
544 git_dir: remote_git_dir,
545 common_git_dir: remote_common_git_dir,
546 } => {
547 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
548 if remote_format != request.format {
549 return Err(GitError::InvalidObjectId(format!(
550 "remote repository uses {}, local repository uses {}",
551 remote_format.name(),
552 request.format.name()
553 )));
554 }
555 let advertisements =
556 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
557 if advertisements
561 .iter()
562 .any(|advertisement| advertisement.name == "HEAD")
563 && let Some(RefTarget::Symbolic(target)) =
564 FileRefStore::new(remote_git_dir, request.format).read_ref("HEAD")?
565 {
566 outcome.head_symref = Some(target);
567 }
568 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
569 let remote_shallow =
581 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
582 let explicit_deepen = options.depth.is_some()
583 || options.deepen_since.is_some()
584 || !options.deepen_not.is_empty();
585 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
586 let mut deepen_not_oids = Vec::new();
589 for name in &options.deepen_not {
590 let resolved = advertisements.iter().find(|advertisement| {
591 advertisement.name == *name
592 || advertisement.name == format!("refs/tags/{name}")
593 || advertisement.name == format!("refs/heads/{name}")
594 || advertisement.name == format!("refs/{name}")
595 });
596 match resolved {
597 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
598 None => {
599 return Err(GitError::Command(format!(
600 "git upload-pack: deepen-not is not a ref: {name}"
601 )));
602 }
603 }
604 }
605 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
606 if !explicit_deepen && !implicit_deepen {
607 return Ok(None);
608 }
609 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
611 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
612 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
613 &remote_db,
614 request.format,
615 heads,
616 client_shallow,
617 options.deepen_since,
618 &deepen_not_oids,
619 )?));
620 }
621 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
622 Ok(Some(crate::local::compute_local_deepen(
623 &remote_db,
624 request.format,
625 heads,
626 client_shallow,
627 depth,
628 options.deepen_relative,
629 )?))
630 };
631 let primary_heads = {
632 let primary = plan_fetch_ref_updates(
633 &advertisements,
634 &parsed_refspecs,
635 options.auto_follow_tags,
636 )?;
637 let mut seen = HashSet::new();
638 let mut heads = Vec::new();
639 for update in &primary {
640 if seen.insert(update.oid) {
641 heads.push(update.oid);
642 }
643 }
644 heads
645 };
646 let mut deepen_plan = plan_deepen(&primary_heads)?;
647 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
648 let mut updates = plan_and_adjust_updates(FetchPlanInput {
649 advertisements: &advertisements,
650 refspecs: &parsed_refspecs,
651 options: &options,
652 store: &store,
653 reachable: Some((&remote_db, &advertisements)),
654 local_db: Some(&local_db),
655 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
656 format: request.format,
657 configured_remote_fetch,
658 has_merge_config,
659 tracking_refspecs: &tracking_refspecs,
660 })?;
661 if implicit_deepen && !options.cloning && !options.update_shallow {
666 let client_shallow: HashSet<ObjectId> =
667 crate::shallow::read_shallow(request.git_dir, request.format)?
668 .into_iter()
669 .collect();
670 let new_points: HashSet<ObjectId> = deepen_plan
671 .as_ref()
672 .map(|plan| {
673 plan.shallow_info
674 .iter()
675 .filter_map(|entry| match entry {
676 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
677 if !client_shallow.contains(oid) =>
678 {
679 Some(*oid)
680 }
681 _ => None,
682 })
683 .collect()
684 })
685 .unwrap_or_default();
686 if !new_points.is_empty() {
687 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
688 let mut dirty = |tip: &ObjectId| -> Result<bool> {
689 if let Some(&cached) = dirty_cache.get(tip) {
690 return Ok(cached);
691 }
692 let result =
693 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
694 dirty_cache.insert(*tip, result);
695 Ok(result)
696 };
697 let mut kept = Vec::new();
698 for update in updates {
699 if dirty(&update.oid)? {
700 continue;
701 }
702 kept.push(update);
703 }
704 updates = kept;
705 let mut seen = HashSet::new();
708 let mut heads = Vec::new();
709 for update in &updates {
710 if seen.insert(update.oid) {
711 heads.push(update.oid);
712 }
713 }
714 deepen_plan = if heads.is_empty() {
715 None
716 } else {
717 plan_deepen(&heads)?
718 };
719 }
720 }
721 let starts: Vec<ObjectId> = if options.refetch {
722 let mut seen = HashSet::new();
723 updates
724 .iter()
725 .map(|update| update.oid)
726 .chain(primary_heads.iter().copied())
727 .filter(|oid| seen.insert(*oid))
728 .collect()
729 } else if deepen_plan.is_none() {
730 let mut starts = Vec::new();
731 for update in &updates {
732 if !local_db.contains(&update.oid)? {
733 starts.push(update.oid);
734 }
735 }
736 starts
737 } else {
738 updates.iter().map(|update| update.oid).collect()
739 };
740 let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
741 if !updates.is_empty() {
742 sley_protocol::trace_packet_write_payload(b"0000");
743 }
744 Vec::new()
745 } else {
746 crate::local::install_fetch_pack_via_local_upload_pack(
747 request.git_dir,
748 remote_git_dir,
749 request.format,
750 starts,
751 deepen_plan.as_ref(),
752 promisor_remote,
753 options.record_promisor_refs,
754 options.filter.clone(),
755 options.refetch,
756 local_fetch_unpack_limit(request.git_dir, promisor_remote),
757 )?
758 };
759 if !options.dry_run {
760 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
761 }
762 finalize_fetch(
763 FetchFinalize {
764 git_dir: request.git_dir,
765 format: request.format,
766 store: &store,
767 options: &options,
768 fetch_head_source: &fetch_head_source,
769 default_head_fetch,
770 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
771 },
772 &mut updates,
773 &mut outcome,
774 )?;
775 advertisements
776 }
777 };
778
779 if options.prune && !parsed_prune_refspecs.is_empty() {
780 outcome.pruned = prune_refs_from_advertisements(
781 PruneRefsInput {
782 config: request.config,
783 store: &store,
784 remote: request.remote_name,
785 advertisements: &advertisements,
786 refspecs: &parsed_prune_refspecs,
787 dry_run: options.dry_run,
788 quiet: options.quiet,
789 },
790 services.progress,
791 )?;
792 }
793
794 Ok(outcome)
795}
796
797fn scheme_for_fetch_source(source: &FetchSource) -> &'static str {
798 match source {
799 FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
800 FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
801 FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
802 FetchSource::Local { .. } => "file",
803 }
804}
805
806fn local_fetch_unpack_limit(git_dir: &Path, promisor_remote: bool) -> Option<usize> {
807 if promisor_remote {
808 return None;
809 }
810 git_dir
811 .join("objects")
812 .join("info")
813 .join("alternates")
814 .exists()
815 .then_some(100)
816}
817
818fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
822 remote_db: &R,
823 format: ObjectFormat,
824 tip: &ObjectId,
825 boundary: &HashSet<ObjectId>,
826) -> Result<bool> {
827 let mut seen: HashSet<ObjectId> = HashSet::new();
828 let mut queue: Vec<ObjectId> = vec![*tip];
829 while let Some(oid) = queue.pop() {
830 if !seen.insert(oid) {
831 continue;
832 }
833 let object = remote_db.read_object(&oid)?;
834 let commit = match object.object_type {
835 sley_object::ObjectType::Commit => {
836 sley_object::Commit::parse_ref(format, &object.body)?
837 }
838 sley_object::ObjectType::Tag => {
839 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
840 queue.push(tag.object);
841 continue;
842 }
843 _ => continue,
844 };
845 if boundary.contains(&oid) {
846 return Ok(true);
847 }
848 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
849 }
850 Ok(false)
851}
852
853fn shallow_boundary_for_request(
858 git_dir: &Path,
859 format: ObjectFormat,
860 depth: Option<u32>,
861) -> Result<Vec<ObjectId>> {
862 if depth.is_none() {
863 return Ok(Vec::new());
864 }
865 crate::shallow::read_shallow(git_dir, format)
866}
867
868struct FetchPlanInput<'a> {
874 advertisements: &'a [RefAdvertisement],
875 refspecs: &'a [RefSpec],
876 options: &'a FetchOptions,
877 store: &'a FileRefStore,
878 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
879 local_db: Option<&'a FileObjectDatabase>,
883 deepen_excluded: Option<&'a HashSet<ObjectId>>,
884 format: ObjectFormat,
885 configured_remote_fetch: bool,
886 has_merge_config: bool,
890 tracking_refspecs: &'a [RefSpec],
892}
893
894fn plan_and_adjust_updates(input: FetchPlanInput<'_>) -> Result<Vec<FetchRefUpdate>> {
895 let FetchPlanInput {
896 advertisements,
897 refspecs,
898 options,
899 store,
900 reachable,
901 local_db,
902 deepen_excluded,
903 format,
904 configured_remote_fetch,
905 has_merge_config,
906 tracking_refspecs,
907 } = input;
908 let visible_advertisements = advertisements_without_peeled_refs(advertisements);
909 let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
910 advertisements
911 } else {
912 visible_advertisements.as_slice()
913 };
914 let mut updates =
915 plan_fetch_ref_updates(planning_advertisements, refspecs, options.auto_follow_tags)?;
916 if options.fetch_all_tags {
917 mark_tag_refspec_updates_not_for_merge(&mut updates);
918 } else {
919 if options.auto_follow_tags
920 && let Some((remote_db, advertisements)) = reachable
921 {
922 let visible_reachable_advertisements =
923 advertisements_without_peeled_refs(advertisements);
924 let reachable_advertisements =
925 if visible_reachable_advertisements.len() == advertisements.len() {
926 advertisements
927 } else {
928 visible_reachable_advertisements.as_slice()
929 };
930 append_reachable_auto_follow_tags(
931 reachable_advertisements,
932 remote_db,
933 local_db,
934 format,
935 refspecs,
936 &mut updates,
937 deepen_excluded,
938 )?;
939 }
940 retain_missing_auto_follow_tags(store, &mut updates)?;
941 }
942 if configured_remote_fetch || has_merge_config {
943 for update in &mut updates {
944 update.not_for_merge = true;
945 }
946 if !options.merge_srcs.is_empty() {
947 for update in &mut updates {
952 if options
953 .merge_srcs
954 .iter()
955 .any(|src| refname_matches(src, &update.src))
956 {
957 update.not_for_merge = false;
958 }
959 }
960 } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
961 && !first.pattern
962 {
963 if let Some(update) = updates.first_mut() {
968 update.not_for_merge = false;
969 }
970 }
971 updates.sort_by_key(|update| update.not_for_merge);
975 }
976 append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
977 Ok(updates)
978}
979
980fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
981 if remote_exists(config, remote) {
982 remote_config_values(config, remote, "fetch")
983 } else {
984 Vec::new()
985 }
986}
987
988fn append_opportunistic_tracking_updates(
989 updates: &mut Vec<FetchRefUpdate>,
990 tracking_refspecs: &[RefSpec],
991) -> Result<()> {
992 if tracking_refspecs.is_empty() {
993 return Ok(());
994 }
995 let mut seen_dsts = updates
996 .iter()
997 .filter_map(|update| update.dst.clone())
998 .collect::<HashSet<_>>();
999 let mut additions = Vec::new();
1000 for update in updates.iter() {
1001 if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1002 continue;
1003 }
1004 for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1005 let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1006 continue;
1007 };
1008 if !seen_dsts.insert(dst.clone()) {
1009 continue;
1010 }
1011 additions.push(FetchRefUpdate {
1012 src: update.src.clone(),
1013 dst: Some(dst),
1014 oid: update.oid,
1015 not_for_merge: true,
1016 force: refspec.force,
1017 });
1018 }
1019 }
1020 updates.extend(additions);
1021 Ok(())
1022}
1023
1024fn advertisements_without_peeled_refs(
1025 advertisements: &[RefAdvertisement],
1026) -> Vec<RefAdvertisement> {
1027 advertisements
1028 .iter()
1029 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1030 .cloned()
1031 .collect()
1032}
1033
1034fn append_missing_ext_advertised_tags(
1035 advertisements: &[RefAdvertisement],
1036 refspecs: &[RefSpec],
1037 store: &FileRefStore,
1038 updates: &mut Vec<FetchRefUpdate>,
1039) -> Result<()> {
1040 let mut seen = updates
1041 .iter()
1042 .map(|update| update.src.clone())
1043 .collect::<HashSet<_>>();
1044 let mut tags = Vec::new();
1045 for reference in advertisements {
1046 if !reference.name.starts_with("refs/tags/")
1047 || reference.name.ends_with("^{}")
1048 || !seen.insert(reference.name.clone())
1049 || fetch_refspec_excludes(refspecs, &reference.name)?
1050 || store.read_ref(&reference.name)?.is_some()
1051 {
1052 continue;
1053 }
1054 tags.push(FetchRefUpdate {
1055 src: reference.name.clone(),
1056 dst: Some(reference.name.clone()),
1057 oid: reference.oid,
1058 not_for_merge: true,
1059 force: false,
1060 });
1061 }
1062 tags.sort_by(|a, b| a.src.cmp(&b.src));
1063 updates.extend(tags);
1064 Ok(())
1065}
1066
1067struct FetchFinalize<'a> {
1071 git_dir: &'a Path,
1072 format: ObjectFormat,
1073 store: &'a FileRefStore,
1074 options: &'a FetchOptions,
1075 fetch_head_source: &'a str,
1076 default_head_fetch: bool,
1077 log_all_ref_updates: bool,
1078}
1079
1080fn downgrade_non_commit_for_merge(
1086 git_dir: &Path,
1087 format: ObjectFormat,
1088 updates: &mut [FetchRefUpdate],
1089) {
1090 if updates.iter().all(|update| update.not_for_merge) {
1091 return;
1092 }
1093 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1094 for update in updates.iter_mut() {
1095 if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
1096 update.not_for_merge = true;
1097 }
1098 }
1099}
1100
1101fn finalize_fetch(
1102 finalize: FetchFinalize<'_>,
1103 updates: &mut Vec<FetchRefUpdate>,
1104 outcome: &mut FetchOutcome,
1105) -> Result<()> {
1106 let FetchFinalize {
1107 git_dir,
1108 format,
1109 store,
1110 options,
1111 fetch_head_source,
1112 default_head_fetch,
1113 log_all_ref_updates,
1114 } = finalize;
1115 if options.dry_run {
1116 outcome.ref_updates = std::mem::take(updates);
1117 return Ok(());
1118 }
1119 downgrade_non_commit_for_merge(git_dir, format, updates);
1120 validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1121 if options.write_fetch_head {
1122 if default_head_fetch
1123 && updates.len() == 1
1124 && updates[0].src == "HEAD"
1125 && updates[0].dst.is_none()
1126 {
1127 write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, options.append)?;
1128 } else {
1129 write_fetch_head(git_dir, fetch_head_source, updates, options.append)?;
1130 }
1131 outcome.wrote_fetch_head = true;
1132 }
1133 apply_fetch_ref_updates(
1134 store,
1135 format,
1136 fetch_head_source,
1137 log_all_ref_updates,
1138 updates,
1139 )?;
1140 outcome.ref_updates = std::mem::take(updates);
1141 Ok(())
1142}
1143
1144fn apply_fetch_ref_updates(
1145 store: &FileRefStore,
1146 format: ObjectFormat,
1147 fetch_head_source: &str,
1148 log_all_ref_updates: bool,
1149 updates: &[FetchRefUpdate],
1150) -> Result<()> {
1151 let mut seen = BTreeSet::new();
1152 let mut tx = store.transaction();
1153 for update in updates {
1154 let Some(dst) = update.dst.as_deref() else {
1155 continue;
1156 };
1157 if !seen.insert(dst.to_string()) {
1158 return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
1159 }
1160 let old_oid = match store.read_ref(dst)? {
1161 Some(RefTarget::Direct(oid)) => Some(oid),
1162 Some(RefTarget::Symbolic(target)) => {
1163 return Err(GitError::Transaction(format!(
1164 "fetch ref {dst} would overwrite symbolic ref {target}"
1165 )));
1166 }
1167 None => None,
1168 };
1169 let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
1170 Some(ReflogEntry {
1171 old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
1172 new_oid: update.oid,
1173 committer: fetch_reflog_committer(),
1174 message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
1175 })
1176 } else {
1177 None
1178 };
1179 tx.update(RefUpdate {
1180 name: dst.to_string(),
1181 expected: old_oid.map(RefTarget::Direct),
1182 new: RefTarget::Direct(update.oid),
1183 reflog,
1184 });
1185 }
1186 tx.commit()
1187}
1188
1189fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
1190 match config.get("core", None, "logallrefupdates") {
1191 Some(value) => {
1192 let value = value.to_ascii_lowercase();
1193 matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
1194 }
1195 None => false,
1196 }
1197}
1198
1199fn fetch_should_write_reflog(refname: &str) -> bool {
1200 refname == "HEAD"
1201 || refname.starts_with("refs/heads/")
1202 || refname.starts_with("refs/remotes/")
1203 || refname.starts_with("refs/notes/")
1204}
1205
1206fn fetch_reflog_committer() -> Vec<u8> {
1207 let seconds = SystemTime::now()
1208 .duration_since(UNIX_EPOCH)
1209 .map(|duration| duration.as_secs())
1210 .unwrap_or(0);
1211 format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
1212}
1213
1214fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
1215 let src = fetch_reflog_short_ref(&update.src);
1216 let dst = update
1217 .dst
1218 .as_deref()
1219 .map(fetch_reflog_short_ref)
1220 .unwrap_or_else(|| update.src.clone());
1221 let action = if !old_exists {
1222 if update.src.starts_with("refs/tags/") {
1223 "storing tag"
1224 } else if update.src.starts_with("refs/heads/") {
1225 "storing head"
1226 } else {
1227 "storing ref"
1228 }
1229 } else if update.force {
1230 "forced-update"
1231 } else if update.src.starts_with("refs/tags/") {
1232 "updating tag"
1233 } else {
1234 "fast-forward"
1235 };
1236 format!("fetch {source} {src}:{dst}: {action}").into_bytes()
1237}
1238
1239fn fetch_reflog_short_ref(refname: &str) -> String {
1240 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
1241 if let Some(short) = refname.strip_prefix(prefix) {
1242 return short.to_string();
1243 }
1244 }
1245 refname.to_string()
1246}
1247
1248fn validate_fetch_ref_updates(
1249 git_dir: &Path,
1250 _format: ObjectFormat,
1251 store: &FileRefStore,
1252 update_head_ok: bool,
1253 updates: &[FetchRefUpdate],
1254) -> Result<()> {
1255 for update in updates {
1256 let Some(dst) = update.dst.as_deref() else {
1257 continue;
1258 };
1259 let old = match store.read_ref(dst)? {
1260 Some(RefTarget::Direct(oid)) => Some(oid),
1261 Some(RefTarget::Symbolic(target)) => {
1262 return Err(GitError::Transaction(format!(
1263 "ref {dst} would overwrite symbolic ref {target}"
1264 )));
1265 }
1266 None => None,
1267 };
1268 if old.is_some()
1269 && !update_head_ok
1270 && dst.starts_with("refs/heads/")
1271 && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
1272 {
1273 return Err(GitError::InvalidFormat(format!(
1274 "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
1275 worktree.path.display()
1276 )));
1277 }
1278 if old.is_some()
1279 && old != Some(update.oid)
1280 && dst.starts_with("refs/tags/")
1281 && !update.force
1282 {
1283 return Err(GitError::Command(format!(
1284 "! [rejected] {} -> {} (would clobber existing tag)",
1285 update.src, dst
1286 )));
1287 }
1288 }
1289 Ok(())
1290}
1291
1292fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
1294 symrefs
1295 .iter()
1296 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
1297}
1298
1299pub fn apply_configured_remote_tag_option(
1302 config: &GitConfig,
1303 source: &str,
1304 options: &mut FetchOptions,
1305) {
1306 if options.tag_option_explicit || !remote_exists(config, source) {
1307 return;
1308 }
1309 match remote_config_values(config, source, "tagopt")
1310 .into_iter()
1311 .last()
1312 .as_deref()
1313 {
1314 Some("--tags") => {
1315 options.auto_follow_tags = true;
1316 options.fetch_all_tags = true;
1317 }
1318 Some("--no-tags") => {
1319 options.auto_follow_tags = false;
1320 options.fetch_all_tags = false;
1321 }
1322 _ => {}
1323 }
1324}
1325
1326pub fn apply_configured_fetch_prune_option(
1329 config: &GitConfig,
1330 source: &str,
1331 options: &mut FetchOptions,
1332) {
1333 if !options.prune_option_explicit {
1334 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
1335 options.prune = prune;
1336 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
1337 options.prune = prune;
1338 }
1339 }
1340 if !options.prune_tags_option_explicit {
1341 if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
1342 options.prune_tags = prune_tags;
1343 } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
1344 options.prune_tags = prune_tags;
1345 }
1346 }
1347}
1348
1349pub fn fetch_refspecs_for_source(
1353 configured: Vec<String>,
1354 refspecs: &[String],
1355 fetch_all_tags: bool,
1356) -> Vec<String> {
1357 let mut effective = if !refspecs.is_empty() {
1358 refspecs.to_vec()
1359 } else if configured.is_empty() {
1360 vec!["HEAD".to_string()]
1361 } else {
1362 configured
1363 };
1364 if fetch_all_tags {
1365 effective.push("refs/tags/*:refs/tags/*".to_string());
1366 }
1367 effective
1368}
1369
1370fn prune_refspecs_for_source(
1371 configured: &[String],
1372 refspecs: &[String],
1373 prune_tags: bool,
1374) -> Vec<String> {
1375 let mut effective = if !refspecs.is_empty() {
1376 refspecs.to_vec()
1377 } else {
1378 configured.to_vec()
1379 };
1380 if prune_tags && refspecs.is_empty() {
1381 effective.push("refs/tags/*:refs/tags/*".to_string());
1382 }
1383 effective
1384}
1385
1386fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
1391 if refspec.pattern {
1392 let Some((prefix, suffix)) = src.split_once('*') else {
1393 return false;
1394 };
1395 let fits = |name: &str| {
1400 name.len() >= prefix.len() + suffix.len()
1401 && name.starts_with(prefix)
1402 && name.ends_with(suffix)
1403 };
1404 fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
1405 } else {
1406 refname_matches(merge_src, src) || refname_matches(src, merge_src)
1407 }
1408}
1409
1410pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
1412 for update in updates {
1413 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
1414 update.not_for_merge = true;
1415 }
1416 }
1417}
1418
1419pub fn retain_missing_auto_follow_tags(
1421 store: &FileRefStore,
1422 updates: &mut Vec<FetchRefUpdate>,
1423) -> Result<()> {
1424 let mut retained = Vec::with_capacity(updates.len());
1425 for update in updates.drain(..) {
1426 if update.not_for_merge
1427 && update.src.starts_with("refs/tags/")
1428 && update.dst.as_deref() == Some(&update.src)
1429 && store.read_ref(&update.src)?.is_some()
1430 {
1431 continue;
1432 }
1433 retained.push(update);
1434 }
1435 *updates = retained;
1436 Ok(())
1437}
1438
1439pub fn append_reachable_auto_follow_tags(
1442 advertisements: &[RefAdvertisement],
1443 remote_db: &FileObjectDatabase,
1444 local_db: Option<&FileObjectDatabase>,
1445 format: ObjectFormat,
1446 refspecs: &[RefSpec],
1447 updates: &mut Vec<FetchRefUpdate>,
1448 deepen_excluded: Option<&HashSet<ObjectId>>,
1449) -> Result<()> {
1450 if !updates.iter().any(|update| update.dst.is_some()) {
1451 return Ok(());
1452 }
1453 updates.retain(|update| {
1458 !(update.src.starts_with("refs/tags/")
1459 && update.dst.as_deref() == Some(update.src.as_str())
1460 && update.not_for_merge)
1461 });
1462 let mut starts = Vec::new();
1467 for update in updates.iter().filter(|update| update.dst.is_some()) {
1468 if update.src.starts_with("refs/tags/") {
1469 if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1470 starts.push(target);
1471 } else {
1472 starts.push(update.oid);
1473 }
1474 } else {
1475 starts.push(update.oid);
1476 }
1477 }
1478 let reachable = match deepen_excluded {
1482 Some(excluded) => {
1483 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1484 }
1485 None => collect_reachable_object_ids(remote_db, format, starts)?,
1486 };
1487 let fetched_srcs = updates
1488 .iter()
1489 .map(|update| update.src.clone())
1490 .collect::<HashSet<_>>();
1491 let mut followed = Vec::new();
1492 for reference in advertisements {
1493 if !reference.name.starts_with("refs/tags/")
1494 || fetched_srcs.contains(&reference.name)
1495 || fetch_refspec_excludes(refspecs, &reference.name)?
1496 {
1497 continue;
1498 }
1499 let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
1507 let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
1508 let present_locally = local_db
1509 .map(|db| db.contains(&target))
1510 .transpose()?
1511 .unwrap_or(false);
1512 if !fetched && !present_locally {
1513 continue;
1514 }
1515 followed.push(FetchRefUpdate {
1516 src: reference.name.clone(),
1517 dst: Some(reference.name.clone()),
1518 oid: reference.oid,
1519 not_for_merge: true,
1520 force: false,
1521 });
1522 }
1523 followed.sort_by(|a, b| a.src.cmp(&b.src));
1524 updates.extend(followed);
1525 Ok(())
1526}
1527
1528fn peel_tag_target(
1533 db: &FileObjectDatabase,
1534 format: ObjectFormat,
1535 oid: &ObjectId,
1536) -> Result<Option<ObjectId>> {
1537 let mut current = *oid;
1538 let mut peeled = None;
1539 loop {
1540 let Ok(object) = db.read_object(¤t) else {
1541 return Ok(peeled);
1542 };
1543 if object.object_type != sley_object::ObjectType::Tag {
1544 return Ok(peeled);
1545 }
1546 let tag = sley_object::Tag::parse(format, &object.body)?;
1547 current = tag.object;
1548 peeled = Some(current);
1549 }
1550}
1551
1552pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
1554 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
1555 if refspec.pattern {
1556 if refspec_map_source(refspec, name)?.is_some() {
1557 return Ok(true);
1558 }
1559 } else if refspec.src.as_deref() == Some(name) {
1560 return Ok(true);
1561 }
1562 }
1563 Ok(false)
1564}
1565
1566pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
1569 let followed_oids = updates
1570 .iter()
1571 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
1572 .map(|update| update.oid)
1573 .collect::<HashSet<_>>();
1574 if followed_oids.is_empty() {
1575 return;
1576 }
1577
1578 let mut non_tags = Vec::new();
1579 let mut followed_tags = Vec::new();
1580 let mut other_tags = Vec::new();
1581 for update in updates.drain(..) {
1582 if update.src.starts_with("refs/tags/") {
1583 if followed_oids.contains(&update.oid) {
1584 followed_tags.push(update);
1585 } else {
1586 other_tags.push(update);
1587 }
1588 } else {
1589 non_tags.push(update);
1590 }
1591 }
1592 updates.extend(non_tags);
1593 updates.extend(followed_tags);
1594 updates.extend(other_tags);
1595}
1596
1597pub fn write_default_fetch_head(
1599 git_dir: &Path,
1600 source: &str,
1601 oid: ObjectId,
1602 append: bool,
1603) -> Result<()> {
1604 let records = [FetchHeadRecord {
1605 oid,
1606 not_for_merge: false,
1607 description: source.to_string(),
1608 }];
1609 write_fetch_head_records(git_dir, &records, append)?;
1610 Ok(())
1611}
1612
1613pub fn write_fetch_head_records(
1615 git_dir: &Path,
1616 records: &[FetchHeadRecord],
1617 append: bool,
1618) -> Result<()> {
1619 let encoded = encode_fetch_head(records)?;
1620 if append {
1621 let mut file = fs::OpenOptions::new()
1622 .create(true)
1623 .append(true)
1624 .open(git_dir.join("FETCH_HEAD"))?;
1625 file.write_all(&encoded)?;
1626 } else {
1627 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
1628 }
1629 Ok(())
1630}
1631
1632pub fn write_fetch_head(
1634 git_dir: &Path,
1635 description: &str,
1636 fetched: &[FetchRefUpdate],
1637 append: bool,
1638) -> Result<()> {
1639 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
1640 write_fetch_head_records(git_dir, &records, append)?;
1641 Ok(())
1642}
1643
1644pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
1647 let url = remote_config_values(config, source, "url")
1648 .into_iter()
1649 .next()
1650 .map(|url| rewrite_url_with_config(config, &url, false))
1651 .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
1652 trim_fetch_head_display_url(&url)
1653}
1654
1655fn trim_fetch_head_display_url(url: &str) -> String {
1659 let bytes = url.as_bytes();
1660 let mut end = bytes.len();
1661 while end > 0 && bytes[end - 1] == b'/' {
1662 end -= 1;
1663 }
1664 if end > 5 && &bytes[end - 4..end] == b".git" {
1667 end -= 4;
1668 }
1669 String::from_utf8_lossy(&bytes[..end]).into_owned()
1670}
1671
1672pub struct PruneRefsInput<'a> {
1677 pub config: &'a GitConfig,
1678 pub store: &'a FileRefStore,
1679 pub remote: &'a str,
1680 pub advertisements: &'a [RefAdvertisement],
1681 pub refspecs: &'a [RefSpec],
1682 pub dry_run: bool,
1683 pub quiet: bool,
1684}
1685
1686pub fn prune_refs_from_advertisements(
1687 input: PruneRefsInput<'_>,
1688 progress: &mut dyn ProgressSink,
1689) -> Result<Vec<PrunedRef>> {
1690 let remote_refs = input
1691 .advertisements
1692 .iter()
1693 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1694 .map(|advertisement| advertisement.name.as_str())
1695 .collect::<BTreeSet<_>>();
1696 let local_refs = input.store.list_refs()?;
1697 let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
1698 if stale_refs.is_empty() {
1699 return Ok(Vec::new());
1700 }
1701 let mut emit = |line: &str| {
1702 if !input.quiet {
1703 progress.message(line);
1704 }
1705 };
1706 let display_url = remote_config_values(input.config, input.remote, "url")
1707 .into_iter()
1708 .next()
1709 .unwrap_or_else(|| input.remote.into());
1710 emit(&format!("Pruning {}", input.remote));
1711 emit(&format!("URL: {display_url}"));
1712 let mut pruned = Vec::new();
1713 for refname in stale_refs {
1714 if !input.dry_run {
1715 match input.store.read_ref(&refname)? {
1716 Some(RefTarget::Symbolic(_)) => {
1717 let _ = input.store.delete_symbolic_ref(&refname)?;
1718 }
1719 Some(RefTarget::Direct(_)) => {
1720 let _ = input.store.delete_ref(&refname)?;
1721 }
1722 None => {}
1723 }
1724 }
1725 let display = prettify_pruned_ref(input.remote, &refname);
1726 let action = if input.dry_run {
1727 "would prune"
1728 } else {
1729 "pruned"
1730 };
1731 emit(&format!(" * [{action}] {display}"));
1732 let branch = display;
1733 pruned.push(PrunedRef { branch, refname });
1734 }
1735 Ok(pruned)
1736}
1737
1738fn stale_refs_for_prune(
1739 local_refs: &[Ref],
1740 refspecs: &[RefSpec],
1741 remote_refs: &BTreeSet<&str>,
1742) -> Result<Vec<String>> {
1743 let mut stale = Vec::new();
1744 for reference in local_refs {
1745 if matches!(reference.target, RefTarget::Symbolic(_)) {
1746 continue;
1747 }
1748 let sources = prune_sources_for_destination(refspecs, &reference.name)?;
1749 if sources.is_empty() {
1750 continue;
1751 }
1752 if sources.iter().all(|source| !remote_refs.contains(source.as_str())) {
1753 stale.push(reference.name.clone());
1754 }
1755 }
1756 stale.sort();
1757 Ok(stale)
1758}
1759
1760fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
1761 let mut sources = Vec::new();
1762 for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
1763 let Some(src) = refspec.src.as_deref() else {
1764 continue;
1765 };
1766 let Some(dst) = refspec.dst.as_deref() else {
1767 continue;
1768 };
1769 if refspec.pattern {
1770 let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
1771 continue;
1772 };
1773 let Some(middle) = destination
1774 .strip_prefix(dst_prefix)
1775 .and_then(|value| value.strip_suffix(dst_suffix))
1776 else {
1777 continue;
1778 };
1779 let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
1780 GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
1781 })?;
1782 sources.push(format!("{src_prefix}{middle}{src_suffix}"));
1783 } else if dst == destination {
1784 sources.push(src.to_string());
1785 }
1786 }
1787 sources.sort();
1788 sources.dedup();
1789 Ok(sources)
1790}
1791
1792fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
1793 if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
1794 return format!("{remote}/{branch}");
1795 }
1796 if let Some(tag) = refname.strip_prefix("refs/tags/") {
1797 return tag.to_string();
1798 }
1799 refname.to_string()
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804 use super::*;
1805 use std::sync::atomic::{AtomicU64, Ordering};
1806
1807 use sley_formats::RepositoryLayout;
1808 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1809 use sley_odb::{FileObjectDatabase, ObjectWriter};
1810 use sley_refs::{RefTarget, RefUpdate};
1811
1812 use crate::{NoCredentials, SilentProgress};
1813
1814 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1815
1816 fn temp_repo(name: &str) -> PathBuf {
1817 let dir = std::env::temp_dir().join(format!(
1818 "sley-remote-fetch-{name}-{}-{}",
1819 std::process::id(),
1820 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1821 ));
1822 let _ = fs::remove_dir_all(&dir);
1823 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1824 .expect("test repository should initialize");
1825 dir.join(".git")
1826 }
1827
1828 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
1829 let format = ObjectFormat::Sha1;
1830 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1831 let tree = db
1832 .write_object(EncodedObject::new(
1833 ObjectType::Tree,
1834 Tree { entries: vec![] }.write(),
1835 ))
1836 .expect("tree should write");
1837 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1838 let oid = db
1839 .write_object(EncodedObject::new(
1840 ObjectType::Commit,
1841 Commit {
1842 tree,
1843 parents: Vec::new(),
1844 author: identity.clone(),
1845 committer: identity,
1846 encoding: None,
1847 message: format!("{message}\n").into_bytes(),
1848 }
1849 .write(),
1850 ))
1851 .expect("commit should write");
1852 let store = FileRefStore::new(git_dir, format);
1853 let mut tx = store.transaction();
1854 tx.update(RefUpdate {
1855 name: format!("refs/heads/{branch}"),
1856 expected: None,
1857 new: RefTarget::Direct(oid),
1858 reflog: None,
1859 });
1860 tx.update(RefUpdate {
1861 name: "HEAD".into(),
1862 expected: None,
1863 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
1864 reflog: None,
1865 });
1866 tx.commit().expect("refs should update");
1867 oid
1868 }
1869
1870 fn default_options() -> FetchOptions {
1871 FetchOptions {
1872 quiet: true,
1873 auto_follow_tags: false,
1874 fetch_all_tags: false,
1875 prune: false,
1876 prune_tags: false,
1877 dry_run: false,
1878 append: false,
1879 write_fetch_head: true,
1880 tag_option_explicit: true,
1881 prune_option_explicit: true,
1882 prune_tags_option_explicit: true,
1883 refmap: None,
1884 depth: None,
1885 merge_srcs: Vec::new(),
1886 filter: None,
1887 refetch: false,
1888 cloning: false,
1889 record_promisor_refs: true,
1890 update_shallow: false,
1891 deepen_relative: false,
1892 update_head_ok: false,
1893 deepen_since: None,
1894 deepen_not: Vec::new(),
1895 ssh_options: None,
1896 }
1897 }
1898
1899 #[test]
1900 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
1901 let remote = temp_repo("remote");
1902 let local = temp_repo("local");
1903 let tip = commit_on(&remote, "main", "remote tip");
1904 let source = FetchSource::Local {
1905 git_dir: remote.clone(),
1906 common_git_dir: remote.clone(),
1907 };
1908 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
1909 let options = default_options();
1910 let mut credentials = NoCredentials;
1911 let mut progress = SilentProgress;
1912
1913 let outcome = fetch(
1914 FetchRequest {
1915 git_dir: &local,
1916 format: ObjectFormat::Sha1,
1917 config: &GitConfig::default(),
1918 remote_name: "origin",
1919 source: &source,
1920 refspecs: &refspecs,
1921 options: &options,
1922 },
1923 FetchServices {
1924 credentials: &mut credentials,
1925 progress: &mut progress,
1926 },
1927 )
1928 .expect("fetch should succeed");
1929
1930 assert_eq!(outcome.ref_updates.len(), 1);
1931 assert!(outcome.wrote_fetch_head);
1932 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
1933 assert!(local_db.contains(&tip).expect("contains should read"));
1934 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1935 assert_eq!(
1936 local_refs
1937 .read_ref("refs/remotes/origin/main")
1938 .expect("ref should read"),
1939 Some(RefTarget::Direct(tip))
1940 );
1941 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
1942 assert!(fetch_head.contains("origin"));
1943 }
1944
1945 #[test]
1946 fn shallow_local_fetch_writes_depth_boundary_metadata() {
1947 let remote = temp_repo("remote-shallow");
1948 let local = temp_repo("local-shallow");
1949 let tip = commit_on(&remote, "main", "tip");
1950 let source = FetchSource::Local {
1951 git_dir: remote.clone(),
1952 common_git_dir: remote.clone(),
1953 };
1954 let mut options = default_options();
1955 options.depth = Some(1);
1956 let mut credentials = NoCredentials;
1957 let mut progress = SilentProgress;
1958
1959 fetch(
1960 FetchRequest {
1961 git_dir: &local,
1962 format: ObjectFormat::Sha1,
1963 config: &GitConfig::default(),
1964 remote_name: "origin",
1965 source: &source,
1966 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1967 options: &options,
1968 },
1969 FetchServices {
1970 credentials: &mut credentials,
1971 progress: &mut progress,
1972 },
1973 )
1974 .expect("shallow fetch should succeed");
1975
1976 assert_eq!(
1977 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
1978 .expect("shallow file should read"),
1979 vec![tip]
1980 );
1981 }
1982
1983 fn pack_file_count(git_dir: &Path) -> usize {
1984 fs::read_dir(git_dir.join("objects/pack"))
1985 .expect("pack directory should read")
1986 .filter_map(|entry| entry.ok())
1987 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
1988 .count()
1989 }
1990
1991 #[test]
1992 fn same_depth_shallow_local_fetch_does_not_install_pack() {
1993 let remote = temp_repo("remote-shallow-noop");
1994 let local = temp_repo("local-shallow-noop");
1995 let tip = commit_on(&remote, "main", "tip");
1996 let source = FetchSource::Local {
1997 git_dir: remote.clone(),
1998 common_git_dir: remote.clone(),
1999 };
2000 let mut options = default_options();
2001 options.depth = Some(1);
2002 let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
2003 let mut credentials = NoCredentials;
2004 let mut progress = SilentProgress;
2005
2006 fetch(
2007 FetchRequest {
2008 git_dir: &local,
2009 format: ObjectFormat::Sha1,
2010 config: &GitConfig::default(),
2011 remote_name: "origin",
2012 source: &source,
2013 refspecs: &refspecs,
2014 options: &options,
2015 },
2016 FetchServices {
2017 credentials: &mut credentials,
2018 progress: &mut progress,
2019 },
2020 )
2021 .expect("initial shallow fetch should succeed");
2022 let pack_count = pack_file_count(&local);
2023 let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2024 .expect("shallow file should read");
2025
2026 fetch(
2027 FetchRequest {
2028 git_dir: &local,
2029 format: ObjectFormat::Sha1,
2030 config: &GitConfig::default(),
2031 remote_name: "origin",
2032 source: &source,
2033 refspecs: &refspecs,
2034 options: &options,
2035 },
2036 FetchServices {
2037 credentials: &mut credentials,
2038 progress: &mut progress,
2039 },
2040 )
2041 .expect("same-depth shallow fetch should succeed");
2042
2043 assert_eq!(pack_file_count(&local), pack_count);
2044 assert_eq!(
2045 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2046 .expect("shallow file should read"),
2047 shallow
2048 );
2049 assert_eq!(shallow, vec![tip]);
2050 }
2051
2052 #[test]
2053 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
2054 let remote = temp_repo("remote-missing");
2055 let local = temp_repo("local-missing");
2056 let old = commit_on(&local, "main", "old local");
2057 let bogus =
2058 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
2059 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2060 let mut tx = remote_refs.transaction();
2061 tx.update(RefUpdate {
2062 name: "refs/heads/main".into(),
2063 expected: None,
2064 new: RefTarget::Direct(bogus),
2065 reflog: None,
2066 });
2067 tx.update(RefUpdate {
2068 name: "HEAD".into(),
2069 expected: None,
2070 new: RefTarget::Symbolic("refs/heads/main".into()),
2071 reflog: None,
2072 });
2073 tx.commit().expect("remote bogus ref should write");
2074 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2075 let mut tx = local_refs.transaction();
2076 tx.update(RefUpdate {
2077 name: "refs/remotes/origin/main".into(),
2078 expected: None,
2079 new: RefTarget::Direct(old),
2080 reflog: None,
2081 });
2082 tx.commit().expect("local tracking ref should write");
2083 let source = FetchSource::Local {
2084 git_dir: remote.clone(),
2085 common_git_dir: remote.clone(),
2086 };
2087 let options = default_options();
2088 let mut credentials = NoCredentials;
2089 let mut progress = SilentProgress;
2090
2091 let err = fetch(
2092 FetchRequest {
2093 git_dir: &local,
2094 format: ObjectFormat::Sha1,
2095 config: &GitConfig::default(),
2096 remote_name: "origin",
2097 source: &source,
2098 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2099 options: &options,
2100 },
2101 FetchServices {
2102 credentials: &mut credentials,
2103 progress: &mut progress,
2104 },
2105 )
2106 .expect_err("fetch should fail before finalizing refs");
2107
2108 assert!(err.to_string().contains("missing object"));
2109 assert_eq!(
2110 local_refs
2111 .read_ref("refs/remotes/origin/main")
2112 .expect("ref should read"),
2113 Some(RefTarget::Direct(old))
2114 );
2115 assert!(!local.join("FETCH_HEAD").exists());
2116 }
2117}