Skip to main content

grit_lib/
clone.rs

1//! Clone a remote repository: initialize, configure `origin`, fetch, set up the default branch,
2//! and optionally check out the working tree.
3
4use 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/// Errors during [`clone`].
33#[derive(Debug, Error)]
34pub enum CloneError {
35    /// Destination exists and is not empty.
36    ///
37    /// Grit does not create or modify the destination in this case; callers must not run
38    /// [`cleanup_failed_clone`] on the path.
39    #[error("destination '{0}' already exists and is not empty")]
40    DestNotEmpty(String),
41    /// Named branch or tag from [`CloneOptions::branch`] was not advertised by the remote.
42    #[error("remote ref '{0}' not found")]
43    RemoteRefNotFound(String),
44    /// [`CloneOptions::depth`] was zero (`git clone --depth 0` is invalid).
45    #[error("depth 0 is not a positive number")]
46    InvalidCloneDepth,
47    /// Underlying library failure.
48    #[error(transparent)]
49    Library(#[from] Error),
50    /// Remote resolution or transport failure.
51    #[error(transparent)]
52    Remote(#[from] crate::remote::RemoteError),
53}
54
55impl CloneError {
56    /// Whether [`cleanup_failed_clone`] may run after this failure.
57    ///
58    /// Returns `false` when the destination must be left unchanged: [`CloneError::DestNotEmpty`]
59    /// and pre-initialization failures such as [`RemoteError::InvalidRemoteName`].
60    #[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    /// Whether the failure is a missing `-b` / `--branch` ref (destination may be removed).
71    #[must_use]
72    pub fn is_remote_ref_not_found(&self) -> bool {
73        matches!(self, Self::RemoteRefNotFound(_))
74    }
75
76    /// Whether the source could not be read at all ([`Error::RemoteUnreadable`]), as opposed to
77    /// an empty remote with no branches or a local failure.
78    #[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    /// Whether the clone was cancelled ([`Error::Cancelled`]).
88    ///
89    /// When this returns `true`, [`Self::should_cleanup_failed_clone`] is also `true`.
90    #[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/// Which tags to fetch during [`clone`], matching `git clone` tag behavior.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CloneTags {
103    /// Fetch every tag on the remote (`git fetch --tags` during clone).
104    All,
105    /// Fetch tags reachable from fetched commits (default for shallow clones).
106    Following,
107    /// Do not fetch tags; writes `remote.<name>.tagOpt=--no-tags`.
108    None,
109}
110
111impl CloneTags {
112    /// Maps to [`TagMode`] for the initial fetch.
113    #[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
123/// Options for [`clone`].
124pub struct CloneOptions {
125    /// Source URL or path (as passed on the command line).
126    pub url: String,
127    /// Directory to create or use for the new repository.
128    pub dest: PathBuf,
129    /// Remote name to configure (default [`DEFAULT_REMOTE`]).
130    pub remote_name: String,
131    /// Process environment for config and reflog identity.
132    pub environment: Environment,
133    /// Initial branch name used when initializing the empty repo (`main`).
134    pub initial_branch: String,
135    /// Global thread policy for fetch pack ingest and the opened repository (`--threads`).
136    pub thread_policy: crate::parallel::ThreadPolicy,
137    /// Truncate fetched history to this many commits per ref tip
138    /// (`git clone --depth N`). `Some(1)` matches a shallow clone.
139    pub depth: Option<u32>,
140    /// Partial-clone object filter applied to the initial fetch and recorded in config.
141    pub filter: Option<ObjectFilterSpec>,
142    /// Remote branch or tag to check out (`git clone -b` / `--branch`).
143    pub branch: Option<String>,
144    /// Single-branch fetch (`git clone --single-branch` / `--no-single-branch`).
145    ///
146    /// `None` matches Git: depth implies single-branch unless explicitly disabled.
147    pub single_branch: Option<bool>,
148    /// Tag fetch mode. `None` uses Git defaults (all tags for full clones, following for shallow).
149    pub tags: Option<CloneTags>,
150    /// Optional on-demand promisor fetcher installed on the opened repository (for checkout prefetch).
151    pub promisor_fetcher: Option<Arc<dyn PromisorFetcher>>,
152    /// Create a bare repository (`git clone --bare`).
153    pub bare: bool,
154    /// Populate the index and working tree after fetch (`git clone --no-checkout` when false).
155    pub checkout: bool,
156    /// Where bundle downloads are kept, so an interrupted clone resumes its
157    /// download next time. `None` skips the server's `bundle-uri` bundle. Shallow
158    /// and partial clones never use one.
159    pub bundle_cache: Option<PathBuf>,
160}
161
162impl CloneOptions {
163    /// Options for cloning into `dest` from `url`, using the current process environment.
164    #[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(), // hygiene: CLI boundary — snapshot process environment for clone
171            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/// Initial checkout disposition after [`clone`] (before working-tree checkout).
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum CloneHead {
189    /// Checked out on a local branch that tracks the remote.
190    Branch(String),
191    /// Detached at a tag's peeled commit (`git clone -b <tag>`).
192    Detached {
193        /// Tag name from `-b` / `--branch`.
194        tag: String,
195        /// Peeled commit object id.
196        oid: ObjectId,
197    },
198}
199
200impl CloneHead {
201    /// Local branch name when [`CloneHead::Branch`]; `None` when detached.
202    #[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    /// Tag name when detached at a tag; `None` on a branch checkout.
211    #[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    /// Whether `HEAD` is detached (tag checkout).
220    #[must_use]
221    pub fn is_detached(&self) -> bool {
222        matches!(self, Self::Detached { .. })
223    }
224}
225
226/// Whether [`clone`] checked out the default branch or left the work tree empty.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum CloneCheckout {
229    /// The default branch tip was fetched and should be checked out.
230    CheckedOut(ObjectId),
231    /// No objects and no remote branches were received.
232    EmptyRemote,
233    /// The remote advertised default branch has no commits yet; other refs may exist.
234    UnbornDefaultBranch,
235    /// A commit was fetched but the working tree was not checked out (`--bare` or `--no-checkout`).
236    Skipped,
237}
238
239/// Result of a successful [`clone`].
240#[derive(Debug)]
241pub struct CloneOutcome {
242    /// Source URL string from the options.
243    pub url: String,
244    /// Destination path.
245    pub path: PathBuf,
246    /// Branch checked out, or detached tag checkout.
247    pub head: CloneHead,
248    /// Commit to check out on that branch.
249    ///
250    /// [`ObjectId::zero()`] when the remote default branch has no commits yet.
251    pub checkout_oid: ObjectId,
252    /// Checkout disposition after fetch and default-branch setup.
253    pub checkout: CloneCheckout,
254    /// Remote-tracking branch names that resolved to a commit (empty unless checkout is
255    /// [`CloneCheckout::UnbornDefaultBranch`]).
256    pub branches_with_commits: Vec<String>,
257    /// Open handle to the new repository.
258    pub repo: Repository,
259    /// Objects and pack bytes received during the initial fetch.
260    pub transfer: TransferStats,
261    /// Remote branches fetched (from the fetch ref updates).
262    pub branches: u64,
263    /// Remote tags fetched (from the fetch ref updates).
264    pub tags: u64,
265    /// Working-tree files checked out (zero when checkout was skipped or not applicable).
266    pub files: u64,
267    /// Total size of checked-out files in bytes.
268    pub worktree_bytes: u64,
269    /// Index paths that collided on a case-insensitive filesystem during checkout.
270    pub path_collisions: Vec<String>,
271    /// Whether the destination is a bare repository.
272    pub bare: bool,
273    /// Whether a bundle from the server's `bundle-uri` seeded the clone.
274    /// [`Self::transfer`] includes what the bundle brought.
275    pub bundle: crate::bundle_uri::BundleSeed,
276}
277
278/// Derive a destination directory from a clone URL (Git [`git_url_basename`]).
279///
280/// # Errors
281///
282/// Returns [`NoDirectoryName`] when no directory name can be guessed (same as `git clone`
283/// without `<dir>`).
284pub fn derive_clone_dir(url: &str, bare: bool) -> std::result::Result<String, NoDirectoryName> {
285    git_url_basename(url, false, bare)
286}
287
288/// Whether `path` is an existing empty directory (used for failed-clone cleanup policy).
289#[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        // Reserve the bare git directory before concurrent inits race on `config` / `HEAD`.
391        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
411/// Remove a partially created clone destination.
412///
413/// When `keep_toplevel` is true, only contents of `path` are removed (empty dir existed before clone).
414pub 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
432/// Initialize, fetch, configure the default branch, and optionally check out files.
433///
434/// # Errors
435///
436/// Returns [`CloneError`] when the destination is invalid or fetch fails.
437pub 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    // Wire clone may upgrade an empty SHA-1 destination to the remote object format; reopen so
590    // checkout uses a fresh ODB hash algorithm (the pre-fetch handle caches SHA-1).
591    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
771/// Initial `remote.*.fetch` refspec and the refspec used for the first fetch.
772///
773/// A depth-limited clone matches `git clone --depth`: only one ref is fetched
774/// (`--single-branch` behavior), not every `refs/heads/*` tip.
775fn 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/// Whether the clone fetches only one branch (`git clone --single-branch` semantics).
823#[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
1139/// When cloning from a local path, match the source repository's object hash up front.
1140fn 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}