use crate::branch_tracking::upstream_branch;
use crate::check_ref_format::validate_branch_short_name;
use crate::error::{Error, Result};
use crate::merge_base::is_ancestor;
use crate::objects::ObjectId;
use crate::porcelain::checkout_reflog::{
branch_create_reflog_source_label, create_branch_ref_with_reflog,
};
use crate::refs;
use crate::repo::Repository;
use crate::state::{resolve_head, resolve_head_for_repository, HeadState};
use crate::worktree::ensure_branch_not_checked_out_in_any_worktree;
const HEADS_PREFIX: &str = "refs/heads/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateBranchOutcome {
pub name: String,
pub oid: ObjectId,
}
pub fn create_branch(repo: &Repository, name: &str) -> Result<CreateBranchOutcome> {
validate_branch_short_name(name)
.map_err(|e| Error::Message(format!("invalid branch name '{name}': {e}")))?;
let branch_ref = format!("{HEADS_PREFIX}{name}");
if refs::resolve_ref(repo.git_dir(), &branch_ref).is_ok() {
return Err(Error::Message(format!("branch '{name}' already exists")));
}
let head = resolve_head(repo.git_dir())?;
let base = match head {
HeadState::Branch { oid: Some(oid), .. } | HeadState::Detached { oid } => oid,
HeadState::Branch { .. } => {
return Err(Error::Message(
"no commits yet to create a branch from".into(),
));
}
HeadState::Invalid => return Err(Error::Message("HEAD is in an unknown state".into())),
};
let source = branch_create_reflog_source_label(&head);
create_branch_ref_with_reflog(repo, &branch_ref, base, &source)?;
Ok(CreateBranchOutcome {
name: name.to_owned(),
oid: base,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalBranch {
pub name: String,
pub oid: ObjectId,
pub current: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchList {
pub current: Option<String>,
pub branches: Vec<LocalBranch>,
}
pub fn list_branches(repo: &Repository) -> Result<BranchList> {
let current = match resolve_head_for_repository(repo)? {
HeadState::Branch { short_name, .. } => Some(short_name),
HeadState::Detached { .. } | HeadState::Invalid => None,
};
let branches = repo
.references()
.list(HEADS_PREFIX)?
.into_iter()
.map(|(refname, oid)| {
let name = refname
.strip_prefix(HEADS_PREFIX)
.unwrap_or(&refname)
.to_owned();
let current = current.as_deref() == Some(name.as_str());
LocalBranch { name, oid, current }
})
.collect();
Ok(BranchList { current, branches })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeletedBranch {
pub name: String,
pub oid: ObjectId,
}
pub fn delete_branch(repo: &Repository, name: &str, force: bool) -> Result<DeletedBranch> {
let head = resolve_head_for_repository(repo)?;
if matches!(&head, HeadState::Branch { short_name, .. } if short_name == name) {
return Err(Error::CannotDeleteCurrentBranch {
branch: name.to_owned(),
});
}
ensure_branch_not_checked_out_in_any_worktree(repo, name)?;
let branch_ref = format!("{HEADS_PREFIX}{name}");
let existing = repo
.references()
.read_existing(&branch_ref)?
.ok_or_else(|| Error::BranchNotFound {
branch: name.to_owned(),
})?;
let oid = existing.oid;
if !force && !is_merged(repo, name, oid, &head)? {
return Err(Error::BranchNotMerged {
branch: name.to_owned(),
});
}
repo.references().delete_verified(&branch_ref, &existing)?;
Ok(DeletedBranch {
name: name.to_owned(),
oid,
})
}
fn is_merged(repo: &Repository, name: &str, tip: ObjectId, head: &HeadState) -> Result<bool> {
let base = upstream_branch(repo, name)
.map(|upstream| upstream.oid)
.or_else(|| head.oid().copied());
match base {
Some(base) => is_ancestor(repo, tip, base),
None => Ok(false),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use crate::porcelain::commit::{create_commit, CommitRequest};
use crate::progress::NullProgress;
use crate::repo::InitOptions;
fn init() -> (tempfile::TempDir, Repository) {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(tmp.path(), &InitOptions::default()).expect("init");
(tmp, repo)
}
fn commit(repo: &Repository, message: &str) -> ObjectId {
let req = CommitRequest {
message: message.into(),
author: "T <t@example.com> 1700000000 +0000".into(),
committer: "T <t@example.com> 1700000000 +0000".into(),
allow_empty: true,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
};
create_commit(repo, &req, &mut NullProgress)
.expect("commit")
.oid
}
fn branch_exists(repo: &Repository, name: &str) -> bool {
repo.references()
.resolve_if_exists(&format!("{HEADS_PREFIX}{name}"))
.unwrap()
.is_some()
}
fn current(repo: &Repository) -> String {
list_branches(repo)
.expect("list")
.current
.expect("on a branch")
}
#[test]
fn list_on_unborn_head_reports_current_without_branches() {
let (_tmp, repo) = init();
let list = list_branches(&repo).unwrap();
assert!(list.current.is_some());
assert!(list.branches.is_empty());
}
#[test]
fn list_marks_current_and_sorts_by_name() {
let (_tmp, repo) = init();
let tip = commit(&repo, "one");
create_branch(&repo, "zeta").unwrap();
create_branch(&repo, "alpha").unwrap();
let list = list_branches(&repo).unwrap();
let current = list.current.clone().unwrap();
let names: Vec<&str> = list.branches.iter().map(|b| b.name.as_str()).collect();
let mut expected = vec!["alpha", "zeta", current.as_str()];
expected.sort_unstable();
assert_eq!(names, expected);
assert!(list.branches.iter().all(|b| b.oid == tip));
for branch in &list.branches {
assert_eq!(branch.current, branch.name == current, "{branch:?}");
}
}
#[test]
fn delete_refuses_current_branch_even_with_force() {
let (_tmp, repo) = init();
commit(&repo, "one");
let current = current(&repo);
let err = delete_branch(&repo, ¤t, true).unwrap_err();
assert!(
matches!(&err, Error::CannotDeleteCurrentBranch { branch } if *branch == current),
"{err:?}"
);
}
#[test]
fn delete_missing_branch_is_typed() {
let (_tmp, repo) = init();
commit(&repo, "one");
let err = delete_branch(&repo, "nope", true).unwrap_err();
assert!(
matches!(&err, Error::BranchNotFound { branch } if branch == "nope"),
"{err:?}"
);
assert_eq!(err.to_string(), "no branch named 'nope'");
}
#[test]
fn delete_merged_branch_returns_old_tip_and_removes_ref() {
let (_tmp, repo) = init();
let tip = commit(&repo, "one");
create_branch(&repo, "topic").unwrap();
commit(&repo, "two");
let deleted = delete_branch(&repo, "topic", false).unwrap();
assert_eq!(
deleted,
DeletedBranch {
name: "topic".into(),
oid: tip,
}
);
assert!(!branch_exists(&repo, "topic"));
}
#[test]
fn delete_unmerged_branch_requires_force() {
let (_tmp, repo) = init();
let first = commit(&repo, "one");
let second = commit(&repo, "two");
create_branch(&repo, "topic").unwrap();
let current = current(&repo);
refs::write_ref(repo.git_dir(), &format!("{HEADS_PREFIX}{current}"), &first).unwrap();
let err = delete_branch(&repo, "topic", false).unwrap_err();
assert!(
matches!(&err, Error::BranchNotMerged { branch } if branch == "topic"),
"{err:?}"
);
assert!(branch_exists(&repo, "topic"));
let deleted = delete_branch(&repo, "topic", true).unwrap();
assert_eq!(deleted.oid, second);
assert!(!branch_exists(&repo, "topic"));
}
}