Lines
98.01 %
Functions
62.5 %
Branches
100 %
use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result};
use git2::BranchType;
use crate::branch::Branch;
use crate::reference::Reference;
use crate::remote::Remote;
/// Result of querying for orphaned branches.
pub struct OrphanedBranches<'repo> {
/// Branches that can be deleted.
pub branches: Vec<Branch<'repo>>,
/// Name of the current branch, if it was skipped from deletion.
pub skipped_head: Option<String>,
}
/// Internal representation of a Git repository.
pub struct Repository {
repository: git2::Repository,
impl Repository {
/// Open a repository at the given path. Traverse the directory tree
/// to find the repository root. Return Ok(Repository) if found, or
/// Error if not found.
///
/// # Errors
/// Returns an error if the repository cannot be opened.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let repository =
git2::Repository::discover(path).context("Failed to discover repository")?;
Ok(Repository { repository })
/// Get path of the repository
pub fn path(&self) -> &Path {
self.repository.path()
/// Get current HEAD reference.
pub fn head(&self) -> Result<Reference<'_>> {
let head = self
.repository
.head()
.context("Failed to get HEAD reference")?;
Ok(Reference::from(head))
/// Return a list of all branches of a given BranchType in a repository as a Vec.
pub fn branches(&self, branch_type: BranchType) -> Vec<Branch<'_>> {
self.repository
.branches(Some(branch_type))
.ok()
.into_iter()
.flat_map(|branches_list| branches_list.filter_map(Result::ok))
.map(|(branch, _)| branch.into())
.collect()
/// Return all orphaned branches in a repository - branches without a
/// remote counterpart. Also reports if the current HEAD branch was
/// excluded from the result.
pub fn orphaned_branches(&self) -> OrphanedBranches<'_> {
let local_branches = self.branches(BranchType::Local);
let remote_branches = self.branches(BranchType::Remote);
let remote_prefixes = self
.remotes()
.iter()
.map(|r| r.as_prefix())
.collect::<HashSet<String>>();
let remote_names: HashSet<String> = remote_branches
.filter_map(|r| r.name_without_prefix(&remote_prefixes).ok())
.collect();
let mut branches = local_branches
.filter(|b| b.name().ok().is_none_or(|n| !remote_names.contains(&n)))
.collect::<Vec<Branch>>();
// Check if the current branch is in the list of orphaned branches. If it is,
// remove it from the list.
let mut skipped_head = None;
if let Ok(head_ref) = self.head() {
if let Some(head_short) = head_ref.shorthand() {
let initial_len = branches.len();
branches.retain(|b| b.to_string() != head_short);
if branches.len() < initial_len {
skipped_head = Some(head_short.to_string());
OrphanedBranches {
branches,
skipped_head,
/// Get a list of remotes for a repository. If there are no remotes, return an
/// empty Vec.
pub fn remotes(&self) -> Vec<Remote> {
let mut remotes = Vec::new();
if let Ok(remote_list) = self.repository.remotes() {
for remote in remote_list.iter().flatten() {
remotes.push(Remote::new(remote));
remotes
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use crate::test_utilities;
#[test]
fn test_open_current() {
// GIVEN a repository
let (testdir, repo) = test_utilities::create_mock_repo();
// WHEN the repository is opened
let actual = Repository::open(testdir).unwrap();
// THEN it should match the expected repository
assert_eq!(actual.path(), repo.path());
fn test_open_parent() {
// GIVEN a repository with a subdirectory
let subdir = repo.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fn test_open_not_found() {
// GIVEN a directory that is not a repository
let testdir = tempfile::tempdir().unwrap();
let actual = Repository::open(testdir.path());
// THEN it should not be found
assert!(actual.is_err());
fn test_head() {
// WHEN getting the HEAD reference
let binding = Repository::open(testdir.path()).unwrap();
let actual = binding.head().unwrap();
let expected = repo.head().unwrap();
// THEN it should match the expected reference
assert_eq!(actual.name(), expected.name());
fn test_list_local() {
// GIVEN a repository with local branches
let target_commit = test_utilities::get_latest_commit(&repo);
let local_branches = vec!["local-branch-1", "local-branch-2"];
for branch_name in local_branches.iter() {
let _ = repo.branch(branch_name, &target_commit, false);
let mut expected = Vec::from_iter(local_branches.iter().map(|b| b.to_string()));
expected.push("main".to_string());
// WHEN the list of branches is retrieved
let actual = Repository::open(testdir.path())
.unwrap()
.branches(BranchType::Local)
.map(|b| b.to_string())
.collect::<Vec<String>>();
// THEN the set of branches should match the expected one
assert_eq!(actual, expected);
fn test_orphaned_branches() {
// GIVEN a repository with local and remote branches
let orphaned_branches = vec!["to-delete-1", "to-delete-2"];
for branch_name in orphaned_branches.iter() {
// WHEN the list of orphaned branches is retrieved
let repo = Repository::open(testdir.path()).unwrap();
let result = repo.orphaned_branches();
let actual: Vec<String> = result.branches.iter().map(|b| b.to_string()).collect();
assert_eq!(actual, orphaned_branches);
// main is HEAD so it gets skipped
assert_eq!(result.skipped_head.as_deref(), Some("main"));
fn test_path() {
// WHEN getting the path
let binding = Repository::open(testdir).unwrap();
let actual = binding.path();
// THEN it should match the expected path
assert_eq!(actual, repo.path());
fn test_remotes_one() {
// GIVEN a repository with remote
let remote_name = "origin";
repo.remote(remote_name, "https://example.com").unwrap();
let remote = Remote::new(remote_name);
// WHEN the remotes are listed
let actual = Repository::open(testdir).unwrap().remotes();
// THEN it should match the expected remotes
assert_eq!(actual.len(), 1);
assert!(actual.contains(&remote));
fn test_remotes_multiple() {
// GIVEN a repository with multiple remotes
let remote1_name = "origin";
repo.remote(remote1_name, "https://example.com").unwrap();
let remote1 = Remote::new(remote1_name);
let remote2_name = "upstream";
repo.remote(remote2_name, "https://example.org").unwrap();
let remote2 = Remote::new(remote2_name);
assert_eq!(actual.len(), 2);
assert!(actual.contains(&remote1));
assert!(actual.contains(&remote2));
fn test_remotes_empty() {
// GIVEN a repository without remotes
let (testdir, _repo) = crate::test_utilities::create_mock_repo();
// WHEN the remote prefixes are listed
// THEN it should be empty
assert!(actual.is_empty());