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