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