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}
54
55impl CloneError {
56 #[must_use]
61 pub fn should_cleanup_failed_clone(&self) -> bool {
62 match self {
63 Self::DestNotEmpty(_) | Self::InvalidCloneDepth => false,
64 Self::Remote(RemoteError::InvalidRemoteName(_)) => false,
65 Self::RemoteRefNotFound(_) => true,
66 Self::Library(_) | Self::Remote(_) => true,
67 }
68 }
69
70 #[must_use]
72 pub fn is_remote_ref_not_found(&self) -> bool {
73 matches!(self, Self::RemoteRefNotFound(_))
74 }
75
76 #[must_use]
79 pub fn is_remote_unreadable(&self) -> bool {
80 match self {
81 Self::Library(e) => e.is_remote_unreadable(),
82 Self::Remote(e) => e.is_remote_unreadable(),
83 Self::DestNotEmpty(_) | Self::RemoteRefNotFound(_) | Self::InvalidCloneDepth => false,
84 }
85 }
86
87 #[must_use]
91 pub fn is_cancelled(&self) -> bool {
92 match self {
93 Self::Library(e) => e.is_cancelled(),
94 Self::Remote(e) => e.is_cancelled(),
95 Self::DestNotEmpty(_) | Self::RemoteRefNotFound(_) | Self::InvalidCloneDepth => false,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CloneTags {
103 All,
105 Following,
107 None,
109}
110
111impl CloneTags {
112 #[must_use]
114 pub const fn tag_mode(self) -> TagMode {
115 match self {
116 Self::All => TagMode::All,
117 Self::Following => TagMode::Following,
118 Self::None => TagMode::None,
119 }
120 }
121}
122
123pub struct CloneOptions {
125 pub url: String,
127 pub dest: PathBuf,
129 pub remote_name: String,
131 pub environment: Environment,
133 pub initial_branch: String,
135 pub thread_policy: crate::parallel::ThreadPolicy,
137 pub depth: Option<u32>,
140 pub filter: Option<ObjectFilterSpec>,
142 pub branch: Option<String>,
144 pub single_branch: Option<bool>,
148 pub tags: Option<CloneTags>,
150 pub promisor_fetcher: Option<Arc<dyn PromisorFetcher>>,
152 pub bare: bool,
154 pub checkout: bool,
156 pub bundle_cache: Option<PathBuf>,
160}
161
162impl CloneOptions {
163 #[must_use]
165 pub fn new(url: impl Into<String>, dest: PathBuf) -> Self {
166 Self {
167 url: url.into(),
168 dest,
169 remote_name: DEFAULT_REMOTE.to_owned(),
170 environment: Environment::capture_process(), initial_branch: "main".to_owned(),
172 thread_policy: crate::parallel::ThreadPolicy::default(),
173 depth: None,
174 filter: None,
175 branch: None,
176 single_branch: None,
177 tags: None,
178 promisor_fetcher: None,
179 bare: false,
180 checkout: true,
181 bundle_cache: None,
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum CloneHead {
189 Branch(String),
191 Detached {
193 tag: String,
195 oid: ObjectId,
197 },
198}
199
200impl CloneHead {
201 #[must_use]
203 pub fn branch_name(&self) -> Option<&str> {
204 match self {
205 Self::Branch(name) => Some(name.as_str()),
206 Self::Detached { .. } => None,
207 }
208 }
209
210 #[must_use]
212 pub fn tag_name(&self) -> Option<&str> {
213 match self {
214 Self::Detached { tag, .. } => Some(tag.as_str()),
215 Self::Branch(_) => None,
216 }
217 }
218
219 #[must_use]
221 pub fn is_detached(&self) -> bool {
222 matches!(self, Self::Detached { .. })
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum CloneCheckout {
229 CheckedOut(ObjectId),
231 EmptyRemote,
233 UnbornDefaultBranch,
235 Skipped,
237}
238
239#[derive(Debug)]
241pub struct CloneOutcome {
242 pub url: String,
244 pub path: PathBuf,
246 pub head: CloneHead,
248 pub checkout_oid: ObjectId,
252 pub checkout: CloneCheckout,
254 pub branches_with_commits: Vec<String>,
257 pub repo: Repository,
259 pub transfer: TransferStats,
261 pub branches: u64,
263 pub tags: u64,
265 pub files: u64,
267 pub worktree_bytes: u64,
269 pub path_collisions: Vec<String>,
271 pub bare: bool,
273 pub bundle: crate::bundle_uri::BundleSeed,
276}
277
278pub fn derive_clone_dir(url: &str, bare: bool) -> std::result::Result<String, NoDirectoryName> {
285 git_url_basename(url, false, bare)
286}
287
288#[must_use]
290pub fn dest_preexisted_empty(path: &Path) -> bool {
291 path.is_dir()
292 && path
293 .read_dir()
294 .map(|mut entries| entries.next().is_none())
295 .unwrap_or(false)
296}
297
298struct CloneDestinationGuard;
299
300impl CloneDestinationGuard {
301 fn try_claim(dest: &Path, bare: bool) -> std::result::Result<(), CloneError> {
302 let label = dest
303 .to_str()
304 .ok_or_else(|| {
305 CloneError::Library(Error::Message("non-UTF-8 destination path".into()))
306 })?
307 .to_owned();
308 if bare {
309 Self::try_claim_bare(dest, label)
310 } else {
311 Self::try_claim_non_bare(dest, label)
312 }
313 }
314
315 fn try_claim_non_bare(dest: &Path, label: String) -> std::result::Result<(), CloneError> {
316 if dest.is_dir() && !dir_is_empty(dest) {
317 return Err(CloneError::DestNotEmpty(label));
318 }
319
320 if !dest.is_dir() {
321 if let Some(parent) = dest.parent() {
322 fs::create_dir_all(parent).map_err(|e| {
323 CloneError::Library(Error::Message(format!(
324 "could not create clone destination parent: {e}"
325 )))
326 })?;
327 }
328 match fs::create_dir(dest) {
329 Ok(()) => {}
330 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
331 Err(source) => {
332 return Err(CloneError::Library(Error::Message(format!(
333 "could not create clone destination: {source}"
334 ))));
335 }
336 }
337 }
338
339 if !dest.is_dir() {
340 return Err(CloneError::DestNotEmpty(label));
341 }
342 if !dir_is_empty(dest) {
343 return Err(CloneError::DestNotEmpty(label));
344 }
345
346 let git_dir = dest.join(".git");
347 match fs::create_dir(&git_dir) {
348 Ok(()) => Ok(()),
349 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
350 Err(CloneError::DestNotEmpty(label))
351 }
352 Err(source) => Err(CloneError::Library(Error::Message(format!(
353 "could not create '{}/.git': {source}",
354 dest.display()
355 )))),
356 }
357 }
358
359 fn try_claim_bare(dest: &Path, label: String) -> std::result::Result<(), CloneError> {
360 if dest.is_dir() && !dir_is_empty(dest) {
361 return Err(CloneError::DestNotEmpty(label));
362 }
363
364 if !dest.is_dir() {
365 if let Some(parent) = dest.parent() {
366 fs::create_dir_all(parent).map_err(|e| {
367 CloneError::Library(Error::Message(format!(
368 "could not create clone destination parent: {e}"
369 )))
370 })?;
371 }
372 match fs::create_dir(dest) {
373 Ok(()) => {}
374 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
375 Err(source) => {
376 return Err(CloneError::Library(Error::Message(format!(
377 "could not create clone destination: {source}"
378 ))));
379 }
380 }
381 }
382
383 if !dest.is_dir() {
384 return Err(CloneError::DestNotEmpty(label));
385 }
386 if !dir_is_empty(dest) {
387 return Err(CloneError::DestNotEmpty(label));
388 }
389
390 let objects = dest.join("objects");
392 match fs::create_dir(&objects) {
393 Ok(()) => Ok(()),
394 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
395 Err(CloneError::DestNotEmpty(label))
396 }
397 Err(source) => Err(CloneError::Library(Error::Message(format!(
398 "could not create '{}/objects': {source}",
399 dest.display()
400 )))),
401 }
402 }
403}
404
405fn dir_is_empty(path: &Path) -> bool {
406 path.read_dir()
407 .map(|mut entries| entries.next().is_none())
408 .unwrap_or(false)
409}
410
411pub fn cleanup_failed_clone(path: &Path, keep_toplevel: bool) {
415 if keep_toplevel {
416 let Ok(entries) = fs::read_dir(path) else {
417 return;
418 };
419 for entry in entries.flatten() {
420 let p = entry.path();
421 if p.is_dir() {
422 let _ = fs::remove_dir_all(p);
423 } else {
424 let _ = fs::remove_file(p);
425 }
426 }
427 } else {
428 let _ = fs::remove_dir_all(path);
429 }
430}
431
432pub fn clone(
438 opts: CloneOptions,
439 progress: &mut dyn Progress,
440 cancel: &dyn Cancel,
441 http_factory: Option<&dyn HttpClientFactory>,
442) -> std::result::Result<CloneOutcome, CloneError> {
443 let dir = opts
444 .dest
445 .to_str()
446 .ok_or_else(|| CloneError::Library(Error::Message("non-UTF-8 destination path".into())))?;
447 if opts.dest.is_dir()
448 && opts
449 .dest
450 .read_dir()
451 .map(|mut entries| entries.next().is_some())
452 .unwrap_or(false)
453 {
454 return Err(CloneError::DestNotEmpty(dir.to_owned()));
455 }
456 if opts.depth == Some(0) {
457 return Err(CloneError::InvalidCloneDepth);
458 }
459 if !crate::remote::is_valid_remote_name(&opts.remote_name) {
460 return Err(CloneError::Remote(RemoteError::InvalidRemoteName(
461 opts.remote_name.clone(),
462 )));
463 }
464
465 let bare = opts.bare;
466 CloneDestinationGuard::try_claim(&opts.dest, bare)?;
467 clone_after_claim(opts, progress, cancel, http_factory, bare)
468}
469
470fn clone_after_claim(
471 opts: CloneOptions,
472 progress: &mut dyn Progress,
473 cancel: &dyn Cancel,
474 http_factory: Option<&dyn HttpClientFactory>,
475 bare: bool,
476) -> std::result::Result<CloneOutcome, CloneError> {
477 let dir = opts
478 .dest
479 .to_str()
480 .ok_or_else(|| CloneError::Library(Error::Message("non-UTF-8 destination path".into())))?;
481 let discovery_cwd = opts.environment.discovery_cwd();
482 let object_format =
483 local_clone_source_hash_algo(&opts.url, &discovery_cwd).unwrap_or(HashAlgo::Sha1);
484 init_repository_with_object_format(
485 &opts.dest,
486 bare,
487 &opts.initial_branch,
488 None,
489 RefStorageFormat::Files,
490 object_format,
491 )
492 .map_err(|e| {
493 CloneError::Library(Error::Message(format!("could not initialize '{dir}': {e}")))
494 })?;
495 let git_dir = if bare {
496 opts.dest.clone()
497 } else {
498 opts.dest.join(".git")
499 };
500 let mut repo = open_clone_repository(
501 &git_dir,
502 &opts.dest,
503 bare,
504 &opts.environment,
505 opts.promisor_fetcher.clone(),
506 )
507 .map_err(CloneError::Library)?;
508
509 let origin_url = stored_clone_remote_url(&opts.url);
510 set_local_config(
511 &repo,
512 &[(
513 &format!("remote.{}.url", opts.remote_name),
514 origin_url.clone(),
515 )],
516 )?;
517
518 let config = ConfigSet::load(&opts.environment, Some(repo.git_dir()), true)
519 .map_err(CloneError::Library)?;
520 let remote = Remote::from_config(&config, &opts.remote_name)?;
521 let requested = match opts.branch.as_deref() {
522 Some(name) => resolve_requested_clone_ref(name, &remote, &repo, http_factory)?,
523 None => CloneRequestedRef::Default,
524 };
525 let fetch_refspec =
526 clone_initial_fetch_refspec(&opts, &requested, &remote, &repo, http_factory)?;
527 if !bare {
528 let mut remote_config_entries = vec![(
529 format!("remote.{}.fetch", opts.remote_name),
530 fetch_refspec.clone(),
531 )];
532 if matches!(opts.tags, Some(CloneTags::None)) {
533 remote_config_entries.push((
534 format!("remote.{}.tagOpt", opts.remote_name),
535 "--no-tags".to_owned(),
536 ));
537 }
538 set_local_config(
539 &repo,
540 &remote_config_entries
541 .iter()
542 .map(|(k, v)| (k.as_str(), v.clone()))
543 .collect::<Vec<_>>(),
544 )?;
545 }
546
547 let clone_message = format!("clone: from {}", opts.url);
548 let identity =
549 fetch_operation_identity(&opts.environment, repo.git_dir()).map_err(CloneError::Library)?;
550 let clone_reflog = CloneReflog {
551 identity: identity.clone(),
552 message: clone_message.clone(),
553 };
554 let mut fetch_opts = FetchOptions {
555 refspecs: vec![fetch_refspec],
556 tags: resolve_clone_tag_mode(&opts),
557 initial_remote_fetch: true,
558 remote_name: if bare {
559 None
560 } else {
561 Some(opts.remote_name.clone())
562 },
563 clone_reflog: Some(clone_reflog),
564 environment: opts.environment.clone(),
565 thread_policy: opts.thread_policy,
566 remote_progress: progress.remote_sideband_progress(),
567 fetch_depth: opts.depth.and_then(crate::transfer::FetchDepth::depth),
568 filter: opts.filter,
569 link_source_objects: should_store_absolute_local_clone_url(&opts.url),
570 ..Default::default()
571 };
572 if let Ok(v) = crate::protocol::try_effective_client_protocol_version_from_config(&config) {
573 fetch_opts.http_protocol_version = Some(v);
574 }
575 let bundle = match opts.bundle_cache.as_deref() {
576 Some(cache) if opts.depth.is_none() && opts.filter.is_none() => {
577 remote.seed_from_bundle_uri(&repo, cache, progress, cancel, http_factory)
578 }
579 _ => crate::bundle_uri::BundleSeed::NotOffered,
580 };
581 if cancel.is_cancelled() {
582 return Err(CloneError::Library(Error::Cancelled));
583 }
584 let mut fetch_outcome = remote.fetch(&repo, fetch_opts, progress, cancel, http_factory)?;
585 if let crate::bundle_uri::BundleSeed::Seeded(seeded) = &bundle {
586 crate::bundle_uri::remove_bundle_refs(&repo).map_err(CloneError::Library)?;
587 fetch_outcome.transfer += seeded.transfer;
588 }
589 repo = open_clone_repository(
592 &git_dir,
593 &opts.dest,
594 bare,
595 &opts.environment,
596 opts.promisor_fetcher.clone(),
597 )
598 .map_err(CloneError::Library)?;
599
600 if let Some(spec) = opts.filter {
601 PartialCloneConfig::write(repo.git_dir(), &opts.remote_name, &spec)
602 .map_err(CloneError::Library)?;
603 }
604
605 let (head, oid) = setup_clone_head(
606 &repo,
607 &opts,
608 &requested,
609 &fetch_outcome,
610 &identity,
611 &clone_message,
612 )?;
613
614 let branches_with_commits = if bare {
615 local_branch_names_with_commits(&repo)
616 } else {
617 remote_branch_names_with_commits(&repo, &opts.remote_name)
618 };
619 let mut checkout = classify_clone_checkout(
620 oid,
621 &fetch_outcome.transfer,
622 fetch_outcome.branch_count(),
623 &branches_with_commits,
624 );
625
626 let repo = repo.with_thread_policy(opts.thread_policy);
627
628 let mut files = 0;
629 let mut worktree_bytes = 0;
630 let mut path_collisions = Vec::new();
631 if opts.checkout && !bare {
632 if let CloneCheckout::CheckedOut(checkout_oid) = checkout {
633 (files, worktree_bytes, path_collisions) =
634 perform_clone_checkout(&repo, checkout_oid, progress, cancel)
635 .map_err(CloneError::Library)?;
636 }
637 } else if matches!(checkout, CloneCheckout::CheckedOut(_)) {
638 checkout = CloneCheckout::Skipped;
639 }
640
641 Ok(CloneOutcome {
642 url: opts.url.clone(),
643 path: opts.dest.clone(),
644 head,
645 checkout_oid: oid,
646 checkout,
647 branches_with_commits,
648 repo,
649 transfer: fetch_outcome.transfer,
650 branches: fetch_outcome.branch_count(),
651 tags: fetch_outcome.tag_count(),
652 files,
653 worktree_bytes,
654 path_collisions,
655 bare,
656 bundle,
657 })
658}
659
660fn perform_clone_checkout(
661 repo: &Repository,
662 commit: ObjectId,
663 progress: &mut dyn Progress,
664 cancel: &dyn Cancel,
665) -> Result<(u64, u64, Vec<String>)> {
666 let tree = crate::diff::tree_oid_of_commit_in_repo(repo, &commit)?;
667 let checkout = crate::porcelain::checkout::checkout_between_trees_with_progress(
668 repo, None, &tree, progress, cancel,
669 )?;
670 let index = repo.index_handle().load()?;
671 let ignorecase = repo
672 .config()?
673 .get_bool("core.ignorecase")
674 .and_then(|r| r.ok())
675 .unwrap_or(false);
676 let (files, worktree_bytes) =
677 crate::path_icase::icase_worktree_file_stats(&index, repo.work_tree(), ignorecase);
678 Ok((files, worktree_bytes, checkout.path_collisions))
679}
680
681fn local_branch_names_with_commits(repo: &Repository) -> Vec<String> {
682 refs::list_refs(repo.git_dir(), "refs/heads/")
683 .unwrap_or_default()
684 .into_iter()
685 .filter_map(|(name, oid)| {
686 if oid.is_zero() {
687 return None;
688 }
689 name.strip_prefix("refs/heads/").map(str::to_owned)
690 })
691 .collect()
692}
693
694fn remote_branch_names_with_commits(repo: &Repository, remote_name: &str) -> Vec<String> {
695 let prefix = format!("refs/remotes/{remote_name}/");
696 refs::list_refs(repo.git_dir(), &prefix)
697 .unwrap_or_default()
698 .into_iter()
699 .filter_map(|(name, oid)| {
700 if oid.is_zero() {
701 return None;
702 }
703 name.strip_prefix(&prefix)
704 .filter(|b| *b != "HEAD")
705 .map(str::to_owned)
706 })
707 .collect()
708}
709
710fn classify_clone_checkout(
711 checkout_oid: ObjectId,
712 transfer: &TransferStats,
713 branch_count: u64,
714 branches_with_commits: &[String],
715) -> CloneCheckout {
716 if !checkout_oid.is_zero() {
717 return CloneCheckout::CheckedOut(checkout_oid);
718 }
719 if transfer.objects == 0 && branch_count == 0 {
720 return CloneCheckout::EmptyRemote;
721 }
722 if transfer.objects > 0 || !branches_with_commits.is_empty() {
723 return CloneCheckout::UnbornDefaultBranch;
724 }
725 CloneCheckout::EmptyRemote
726}
727
728#[derive(Debug, Clone, PartialEq, Eq)]
729enum CloneRequestedRef {
730 Default,
731 Branch(String),
732 Tag { name: String, peel: ObjectId },
733}
734
735fn resolve_requested_clone_ref(
736 name: &str,
737 remote: &Remote,
738 repo: &Repository,
739 http_factory: Option<&dyn HttpClientFactory>,
740) -> std::result::Result<CloneRequestedRef, CloneError> {
741 let refs = remote
742 .list_refs(
743 Some(repo),
744 &ListRefsOptions {
745 symrefs: true,
746 peel: true,
747 ..Default::default()
748 },
749 http_factory,
750 )
751 .map_err(CloneError::Remote)?;
752 let head_ref = format!("refs/heads/{name}");
753 if refs.iter().any(|r| r.name == head_ref) {
754 return Ok(CloneRequestedRef::Branch(name.to_owned()));
755 }
756 let tag_ref = format!("refs/tags/{name}");
757 let folded = fold_peeled_refs(&refs);
758 let Some(tag) = folded.iter().find(|r| r.name == tag_ref) else {
759 return Err(CloneError::RemoteRefNotFound(name.to_owned()));
760 };
761 let peel = tag.peeled.filter(|oid| !oid.is_zero()).unwrap_or(tag.oid);
762 if peel.is_zero() {
763 return Err(CloneError::RemoteRefNotFound(name.to_owned()));
764 }
765 Ok(CloneRequestedRef::Tag {
766 name: name.to_owned(),
767 peel,
768 })
769}
770
771fn clone_initial_fetch_refspec(
776 opts: &CloneOptions,
777 requested: &CloneRequestedRef,
778 remote: &Remote,
779 repo: &Repository,
780 http_factory: Option<&dyn HttpClientFactory>,
781) -> std::result::Result<String, CloneError> {
782 if clone_effective_single_branch(opts) {
783 return match requested {
784 CloneRequestedRef::Tag { name, .. } => {
785 Ok(format!("+refs/tags/{name}:refs/tags/{name}"))
786 }
787 CloneRequestedRef::Branch(branch) => {
788 if opts.bare {
789 Ok(format!("+refs/heads/{branch}:refs/heads/{branch}"))
790 } else {
791 Ok(format!(
792 "+refs/heads/{branch}:refs/remotes/{}/{}",
793 opts.remote_name, branch
794 ))
795 }
796 }
797 CloneRequestedRef::Default => {
798 let branch = advertised_default_branch_from_remote(
799 remote,
800 repo,
801 http_factory,
802 &opts.url,
803 &opts.initial_branch,
804 )?;
805 if opts.bare {
806 Ok(format!("+refs/heads/{branch}:refs/heads/{branch}"))
807 } else {
808 Ok(format!(
809 "+refs/heads/{branch}:refs/remotes/{}/{}",
810 opts.remote_name, branch
811 ))
812 }
813 }
814 };
815 }
816 if opts.bare {
817 return Ok("+refs/heads/*:refs/heads/*".to_owned());
818 }
819 Ok(format!("+refs/heads/*:refs/remotes/{}/*", opts.remote_name))
820}
821
822#[must_use]
824pub fn clone_effective_single_branch(opts: &CloneOptions) -> bool {
825 match opts.single_branch {
826 Some(true) => true,
827 Some(false) => false,
828 None => opts.depth.is_some_and(|d| d > 0),
829 }
830}
831
832fn resolve_clone_tag_mode(opts: &CloneOptions) -> TagMode {
833 opts.tags.map(CloneTags::tag_mode).unwrap_or_else(|| {
834 if opts.depth.is_some() {
835 TagMode::Following
836 } else {
837 TagMode::All
838 }
839 })
840}
841
842fn setup_clone_head(
843 repo: &Repository,
844 opts: &CloneOptions,
845 requested: &CloneRequestedRef,
846 fetch_outcome: &crate::transfer::FetchOutcome,
847 identity: &crate::ident::Signature,
848 clone_message: &str,
849) -> std::result::Result<(CloneHead, ObjectId), CloneError> {
850 let zero = ObjectId::zero();
851 match requested {
852 CloneRequestedRef::Tag { name, peel } => {
853 if !peel.is_zero() {
854 refs::write_ref(repo.git_dir(), "HEAD", peel).map_err(CloneError::Library)?;
855 refs::append_reflog_with_signature(
856 repo.git_dir(),
857 "HEAD",
858 &zero,
859 peel,
860 identity,
861 clone_message,
862 false,
863 )
864 .map_err(CloneError::Library)?;
865 }
866 Ok((
867 CloneHead::Detached {
868 tag: name.clone(),
869 oid: *peel,
870 },
871 *peel,
872 ))
873 }
874 CloneRequestedRef::Branch(branch) => {
875 let branch_ref = format!("refs/heads/{branch}");
876 let oid = if opts.bare {
877 refs::resolve_ref(repo.git_dir(), &branch_ref).unwrap_or(ObjectId::zero())
878 } else {
879 let tracking = format!("refs/remotes/{}/{}", opts.remote_name, branch);
880 let oid = refs::resolve_ref(repo.git_dir(), &tracking).unwrap_or(ObjectId::zero());
881 if !oid.is_zero() {
882 refs::write_ref(repo.git_dir(), &branch_ref, &oid)
883 .map_err(CloneError::Library)?;
884 refs::append_reflog_with_signature(
885 repo.git_dir(),
886 &branch_ref,
887 &zero,
888 &oid,
889 identity,
890 clone_message,
891 false,
892 )
893 .map_err(CloneError::Library)?;
894 }
895 oid
896 };
897 refs::write_symbolic_ref(repo.git_dir(), "HEAD", &branch_ref)
898 .map_err(CloneError::Library)?;
899 if !oid.is_zero() {
900 refs::append_reflog_with_signature(
901 repo.git_dir(),
902 "HEAD",
903 &zero,
904 &oid,
905 identity,
906 clone_message,
907 false,
908 )
909 .map_err(CloneError::Library)?;
910 }
911 if !opts.bare {
912 set_local_config(
913 repo,
914 &[
915 (&format!("branch.{branch}.remote"), opts.remote_name.clone()),
916 (
917 &format!("branch.{branch}.merge"),
918 format!("refs/heads/{branch}"),
919 ),
920 ],
921 )?;
922 }
923 Ok((CloneHead::Branch(branch.clone()), oid))
924 }
925 CloneRequestedRef::Default => {
926 let default = choose_clone_default_branch(
927 repo,
928 fetch_outcome,
929 &opts.url,
930 &opts.remote_name,
931 &opts.initial_branch,
932 opts.bare,
933 );
934 let branch_ref = format!("refs/heads/{default}");
935 let oid = if opts.bare {
936 refs::resolve_ref(repo.git_dir(), &branch_ref).unwrap_or(ObjectId::zero())
937 } else {
938 let tracking = format!("refs/remotes/{}/{default}", opts.remote_name);
939 let oid = refs::resolve_ref(repo.git_dir(), &tracking).unwrap_or(ObjectId::zero());
940 if !oid.is_zero() {
941 refs::write_ref(repo.git_dir(), &branch_ref, &oid)
942 .map_err(CloneError::Library)?;
943 refs::append_reflog_with_signature(
944 repo.git_dir(),
945 &branch_ref,
946 &zero,
947 &oid,
948 identity,
949 clone_message,
950 false,
951 )
952 .map_err(CloneError::Library)?;
953 }
954 oid
955 };
956 refs::write_symbolic_ref(repo.git_dir(), "HEAD", &branch_ref)
957 .map_err(CloneError::Library)?;
958 if !oid.is_zero() {
959 refs::append_reflog_with_signature(
960 repo.git_dir(),
961 "HEAD",
962 &zero,
963 &oid,
964 identity,
965 clone_message,
966 false,
967 )
968 .map_err(CloneError::Library)?;
969 }
970 if !opts.bare {
971 set_local_config(
972 repo,
973 &[
974 (
975 &format!("branch.{default}.remote"),
976 opts.remote_name.clone(),
977 ),
978 (
979 &format!("branch.{default}.merge"),
980 format!("refs/heads/{default}"),
981 ),
982 ],
983 )?;
984 }
985 Ok((CloneHead::Branch(default), oid))
986 }
987 }
988}
989
990fn advertised_default_branch_from_remote(
991 remote: &Remote,
992 repo: &Repository,
993 http_factory: Option<&dyn HttpClientFactory>,
994 url: &str,
995 initial_branch: &str,
996) -> std::result::Result<String, CloneError> {
997 let refs = remote
998 .list_refs(
999 Some(repo),
1000 &ListRefsOptions {
1001 symrefs: true,
1002 ..Default::default()
1003 },
1004 http_factory,
1005 )
1006 .map_err(CloneError::Remote)?;
1007 for entry in &refs {
1008 if entry.name == "HEAD" {
1009 if let Some(target) = &entry.symref_target {
1010 let name = target
1011 .strip_prefix("refs/heads/")
1012 .unwrap_or(target.as_str());
1013 if !name.is_empty() {
1014 return Ok(name.to_owned());
1015 }
1016 }
1017 }
1018 }
1019 if let Some(name) = pick_default_branch_from_remote_refs(&refs) {
1020 return Ok(name);
1021 }
1022 if let Some(name) = default_branch_from_local_remote(url, repo) {
1023 return Ok(name);
1024 }
1025 Ok(initial_branch.to_owned())
1026}
1027
1028fn pick_default_branch_from_remote_refs(refs: &[crate::remote::RemoteRef]) -> Option<String> {
1029 for candidate in ["main", "master"] {
1030 let full = format!("refs/heads/{candidate}");
1031 if refs.iter().any(|r| r.name == full) {
1032 return Some(candidate.to_owned());
1033 }
1034 }
1035 refs.iter()
1036 .find_map(|r| r.name.strip_prefix("refs/heads/").map(str::to_owned))
1037}
1038
1039fn stored_clone_remote_url(url: &str) -> String {
1040 if should_store_absolute_local_clone_url(url) {
1041 absolute_local_clone_source_url(Path::new(url.trim()))
1042 } else {
1043 url.to_owned()
1044 }
1045}
1046
1047fn set_local_config(repo: &Repository, entries: &[(&str, String)]) -> Result<()> {
1048 let path = repo.git_dir().join("config");
1049 let content = fs::read_to_string(&path).unwrap_or_default();
1050 let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
1051 for (key, value) in entries {
1052 config.set(key, value)?;
1053 }
1054 config.write()?;
1055 Ok(())
1056}
1057
1058fn choose_clone_default_branch(
1059 repo: &Repository,
1060 fetch_outcome: &crate::transfer::FetchOutcome,
1061 url: &str,
1062 remote_name: &str,
1063 initial_branch: &str,
1064 bare: bool,
1065) -> String {
1066 if let Some(advertised) = fetch_outcome.default_branch.as_deref() {
1067 let name = advertised.strip_prefix("refs/heads/").unwrap_or(advertised);
1068 if !name.is_empty() {
1069 return name.to_owned();
1070 }
1071 }
1072
1073 let mut candidates: Vec<String> = Vec::new();
1074 let mut push = |name: Option<String>| {
1075 if let Some(name) = name {
1076 if !name.is_empty() && !candidates.iter().any(|c| c == &name) {
1077 candidates.push(name);
1078 }
1079 }
1080 };
1081 if bare {
1082 push(pick_default_branch_bare(repo));
1083 } else {
1084 push(pick_default_branch(repo, remote_name));
1085 }
1086 push(default_branch_from_local_remote(url, repo));
1087 push(Some(initial_branch.to_owned()));
1088
1089 for name in &candidates {
1090 let resolved = if bare {
1091 format!("refs/heads/{name}")
1092 } else {
1093 format!("refs/remotes/{remote_name}/{name}")
1094 };
1095 if refs::resolve_ref(repo.git_dir(), &resolved).is_ok() {
1096 return name.clone();
1097 }
1098 }
1099 candidates
1100 .into_iter()
1101 .next()
1102 .unwrap_or_else(|| initial_branch.to_owned())
1103}
1104
1105fn pick_default_branch_bare(repo: &Repository) -> Option<String> {
1106 for candidate in ["main", "master"] {
1107 if refs::resolve_ref(repo.git_dir(), &format!("refs/heads/{candidate}")).is_ok() {
1108 return Some(candidate.to_owned());
1109 }
1110 }
1111 refs::list_refs(repo.git_dir(), "refs/heads/")
1112 .ok()?
1113 .into_iter()
1114 .find_map(|(name, _)| name.strip_prefix("refs/heads/").map(str::to_owned))
1115}
1116
1117fn open_clone_repository(
1118 git_dir: &Path,
1119 dest: &Path,
1120 bare: bool,
1121 environment: &Environment,
1122 promisor_fetcher: Option<Arc<dyn PromisorFetcher>>,
1123) -> Result<Repository> {
1124 if bare {
1125 Repository::open(
1126 git_dir,
1127 &RepositoryOptions::with_environment(environment.clone()),
1128 )
1129 } else {
1130 let mut open_opts = RepositoryOptions::with_environment(environment.clone())
1131 .with_work_tree(dest.to_path_buf());
1132 if let Some(fetcher) = promisor_fetcher {
1133 open_opts = open_opts.with_promisor_fetcher(Some(fetcher));
1134 }
1135 Repository::open(git_dir, &open_opts)
1136 }
1137}
1138
1139fn local_clone_source_hash_algo(url: &str, discovery_cwd: &Path) -> Option<HashAlgo> {
1141 if !url_is_local_not_ssh(url.trim()) {
1142 return None;
1143 }
1144 let probe_git = discovery_cwd.join(".git");
1145 let remote_git = resolve_local_remote_git_dir(url.trim(), &probe_git, Some(discovery_cwd));
1146 Some(crate::transfer::open_odb(&remote_git).hash_algo())
1147}
1148
1149fn default_branch_from_local_remote(url: &str, repo: &Repository) -> Option<String> {
1150 if !url_is_local_not_ssh(url.trim()) {
1151 return None;
1152 }
1153 let remote_git = resolve_local_remote_git_dir(url.trim(), repo.git_dir(), repo.work_tree());
1154 crate::gc::remote_default_branch_local(&remote_git)
1155 .ok()
1156 .flatten()
1157}
1158
1159fn pick_default_branch(repo: &Repository, remote_name: &str) -> Option<String> {
1160 for candidate in ["main", "master"] {
1161 if refs::resolve_ref(
1162 repo.git_dir(),
1163 &format!("refs/remotes/{remote_name}/{candidate}"),
1164 )
1165 .is_ok()
1166 {
1167 return Some(candidate.to_owned());
1168 }
1169 }
1170 refs::list_refs(repo.git_dir(), &format!("refs/remotes/{remote_name}/"))
1171 .ok()?
1172 .into_iter()
1173 .find_map(|(name, _)| {
1174 name.strip_prefix(&format!("refs/remotes/{remote_name}/"))
1175 .filter(|b| *b != "HEAD")
1176 .map(str::to_owned)
1177 })
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use crate::progress::NeverCancel;
1184 use std::fs;
1185
1186 #[test]
1187 fn dest_preexisted_empty_missing_dir() {
1188 let dir = tempfile::tempdir().expect("tempdir");
1189 let missing = dir.path().join("nope");
1190 assert!(!dest_preexisted_empty(&missing));
1191 }
1192
1193 #[test]
1194 fn dest_preexisted_empty_empty_dir() {
1195 let dir = tempfile::tempdir().expect("tempdir");
1196 assert!(dest_preexisted_empty(dir.path()));
1197 }
1198
1199 #[test]
1200 fn dest_preexisted_empty_nonempty_dir() {
1201 let dir = tempfile::tempdir().expect("tempdir");
1202 fs::write(dir.path().join("file"), b"x").expect("write");
1203 assert!(!dest_preexisted_empty(dir.path()));
1204 }
1205
1206 #[test]
1207 fn dest_not_empty_skips_failed_clone_cleanup() {
1208 assert!(!CloneError::DestNotEmpty("x".into()).should_cleanup_failed_clone());
1209 assert!(
1210 !CloneError::Remote(RemoteError::InvalidRemoteName("bad".into()))
1211 .should_cleanup_failed_clone()
1212 );
1213 assert!(CloneError::Library(Error::Message("x".into())).should_cleanup_failed_clone());
1214 }
1215
1216 #[cfg_attr(
1217 not(feature = "full-tests"),
1218 ignore = "starts system git or is heavy; run with --features full-tests"
1219 )]
1220 #[test]
1221 fn outcome_reports_transfer_stats() {
1222 use std::process::Command;
1223
1224 let upstream = tempfile::tempdir().expect("upstream");
1225 let upstream_wt = upstream.path().join("src");
1226 std::fs::create_dir_all(&upstream_wt).expect("mkdir");
1227 let git = |args: &[&str]| {
1228 let out = Command::new("git")
1229 .args(args)
1230 .current_dir(&upstream_wt)
1231 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1232 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1233 .output()
1234 .expect("git");
1235 assert!(
1236 out.status.success(),
1237 "git {:?}: {}",
1238 args,
1239 String::from_utf8_lossy(&out.stderr)
1240 );
1241 };
1242 git(&["init", "-b", "main"]);
1243 git(&["config", "user.email", "t@example.com"]);
1244 git(&["config", "user.name", "Test"]);
1245 std::fs::write(upstream_wt.join("README.md"), b"hello\n").expect("write");
1246 git(&["add", "README.md"]);
1247 git(&["commit", "-m", "init"]);
1248 git(&["tag", "v1"]);
1249 git(&["branch", "feature"]);
1250 std::fs::write(upstream_wt.join("extra.txt"), b"x\n").expect("write");
1251 git(&["add", "extra.txt"]);
1252 git(&["commit", "-m", "on feature"]);
1253 git(&["checkout", "main"]);
1254
1255 let dest = tempfile::tempdir().expect("dest");
1256 let outcome = clone(
1257 CloneOptions::new(upstream_wt.to_string_lossy(), dest.path().to_path_buf()),
1258 &mut crate::fetch::NoProgress,
1259 &NeverCancel,
1260 None,
1261 )
1262 .expect("clone");
1263
1264 let count_out = Command::new("git")
1265 .args(["count-objects", "-v"])
1266 .current_dir(dest.path())
1267 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1268 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1269 .output()
1270 .expect("count-objects");
1271 assert!(
1272 count_out.status.success(),
1273 "{}",
1274 String::from_utf8_lossy(&count_out.stderr)
1275 );
1276 let count_text = String::from_utf8_lossy(&count_out.stdout);
1277 let mut in_pack = None::<u64>;
1278 let mut loose_count = None::<u64>;
1279 for line in count_text.lines() {
1280 if let Some(v) = line.strip_prefix("in-pack: ") {
1281 in_pack = Some(v.trim().parse().expect("in-pack"));
1282 } else if let Some(v) = line.strip_prefix("count: ") {
1283 loose_count = Some(v.trim().parse().expect("count"));
1284 }
1285 }
1286 let git_objects = in_pack.expect("in-pack line") + loose_count.expect("count line");
1287 let git_dir = dest.path().join(".git");
1288 let objects_dir = git_dir.join("objects");
1289 let on_disk_bytes =
1290 |path: &Path| -> u64 { std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) };
1291 let mut git_bytes = 0u64;
1292 let pack_dir = objects_dir.join("pack");
1293 if pack_dir.is_dir() {
1294 for entry in std::fs::read_dir(&pack_dir).expect("pack dir").flatten() {
1295 let path = entry.path();
1296 if path.extension().is_some_and(|x| x == "pack") {
1297 git_bytes += on_disk_bytes(&path);
1298 }
1299 }
1300 }
1301 for shard in std::fs::read_dir(&objects_dir).expect("objects").flatten() {
1302 let name = shard.file_name().to_string_lossy().into_owned();
1303 if name.len() != 2 || !name.chars().all(|c| c.is_ascii_hexdigit()) {
1304 continue;
1305 }
1306 for obj in std::fs::read_dir(shard.path()).expect("shard").flatten() {
1307 git_bytes += on_disk_bytes(&obj.path());
1308 }
1309 }
1310 assert_eq!(
1311 outcome.transfer.objects, git_objects,
1312 "object count must match git count-objects -v (in-pack + loose)"
1313 );
1314 assert_eq!(
1315 outcome.transfer.bytes, git_bytes,
1316 "transfer bytes must match on-disk object store payload size"
1317 );
1318 assert!(outcome.branches >= 2, "expected at least main and feature");
1319 assert!(outcome.tags >= 1, "expected tag v1");
1320 }
1321
1322 #[cfg_attr(
1323 not(feature = "full-tests"),
1324 ignore = "starts system git or is heavy; run with --features full-tests"
1325 )]
1326 #[test]
1327 fn clone_unborn_advertised_trunk_not_populated_feature() {
1328 use std::process::Command;
1329
1330 let upstream = tempfile::tempdir().expect("upstream");
1331 let bare = upstream.path().join("origin.git");
1332 let git = |args: &[&str], cwd: &std::path::Path| {
1333 let out = Command::new("git")
1334 .args(args)
1335 .current_dir(cwd)
1336 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1337 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1338 .output()
1339 .expect("git");
1340 assert!(
1341 out.status.success(),
1342 "git {:?}: {}",
1343 args,
1344 String::from_utf8_lossy(&out.stderr)
1345 );
1346 };
1347 git(
1348 &[
1349 "init",
1350 "-q",
1351 "--bare",
1352 "-b",
1353 "trunk",
1354 bare.to_str().unwrap(),
1355 ],
1356 upstream.path(),
1357 );
1358 git(&["symbolic-ref", "HEAD", "refs/heads/trunk"], &bare);
1359 let wt = upstream.path().join("wt");
1360 fs::create_dir_all(&wt).expect("mkdir");
1361 git(&["init", "-q"], &wt);
1362 git(&["config", "user.email", "t@example.com"], &wt);
1363 git(&["config", "user.name", "Test"], &wt);
1364 fs::write(wt.join("f"), b"x\n").expect("write");
1365 git(&["add", "f"], &wt);
1366 git(&["commit", "-m", "c"], &wt);
1367 git(&["branch", "feature"], &wt);
1368 git(&["push", bare.to_str().unwrap(), "feature"], &wt);
1369
1370 let dest = tempfile::tempdir().expect("dest");
1371 let outcome = clone(
1372 CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
1373 &mut crate::fetch::NoProgress,
1374 &NeverCancel,
1375 None,
1376 )
1377 .expect("clone");
1378
1379 assert_eq!(outcome.head.branch_name(), Some("trunk"));
1380 assert!(outcome.checkout_oid.is_zero());
1381 assert_eq!(outcome.checkout, CloneCheckout::UnbornDefaultBranch);
1382 assert!(
1383 outcome
1384 .branches_with_commits
1385 .contains(&"feature".to_owned()),
1386 "{:?}",
1387 outcome.branches_with_commits
1388 );
1389 assert!(!dest.path().join(".git/refs/heads/trunk").exists());
1390 assert!(!dest.path().join(".git/refs/heads/feature").exists());
1391 let head = fs::read_to_string(dest.path().join(".git/HEAD")).expect("HEAD");
1392 assert_eq!(head.trim(), "ref: refs/heads/trunk");
1393 }
1394
1395 #[cfg_attr(
1396 not(feature = "full-tests"),
1397 ignore = "starts system git or is heavy; run with --features full-tests"
1398 )]
1399 #[test]
1400 fn clone_empty_bare_remote_leaves_unborn_default_branch() {
1401 use std::process::Command;
1402
1403 let upstream = tempfile::tempdir().expect("upstream");
1404 let bare = upstream.path().join("origin.git");
1405 let out = Command::new("git")
1406 .args(["init", "-q", "--bare", "-b", "main"])
1407 .arg(&bare)
1408 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1409 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1410 .output()
1411 .expect("git init");
1412 assert!(
1413 out.status.success(),
1414 "{}",
1415 String::from_utf8_lossy(&out.stderr)
1416 );
1417
1418 let dest = tempfile::tempdir().expect("dest");
1419 let outcome = clone(
1420 CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
1421 &mut crate::fetch::NoProgress,
1422 &NeverCancel,
1423 None,
1424 )
1425 .expect("clone empty bare");
1426
1427 assert_eq!(outcome.head.branch_name(), Some("main"));
1428 assert!(outcome.checkout_oid.is_zero());
1429 assert_eq!(outcome.checkout, CloneCheckout::EmptyRemote);
1430 assert!(outcome.branches_with_commits.is_empty());
1431 assert!(!dest.path().join(".git").join("refs/heads/main").exists());
1432 let head = fs::read_to_string(dest.path().join(".git/HEAD")).expect("HEAD");
1433 assert_eq!(head.trim(), "ref: refs/heads/main");
1434 }
1435
1436 #[cfg_attr(
1437 not(feature = "full-tests"),
1438 ignore = "starts system git or is heavy; run with --features full-tests"
1439 )]
1440 #[test]
1441 fn classify_unborn_default_when_master_advertised_but_main_has_commits() {
1442 use std::process::Command;
1443
1444 let upstream = tempfile::tempdir().expect("upstream");
1445 let bare = upstream.path().join("remote.git");
1446 let git = |args: &[&str], cwd: &std::path::Path| {
1447 let out = Command::new("git")
1448 .args(args)
1449 .current_dir(cwd)
1450 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1451 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1452 .output()
1453 .expect("git");
1454 assert!(
1455 out.status.success(),
1456 "git {:?}: {}",
1457 args,
1458 String::from_utf8_lossy(&out.stderr)
1459 );
1460 };
1461 git(
1462 &["init", "-q", "--bare", bare.to_str().unwrap()],
1463 upstream.path(),
1464 );
1465
1466 let src = upstream.path().join("src");
1467 fs::create_dir_all(&src).expect("mkdir");
1468 git(&["init", "-q", "-b", "main"], &src);
1469 git(&["config", "user.email", "a@b.c"], &src);
1470 git(&["config", "user.name", "a"], &src);
1471 fs::write(src.join("f"), b"x\n").expect("write");
1472 git(&["add", "f"], &src);
1473 git(&["commit", "-m", "c1"], &src);
1474 git(&["remote", "add", "origin", bare.to_str().unwrap()], &src);
1475 git(&["push", "-u", "origin", "main"], &src);
1476
1477 let dest = tempfile::tempdir().expect("dest");
1478 let outcome = clone(
1479 CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
1480 &mut crate::fetch::NoProgress,
1481 &NeverCancel,
1482 None,
1483 )
1484 .expect("clone");
1485
1486 assert!(outcome.checkout_oid.is_zero());
1487 assert_eq!(outcome.checkout, CloneCheckout::UnbornDefaultBranch);
1488 assert!(
1489 outcome.branches_with_commits.contains(&"main".to_owned()),
1490 "{:?}",
1491 outcome.branches_with_commits
1492 );
1493 assert!(outcome.transfer.objects > 0);
1494 }
1495
1496 #[test]
1497 fn derive_clone_dir_windows_style_paths() {
1498 assert_eq!(
1499 derive_clone_dir(r"..\..\origin.git", false).expect("name"),
1500 "origin"
1501 );
1502 assert_eq!(
1503 derive_clone_dir(r"C:\scratch\a\origin.git", false).expect("name"),
1504 "origin"
1505 );
1506 assert_eq!(
1507 derive_clone_dir(r"..\..\origin.git", true).expect("name"),
1508 "origin.git"
1509 );
1510 }
1511
1512 fn git_cmd(args: &[&str], cwd: &std::path::Path) {
1513 use std::process::Command;
1514 let out = Command::new("git")
1515 .args(args)
1516 .current_dir(cwd)
1517 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1518 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1519 .output()
1520 .expect("git");
1521 assert!(
1522 out.status.success(),
1523 "git {:?}: {}",
1524 args,
1525 String::from_utf8_lossy(&out.stderr)
1526 );
1527 }
1528
1529 fn git_out(args: &[&str], cwd: &std::path::Path) -> String {
1530 use std::process::Command;
1531 let out = Command::new("git")
1532 .args(args)
1533 .current_dir(cwd)
1534 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1535 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1536 .output()
1537 .expect("git");
1538 assert!(out.status.success(), "git {:?} failed", args);
1539 String::from_utf8_lossy(&out.stdout).trim().to_string()
1540 }
1541
1542 fn shallow_file_oids(path: &std::path::Path) -> Vec<String> {
1543 let shallow = path.join(".git/shallow");
1544 if !shallow.is_file() {
1545 return Vec::new();
1546 }
1547 std::fs::read_to_string(shallow)
1548 .expect("read shallow")
1549 .lines()
1550 .map(str::trim)
1551 .filter(|l| !l.is_empty())
1552 .map(str::to_owned)
1553 .collect()
1554 }
1555
1556 fn git_fsck_strict(work_tree: &std::path::Path) {
1557 use std::process::Command;
1558 let out = Command::new("git")
1559 .args(["fsck", "--strict"])
1560 .current_dir(work_tree)
1561 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1562 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1563 .output()
1564 .expect("git fsck");
1565 assert!(
1566 out.status.success(),
1567 "git fsck --strict: {}",
1568 String::from_utf8_lossy(&out.stderr)
1569 );
1570 }
1571
1572 fn build_feature_upstream(bare: &std::path::Path) {
1573 let wt = bare.with_extension("wt");
1574 fs::create_dir_all(&wt).expect("mkdir");
1575 git_cmd(&["init", "-q", "-b", "main", "."], &wt);
1576 git_cmd(&["config", "user.email", "t@example.com"], &wt);
1577 git_cmd(&["config", "user.name", "Test"], &wt);
1578 for i in 1..=5 {
1579 fs::write(wt.join("f"), format!("{i}\n")).expect("write");
1580 git_cmd(&["add", "f"], &wt);
1581 git_cmd(&["commit", "-q", "-m", &format!("c{i}")], &wt);
1582 }
1583 git_cmd(&["branch", "feature", "HEAD~2"], &wt);
1584 git_cmd(&["tag", "v1", "HEAD~1"], &wt);
1585 let url = format!("file://{}", bare.to_string_lossy());
1586 git_cmd(&["clone", "-q", "--bare", ".", bare.to_str().unwrap()], &wt);
1587 }
1588
1589 #[cfg_attr(
1590 not(feature = "full-tests"),
1591 ignore = "starts system git or is heavy; run with --features full-tests"
1592 )]
1593 #[test]
1594 fn clone_depth_n_writes_shallow_like_git() {
1595 let tmp = tempfile::tempdir().expect("tempdir");
1596 let bare = tmp.path().join("remote.git");
1597 build_feature_upstream(&bare);
1598 let url = format!("file://{}", bare.to_string_lossy());
1599
1600 let grit_dest = tmp.path().join("grit");
1601 clone(
1602 CloneOptions {
1603 depth: Some(3),
1604 ..CloneOptions::new(url.clone(), grit_dest.clone())
1605 },
1606 &mut crate::fetch::NoProgress,
1607 &NeverCancel,
1608 None,
1609 )
1610 .expect("grit shallow clone");
1611
1612 let git_dest = tmp.path().join("git");
1613 git_cmd(
1614 &[
1615 "clone",
1616 "-q",
1617 "--depth",
1618 "3",
1619 url.as_str(),
1620 git_dest.to_str().unwrap(),
1621 ],
1622 tmp.path(),
1623 );
1624
1625 assert_eq!(
1626 shallow_file_oids(&grit_dest),
1627 shallow_file_oids(&git_dest),
1628 "shallow boundary set must match git"
1629 );
1630 assert_eq!(
1631 git_out(&["rev-list", "--count", "HEAD"], &grit_dest),
1632 git_out(&["rev-list", "--count", "HEAD"], &git_dest)
1633 );
1634 git_fsck_strict(&grit_dest);
1635 assert!(git_out(&["log", "--oneline"], &grit_dest).lines().count() >= 1);
1636 }
1637
1638 #[cfg_attr(
1639 not(feature = "full-tests"),
1640 ignore = "starts system git or is heavy; run with --features full-tests"
1641 )]
1642 #[test]
1643 fn clone_branch_checks_out_named_branch() {
1644 for depth in [None, Some(3u32)] {
1645 let tmp = tempfile::tempdir().expect("tempdir");
1646 let bare = tmp.path().join("remote.git");
1647 build_feature_upstream(&bare);
1648 let url = format!("file://{}", bare.to_string_lossy());
1649
1650 let grit_dest = tmp.path().join("grit");
1651 clone(
1652 CloneOptions {
1653 depth,
1654 branch: Some("feature".to_owned()),
1655 ..CloneOptions::new(url.clone(), grit_dest.clone())
1656 },
1657 &mut crate::fetch::NoProgress,
1658 &NeverCancel,
1659 None,
1660 )
1661 .expect("grit clone -b feature");
1662
1663 let git_dest = tmp.path().join("git");
1664 match depth {
1665 None => git_cmd(
1666 &[
1667 "clone",
1668 "-q",
1669 "-b",
1670 "feature",
1671 url.as_str(),
1672 git_dest.to_str().unwrap(),
1673 ],
1674 tmp.path(),
1675 ),
1676 Some(d) => {
1677 let depth_s = d.to_string();
1678 git_cmd(
1679 &[
1680 "clone",
1681 "-q",
1682 "--depth",
1683 depth_s.as_str(),
1684 "-b",
1685 "feature",
1686 url.as_str(),
1687 git_dest.to_str().unwrap(),
1688 ],
1689 tmp.path(),
1690 );
1691 }
1692 }
1693
1694 assert_eq!(
1695 fs::read_to_string(grit_dest.join(".git/HEAD"))
1696 .expect("HEAD")
1697 .trim(),
1698 fs::read_to_string(git_dest.join(".git/HEAD"))
1699 .expect("HEAD")
1700 .trim()
1701 );
1702 assert_eq!(
1703 git_out(&["config", "--get", "branch.feature.remote"], &grit_dest),
1704 git_out(&["config", "--get", "branch.feature.remote"], &git_dest)
1705 );
1706 assert_eq!(
1707 git_out(&["config", "--get", "branch.feature.merge"], &grit_dest),
1708 git_out(&["config", "--get", "branch.feature.merge"], &git_dest)
1709 );
1710 assert_eq!(
1711 git_out(&["config", "--get", "remote.origin.fetch"], &grit_dest),
1712 git_out(&["config", "--get", "remote.origin.fetch"], &git_dest)
1713 );
1714 git_fsck_strict(&grit_dest);
1715 }
1716 }
1717
1718 #[cfg_attr(
1719 not(feature = "full-tests"),
1720 ignore = "starts system git or is heavy; run with --features full-tests"
1721 )]
1722 #[test]
1723 fn clone_tag_detaches_head() {
1724 let tmp = tempfile::tempdir().expect("tempdir");
1725 let bare = tmp.path().join("remote.git");
1726 build_feature_upstream(&bare);
1727 let url = format!("file://{}", bare.to_string_lossy());
1728 let tag_commit = git_out(&["rev-parse", "v1^{}"], &bare);
1729
1730 let grit_dest = tmp.path().join("grit");
1731 let outcome = clone(
1732 CloneOptions {
1733 branch: Some("v1".to_owned()),
1734 ..CloneOptions::new(url, grit_dest.clone())
1735 },
1736 &mut crate::fetch::NoProgress,
1737 &NeverCancel,
1738 None,
1739 )
1740 .expect("clone tag");
1741
1742 assert!(outcome.head.is_detached());
1743 assert_eq!(outcome.head.tag_name(), Some("v1"));
1744 assert_eq!(git_out(&["rev-parse", "HEAD"], &grit_dest), tag_commit);
1745 let head = fs::read_to_string(grit_dest.join(".git/HEAD")).expect("HEAD");
1746 assert!(!head.starts_with("ref: "));
1747 let config = fs::read_to_string(grit_dest.join(".git/config")).expect("config");
1748 assert!(!config.contains("[branch "));
1749 git_fsck_strict(&grit_dest);
1750 }
1751
1752 #[cfg_attr(
1753 not(feature = "full-tests"),
1754 ignore = "starts system git or is heavy; run with --features full-tests"
1755 )]
1756 #[test]
1757 fn clone_missing_branch_is_typed_error() {
1758 let tmp = tempfile::tempdir().expect("tempdir");
1759 let bare = tmp.path().join("remote.git");
1760 build_feature_upstream(&bare);
1761 let url = format!("file://{}", bare.to_string_lossy());
1762 let dest = tmp.path().join("dest");
1763 let err = clone(
1764 CloneOptions {
1765 branch: Some("no-such-ref".to_owned()),
1766 ..CloneOptions::new(url, dest.clone())
1767 },
1768 &mut crate::fetch::NoProgress,
1769 &NeverCancel,
1770 None,
1771 )
1772 .expect_err("missing ref");
1773 assert!(matches!(err, CloneError::RemoteRefNotFound(_)));
1774 assert!(err.should_cleanup_failed_clone());
1775 cleanup_failed_clone(&dest, false);
1776 assert!(!dest.exists());
1777 }
1778
1779 #[test]
1780 fn clone_rejects_nonempty_destination_without_git_dir() {
1781 let upstream = tempfile::tempdir().expect("upstream");
1782 let dest = tempfile::tempdir().expect("dest");
1783 fs::write(dest.path().join("notes.txt"), b"keep").expect("write");
1784 let err = clone(
1785 CloneOptions::new(upstream.path().to_string_lossy(), dest.path().to_path_buf()),
1786 &mut crate::fetch::NoProgress,
1787 &NeverCancel,
1788 None,
1789 )
1790 .expect_err("non-empty dest");
1791 assert!(matches!(err, CloneError::DestNotEmpty(_)));
1792 assert!(dest.path().join("notes.txt").is_file());
1793 assert!(!dest.path().join(".git").exists());
1794 }
1795}