Files
sausage/userland/git/src/branches.rs
T

347 lines
12 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,
}
/// Reflog-only writes use a fallback identity when the repository has none.
pub(crate) fn reflog_signature(repo: &gix::Repository) -> Result<gix::actor::Signature> {
if let Some(signature) = repo
.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?
{
return signature
.to_owned()
.map_err(|e| error("reflog identity", e));
}
let config = repo.config_snapshot();
let name = std::env::var("GIT_COMMITTER_NAME")
.ok()
.or_else(|| config.string("user.name").map(|s| s.to_string()))
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "Sausage".into());
let email = std::env::var("GIT_COMMITTER_EMAIL")
.ok()
.or_else(|| config.string("user.email").map(|s| s.to_string()))
.unwrap_or_else(|| {
format!(
"{}@{}",
std::env::var("USER").unwrap_or_else(|_| "sausage".into()),
std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".into())
)
});
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
Ok(gix::actor::Signature {
name: name.into(),
email: email.into(),
time: gix::date::Time::new(seconds, 0),
})
}
/// Publish references and their reflog entries without requiring a commit identity.
pub(crate) fn edit_refs(
repo: &gix::Repository,
edits: Vec<gix::refs::transaction::RefEdit>,
) -> Result<Vec<String>> {
let signature = reflog_signature(repo)?;
let mut time = Default::default();
repo.edit_references_as(edits, Some(signature.to_ref(&mut time)))
.map_err(|e| error("checkout refs", e))?;
Ok(Vec::new())
}
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 })
}