1use crate::local::LocalDeepenPlan;
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use sley_config::GitConfig;
26use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
27use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
28use sley_odb::{
29 FileObjectDatabase, ObjectReader, collect_reachable_object_ids,
30 collect_reachable_object_ids_excluding,
31};
32#[cfg(feature = "http")]
33use sley_protocol::ProtocolVersion;
34use sley_protocol::{
35 FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
36 fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refname_matches,
37 refspec_map_source,
38};
39use sley_refs::{FileRefStore, Ref, RefTarget, RefUpdate, ReflogEntry};
40use sley_transport::{RemoteTransport, RemoteUrl};
41
42use crate::{CredentialProvider, ProgressSink};
43
44pub enum FetchSource {
49 Http(RemoteUrl),
51 Ssh(RemoteUrl),
54 Git {
56 remote: RemoteUrl,
57 protocol_v2: bool,
58 },
59 Local {
61 git_dir: PathBuf,
63 common_git_dir: PathBuf,
65 },
66}
67
68#[derive(Debug, Clone)]
70pub struct FetchOptions {
71 pub quiet: bool,
74 pub auto_follow_tags: bool,
76 pub fetch_all_tags: bool,
78 pub prune: bool,
80 pub prune_tags: bool,
82 pub dry_run: bool,
84 pub force: bool,
87 pub append: bool,
89 pub write_fetch_head: bool,
91 pub tag_option_explicit: bool,
94 pub prune_option_explicit: bool,
97 pub prune_tags_option_explicit: bool,
100 pub refmap: Option<Vec<String>>,
104 pub depth: Option<u32>,
109 pub merge_srcs: Vec<String>,
116 pub filter: Option<sley_odb::PackObjectFilter>,
122 pub refetch: bool,
125 pub cloning: bool,
128 pub record_promisor_refs: bool,
132 pub update_shallow: bool,
135 pub reject_shallow: bool,
137 pub deepen_relative: bool,
140 pub update_head_ok: bool,
143 pub deepen_since: Option<i64>,
146 pub deepen_not: Vec<String>,
150 pub ssh_options: Option<crate::ssh::SshTransportOptions>,
154 pub atomic: bool,
160 pub negotiation_restrict: Option<Vec<String>>,
164 pub negotiation_include: Option<Vec<String>>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct PrunedRef {
173 pub branch: String,
175 pub refname: String,
177}
178
179#[derive(Debug, Clone, Default)]
181pub struct FetchOutcome {
182 pub ref_updates: Vec<FetchRefUpdate>,
186 pub pruned: Vec<PrunedRef>,
189 pub head_symref: Option<String>,
192 pub wrote_fetch_head: bool,
194}
195
196pub struct FetchRequest<'a> {
198 pub git_dir: &'a Path,
200 pub format: ObjectFormat,
202 pub config: &'a GitConfig,
204 pub remote_name: &'a str,
206 pub source: &'a FetchSource,
208 pub refspecs: &'a [String],
211 pub options: &'a FetchOptions,
213}
214
215pub struct FetchServices<'a> {
217 pub credentials: &'a mut dyn CredentialProvider,
219 pub progress: &'a mut dyn ProgressSink,
221 pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
226}
227
228pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
240 let ref_hook = services.ref_hook;
241 let mut options = request.options.clone();
242 apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
243 apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
244 crate::protocol::check_transport_allowed(
245 scheme_for_fetch_source(request.source),
246 Some(request.config),
247 None,
248 )
249 .map_err(crate::protocol::transport_policy_git_error)?;
250 let promisor_remote = request
255 .config
256 .get_bool("remote", Some(request.remote_name), "promisor")
257 .unwrap_or(false)
258 || request.options.filter.is_some();
259 let max_input_size = fetch_max_input_size(request.config);
260 let configured_refspecs = if request.refspecs.is_empty() {
261 remote_config_values(request.config, request.remote_name, "fetch")
262 } else {
263 Vec::new()
264 };
265 let configured_refspecs_empty = configured_refspecs.is_empty();
266 let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
272 let default_head_fetch =
273 request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
274 let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
275 let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
276 let prune_refspecs =
277 prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
278 let mut effective_refspecs = fetch_refspecs_for_source(
279 configured_refspecs,
280 request.refspecs,
281 options.fetch_all_tags,
282 );
283 if options.prune_tags
284 && request.refspecs.is_empty()
285 && !effective_refspecs
286 .iter()
287 .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
288 {
289 effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
290 }
291 if has_merge_config {
292 if configured_refspecs_empty && request.refspecs.is_empty() {
295 effective_refspecs.retain(|spec| spec != "HEAD");
296 }
297 let configured_parsed = effective_refspecs
300 .iter()
301 .map(|refspec| parse_refspec(refspec))
302 .collect::<Result<Vec<_>>>()?;
303 for merge_src in &options.merge_srcs {
304 let covered = configured_parsed.iter().any(|refspec| {
308 refspec
309 .src
310 .as_deref()
311 .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
312 });
313 if !covered {
314 effective_refspecs.push(merge_src.clone());
317 }
318 }
319 }
320 let mut parsed_refspecs = effective_refspecs
321 .iter()
322 .map(|refspec| parse_refspec(refspec))
323 .collect::<Result<Vec<_>>>()?;
324 if options.force {
325 for refspec in &mut parsed_refspecs {
326 refspec.force = true;
327 }
328 }
329 if options.refmap.is_some() && request.refspecs.is_empty() {
330 return Err(GitError::Command(
331 "--refmap option is only meaningful with command-line refspec(s)".into(),
332 ));
333 }
334 let tracking_refspec_strings = if request.refspecs.is_empty() {
335 Vec::new()
336 } else {
337 options.refmap.clone().unwrap_or_else(|| {
338 configured_refspecs_for_tracking(request.config, request.remote_name)
339 })
340 };
341 let tracking_refspecs = tracking_refspec_strings
342 .iter()
343 .map(|refspec| parse_refspec(refspec))
344 .collect::<Result<Vec<_>>>()?;
345 let parsed_prune_refspecs = prune_refspecs
346 .iter()
347 .map(|refspec| parse_refspec(refspec))
348 .collect::<Result<Vec<_>>>()?;
349
350 let store = FileRefStore::new(request.git_dir, request.format);
351 let negotiation_haves = custom_negotiation_haves(
352 request.git_dir,
353 request.format,
354 request.config,
355 request.remote_name,
356 &options,
357 )?;
358 let mut outcome = FetchOutcome::default();
359
360 let advertisements = match request.source {
364 #[cfg(not(feature = "http"))]
365 FetchSource::Http(_) => {
366 return Err(GitError::Unsupported(
367 "HTTP transport is not enabled in this build".into(),
368 ));
369 }
370 #[cfg(feature = "http")]
371 FetchSource::Http(remote) => {
372 let http_batch = crate::http::HttpOperationBatch::new();
373 let client = http_batch.client();
374 let git_protocol =
375 crate::http::http_git_protocol_header_value(Some(request.config))?;
376 let discovered = crate::http::http_service_advertisements(
377 client,
378 remote,
379 request.format,
380 sley_protocol::GitService::UploadPack,
381 services.credentials,
382 Some(request.config),
383 )?;
384 let advertisements = discovered.set.refs;
385 let features = crate::http::http_upload_pack_features(
386 &advertisements,
387 discovered.handshake.as_ref(),
388 )?;
389 outcome.head_symref = head_symref_from_features(&features.symrefs);
390 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
391 advertisements: &advertisements,
392 refspecs: &parsed_refspecs,
393 options: &options,
394 store: &store,
395 reachable: None,
396 local_db: None,
397 deepen_excluded: None,
398 format: request.format,
399 configured_remote_fetch,
400 has_merge_config,
401 tracking_refspecs: &tracking_refspecs,
402 })?;
403 let wants = updates.iter().map(|update| update.oid).collect();
404 let existing_shallow =
408 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
409 let deepen_not = resolve_deepen_not_refs(&advertisements, &options.deepen_not)?;
410 let pack_request = crate::http::HttpFetchPackRequest {
411 client,
412 git_dir: request.git_dir,
413 format: request.format,
414 remote,
415 wants,
416 haves: negotiation_haves.clone(),
417 shallow: existing_shallow,
418 deepen: options.depth,
419 promisor: promisor_remote,
420 max_input_size,
421 filter: options.filter.clone(),
422 deepen_since: options.deepen_since,
423 deepen_not,
424 deepen_relative: options.deepen_relative,
425 git_protocol: git_protocol.as_deref(),
426 omit_haves: false,
427 };
428 let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
429 let handshake = discovered.handshake.as_ref().ok_or_else(|| {
430 GitError::InvalidFormat(
431 "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
432 .into(),
433 )
434 })?;
435 crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
436 pack_request,
437 handshake,
438 services.credentials,
439 services.progress,
440 )?
441 } else {
442 crate::http::install_fetch_pack_via_http_upload_pack(
443 pack_request,
444 services.credentials,
445 services.progress,
446 )?
447 };
448 reject_shallow_clone_fetch(&options, &shallow_info)?;
449 if !options.dry_run {
450 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
451 }
452 finalize_fetch(
453 FetchFinalize {
454 git_dir: request.git_dir,
455 format: request.format,
456 store: &store,
457 options: &options,
458 fetch_head_source: &fetch_head_source,
459 default_head_fetch,
460 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
461 ref_hook,
462 opportunistic_dsts: &opportunistic_dsts,
463 },
464 &mut updates,
465 &mut outcome,
466 )?;
467 advertisements
468 }
469 FetchSource::Ssh(remote) => {
470 crate::ssh::validate_ssh_fetch_options(
471 options.filter.as_ref(),
472 options.deepen_since,
473 &options.deepen_not,
474 )?;
475 let ssh_options = options
479 .ssh_options
480 .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
481 let (advertisements, features) =
482 crate::ssh::ssh_upload_pack_advertisements_with_options(
483 remote,
484 request.format,
485 ssh_options,
486 )?;
487 outcome.head_symref = head_symref_from_features(&features.symrefs);
488 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
489 advertisements: &advertisements,
490 refspecs: &parsed_refspecs,
491 options: &options,
492 store: &store,
493 reachable: None,
494 local_db: None,
495 deepen_excluded: None,
496 format: request.format,
497 configured_remote_fetch,
498 has_merge_config,
499 tracking_refspecs: &tracking_refspecs,
500 })?;
501 if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
502 append_missing_ext_advertised_tags(
503 &advertisements,
504 &parsed_refspecs,
505 &store,
506 &mut updates,
507 )?;
508 }
509 let wants = updates.iter().map(|update| update.oid).collect();
510 let existing_shallow =
513 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
514 let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
515 crate::ssh::SshFetchPackRequest {
516 git_dir: request.git_dir,
517 format: request.format,
518 remote,
519 features: &features,
520 wants,
521 haves: negotiation_haves.clone(),
522 shallow: existing_shallow,
523 deepen: options.depth,
524 promisor: promisor_remote,
525 command_options: ssh_options,
526 max_input_size,
527 },
528 services.progress,
529 )?;
530 reject_shallow_clone_fetch(&options, &shallow_info)?;
531 if !options.dry_run {
532 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
533 }
534 finalize_fetch(
535 FetchFinalize {
536 git_dir: request.git_dir,
537 format: request.format,
538 store: &store,
539 options: &options,
540 fetch_head_source: &fetch_head_source,
541 default_head_fetch,
542 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
543 ref_hook,
544 opportunistic_dsts: &opportunistic_dsts,
545 },
546 &mut updates,
547 &mut outcome,
548 )?;
549 advertisements
550 }
551 FetchSource::Git {
552 remote,
553 protocol_v2,
554 } => {
555 let protocol_v2 =
556 *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
557 let discovered = crate::git::git_upload_pack_advertisements_with_protocol(
558 remote,
559 request.format,
560 protocol_v2,
561 Some(request.config),
562 )?;
563 let advertisements = discovered.refs;
564 let features = discovered.features;
565 outcome.head_symref = head_symref_from_features(&features.symrefs);
566 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
567 advertisements: &advertisements,
568 refspecs: &parsed_refspecs,
569 options: &options,
570 store: &store,
571 reachable: None,
572 local_db: None,
573 deepen_excluded: None,
574 format: request.format,
575 configured_remote_fetch,
576 has_merge_config,
577 tracking_refspecs: &tracking_refspecs,
578 })?;
579 let wants = updates.iter().map(|update| update.oid).collect();
580 let existing_shallow =
581 shallow_boundary_for_request(request.git_dir, request.format, &options)?;
582 let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
583 crate::git::GitFetchPackRequest {
584 git_dir: request.git_dir,
585 format: request.format,
586 remote,
587 config: Some(request.config),
588 features: &features,
589 wants,
590 haves: negotiation_haves.clone(),
591 shallow: existing_shallow,
592 deepen: options.depth,
593 promisor: promisor_remote,
594 protocol_v2: discovered.protocol_v2,
595 max_input_size,
596 },
597 services.progress,
598 )?;
599 reject_shallow_clone_fetch(&options, &shallow_info)?;
600 if !options.dry_run {
601 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
602 }
603 finalize_fetch(
604 FetchFinalize {
605 git_dir: request.git_dir,
606 format: request.format,
607 store: &store,
608 options: &options,
609 fetch_head_source: &fetch_head_source,
610 default_head_fetch,
611 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
612 ref_hook,
613 opportunistic_dsts: &opportunistic_dsts,
614 },
615 &mut updates,
616 &mut outcome,
617 )?;
618 advertisements
619 }
620 FetchSource::Local {
621 git_dir: remote_git_dir,
622 common_git_dir: remote_common_git_dir,
623 } => {
624 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
625 if remote_format != request.format {
626 return Err(GitError::InvalidObjectId(format!(
627 "remote repository uses {}, local repository uses {}",
628 remote_format.name(),
629 request.format.name()
630 )));
631 }
632 let advertisements =
633 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
634 if advertisements
638 .iter()
639 .any(|advertisement| advertisement.name == "HEAD")
640 && let Some(RefTarget::Symbolic(target)) =
641 FileRefStore::new_without_reference_backend_env(remote_git_dir, request.format)
642 .read_ref("HEAD")?
643 {
644 outcome.head_symref = Some(target);
645 }
646 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
647 let remote_shallow =
659 crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
660 let explicit_deepen = options.depth.is_some()
661 || options.deepen_since.is_some()
662 || !options.deepen_not.is_empty();
663 let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
664 let mut deepen_not_oids = Vec::new();
667 for name in &options.deepen_not {
668 let resolved = advertisements.iter().find(|advertisement| {
669 advertisement.name == *name
670 || advertisement.name == format!("refs/tags/{name}")
671 || advertisement.name == format!("refs/heads/{name}")
672 || advertisement.name == format!("refs/{name}")
673 });
674 match resolved {
675 Some(advertisement) => deepen_not_oids.push(advertisement.oid),
676 None => {
677 return Err(GitError::Command(format!(
678 "git upload-pack: deepen-not is not a ref: {name}"
679 )));
680 }
681 }
682 }
683 let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
684 if !explicit_deepen && !implicit_deepen {
685 return Ok(None);
686 }
687 let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
689 if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
690 return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
691 &remote_db,
692 request.format,
693 heads,
694 client_shallow,
695 options.deepen_since,
696 &deepen_not_oids,
697 )?));
698 }
699 let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
700 Ok(Some(crate::local::compute_local_deepen(
701 &remote_db,
702 request.format,
703 heads,
704 client_shallow,
705 depth,
706 options.deepen_relative,
707 )?))
708 };
709 let primary_heads = {
710 let primary = plan_fetch_ref_updates(
711 &advertisements,
712 &parsed_refspecs,
713 options.auto_follow_tags,
714 )?;
715 let mut seen = HashSet::new();
716 let mut heads = Vec::new();
717 for update in &primary {
718 if seen.insert(update.oid) {
719 heads.push(update.oid);
720 }
721 }
722 heads
723 };
724 let mut deepen_plan = plan_deepen(&primary_heads)?;
725 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
726 let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
727 advertisements: &advertisements,
728 refspecs: &parsed_refspecs,
729 options: &options,
730 store: &store,
731 reachable: Some((&remote_db, &advertisements)),
732 local_db: Some(&local_db),
733 deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
734 format: request.format,
735 configured_remote_fetch,
736 has_merge_config,
737 tracking_refspecs: &tracking_refspecs,
738 })?;
739 if implicit_deepen && !options.cloning && !options.update_shallow {
744 let client_shallow: HashSet<ObjectId> =
745 crate::shallow::read_shallow(request.git_dir, request.format)?
746 .into_iter()
747 .collect();
748 let new_points: HashSet<ObjectId> = deepen_plan
749 .as_ref()
750 .map(|plan| {
751 plan.shallow_info
752 .iter()
753 .filter_map(|entry| match entry {
754 sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
755 if !client_shallow.contains(oid) =>
756 {
757 Some(*oid)
758 }
759 _ => None,
760 })
761 .collect()
762 })
763 .unwrap_or_default();
764 if !new_points.is_empty() {
765 let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
766 let mut dirty = |tip: &ObjectId| -> Result<bool> {
767 if let Some(&cached) = dirty_cache.get(tip) {
768 return Ok(cached);
769 }
770 let result =
771 tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
772 dirty_cache.insert(*tip, result);
773 Ok(result)
774 };
775 let mut kept = Vec::new();
776 for update in updates {
777 if dirty(&update.oid)? {
778 continue;
779 }
780 kept.push(update);
781 }
782 updates = kept;
783 let mut seen = HashSet::new();
786 let mut heads = Vec::new();
787 for update in &updates {
788 if seen.insert(update.oid) {
789 heads.push(update.oid);
790 }
791 }
792 deepen_plan = if heads.is_empty() {
793 None
794 } else {
795 plan_deepen(&heads)?
796 };
797 }
798 }
799 let starts: Vec<ObjectId> = if options.refetch {
800 let mut seen = HashSet::new();
801 updates
802 .iter()
803 .map(|update| update.oid)
804 .chain(primary_heads.iter().copied())
805 .filter(|oid| seen.insert(*oid))
806 .collect()
807 } else if deepen_plan.is_none() {
808 let mut starts = Vec::new();
809 for update in &updates {
810 if !local_db.contains(&update.oid)? {
811 starts.push(update.oid);
812 }
813 }
814 starts
815 } else {
816 updates.iter().map(|update| update.oid).collect()
817 };
818 let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
819 if !updates.is_empty() {
820 sley_protocol::trace_packet_write_payload(b"0000");
821 }
822 Vec::new()
823 } else {
824 crate::local::install_fetch_pack_via_local_upload_pack(
825 request.git_dir,
826 remote_git_dir,
827 request.format,
828 starts,
829 deepen_plan.as_ref(),
830 promisor_remote,
831 options.record_promisor_refs,
832 options.filter.clone(),
833 negotiation_haves.clone(),
834 options.refetch,
835 local_fetch_unpack_limit(request.git_dir, promisor_remote),
836 )?
837 };
838 if !options.dry_run {
839 crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
840 }
841 finalize_fetch(
842 FetchFinalize {
843 git_dir: request.git_dir,
844 format: request.format,
845 store: &store,
846 options: &options,
847 fetch_head_source: &fetch_head_source,
848 default_head_fetch,
849 log_all_ref_updates: fetch_log_all_ref_updates(request.config),
850 ref_hook,
851 opportunistic_dsts: &opportunistic_dsts,
852 },
853 &mut updates,
854 &mut outcome,
855 )?;
856 advertisements
857 }
858 };
859
860 if options.prune && !parsed_prune_refspecs.is_empty() {
861 outcome.pruned = prune_refs_from_advertisements(
862 PruneRefsInput {
863 config: request.config,
864 store: &store,
865 remote: request.remote_name,
866 advertisements: &advertisements,
867 refspecs: &parsed_prune_refspecs,
868 dry_run: options.dry_run,
869 quiet: options.quiet,
870 },
871 services.progress,
872 )?;
873 }
874
875 Ok(outcome)
876}
877
878fn scheme_for_fetch_source(source: &FetchSource) -> &'static str {
879 match source {
880 FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
881 FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
882 FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
883 FetchSource::Local { .. } => "file",
884 }
885}
886
887fn local_fetch_unpack_limit(git_dir: &Path, promisor_remote: bool) -> Option<usize> {
888 if promisor_remote {
889 return None;
890 }
891 git_dir
892 .join("objects")
893 .join("info")
894 .join("alternates")
895 .exists()
896 .then_some(100)
897}
898
899fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
903 remote_db: &R,
904 format: ObjectFormat,
905 tip: &ObjectId,
906 boundary: &HashSet<ObjectId>,
907) -> Result<bool> {
908 let mut seen: HashSet<ObjectId> = HashSet::new();
909 let mut queue: Vec<ObjectId> = vec![*tip];
910 while let Some(oid) = queue.pop() {
911 if !seen.insert(oid) {
912 continue;
913 }
914 let object = remote_db.read_object(&oid)?;
915 let commit = match object.object_type {
916 sley_object::ObjectType::Commit => {
917 sley_object::Commit::parse_ref(format, &object.body)?
918 }
919 sley_object::ObjectType::Tag => {
920 let tag = sley_object::Tag::parse_ref(format, &object.body)?;
921 queue.push(tag.object);
922 continue;
923 }
924 _ => continue,
925 };
926 if boundary.contains(&oid) {
927 return Ok(true);
928 }
929 queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
930 }
931 Ok(false)
932}
933
934fn shallow_boundary_for_request(
939 git_dir: &Path,
940 format: ObjectFormat,
941 options: &FetchOptions,
942) -> Result<Vec<ObjectId>> {
943 if options.depth.is_none()
944 && options.deepen_since.is_none()
945 && options.deepen_not.is_empty()
946 {
947 return Ok(Vec::new());
948 }
949 crate::shallow::read_shallow(git_dir, format)
950}
951
952fn resolve_deepen_not_refs(
953 advertisements: &[RefAdvertisement],
954 deepen_not: &[String],
955) -> Result<Vec<String>> {
956 let mut resolved = Vec::with_capacity(deepen_not.len());
957 for name in deepen_not {
958 let found = advertisements.iter().any(|advertisement| {
959 advertisement.name == *name
960 || advertisement.name == format!("refs/tags/{name}")
961 || advertisement.name == format!("refs/heads/{name}")
962 || advertisement.name == format!("refs/{name}")
963 });
964 if found {
965 resolved.push(name.clone());
966 } else {
967 return Err(GitError::Command(format!(
968 "git upload-pack: deepen-not is not a ref: {name}"
969 )));
970 }
971 }
972 Ok(resolved)
973}
974
975struct FetchPlanInput<'a> {
981 advertisements: &'a [RefAdvertisement],
982 refspecs: &'a [RefSpec],
983 options: &'a FetchOptions,
984 store: &'a FileRefStore,
985 reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
986 local_db: Option<&'a FileObjectDatabase>,
990 deepen_excluded: Option<&'a HashSet<ObjectId>>,
991 format: ObjectFormat,
992 configured_remote_fetch: bool,
993 has_merge_config: bool,
997 tracking_refspecs: &'a [RefSpec],
999}
1000
1001fn plan_and_adjust_updates(
1002 input: FetchPlanInput<'_>,
1003) -> Result<(Vec<FetchRefUpdate>, HashSet<String>)> {
1004 let FetchPlanInput {
1005 advertisements,
1006 refspecs,
1007 options,
1008 store,
1009 reachable,
1010 local_db,
1011 deepen_excluded,
1012 format,
1013 configured_remote_fetch,
1014 has_merge_config,
1015 tracking_refspecs,
1016 } = input;
1017 let visible_advertisements = advertisements_without_peeled_refs(advertisements);
1018 let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
1019 advertisements
1020 } else {
1021 visible_advertisements.as_slice()
1022 };
1023 let mut updates =
1024 plan_fetch_ref_updates(planning_advertisements, refspecs, options.auto_follow_tags)?;
1025 if options.fetch_all_tags {
1026 mark_tag_refspec_updates_not_for_merge(&mut updates);
1027 } else {
1028 if options.auto_follow_tags
1029 && let Some((remote_db, advertisements)) = reachable
1030 {
1031 let visible_reachable_advertisements =
1032 advertisements_without_peeled_refs(advertisements);
1033 let reachable_advertisements =
1034 if visible_reachable_advertisements.len() == advertisements.len() {
1035 advertisements
1036 } else {
1037 visible_reachable_advertisements.as_slice()
1038 };
1039 append_reachable_auto_follow_tags(
1040 reachable_advertisements,
1041 remote_db,
1042 local_db,
1043 format,
1044 refspecs,
1045 &mut updates,
1046 deepen_excluded,
1047 )?;
1048 }
1049 retain_missing_auto_follow_tags(store, &mut updates)?;
1050 }
1051 if configured_remote_fetch || has_merge_config {
1052 for update in &mut updates {
1053 update.not_for_merge = true;
1054 }
1055 if !options.merge_srcs.is_empty() {
1056 for update in &mut updates {
1061 if options
1062 .merge_srcs
1063 .iter()
1064 .any(|src| refname_matches(src, &update.src))
1065 {
1066 update.not_for_merge = false;
1067 }
1068 }
1069 } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
1070 && !first.pattern
1071 {
1072 if let Some(update) = updates.first_mut() {
1077 update.not_for_merge = false;
1078 }
1079 }
1080 updates.sort_by_key(|update| update.not_for_merge);
1084 }
1085 let opportunistic_dsts =
1086 append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
1087 ref_remove_duplicate_updates(&mut updates)?;
1088 Ok((updates, opportunistic_dsts))
1089}
1090
1091fn ref_remove_duplicate_updates(updates: &mut Vec<FetchRefUpdate>) -> Result<()> {
1096 let mut seen: BTreeMap<String, String> = BTreeMap::new();
1097 let mut error = None;
1098 updates.retain(|update| {
1099 let Some(dst) = update.dst.as_deref() else {
1100 return true;
1101 };
1102 match seen.get(dst) {
1103 Some(prev_src) if prev_src == &update.src => false,
1104 Some(prev_src) => {
1105 if error.is_none() {
1106 error = Some(GitError::Command(format!(
1107 "Cannot fetch both {} and {} to {dst}",
1108 prev_src, update.src
1109 )));
1110 }
1111 true
1112 }
1113 None => {
1114 seen.insert(dst.to_string(), update.src.clone());
1115 true
1116 }
1117 }
1118 });
1119 match error {
1120 Some(err) => Err(err),
1121 None => Ok(()),
1122 }
1123}
1124
1125fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
1126 if remote_exists(config, remote) {
1127 remote_config_values(config, remote, "fetch")
1128 } else {
1129 Vec::new()
1130 }
1131}
1132
1133fn append_opportunistic_tracking_updates(
1138 updates: &mut Vec<FetchRefUpdate>,
1139 tracking_refspecs: &[RefSpec],
1140) -> Result<HashSet<String>> {
1141 let mut opportunistic_dsts = HashSet::new();
1142 if tracking_refspecs.is_empty() {
1143 return Ok(opportunistic_dsts);
1144 }
1145 let mut seen_dsts = updates
1146 .iter()
1147 .filter_map(|update| update.dst.clone())
1148 .collect::<HashSet<_>>();
1149 let mut additions = Vec::new();
1150 for update in updates.iter() {
1151 if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1152 continue;
1153 }
1154 for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1155 let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1156 continue;
1157 };
1158 if !seen_dsts.insert(dst.clone()) {
1159 continue;
1160 }
1161 opportunistic_dsts.insert(dst.clone());
1162 additions.push(FetchRefUpdate {
1163 src: update.src.clone(),
1164 dst: Some(dst),
1165 oid: update.oid,
1166 not_for_merge: true,
1167 force: refspec.force,
1168 });
1169 }
1170 }
1171 updates.extend(additions);
1172 Ok(opportunistic_dsts)
1173}
1174
1175fn advertisements_without_peeled_refs(
1176 advertisements: &[RefAdvertisement],
1177) -> Vec<RefAdvertisement> {
1178 advertisements
1179 .iter()
1180 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1181 .cloned()
1182 .collect()
1183}
1184
1185fn append_missing_ext_advertised_tags(
1186 advertisements: &[RefAdvertisement],
1187 refspecs: &[RefSpec],
1188 store: &FileRefStore,
1189 updates: &mut Vec<FetchRefUpdate>,
1190) -> Result<()> {
1191 let mut seen = updates
1192 .iter()
1193 .map(|update| update.src.clone())
1194 .collect::<HashSet<_>>();
1195 let mut tags = Vec::new();
1196 for reference in advertisements {
1197 if !reference.name.starts_with("refs/tags/")
1198 || reference.name.ends_with("^{}")
1199 || !seen.insert(reference.name.clone())
1200 || fetch_refspec_excludes(refspecs, &reference.name)?
1201 || store.read_ref(&reference.name)?.is_some()
1202 {
1203 continue;
1204 }
1205 tags.push(FetchRefUpdate {
1206 src: reference.name.clone(),
1207 dst: Some(reference.name.clone()),
1208 oid: reference.oid,
1209 not_for_merge: true,
1210 force: false,
1211 });
1212 }
1213 tags.sort_by(|a, b| a.src.cmp(&b.src));
1214 updates.extend(tags);
1215 Ok(())
1216}
1217
1218struct FetchFinalize<'a> {
1222 git_dir: &'a Path,
1223 format: ObjectFormat,
1224 store: &'a FileRefStore,
1225 options: &'a FetchOptions,
1226 fetch_head_source: &'a str,
1227 default_head_fetch: bool,
1228 log_all_ref_updates: bool,
1229 ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
1230 opportunistic_dsts: &'a HashSet<String>,
1233}
1234
1235fn downgrade_non_commit_for_merge(
1241 git_dir: &Path,
1242 format: ObjectFormat,
1243 updates: &mut [FetchRefUpdate],
1244) {
1245 if updates.iter().all(|update| update.not_for_merge) {
1246 return;
1247 }
1248 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1249 for update in updates.iter_mut() {
1250 if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
1251 update.not_for_merge = true;
1252 }
1253 }
1254}
1255
1256fn finalize_fetch(
1257 finalize: FetchFinalize<'_>,
1258 updates: &mut Vec<FetchRefUpdate>,
1259 outcome: &mut FetchOutcome,
1260) -> Result<()> {
1261 let FetchFinalize {
1262 git_dir,
1263 format,
1264 store,
1265 options,
1266 fetch_head_source,
1267 default_head_fetch,
1268 log_all_ref_updates,
1269 ref_hook,
1270 opportunistic_dsts,
1271 } = finalize;
1272 if options.dry_run {
1273 outcome.ref_updates = std::mem::take(updates);
1274 return Ok(());
1275 }
1276 downgrade_non_commit_for_merge(git_dir, format, updates);
1277 validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1278 if options.atomic {
1279 if options.write_fetch_head && !options.append {
1287 fs::write(git_dir.join("FETCH_HEAD"), b"")?;
1288 }
1289 if let Some(reason) = atomic_non_fast_forward_rejection(git_dir, format, store, updates)? {
1290 return Err(GitError::Command(reason));
1291 }
1292 apply_fetch_ref_updates(
1293 store,
1294 format,
1295 fetch_head_source,
1296 log_all_ref_updates,
1297 updates,
1298 ref_hook,
1299 )?;
1300 if options.write_fetch_head {
1301 write_finalized_fetch_head(
1304 git_dir,
1305 fetch_head_source,
1306 default_head_fetch,
1307 updates,
1308 opportunistic_dsts,
1309 true,
1310 )?;
1311 outcome.wrote_fetch_head = true;
1312 }
1313 outcome.ref_updates = std::mem::take(updates);
1314 return Ok(());
1315 }
1316 if options.write_fetch_head {
1317 write_finalized_fetch_head(
1318 git_dir,
1319 fetch_head_source,
1320 default_head_fetch,
1321 updates,
1322 opportunistic_dsts,
1323 options.append,
1324 )?;
1325 outcome.wrote_fetch_head = true;
1326 }
1327 apply_fetch_ref_updates(
1328 store,
1329 format,
1330 fetch_head_source,
1331 log_all_ref_updates,
1332 updates,
1333 ref_hook,
1334 )?;
1335 outcome.ref_updates = std::mem::take(updates);
1336 Ok(())
1337}
1338
1339fn write_finalized_fetch_head(
1344 git_dir: &Path,
1345 fetch_head_source: &str,
1346 default_head_fetch: bool,
1347 updates: &[FetchRefUpdate],
1348 opportunistic_dsts: &HashSet<String>,
1349 append: bool,
1350) -> Result<()> {
1351 if default_head_fetch
1352 && updates.len() == 1
1353 && updates[0].src == "HEAD"
1354 && updates[0].dst.is_none()
1355 {
1356 return write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, append);
1357 }
1358 let records: Vec<FetchRefUpdate> = updates
1359 .iter()
1360 .filter(|update| {
1361 update
1362 .dst
1363 .as_deref()
1364 .is_none_or(|dst| !opportunistic_dsts.contains(dst))
1365 })
1366 .cloned()
1367 .collect();
1368 write_fetch_head(git_dir, fetch_head_source, &records, append)
1369}
1370
1371fn atomic_non_fast_forward_rejection(
1376 git_dir: &Path,
1377 format: ObjectFormat,
1378 store: &FileRefStore,
1379 updates: &[FetchRefUpdate],
1380) -> Result<Option<String>> {
1381 let mut db: Option<FileObjectDatabase> = None;
1382 for update in updates {
1383 let Some(dst) = update.dst.as_deref() else {
1384 continue;
1385 };
1386 if update.force {
1387 continue;
1388 }
1389 let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
1390 continue;
1391 };
1392 if old == update.oid || dst.starts_with("refs/tags/") {
1393 continue;
1394 }
1395 let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
1396 if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
1397 return Ok(Some(format!(
1398 "! [rejected] {} -> {} (non-fast-forward)",
1399 update.src, dst
1400 )));
1401 }
1402 }
1403 Ok(None)
1404}
1405
1406fn apply_fetch_ref_updates(
1407 store: &FileRefStore,
1408 format: ObjectFormat,
1409 fetch_head_source: &str,
1410 log_all_ref_updates: bool,
1411 updates: &[FetchRefUpdate],
1412 ref_hook: Option<&dyn sley_refs::ReferenceTransactionHook>,
1413) -> Result<()> {
1414 let mut seen = BTreeSet::new();
1415 let mut tx = store.transaction();
1416 if let Some(hook) = ref_hook {
1417 tx = tx.with_hook(hook);
1418 }
1419 for update in updates {
1420 let Some(dst) = update.dst.as_deref() else {
1421 continue;
1422 };
1423 if !seen.insert(dst.to_string()) {
1424 return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
1425 }
1426 let old_oid = match store.read_ref(dst)? {
1427 Some(RefTarget::Direct(oid)) => Some(oid),
1428 Some(RefTarget::Symbolic(target)) => {
1429 return Err(GitError::Transaction(format!(
1430 "fetch ref {dst} would overwrite symbolic ref {target}"
1431 )));
1432 }
1433 None => None,
1434 };
1435 let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
1436 Some(ReflogEntry {
1437 old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
1438 new_oid: update.oid,
1439 committer: fetch_reflog_committer(),
1440 message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
1441 })
1442 } else {
1443 None
1444 };
1445 tx.update(RefUpdate {
1446 name: dst.to_string(),
1447 expected: old_oid.map(RefTarget::Direct),
1448 new: RefTarget::Direct(update.oid),
1449 reflog,
1450 });
1451 }
1452 tx.commit()
1453}
1454
1455pub fn fetch_max_input_size(config: &GitConfig) -> Option<u64> {
1458 config_max_input_size(config, "fetch", "maxInputSize")
1459 .or_else(|| config_max_input_size(config, "transfer", "maxSize"))
1460}
1461
1462fn config_max_input_size(config: &GitConfig, section: &str, key: &str) -> Option<u64> {
1463 let raw = config.get(section, None, key)?;
1464 match sley_config::parse_config_int(raw) {
1465 Some(limit) if limit > 0 => Some(limit as u64),
1466 _ => None,
1467 }
1468}
1469
1470fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
1471 match config.get("core", None, "logallrefupdates") {
1472 Some(value) => {
1473 let value = value.to_ascii_lowercase();
1474 matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
1475 }
1476 None => false,
1477 }
1478}
1479
1480fn fetch_should_write_reflog(refname: &str) -> bool {
1481 refname == "HEAD"
1482 || refname.starts_with("refs/heads/")
1483 || refname.starts_with("refs/remotes/")
1484 || refname.starts_with("refs/notes/")
1485}
1486
1487fn fetch_reflog_committer() -> Vec<u8> {
1488 let seconds = SystemTime::now()
1489 .duration_since(UNIX_EPOCH)
1490 .map(|duration| duration.as_secs())
1491 .unwrap_or(0);
1492 format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
1493}
1494
1495fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
1496 let src = fetch_reflog_short_ref(&update.src);
1497 let dst = update
1498 .dst
1499 .as_deref()
1500 .map(fetch_reflog_short_ref)
1501 .unwrap_or_else(|| update.src.clone());
1502 let action = if !old_exists {
1503 if update.src.starts_with("refs/tags/") {
1504 "storing tag"
1505 } else if update.src.starts_with("refs/heads/") {
1506 "storing head"
1507 } else {
1508 "storing ref"
1509 }
1510 } else if update.force {
1511 "forced-update"
1512 } else if update.src.starts_with("refs/tags/") {
1513 "updating tag"
1514 } else {
1515 "fast-forward"
1516 };
1517 format!("fetch {source} {src}:{dst}: {action}").into_bytes()
1518}
1519
1520fn fetch_reflog_short_ref(refname: &str) -> String {
1521 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
1522 if let Some(short) = refname.strip_prefix(prefix) {
1523 return short.to_string();
1524 }
1525 }
1526 refname.to_string()
1527}
1528
1529fn validate_fetch_ref_updates(
1530 git_dir: &Path,
1531 _format: ObjectFormat,
1532 store: &FileRefStore,
1533 update_head_ok: bool,
1534 updates: &[FetchRefUpdate],
1535) -> Result<()> {
1536 for update in updates {
1537 let Some(dst) = update.dst.as_deref() else {
1538 continue;
1539 };
1540 let old = match store.read_ref(dst)? {
1541 Some(RefTarget::Direct(oid)) => Some(oid),
1542 Some(RefTarget::Symbolic(target)) => {
1543 return Err(GitError::Transaction(format!(
1544 "ref {dst} would overwrite symbolic ref {target}"
1545 )));
1546 }
1547 None => None,
1548 };
1549 if old.is_some()
1550 && !update_head_ok
1551 && dst.starts_with("refs/heads/")
1552 && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
1553 {
1554 return Err(GitError::InvalidFormat(format!(
1555 "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
1556 worktree.path.display()
1557 )));
1558 }
1559 if old.is_some()
1560 && old != Some(update.oid)
1561 && dst.starts_with("refs/tags/")
1562 && !update.force
1563 {
1564 return Err(GitError::Command(format!(
1565 "! [rejected] {} -> {} (would clobber existing tag)",
1566 update.src, dst
1567 )));
1568 }
1569 }
1570 Ok(())
1571}
1572
1573fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
1575 symrefs
1576 .iter()
1577 .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
1578}
1579
1580fn reject_shallow_clone_fetch(
1581 options: &FetchOptions,
1582 shallow_info: &[sley_protocol::ProtocolV2FetchShallowInfo],
1583) -> Result<()> {
1584 let deepening = options.depth.is_some()
1589 || options.deepen_since.is_some()
1590 || !options.deepen_not.is_empty();
1591 if options.reject_shallow && options.cloning && !deepening && !shallow_info.is_empty() {
1592 eprintln!("fatal: source repository is shallow, reject to clone.");
1593 return Err(GitError::Exit(128));
1594 }
1595 Ok(())
1596}
1597
1598fn custom_negotiation_haves(
1599 git_dir: &Path,
1600 format: ObjectFormat,
1601 config: &GitConfig,
1602 remote: &str,
1603 options: &FetchOptions,
1604) -> Result<Option<Vec<ObjectId>>> {
1605 let restrict = match &options.negotiation_restrict {
1606 Some(values) => values.clone(),
1607 None => configured_negotiation_values(config, remote, "negotiationrestrict"),
1608 };
1609 let include = match &options.negotiation_include {
1610 Some(values) => values.clone(),
1611 None => configured_negotiation_values(config, remote, "negotiationinclude"),
1612 };
1613 if restrict.is_empty() && include.is_empty() {
1614 return Ok(None);
1615 }
1616
1617 let store = FileRefStore::new(git_dir, format);
1618 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1619 let mut seen = HashSet::new();
1620 let mut haves = Vec::new();
1621 if restrict.is_empty() {
1622 for oid in crate::local::local_have_oids(git_dir, format)? {
1623 push_have_oid(&mut haves, &mut seen, oid);
1624 }
1625 } else {
1626 for value in &restrict {
1627 for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
1628 push_have_oid(&mut haves, &mut seen, oid);
1629 }
1630 }
1631 }
1632 for value in &include {
1633 for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
1634 push_have_oid(&mut haves, &mut seen, oid);
1635 }
1636 }
1637 Ok(Some(haves))
1638}
1639
1640fn configured_negotiation_values(config: &GitConfig, remote: &str, key: &str) -> Vec<String> {
1641 let mut values = Vec::new();
1642 if !remote_exists(config, remote) {
1643 return values;
1644 }
1645 for value in remote_config_values(config, remote, key) {
1646 if value.is_empty() {
1647 values.clear();
1648 } else {
1649 values.push(value);
1650 }
1651 }
1652 values
1653}
1654
1655fn push_have_oid(haves: &mut Vec<ObjectId>, seen: &mut HashSet<ObjectId>, oid: ObjectId) {
1656 if seen.insert(oid) {
1657 haves.push(oid);
1658 }
1659}
1660
1661fn resolve_negotiation_have_value(
1662 git_dir: &Path,
1663 format: ObjectFormat,
1664 store: &FileRefStore,
1665 db: &FileObjectDatabase,
1666 value: &str,
1667) -> Result<Vec<ObjectId>> {
1668 if has_glob(value) {
1669 let mut out = Vec::new();
1670 for reference in store.list_refs()? {
1671 let RefTarget::Direct(oid) = reference.target else {
1672 continue;
1673 };
1674 if negotiation_pattern_matches(value, &reference.name) {
1675 out.push(oid);
1676 }
1677 }
1678 out.sort();
1679 out.dedup();
1680 return Ok(out);
1681 }
1682 if is_full_hex_oid(format, value) {
1683 let oid = ObjectId::from_hex(format, value)?;
1684 return if db.contains(&oid)? {
1685 Ok(vec![oid])
1686 } else {
1687 Err(GitError::InvalidFormat(format!(
1688 "fatal: the object {oid} does not exist"
1689 )))
1690 };
1691 }
1692 match sley_rev::resolve_revision(git_dir, format, value) {
1693 Ok(oid) => Ok(vec![oid]),
1694 Err(_) => Ok(Vec::new()),
1695 }
1696}
1697
1698fn has_glob(value: &str) -> bool {
1699 value.as_bytes().iter().any(|byte| matches!(byte, b'*' | b'?'))
1700}
1701
1702fn is_full_hex_oid(format: ObjectFormat, value: &str) -> bool {
1703 value.len() == format.hex_len() && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
1704}
1705
1706fn negotiation_pattern_matches(pattern: &str, refname: &str) -> bool {
1707 glob_match(pattern, refname)
1708 || ["refs/heads/", "refs/tags/", "refs/remotes/"]
1709 .iter()
1710 .filter_map(|prefix| refname.strip_prefix(prefix))
1711 .any(|short| glob_match(pattern, short))
1712}
1713
1714fn glob_match(pattern: &str, text: &str) -> bool {
1715 glob_match_bytes(pattern.as_bytes(), text.as_bytes())
1716}
1717
1718fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
1719 let (mut p, mut t) = (0, 0);
1720 let mut star = None;
1721 let mut match_after_star = 0;
1722 while t < text.len() {
1723 if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
1724 p += 1;
1725 t += 1;
1726 } else if p < pattern.len() && pattern[p] == b'*' {
1727 star = Some(p);
1728 p += 1;
1729 match_after_star = t;
1730 } else if let Some(star_pos) = star {
1731 p = star_pos + 1;
1732 match_after_star += 1;
1733 t = match_after_star;
1734 } else {
1735 return false;
1736 }
1737 }
1738 while p < pattern.len() && pattern[p] == b'*' {
1739 p += 1;
1740 }
1741 p == pattern.len()
1742}
1743
1744pub fn apply_configured_partial_clone_filter(
1746 config: &GitConfig,
1747 remote: &str,
1748 options: &mut FetchOptions,
1749) {
1750 if config
1751 .get_bool("remote", Some(remote), "promisor")
1752 .unwrap_or(false)
1753 && let Some(filter) = config.get("remote", Some(remote), "partialclonefilter")
1754 {
1755 options.filter = crate::pack_filter_from_spec(filter);
1756 }
1757}
1758
1759pub fn apply_configured_remote_tag_option(
1762 config: &GitConfig,
1763 source: &str,
1764 options: &mut FetchOptions,
1765) {
1766 if options.tag_option_explicit || !remote_exists(config, source) {
1767 return;
1768 }
1769 match remote_config_values(config, source, "tagopt")
1770 .into_iter()
1771 .last()
1772 .as_deref()
1773 {
1774 Some("--tags") => {
1775 options.auto_follow_tags = true;
1776 options.fetch_all_tags = true;
1777 }
1778 Some("--no-tags") => {
1779 options.auto_follow_tags = false;
1780 options.fetch_all_tags = false;
1781 }
1782 _ => {}
1783 }
1784}
1785
1786pub fn apply_configured_fetch_prune_option(
1789 config: &GitConfig,
1790 source: &str,
1791 options: &mut FetchOptions,
1792) {
1793 if !options.prune_option_explicit {
1794 if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
1795 options.prune = prune;
1796 } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
1797 options.prune = prune;
1798 }
1799 }
1800 if !options.prune_tags_option_explicit {
1801 if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
1802 options.prune_tags = prune_tags;
1803 } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
1804 options.prune_tags = prune_tags;
1805 }
1806 }
1807}
1808
1809pub fn fetch_refspecs_for_source(
1813 configured: Vec<String>,
1814 refspecs: &[String],
1815 fetch_all_tags: bool,
1816) -> Vec<String> {
1817 let mut effective = if !refspecs.is_empty() {
1818 refspecs.to_vec()
1819 } else if configured.is_empty() {
1820 vec!["HEAD".to_string()]
1821 } else {
1822 configured
1823 };
1824 if fetch_all_tags {
1825 effective.push("refs/tags/*:refs/tags/*".to_string());
1826 }
1827 effective
1828}
1829
1830fn prune_refspecs_for_source(
1831 configured: &[String],
1832 refspecs: &[String],
1833 prune_tags: bool,
1834) -> Vec<String> {
1835 let mut effective = if !refspecs.is_empty() {
1836 refspecs.to_vec()
1837 } else {
1838 configured.to_vec()
1839 };
1840 if prune_tags && refspecs.is_empty() {
1841 effective.push("refs/tags/*:refs/tags/*".to_string());
1842 }
1843 effective
1844}
1845
1846fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
1851 if refspec.pattern {
1852 let Some((prefix, suffix)) = src.split_once('*') else {
1853 return false;
1854 };
1855 let fits = |name: &str| {
1860 name.len() >= prefix.len() + suffix.len()
1861 && name.starts_with(prefix)
1862 && name.ends_with(suffix)
1863 };
1864 fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
1865 } else {
1866 refname_matches(merge_src, src) || refname_matches(src, merge_src)
1867 }
1868}
1869
1870pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
1872 for update in updates {
1873 if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
1874 update.not_for_merge = true;
1875 }
1876 }
1877}
1878
1879pub fn retain_missing_auto_follow_tags(
1881 store: &FileRefStore,
1882 updates: &mut Vec<FetchRefUpdate>,
1883) -> Result<()> {
1884 let mut retained = Vec::with_capacity(updates.len());
1885 for update in updates.drain(..) {
1886 if update.not_for_merge
1887 && update.src.starts_with("refs/tags/")
1888 && update.dst.as_deref() == Some(&update.src)
1889 && store.read_ref(&update.src)?.is_some()
1890 {
1891 continue;
1892 }
1893 retained.push(update);
1894 }
1895 *updates = retained;
1896 Ok(())
1897}
1898
1899pub fn append_reachable_auto_follow_tags(
1902 advertisements: &[RefAdvertisement],
1903 remote_db: &FileObjectDatabase,
1904 local_db: Option<&FileObjectDatabase>,
1905 format: ObjectFormat,
1906 refspecs: &[RefSpec],
1907 updates: &mut Vec<FetchRefUpdate>,
1908 deepen_excluded: Option<&HashSet<ObjectId>>,
1909) -> Result<()> {
1910 if !updates.iter().any(|update| update.dst.is_some()) {
1911 return Ok(());
1912 }
1913 updates.retain(|update| {
1918 !(update.src.starts_with("refs/tags/")
1919 && update.dst.as_deref() == Some(update.src.as_str())
1920 && update.not_for_merge)
1921 });
1922 let mut starts = Vec::new();
1927 for update in updates.iter().filter(|update| update.dst.is_some()) {
1928 if update.src.starts_with("refs/tags/") {
1929 if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1930 starts.push(target);
1931 } else {
1932 starts.push(update.oid);
1933 }
1934 } else {
1935 starts.push(update.oid);
1936 }
1937 }
1938 let reachable = match deepen_excluded {
1942 Some(excluded) => {
1943 collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1944 }
1945 None => collect_reachable_object_ids(remote_db, format, starts)?,
1946 };
1947 let fetched_srcs = updates
1948 .iter()
1949 .map(|update| update.src.clone())
1950 .collect::<HashSet<_>>();
1951 let mut followed = Vec::new();
1952 for reference in advertisements {
1953 if !reference.name.starts_with("refs/tags/")
1954 || fetched_srcs.contains(&reference.name)
1955 || fetch_refspec_excludes(refspecs, &reference.name)?
1956 {
1957 continue;
1958 }
1959 let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
1967 let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
1968 let present_locally = local_db
1969 .map(|db| db.contains(&target))
1970 .transpose()?
1971 .unwrap_or(false);
1972 if !fetched && !present_locally {
1973 continue;
1974 }
1975 followed.push(FetchRefUpdate {
1976 src: reference.name.clone(),
1977 dst: Some(reference.name.clone()),
1978 oid: reference.oid,
1979 not_for_merge: true,
1980 force: false,
1981 });
1982 }
1983 followed.sort_by(|a, b| a.src.cmp(&b.src));
1984 updates.extend(followed);
1985 Ok(())
1986}
1987
1988fn peel_tag_target(
1993 db: &FileObjectDatabase,
1994 format: ObjectFormat,
1995 oid: &ObjectId,
1996) -> Result<Option<ObjectId>> {
1997 let mut current = *oid;
1998 let mut peeled = None;
1999 loop {
2000 let Ok(object) = db.read_object(¤t) else {
2001 return Ok(peeled);
2002 };
2003 if object.object_type != sley_object::ObjectType::Tag {
2004 return Ok(peeled);
2005 }
2006 let tag = sley_object::Tag::parse(format, &object.body)?;
2007 current = tag.object;
2008 peeled = Some(current);
2009 }
2010}
2011
2012pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
2014 for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
2015 if refspec.pattern {
2016 if refspec_map_source(refspec, name)?.is_some() {
2017 return Ok(true);
2018 }
2019 } else if refspec.src.as_deref() == Some(name) {
2020 return Ok(true);
2021 }
2022 }
2023 Ok(false)
2024}
2025
2026pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
2029 let followed_oids = updates
2030 .iter()
2031 .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
2032 .map(|update| update.oid)
2033 .collect::<HashSet<_>>();
2034 if followed_oids.is_empty() {
2035 return;
2036 }
2037
2038 let mut non_tags = Vec::new();
2039 let mut followed_tags = Vec::new();
2040 let mut other_tags = Vec::new();
2041 for update in updates.drain(..) {
2042 if update.src.starts_with("refs/tags/") {
2043 if followed_oids.contains(&update.oid) {
2044 followed_tags.push(update);
2045 } else {
2046 other_tags.push(update);
2047 }
2048 } else {
2049 non_tags.push(update);
2050 }
2051 }
2052 updates.extend(non_tags);
2053 updates.extend(followed_tags);
2054 updates.extend(other_tags);
2055}
2056
2057pub fn write_default_fetch_head(
2059 git_dir: &Path,
2060 source: &str,
2061 oid: ObjectId,
2062 append: bool,
2063) -> Result<()> {
2064 let records = [FetchHeadRecord {
2065 oid,
2066 not_for_merge: false,
2067 description: source.to_string(),
2068 }];
2069 write_fetch_head_records(git_dir, &records, append)?;
2070 Ok(())
2071}
2072
2073pub fn write_fetch_head_records(
2075 git_dir: &Path,
2076 records: &[FetchHeadRecord],
2077 append: bool,
2078) -> Result<()> {
2079 let encoded = encode_fetch_head(records)?;
2080 if append {
2081 let mut file = fs::OpenOptions::new()
2082 .create(true)
2083 .append(true)
2084 .open(git_dir.join("FETCH_HEAD"))?;
2085 file.write_all(&encoded)?;
2086 } else {
2087 fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
2088 }
2089 Ok(())
2090}
2091
2092pub fn write_fetch_head(
2094 git_dir: &Path,
2095 description: &str,
2096 fetched: &[FetchRefUpdate],
2097 append: bool,
2098) -> Result<()> {
2099 let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
2100 write_fetch_head_records(git_dir, &records, append)?;
2101 Ok(())
2102}
2103
2104pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
2107 let url = remote_config_values(config, source, "url")
2108 .into_iter()
2109 .next()
2110 .map(|url| rewrite_url_with_config(config, &url, false))
2111 .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
2112 redact_url_for_display(&trim_fetch_head_display_url(&url))
2113}
2114
2115fn trim_fetch_head_display_url(url: &str) -> String {
2119 let bytes = url.as_bytes();
2120 let mut end = bytes.len();
2121 while end > 0 && bytes[end - 1] == b'/' {
2122 end -= 1;
2123 }
2124 if end > 5 && &bytes[end - 4..end] == b".git" {
2127 end -= 4;
2128 }
2129 String::from_utf8_lossy(&bytes[..end]).into_owned()
2130}
2131
2132pub struct PruneRefsInput<'a> {
2137 pub config: &'a GitConfig,
2138 pub store: &'a FileRefStore,
2139 pub remote: &'a str,
2140 pub advertisements: &'a [RefAdvertisement],
2141 pub refspecs: &'a [RefSpec],
2142 pub dry_run: bool,
2143 pub quiet: bool,
2144}
2145
2146pub fn prune_refs_from_advertisements(
2147 input: PruneRefsInput<'_>,
2148 progress: &mut dyn ProgressSink,
2149) -> Result<Vec<PrunedRef>> {
2150 let remote_refs = input
2151 .advertisements
2152 .iter()
2153 .filter(|advertisement| !advertisement.name.ends_with("^{}"))
2154 .map(|advertisement| advertisement.name.as_str())
2155 .collect::<BTreeSet<_>>();
2156 let local_refs = input.store.list_refs()?;
2157 let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
2158 if stale_refs.is_empty() {
2159 return Ok(Vec::new());
2160 }
2161 let mut emit = |line: &str| {
2162 if !input.quiet {
2163 progress.message(line);
2164 }
2165 };
2166 let display_url = redact_url_for_display(
2167 &remote_config_values(input.config, input.remote, "url")
2168 .into_iter()
2169 .next()
2170 .unwrap_or_else(|| input.remote.into()),
2171 );
2172 emit(&format!("Pruning {}", input.remote));
2173 emit(&format!("URL: {display_url}"));
2174 let mut pruned = Vec::new();
2175 for refname in stale_refs {
2176 if !input.dry_run {
2177 match input.store.read_ref(&refname)? {
2178 Some(RefTarget::Symbolic(_)) => {
2179 let _ = input.store.delete_symbolic_ref(&refname)?;
2180 }
2181 Some(RefTarget::Direct(_)) => {
2182 let _ = input.store.delete_ref(&refname)?;
2183 }
2184 None => {}
2185 }
2186 }
2187 let display = prettify_pruned_ref(input.remote, &refname);
2188 let action = if input.dry_run {
2189 "would prune"
2190 } else {
2191 "pruned"
2192 };
2193 emit(&format!(" * [{action}] {display}"));
2194 let branch = display;
2195 pruned.push(PrunedRef { branch, refname });
2196 }
2197 Ok(pruned)
2198}
2199
2200fn stale_refs_for_prune(
2201 local_refs: &[Ref],
2202 refspecs: &[RefSpec],
2203 remote_refs: &BTreeSet<&str>,
2204) -> Result<Vec<String>> {
2205 let mut stale = Vec::new();
2206 for reference in local_refs {
2207 if matches!(reference.target, RefTarget::Symbolic(_)) {
2208 continue;
2209 }
2210 let sources = prune_sources_for_destination(refspecs, &reference.name)?;
2211 if sources.is_empty() {
2212 continue;
2213 }
2214 if sources
2215 .iter()
2216 .all(|source| !remote_refs.contains(source.as_str()))
2217 {
2218 stale.push(reference.name.clone());
2219 }
2220 }
2221 stale.sort();
2222 Ok(stale)
2223}
2224
2225fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
2226 let mut sources = Vec::new();
2227 for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
2228 let Some(src) = refspec.src.as_deref() else {
2229 continue;
2230 };
2231 let Some(dst) = refspec.dst.as_deref() else {
2232 continue;
2233 };
2234 if refspec.pattern {
2235 let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
2236 continue;
2237 };
2238 let Some(middle) = destination
2239 .strip_prefix(dst_prefix)
2240 .and_then(|value| value.strip_suffix(dst_suffix))
2241 else {
2242 continue;
2243 };
2244 let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
2245 GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
2246 })?;
2247 sources.push(format!("{src_prefix}{middle}{src_suffix}"));
2248 } else if dst == destination {
2249 sources.push(src.to_string());
2250 }
2251 }
2252 sources.sort();
2253 sources.dedup();
2254 Ok(sources)
2255}
2256
2257fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
2258 if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
2259 return format!("{remote}/{branch}");
2260 }
2261 if let Some(tag) = refname.strip_prefix("refs/tags/") {
2262 return tag.to_string();
2263 }
2264 refname.to_string()
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269 use super::*;
2270 use std::sync::atomic::{AtomicU64, Ordering};
2271
2272 use sley_config::{ConfigEntry, ConfigSection};
2273 use sley_formats::RepositoryLayout;
2274 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2275 use sley_odb::{FileObjectDatabase, ObjectWriter};
2276 use sley_refs::{RefTarget, RefUpdate};
2277
2278 use crate::{NoCredentials, SilentProgress};
2279
2280 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2281
2282 fn temp_repo(name: &str) -> PathBuf {
2283 let dir = std::env::temp_dir().join(format!(
2284 "sley-remote-fetch-{name}-{}-{}",
2285 std::process::id(),
2286 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2287 ));
2288 let _ = fs::remove_dir_all(&dir);
2289 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2290 .expect("test repository should initialize");
2291 dir.join(".git")
2292 }
2293
2294 fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
2295 let format = ObjectFormat::Sha1;
2296 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2297 let tree = db
2298 .write_object(EncodedObject::new(
2299 ObjectType::Tree,
2300 Tree { entries: vec![] }.write(),
2301 ))
2302 .expect("tree should write");
2303 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2304 let oid = db
2305 .write_object(EncodedObject::new(
2306 ObjectType::Commit,
2307 Commit {
2308 tree,
2309 parents: Vec::new(),
2310 author: identity.clone(),
2311 committer: identity,
2312 encoding: None,
2313 message: format!("{message}\n").into_bytes(),
2314 }
2315 .write(),
2316 ))
2317 .expect("commit should write");
2318 let store = FileRefStore::new(git_dir, format);
2319 let mut tx = store.transaction();
2320 tx.update(RefUpdate {
2321 name: format!("refs/heads/{branch}"),
2322 expected: None,
2323 new: RefTarget::Direct(oid),
2324 reflog: None,
2325 });
2326 tx.update(RefUpdate {
2327 name: "HEAD".into(),
2328 expected: None,
2329 new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
2330 reflog: None,
2331 });
2332 tx.commit().expect("refs should update");
2333 oid
2334 }
2335
2336 fn default_options() -> FetchOptions {
2337 FetchOptions {
2338 quiet: true,
2339 auto_follow_tags: false,
2340 fetch_all_tags: false,
2341 prune: false,
2342 prune_tags: false,
2343 dry_run: false,
2344 force: false,
2345 append: false,
2346 write_fetch_head: true,
2347 tag_option_explicit: true,
2348 prune_option_explicit: true,
2349 prune_tags_option_explicit: true,
2350 refmap: None,
2351 depth: None,
2352 merge_srcs: Vec::new(),
2353 filter: None,
2354 refetch: false,
2355 cloning: false,
2356 record_promisor_refs: true,
2357 update_shallow: false,
2358 reject_shallow: false,
2359 deepen_relative: false,
2360 update_head_ok: false,
2361 deepen_since: None,
2362 deepen_not: Vec::new(),
2363 ssh_options: None,
2364 atomic: false,
2365 negotiation_restrict: None,
2366 negotiation_include: None,
2367 }
2368 }
2369
2370 #[test]
2371 fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
2372 let remote = temp_repo("remote");
2373 let local = temp_repo("local");
2374 let tip = commit_on(&remote, "main", "remote tip");
2375 let source = FetchSource::Local {
2376 git_dir: remote.clone(),
2377 common_git_dir: remote.clone(),
2378 };
2379 let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
2380 let options = default_options();
2381 let mut credentials = NoCredentials;
2382 let mut progress = SilentProgress;
2383
2384 let outcome = fetch(
2385 FetchRequest {
2386 git_dir: &local,
2387 format: ObjectFormat::Sha1,
2388 config: &GitConfig::default(),
2389 remote_name: "origin",
2390 source: &source,
2391 refspecs: &refspecs,
2392 options: &options,
2393 },
2394 FetchServices {
2395 credentials: &mut credentials,
2396 progress: &mut progress,
2397 ref_hook: None,
2398 },
2399 )
2400 .expect("fetch should succeed");
2401
2402 assert_eq!(outcome.ref_updates.len(), 1);
2403 assert!(outcome.wrote_fetch_head);
2404 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
2405 assert!(local_db.contains(&tip).expect("contains should read"));
2406 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2407 assert_eq!(
2408 local_refs
2409 .read_ref("refs/remotes/origin/main")
2410 .expect("ref should read"),
2411 Some(RefTarget::Direct(tip))
2412 );
2413 let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
2414 assert!(fetch_head.contains("origin"));
2415 }
2416
2417 #[test]
2418 fn shallow_local_fetch_writes_depth_boundary_metadata() {
2419 let remote = temp_repo("remote-shallow");
2420 let local = temp_repo("local-shallow");
2421 let tip = commit_on(&remote, "main", "tip");
2422 let source = FetchSource::Local {
2423 git_dir: remote.clone(),
2424 common_git_dir: remote.clone(),
2425 };
2426 let mut options = default_options();
2427 options.depth = Some(1);
2428 let mut credentials = NoCredentials;
2429 let mut progress = SilentProgress;
2430
2431 fetch(
2432 FetchRequest {
2433 git_dir: &local,
2434 format: ObjectFormat::Sha1,
2435 config: &GitConfig::default(),
2436 remote_name: "origin",
2437 source: &source,
2438 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2439 options: &options,
2440 },
2441 FetchServices {
2442 credentials: &mut credentials,
2443 progress: &mut progress,
2444 ref_hook: None,
2445 },
2446 )
2447 .expect("shallow fetch should succeed");
2448
2449 assert_eq!(
2450 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2451 .expect("shallow file should read"),
2452 vec![tip]
2453 );
2454 }
2455
2456 fn pack_file_count(git_dir: &Path) -> usize {
2457 fs::read_dir(git_dir.join("objects/pack"))
2458 .expect("pack directory should read")
2459 .filter_map(|entry| entry.ok())
2460 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
2461 .count()
2462 }
2463
2464 #[test]
2465 fn same_depth_shallow_local_fetch_does_not_install_pack() {
2466 let remote = temp_repo("remote-shallow-noop");
2467 let local = temp_repo("local-shallow-noop");
2468 let tip = commit_on(&remote, "main", "tip");
2469 let source = FetchSource::Local {
2470 git_dir: remote.clone(),
2471 common_git_dir: remote.clone(),
2472 };
2473 let mut options = default_options();
2474 options.depth = Some(1);
2475 let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
2476 let mut credentials = NoCredentials;
2477 let mut progress = SilentProgress;
2478
2479 fetch(
2480 FetchRequest {
2481 git_dir: &local,
2482 format: ObjectFormat::Sha1,
2483 config: &GitConfig::default(),
2484 remote_name: "origin",
2485 source: &source,
2486 refspecs: &refspecs,
2487 options: &options,
2488 },
2489 FetchServices {
2490 credentials: &mut credentials,
2491 progress: &mut progress,
2492 ref_hook: None,
2493 },
2494 )
2495 .expect("initial shallow fetch should succeed");
2496 let pack_count = pack_file_count(&local);
2497 let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2498 .expect("shallow file should read");
2499
2500 fetch(
2501 FetchRequest {
2502 git_dir: &local,
2503 format: ObjectFormat::Sha1,
2504 config: &GitConfig::default(),
2505 remote_name: "origin",
2506 source: &source,
2507 refspecs: &refspecs,
2508 options: &options,
2509 },
2510 FetchServices {
2511 credentials: &mut credentials,
2512 progress: &mut progress,
2513 ref_hook: None,
2514 },
2515 )
2516 .expect("same-depth shallow fetch should succeed");
2517
2518 assert_eq!(pack_file_count(&local), pack_count);
2519 assert_eq!(
2520 crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2521 .expect("shallow file should read"),
2522 shallow
2523 );
2524 assert_eq!(shallow, vec![tip]);
2525 }
2526
2527 #[test]
2528 fn fetch_head_source_description_redacts_embedded_credentials() {
2529 let config = GitConfig {
2530 sections: vec![ConfigSection::new(
2531 "remote",
2532 Some("origin".into()),
2533 vec![ConfigEntry::new(
2534 "url",
2535 Some("https://user:pass@host/repo.git".into()),
2536 )],
2537 )],
2538 ..GitConfig::default()
2539 };
2540 assert_eq!(
2541 fetch_head_source_description(&config, "origin"),
2542 "https://<redacted>@host/repo"
2543 );
2544 }
2545
2546 #[test]
2547 fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
2548 let remote = temp_repo("remote-missing");
2549 let local = temp_repo("local-missing");
2550 let old = commit_on(&local, "main", "old local");
2551 let bogus =
2552 ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
2553 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2554 let mut tx = remote_refs.transaction();
2555 tx.update(RefUpdate {
2556 name: "refs/heads/main".into(),
2557 expected: None,
2558 new: RefTarget::Direct(bogus),
2559 reflog: None,
2560 });
2561 tx.update(RefUpdate {
2562 name: "HEAD".into(),
2563 expected: None,
2564 new: RefTarget::Symbolic("refs/heads/main".into()),
2565 reflog: None,
2566 });
2567 tx.commit().expect("remote bogus ref should write");
2568 let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2569 let mut tx = local_refs.transaction();
2570 tx.update(RefUpdate {
2571 name: "refs/remotes/origin/main".into(),
2572 expected: None,
2573 new: RefTarget::Direct(old),
2574 reflog: None,
2575 });
2576 tx.commit().expect("local tracking ref should write");
2577 let source = FetchSource::Local {
2578 git_dir: remote.clone(),
2579 common_git_dir: remote.clone(),
2580 };
2581 let options = default_options();
2582 let mut credentials = NoCredentials;
2583 let mut progress = SilentProgress;
2584
2585 let err = fetch(
2586 FetchRequest {
2587 git_dir: &local,
2588 format: ObjectFormat::Sha1,
2589 config: &GitConfig::default(),
2590 remote_name: "origin",
2591 source: &source,
2592 refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2593 options: &options,
2594 },
2595 FetchServices {
2596 credentials: &mut credentials,
2597 progress: &mut progress,
2598 ref_hook: None,
2599 },
2600 )
2601 .expect_err("fetch should fail before finalizing refs");
2602
2603 assert!(err.to_string().contains("missing object"));
2604 assert_eq!(
2605 local_refs
2606 .read_ref("refs/remotes/origin/main")
2607 .expect("ref should read"),
2608 Some(RefTarget::Direct(old))
2609 );
2610 assert!(!local.join("FETCH_HEAD").exists());
2611 }
2612
2613 fn config_from_ini(ini: &str) -> GitConfig {
2614 GitConfig::parse(ini.as_bytes()).expect("config should parse")
2615 }
2616
2617 #[test]
2618 fn fetch_max_input_size_unset_means_unlimited() {
2619 assert_eq!(fetch_max_input_size(&GitConfig::default()), None);
2620 }
2621
2622 #[test]
2623 fn fetch_max_input_size_honors_fetch_section() {
2624 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n");
2625 assert_eq!(fetch_max_input_size(&cfg), Some(64));
2626 }
2627
2628 #[test]
2629 fn fetch_max_input_size_falls_back_to_transfer_max_size() {
2630 let cfg = config_from_ini("[transfer]\n\tmaxSize = 1k\n");
2631 assert_eq!(fetch_max_input_size(&cfg), Some(1024));
2632 }
2633
2634 #[test]
2635 fn fetch_max_input_size_prefers_fetch_over_transfer() {
2636 let cfg = config_from_ini(
2637 "[fetch]\n\tmaxInputSize = 64\n[transfer]\n\tmaxSize = 1k\n",
2638 );
2639 assert_eq!(fetch_max_input_size(&cfg), Some(64));
2640 }
2641
2642 #[test]
2643 fn fetch_max_input_size_non_positive_means_unlimited() {
2644 let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 0\n");
2645 assert_eq!(fetch_max_input_size(&cfg), None);
2646 }
2647}