Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
}