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