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