1use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use thiserror::Error;
10
11use crate::config::{ConfigFile, ConfigScope, ConfigSet};
12use crate::environment::Environment;
13use crate::environment::RepositoryOptions;
14use crate::error::{Error, Result};
15use crate::fetch::{fetch_operation_identity, Progress};
16use crate::objects::{HashAlgo, ObjectId};
17use crate::partial_clone::{ObjectFilterSpec, PartialCloneConfig};
18use crate::progress::Cancel;
19use crate::promisor_fetch::PromisorFetcher;
20use crate::ref_storage::RefStorageFormat;
21use crate::refs;
22use crate::remote::{
23 fold_peeled_refs, HttpClientFactory, ListRefsOptions, Remote, RemoteError, DEFAULT_REMOTE,
24};
25use crate::repo::{init_repository_with_object_format, Repository};
26use crate::transfer::{CloneReflog, FetchOptions, TagMode, TransferStats};
27use crate::transport_path::{
28 absolute_local_clone_source_url, git_url_basename, is_local_path_remote_url,
29 resolve_local_clone_source_path, resolve_local_remote_git_dir,
30 should_store_absolute_local_clone_url, url_is_local_not_ssh, NoDirectoryName,
31};
32
33#[derive(Debug, Error)]
35pub enum CloneError {
36 #[error("destination '{0}' already exists and is not empty")]
41 DestNotEmpty(String),
42 #[error("remote ref '{0}' not found")]
44 RemoteRefNotFound(String),
45 #[error("depth 0 is not a positive number")]
47 InvalidCloneDepth,
48 #[error(transparent)]
50 Library(#[from] Error),
51 #[error(transparent)]
53 Remote(#[from] crate::remote::RemoteError),
54 #[error("cloned into '{}', but checking out {commit} failed: {source}", .path.display())]
62 CheckoutFailed {
63 path: PathBuf,
65 commit: ObjectId,
67 #[source]
69 source: Box<Error>,
70 },
71}
72
73impl CloneError {
74 #[must_use]
80 pub fn should_cleanup_failed_clone(&self) -> bool {
81 match self {
82 Self::DestNotEmpty(_) | Self::InvalidCloneDepth | Self::CheckoutFailed { .. } => false,
83 Self::Remote(RemoteError::InvalidRemoteName(_)) => false,
84 Self::RemoteRefNotFound(_) => true,
85 Self::Library(_) | Self::Remote(_) => true,
86 }
87 }
88
89 #[must_use]
91 pub fn is_remote_ref_not_found(&self) -> bool {
92 matches!(self, Self::RemoteRefNotFound(_))
93 }
94
95 #[must_use]
98 pub fn is_remote_unreadable(&self) -> bool {
99 match self {
100 Self::Library(e) => e.is_remote_unreadable(),
101 Self::Remote(e) => e.is_remote_unreadable(),
102 Self::DestNotEmpty(_)
103 | Self::RemoteRefNotFound(_)
104 | Self::InvalidCloneDepth
105 | Self::CheckoutFailed { .. } => false,
106 }
107 }
108
109 #[must_use]
113 pub fn is_cancelled(&self) -> bool {
114 match self {
115 Self::Library(e) => e.is_cancelled(),
116 Self::Remote(e) => e.is_cancelled(),
117 Self::DestNotEmpty(_)
118 | Self::RemoteRefNotFound(_)
119 | Self::InvalidCloneDepth
120 | Self::CheckoutFailed { .. } => false,
121 }
122 }
123
124 #[must_use]
131 pub fn kept_repository(&self) -> Option<&Path> {
132 match self {
133 Self::CheckoutFailed { path, .. } => Some(path),
134 _ => None,
135 }
136 }
137
138 #[must_use]
146 pub fn missing_promised_object(&self) -> Option<ObjectId> {
147 let Self::CheckoutFailed { source, .. } = self else {
148 return None;
149 };
150 match promisor_cause(source)? {
151 Error::PromisorFetchFailed { missing, .. } => Some(*missing),
152 Error::PromisedObjectMissing(oid) => Some(*oid),
153 _ => None,
154 }
155 }
156
157 #[must_use]
165 pub fn failed_promisor_remotes(&self) -> &[(String, Error)] {
166 let Self::CheckoutFailed { source, .. } = self else {
167 return &[];
168 };
169 match promisor_cause(source) {
170 Some(Error::PromisorFetchFailed { failures, .. }) => failures,
171 _ => &[],
172 }
173 }
174}
175
176fn promisor_cause(err: &Error) -> Option<&Error> {
179 match err {
180 Error::PromisorFetchFailed { .. } | Error::PromisedObjectMissing(_) => Some(err),
181 Error::CheckoutRollbackIncomplete { source, .. } => promisor_cause(source),
182 _ => None,
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum CloneTags {
189 All,
191 Following,
193 None,
195}
196
197impl CloneTags {
198 #[must_use]
200 pub const fn tag_mode(self) -> TagMode {
201 match self {
202 Self::All => TagMode::All,
203 Self::Following => TagMode::Following,
204 Self::None => TagMode::None,
205 }
206 }
207}
208
209pub struct CloneOptions {
211 pub url: String,
213 pub dest: PathBuf,
215 pub remote_name: String,
217 pub environment: Environment,
219 pub initial_branch: String,
221 pub thread_policy: crate::parallel::ThreadPolicy,
223 pub depth: Option<u32>,
226 pub filter: Option<ObjectFilterSpec>,
228 pub auto_filter: bool,
234 pub config: Vec<(String, String)>,
238 pub branch: Option<String>,
240 pub single_branch: Option<bool>,
244 pub tags: Option<CloneTags>,
248 pub promisor_fetcher: Option<Arc<dyn PromisorFetcher>>,
250 pub bare: bool,
252 pub checkout: bool,
254 pub bundle_cache: Option<PathBuf>,
258}
259
260impl CloneOptions {
261 #[must_use]
263 pub fn new(url: impl Into<String>, dest: PathBuf) -> Self {
264 Self {
265 url: url.into(),
266 dest,
267 remote_name: DEFAULT_REMOTE.to_owned(),
268 environment: Environment::capture_process(), initial_branch: "main".to_owned(),
270 thread_policy: crate::parallel::ThreadPolicy::default(),
271 depth: None,
272 filter: None,
273 auto_filter: false,
274 config: Vec::new(),
275 branch: None,
276 single_branch: None,
277 tags: None,
278 promisor_fetcher: None,
279 bare: false,
280 checkout: true,
281 bundle_cache: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum CloneHead {
289 Branch(String),
291 Detached {
293 tag: String,
295 oid: ObjectId,
297 },
298}
299
300impl CloneHead {
301 #[must_use]
303 pub fn branch_name(&self) -> Option<&str> {
304 match self {
305 Self::Branch(name) => Some(name.as_str()),
306 Self::Detached { .. } => None,
307 }
308 }
309
310 #[must_use]
312 pub fn tag_name(&self) -> Option<&str> {
313 match self {
314 Self::Detached { tag, .. } => Some(tag.as_str()),
315 Self::Branch(_) => None,
316 }
317 }
318
319 #[must_use]
321 pub fn is_detached(&self) -> bool {
322 matches!(self, Self::Detached { .. })
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum CloneCheckout {
329 CheckedOut(ObjectId),
331 EmptyRemote,
333 UnbornDefaultBranch,
335 Skipped,
337}
338
339#[derive(Debug)]
341pub struct CloneOutcome {
342 pub url: String,
344 pub path: PathBuf,
346 pub head: CloneHead,
348 pub checkout_oid: ObjectId,
352 pub checkout: CloneCheckout,
354 pub branches_with_commits: Vec<String>,
357 pub repo: Repository,
359 pub transfer: TransferStats,
361 pub branches: u64,
363 pub tags: u64,
365 pub files: u64,
367 pub worktree_bytes: u64,
369 pub path_collisions: Vec<String>,
371 pub submodule_paths: Vec<String>,
373 pub bare_gitlink_paths: Vec<String>,
375 pub bare: bool,
377 pub bundle: crate::bundle_uri::BundleSeed,
380 pub filter_sent: bool,
385 pub promisor: Option<crate::promisor_remote::PromisorHandshake>,
388 pub filter: Option<ObjectFilterSpec>,
391}
392
393impl CloneOutcome {
394 #[must_use]
402 pub fn filter_ignored(&self) -> bool {
403 self.filter.is_some() && !self.filter_sent
404 }
405}
406
407pub fn derive_clone_dir(url: &str, bare: bool) -> std::result::Result<String, NoDirectoryName> {
414 git_url_basename(url, false, bare)
415}
416
417#[must_use]
419pub fn dest_preexisted_empty(path: &Path) -> bool {
420 path.is_dir()
421 && path
422 .read_dir()
423 .map(|mut entries| entries.next().is_none())
424 .unwrap_or(false)
425}
426
427struct CloneDestinationGuard;
428
429impl CloneDestinationGuard {
430 fn try_claim(dest: &Path, bare: bool) -> std::result::Result<(), CloneError> {
431 let label = dest
432 .to_str()
433 .ok_or_else(|| {
434 CloneError::Library(Error::Message("non-UTF-8 destination path".into()))
435 })?
436 .to_owned();
437 if bare {
438 Self::try_claim_bare(dest, label)
439 } else {
440 Self::try_claim_non_bare(dest, label)
441 }
442 }
443
444 fn try_claim_non_bare(dest: &Path, label: String) -> std::result::Result<(), CloneError> {
445 if dest.is_dir() && !dir_is_empty(dest) {
446 return Err(CloneError::DestNotEmpty(label));
447 }
448
449 if !dest.is_dir() {
450 if let Some(parent) = dest.parent() {
451 fs::create_dir_all(parent).map_err(|e| {
452 CloneError::Library(Error::Message(format!(
453 "could not create clone destination parent: {e}"
454 )))
455 })?;
456 }
457 match fs::create_dir(dest) {
458 Ok(()) => {}
459 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
460 Err(source) => {
461 return Err(CloneError::Library(Error::Message(format!(
462 "could not create clone destination: {source}"
463 ))));
464 }
465 }
466 }
467
468 if !dest.is_dir() {
469 return Err(CloneError::DestNotEmpty(label));
470 }
471 if !dir_is_empty(dest) {
472 return Err(CloneError::DestNotEmpty(label));
473 }
474
475 let git_dir = dest.join(".git");
476 match fs::create_dir(&git_dir) {
477 Ok(()) => Ok(()),
478 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
479 Err(CloneError::DestNotEmpty(label))
480 }
481 Err(source) => Err(CloneError::Library(Error::Message(format!(
482 "could not create '{}/.git': {source}",
483 dest.display()
484 )))),
485 }
486 }
487
488 fn try_claim_bare(dest: &Path, label: String) -> std::result::Result<(), CloneError> {
489 if dest.is_dir() && !dir_is_empty(dest) {
490 return Err(CloneError::DestNotEmpty(label));
491 }
492
493 if !dest.is_dir() {
494 if let Some(parent) = dest.parent() {
495 fs::create_dir_all(parent).map_err(|e| {
496 CloneError::Library(Error::Message(format!(
497 "could not create clone destination parent: {e}"
498 )))
499 })?;
500 }
501 match fs::create_dir(dest) {
502 Ok(()) => {}
503 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
504 Err(source) => {
505 return Err(CloneError::Library(Error::Message(format!(
506 "could not create clone destination: {source}"
507 ))));
508 }
509 }
510 }
511
512 if !dest.is_dir() {
513 return Err(CloneError::DestNotEmpty(label));
514 }
515 if !dir_is_empty(dest) {
516 return Err(CloneError::DestNotEmpty(label));
517 }
518
519 let objects = dest.join("objects");
521 match fs::create_dir(&objects) {
522 Ok(()) => Ok(()),
523 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
524 Err(CloneError::DestNotEmpty(label))
525 }
526 Err(source) => Err(CloneError::Library(Error::Message(format!(
527 "could not create '{}/objects': {source}",
528 dest.display()
529 )))),
530 }
531 }
532}
533
534fn dir_is_empty(path: &Path) -> bool {
535 path.read_dir()
536 .map(|mut entries| entries.next().is_none())
537 .unwrap_or(false)
538}
539
540pub fn cleanup_failed_clone(path: &Path, keep_toplevel: bool) {
545 crate::pack::clear_pack_cache();
548 if keep_toplevel {
549 let Ok(entries) = fs::read_dir(path) else {
550 return;
551 };
552 for entry in entries.flatten() {
553 let p = entry.path();
554 if p.is_dir() {
555 let _ = fs::remove_dir_all(p);
556 } else {
557 let _ = fs::remove_file(p);
558 }
559 }
560 } else {
561 let _ = fs::remove_dir_all(path);
562 }
563}
564
565pub struct CloneIntoOptions {
567 pub url: String,
569 pub remote_name: String,
571 pub environment: Environment,
573 pub initial_branch: String,
575 pub thread_policy: crate::parallel::ThreadPolicy,
577 pub depth: Option<u32>,
579 pub filter: Option<ObjectFilterSpec>,
581 pub branch: Option<String>,
583 pub single_branch: Option<bool>,
585 pub tags: Option<CloneTags>,
587 pub promisor_fetcher: Option<Arc<dyn PromisorFetcher>>,
589 pub bare: bool,
591 pub checkout: bool,
593 pub bundle_cache: Option<PathBuf>,
595}
596
597impl CloneIntoOptions {
598 #[must_use]
600 pub fn new(url: impl Into<String>) -> Self {
601 Self {
602 url: url.into(),
603 remote_name: DEFAULT_REMOTE.to_owned(),
604 environment: Environment::empty(),
605 initial_branch: "main".to_owned(),
606 thread_policy: crate::parallel::ThreadPolicy::default(),
607 depth: None,
608 filter: None,
609 branch: None,
610 single_branch: None,
611 tags: None,
612 promisor_fetcher: None,
613 bare: false,
614 checkout: true,
615 bundle_cache: None,
616 }
617 }
618
619 #[must_use]
621 pub fn with_environment(mut self, environment: Environment) -> Self {
622 self.environment = environment;
623 self
624 }
625
626 fn clone_options_for_repo(&self, repo: &Repository) -> CloneOptions {
627 let dest = repo
628 .work_tree()
629 .map(std::path::Path::to_path_buf)
630 .unwrap_or_else(|| repo.git_dir().to_path_buf());
631 CloneOptions {
632 url: self.url.clone(),
633 dest,
634 remote_name: self.remote_name.clone(),
635 environment: self.environment.clone(),
636 initial_branch: self.initial_branch.clone(),
637 thread_policy: self.thread_policy,
638 depth: self.depth,
639 filter: self.filter,
640 branch: self.branch.clone(),
641 single_branch: self.single_branch,
642 tags: self.tags,
643 promisor_fetcher: self.promisor_fetcher.clone(),
644 bare: self.bare,
645 checkout: self.checkout,
646 bundle_cache: self.bundle_cache.clone(),
647 auto_filter: false,
648 config: Vec::new(),
649 }
650 }
651}
652
653#[derive(Debug)]
655pub struct CloneIntoOutcome {
656 pub head: CloneHead,
658 pub checkout_oid: ObjectId,
660 pub checkout: CloneCheckout,
662 pub transfer: TransferStats,
664 pub branches: u64,
666 pub tags: u64,
668 pub files: u64,
670 pub worktree_bytes: u64,
672 pub path_collisions: Vec<String>,
674 pub submodule_paths: Vec<String>,
676 pub bare_gitlink_paths: Vec<String>,
678 pub bundle: crate::bundle_uri::BundleSeed,
680 pub filter_sent: bool,
682}
683
684pub fn clone_into(
698 repo: Repository,
699 opts: CloneIntoOptions,
700 progress: &mut dyn Progress,
701 cancel: &dyn Cancel,
702 http_factory: Option<&dyn HttpClientFactory>,
703) -> std::result::Result<CloneIntoOutcome, CloneError> {
704 if opts.depth == Some(0) {
705 return Err(CloneError::InvalidCloneDepth);
706 }
707 if !crate::remote::is_valid_remote_name(&opts.remote_name) {
708 return Err(CloneError::Remote(RemoteError::InvalidRemoteName(
709 opts.remote_name.clone(),
710 )));
711 }
712 ensure_clone_remote_object_format(&repo, &opts.url, &opts.environment)?;
713 let clone_opts = opts.clone_options_for_repo(&repo);
714 let outcome = populate_repo_from_remote(repo, clone_opts, progress, cancel, http_factory)?;
715 Ok(CloneIntoOutcome {
716 head: outcome.head,
717 checkout_oid: outcome.checkout_oid,
718 checkout: outcome.checkout,
719 transfer: outcome.transfer,
720 branches: outcome.branches,
721 tags: outcome.tags,
722 files: outcome.files,
723 worktree_bytes: outcome.worktree_bytes,
724 path_collisions: outcome.path_collisions,
725 submodule_paths: outcome.submodule_paths,
726 bare_gitlink_paths: outcome.bare_gitlink_paths,
727 bundle: outcome.bundle,
728 filter_sent: outcome.filter_sent,
729 })
730}
731
732pub fn clone(
738 opts: CloneOptions,
739 progress: &mut dyn Progress,
740 cancel: &dyn Cancel,
741 http_factory: Option<&dyn HttpClientFactory>,
742) -> std::result::Result<CloneOutcome, CloneError> {
743 let dir = opts
744 .dest
745 .to_str()
746 .ok_or_else(|| CloneError::Library(Error::Message("non-UTF-8 destination path".into())))?;
747 if opts.dest.is_dir()
748 && opts
749 .dest
750 .read_dir()
751 .map(|mut entries| entries.next().is_some())
752 .unwrap_or(false)
753 {
754 return Err(CloneError::DestNotEmpty(dir.to_owned()));
755 }
756 if opts.depth == Some(0) {
757 return Err(CloneError::InvalidCloneDepth);
758 }
759 if !crate::remote::is_valid_remote_name(&opts.remote_name) {
760 return Err(CloneError::Remote(RemoteError::InvalidRemoteName(
761 opts.remote_name.clone(),
762 )));
763 }
764
765 ensure_local_clone_source_readable(&opts.url, &opts.environment)?;
766
767 let bare = opts.bare;
768 CloneDestinationGuard::try_claim(&opts.dest, bare)?;
769 let discovery_cwd = opts.environment.discovery_cwd();
770 let object_format =
771 local_clone_source_hash_algo(&opts.url, &discovery_cwd).unwrap_or(HashAlgo::Sha1);
772 init_repository_with_object_format(
773 &opts.dest,
774 bare,
775 &opts.initial_branch,
776 None,
777 RefStorageFormat::Files,
778 object_format,
779 crate::init_filesystem::InitFilesystemConfigOptions::default(),
780 &Environment::empty(),
781 )
782 .map_err(|e| {
783 CloneError::Library(Error::Message(format!("could not initialize '{dir}': {e}")))
784 })?;
785 let git_dir = if bare {
786 opts.dest.clone()
787 } else {
788 opts.dest.join(".git")
789 };
790 let repo = open_clone_repository(
791 &git_dir,
792 &opts.dest,
793 bare,
794 &opts.environment,
795 opts.promisor_fetcher.clone(),
796 )
797 .map_err(CloneError::Library)?;
798 populate_repo_from_remote(repo, opts, progress, cancel, http_factory)
799}
800
801fn populate_repo_from_remote(
802 repo: Repository,
803 opts: CloneOptions,
804 progress: &mut dyn Progress,
805 cancel: &dyn Cancel,
806 http_factory: Option<&dyn HttpClientFactory>,
807) -> std::result::Result<CloneOutcome, CloneError> {
808 let bare = opts.bare;
809 clone_after_claim(repo, opts, progress, cancel, http_factory, bare)
810}
811
812fn ensure_clone_remote_object_format(
814 repo: &Repository,
815 url: &str,
816 environment: &Environment,
817) -> std::result::Result<(), CloneError> {
818 if repo.odb().uses_files_primary() {
819 return Ok(());
820 }
821 let Some(remote_algo) = local_clone_source_hash_algo(url, &environment.discovery_cwd()) else {
822 return Ok(());
823 };
824 let local = repo.odb().hash_algo();
825 if local != remote_algo {
826 return Err(CloneError::Library(Error::ObjectFormatMismatch {
827 repository: local,
828 store: remote_algo,
829 }));
830 }
831 Ok(())
832}
833
834fn clone_after_claim(
835 mut repo: Repository,
836 opts: CloneOptions,
837 progress: &mut dyn Progress,
838 cancel: &dyn Cancel,
839 http_factory: Option<&dyn HttpClientFactory>,
840 bare: bool,
841) -> std::result::Result<CloneOutcome, CloneError> {
842 let git_dir = repo.git_dir().to_path_buf();
843 let preserve_injected_store = !repo.odb().uses_files_primary();
844
845 add_local_config(&repo, &opts.config)?;
846 let origin_url = stored_clone_remote_url(&opts.url);
847 set_local_config(
848 &repo,
849 &[(
850 &format!("remote.{}.url", opts.remote_name),
851 origin_url.clone(),
852 )],
853 )?;
854
855 let config = ConfigSet::load(&opts.environment, Some(repo.git_dir()), true)
856 .map_err(CloneError::Library)?;
857 let mut remote = Remote::from_config(&config, &opts.remote_name)?;
858 if bare {
859 remote.fetch_refspecs.clear();
862 }
863 let requested = match opts.branch.as_deref() {
864 Some(name) => resolve_requested_clone_ref(name, &remote, &repo, http_factory)?,
865 None => CloneRequestedRef::Default,
866 };
867 let fetch_refspec =
868 clone_initial_fetch_refspec(&opts, &requested, &remote, &repo, http_factory)?;
869 if !bare {
870 let mut remote_config_entries = vec![(
871 format!("remote.{}.fetch", opts.remote_name),
872 fetch_refspec.clone(),
873 )];
874 if matches!(opts.tags, Some(CloneTags::None)) {
875 remote_config_entries.push((
876 format!("remote.{}.tagOpt", opts.remote_name),
877 "--no-tags".to_owned(),
878 ));
879 }
880 set_local_config(
881 &repo,
882 &remote_config_entries
883 .iter()
884 .map(|(k, v)| (k.as_str(), v.clone()))
885 .collect::<Vec<_>>(),
886 )?;
887 }
888
889 let clone_message = format!("clone: from {}", opts.url);
890 let identity =
891 fetch_operation_identity(&opts.environment, repo.git_dir()).map_err(CloneError::Library)?;
892 let clone_reflog = CloneReflog {
893 identity: identity.clone(),
894 message: clone_message.clone(),
895 };
896 let mut fetch_opts = FetchOptions {
897 refspecs: vec![fetch_refspec],
898 tags: resolve_clone_tag_mode(&opts),
899 initial_remote_fetch: true,
900 remote_name: if bare {
901 None
902 } else {
903 Some(opts.remote_name.clone())
904 },
905 clone_reflog: Some(clone_reflog),
906 environment: opts.environment.clone(),
907 thread_policy: opts.thread_policy,
908 remote_progress: progress.remote_sideband_progress(),
909 fetch_depth: opts.depth.and_then(crate::transfer::FetchDepth::depth),
910 filter: opts.filter,
911 auto_filter: opts.auto_filter && opts.filter.is_none(),
912 link_source_objects: should_store_absolute_local_clone_url(&opts.url),
913 ..Default::default()
914 };
915 if let Ok(v) = crate::protocol::try_effective_client_protocol_version_from_config(&config) {
916 fetch_opts.http_protocol_version = Some(v);
917 }
918 let bundle = match opts.bundle_cache.as_deref() {
919 Some(cache) if opts.depth.is_none() && opts.filter.is_none() && !opts.auto_filter => {
920 remote.seed_from_bundle_uri(&repo, cache, progress, cancel, http_factory)
921 }
922 _ => crate::bundle_uri::BundleSeed::NotOffered,
923 };
924 if cancel.is_cancelled() {
925 return Err(CloneError::Library(Error::Cancelled));
926 }
927 if let crate::bundle_uri::BundleSeed::Failed { reason, .. } = &bundle {
928 if crate::transport::pending_http::is_low_speed_transport_message(reason) {
929 return Err(CloneError::Remote(RemoteError::Library(Error::Message(
930 reason.clone(),
931 ))));
932 }
933 }
934 let mut fetch_outcome = remote.fetch(&repo, fetch_opts, progress, cancel, http_factory)?;
935 if let crate::bundle_uri::BundleSeed::Seeded(seeded) = &bundle {
936 crate::bundle_uri::remove_bundle_refs(&repo).map_err(CloneError::Library)?;
937 fetch_outcome.transfer += seeded.transfer;
938 }
939 if !preserve_injected_store {
940 repo = open_clone_repository(
943 &git_dir,
944 &opts.dest,
945 bare,
946 &opts.environment,
947 opts.promisor_fetcher.clone(),
948 )
949 .map_err(CloneError::Library)?;
950 }
951
952 let used_filter = match opts.filter {
953 Some(spec) => {
954 PartialCloneConfig::write(repo.git_dir(), &opts.remote_name, &spec)
955 .map_err(CloneError::Library)?;
956 Some(spec)
957 }
958 None if opts.auto_filter => {
959 PartialCloneConfig::write_auto(repo.git_dir(), &opts.remote_name)
960 .map_err(CloneError::Library)?;
961 fetch_outcome
962 .promisor
963 .as_ref()
964 .and_then(|h| h.resolved_filter)
965 }
966 None => None,
967 };
968
969 let (head, oid) = setup_clone_head(
970 &repo,
971 &opts,
972 &requested,
973 &fetch_outcome,
974 &identity,
975 &clone_message,
976 )?;
977
978 let branches_with_commits = if bare {
979 local_branch_names_with_commits(&repo)
980 } else {
981 remote_branch_names_with_commits(&repo, &opts.remote_name)
982 };
983 let mut checkout = classify_clone_checkout(
984 oid,
985 &fetch_outcome.transfer,
986 fetch_outcome.branch_count(),
987 &branches_with_commits,
988 );
989
990 let repo = repo.with_thread_policy(opts.thread_policy);
991
992 let mut files = 0;
993 let mut worktree_bytes = 0;
994 let mut path_collisions = Vec::new();
995 let mut submodule_paths = Vec::new();
996 let mut bare_gitlink_paths = Vec::new();
997 if opts.checkout && !bare {
998 if let CloneCheckout::CheckedOut(checkout_oid) = checkout {
999 (
1002 files,
1003 worktree_bytes,
1004 path_collisions,
1005 submodule_paths,
1006 bare_gitlink_paths,
1007 ) = perform_clone_checkout(&repo, checkout_oid, progress, cancel).map_err(
1008 |source| {
1009 if source.is_cancelled() {
1010 CloneError::Library(source)
1011 } else {
1012 CloneError::CheckoutFailed {
1013 path: opts.dest.clone(),
1014 commit: checkout_oid,
1015 source: Box::new(source),
1016 }
1017 }
1018 },
1019 )?;
1020 }
1021 } else if matches!(checkout, CloneCheckout::CheckedOut(_)) {
1022 checkout = CloneCheckout::Skipped;
1023 }
1024
1025 Ok(CloneOutcome {
1026 url: opts.url.clone(),
1027 path: opts.dest.clone(),
1028 head,
1029 checkout_oid: oid,
1030 checkout,
1031 branches_with_commits,
1032 repo,
1033 transfer: fetch_outcome.transfer,
1034 branches: fetch_outcome.branch_count(),
1035 tags: fetch_outcome.tag_count(),
1036 files,
1037 worktree_bytes,
1038 path_collisions,
1039 submodule_paths,
1040 bare_gitlink_paths,
1041 bare,
1042 bundle,
1043 filter_sent: fetch_outcome.filter_sent,
1044 promisor: fetch_outcome.promisor,
1045 filter: used_filter,
1046 })
1047}
1048
1049fn gitlink_paths_in_index(index: &crate::index::Index) -> Vec<String> {
1050 index
1051 .entries
1052 .iter()
1053 .filter(|e| e.stage() == 0 && e.mode == crate::index::MODE_GITLINK)
1054 .map(|e| String::from_utf8_lossy(&e.path).into_owned())
1055 .collect()
1056}
1057
1058type CloneCheckoutStats = (u64, u64, Vec<String>, Vec<String>, Vec<String>);
1059
1060fn perform_clone_checkout(
1061 repo: &Repository,
1062 commit: ObjectId,
1063 progress: &mut dyn Progress,
1064 cancel: &dyn Cancel,
1065) -> Result<CloneCheckoutStats> {
1066 let tree = crate::diff::tree_oid_of_commit_in_repo(repo, &commit)?;
1067 let checkout = crate::porcelain::checkout::checkout_between_trees_with_progress(
1068 repo, None, &tree, progress, cancel,
1069 )?;
1070 let index = repo.index_handle().load()?;
1071 let ignorecase = repo.config()?.ignore_case();
1072 let (files, worktree_bytes) =
1073 crate::path_icase::icase_worktree_file_stats(&index, repo.work_tree(), ignorecase);
1074 let (submodule_paths, bare_gitlink_paths) =
1075 classify_gitlink_paths(repo, gitlink_paths_in_index(&index));
1076 Ok((
1077 files,
1078 worktree_bytes,
1079 checkout.path_collisions,
1080 submodule_paths,
1081 bare_gitlink_paths,
1082 ))
1083}
1084
1085fn classify_gitlink_paths(repo: &Repository, gitlinks: Vec<String>) -> (Vec<String>, Vec<String>) {
1086 let Some(work_tree) = repo.work_tree() else {
1087 return (Vec::new(), gitlinks);
1088 };
1089 let registered: std::collections::HashSet<String> =
1090 crate::submodule_gitdir::registered_submodule_paths(work_tree)
1091 .into_iter()
1092 .collect();
1093 let mut submodule_paths = Vec::new();
1094 let mut bare_gitlink_paths = Vec::new();
1095 for path in gitlinks {
1096 if registered.contains(&path) {
1097 submodule_paths.push(path);
1098 } else {
1099 bare_gitlink_paths.push(path);
1100 }
1101 }
1102 (submodule_paths, bare_gitlink_paths)
1103}
1104
1105fn resolve_clone_ref(repo: &Repository, refname: &str) -> ObjectId {
1106 refs::resolve_ref_in(repo, refname).unwrap_or(ObjectId::zero())
1107}
1108
1109fn write_clone_head_symref(repo: &Repository, target: &str) -> std::result::Result<(), CloneError> {
1110 refs::write_symbolic_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", target)
1111 .map_err(CloneError::Library)
1112}
1113
1114fn local_branch_names_with_commits(repo: &Repository) -> Vec<String> {
1115 refs::list_refs_in(repo, "refs/heads/")
1116 .unwrap_or_default()
1117 .into_iter()
1118 .filter_map(|(name, oid)| {
1119 if oid.is_zero() {
1120 return None;
1121 }
1122 name.strip_prefix("refs/heads/").map(str::to_owned)
1123 })
1124 .collect()
1125}
1126
1127fn remote_branch_names_with_commits(repo: &Repository, remote_name: &str) -> Vec<String> {
1128 let prefix = format!("refs/remotes/{remote_name}/");
1129 refs::list_refs_in(repo, &prefix)
1130 .unwrap_or_default()
1131 .into_iter()
1132 .filter_map(|(name, oid)| {
1133 if oid.is_zero() {
1134 return None;
1135 }
1136 name.strip_prefix(&prefix)
1137 .filter(|b| *b != "HEAD")
1138 .map(str::to_owned)
1139 })
1140 .collect()
1141}
1142
1143fn classify_clone_checkout(
1144 checkout_oid: ObjectId,
1145 transfer: &TransferStats,
1146 branch_count: u64,
1147 branches_with_commits: &[String],
1148) -> CloneCheckout {
1149 if !checkout_oid.is_zero() {
1150 return CloneCheckout::CheckedOut(checkout_oid);
1151 }
1152 if transfer.objects == 0 && branch_count == 0 {
1153 return CloneCheckout::EmptyRemote;
1154 }
1155 if transfer.objects > 0 || !branches_with_commits.is_empty() {
1156 return CloneCheckout::UnbornDefaultBranch;
1157 }
1158 CloneCheckout::EmptyRemote
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq)]
1162enum CloneRequestedRef {
1163 Default,
1164 Branch(String),
1165 Tag { name: String, peel: ObjectId },
1166}
1167
1168fn resolve_requested_clone_ref(
1169 name: &str,
1170 remote: &Remote,
1171 repo: &Repository,
1172 http_factory: Option<&dyn HttpClientFactory>,
1173) -> std::result::Result<CloneRequestedRef, CloneError> {
1174 let refs = remote
1175 .list_refs(
1176 Some(repo),
1177 &ListRefsOptions {
1178 symrefs: true,
1179 peel: true,
1180 ..Default::default()
1181 },
1182 http_factory,
1183 )
1184 .map_err(CloneError::Remote)?;
1185 let head_ref = format!("refs/heads/{name}");
1186 if refs.iter().any(|r| r.name == head_ref) {
1187 return Ok(CloneRequestedRef::Branch(name.to_owned()));
1188 }
1189 let tag_ref = format!("refs/tags/{name}");
1190 let folded = fold_peeled_refs(&refs);
1191 let Some(tag) = folded.iter().find(|r| r.name == tag_ref) else {
1192 return Err(CloneError::RemoteRefNotFound(name.to_owned()));
1193 };
1194 let peel = tag.peeled.filter(|oid| !oid.is_zero()).unwrap_or(tag.oid);
1195 if peel.is_zero() {
1196 return Err(CloneError::RemoteRefNotFound(name.to_owned()));
1197 }
1198 Ok(CloneRequestedRef::Tag {
1199 name: name.to_owned(),
1200 peel,
1201 })
1202}
1203
1204fn clone_initial_fetch_refspec(
1209 opts: &CloneOptions,
1210 requested: &CloneRequestedRef,
1211 remote: &Remote,
1212 repo: &Repository,
1213 http_factory: Option<&dyn HttpClientFactory>,
1214) -> std::result::Result<String, CloneError> {
1215 if clone_effective_single_branch(opts) {
1216 return match requested {
1217 CloneRequestedRef::Tag { name, .. } => {
1218 Ok(format!("+refs/tags/{name}:refs/tags/{name}"))
1219 }
1220 CloneRequestedRef::Branch(branch) => {
1221 if opts.bare {
1222 Ok(format!("+refs/heads/{branch}:refs/heads/{branch}"))
1223 } else {
1224 Ok(format!(
1225 "+refs/heads/{branch}:refs/remotes/{}/{}",
1226 opts.remote_name, branch
1227 ))
1228 }
1229 }
1230 CloneRequestedRef::Default => {
1231 let branch = advertised_default_branch_from_remote(
1232 remote,
1233 repo,
1234 http_factory,
1235 &opts.url,
1236 &opts.initial_branch,
1237 )?;
1238 if opts.bare {
1239 Ok(format!("+refs/heads/{branch}:refs/heads/{branch}"))
1240 } else {
1241 Ok(format!(
1242 "+refs/heads/{branch}:refs/remotes/{}/{}",
1243 opts.remote_name, branch
1244 ))
1245 }
1246 }
1247 };
1248 }
1249 if opts.bare {
1250 return Ok("+refs/heads/*:refs/heads/*".to_owned());
1251 }
1252 Ok(format!("+refs/heads/*:refs/remotes/{}/*", opts.remote_name))
1253}
1254
1255#[must_use]
1257pub fn clone_effective_single_branch(opts: &CloneOptions) -> bool {
1258 match opts.single_branch {
1259 Some(true) => true,
1260 Some(false) => false,
1261 None => opts.depth.is_some_and(|d| d > 0),
1262 }
1263}
1264
1265fn resolve_clone_tag_mode(opts: &CloneOptions) -> TagMode {
1266 opts.tags.map(CloneTags::tag_mode).unwrap_or_else(|| {
1267 if clone_effective_single_branch(opts) {
1268 TagMode::Following
1269 } else {
1270 TagMode::All
1271 }
1272 })
1273}
1274
1275fn append_clone_reflog_on_store(
1276 repo: &Repository,
1277 refname: &str,
1278 old_oid: &ObjectId,
1279 new_oid: &ObjectId,
1280 identity: &crate::ident::Signature,
1281 message: &str,
1282) -> Result<()> {
1283 identity.validate().map_err(Error::InvalidSignature)?;
1284 let formatted =
1285 refs::store::format_reflog_identity(&identity.reflog_name_email(), identity.time);
1286 let config = repo.config()?;
1287 refs::append_reflog_on_store(
1288 repo.refs(),
1289 refname,
1290 old_oid,
1291 new_oid,
1292 &formatted,
1293 message,
1294 false,
1295 &config,
1296 )
1297}
1298
1299fn setup_clone_head(
1300 repo: &Repository,
1301 opts: &CloneOptions,
1302 requested: &CloneRequestedRef,
1303 fetch_outcome: &crate::transfer::FetchOutcome,
1304 identity: &crate::ident::Signature,
1305 clone_message: &str,
1306) -> std::result::Result<(CloneHead, ObjectId), CloneError> {
1307 let zero = ObjectId::zero();
1308 match requested {
1309 CloneRequestedRef::Tag { name, peel } => {
1310 if !peel.is_zero() {
1311 refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", peel)
1312 .map_err(CloneError::Library)?;
1313 append_clone_reflog_on_store(repo, "HEAD", &zero, peel, identity, clone_message)
1314 .map_err(CloneError::Library)?;
1315 }
1316 Ok((
1317 CloneHead::Detached {
1318 tag: name.clone(),
1319 oid: *peel,
1320 },
1321 *peel,
1322 ))
1323 }
1324 CloneRequestedRef::Branch(branch) => {
1325 let branch_ref = format!("refs/heads/{branch}");
1326 let oid = if opts.bare {
1327 resolve_clone_ref(repo, &branch_ref)
1328 } else {
1329 let tracking = format!("refs/remotes/{}/{}", opts.remote_name, branch);
1330 let oid = resolve_clone_ref(repo, &tracking);
1331 if !oid.is_zero() {
1332 refs::update_direct_ref_on_store(
1333 repo.refs(),
1334 repo.git_dir(),
1335 &branch_ref,
1336 &oid,
1337 )
1338 .map_err(CloneError::Library)?;
1339 append_clone_reflog_on_store(
1340 repo,
1341 &branch_ref,
1342 &zero,
1343 &oid,
1344 identity,
1345 clone_message,
1346 )
1347 .map_err(CloneError::Library)?;
1348 }
1349 oid
1350 };
1351 write_clone_head_symref(repo, &branch_ref)?;
1352 if !oid.is_zero() {
1353 append_clone_reflog_on_store(repo, "HEAD", &zero, &oid, identity, clone_message)
1354 .map_err(CloneError::Library)?;
1355 }
1356 if !opts.bare {
1357 set_local_config(
1358 repo,
1359 &[
1360 (&format!("branch.{branch}.remote"), opts.remote_name.clone()),
1361 (
1362 &format!("branch.{branch}.merge"),
1363 format!("refs/heads/{branch}"),
1364 ),
1365 ],
1366 )?;
1367 }
1368 Ok((CloneHead::Branch(branch.clone()), oid))
1369 }
1370 CloneRequestedRef::Default => {
1371 let default = choose_clone_default_branch(
1372 repo,
1373 fetch_outcome,
1374 &opts.url,
1375 &opts.remote_name,
1376 &opts.initial_branch,
1377 opts.bare,
1378 );
1379 let branch_ref = format!("refs/heads/{default}");
1380 let oid = if opts.bare {
1381 resolve_clone_ref(repo, &branch_ref)
1382 } else {
1383 let tracking = format!("refs/remotes/{}/{default}", opts.remote_name);
1384 let oid = resolve_clone_ref(repo, &tracking);
1385 if !oid.is_zero() {
1386 refs::update_direct_ref_on_store(
1387 repo.refs(),
1388 repo.git_dir(),
1389 &branch_ref,
1390 &oid,
1391 )
1392 .map_err(CloneError::Library)?;
1393 append_clone_reflog_on_store(
1394 repo,
1395 &branch_ref,
1396 &zero,
1397 &oid,
1398 identity,
1399 clone_message,
1400 )
1401 .map_err(CloneError::Library)?;
1402 }
1403 oid
1404 };
1405 write_clone_head_symref(repo, &branch_ref)?;
1406 if !oid.is_zero() {
1407 append_clone_reflog_on_store(repo, "HEAD", &zero, &oid, identity, clone_message)
1408 .map_err(CloneError::Library)?;
1409 }
1410 if !opts.bare {
1411 set_local_config(
1412 repo,
1413 &[
1414 (
1415 &format!("branch.{default}.remote"),
1416 opts.remote_name.clone(),
1417 ),
1418 (
1419 &format!("branch.{default}.merge"),
1420 format!("refs/heads/{default}"),
1421 ),
1422 ],
1423 )?;
1424 }
1425 Ok((CloneHead::Branch(default), oid))
1426 }
1427 }
1428}
1429
1430fn advertised_default_branch_from_remote(
1431 remote: &Remote,
1432 repo: &Repository,
1433 http_factory: Option<&dyn HttpClientFactory>,
1434 url: &str,
1435 initial_branch: &str,
1436) -> std::result::Result<String, CloneError> {
1437 let refs = remote
1438 .list_refs(
1439 Some(repo),
1440 &ListRefsOptions {
1441 symrefs: true,
1442 ..Default::default()
1443 },
1444 http_factory,
1445 )
1446 .map_err(CloneError::Remote)?;
1447 for entry in &refs {
1448 if entry.name == "HEAD" {
1449 if let Some(target) = &entry.symref_target {
1450 let name = target
1451 .strip_prefix("refs/heads/")
1452 .unwrap_or(target.as_str());
1453 if !name.is_empty() {
1454 return Ok(name.to_owned());
1455 }
1456 }
1457 }
1458 }
1459 if let Some(name) = pick_default_branch_from_remote_refs(&refs) {
1460 return Ok(name);
1461 }
1462 if let Some(name) = default_branch_from_local_remote(url, repo) {
1463 return Ok(name);
1464 }
1465 Ok(initial_branch.to_owned())
1466}
1467
1468fn pick_default_branch_from_remote_refs(refs: &[crate::remote::RemoteRef]) -> Option<String> {
1469 for candidate in ["main", "master"] {
1470 let full = format!("refs/heads/{candidate}");
1471 if refs.iter().any(|r| r.name == full) {
1472 return Some(candidate.to_owned());
1473 }
1474 }
1475 refs.iter()
1476 .find_map(|r| r.name.strip_prefix("refs/heads/").map(str::to_owned))
1477}
1478
1479fn stored_clone_remote_url(url: &str) -> String {
1480 if should_store_absolute_local_clone_url(url) {
1481 absolute_local_clone_source_url(Path::new(url.trim()))
1482 } else {
1483 url.to_owned()
1484 }
1485}
1486
1487fn add_local_config(repo: &Repository, entries: &[(String, String)]) -> Result<()> {
1490 if entries.is_empty() {
1491 return Ok(());
1492 }
1493 let path = repo.git_dir().join("config");
1494 let content = fs::read_to_string(&path).unwrap_or_default();
1495 let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
1496 for (key, value) in entries {
1497 config.add_value(key, value)?;
1498 }
1499 config.write()?;
1500 Ok(())
1501}
1502
1503fn set_local_config(repo: &Repository, entries: &[(&str, String)]) -> Result<()> {
1504 let path = repo.git_dir().join("config");
1505 let content = fs::read_to_string(&path).unwrap_or_default();
1506 let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
1507 for (key, value) in entries {
1508 config.set(key, value)?;
1509 }
1510 config.write()?;
1511 Ok(())
1512}
1513
1514fn choose_clone_default_branch(
1515 repo: &Repository,
1516 fetch_outcome: &crate::transfer::FetchOutcome,
1517 url: &str,
1518 remote_name: &str,
1519 initial_branch: &str,
1520 bare: bool,
1521) -> String {
1522 if let Some(advertised) = fetch_outcome.default_branch.as_deref() {
1523 let name = advertised.strip_prefix("refs/heads/").unwrap_or(advertised);
1524 if !name.is_empty() {
1525 return name.to_owned();
1526 }
1527 }
1528
1529 let mut candidates: Vec<String> = Vec::new();
1530 let mut push = |name: Option<String>| {
1531 if let Some(name) = name {
1532 if !name.is_empty() && !candidates.iter().any(|c| c == &name) {
1533 candidates.push(name);
1534 }
1535 }
1536 };
1537 if bare {
1538 push(pick_default_branch_bare(repo));
1539 } else {
1540 push(pick_default_branch(repo, remote_name));
1541 }
1542 push(default_branch_from_local_remote(url, repo));
1543 push(Some(initial_branch.to_owned()));
1544
1545 for name in &candidates {
1546 let resolved = if bare {
1547 format!("refs/heads/{name}")
1548 } else {
1549 format!("refs/remotes/{remote_name}/{name}")
1550 };
1551 if refs::resolve_ref_in(repo, &resolved).is_ok() {
1552 return name.clone();
1553 }
1554 }
1555 candidates
1556 .into_iter()
1557 .next()
1558 .unwrap_or_else(|| initial_branch.to_owned())
1559}
1560
1561fn pick_default_branch_bare(repo: &Repository) -> Option<String> {
1562 for candidate in ["main", "master"] {
1563 if refs::resolve_ref_in(repo, &format!("refs/heads/{candidate}")).is_ok() {
1564 return Some(candidate.to_owned());
1565 }
1566 }
1567 refs::list_refs_in(repo, "refs/heads/")
1568 .ok()?
1569 .into_iter()
1570 .find_map(|(name, _)| name.strip_prefix("refs/heads/").map(str::to_owned))
1571}
1572
1573fn open_clone_repository(
1574 git_dir: &Path,
1575 dest: &Path,
1576 bare: bool,
1577 environment: &Environment,
1578 promisor_fetcher: Option<Arc<dyn PromisorFetcher>>,
1579) -> Result<Repository> {
1580 if bare {
1581 Repository::open(
1582 git_dir,
1583 &RepositoryOptions::with_environment(environment.clone()),
1584 )
1585 } else {
1586 let mut open_opts = RepositoryOptions::with_environment(environment.clone())
1587 .with_work_tree(dest.to_path_buf());
1588 if let Some(fetcher) = promisor_fetcher {
1589 open_opts = open_opts.with_promisor_fetcher(Some(fetcher));
1590 }
1591 Repository::open(git_dir, &open_opts)
1592 }
1593}
1594
1595fn ensure_local_clone_source_readable(
1598 url: &str,
1599 environment: &Environment,
1600) -> std::result::Result<(), CloneError> {
1601 let url = url.trim();
1602 if !is_local_path_remote_url(url) {
1603 return Ok(());
1604 }
1605 if !url.starts_with("file://") && !url_is_local_not_ssh(url) {
1606 return Ok(());
1607 }
1608 let discovery_cwd = environment.discovery_cwd();
1609 let source_path =
1610 resolve_local_clone_source_path(url, &discovery_cwd).map_err(CloneError::Library)?;
1611 if crate::bundle_remote::is_bundle_path(&source_path) {
1612 return Ok(());
1613 }
1614 let probe_git = discovery_cwd.join(".git");
1615 let remote_git = resolve_local_remote_git_dir(url, &probe_git, Some(&discovery_cwd));
1616 crate::transfer::ensure_local_remote_repository(&remote_git).map_err(CloneError::Library)
1617}
1618
1619fn local_clone_source_hash_algo(url: &str, discovery_cwd: &Path) -> Option<HashAlgo> {
1621 if !url_is_local_not_ssh(url.trim()) {
1622 return None;
1623 }
1624 let probe_git = discovery_cwd.join(".git");
1625 let remote_git = resolve_local_remote_git_dir(url.trim(), &probe_git, Some(discovery_cwd));
1626 Some(crate::transfer::open_odb(&remote_git).hash_algo())
1627}
1628
1629fn default_branch_from_local_remote(url: &str, repo: &Repository) -> Option<String> {
1630 if !url_is_local_not_ssh(url.trim()) {
1631 return None;
1632 }
1633 let remote_git = resolve_local_remote_git_dir(url.trim(), repo.git_dir(), repo.work_tree());
1634 crate::gc::remote_default_branch_local(&remote_git)
1635 .ok()
1636 .flatten()
1637}
1638
1639fn pick_default_branch(repo: &Repository, remote_name: &str) -> Option<String> {
1640 for candidate in ["main", "master"] {
1641 if refs::resolve_ref_in(repo, &format!("refs/remotes/{remote_name}/{candidate}")).is_ok() {
1642 return Some(candidate.to_owned());
1643 }
1644 }
1645 refs::list_refs_in(repo, &format!("refs/remotes/{remote_name}/"))
1646 .ok()?
1647 .into_iter()
1648 .find_map(|(name, _)| {
1649 name.strip_prefix(&format!("refs/remotes/{remote_name}/"))
1650 .filter(|b| *b != "HEAD")
1651 .map(str::to_owned)
1652 })
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657 use super::*;
1658 use crate::progress::NeverCancel;
1659 use std::fs;
1660
1661 #[test]
1662 fn dest_preexisted_empty_missing_dir() {
1663 let dir = tempfile::tempdir().expect("tempdir");
1664 let missing = dir.path().join("nope");
1665 assert!(!dest_preexisted_empty(&missing));
1666 }
1667
1668 #[test]
1669 fn dest_preexisted_empty_empty_dir() {
1670 let dir = tempfile::tempdir().expect("tempdir");
1671 assert!(dest_preexisted_empty(dir.path()));
1672 }
1673
1674 #[test]
1675 fn dest_preexisted_empty_nonempty_dir() {
1676 let dir = tempfile::tempdir().expect("tempdir");
1677 fs::write(dir.path().join("file"), b"x").expect("write");
1678 assert!(!dest_preexisted_empty(dir.path()));
1679 }
1680
1681 #[test]
1682 fn dest_not_empty_skips_failed_clone_cleanup() {
1683 assert!(!CloneError::DestNotEmpty("x".into()).should_cleanup_failed_clone());
1684 assert!(
1685 !CloneError::Remote(RemoteError::InvalidRemoteName("bad".into()))
1686 .should_cleanup_failed_clone()
1687 );
1688 assert!(CloneError::Library(Error::Message("x".into())).should_cleanup_failed_clone());
1689 }
1690
1691 #[cfg_attr(
1692 not(feature = "full-tests"),
1693 ignore = "starts system git or is heavy; run with --features full-tests"
1694 )]
1695 #[test]
1696 fn outcome_reports_transfer_stats() {
1697 use std::process::Command;
1698
1699 let upstream = tempfile::tempdir().expect("upstream");
1700 let upstream_wt = upstream.path().join("src");
1701 std::fs::create_dir_all(&upstream_wt).expect("mkdir");
1702 let git = |args: &[&str]| {
1703 let out = Command::new("git")
1704 .args(args)
1705 .current_dir(&upstream_wt)
1706 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1707 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1708 .output()
1709 .expect("git");
1710 assert!(
1711 out.status.success(),
1712 "git {:?}: {}",
1713 args,
1714 String::from_utf8_lossy(&out.stderr)
1715 );
1716 };
1717 git(&["init", "-b", "main"]);
1718 git(&["config", "user.email", "t@example.com"]);
1719 git(&["config", "user.name", "Test"]);
1720 std::fs::write(upstream_wt.join("README.md"), b"hello\n").expect("write");
1721 git(&["add", "README.md"]);
1722 git(&["commit", "-m", "init"]);
1723 git(&["tag", "v1"]);
1724 git(&["branch", "feature"]);
1725 std::fs::write(upstream_wt.join("extra.txt"), b"x\n").expect("write");
1726 git(&["add", "extra.txt"]);
1727 git(&["commit", "-m", "on feature"]);
1728 git(&["checkout", "main"]);
1729
1730 let dest = tempfile::tempdir().expect("dest");
1731 let outcome = clone(
1732 CloneOptions::new(upstream_wt.to_string_lossy(), dest.path().to_path_buf()),
1733 &mut crate::fetch::NoProgress,
1734 &NeverCancel,
1735 None,
1736 )
1737 .expect("clone");
1738
1739 let count_out = Command::new("git")
1740 .args(["count-objects", "-v"])
1741 .current_dir(dest.path())
1742 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1743 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1744 .output()
1745 .expect("count-objects");
1746 assert!(
1747 count_out.status.success(),
1748 "{}",
1749 String::from_utf8_lossy(&count_out.stderr)
1750 );
1751 let count_text = String::from_utf8_lossy(&count_out.stdout);
1752 let mut in_pack = None::<u64>;
1753 let mut loose_count = None::<u64>;
1754 for line in count_text.lines() {
1755 if let Some(v) = line.strip_prefix("in-pack: ") {
1756 in_pack = Some(v.trim().parse().expect("in-pack"));
1757 } else if let Some(v) = line.strip_prefix("count: ") {
1758 loose_count = Some(v.trim().parse().expect("count"));
1759 }
1760 }
1761 let git_objects = in_pack.expect("in-pack line") + loose_count.expect("count line");
1762 let git_dir = dest.path().join(".git");
1763 let objects_dir = git_dir.join("objects");
1764 let on_disk_bytes =
1765 |path: &Path| -> u64 { std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) };
1766 let mut git_bytes = 0u64;
1767 let pack_dir = objects_dir.join("pack");
1768 if pack_dir.is_dir() {
1769 for entry in std::fs::read_dir(&pack_dir).expect("pack dir").flatten() {
1770 let path = entry.path();
1771 if path.extension().is_some_and(|x| x == "pack") {
1772 git_bytes += on_disk_bytes(&path);
1773 }
1774 }
1775 }
1776 for shard in std::fs::read_dir(&objects_dir).expect("objects").flatten() {
1777 let name = shard.file_name().to_string_lossy().into_owned();
1778 if name.len() != 2 || !name.chars().all(|c| c.is_ascii_hexdigit()) {
1779 continue;
1780 }
1781 for obj in std::fs::read_dir(shard.path()).expect("shard").flatten() {
1782 git_bytes += on_disk_bytes(&obj.path());
1783 }
1784 }
1785 assert_eq!(
1786 outcome.transfer.objects, git_objects,
1787 "object count must match git count-objects -v (in-pack + loose)"
1788 );
1789 assert_eq!(
1790 outcome.transfer.bytes, git_bytes,
1791 "transfer bytes must match on-disk object store payload size"
1792 );
1793 assert!(outcome.branches >= 2, "expected at least main and feature");
1794 assert!(outcome.tags >= 1, "expected tag v1");
1795 }
1796
1797 #[cfg_attr(
1798 not(feature = "full-tests"),
1799 ignore = "starts system git or is heavy; run with --features full-tests"
1800 )]
1801 #[test]
1802 fn clone_unborn_advertised_trunk_not_populated_feature() {
1803 use std::process::Command;
1804
1805 let upstream = tempfile::tempdir().expect("upstream");
1806 let bare = upstream.path().join("origin.git");
1807 let git = |args: &[&str], cwd: &std::path::Path| {
1808 let out = Command::new("git")
1809 .args(args)
1810 .current_dir(cwd)
1811 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1812 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1813 .output()
1814 .expect("git");
1815 assert!(
1816 out.status.success(),
1817 "git {:?}: {}",
1818 args,
1819 String::from_utf8_lossy(&out.stderr)
1820 );
1821 };
1822 git(
1823 &[
1824 "init",
1825 "-q",
1826 "--bare",
1827 "-b",
1828 "trunk",
1829 bare.to_str().unwrap(),
1830 ],
1831 upstream.path(),
1832 );
1833 git(&["symbolic-ref", "HEAD", "refs/heads/trunk"], &bare);
1834 let wt = upstream.path().join("wt");
1835 fs::create_dir_all(&wt).expect("mkdir");
1836 git(&["init", "-q"], &wt);
1837 git(&["config", "user.email", "t@example.com"], &wt);
1838 git(&["config", "user.name", "Test"], &wt);
1839 fs::write(wt.join("f"), b"x\n").expect("write");
1840 git(&["add", "f"], &wt);
1841 git(&["commit", "-m", "c"], &wt);
1842 git(&["branch", "feature"], &wt);
1843 git(&["push", bare.to_str().unwrap(), "feature"], &wt);
1844
1845 let dest = tempfile::tempdir().expect("dest");
1846 let outcome = clone(
1847 CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
1848 &mut crate::fetch::NoProgress,
1849 &NeverCancel,
1850 None,
1851 )
1852 .expect("clone");
1853
1854 assert_eq!(outcome.head.branch_name(), Some("trunk"));
1855 assert!(outcome.checkout_oid.is_zero());
1856 assert_eq!(outcome.checkout, CloneCheckout::UnbornDefaultBranch);
1857 assert!(
1858 outcome
1859 .branches_with_commits
1860 .contains(&"feature".to_owned()),
1861 "{:?}",
1862 outcome.branches_with_commits
1863 );
1864 assert!(!dest.path().join(".git/refs/heads/trunk").exists());
1865 assert!(!dest.path().join(".git/refs/heads/feature").exists());
1866 let head = fs::read_to_string(dest.path().join(".git/HEAD")).expect("HEAD");
1867 assert_eq!(head.trim(), "ref: refs/heads/trunk");
1868 }
1869
1870 #[cfg_attr(
1871 not(feature = "full-tests"),
1872 ignore = "starts system git or is heavy; run with --features full-tests"
1873 )]
1874 #[test]
1875 fn clone_empty_bare_remote_leaves_unborn_default_branch() {
1876 use std::process::Command;
1877
1878 let upstream = tempfile::tempdir().expect("upstream");
1879 let bare = upstream.path().join("origin.git");
1880 let out = Command::new("git")
1881 .args(["init", "-q", "--bare", "-b", "main"])
1882 .arg(&bare)
1883 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1884 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1885 .output()
1886 .expect("git init");
1887 assert!(
1888 out.status.success(),
1889 "{}",
1890 String::from_utf8_lossy(&out.stderr)
1891 );
1892
1893 let dest = tempfile::tempdir().expect("dest");
1894 let outcome = clone(
1895 CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
1896 &mut crate::fetch::NoProgress,
1897 &NeverCancel,
1898 None,
1899 )
1900 .expect("clone empty bare");
1901
1902 assert_eq!(outcome.head.branch_name(), Some("main"));
1903 assert!(outcome.checkout_oid.is_zero());
1904 assert_eq!(outcome.checkout, CloneCheckout::EmptyRemote);
1905 assert!(outcome.branches_with_commits.is_empty());
1906 assert!(!dest.path().join(".git").join("refs/heads/main").exists());
1907 let head = fs::read_to_string(dest.path().join(".git/HEAD")).expect("HEAD");
1908 assert_eq!(head.trim(), "ref: refs/heads/main");
1909 }
1910
1911 #[cfg_attr(
1912 not(feature = "full-tests"),
1913 ignore = "starts system git or is heavy; run with --features full-tests"
1914 )]
1915 #[test]
1916 fn classify_unborn_default_when_master_advertised_but_main_has_commits() {
1917 use std::process::Command;
1918
1919 let upstream = tempfile::tempdir().expect("upstream");
1920 let bare = upstream.path().join("remote.git");
1921 let git = |args: &[&str], cwd: &std::path::Path| {
1922 let out = Command::new("git")
1923 .args(args)
1924 .current_dir(cwd)
1925 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1926 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1927 .output()
1928 .expect("git");
1929 assert!(
1930 out.status.success(),
1931 "git {:?}: {}",
1932 args,
1933 String::from_utf8_lossy(&out.stderr)
1934 );
1935 };
1936 git(
1937 &["init", "-q", "--bare", bare.to_str().unwrap()],
1938 upstream.path(),
1939 );
1940
1941 let src = upstream.path().join("src");
1942 fs::create_dir_all(&src).expect("mkdir");
1943 git(&["init", "-q", "-b", "main"], &src);
1944 git(&["config", "user.email", "a@b.c"], &src);
1945 git(&["config", "user.name", "a"], &src);
1946 fs::write(src.join("f"), b"x\n").expect("write");
1947 git(&["add", "f"], &src);
1948 git(&["commit", "-m", "c1"], &src);
1949 git(&["remote", "add", "origin", bare.to_str().unwrap()], &src);
1950 git(&["push", "-u", "origin", "main"], &src);
1951
1952 let dest = tempfile::tempdir().expect("dest");
1953 let outcome = clone(
1954 CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
1955 &mut crate::fetch::NoProgress,
1956 &NeverCancel,
1957 None,
1958 )
1959 .expect("clone");
1960
1961 assert!(outcome.checkout_oid.is_zero());
1962 assert_eq!(outcome.checkout, CloneCheckout::UnbornDefaultBranch);
1963 assert!(
1964 outcome.branches_with_commits.contains(&"main".to_owned()),
1965 "{:?}",
1966 outcome.branches_with_commits
1967 );
1968 assert!(outcome.transfer.objects > 0);
1969 }
1970
1971 #[test]
1972 fn derive_clone_dir_windows_style_paths() {
1973 assert_eq!(
1974 derive_clone_dir(r"..\..\origin.git", false).expect("name"),
1975 "origin"
1976 );
1977 assert_eq!(
1978 derive_clone_dir(r"C:\scratch\a\origin.git", false).expect("name"),
1979 "origin"
1980 );
1981 assert_eq!(
1982 derive_clone_dir(r"..\..\origin.git", true).expect("name"),
1983 "origin.git"
1984 );
1985 }
1986
1987 fn git_cmd(args: &[&str], cwd: &std::path::Path) {
1988 use std::process::Command;
1989 let out = Command::new("git")
1990 .args(args)
1991 .current_dir(cwd)
1992 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1993 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1994 .output()
1995 .expect("git");
1996 assert!(
1997 out.status.success(),
1998 "git {:?}: {}",
1999 args,
2000 String::from_utf8_lossy(&out.stderr)
2001 );
2002 }
2003
2004 fn git_out(args: &[&str], cwd: &std::path::Path) -> String {
2005 use std::process::Command;
2006 let out = Command::new("git")
2007 .args(args)
2008 .current_dir(cwd)
2009 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2010 .env("GIT_CONFIG_SYSTEM", "/dev/null")
2011 .output()
2012 .expect("git");
2013 assert!(out.status.success(), "git {:?} failed", args);
2014 String::from_utf8_lossy(&out.stdout).trim().to_string()
2015 }
2016
2017 fn shallow_file_oids(path: &std::path::Path) -> Vec<String> {
2018 let shallow = path.join(".git/shallow");
2019 if !shallow.is_file() {
2020 return Vec::new();
2021 }
2022 std::fs::read_to_string(shallow)
2023 .expect("read shallow")
2024 .lines()
2025 .map(str::trim)
2026 .filter(|l| !l.is_empty())
2027 .map(str::to_owned)
2028 .collect()
2029 }
2030
2031 fn git_fsck_strict(work_tree: &std::path::Path) {
2032 use std::process::Command;
2033 let out = Command::new("git")
2034 .args(["fsck", "--strict"])
2035 .current_dir(work_tree)
2036 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2037 .env("GIT_CONFIG_SYSTEM", "/dev/null")
2038 .output()
2039 .expect("git fsck");
2040 assert!(
2041 out.status.success(),
2042 "git fsck --strict: {}",
2043 String::from_utf8_lossy(&out.stderr)
2044 );
2045 }
2046
2047 fn build_feature_upstream(bare: &std::path::Path) {
2048 let wt = bare.with_extension("wt");
2049 fs::create_dir_all(&wt).expect("mkdir");
2050 git_cmd(&["init", "-q", "-b", "main", "."], &wt);
2051 git_cmd(&["config", "user.email", "t@example.com"], &wt);
2052 git_cmd(&["config", "user.name", "Test"], &wt);
2053 for i in 1..=5 {
2054 fs::write(wt.join("f"), format!("{i}\n")).expect("write");
2055 git_cmd(&["add", "f"], &wt);
2056 git_cmd(&["commit", "-q", "-m", &format!("c{i}")], &wt);
2057 }
2058 git_cmd(&["branch", "feature", "HEAD~2"], &wt);
2059 git_cmd(&["tag", "v1", "HEAD~1"], &wt);
2060 git_cmd(&["clone", "-q", "--bare", ".", bare.to_str().unwrap()], &wt);
2061 }
2062
2063 #[cfg_attr(
2064 not(feature = "full-tests"),
2065 ignore = "starts system git or is heavy; run with --features full-tests"
2066 )]
2067 #[test]
2068 fn clone_depth_n_writes_shallow_like_git() {
2069 let tmp = tempfile::tempdir().expect("tempdir");
2070 let bare = tmp.path().join("remote.git");
2071 build_feature_upstream(&bare);
2072 let url = format!("file://{}", bare.to_string_lossy());
2073
2074 let grit_dest = tmp.path().join("grit");
2075 clone(
2076 CloneOptions {
2077 depth: Some(3),
2078 ..CloneOptions::new(url.clone(), grit_dest.clone())
2079 },
2080 &mut crate::fetch::NoProgress,
2081 &NeverCancel,
2082 None,
2083 )
2084 .expect("grit shallow clone");
2085
2086 let git_dest = tmp.path().join("git");
2087 git_cmd(
2088 &[
2089 "clone",
2090 "-q",
2091 "--depth",
2092 "3",
2093 url.as_str(),
2094 git_dest.to_str().unwrap(),
2095 ],
2096 tmp.path(),
2097 );
2098
2099 assert_eq!(
2100 shallow_file_oids(&grit_dest),
2101 shallow_file_oids(&git_dest),
2102 "shallow boundary set must match git"
2103 );
2104 assert_eq!(
2105 git_out(&["rev-list", "--count", "HEAD"], &grit_dest),
2106 git_out(&["rev-list", "--count", "HEAD"], &git_dest)
2107 );
2108 git_fsck_strict(&grit_dest);
2109 assert!(git_out(&["log", "--oneline"], &grit_dest).lines().count() >= 1);
2110 }
2111
2112 #[cfg_attr(
2113 not(feature = "full-tests"),
2114 ignore = "starts system git or is heavy; run with --features full-tests"
2115 )]
2116 #[test]
2117 fn clone_branch_checks_out_named_branch() {
2118 for depth in [None, Some(3u32)] {
2119 let tmp = tempfile::tempdir().expect("tempdir");
2120 let bare = tmp.path().join("remote.git");
2121 build_feature_upstream(&bare);
2122 let url = format!("file://{}", bare.to_string_lossy());
2123
2124 let grit_dest = tmp.path().join("grit");
2125 clone(
2126 CloneOptions {
2127 depth,
2128 branch: Some("feature".to_owned()),
2129 ..CloneOptions::new(url.clone(), grit_dest.clone())
2130 },
2131 &mut crate::fetch::NoProgress,
2132 &NeverCancel,
2133 None,
2134 )
2135 .expect("grit clone -b feature");
2136
2137 let git_dest = tmp.path().join("git");
2138 match depth {
2139 None => git_cmd(
2140 &[
2141 "clone",
2142 "-q",
2143 "-b",
2144 "feature",
2145 url.as_str(),
2146 git_dest.to_str().unwrap(),
2147 ],
2148 tmp.path(),
2149 ),
2150 Some(d) => {
2151 let depth_s = d.to_string();
2152 git_cmd(
2153 &[
2154 "clone",
2155 "-q",
2156 "--depth",
2157 depth_s.as_str(),
2158 "-b",
2159 "feature",
2160 url.as_str(),
2161 git_dest.to_str().unwrap(),
2162 ],
2163 tmp.path(),
2164 );
2165 }
2166 }
2167
2168 assert_eq!(
2169 fs::read_to_string(grit_dest.join(".git/HEAD"))
2170 .expect("HEAD")
2171 .trim(),
2172 fs::read_to_string(git_dest.join(".git/HEAD"))
2173 .expect("HEAD")
2174 .trim()
2175 );
2176 assert_eq!(
2177 git_out(&["config", "--get", "branch.feature.remote"], &grit_dest),
2178 git_out(&["config", "--get", "branch.feature.remote"], &git_dest)
2179 );
2180 assert_eq!(
2181 git_out(&["config", "--get", "branch.feature.merge"], &grit_dest),
2182 git_out(&["config", "--get", "branch.feature.merge"], &git_dest)
2183 );
2184 assert_eq!(
2185 git_out(&["config", "--get", "remote.origin.fetch"], &grit_dest),
2186 git_out(&["config", "--get", "remote.origin.fetch"], &git_dest)
2187 );
2188 git_fsck_strict(&grit_dest);
2189 }
2190 }
2191
2192 #[cfg_attr(
2193 not(feature = "full-tests"),
2194 ignore = "starts system git or is heavy; run with --features full-tests"
2195 )]
2196 #[test]
2197 fn clone_tag_detaches_head() {
2198 let tmp = tempfile::tempdir().expect("tempdir");
2199 let bare = tmp.path().join("remote.git");
2200 build_feature_upstream(&bare);
2201 let url = format!("file://{}", bare.to_string_lossy());
2202 let tag_commit = git_out(&["rev-parse", "v1^{}"], &bare);
2203
2204 let grit_dest = tmp.path().join("grit");
2205 let outcome = clone(
2206 CloneOptions {
2207 branch: Some("v1".to_owned()),
2208 ..CloneOptions::new(url, grit_dest.clone())
2209 },
2210 &mut crate::fetch::NoProgress,
2211 &NeverCancel,
2212 None,
2213 )
2214 .expect("clone tag");
2215
2216 assert!(outcome.head.is_detached());
2217 assert_eq!(outcome.head.tag_name(), Some("v1"));
2218 assert_eq!(git_out(&["rev-parse", "HEAD"], &grit_dest), tag_commit);
2219 let head = fs::read_to_string(grit_dest.join(".git/HEAD")).expect("HEAD");
2220 assert!(!head.starts_with("ref: "));
2221 let config = fs::read_to_string(grit_dest.join(".git/config")).expect("config");
2222 assert!(!config.contains("[branch "));
2223 git_fsck_strict(&grit_dest);
2224 }
2225
2226 #[cfg_attr(
2227 not(feature = "full-tests"),
2228 ignore = "starts system git or is heavy; run with --features full-tests"
2229 )]
2230 #[test]
2231 fn clone_missing_branch_is_typed_error() {
2232 let tmp = tempfile::tempdir().expect("tempdir");
2233 let bare = tmp.path().join("remote.git");
2234 build_feature_upstream(&bare);
2235 let url = format!("file://{}", bare.to_string_lossy());
2236 let dest = tmp.path().join("dest");
2237 let err = clone(
2238 CloneOptions {
2239 branch: Some("no-such-ref".to_owned()),
2240 ..CloneOptions::new(url, dest.clone())
2241 },
2242 &mut crate::fetch::NoProgress,
2243 &NeverCancel,
2244 None,
2245 )
2246 .expect_err("missing ref");
2247 assert!(matches!(err, CloneError::RemoteRefNotFound(_)));
2248 assert!(err.should_cleanup_failed_clone());
2249 cleanup_failed_clone(&dest, false);
2250 assert!(!dest.exists());
2251 }
2252
2253 #[test]
2254 fn clone_rejects_nonempty_destination_without_git_dir() {
2255 let upstream = tempfile::tempdir().expect("upstream");
2256 let dest = tempfile::tempdir().expect("dest");
2257 fs::write(dest.path().join("notes.txt"), b"keep").expect("write");
2258 let err = clone(
2259 CloneOptions::new(upstream.path().to_string_lossy(), dest.path().to_path_buf()),
2260 &mut crate::fetch::NoProgress,
2261 &NeverCancel,
2262 None,
2263 )
2264 .expect_err("non-empty dest");
2265 assert!(matches!(err, CloneError::DestNotEmpty(_)));
2266 assert!(dest.path().join("notes.txt").is_file());
2267 assert!(!dest.path().join(".git").exists());
2268 }
2269}