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