use std::collections::HashSet;
use std::fs::{File, Permissions};
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use anyhow::{bail, Context, Result};
use git2::BranchType;
use crate::branch::Branch;
use crate::hook::{GitHook, GitHookType};
use crate::reference::Reference;
use crate::remote::Remote;
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 install_hook(&self, hook: &GitHook, hook_type: GitHookType) -> Result<()> {
let hook_dir = self.path().join("hooks");
let hook_path = hook_dir.join(hook_type.to_string());
if hook_path.exists() {
bail!("Hook already exists at {}", hook_path.to_string_lossy());
}
let mut file = File::create(&hook_path).context("Failed to create hook file")?;
file.write_all(hook.as_bytes())
.context("Failed to write hook script")?;
file.set_permissions(Permissions::from_mode(0o755))
.context("Failed to set permissions on hook file")?;
Ok(())
}
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) -> Vec<Branch> {
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 mut orphaned_branches = local_branches
.into_iter()
.filter(|b| {
let branch_name = b.name();
!remote_branches.iter().any(|r| {
r.name_without_prefix(&remote_prefixes).as_ref().ok()
== branch_name.as_ref().ok()
})
})
.collect::<Vec<Branch>>();
if let Ok(head_ref) = self.head() {
if let Some(head_short) = head_ref.shorthand() {
let initial_len = orphaned_branches.len();
orphaned_branches.retain(|b| b.to_string() != head_short);
if orphaned_branches.len() < initial_len {
println!("Cannot delete current branch: {}", head_short);
}
}
}
orphaned_branches
}
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_git_hook_install() {
let (_testdir, repo) = test_utilities::create_mock_repo();
let mock_script = String::from("echo 'Hello, world!'");
let hook = GitHook::new(mock_script);
let result = Repository::open(repo.path())
.unwrap()
.install_hook(&hook, GitHookType::PostMerge);
assert!(result.is_ok());
let hook_path = &repo
.path()
.to_path_buf()
.join("hooks")
.join(GitHookType::PostMerge.to_string());
let hook_script = std::fs::read_to_string(hook_path).unwrap();
assert_eq!(hook_script, hook.into_string());
}
#[test]
fn test_git_hook_install_already_exists() {
let (testdir, repo) = test_utilities::create_mock_repo();
let hook_path = &repo
.path()
.to_path_buf()
.join("hooks")
.join(GitHookType::PostMerge.to_string());
let _ = File::create(&hook_path).unwrap();
let hook = GitHook::default();
let result = Repository::open(testdir)
.unwrap()
.install_hook(&hook, GitHookType::PostMerge);
assert!(result.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 actual = Repository::open(testdir.path())
.unwrap()
.orphaned_branches()
.iter()
.map(|b| b.to_string())
.collect::<Vec<String>>();
assert_eq!(actual, orphaned_branches);
}
#[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());
}
}