//! 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, /// Discard overwritten tracked changes, never untracked files (default false). pub force: Option, } /// 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, /// Switch to the new branch, refusing overwritten dirty changes (default false). pub checkout: Option, } /// 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, } /// 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, /// Unsupported worktree features (symlinks/submodules/chmod); raw content has no filters or eol conversion. pub warnings: Vec, } /// 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 { 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, ) -> Result> { 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 { 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 { 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> { 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> { 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 { 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 >::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 { 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 { 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 }) }