use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::config::{ConfigFile, ConfigScope, ConfigSet};
use crate::environment::Environment;
use crate::environment::RepositoryOptions;
use crate::error::{Error, Result};
use crate::fetch::{fetch_operation_identity, Progress};
use crate::objects::ObjectId;
use crate::partial_clone::{ObjectFilterSpec, PartialCloneConfig};
use crate::ref_storage::RefStorageFormat;
use crate::refs;
use crate::remote::{fold_peeled_refs, HttpClientFactory, ListRefsOptions, Remote, DEFAULT_REMOTE};
use crate::repo::{init_repository, Repository};
use crate::transfer::{CloneReflog, FetchOptions, TagMode, TransferStats};
use crate::transport_path::{
absolute_local_clone_source_url, git_url_basename, resolve_local_remote_git_dir,
should_store_absolute_local_clone_url, url_is_local_not_ssh, NoDirectoryName,
};
#[derive(Debug, Error)]
pub enum CloneError {
#[error("destination '{0}' already exists and is not empty")]
DestNotEmpty(String),
#[error("remote ref '{0}' not found")]
RemoteRefNotFound(String),
#[error("depth 0 is not a positive number")]
InvalidCloneDepth,
#[error(transparent)]
Library(#[from] Error),
#[error(transparent)]
Remote(#[from] crate::remote::RemoteError),
}
impl CloneError {
#[must_use]
pub fn should_cleanup_failed_clone(&self) -> bool {
!matches!(self, Self::DestNotEmpty(_))
}
#[must_use]
pub fn is_remote_ref_not_found(&self) -> bool {
matches!(self, Self::RemoteRefNotFound(_))
}
#[must_use]
pub fn is_remote_read_failure(&self) -> bool {
match self {
Self::Library(e) => matches!(e, Error::RemoteReadFailed),
Self::Remote(crate::remote::RemoteError::Library(e)) => {
matches!(e, Error::RemoteReadFailed)
}
Self::DestNotEmpty(_)
| Self::RemoteRefNotFound(_)
| Self::InvalidCloneDepth
| Self::Remote(_) => false,
}
}
}
pub struct CloneOptions {
pub url: String,
pub dest: PathBuf,
pub remote_name: String,
pub environment: Environment,
pub initial_branch: String,
pub thread_policy: crate::parallel::ThreadPolicy,
pub depth: Option<u32>,
pub filter: Option<ObjectFilterSpec>,
pub branch: Option<String>,
}
impl CloneOptions {
#[must_use]
pub fn new(url: impl Into<String>, dest: PathBuf) -> Self {
Self {
url: url.into(),
dest,
remote_name: DEFAULT_REMOTE.to_owned(),
environment: Environment::capture_process(), initial_branch: "main".to_owned(),
thread_policy: crate::parallel::ThreadPolicy::default(),
depth: None,
filter: None,
branch: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloneHead {
Branch(String),
Detached {
tag: String,
oid: ObjectId,
},
}
impl CloneHead {
#[must_use]
pub fn branch_name(&self) -> Option<&str> {
match self {
Self::Branch(name) => Some(name.as_str()),
Self::Detached { .. } => None,
}
}
#[must_use]
pub fn tag_name(&self) -> Option<&str> {
match self {
Self::Detached { tag, .. } => Some(tag.as_str()),
Self::Branch(_) => None,
}
}
#[must_use]
pub fn is_detached(&self) -> bool {
matches!(self, Self::Detached { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloneCheckout {
CheckedOut(ObjectId),
EmptyRemote,
UnbornDefaultBranch,
}
#[derive(Debug)]
pub struct CloneOutcome {
pub url: String,
pub path: PathBuf,
pub head: CloneHead,
pub checkout_oid: ObjectId,
pub checkout: CloneCheckout,
pub branches_with_commits: Vec<String>,
pub repo: Repository,
pub transfer: TransferStats,
pub branches: u64,
pub tags: u64,
}
pub fn derive_clone_dir(url: &str) -> std::result::Result<String, NoDirectoryName> {
git_url_basename(url, false, false)
}
#[must_use]
pub fn dest_preexisted_empty(path: &Path) -> bool {
path.is_dir()
&& path
.read_dir()
.map(|mut entries| entries.next().is_none())
.unwrap_or(false)
}
pub fn cleanup_failed_clone(path: &Path, keep_toplevel: bool) {
if keep_toplevel {
let Ok(entries) = fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
let _ = fs::remove_dir_all(p);
} else {
let _ = fs::remove_file(p);
}
}
} else {
let _ = fs::remove_dir_all(path);
}
}
pub fn clone(
opts: CloneOptions,
progress: &mut dyn Progress,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<CloneOutcome, CloneError> {
let dir = opts
.dest
.to_str()
.ok_or_else(|| CloneError::Library(Error::Message("non-UTF-8 destination path".into())))?;
if opts.dest.is_dir()
&& opts
.dest
.read_dir()
.map(|mut entries| entries.next().is_some())
.unwrap_or(false)
{
return Err(CloneError::DestNotEmpty(dir.to_owned()));
}
if opts.depth == Some(0) {
return Err(CloneError::InvalidCloneDepth);
}
init_repository(
&opts.dest,
false,
&opts.initial_branch,
None,
RefStorageFormat::Files,
)
.map_err(|e| {
CloneError::Library(Error::Message(format!("could not initialize '{dir}': {e}")))
})?;
let git_dir = opts.dest.join(".git");
let repo = Repository::open(
&git_dir,
&RepositoryOptions::with_environment(opts.environment.clone())
.with_work_tree(opts.dest.clone()),
)
.map_err(CloneError::Library)?;
let origin_url = stored_clone_remote_url(&opts.url);
set_local_config(
&repo,
&[(
&format!("remote.{}.url", opts.remote_name),
origin_url.clone(),
)],
)?;
let config = ConfigSet::load(&opts.environment, Some(repo.git_dir()), true)
.map_err(CloneError::Library)?;
let remote = Remote::from_config(&config, &opts.remote_name)?;
let requested = match opts.branch.as_deref() {
Some(name) => resolve_requested_clone_ref(name, &remote, &repo, http_factory)?,
None => CloneRequestedRef::Default,
};
let fetch_refspec =
clone_initial_fetch_refspec(&opts, &requested, &remote, &repo, http_factory)?;
set_local_config(
&repo,
&[(
&format!("remote.{}.fetch", opts.remote_name),
fetch_refspec.clone(),
)],
)?;
let clone_message = format!("clone: from {}", opts.url);
let identity =
fetch_operation_identity(&opts.environment, repo.git_dir()).map_err(CloneError::Library)?;
let clone_reflog = CloneReflog {
identity: identity.clone(),
message: clone_message.clone(),
};
let shallow_clone = opts.depth.is_some();
let mut fetch_opts = FetchOptions {
refspecs: vec![fetch_refspec],
tags: if shallow_clone {
TagMode::Following
} else {
TagMode::All
},
initial_remote_fetch: true,
remote_name: Some(opts.remote_name.clone()),
clone_reflog: Some(clone_reflog),
environment: opts.environment.clone(),
thread_policy: opts.thread_policy,
remote_progress: progress.remote_sideband_progress(),
fetch_depth: opts.depth.and_then(crate::transfer::FetchDepth::depth),
filter: opts.filter,
..Default::default()
};
if let Ok(v) = crate::protocol::try_effective_client_protocol_version_from_config(&config) {
fetch_opts.http_protocol_version = Some(v);
}
let fetch_outcome = remote.fetch(&repo, fetch_opts, progress, http_factory)?;
if let Some(spec) = opts.filter {
PartialCloneConfig::write(repo.git_dir(), &opts.remote_name, &spec)
.map_err(CloneError::Library)?;
}
let (head, oid) = setup_clone_head(
&repo,
&opts,
&requested,
&fetch_outcome,
&identity,
&clone_message,
)?;
let branches_with_commits = remote_branch_names_with_commits(&repo, &opts.remote_name);
let checkout = classify_clone_checkout(
oid,
&fetch_outcome.transfer,
fetch_outcome.branch_count(),
&branches_with_commits,
);
Ok(CloneOutcome {
url: opts.url.clone(),
path: opts.dest.clone(),
head,
checkout_oid: oid,
checkout,
branches_with_commits,
repo: repo.with_thread_policy(opts.thread_policy),
transfer: fetch_outcome.transfer,
branches: fetch_outcome.branch_count(),
tags: fetch_outcome.tag_count(),
})
}
fn remote_branch_names_with_commits(repo: &Repository, remote_name: &str) -> Vec<String> {
let prefix = format!("refs/remotes/{remote_name}/");
refs::list_refs(repo.git_dir(), &prefix)
.unwrap_or_default()
.into_iter()
.filter_map(|(name, oid)| {
if oid.is_zero() {
return None;
}
name.strip_prefix(&prefix)
.filter(|b| *b != "HEAD")
.map(str::to_owned)
})
.collect()
}
fn classify_clone_checkout(
checkout_oid: ObjectId,
transfer: &TransferStats,
branch_count: u64,
branches_with_commits: &[String],
) -> CloneCheckout {
if !checkout_oid.is_zero() {
return CloneCheckout::CheckedOut(checkout_oid);
}
if transfer.objects == 0 && branch_count == 0 {
return CloneCheckout::EmptyRemote;
}
if transfer.objects > 0 || !branches_with_commits.is_empty() {
return CloneCheckout::UnbornDefaultBranch;
}
CloneCheckout::EmptyRemote
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CloneRequestedRef {
Default,
Branch(String),
Tag { name: String, peel: ObjectId },
}
fn resolve_requested_clone_ref(
name: &str,
remote: &Remote,
repo: &Repository,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<CloneRequestedRef, CloneError> {
let refs = remote
.list_refs(
Some(repo),
&ListRefsOptions {
symrefs: true,
peel: true,
..Default::default()
},
http_factory,
)
.map_err(CloneError::Remote)?;
let head_ref = format!("refs/heads/{name}");
if refs.iter().any(|r| r.name == head_ref) {
return Ok(CloneRequestedRef::Branch(name.to_owned()));
}
let tag_ref = format!("refs/tags/{name}");
let folded = fold_peeled_refs(&refs);
let Some(tag) = folded.iter().find(|r| r.name == tag_ref) else {
return Err(CloneError::RemoteRefNotFound(name.to_owned()));
};
let peel = tag.peeled.filter(|oid| !oid.is_zero()).unwrap_or(tag.oid);
if peel.is_zero() {
return Err(CloneError::RemoteRefNotFound(name.to_owned()));
}
Ok(CloneRequestedRef::Tag {
name: name.to_owned(),
peel,
})
}
fn clone_initial_fetch_refspec(
opts: &CloneOptions,
requested: &CloneRequestedRef,
remote: &Remote,
repo: &Repository,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<String, CloneError> {
if let Some(depth) = opts.depth.filter(|d| *d > 0) {
let _ = depth;
return match requested {
CloneRequestedRef::Tag { name, .. } => {
Ok(format!("+refs/tags/{name}:refs/tags/{name}"))
}
CloneRequestedRef::Branch(branch) => Ok(format!(
"+refs/heads/{branch}:refs/remotes/{}/{}",
opts.remote_name, branch
)),
CloneRequestedRef::Default => {
let branch = advertised_default_branch_from_remote(
remote,
repo,
http_factory,
&opts.url,
&opts.initial_branch,
)?;
Ok(format!(
"+refs/heads/{branch}:refs/remotes/{}/{}",
opts.remote_name, branch
))
}
};
}
Ok(format!("+refs/heads/*:refs/remotes/{}/*", opts.remote_name))
}
fn setup_clone_head(
repo: &Repository,
opts: &CloneOptions,
requested: &CloneRequestedRef,
fetch_outcome: &crate::transfer::FetchOutcome,
identity: &str,
clone_message: &str,
) -> std::result::Result<(CloneHead, ObjectId), CloneError> {
let zero = ObjectId::zero();
match requested {
CloneRequestedRef::Tag { name, peel } => {
if !peel.is_zero() {
refs::write_ref(repo.git_dir(), "HEAD", peel).map_err(CloneError::Library)?;
refs::append_reflog(
repo.git_dir(),
"HEAD",
&zero,
peel,
identity,
clone_message,
false,
)
.map_err(CloneError::Library)?;
}
Ok((
CloneHead::Detached {
tag: name.clone(),
oid: *peel,
},
*peel,
))
}
CloneRequestedRef::Branch(branch) => {
let tracking = format!("refs/remotes/{}/{}", opts.remote_name, branch);
let oid = refs::resolve_ref(repo.git_dir(), &tracking).unwrap_or(ObjectId::zero());
let branch_ref = format!("refs/heads/{branch}");
if !oid.is_zero() {
refs::write_ref(repo.git_dir(), &branch_ref, &oid).map_err(CloneError::Library)?;
refs::append_reflog(
repo.git_dir(),
&branch_ref,
&zero,
&oid,
identity,
clone_message,
false,
)
.map_err(CloneError::Library)?;
}
refs::write_symbolic_ref(repo.git_dir(), "HEAD", &branch_ref)
.map_err(CloneError::Library)?;
if !oid.is_zero() {
refs::append_reflog(
repo.git_dir(),
"HEAD",
&zero,
&oid,
identity,
clone_message,
false,
)
.map_err(CloneError::Library)?;
}
set_local_config(
repo,
&[
(&format!("branch.{branch}.remote"), opts.remote_name.clone()),
(
&format!("branch.{branch}.merge"),
format!("refs/heads/{branch}"),
),
],
)?;
Ok((CloneHead::Branch(branch.clone()), oid))
}
CloneRequestedRef::Default => {
let default = choose_clone_default_branch(
repo,
fetch_outcome,
&opts.url,
&opts.remote_name,
&opts.initial_branch,
);
let tracking = format!("refs/remotes/{}/{default}", opts.remote_name);
let oid = refs::resolve_ref(repo.git_dir(), &tracking).unwrap_or(ObjectId::zero());
let branch_ref = format!("refs/heads/{default}");
if !oid.is_zero() {
refs::write_ref(repo.git_dir(), &branch_ref, &oid).map_err(CloneError::Library)?;
refs::append_reflog(
repo.git_dir(),
&branch_ref,
&zero,
&oid,
identity,
clone_message,
false,
)
.map_err(CloneError::Library)?;
}
refs::write_symbolic_ref(repo.git_dir(), "HEAD", &branch_ref)
.map_err(CloneError::Library)?;
if !oid.is_zero() {
refs::append_reflog(
repo.git_dir(),
"HEAD",
&zero,
&oid,
identity,
clone_message,
false,
)
.map_err(CloneError::Library)?;
}
set_local_config(
repo,
&[
(
&format!("branch.{default}.remote"),
opts.remote_name.clone(),
),
(
&format!("branch.{default}.merge"),
format!("refs/heads/{default}"),
),
],
)?;
Ok((CloneHead::Branch(default), oid))
}
}
}
fn advertised_default_branch_from_remote(
remote: &Remote,
repo: &Repository,
http_factory: Option<&dyn HttpClientFactory>,
url: &str,
initial_branch: &str,
) -> std::result::Result<String, CloneError> {
let refs = remote
.list_refs(
Some(repo),
&ListRefsOptions {
symrefs: true,
..Default::default()
},
http_factory,
)
.map_err(CloneError::Remote)?;
for entry in &refs {
if entry.name == "HEAD" {
if let Some(target) = &entry.symref_target {
let name = target
.strip_prefix("refs/heads/")
.unwrap_or(target.as_str());
if !name.is_empty() {
return Ok(name.to_owned());
}
}
}
}
if let Some(name) = pick_default_branch_from_remote_refs(&refs) {
return Ok(name);
}
if let Some(name) = default_branch_from_local_remote(url, repo) {
return Ok(name);
}
Ok(initial_branch.to_owned())
}
fn pick_default_branch_from_remote_refs(refs: &[crate::remote::RemoteRef]) -> Option<String> {
for candidate in ["main", "master"] {
let full = format!("refs/heads/{candidate}");
if refs.iter().any(|r| r.name == full) {
return Some(candidate.to_owned());
}
}
refs.iter()
.find_map(|r| r.name.strip_prefix("refs/heads/").map(str::to_owned))
}
fn stored_clone_remote_url(url: &str) -> String {
if should_store_absolute_local_clone_url(url) {
absolute_local_clone_source_url(Path::new(url.trim()))
} else {
url.to_owned()
}
}
fn set_local_config(repo: &Repository, entries: &[(&str, String)]) -> Result<()> {
let path = repo.git_dir().join("config");
let content = fs::read_to_string(&path).unwrap_or_default();
let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
for (key, value) in entries {
config.set(key, value)?;
}
config.write()?;
Ok(())
}
fn choose_clone_default_branch(
repo: &Repository,
fetch_outcome: &crate::transfer::FetchOutcome,
url: &str,
remote_name: &str,
initial_branch: &str,
) -> String {
if let Some(advertised) = fetch_outcome.default_branch.as_deref() {
let name = advertised.strip_prefix("refs/heads/").unwrap_or(advertised);
if !name.is_empty() {
return name.to_owned();
}
}
let mut candidates: Vec<String> = Vec::new();
let mut push = |name: Option<String>| {
if let Some(name) = name {
if !name.is_empty() && !candidates.iter().any(|c| c == &name) {
candidates.push(name);
}
}
};
push(pick_default_branch(repo, remote_name));
push(default_branch_from_local_remote(url, repo));
push(Some(initial_branch.to_owned()));
for name in &candidates {
let tracking = format!("refs/remotes/{remote_name}/{name}");
if refs::resolve_ref(repo.git_dir(), &tracking).is_ok() {
return name.clone();
}
}
candidates
.into_iter()
.next()
.unwrap_or_else(|| initial_branch.to_owned())
}
fn default_branch_from_local_remote(url: &str, repo: &Repository) -> Option<String> {
if !url_is_local_not_ssh(url.trim()) {
return None;
}
let remote_git = resolve_local_remote_git_dir(url.trim(), repo.git_dir(), repo.work_tree());
crate::gc::remote_default_branch_local(&remote_git)
.ok()
.flatten()
}
fn pick_default_branch(repo: &Repository, remote_name: &str) -> Option<String> {
for candidate in ["main", "master"] {
if refs::resolve_ref(
repo.git_dir(),
&format!("refs/remotes/{remote_name}/{candidate}"),
)
.is_ok()
{
return Some(candidate.to_owned());
}
}
refs::list_refs(repo.git_dir(), &format!("refs/remotes/{remote_name}/"))
.ok()?
.into_iter()
.find_map(|(name, _)| {
name.strip_prefix(&format!("refs/remotes/{remote_name}/"))
.filter(|b| *b != "HEAD")
.map(str::to_owned)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn dest_preexisted_empty_missing_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope");
assert!(!dest_preexisted_empty(&missing));
}
#[test]
fn dest_preexisted_empty_empty_dir() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(dest_preexisted_empty(dir.path()));
}
#[test]
fn dest_preexisted_empty_nonempty_dir() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("file"), b"x").expect("write");
assert!(!dest_preexisted_empty(dir.path()));
}
#[test]
fn dest_not_empty_skips_failed_clone_cleanup() {
assert!(!CloneError::DestNotEmpty("x".into()).should_cleanup_failed_clone());
assert!(CloneError::Library(Error::Message("x".into())).should_cleanup_failed_clone());
}
#[test]
fn outcome_reports_transfer_stats() {
use std::process::Command;
let upstream = tempfile::tempdir().expect("upstream");
let upstream_wt = upstream.path().join("src");
std::fs::create_dir_all(&upstream_wt).expect("mkdir");
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&upstream_wt)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git");
assert!(
out.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-b", "main"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "Test"]);
std::fs::write(upstream_wt.join("README.md"), b"hello\n").expect("write");
git(&["add", "README.md"]);
git(&["commit", "-m", "init"]);
git(&["tag", "v1"]);
git(&["branch", "feature"]);
std::fs::write(upstream_wt.join("extra.txt"), b"x\n").expect("write");
git(&["add", "extra.txt"]);
git(&["commit", "-m", "on feature"]);
git(&["checkout", "main"]);
let dest = tempfile::tempdir().expect("dest");
let outcome = clone(
CloneOptions::new(upstream_wt.to_string_lossy(), dest.path().to_path_buf()),
&mut crate::fetch::NoProgress,
None,
)
.expect("clone");
let count_out = Command::new("git")
.args(["count-objects", "-v"])
.current_dir(dest.path())
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("count-objects");
assert!(
count_out.status.success(),
"{}",
String::from_utf8_lossy(&count_out.stderr)
);
let count_text = String::from_utf8_lossy(&count_out.stdout);
let mut in_pack = None::<u64>;
for line in count_text.lines() {
if let Some(v) = line.strip_prefix("in-pack: ") {
in_pack = Some(v.trim().parse().expect("in-pack"));
}
}
let git_objects = in_pack.expect("in-pack line");
let git_dir = dest.path().join(".git");
let pack_dir = git_dir.join("objects/pack");
let pack_bytes = std::fs::read_dir(&pack_dir)
.expect("pack dir")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "pack"))
.map(|p| std::fs::metadata(&p).expect("pack stat").len())
.sum::<u64>();
assert_eq!(
outcome.transfer.objects, git_objects,
"object count must match git count-objects -v in-pack"
);
assert_eq!(
outcome.transfer.bytes, pack_bytes,
"pack bytes must match on-disk .pack file size"
);
assert!(outcome.branches >= 2, "expected at least main and feature");
assert!(outcome.tags >= 1, "expected tag v1");
}
#[test]
fn clone_unborn_advertised_trunk_not_populated_feature() {
use std::process::Command;
let upstream = tempfile::tempdir().expect("upstream");
let bare = upstream.path().join("origin.git");
let git = |args: &[&str], cwd: &std::path::Path| {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git");
assert!(
out.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
git(
&[
"init",
"-q",
"--bare",
"-b",
"trunk",
bare.to_str().unwrap(),
],
upstream.path(),
);
git(&["symbolic-ref", "HEAD", "refs/heads/trunk"], &bare);
let wt = upstream.path().join("wt");
fs::create_dir_all(&wt).expect("mkdir");
git(&["init", "-q"], &wt);
git(&["config", "user.email", "t@example.com"], &wt);
git(&["config", "user.name", "Test"], &wt);
fs::write(wt.join("f"), b"x\n").expect("write");
git(&["add", "f"], &wt);
git(&["commit", "-m", "c"], &wt);
git(&["branch", "feature"], &wt);
git(&["push", bare.to_str().unwrap(), "feature"], &wt);
let dest = tempfile::tempdir().expect("dest");
let outcome = clone(
CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
&mut crate::fetch::NoProgress,
None,
)
.expect("clone");
assert_eq!(outcome.head.branch_name(), Some("trunk"));
assert!(outcome.checkout_oid.is_zero());
assert_eq!(outcome.checkout, CloneCheckout::UnbornDefaultBranch);
assert!(
outcome
.branches_with_commits
.contains(&"feature".to_owned()),
"{:?}",
outcome.branches_with_commits
);
assert!(!dest.path().join(".git/refs/heads/trunk").exists());
assert!(!dest.path().join(".git/refs/heads/feature").exists());
let head = fs::read_to_string(dest.path().join(".git/HEAD")).expect("HEAD");
assert_eq!(head.trim(), "ref: refs/heads/trunk");
}
#[test]
fn clone_empty_bare_remote_leaves_unborn_default_branch() {
use std::process::Command;
let upstream = tempfile::tempdir().expect("upstream");
let bare = upstream.path().join("origin.git");
let out = Command::new("git")
.args(["init", "-q", "--bare", "-b", "main"])
.arg(&bare)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git init");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let dest = tempfile::tempdir().expect("dest");
let outcome = clone(
CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
&mut crate::fetch::NoProgress,
None,
)
.expect("clone empty bare");
assert_eq!(outcome.head.branch_name(), Some("main"));
assert!(outcome.checkout_oid.is_zero());
assert_eq!(outcome.checkout, CloneCheckout::EmptyRemote);
assert!(outcome.branches_with_commits.is_empty());
assert!(!dest.path().join(".git").join("refs/heads/main").exists());
let head = fs::read_to_string(dest.path().join(".git/HEAD")).expect("HEAD");
assert_eq!(head.trim(), "ref: refs/heads/main");
}
#[test]
fn classify_unborn_default_when_master_advertised_but_main_has_commits() {
use std::process::Command;
let upstream = tempfile::tempdir().expect("upstream");
let bare = upstream.path().join("remote.git");
let git = |args: &[&str], cwd: &std::path::Path| {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git");
assert!(
out.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
git(
&["init", "-q", "--bare", bare.to_str().unwrap()],
upstream.path(),
);
let src = upstream.path().join("src");
fs::create_dir_all(&src).expect("mkdir");
git(&["init", "-q", "-b", "main"], &src);
git(&["config", "user.email", "a@b.c"], &src);
git(&["config", "user.name", "a"], &src);
fs::write(src.join("f"), b"x\n").expect("write");
git(&["add", "f"], &src);
git(&["commit", "-m", "c1"], &src);
git(&["remote", "add", "origin", bare.to_str().unwrap()], &src);
git(&["push", "-u", "origin", "main"], &src);
let dest = tempfile::tempdir().expect("dest");
let outcome = clone(
CloneOptions::new(bare.to_string_lossy(), dest.path().to_path_buf()),
&mut crate::fetch::NoProgress,
None,
)
.expect("clone");
assert!(outcome.checkout_oid.is_zero());
assert_eq!(outcome.checkout, CloneCheckout::UnbornDefaultBranch);
assert!(
outcome.branches_with_commits.contains(&"main".to_owned()),
"{:?}",
outcome.branches_with_commits
);
assert!(outcome.transfer.objects > 0);
}
#[test]
fn derive_clone_dir_windows_style_paths() {
assert_eq!(
derive_clone_dir(r"..\..\origin.git").expect("name"),
"origin"
);
assert_eq!(
derive_clone_dir(r"C:\scratch\a\origin.git").expect("name"),
"origin"
);
}
fn git_cmd(args: &[&str], cwd: &std::path::Path) {
use std::process::Command;
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git");
assert!(
out.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn git_out(args: &[&str], cwd: &std::path::Path) -> String {
use std::process::Command;
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git");
assert!(out.status.success(), "git {:?} failed", args);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn shallow_file_oids(path: &std::path::Path) -> Vec<String> {
let shallow = path.join(".git/shallow");
if !shallow.is_file() {
return Vec::new();
}
std::fs::read_to_string(shallow)
.expect("read shallow")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect()
}
fn git_fsck_strict(work_tree: &std::path::Path) {
use std::process::Command;
let out = Command::new("git")
.args(["fsck", "--strict"])
.current_dir(work_tree)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git fsck");
assert!(
out.status.success(),
"git fsck --strict: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn build_feature_upstream(bare: &std::path::Path) {
let wt = bare.with_extension("wt");
fs::create_dir_all(&wt).expect("mkdir");
git_cmd(&["init", "-q", "-b", "main", "."], &wt);
git_cmd(&["config", "user.email", "t@example.com"], &wt);
git_cmd(&["config", "user.name", "Test"], &wt);
for i in 1..=5 {
fs::write(wt.join("f"), format!("{i}\n")).expect("write");
git_cmd(&["add", "f"], &wt);
git_cmd(&["commit", "-q", "-m", &format!("c{i}")], &wt);
}
git_cmd(&["branch", "feature", "HEAD~2"], &wt);
git_cmd(&["tag", "v1", "HEAD~1"], &wt);
let url = format!("file://{}", bare.to_string_lossy());
git_cmd(&["clone", "-q", "--bare", ".", bare.to_str().unwrap()], &wt);
}
#[test]
fn clone_depth_n_writes_shallow_like_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("remote.git");
build_feature_upstream(&bare);
let url = format!("file://{}", bare.to_string_lossy());
let grit_dest = tmp.path().join("grit");
clone(
CloneOptions {
depth: Some(3),
..CloneOptions::new(url.clone(), grit_dest.clone())
},
&mut crate::fetch::NoProgress,
None,
)
.expect("grit shallow clone");
let git_dest = tmp.path().join("git");
git_cmd(
&[
"clone",
"-q",
"--depth",
"3",
url.as_str(),
git_dest.to_str().unwrap(),
],
tmp.path(),
);
assert_eq!(
shallow_file_oids(&grit_dest),
shallow_file_oids(&git_dest),
"shallow boundary set must match git"
);
assert_eq!(
git_out(&["rev-list", "--count", "HEAD"], &grit_dest),
git_out(&["rev-list", "--count", "HEAD"], &git_dest)
);
git_fsck_strict(&grit_dest);
assert!(git_out(&["log", "--oneline"], &grit_dest).lines().count() >= 1);
}
#[test]
fn clone_branch_checks_out_named_branch() {
for depth in [None, Some(3u32)] {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("remote.git");
build_feature_upstream(&bare);
let url = format!("file://{}", bare.to_string_lossy());
let grit_dest = tmp.path().join("grit");
clone(
CloneOptions {
depth,
branch: Some("feature".to_owned()),
..CloneOptions::new(url.clone(), grit_dest.clone())
},
&mut crate::fetch::NoProgress,
None,
)
.expect("grit clone -b feature");
let git_dest = tmp.path().join("git");
match depth {
None => git_cmd(
&[
"clone",
"-q",
"-b",
"feature",
url.as_str(),
git_dest.to_str().unwrap(),
],
tmp.path(),
),
Some(d) => {
let depth_s = d.to_string();
git_cmd(
&[
"clone",
"-q",
"--depth",
depth_s.as_str(),
"-b",
"feature",
url.as_str(),
git_dest.to_str().unwrap(),
],
tmp.path(),
);
}
}
assert_eq!(
fs::read_to_string(grit_dest.join(".git/HEAD"))
.expect("HEAD")
.trim(),
fs::read_to_string(git_dest.join(".git/HEAD"))
.expect("HEAD")
.trim()
);
assert_eq!(
git_out(&["config", "--get", "branch.feature.remote"], &grit_dest),
git_out(&["config", "--get", "branch.feature.remote"], &git_dest)
);
assert_eq!(
git_out(&["config", "--get", "branch.feature.merge"], &grit_dest),
git_out(&["config", "--get", "branch.feature.merge"], &git_dest)
);
assert_eq!(
git_out(&["config", "--get", "remote.origin.fetch"], &grit_dest),
git_out(&["config", "--get", "remote.origin.fetch"], &git_dest)
);
git_fsck_strict(&grit_dest);
}
}
#[test]
fn clone_tag_detaches_head() {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("remote.git");
build_feature_upstream(&bare);
let url = format!("file://{}", bare.to_string_lossy());
let tag_commit = git_out(&["rev-parse", "v1^{}"], &bare);
let grit_dest = tmp.path().join("grit");
let outcome = clone(
CloneOptions {
branch: Some("v1".to_owned()),
..CloneOptions::new(url, grit_dest.clone())
},
&mut crate::fetch::NoProgress,
None,
)
.expect("clone tag");
assert!(outcome.head.is_detached());
assert_eq!(outcome.head.tag_name(), Some("v1"));
assert_eq!(git_out(&["rev-parse", "HEAD"], &grit_dest), tag_commit);
let head = fs::read_to_string(grit_dest.join(".git/HEAD")).expect("HEAD");
assert!(!head.starts_with("ref: "));
let config = fs::read_to_string(grit_dest.join(".git/config")).expect("config");
assert!(!config.contains("[branch "));
git_fsck_strict(&grit_dest);
}
#[test]
fn clone_missing_branch_is_typed_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("remote.git");
build_feature_upstream(&bare);
let url = format!("file://{}", bare.to_string_lossy());
let dest = tmp.path().join("dest");
let err = clone(
CloneOptions {
branch: Some("no-such-ref".to_owned()),
..CloneOptions::new(url, dest.clone())
},
&mut crate::fetch::NoProgress,
None,
)
.expect_err("missing ref");
assert!(matches!(err, CloneError::RemoteRefNotFound(_)));
assert!(err.should_cleanup_failed_clone());
cleanup_failed_clone(&dest, false);
assert!(!dest.exists());
}
#[test]
fn clone_rejects_nonempty_destination_without_git_dir() {
let upstream = tempfile::tempdir().expect("upstream");
let dest = tempfile::tempdir().expect("dest");
fs::write(dest.path().join("notes.txt"), b"keep").expect("write");
let err = clone(
CloneOptions::new(upstream.path().to_string_lossy(), dest.path().to_path_buf()),
&mut crate::fetch::NoProgress,
None,
)
.expect_err("non-empty dest");
assert!(matches!(err, CloneError::DestNotEmpty(_)));
assert!(dest.path().join("notes.txt").is_file());
assert!(!dest.path().join(".git").exists());
}
}