crates/gunnar-client/src/clone.rs
//! `gunnar clone --depth N`: the clone that `nornir-git` cannot do.
//!
//! # Why there is a second clone path, and exactly how wide it is
//!
//! Ordinary `gunnar clone` is [`crate::fetch::clone_or_fetch`], which delegates
//! to `nornir_git::gitio::clone_or_fetch` — proven against codeberg/Forgejo and
//! GitHub, and reused rather than copied (one implementation). Its signature is
//! `(url, dest, ssh_key)`. There is no depth in it, no filter, and it is a
//! published crates.io crate, so gunnar cannot add one: patching it onto a
//! sibling checkout to make room for one is not allowed here.
//!
//! So a shallow clone has to be assembled here. What that costs is deliberately
//! small, and it is small because the three steps are all things gunnar already
//! owns exactly once:
//!
//! | step | writer |
//! |---|---|
//! | directory, object format, `HEAD`, `remote.origin` | [`crate::fetch::init_clone_dir`] — the same one the empty-remote clone uses |
//! | the fetch itself, with the shallow request | [`crate::fetch::fetch_with_io_opts`] — the same one every gunnar fetch uses |
//! | local branch + worktree | `nornir_git::gitio::set_head_and_checkout` |
//!
//! Nothing about the wire, the pack, the refs or the checkout is written twice.
//! What is new is the *order*, which is the only thing `clone_or_fetch` was
//! hiding.
//!
//! # Why the plain clone still goes the old way
//!
//! Because it is proven and this is not, yet. `gunnar clone` routes here only
//! when the command line asks for something the old path cannot express; with no
//! shallow flags it is byte-for-byte the behaviour it had. Collapsing the two is
//! the right end state and is a deliberate follow-up, not an oversight —
//! `crate::fetch::clone_or_fetch` also carries the https→ssh coercion and the
//! already-a-checkout branch, neither of which belongs in a first cut.
use std::path::Path;
use crate::error::{Error, Result};
use crate::fetch::{fetch_with_io_opts, init_clone_dir, remote_identity_with_io, FetchOptions};
/// What a [`clone_with_io`] produced.
#[derive(Debug, Clone)]
pub struct CloneReport {
/// The full ref name `HEAD` was pointed at, e.g. `refs/heads/main`.
pub branch: String,
/// The commit that branch resolves to, or `None` for a remote with no
/// commits.
pub head: Option<gix::hash::ObjectId>,
/// The object format the clone was created in — the **remote's**, read from
/// its `object-format` capability.
pub hash_kind: gix::hash::Kind,
/// Where history is cut, read back from `.git/shallow` after the fetch.
/// Empty for a complete clone.
pub shallow_boundary: Vec<gix::hash::ObjectId>,
/// Objects the pack carried.
pub objects_received: u64,
}
/// The refspecs a clone fetches. `git clone` writes the first into
/// `remote.origin.fetch` and picks up tags along the way.
const CLONE_REFSPECS: &[&str] = &[
"+refs/heads/*:refs/remotes/origin/*",
"+refs/tags/*:refs/tags/*",
];
/// Clone over an already-connected transport, with a [`FetchOptions`].
///
/// `identity` is the remote's own answer about itself — object format and the
/// symbolic `HEAD` — obtained from a **previous** connection with
/// [`remote_identity_with_io`], because the object format has to be known before
/// the repository is created and a repository created in the wrong one can never
/// be corrected. `git clone` learns both in the same connection it fetches on;
/// splitting it in two costs one extra handshake and buys the ability to test
/// this over a pair of pipes at all.
///
/// The reader/writer pair must be a **fresh** `git-upload-pack`.
pub fn clone_with_io<R: std::io::Read, W: std::io::Write>(
identity: &crate::fetch::RemoteIdentity,
reader: R,
writer: W,
url: &str,
path: &str,
dest: &Path,
options: &FetchOptions,
) -> Result<CloneReport> {
if !identity.has_refs {
// A shallow clone of a repository with no commits is a shallow clone of
// nothing. Said out loud rather than silently ignored, because the
// alternative is a `.git/shallow`-less clone from a command line that
// asked for one.
return Err(Error::repo_msg(format!(
"{url} has no commits yet, so there is no history to make shallow; \
clone it without a depth"
)));
}
let branch = identity
.head_target
.clone()
// A remote that advertises refs but no symbolic HEAD is unusual and not
// a reason to fail: git falls back to the configured init default, and
// so does this. Named so a reader knows it is a fallback.
.unwrap_or_else(|| "refs/heads/main".to_string());
let repo = init_clone_dir(dest, url, identity.hash_kind, &branch)?;
let outcome = fetch_with_io_opts(&repo, reader, writer, url, path, CLONE_REFSPECS, options)?;
// The commit the clone checks out is read from what the fetch WROTE, not
// from the advertisement: a refspec that mapped nothing leaves no tracking
// ref, and checking out the advertised oid anyway would produce a worktree
// whose HEAD names an object no local ref reaches.
let short = branch.strip_prefix("refs/heads/").unwrap_or(&branch);
let tracking = format!("refs/remotes/origin/{short}");
let head = outcome
.updated_refs
.iter()
.find(|u| u.name == tracking)
.and_then(|u| u.new);
let head = head.ok_or_else(|| {
Error::repo_msg(format!(
"the fetch wrote no {tracking}, so {url}'s {branch} did not arrive; \
{} local refs were written",
outcome.updated_refs.len()
))
})?;
// `set_head_and_checkout` creates `refs/heads/<short>` at `head`, points
// `HEAD` at it and materialises the worktree — `nornir-git`'s, so gunnar has
// one checkout implementation and not two.
// `repo_chain`, not `repo`: gix's top line here is "set HEAD + branch",
// which names the step and nothing about why it failed. See
// [`Error::repo_chain`].
nornir_git::gitio::set_head_and_checkout(dest, short, &head.to_string()).map_err(|e| {
Error::repo_chain(
format!("check out {short} in {}", dest.display()),
e.as_ref(),
)
})?;
// `materialize_worktree` runs the configured smudge driver (nornir-git
// 0.1.11), but where no driver is configured or it hands the stored blob back
// (`GIT_LFS_SKIP_SMUDGE=1`) this path can leave Git-LFS pointers exactly as
// `clone_or_fetch` can. Both
// clone paths therefore end in the SAME guard rather than each deciding for
// itself what an unfilterable checkout means (one implementation); see
// [`crate::attributes`].
crate::attributes::guard_checkout(dest)?;
Ok(CloneReport {
branch,
head: Some(head),
hash_kind: identity.hash_kind,
shallow_boundary: outcome.shallow_boundary,
objects_received: outcome.objects_received,
})
}
/// [`clone_with_io`] over SSH: two connections, one to ask the remote what it is
/// and one to fetch.
pub fn clone_ssh_with(
url: &str,
dest: &Path,
ssh: &crate::ssh::SshOptions,
options: &FetchOptions,
) -> Result<CloneReport> {
let loc = nornir_git::ssh::parse_ssh_url(url).map_err(Error::Ssh)?;
let path = loc.path.clone();
let identity = {
let mut up = crate::ssh::connect_upload_pack(&loc, ssh)?;
let (reader, writer) = up.io();
remote_identity_with_io(reader, writer, &path)?
};
let mut up = crate::ssh::connect_upload_pack(&loc, ssh)?;
let (reader, writer) = up.io();
clone_with_io(&identity, reader, writer, url, &path, dest, options)
}