Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
feat(clone): initial groundwork for rad clone --meta flag
✗ CI failure Quaylyn Rimer committed 3 months ago
commit f56315a766e2b84d035eaeb8f02545e103ac8c06
parent 589925e3a3d792b321661a2e3f33b1f38be15063
1 failed (1 total) View logs
17 files changed +532 -101
added PROPOSAL-clone-meta.md
@@ -0,0 +1,74 @@
+
# Proposal: `rad clone --meta`
+

+
## Summary
+
Add a `--meta` flag to `rad clone` to fetch only basic project metadata
+
(name, description, and Git hash) from a seeder without fetching Git data or
+
materializing a working copy.
+

+
Example:
+

+
```
+
rad clone rad:id --meta
+
```
+

+
## Motivation
+
Users often want a quick, low-bandwidth way to inspect a project before
+
downloading all Git objects. A metadata-only clone enables fast discovery,
+
better UX on slow links, and reduces resource usage.
+

+
## Goals
+
- Fetch only project metadata from a seeder.
+
- Provide a consistent local representation so existing commands (eg.
+
  `rad inspect`, `rad id`, or similar) can show metadata immediately.
+
- Keep behavior explicit and opt-in.
+

+
## Non-goals
+
- No Git refs, packfiles, or working directory checkout.
+
- No partial file history or selective ref download.
+
- No change to default `rad clone` behavior.
+

+
## User experience
+
- `rad clone rad:id --meta`:
+
  - Contacts the seeder(s) and fetches metadata only.
+
  - Stores metadata in local storage.
+
  - Does **not** create a working directory checkout.
+
- A follow-up command can be used to fetch full data later (eg. `rad fetch`
+
  or `rad clone` without `--meta`).
+

+
## Metadata scope
+
Metadata includes:
+
- Project name.
+
- Project description.
+
- Project Git hash.
+

+
## Implementation outline
+
1. CLI: add `--meta` flag to `rad clone`.
+
2. Clone path:
+
   - Use a metadata-only refspec to fetch project metadata refs (eg.
+
     project/identity COBs) from the seeder.
+
   - Skip Git data fetching and working tree checkout.
+
3. Local storage:
+
   - Persist metadata so existing inspection commands can read it.
+
4. UX messaging:
+
   - Inform the user that this is metadata-only and how to fetch full data.
+

+
## Compatibility
+
- Fully backwards compatible: no change to default clone behavior.
+
- Seeders not supporting metadata-only fetch still allow a full clone without
+
  `--meta`.
+

+
## Security and privacy
+
- No additional data is disclosed beyond existing metadata.
+
- Reduces exposure by avoiding Git object transfer when not needed.
+

+
## Open questions
+
- Exact refspec for metadata-only fetch (project/identity COB refs).
+
- Should `--meta` imply `--bare`, or should it create a minimal local state
+
  without a workdir? (Preference: no workdir, metadata-only state.)
+
- Should a metadata-only clone be upgradable in-place to a full clone, or
+
  should the user re-run `rad clone` without `--meta`?
+

+
## Testing
+
- CLI parsing test: `rad clone --meta` recognized.
+
- Integration test: metadata present after clone; no Git refs fetched.
+
- Regression test: default clone unchanged.
modified crates/radicle-cli/src/commands/clone.rs
@@ -9,8 +9,10 @@ use thiserror::Error;
use radicle::git::raw;
use radicle::identity::doc;
use radicle::identity::doc::RepoId;
+
use radicle::identity::DocAt;
use radicle::node;
use radicle::node::policy::Scope;
+
use radicle::node::FetchMode;
use radicle::node::{Handle as _, Node};
use radicle::prelude::*;
use radicle::rad;
@@ -37,6 +39,31 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
        );
    }

+
    if args.meta {
+
        let doc = clone_meta(
+
            args.repo,
+
            args.scope,
+
            SyncSettings::from(args.sync).with_profile(&profile),
+
            &mut node,
+
            &profile,
+
        )?;
+
        let proj = doc.project()?;
+

+
        term::success!("Fetched metadata for {}", term::format::tertiary(args.repo));
+

+
        let mut info: term::Table<1, term::Line> = term::Table::new(term::TableOptions::bordered());
+
        info.push([term::format::bold(proj.name()).into()]);
+
        info.push([term::format::italic(proj.description()).into()]);
+
        info.push([term::Line::spaced([
+
            term::format::secondary("git").into(),
+
            term::format::dim("·").into(),
+
            term::format::oid(doc.commit).into(),
+
        ])]);
+
        info.print();
+

+
        return Ok(());
+
    }
+

    let Success {
        working_copy: working,
        repository: repo,
@@ -111,12 +138,42 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    Ok(())
}

+
fn clone_meta(
+
    id: RepoId,
+
    scope: Scope,
+
    settings: SyncSettings,
+
    node: &mut Node,
+
    profile: &Profile,
+
) -> Result<DocAt, CloneError> {
+
    if node.seed(id, scope)? {
+
        term::success!(
+
            "Seeding policy updated for {} with scope '{scope}'",
+
            term::format::tertiary(id)
+
        );
+
    }
+

+
    let settings = settings.replicas(node::sync::ReplicationFactor::must_reach(1));
+
    let result = sync::fetch_with_mode(id, settings, node, profile, FetchMode::Meta)?;
+

+
    match &result {
+
        node::sync::FetcherResult::TargetReached(_) => {
+
            let repo = profile.storage.repository(id)?;
+
            let doc = repo.identity_doc()?;
+

+
            Ok(doc)
+
        }
+
        node::sync::FetcherResult::TargetError(failure) => Err(handle_fetch_error(id, failure)),
+
    }
+
}
+

