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;
pub struct OrphanedBranches<'repo> {
pub branches: Vec<Branch<'repo>>,
pub skipped_head: Option<String>,
}
pub struct Repository {
repository: git2::Repository,
}
impl Repository {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let repository =
git2::Repository::discover(path).context("Failed to discover repository")?;
Ok(Repository { repository })
}
pub fn path(&self) -> &Path {
self.repository.path()
}
pub fn head(&self) -> Result<Reference<'_>> {
let head = self
.repository
.head()
.context("Failed to get HEAD reference")?;
Ok(Reference::from(head))
}
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()
}
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
.iter()
.filter_map(|r| r.name_without_prefix(&remote_prefixes).ok())
.collect();
let mut branches = local_branches
.into_iter()
.filter(|b| b.name().ok().is_none_or(|n| !remote_names.contains(&n)))
.collect::<Vec<Branch>>();
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,
}
}
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() {
let (testdir, repo) = test_utilities::create_mock_repo();
let actual = Repository::open(testdir).unwrap();
assert_eq!(actual.path(), repo.path());
}
#[test]
fn test_open_parent() {
let (testdir, repo) = test_utilities::create_mock_repo();
let subdir = repo.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let actual = Repository::open(testdir).unwrap();
assert_eq!(actual.path(), repo.path());
}
#[test]
fn test_open_not_found() {
let testdir = tempfile::tempdir().unwrap();
let actual = Repository::open(testdir.path());
assert!(actual.is_err());
}
#[test]
fn test_head() {
let (testdir, repo) = test_utilities::create_mock_repo();
let binding = Repository::open(testdir.path()).unwrap();
let actual = binding.head().unwrap();
let expected = repo.head().unwrap();
assert_eq!(actual.name(), expected.name());
}
#[test]
fn test_list_local() {
let (testdir, repo) = test_utilities::create_mock_repo();
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());
let actual = Repository::open(testdir.path())
.unwrap()
.branches(BranchType::Local)
.iter()
.map(|b| b.to_string())
.collect::<Vec<String>>();
assert_eq!(actual, expected);
}
#[test]
fn test_orphaned_branches() {
let (testdir, repo) = test_utilities::create_mock_repo();
let target_commit = test_utilities::get_latest_commit(&repo);
let orphaned_branches = vec!["to-delete-1", "to-delete-2"];
for branch_name in orphaned_branches.iter() {
let _ = repo.branch(branch_name, &target_commit, false);
}
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);
assert_eq!(result.skipped_head.as_deref(), Some("main"));
}
#[test]
fn test_path() {
let (testdir, repo) = test_utilities::create_mock_repo();
let binding = Repository::open(testdir).unwrap();
let actual = binding.path();
assert_eq!(actual, repo.path());
}
#[test]
fn test_remotes_one() {
let (testdir, repo) = test_utilities::create_mock_repo();
let remote_name = "origin";
repo.remote(remote_name, "https://example.com").unwrap();
let remote = Remote::new(remote_name);
let actual = Repository::open(testdir).unwrap().remotes();
assert_eq!(actual.len(), 1);
assert!(actual.contains(&remote));
}
#[test]
fn test_remotes_multiple() {
let (testdir, repo) = test_utilities::create_mock_repo();
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);
let actual = Repository::open(testdir).unwrap().remotes();
assert_eq!(actual.len(), 2);
assert!(actual.contains(&remote1));
assert!(actual.contains(&remote2));
}
#[test]
fn test_remotes_empty() {
let (testdir, _repo) = crate::test_utilities::create_mock_repo();
let actual = Repository::open(testdir).unwrap().remotes();
assert!(actual.is_empty());
}
}