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