#[derive(Error, Debug)]
enum CloneError {
    #[error("node: {0}")]
    Node(#[from] node::Error),
    #[error("checkout: {0}")]
    Checkout(#[from] rad::CheckoutError),
+
    #[error("storage: {0}")]
+
    Storage(#[from] RepositoryError),
    #[error("no seeds found for {0}")]
    NoSeeds(RepoId),
    #[error("fetch: {0}")]
modified crates/radicle-cli/src/commands/clone/args.rs
@@ -70,6 +70,10 @@ pub struct Args {
    #[clap(flatten)]
    pub(super) sync: SyncArgs,

+
    /// Fetch metadata only (no working copy or Git refs)
+
    #[arg(long, conflicts_with_all = ["bare", "directory"])]
+
    pub(super) meta: bool,
+

    /// Make a bare repository
    #[arg(long)]
    pub(super) bare: bool,
@@ -102,4 +106,10 @@ mod test {
        let args = Args::try_parse_from(["clone", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
        assert!(args.is_ok())
    }
+

+
    #[test]
+
    fn should_parse_meta_flag() {
+
        let args = Args::try_parse_from(["clone", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH", "--meta"]);
+
        assert!(args.is_ok())
+
    }
}
modified crates/radicle-cli/src/commands/sync.rs
@@ -11,6 +11,7 @@ use radicle::node;
use radicle::node::address::Store;
use radicle::node::sync;
use radicle::node::sync::fetch::SuccessfulOutcome;
+
use radicle::node::FetchMode;
use radicle::node::SyncedAt;
use radicle::node::{AliasStore, Handle as _, Node, Seed, SyncStatus};
use radicle::prelude::{NodeId, Profile, RepoId};
@@ -265,6 +266,16 @@ pub fn fetch(
    node: &mut Node,
    profile: &Profile,
) -> Result<sync::FetcherResult, FetchError> {
+
    fetch_with_mode(rid, settings, node, profile, FetchMode::Full)
+
}
+

+
pub fn fetch_with_mode(
+
    rid: RepoId,
+
    settings: SyncSettings,
+
    node: &mut Node,
+
    profile: &Profile,
+
    mode: FetchMode,
+
) -> Result<sync::FetcherResult, FetchError> {
    let db = profile.database()?;
    let local = profile.id();
    let is_private = profile.storage.repository(rid).ok().and_then(|repo| {
@@ -324,7 +335,7 @@ pub fn fetch(
        }
        if let Some((nid, addr)) = fetcher.next_fetch() {
            spinner.emit_fetching(&nid, &addr, &progress);
-
            let result = node.fetch(rid, nid, settings.timeout)?;
+
            let result = node.fetch_with_mode(rid, nid, settings.timeout, mode)?;
            match fetcher.fetch_complete(nid, result) {
                std::ops::ControlFlow::Continue(update) => {
                    spinner.emit_progress(&update);
modified crates/radicle-fetch/src/lib.rs
@@ -22,6 +22,7 @@ pub use state::{FetchLimit, FetchResult};
pub use transport::Transport;

use radicle::crypto::PublicKey;
+
use radicle::git::Oid;
use radicle::storage::refs::RefsAt;
use radicle::storage::ReadRepository as _;
use state::FetchState;
@@ -44,6 +45,13 @@ pub enum Error {
    ReplicateSelf,
}

+
/// Metadata-only fetch result.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
pub struct MetadataFetch {
+
    /// Canonical `rad/id` commit.
+
    pub canonical: Oid,
+
}
+

impl From<HandshakeError> for Error {
    fn from(err: HandshakeError) -> Self {
        Self::Handshake(Box::new(err))
@@ -95,6 +103,46 @@ where
    result
}

+
/// Pull metadata only from the `remote`.
+
pub fn pull_metadata<R, S>(
+
    handle: &mut Handle<R, S>,
+
    limit: FetchLimit,
+
    remote: PublicKey,
+
) -> Result<MetadataFetch, Error>
+
where
+
    R: AsRef<Repository>,
+
    S: transport::ConnectionStream,
+
{
+
    let local = *handle.local();
+
    if local == remote {
+
        return Err(Error::ReplicateSelf);
+
    }
+
    let handshake = perform_handshake(handle)?;
+
    let mut state = FetchState::default();
+

+
    // N.b. ensure that we ignore the local peer's key.
+
    handle.blocked.extend([local]);
+
    state
+
        .run_stage(
+
            handle,
+
            &handshake,
+
            &stage::CanonicalId {
+
                remote,
+
                limit: limit.special,
+
            },
+
        )
+
        .map_err(state::error::Protocol::from)
+
        .map_err(Error::Protocol)?;
+

+
    let _ = handle.transport.done();
+
    let canonical = state
+
        .canonical_rad_id()
+
        .copied()
+
        .ok_or(Error::MissingRadId)?;
+

+
    Ok(MetadataFetch { canonical })
+
}
+

/// Clone changes from the `remote`.
///
/// It is expected that the local peer has an empty repository which
@@ -131,6 +179,56 @@ where
    result
}

+
/// Clone metadata only from the `remote`.
+
pub fn clone_metadata<R, S>(
+
    handle: &mut Handle<R, S>,
+
    limit: FetchLimit,
+
    remote: PublicKey,
+
) -> Result<MetadataFetch, Error>
+
where
+
    R: AsRef<Repository>,
+
    S: transport::ConnectionStream,
+
{
+
    let start = Instant::now();
+
    if *handle.local() == remote {
+
        return Err(Error::ReplicateSelf);
+
    }
+
    let handshake = perform_handshake(handle)?;
+
    let mut state = FetchState::default();
+
    let result = state
+
        .run_stage(
+
            handle,
+
            &handshake,
+
            &stage::CanonicalId {
+
                remote,
+
                limit: limit.special,
+
            },
+
        )
+
        .map_err(state::error::Protocol::from)
+
        .map_err(Error::Protocol);
+

+
    let elapsed = start.elapsed().as_millis();
+
    let rid = handle.repository().id();
+

+
    match &result {
+
        Ok(_) => {
+
            log::debug!("Finished metadata clone of {rid} from {remote} ({elapsed}ms)");
+
        }
+
        Err(e) => {
+
            log::debug!("Metadata clone of {rid} from {remote} failed with '{e}' ({elapsed}ms)");
+
        }
+
    }
+
    result?;
+

+
    let _ = handle.transport.done();
+
    let canonical = state
+
        .canonical_rad_id()
+
        .copied()
+
        .ok_or(Error::MissingRadId)?;
+

+
    Ok(MetadataFetch { canonical })
+
}
+

fn perform_handshake<R, S>(handle: &mut Handle<R, S>) -> Result<handshake::Outcome, Error>
where
    S: transport::ConnectionStream,
modified crates/radicle-node/src/control.rs
@@ -159,8 +159,13 @@ where
                CommandResult::ok().to_writer(writer).ok();
            }
        },
-
        Command::Fetch { rid, nid, timeout } => {
-
            fetch(rid, nid, timeout, writer, &mut handle)?;
+
        Command::Fetch {
+
            rid,
+
            nid,
+
            timeout,
+
            mode,
+
        } => {
+
            fetch(rid, nid, timeout, mode, writer, &mut handle)?;
        }
        Command::Config => {
            let config = handle.config()?;
@@ -287,10 +292,11 @@ fn fetch<W: Write, H: Handle<Error = runtime::HandleError>>(
    id: RepoId,
    node: NodeId,
    timeout: time::Duration,
+
    mode: radicle::node::FetchMode,
    mut writer: W,
    handle: &mut H,
) -> Result<(), CommandError> {
-
    match handle.fetch(id, node, timeout) {
+
    match handle.fetch_with_mode(id, node, timeout, mode) {
        Ok(result) => {
            json::to_writer(&mut writer, &result)?;
        }
modified crates/radicle-node/src/runtime/handle.rs
@@ -230,8 +230,18 @@ impl radicle::node::Handle for Handle {
        from: NodeId,
        timeout: time::Duration,
    ) -> Result<FetchResult, Error> {
+
        self.fetch_with_mode(id, from, timeout, radicle::node::FetchMode::Full)
+
    }
+

+
    fn fetch_with_mode(
+
        &mut self,
+
        id: RepoId,
+
        from: NodeId,
+
        timeout: time::Duration,
+
        mode: radicle::node::FetchMode,
+
    ) -> Result<FetchResult, Error> {
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Fetch(id, from, timeout, sender))?;
+
        self.command(service::Command::Fetch(id, from, timeout, mode, sender))?;
        receiver.recv().map_err(Error::from)
    }

modified crates/radicle-node/src/tests.rs
@@ -17,7 +17,7 @@ use radicle::node::policy;
use radicle::node::refs::Store as _;
use radicle::node::routing::Store as _;
use radicle::node::Link;
-
use radicle::node::{ConnectOptions, DEFAULT_TIMEOUT};
+
use radicle::node::{ConnectOptions, FetchMode, DEFAULT_TIMEOUT};
use radicle::storage::refs::RefsAt;
use radicle::storage::RefUpdate;
use radicle::test::arbitrary::gen;
@@ -1492,15 +1492,33 @@ fn test_queued_fetch_max_capacity() {

    // Send the first fetch.
    let (send, _recv1) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, bob.id, DEFAULT_TIMEOUT, send));
+
    alice.command(Command::Fetch(
+
        rid1,
+
        bob.id,
+
        DEFAULT_TIMEOUT,
+
        FetchMode::Full,
+
        send,
+
    ));

    // Send the 2nd fetch that will be queued.
    let (send2, _recv2) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid2, bob.id, DEFAULT_TIMEOUT, send2));
+
    alice.command(Command::Fetch(
+
        rid2,
+
        bob.id,
+
        DEFAULT_TIMEOUT,
+
        FetchMode::Full,
+
        send2,
+
    ));

    // Send the 3rd fetch that will be queued.
    let (send3, _recv3) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid3, bob.id, DEFAULT_TIMEOUT, send3));
+
    alice.command(Command::Fetch(
+
        rid3,
+
        bob.id,
+
        DEFAULT_TIMEOUT,
+
        FetchMode::Full,
+
        send3,
+
    ));

    // The first fetch is initiated.
    assert_matches!(alice.fetches().next(), Some((rid, _)) if rid == rid1);
@@ -1615,15 +1633,33 @@ fn test_queued_fetch_from_command_same_rid() {

    // Send the first fetch.
    let (send, _recv1) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, bob.id, DEFAULT_TIMEOUT, send));
+
    alice.command(Command::Fetch(
+
        rid1,
+
        bob.id,
+
        DEFAULT_TIMEOUT,
+
        FetchMode::Full,
+
        send,
+
    ));

    // Send the 2nd fetch that will be queued.
    let (send2, _recv2) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, eve.id, DEFAULT_TIMEOUT, send2));
+
    alice.command(Command::Fetch(
+
        rid1,
+
        eve.id,
+
        DEFAULT_TIMEOUT,
+
        FetchMode::Full,
+
        send2,
+
    ));

    // Send the 3rd fetch that will be queued.
    let (send3, _recv3) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, carol.id, DEFAULT_TIMEOUT, send3));
+
    alice.command(Command::Fetch(
+
        rid1,
+
        carol.id,
+
        DEFAULT_TIMEOUT,
+
        FetchMode::Full,
+
        send3,
+
    ));

    // Peers Alice will fetch from.
    let mut peers = [bob.id, eve.id, carol.id]
modified crates/radicle-node/src/wire.rs
@@ -1019,6 +1019,7 @@ where
                    timeout,
                    reader_limit,
                    refs_at,
+
                    mode,
                } => {
                    log::trace!(target: "wire", "Processing fetch for {rid} from {remote}..");

@@ -1043,6 +1044,7 @@ where
                            rid,
                            remote,
                            refs_at,
+
                            mode,
                        },
                        stream,
                        channels,
modified crates/radicle-node/src/worker.rs
@@ -131,9 +131,10 @@ impl Worker {
                rid,
                remote,
                refs_at,
+
                mode,
            } => {
                log::debug!(target: "worker", "Worker processing outgoing fetch for {rid}");
-
                let result = self.fetch(rid, remote, refs_at, channels, notifs);
+
                let result = self.fetch(rid, remote, refs_at, mode, channels, notifs);
                FetchResult::Initiator { rid, result }
            }
            FetchRequest::Responder { remote, emitter } => {
@@ -203,6 +204,7 @@ impl Worker {
        rid: RepoId,
        remote: NodeId,
        refs_at: Option<Vec<RefsAt>>,
+
        mode: radicle::node::FetchMode,
        channels: channels::ChannelsFlush,
        notifs: notifications::StoreWriter,
    ) -> Result<fetch::FetchResult, FetchError> {
@@ -234,6 +236,7 @@ impl Worker {
            *limit,
            remote,
            refs_at,
+
            mode,
        )?;

        if let Err(e) = garbage::collect(&self.storage, rid, *expiry) {
modified crates/radicle-node/src/worker/fetch.rs
@@ -3,7 +3,7 @@ use radicle::identity::CanonicalRefs;
use radicle::storage::git::TempRepository;
pub(crate) use radicle_protocol::worker::fetch::error;

-
use std::collections::BTreeSet;
+
use std::collections::{BTreeSet, HashSet};
use std::str::FromStr;

use localtime::LocalTime;
@@ -11,6 +11,7 @@ use localtime::LocalTime;
use radicle::cob::TypedId;
use radicle::crypto::PublicKey;
use radicle::identity::crefs::GetCanonicalRefs as _;
+
use radicle::node::FetchMode;
use radicle::prelude::NodeId;
use radicle::prelude::RepoId;
use radicle::storage::git::Repository;
@@ -70,19 +71,38 @@ impl Handle {
        limit: FetchLimit,
        remote: PublicKey,
        refs_at: Option<Vec<RefsAt>>,
+
        mode: FetchMode,
    ) -> Result<FetchResult, error::Fetch> {
-
        let (result, clone, notifs) = match self {
+
        enum Outcome {
+
            Full(radicle_fetch::FetchResult),
+
            Meta { canonical: git::Oid },
+
        }
+

+
        let (outcome, clone, notifs) = match self {
            Self::Clone { mut handle } => {
                log::debug!(target: "worker", "{} cloning from {remote}", handle.local());
-
                match radicle_fetch::clone(&mut handle, limit, remote) {
-
                    Err(err) => {
-
                        handle.into_inner().cleanup();
-
                        return Err(err.into());
-
                    }
-
                    Ok(result) => {
+
                match mode {
+
                    FetchMode::Meta => {
+
                        let meta = radicle_fetch::clone_metadata(&mut handle, limit, remote)?;
                        handle.into_inner().mv(storage.path_of(&rid))?;
-
                        (result, true, None)
+
                        (
+
                            Outcome::Meta {
+
                                canonical: meta.canonical,
+
                            },
+
                            false,
+
                            None,
+
                        )
                    }
+
                    FetchMode::Full => match radicle_fetch::clone(&mut handle, limit, remote) {
+
                        Err(err) => {
+
                            handle.into_inner().cleanup();
+
                            return Err(err.into());
+
                        }
+
                        Ok(result) => {
+
                            handle.into_inner().mv(storage.path_of(&rid))?;
+
                            (Outcome::Full(result), true, None)
+
                        }
+
                    },
                }
            }
            Self::Pull {
@@ -90,81 +110,114 @@ impl Handle {
                notifications,
            } => {
                log::debug!(target: "worker", "{} pulling from {remote}", handle.local());
-
                let result = radicle_fetch::pull(&mut handle, limit, remote, refs_at)?;
-
                (result, false, Some(notifications))
+
                match mode {
+
                    FetchMode::Meta => {
+
                        let meta = radicle_fetch::pull_metadata(&mut handle, limit, remote)?;
+
                        (
+
                            Outcome::Meta {
+
                                canonical: meta.canonical,
+
                            },
+
                            false,
+
                            None,
+
                        )
+
                    }
+
                    FetchMode::Full => {
+
                        let result = radicle_fetch::pull(&mut handle, limit, remote, refs_at)?;
+
                        (Outcome::Full(result), false, Some(notifications))
+
                    }
+
                }
            }
        };

-
        for rejected in result.rejected() {
-
            log::debug!(target: "worker", "Rejected update for {}", rejected.refname())
-
        }
-

-
        match result {
-
            radicle_fetch::FetchResult::Failed {
-
                threshold,
-
                delegates,
-
                validations,
-
            } => {
-
                for fail in validations.iter() {
-
                    log::warn!(target: "worker", "Validation error: {fail}");
-
                }
-
                Err(error::Fetch::Validation {
-
                    threshold,
-
                    delegates: delegates.into_iter().map(|key| key.to_string()).collect(),
-
                })
-
            }
-
            radicle_fetch::FetchResult::Success {
-
                applied,
-
                remotes,
-
                validations,
-
            } => {
-
                for warn in validations {
-
                    log::debug!(target: "worker", "Validation error: {warn}");
+
        match outcome {
+
            Outcome::Full(result) => {
+
                for rejected in result.rejected() {
+
                    log::debug!(target: "worker", "Rejected update for {}", rejected.refname())
                }

-
                // N.b. We do not go through handle for this since the cloning handle
-
                // points to a repository that is temporary and gets moved by [`mv`].
-
                let repo = storage.repository(rid)?;
-
                repo.set_identity_head()?;
-
                match repo.set_head() {
-
                    Ok(head) => {
-
                        if head.is_updated() {
-
                            log::trace!(target: "worker", "Set HEAD to {}", head.new);
+
                match result {
+
                    radicle_fetch::FetchResult::Failed {
+
                        threshold,
+
                        delegates,
+
                        validations,
+
                    } => {
+
                        for fail in validations.iter() {
+
                            log::warn!(target: "worker", "Validation error: {fail}");
                        }
+
                        Err(error::Fetch::Validation {
+
                            threshold,
+
                            delegates: delegates.into_iter().map(|key| key.to_string()).collect(),
+
                        })
                    }
-
                    Err(RepositoryError::Quorum(e)) => {
-
                        log::warn!(target: "worker", "Fetch could not set HEAD for {rid}: {e}")
-
                    }
-
                    Err(e) => return Err(e.into()),
-
                }
+
                    radicle_fetch::FetchResult::Success {
+
                        applied,
+
                        remotes,
+
                        validations,
+
                    } => {
+
                        for warn in validations {
+
                            log::debug!(target: "worker", "Validation error: {warn}");
+
                        }

-
                let canonical = match set_canonical_refs(&repo, &applied) {
-
                    Ok(updates) => updates.unwrap_or_default(),
-
                    Err(e) => {
-
                        log::warn!(target: "worker", "Failed to set canonical references for {rid}: {e}");
-
                        UpdatedCanonicalRefs::default()
-
                    }
-
                };
-

-
                // Notifications are only posted for pulls, not clones.
-
                if let Some(mut store) = notifs {
-
                    // Only create notifications for repos that we have
-
                    // contributed to in some way, otherwise our inbox will
-
                    // be flooded by all the repos we are seeding.
-
                    if repo.remote(&storage.info().key).is_ok() {
-
                        notify(&rid, &applied, &mut store)?;
+
                        // N.b. We do not go through handle for this since the cloning handle
+
                        // points to a repository that is temporary and gets moved by [`mv`].
+
                        let repo = storage.repository(rid)?;
+
                        repo.set_identity_head()?;
+
                        match repo.set_head() {
+
                            Ok(head) => {
+
                                if head.is_updated() {
+
                                    log::trace!(target: "worker", "Set HEAD to {}", head.new);
+
                                }
+
                            }
+
                            Err(RepositoryError::Quorum(e)) => {
+
                                log::warn!(target: "worker", "Fetch could not set HEAD for {rid}: {e}")
+
                            }
+
                            Err(e) => return Err(e.into()),
+
                        }
+

+
                        let canonical = match set_canonical_refs(&repo, &applied) {
+
                            Ok(updates) => updates.unwrap_or_default(),
+
                            Err(e) => {
+
                                log::warn!(target: "worker", "Failed to set canonical references for {rid}: {e}");
+
                                UpdatedCanonicalRefs::default()
+
                            }
+
                        };
+

+
                        // Notifications are only posted for pulls, not clones.
+
                        if let Some(mut store) = notifs {
+
                            // Only create notifications for repos that we have
+
                            // contributed to in some way, otherwise our inbox will
+
                            // be flooded by all the repos we are seeding.
+
                            if repo.remote(&storage.info().key).is_ok() {
+
                                notify(&rid, &applied, &mut store)?;
+
                            }
+
                        }
+

+
                        cache_cobs(&rid, &applied.updated, &repo, cache)?;
+
                        cache_refs(&rid, &applied.updated, refsdb)?;
+

+
                        Ok(FetchResult {
+
                            updated: applied.updated,
+
                            canonical,
+
                            namespaces: remotes.into_iter().collect(),
+
                            doc: repo.identity_doc()?,
+
                            clone,
+
                        })
                    }
                }
-

-
                cache_cobs(&rid, &applied.updated, &repo, cache)?;
-
                cache_refs(&rid, &applied.updated, refsdb)?;
+
            }
+
            Outcome::Meta { canonical } => {
+
                let repo = storage.repository(rid)?;
+
                repo.set_identity_head_to(canonical)?;
+
                let doc = repo
+
                    .identity_doc_at(canonical)
+
                    .map_err(RepositoryError::from)?;

                Ok(FetchResult {
-
                    updated: applied.updated,
-
                    canonical,
-
                    namespaces: remotes.into_iter().collect(),
-
                    doc: repo.identity_doc()?,
-
                    clone,
+
                    updated: Vec::new(),
+
                    canonical: UpdatedCanonicalRefs::default(),
+
                    namespaces: HashSet::new(),
+
                    doc,
+
                    clone: false,
                })
            }
        }
modified crates/radicle-protocol/src/service.rs
@@ -33,7 +33,7 @@ use radicle::node::refs::Store as _;
use radicle::node::routing::Store as _;
use radicle::node::seed;
use radicle::node::seed::Store as _;
-
use radicle::node::{ConnectOptions, Penalty, Severity};
+
use radicle::node::{ConnectOptions, FetchMode, Penalty, Severity};
use radicle::storage::refs::SIGREFS_BRANCH;
use radicle::storage::RepositoryError;
use radicle_fetch::policy::SeedingPolicy;
@@ -254,7 +254,13 @@ pub enum Command {
    /// sync status for given namespaces.
    Seeds(RepoId, HashSet<PublicKey>, chan::Sender<Seeds>),
    /// Fetch the given repository from the network.
-
    Fetch(RepoId, NodeId, time::Duration, chan::Sender<FetchResult>),
+
    Fetch(
+
        RepoId,
+
        NodeId,
+
        time::Duration,
+
        FetchMode,
+
        chan::Sender<FetchResult>,
+
    ),
    /// Seed the given repository.
    Seed(RepoId, Scope, chan::Sender<bool>),
    /// Unseed the given repository.
@@ -278,7 +284,7 @@ impl fmt::Debug for Command {
            Self::Config(_) => write!(f, "Config"),
            Self::ListenAddrs(_) => write!(f, "ListenAddrs"),
            Self::Seeds(id, _, _) => write!(f, "Seeds({id})"),
-
            Self::Fetch(id, node, _, _) => write!(f, "Fetch({id}, {node})"),
+
            Self::Fetch(id, node, _, mode, _) => write!(f, "Fetch({id}, {node}, {mode:?})"),
            Self::Seed(id, scope, _) => write!(f, "Seed({id}, {scope})"),
            Self::Unseed(id, _) => write!(f, "Unseed({id})"),
            Self::Follow(id, _, _) => write!(f, "Follow({id})"),
@@ -325,6 +331,8 @@ pub struct FetchState {
    pub from: NodeId,
    /// What refs we're fetching.
    pub refs_at: Vec<RefsAt>,
+
    /// Fetch mode.
+
    pub mode: FetchMode,
    /// Channels waiting for fetch results.
    pub subscribers: Vec<chan::Sender<FetchResult>>,
}
@@ -895,8 +903,8 @@ where
                    warn!(target: "service", "Failed to get seeds for {rid}: {e}");
                }
            },
-
            Command::Fetch(rid, seed, timeout, resp) => {
-
                self.fetch(rid, seed, timeout, Some(resp));
+
            Command::Fetch(rid, seed, timeout, mode, resp) => {
+
                self.fetch(rid, seed, timeout, mode, Some(resp));
            }
            Command::Seed(rid, scope, resp) => {
                // Update our seeding policy.
@@ -981,6 +989,7 @@ where
        refs: NonEmpty<RefsAt>,
        scope: Scope,
        timeout: time::Duration,
+
        mode: FetchMode,
        channel: Option<chan::Sender<FetchResult>>,
    ) -> bool {
        match self.refs_status_of(rid, refs, &scope) {
@@ -988,7 +997,7 @@ where
                if status.want.is_empty() {
                    debug!(target: "service", "Skipping fetch for {rid}, all refs are already in storage");
                } else {
-
                    return self._fetch(rid, from, status.want, timeout, channel);
+
                    return self._fetch(rid, from, status.want, timeout, mode, channel);
                }
            }
            Err(e) => {
@@ -1005,9 +1014,10 @@ where
        rid: RepoId,
        from: NodeId,
        timeout: time::Duration,
+
        mode: FetchMode,
        channel: Option<chan::Sender<FetchResult>>,
    ) -> bool {
-
        self._fetch(rid, from, vec![], timeout, channel)
+
        self._fetch(rid, from, vec![], timeout, mode, channel)
    }

    fn _fetch(
@@ -1016,9 +1026,10 @@ where
        from: NodeId,
        refs_at: Vec<RefsAt>,
        timeout: time::Duration,
+
        mode: FetchMode,
        channel: Option<chan::Sender<FetchResult>>,
    ) -> bool {
-
        match self.try_fetch(rid, &from, refs_at.clone(), timeout) {
+
        match self.try_fetch(rid, &from, refs_at.clone(), timeout, mode) {
            Ok(fetching) => {
                if let Some(c) = channel {
                    fetching.subscribe(c);
@@ -1029,7 +1040,7 @@ where
                // If we're already fetching the same refs from the requested peer, there's nothing
                // to do, we simply add the supplied channel to the list of subscribers so that it
                // is notified on completion. Otherwise, we queue a fetch with the requested peer.
-
                if fetching.from == from && fetching.refs_at == refs_at {
+
                if fetching.from == from && fetching.refs_at == refs_at && fetching.mode == mode {
                    debug!(target: "service", "Ignoring redundant fetch of {rid} from {from}");

                    if let Some(c) = channel {
@@ -1041,6 +1052,7 @@ where
                        refs_at,
                        from,
                        timeout,
+
                        mode,
                        channel,
                    };
                    debug!(target: "service", "Queueing fetch for {rid} with {from} (already fetching)..");
@@ -1055,6 +1067,7 @@ where
                    refs_at,
                    from,
                    timeout,
+
                    mode,
                    channel,
                });
            }
@@ -1088,6 +1101,7 @@ where
        from: &NodeId,
        refs_at: Vec<RefsAt>,
        timeout: time::Duration,
+
        mode: FetchMode,
    ) -> Result<&mut FetchState, TryFetchError<'_>> {
        let from = *from;
        let Some(session) = self.sessions.get_mut(&from) else {
@@ -1121,6 +1135,7 @@ where
        let fetching = fetching.insert(FetchState {
            from,
            refs_at: refs_at.clone(),
+
            mode,
            subscribers: vec![],
        });
        self.outbox.fetch(
@@ -1128,6 +1143,7 @@ where
            rid,
            refs_at,
            timeout,
+
            mode,
            self.config.limits.fetch_pack_receive,
        );

@@ -1270,6 +1286,7 @@ where
                from,
                refs_at,
                timeout,
+
                mode,
                channel,
            }) = sess.dequeue_fetch()
            {
@@ -1283,10 +1300,10 @@ where
                        debug!(target: "service", "Repository {rid} is no longer seeded, skipping..");
                        continue;
                    };
-
                    self.fetch_refs_at(rid, from, refs, scope, timeout, channel);
+
                    self.fetch_refs_at(rid, from, refs, scope, timeout, mode, channel);
                } else {
                    // If no refs are specified, always do a full fetch.
-
                    self.fetch(rid, from, timeout, channel);
+
                    self.fetch(rid, from, timeout, mode, channel);
                }
            }
        }
@@ -1649,7 +1666,7 @@ where

                for rid in missing {
                    debug!(target: "service", "Missing seeded inventory {rid}; initiating fetch..");
-
                    self.fetch(rid, *announcer, FETCH_TIMEOUT, None);
+
                    self.fetch(rid, *announcer, FETCH_TIMEOUT, FetchMode::Full, None);
                }
                return Ok(relay);
            }
@@ -1730,7 +1747,15 @@ where
                    return Ok(relay);
                };
                // Finally, start the fetch.
-
                self.fetch_refs_at(message.rid, remote.id, refs, scope, FETCH_TIMEOUT, None);
+
                self.fetch_refs_at(
+
                    message.rid,
+
                    remote.id,
+
                    refs,
+
                    scope,
+
                    FETCH_TIMEOUT,
+
                    FetchMode::Full,
+
                    None,
+
                );

                return Ok(relay);
            }
@@ -2558,7 +2583,7 @@ where
                Ok(seeds) => {
                    if let Some(connected) = NonEmpty::from_vec(seeds.connected().collect()) {
                        for seed in connected {
-
                            self.fetch(rid, seed.nid, FETCH_TIMEOUT, None);
+
                            self.fetch(rid, seed.nid, FETCH_TIMEOUT, FetchMode::Full, None);
                        }
                    } else {
                        // TODO: We should make sure that this fetch is retried later, either
modified crates/radicle-protocol/src/service/io.rs
@@ -6,6 +6,7 @@ use log::*;
use radicle::identity::RepoId;
use radicle::node::config::FetchPackSizeLimit;
use radicle::node::Address;
+
use radicle::node::FetchMode;
use radicle::node::NodeId;
use radicle::storage::refs::RefsAt;

@@ -36,6 +37,8 @@ pub enum Io {
        refs_at: Option<Vec<RefsAt>>,
        /// Fetch timeout.
        timeout: time::Duration,
+
        /// Fetch mode.
+
        mode: FetchMode,
        /// Limit the number of bytes fetched.
        reader_limit: FetchPackSizeLimit,
    },
@@ -136,6 +139,7 @@ impl Outbox {
        rid: RepoId,
        refs_at: Vec<RefsAt>,
        timeout: time::Duration,
+
        mode: FetchMode,
        reader_limit: FetchPackSizeLimit,
    ) {
        peer.fetching(rid);
@@ -156,6 +160,7 @@ impl Outbox {
            refs_at,
            remote: peer.id,
            timeout,
+
            mode,
            reader_limit,
        });
    }
modified crates/radicle-protocol/src/service/session.rs
@@ -3,7 +3,7 @@ use std::{fmt, time};

use crossbeam_channel as chan;
use radicle::node::config::Limits;
-
use radicle::node::{FetchResult, Severity};
+
use radicle::node::{FetchMode, FetchResult, Severity};
use radicle::node::{Link, Timestamp};
pub use radicle::node::{PingState, State};
use radicle::storage::refs::RefsAt;
@@ -79,6 +79,8 @@ pub struct QueuedFetch {
    pub refs_at: Vec<RefsAt>,
    /// The timeout given for the fetch request.
    pub timeout: time::Duration,
+
    /// Fetch mode.
+
    pub mode: FetchMode,
    /// Result channel.
    pub channel: Option<chan::Sender<FetchResult>>,
}
@@ -88,6 +90,7 @@ impl PartialEq for QueuedFetch {
        self.rid == other.rid
            && self.from == other.from
            && self.refs_at == other.refs_at
+
            && self.mode == other.mode
            && self.channel.is_none()
            && other.channel.is_none()
    }
modified crates/radicle-protocol/src/worker.rs
@@ -4,7 +4,7 @@ pub mod fetch;
use std::io;

use radicle::identity::RepoId;
-
use radicle::node::Event;
+
use radicle::node::{Event, FetchMode};
use radicle::prelude::NodeId;
use radicle::storage::refs::RefsAt;

@@ -82,6 +82,8 @@ pub enum FetchRequest {
        remote: NodeId,
        /// If this fetch is for a particular set of `rad/sigrefs`.
        refs_at: Option<Vec<RefsAt>>,
+
        /// Fetch mode.
+
        mode: FetchMode,
    },
    /// Server is responding to a fetch request by uploading the
    /// specified `refspecs` sent by the client.
modified crates/radicle/src/node.rs
@@ -715,6 +715,18 @@ pub enum FetchResult {
    },
}

+
/// Fetch mode for repository data.
+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub enum FetchMode {
+
    /// Fetch full repository data and metadata.
+
    #[default]
+
    Full,
+
    /// Fetch metadata only (eg. project name/description) without data refs.
+
    Meta,
+
}
+

impl FetchResult {
    pub fn is_success(&self) -> bool {
        matches!(self, FetchResult::Success { .. })
@@ -916,6 +928,16 @@ pub trait Handle: Clone + Sync + Send {
        from: NodeId,
        timeout: time::Duration,
    ) -> Result<FetchResult, Self::Error>;
+
    /// Fetch a repository from the network using the given mode.
+
    fn fetch_with_mode(
+
        &mut self,
+
        id: RepoId,
+
        from: NodeId,
+
        timeout: time::Duration,
+
        _mode: FetchMode,
+
    ) -> Result<FetchResult, Self::Error> {
+
        self.fetch(id, from, timeout)
+
    }
    /// Start seeding the given repo. May update the scope. Does nothing if the
    /// repo is already seeded.
    fn seed(&mut self, id: RepoId, scope: policy::Scope) -> Result<bool, Self::Error>;
@@ -1183,12 +1205,23 @@ impl Handle for Node {
        from: NodeId,
        timeout: time::Duration,
    ) -> Result<FetchResult, Error> {
+
        self.fetch_with_mode(rid, from, timeout, FetchMode::Full)
+
    }
+

+
    fn fetch_with_mode(
+
        &mut self,
+
        rid: RepoId,
+
        from: NodeId,
+
        timeout: time::Duration,
+
        mode: FetchMode,
+
    ) -> Result<FetchResult, Error> {
        let result = self
            .call(
                Command::Fetch {
                    rid,
                    nid: from,
                    timeout,
+
                    mode,
                },
                DEFAULT_TIMEOUT.max(timeout),
            )?
modified crates/radicle/src/node/command.rs
@@ -17,6 +17,7 @@ use crate::crypto::PublicKey;
use crate::identity::RepoId;

use super::events::Event;
+
use super::FetchMode;
use super::NodeId;

/// Default timeout when waiting for the node to respond with data.
@@ -120,6 +121,8 @@ pub enum Command {
        )]
        nid: NodeId,
        timeout: time::Duration,
+
        #[serde(default)]
+
        mode: FetchMode,
    },

    /// Seed the given repository.