userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http.
|
||||
use crate::{
|
||||
Result,
|
||||
http::{WasiHttp, clean_url},
|
||||
remotes,
|
||||
repo::{self, error},
|
||||
};
|
||||
use gix::bstr::ByteSlice;
|
||||
use std::{collections::BTreeSet, sync::atomic::AtomicBool};
|
||||
struct FetchResult {
|
||||
remote: String,
|
||||
advertised_refs: usize,
|
||||
received_pack: bool,
|
||||
pruned: Vec<String>,
|
||||
copied_objects: Option<usize>,
|
||||
updated_refs: Option<Vec<String>>,
|
||||
}
|
||||
pub(crate) fn fetch(path: &str, remote: &str) -> Result<()> {
|
||||
let mut repo = repo::open(path)?;
|
||||
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
|
||||
perform(&repo, remote, None, false)?;
|
||||
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)?;
|
||||
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<String>,
|
||||
heads: BTreeSet<String>,
|
||||
empty: bool,
|
||||
}
|
||||
fn perform(
|
||||
repo: &gix::Repository,
|
||||
remote_name: &str,
|
||||
depth: Option<u32>,
|
||||
prune: bool,
|
||||
) -> Result<Fetched> {
|
||||
remotes::validate_name(remote_name)?;
|
||||
let 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/<remote>/* refspec",
|
||||
));
|
||||
}
|
||||
}
|
||||
let transport: Box<dyn gix_transport::client::blocking_io::Transport> = {
|
||||
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 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(branch) = default_branch.as_deref().filter(|b| 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<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user