Skip to main content

grit_lib/
clone.rs

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