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,322 @@
|
||||
//! Branch/HEAD plumbing. All mutators are called under the common .git lock.
|
||||
use crate::Result;
|
||||
use crate::{HeadInfo, checkout::write_tree_to_worktree, head_info, repo::error};
|
||||
|
||||
/// Switch a branch, tag, or commit; optionally create a branch at HEAD.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct CheckoutParams {
|
||||
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
|
||||
pub repo: String,
|
||||
/// Branch, tag or commit revision; with create=true, the new branch name.
|
||||
pub target: String,
|
||||
/// Create a new branch at HEAD and switch to it (default false).
|
||||
pub create: Option<bool>,
|
||||
/// Discard overwritten tracked changes, never untracked files (default false).
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
/// Create a local branch, optionally switching the worktree to it.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct BranchCreateParams {
|
||||
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
|
||||
pub repo: String,
|
||||
/// Short new branch name.
|
||||
pub name: String,
|
||||
/// Starting commit revision, default HEAD.
|
||||
pub from: Option<String>,
|
||||
/// Switch to the new branch, refusing overwritten dirty changes (default false).
|
||||
pub checkout: Option<bool>,
|
||||
}
|
||||
|
||||
/// Delete a local branch.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct BranchDeleteParams {
|
||||
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
|
||||
pub repo: String,
|
||||
/// Short existing branch name.
|
||||
pub name: String,
|
||||
/// Allow deletion when not merged into HEAD (default false). Current branch is always protected.
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
/// Result of a checkout or branch creation.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct CheckoutResult {
|
||||
/// HEAD after the operation.
|
||||
pub head: HeadInfo,
|
||||
/// Full branch reference created, if any.
|
||||
pub created: Option<String>,
|
||||
/// Unsupported worktree features (symlinks/submodules/chmod); raw content has no filters or eol conversion.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Deleted branch identity.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct BranchDeleted {
|
||||
/// Full reference deleted.
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
/// Reflogs require an identity, but switching branches must not require configuring one.
|
||||
/// With no repository identity, leave reflogs untouched and report that explicitly; never invent an author.
|
||||
pub(crate) fn edit_refs(
|
||||
repo: &gix::Repository,
|
||||
edits: Vec<gix::refs::transaction::RefEdit>,
|
||||
) -> Result<Vec<String>> {
|
||||
let committer = repo
|
||||
.committer()
|
||||
.transpose()
|
||||
.map_err(|e| error("reflog identity", e))?;
|
||||
if let Some(committer) = committer {
|
||||
repo.edit_references_as(edits, Some(committer))
|
||||
.map_err(|e| error("checkout refs", e))?;
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
let mut local = repo.clone();
|
||||
local.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
|
||||
local
|
||||
.edit_references_as(edits, None)
|
||||
.map_err(|e| error("checkout refs", e))?;
|
||||
Ok(vec![
|
||||
"reflog not written: no repository committer identity configured".into(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn branch_name(name: &str) -> Result<String> {
|
||||
if name.is_empty() || name.starts_with('-') || name.starts_with("refs/") || name == "HEAD" {
|
||||
return Err(error("branch name", "expected short local branch name"));
|
||||
}
|
||||
let full = format!("refs/heads/{name}");
|
||||
let _: gix::refs::FullName = full
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|e| error("branch name", e))?;
|
||||
Ok(full)
|
||||
}
|
||||
|
||||
fn unused(repo: &gix::Repository, name: &str) -> Result<()> {
|
||||
if repo
|
||||
.try_find_reference(name)
|
||||
.map_err(|e| error("branch lookup", e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(error("branch create", "branch already exists"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(repo: &gix::Repository, rev: &str) -> Result<gix::ObjectId> {
|
||||
Ok(repo
|
||||
.rev_parse_single(rev)
|
||||
.map_err(|e| error("checkout revision", e))?
|
||||
.object()
|
||||
.map_err(|e| error("checkout object", e))?
|
||||
.peel_to_kind(gix::objs::Kind::Commit)
|
||||
.map_err(|e| error("checkout commit", e))?
|
||||
.id)
|
||||
}
|
||||
|
||||
fn old_tree(repo: &gix::Repository) -> Result<Option<gix::ObjectId>> {
|
||||
if repo
|
||||
.head()
|
||||
.map_err(|e| error("checkout HEAD", e))?
|
||||
.is_unborn()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let id = resolve(repo, "HEAD")?;
|
||||
Ok(Some(
|
||||
repo.find_commit(id)
|
||||
.map_err(|e| error("checkout HEAD", e))?
|
||||
.tree_id()
|
||||
.map_err(|e| error("checkout HEAD tree", e))?
|
||||
.detach(),
|
||||
))
|
||||
}
|
||||
|
||||
fn switch(
|
||||
repo: &gix::Repository,
|
||||
id: gix::ObjectId,
|
||||
branch: Option<&str>,
|
||||
create: bool,
|
||||
force: bool,
|
||||
) -> Result<Vec<String>> {
|
||||
crate::merge::ensure_idle(repo)?;
|
||||
if let Some(branch) = branch {
|
||||
crate::worktrees::ensure_available(repo, branch)?;
|
||||
}
|
||||
let tree = repo
|
||||
.find_commit(id)
|
||||
.map_err(|e| error("checkout commit", e))?
|
||||
.tree_id()
|
||||
.map_err(|e| error("checkout tree", e))?
|
||||
.detach();
|
||||
// Validate identity before publishing worktree/index. edit_refs resolves the same
|
||||
// immutable repository config again when publishing refs, but must not discover a
|
||||
// malformed identity only after files have changed.
|
||||
repo.committer()
|
||||
.transpose()
|
||||
.map_err(|e| error("reflog identity", e))?;
|
||||
let mut warnings = write_tree_to_worktree(repo, old_tree(repo)?, tree, force)?;
|
||||
// Index already published. Refs last, using gix transactions (including packed refs and reflogs).
|
||||
use gix::refs::{
|
||||
Target,
|
||||
transaction::{Change, PreviousValue, RefEdit},
|
||||
};
|
||||
let mut edits = Vec::new();
|
||||
if create {
|
||||
let name = branch.ok_or_else(|| error("checkout", "missing new branch"))?;
|
||||
edits.push(RefEdit {
|
||||
name: name.try_into().map_err(|e| error("branch name", e))?,
|
||||
deref: false,
|
||||
change: Change::Update {
|
||||
log: Default::default(),
|
||||
expected: PreviousValue::MustNotExist,
|
||||
new: Target::Object(id),
|
||||
},
|
||||
});
|
||||
}
|
||||
let target = match branch {
|
||||
Some(name) => Target::Symbolic(name.try_into().map_err(|e| error("branch name", e))?),
|
||||
None => Target::Object(id),
|
||||
};
|
||||
edits.push(RefEdit {
|
||||
name: "HEAD".try_into().map_err(|e| error("HEAD", e))?,
|
||||
deref: false,
|
||||
change: Change::Update {
|
||||
log: Default::default(),
|
||||
expected: PreviousValue::Any,
|
||||
new: target,
|
||||
},
|
||||
});
|
||||
warnings.extend(edit_refs(repo, edits)?);
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
pub(crate) fn checkout(repo: &gix::Repository, p: CheckoutParams) -> Result<CheckoutResult> {
|
||||
crate::repo::worktree(repo)?;
|
||||
let create = p.create.unwrap_or(false);
|
||||
let (id, branch) = if create {
|
||||
let name = branch_name(&p.target)?;
|
||||
crate::worktrees::ensure_available(repo, &name)?;
|
||||
unused(repo, &name)?;
|
||||
(resolve(repo, "HEAD")?, Some(name))
|
||||
} else {
|
||||
// Local branches take precedence over tags; tags and all other revisions detach HEAD.
|
||||
let name = if p.target.starts_with("refs/heads/") {
|
||||
p.target.clone()
|
||||
} else {
|
||||
format!("refs/heads/{}", p.target)
|
||||
};
|
||||
// Revision operators are not valid reference names. Only probe a local
|
||||
// branch when its full name validates; otherwise let rev_parse interpret it.
|
||||
let reference = if <gix::refs::FullName as TryFrom<&str>>::try_from(name.as_str()).is_ok() {
|
||||
crate::worktrees::ensure_available(repo, &name)?;
|
||||
repo.try_find_reference(name.as_str())
|
||||
.map_err(|e| error("checkout branch", e))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mut reference) = reference {
|
||||
let id = reference
|
||||
.peel_to_id()
|
||||
.map_err(|e| error("checkout branch", e))?
|
||||
.detach();
|
||||
(id, Some(name))
|
||||
} else {
|
||||
(resolve(repo, &p.target)?, None)
|
||||
}
|
||||
};
|
||||
let warnings = switch(
|
||||
repo,
|
||||
id,
|
||||
branch.as_deref(),
|
||||
create,
|
||||
p.force.unwrap_or(false),
|
||||
)?;
|
||||
Ok(CheckoutResult {
|
||||
head: head_info(repo)?,
|
||||
created: if create { branch } else { None },
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create(repo: &gix::Repository, p: BranchCreateParams) -> Result<CheckoutResult> {
|
||||
let name = branch_name(&p.name)?;
|
||||
if p.checkout.unwrap_or(false) {
|
||||
crate::worktrees::ensure_available(repo, &name)?;
|
||||
}
|
||||
unused(repo, &name)?;
|
||||
let id = resolve(repo, p.from.as_deref().unwrap_or("HEAD"))?;
|
||||
let warnings = if p.checkout.unwrap_or(false) {
|
||||
switch(repo, id, Some(&name), true, false)?
|
||||
} else {
|
||||
use gix::refs::{
|
||||
Target,
|
||||
transaction::{Change, PreviousValue, RefEdit},
|
||||
};
|
||||
edit_refs(
|
||||
repo,
|
||||
vec![RefEdit {
|
||||
name: name
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|e| error("branch name", e))?,
|
||||
deref: false,
|
||||
change: Change::Update {
|
||||
log: Default::default(),
|
||||
expected: PreviousValue::MustNotExist,
|
||||
new: Target::Object(id),
|
||||
},
|
||||
}],
|
||||
)?
|
||||
};
|
||||
Ok(CheckoutResult {
|
||||
head: head_info(repo)?,
|
||||
created: Some(name),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn delete(repo: &gix::Repository, p: BranchDeleteParams) -> Result<BranchDeleted> {
|
||||
crate::merge::ensure_idle(repo)?;
|
||||
let name = branch_name(&p.name)?;
|
||||
crate::worktrees::ensure_available(repo, &name)?;
|
||||
if head_info(repo)?.branch.as_deref() == Some(&name) {
|
||||
return Err(error("branch delete", "cannot delete current branch"));
|
||||
}
|
||||
let mut reference = repo
|
||||
.find_reference(name.as_str())
|
||||
.map_err(|e| error("branch delete", e))?;
|
||||
let tip = reference
|
||||
.peel_to_id()
|
||||
.map_err(|e| error("branch delete", e))?
|
||||
.detach();
|
||||
if !p.force.unwrap_or(false) {
|
||||
let head = resolve(repo, "HEAD")?;
|
||||
// Explicit traversal avoids commit-graph mmap on WASI; includes every parent.
|
||||
let walk = repo
|
||||
.find_commit(head)
|
||||
.map_err(|e| error("branch merged", e))?
|
||||
.ancestors()
|
||||
.use_commit_graph(false)
|
||||
.all()
|
||||
.map_err(|e| error("branch merged", e))?;
|
||||
let mut merged = false;
|
||||
for entry in walk {
|
||||
if entry.map_err(|e| error("branch merged", e))?.id == tip {
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !merged {
|
||||
return Err(error(
|
||||
"branch delete",
|
||||
"branch is not merged into HEAD; use force",
|
||||
));
|
||||
}
|
||||
}
|
||||
reference.delete().map_err(|e| error("branch delete", e))?;
|
||||
Ok(BranchDeleted { reference: name })
|
||||
}
|
||||
Reference in New Issue
Block a user