//! Clone and fetch over standard WASI HTTP or in-process SSH exec channels. use crate::{ Result, http::WasiHttp, remotes, repo::{self, error}, ssh::clean_url, }; use gix::bstr::ByteSlice; use std::{collections::BTreeSet, sync::atomic::AtomicBool}; struct FetchResult { remote: String, advertised_refs: usize, received_pack: bool, pruned: Vec, copied_objects: Option, updated_refs: Option>, } pub(crate) fn fetch(path: &str, remote: &str, revision: Option<&str>) -> Result<()> { let mut repo = repo::open(path)?; repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; perform(&repo, remote, None, false, revision)?; Ok(()) } pub(crate) fn clone(url: &str, path: &str) -> Result<()> { clean_url(url).map_err(|e| error("clone URL", e))?; let path = std::path::absolute(path).map_err(|e| error("clone path", e))?; std::fs::create_dir(&path).map_err(|e| error("clone destination", e))?; gix::init(&path).map_err(|e| error("clone init", e))?; let name = path.to_string_lossy(); let repo = repo::open(&name)?; remotes::set(&repo, "origin", url, true)?; let mut repo = repo::open(&name)?; repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; let fetched = perform(&repo, "origin", None, false, None)?; let branch = fetched .default_branch .ok_or_else(|| error("clone", "server HEAD does not name a branch"))?; if !fetched.empty { crate::branches::create( &repo, crate::branches::BranchCreateParams { repo: name.to_string(), name: branch.clone(), from: Some(format!("refs/remotes/origin/{branch}")), checkout: Some(true), }, )?; } remotes::upstream(&repo, &branch)?; Ok(()) } struct Fetched { summary: FetchResult, default_branch: Option, heads: BTreeSet, empty: bool, } fn perform( repo: &gix::Repository, remote_name: &str, depth: Option, prune: bool, revision: Option<&str>, ) -> Result { remotes::validate_name(remote_name)?; let mut remote = repo .find_remote(remote_name) .map_err(|_| error("fetch", "configured remote not found"))?; let url = remote .url(gix::remote::Direction::Fetch) .ok_or_else(|| error("fetch", "remote has no fetch URL"))? .to_bstring() .to_str_lossy() .into_owned(); clean_url(&url).map_err(|e| error("fetch URL", e))?; if prune { let expected = format!("+refs/heads/*:refs/remotes/{remote_name}/*"); let specs = remote.refspecs(gix::remote::Direction::Fetch); if specs.len() != 1 || specs[0].to_ref().to_bstring() != expected.as_bytes() { return Err(error( "prune", "prune requires the standard +refs/heads/*:refs/remotes//* refspec", )); } } if let Some(revision) = revision { let source = if revision.starts_with("refs/") || revision.len() == 40 { revision.to_owned() } else { format!("refs/heads/{revision}") }; let spec = if let Some(branch) = source.strip_prefix("refs/heads/") { format!("+{source}:refs/remotes/{remote_name}/{branch}") } else { source }; remote .replace_refspecs([spec.as_bytes().as_bstr()], gix::remote::Direction::Fetch) .map_err(|e| error("fetch refspec", e))?; } let transport: Box = if crate::ssh::is_ssh(&url) { Box::new(crate::ssh::connection( &url, gix_transport::Service::UploadPack, )?) } else { let transport_url = gix::url::parse(url.as_bytes().as_bstr()) .map_err(|_| error("fetch", "invalid HTTPS URL"))?; Box::new( gix_transport::client::blocking_io::http::Transport::new_http( WasiHttp::default(), transport_url, gix_transport::Protocol::V2, false, ), ) }; let mut options = gix::remote::ref_map::Options::default(); options.extra_refspecs.push( gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch) .map_err(|e| error("fetch HEAD", e))? .to_owned(), ); let mut prepared = remote .to_connection_with_transport(transport) .prepare_fetch(gix::progress::Discard, options) .map_err(|e| error("fetch handshake", format!("{e:#}")))?; let refs = &prepared.ref_map().remote_refs; let mut heads = BTreeSet::new(); let mut default_branch = None; for reference in refs { let (name, _, _) = reference.unpack(); if let Some(branch) = name.to_str_lossy().strip_prefix("refs/heads/") { heads.insert(branch.to_owned()); } match reference { gix::protocol::handshake::Ref::Symbolic { full_ref_name, target, .. } | gix::protocol::handshake::Ref::Unborn { full_ref_name, target, } if full_ref_name.as_slice() == b"HEAD" => { default_branch = target .to_str_lossy() .strip_prefix("refs/heads/") .map(str::to_owned); } _ => {} } } let fetch_heads: Vec<_> = refs .iter() .filter_map(|r| { let (name, id, _) = r.unpack(); id.map(|id| (name.to_string(), id.to_string())) }) .collect(); let advertised_refs = refs.len(); let empty = heads.is_empty() && refs.iter().all(|r| r.unpack().1.is_none()); if let Some(depth) = depth { prepared = prepared.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote( depth .try_into() .map_err(|_| error("fetch", "depth must be positive"))?, )); } let outcome = prepared .receive(gix::progress::Discard, &AtomicBool::new(false)) .map_err(|e| error("fetch", format!("{e:#}")))?; let received_pack = matches!(outcome.status, gix::remote::fetch::Status::Change { .. }); let updates = match &outcome.status { gix::remote::fetch::Status::Change { update_refs, .. } | gix::remote::fetch::Status::NoPackReceived { update_refs, .. } => update_refs, }; use gix::remote::fetch::refs::update::Mode; for update in &updates.updates { if !matches!( update.mode, Mode::NoChangeNeeded | Mode::FastForward | Mode::Forced | Mode::New | Mode::ImplicitTagNotSentByRemote ) { return Err(error( "fetch refs (other refs may already be updated)", &update.mode, )); } } let fetch_head = fetch_heads .iter() .filter(|(name, _)| { revision.map_or(name.starts_with("refs/heads/"), |r| { name == r || name == &format!("refs/heads/{r}") }) }) .map(|(name, id)| { format!( "{id}\t\tbranch '{}' of {url}\n", name.trim_start_matches("refs/heads/") ) }) .collect::(); std::fs::write(repo.git_dir().join("FETCH_HEAD"), fetch_head) .map_err(|e| error("FETCH_HEAD", e))?; if let Some(branch) = default_branch .as_deref() .filter(|b| revision.is_none() && heads.contains(*b)) { use gix::refs::{ Target, transaction::{Change, PreviousValue, RefEdit}, }; repo.edit_references_as( [RefEdit { name: format!("refs/remotes/{remote_name}/HEAD") .as_str() .try_into() .map_err(|e| error("remote HEAD", e))?, deref: false, change: Change::Update { log: Default::default(), expected: PreviousValue::Any, new: Target::Symbolic( format!("refs/remotes/{remote_name}/{branch}") .as_str() .try_into() .map_err(|e| error("remote HEAD target", e))?, ), }, }], None, ) .map_err(|e| error("remote HEAD", e))?; } let pruned = if prune { prune_refs(repo, remote_name, &heads)? } else { vec![] }; Ok(Fetched { summary: FetchResult { remote: remote_name.into(), advertised_refs, received_pack, pruned, copied_objects: None, updated_refs: None, }, default_branch, heads, empty, }) } /// Delete only stale refs in this remote's namespace. Symbolic HEAD is not a branch. pub fn prune_refs( repo: &gix::Repository, remote: &str, heads: &BTreeSet, ) -> Result> { remotes::validate_name(remote)?; let prefix = format!("refs/remotes/{remote}/"); let refs = repo.references().map_err(|e| error("prune refs", e))?; let mut stale = Vec::new(); for reference in refs .prefixed(prefix.as_str()) .map_err(|e| error("prune refs", e))? { let reference = reference.map_err(|e| error("prune ref", e))?; let name = reference.name().as_bstr().to_str_lossy().into_owned(); if let Some(short) = name.strip_prefix(&prefix) { if short != "HEAD" && !heads.contains(short) { stale.push(name); } } } for name in &stale { repo.find_reference(name.as_str()) .map_err(|e| error("prune ref", e))? .delete() .map_err(|e| error("prune delete", e))?; } Ok(stale) }