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