Files
sausage/userland/git/src/branches.rs
T
surma 4b27e9afd5 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).
2026-11-06 16:07:57 +00:00

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 })
}