git-cleaner 0.1.0

Bulk cleanup of git worktrees and merged branches across multiple repositories
use git2::{BranchType, Repository};

use crate::error::{Error, Result};

/// A branch that is fully merged into main
#[derive(Debug)]
pub struct MergedBranch {
    pub name: String,
}

/// Analysis result for a repository's branches
#[derive(Debug)]
pub struct BranchReport {
    pub merged_branches: Vec<MergedBranch>,
}

/// Find all local branches that are fully merged into main/master.
/// Excludes the current HEAD branch and main/master itself.
pub fn find_merged_branches(repo: &Repository) -> Result<BranchReport> {
    let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();

    let main_oid = match resolve_main_oid(repo) {
        Some(oid) => oid,
        None => {
            return Ok(BranchReport {
                merged_branches: Vec::new(),
            });
        }
    };

    let head_name = repo
        .head()
        .ok()
        .and_then(|h| h.shorthand().map(|s| s.to_string()));

    let branches = repo
        .branches(Some(BranchType::Local))
        .map_err(|e| Error::Git {
            path: repo_path.clone(),
            source: e,
        })?;

    let mut merged = Vec::new();

    for branch_result in branches {
        let (branch, _) = match branch_result {
            Ok(b) => b,
            Err(_) => continue,
        };

        let name = match branch.name() {
            Ok(Some(n)) => n.to_string(),
            _ => continue,
        };

        // Skip main/master and current HEAD
        if name == "main" || name == "master" {
            continue;
        }
        if Some(&name) == head_name.as_ref() {
            continue;
        }

        let branch_oid = match branch.get().target() {
            Some(oid) => oid,
            None => continue,
        };

        // A branch is "merged" if its tip is an ancestor of main
        if is_ancestor_of(repo, branch_oid, main_oid) {
            merged.push(MergedBranch { name });
        }
    }

    merged.sort_by(|a, b| a.name.cmp(&b.name));

    Ok(BranchReport {
        merged_branches: merged,
    })
}

/// Delete a local branch by name.
pub fn delete_branch(repo: &Repository, branch_name: &str) -> Result<()> {
    let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();

    let mut branch = repo
        .find_branch(branch_name, BranchType::Local)
        .map_err(|e| Error::Git {
            path: repo_path.clone(),
            source: e,
        })?;

    branch.delete().map_err(|e| Error::Git {
        path: repo_path,
        source: e,
    })?;

    Ok(())
}

fn resolve_main_oid(repo: &Repository) -> Option<git2::Oid> {
    for name in &["main", "master"] {
        if let Ok(branch) = repo.find_branch(name, BranchType::Local) {
            if let Some(oid) = branch.get().target() {
                return Some(oid);
            }
        }
    }
    None
}

fn is_ancestor_of(repo: &Repository, maybe_ancestor: git2::Oid, descendant: git2::Oid) -> bool {
    if maybe_ancestor == descendant {
        return true;
    }
    repo.merge_base(maybe_ancestor, descendant)
        .map(|base| base == maybe_ancestor)
        .unwrap_or(false)
}