use anyhow::{bail, Context, Result};
use git2::{BranchType as Git2BranchType, Repository};
use tracing::debug;
use super::types::{BranchInfo, BranchType};
pub fn list_branches(repo: &Repository) -> Result<Vec<BranchInfo>> {
let mut branches = Vec::new();
let head_ref = repo.head().ok();
let head_name = head_ref
.as_ref()
.and_then(|r| r.shorthand().map(String::from));
for branch_result in repo.branches(None)? {
let (branch, bt) = branch_result?;
let name = branch.name()?.unwrap_or("<invalid utf-8>").to_string();
let branch_type = match bt {
Git2BranchType::Local => BranchType::Local,
Git2BranchType::Remote => BranchType::Remote,
};
let is_head = match branch_type {
BranchType::Local => head_name.as_deref() == Some(name.as_str()),
BranchType::Remote => false,
};
let target_oid = branch.get().target().map(|oid| oid.to_string());
branches.push(BranchInfo {
name,
branch_type,
is_head,
target_oid,
});
}
debug!("listed {} branches", branches.len());
Ok(branches)
}
pub fn create_branch(repo: &Repository, name: &str) -> Result<BranchInfo> {
let head_ref = repo
.head()
.context("HEAD not found — is this an empty repository?")?;
let commit = head_ref
.peel_to_commit()
.context("HEAD does not point to a commit")?;
let branch = repo
.branch(name, &commit, false)
.with_context(|| format!("failed to create branch '{name}'"))?;
let target_oid = branch.get().target().map(|oid| oid.to_string());
debug!(name, "created branch");
Ok(BranchInfo {
name: name.to_string(),
branch_type: BranchType::Local,
is_head: false,
target_oid,
})
}
pub fn delete_branch(repo: &Repository, name: &str) -> Result<()> {
let mut branch = repo
.find_branch(name, Git2BranchType::Local)
.with_context(|| format!("local branch '{name}' not found"))?;
if branch.is_head() {
bail!("cannot delete the currently checked-out branch '{name}'");
}
branch
.delete()
.with_context(|| format!("failed to delete branch '{name}'"))?;
debug!(name, "deleted branch");
Ok(())
}
pub fn checkout_branch(repo: &Repository, name: &str) -> Result<()> {
let refname = format!("refs/heads/{name}");
repo.find_branch(name, Git2BranchType::Local)
.with_context(|| format!("local branch '{name}' not found"))?;
repo.set_head(&refname)
.with_context(|| format!("failed to set HEAD to '{refname}'"))?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
.with_context(|| format!("failed to checkout branch '{name}'"))?;
debug!(name, "checked out branch");
Ok(())
}
pub fn merge_branch(repo: &Repository, source_branch: &str) -> Result<()> {
let branch = repo
.find_branch(source_branch, Git2BranchType::Local)
.with_context(|| format!("local branch '{source_branch}' not found"))?;
let source_ref = branch.get();
let source_oid = source_ref
.target()
.with_context(|| format!("branch '{source_branch}' has no target OID"))?;
let annotated_commit = repo
.find_annotated_commit(source_oid)
.context("failed to find annotated commit for source branch")?;
let (analysis, _preference) = repo
.merge_analysis(&[&annotated_commit])
.context("merge analysis failed")?;
if analysis.is_up_to_date() {
debug!(source_branch, "already up to date");
return Ok(());
}
if analysis.is_fast_forward() {
debug!(source_branch, "fast-forwarding");
let refname = format!("refs/heads/{}", head_branch_name(repo)?);
let msg = format!("Fast-forward merge of '{source_branch}'");
repo.reference(&refname, source_oid, true, &msg)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
return Ok(());
}
if analysis.is_normal() {
debug!(source_branch, "performing normal merge");
repo.merge(&[&annotated_commit], None, None)
.context("merge failed")?;
let index = repo.index().context("failed to read index after merge")?;
if index.has_conflicts() {
bail!(
"merge of '{source_branch}' resulted in conflicts — resolve them and commit manually"
);
}
let sig = repo
.signature()
.or_else(|_| git2::Signature::now("GitKraft User", "user@gitkraft.local"))
.context("failed to obtain signature")?;
let mut index = repo.index().context("failed to read index")?;
let tree_oid = index.write_tree().context("failed to write merged tree")?;
let tree = repo
.find_tree(tree_oid)
.context("failed to find merged tree")?;
let head_commit = repo
.head()?
.peel_to_commit()
.context("HEAD does not point to a commit")?;
let source_commit = repo
.find_commit(source_oid)
.context("failed to find source commit")?;
let message = format!("Merge branch '{source_branch}'");
repo.commit(
Some("HEAD"),
&sig,
&sig,
&message,
&tree,
&[&head_commit, &source_commit],
)
.context("failed to create merge commit")?;
repo.cleanup_state()
.context("failed to clean up merge state")?;
debug!(source_branch, "merge commit created");
return Ok(());
}
bail!("merge analysis returned an unexpected result for branch '{source_branch}'");
}
fn head_branch_name(repo: &Repository) -> Result<String> {
let head = repo.head().context("HEAD not found")?;
let name = head
.shorthand()
.context("HEAD is not a symbolic reference (detached HEAD?)")?
.to_string();
Ok(name)
}
fn run_git(workdir: &std::path::Path, args: &[&str]) -> anyhow::Result<()> {
let output = std::process::Command::new("git")
.current_dir(workdir)
.args(args)
.output()
.context("failed to spawn git")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("{}", stderr.trim());
}
Ok(())
}
pub fn rename_branch(repo: &Repository, old_name: &str, new_name: &str) -> Result<()> {
let mut branch = repo
.find_branch(old_name, Git2BranchType::Local)
.with_context(|| format!("branch '{old_name}' not found"))?;
branch
.rename(new_name, false)
.with_context(|| format!("failed to rename '{old_name}' → '{new_name}'"))?;
debug!(old_name, new_name, "renamed branch");
Ok(())
}
pub fn create_branch_at_commit(repo: &Repository, name: &str, oid_str: &str) -> Result<()> {
let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
let commit = repo
.find_commit(oid)
.with_context(|| format!("commit {oid_str} not found"))?;
repo.branch(name, &commit, false)
.with_context(|| format!("failed to create branch '{name}' at {oid_str}"))?;
debug!(name, oid_str, "created branch at commit");
Ok(())
}
pub fn push_branch(workdir: &std::path::Path, branch: &str, remote: &str) -> Result<()> {
run_git(workdir, &["push", remote, branch])
}
pub fn delete_remote_branch(workdir: &std::path::Path, full_name: &str) -> Result<()> {
let (remote, branch) = full_name.split_once('/').with_context(|| {
format!("invalid remote branch name '{full_name}' — expected 'remote/branch'")
})?;
run_git(workdir, &["push", remote, "--delete", branch])
}
pub fn checkout_remote_branch(workdir: &std::path::Path, full_name: &str) -> Result<()> {
let (_remote, branch) = full_name.split_once('/').with_context(|| {
format!("invalid remote branch name '{full_name}' — expected 'remote/branch'")
})?;
run_git(workdir, &["checkout", "-b", branch, "--track", full_name])
}
pub fn pull_rebase(workdir: &std::path::Path, remote: &str) -> Result<()> {
run_git(workdir, &["pull", "--rebase", remote])
}
pub fn rebase_onto(workdir: &std::path::Path, target: &str) -> Result<()> {
run_git(workdir, &["rebase", target])
}
pub fn create_tag(repo: &Repository, name: &str, oid_str: &str) -> Result<()> {
let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
let object = repo
.find_object(oid, None)
.with_context(|| format!("object {oid_str} not found"))?;
repo.tag_lightweight(name, &object, false)
.with_context(|| format!("failed to create lightweight tag '{name}'"))?;
debug!(name, oid_str, "created lightweight tag");
Ok(())
}
pub fn create_annotated_tag(
repo: &Repository,
name: &str,
message: &str,
oid_str: &str,
) -> Result<()> {
let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
let object = repo
.find_object(oid, None)
.with_context(|| format!("object {oid_str} not found"))?;
let sig = repo
.signature()
.or_else(|_| git2::Signature::now("GitKraft User", "user@gitkraft.local"))
.context("failed to obtain signature")?;
repo.tag(name, &object, &sig, message, false)
.with_context(|| format!("failed to create annotated tag '{name}'"))?;
debug!(name, oid_str, "created annotated tag");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_repo_with_commit() -> (TempDir, Repository) {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
std::fs::write(dir.path().join("file.txt"), "hello\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("file.txt")).unwrap();
index.write().unwrap();
let tree_oid = index.write_tree().unwrap();
{
let tree = repo.find_tree(tree_oid).unwrap();
let sig = repo.signature().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
}
(dir, repo)
}
#[test]
fn list_branches_shows_main() {
let (_dir, repo) = setup_repo_with_commit();
let branches = list_branches(&repo).unwrap();
assert!(!branches.is_empty());
assert!(branches.iter().any(|b| b.is_head));
}
#[test]
fn create_and_delete_branch() {
let (_dir, repo) = setup_repo_with_commit();
let branch = create_branch(&repo, "feature-test").unwrap();
assert_eq!(branch.name, "feature-test");
assert!(!branch.is_head);
delete_branch(&repo, "feature-test").unwrap();
let branches = list_branches(&repo).unwrap();
assert!(!branches.iter().any(|b| b.name == "feature-test"));
}
#[test]
fn checkout_branch_switches_head() {
let (_dir, repo) = setup_repo_with_commit();
create_branch(&repo, "new-branch").unwrap();
checkout_branch(&repo, "new-branch").unwrap();
let branches = list_branches(&repo).unwrap();
let head = branches.iter().find(|b| b.is_head).unwrap();
assert_eq!(head.name, "new-branch");
}
#[test]
fn delete_head_branch_fails() {
let (_dir, repo) = setup_repo_with_commit();
let branches = list_branches(&repo).unwrap();
let head = branches.iter().find(|b| b.is_head).unwrap();
let result = delete_branch(&repo, &head.name);
assert!(result.is_err());
}
}