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).
323 lines
11 KiB
Rust
323 lines
11 KiB
Rust
//! 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 })
|
|
}
|