git-cleaner 0.1.0

Bulk cleanup of git worktrees and merged branches across multiple repositories
use std::path::PathBuf;

use git2::Repository;

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

/// Analysis result for a single worktree
#[derive(Debug)]
pub struct WorktreeStatus {
    pub name: String,
    pub path: PathBuf,
    pub branch: Option<String>,
    pub unmerged_commits: usize,
    pub has_uncommitted_changes: bool,
    pub removable: bool,
}

/// Analyze all linked worktrees for a repository.
/// Returns status for each worktree (excluding the main worktree).
pub fn analyze_worktrees(repo: &Repository) -> Result<Vec<WorktreeStatus>> {
    let repo_path = repo
        .workdir()
        .ok_or_else(|| Error::NotRepo(PathBuf::from("bare repository")))?;

    let worktree_names = repo.worktrees().map_err(|e| Error::Git {
        path: repo_path.to_path_buf(),
        source: e,
    })?;

    let main_oid = resolve_main_oid(repo, repo_path)?;

    let mut results = Vec::new();

    for name in worktree_names.iter().flatten() {
        let wt = match repo.find_worktree(name) {
            Ok(wt) => wt,
            Err(_) => continue,
        };

        let wt_path = wt.path().to_path_buf();

        // Open the worktree as a repository to inspect its state
        let wt_repo = match Repository::open(&wt_path) {
            Ok(r) => r,
            Err(_) => continue,
        };

        let branch = current_branch_name(&wt_repo);
        let unmerged_commits = count_unmerged_commits(&wt_repo, main_oid);
        let has_uncommitted_changes = has_changes(&wt_repo);

        let removable = unmerged_commits == 0 && !has_uncommitted_changes;

        results.push(WorktreeStatus {
            name: name.to_string(),
            path: wt_path,
            branch,
            unmerged_commits,
            has_uncommitted_changes,
            removable,
        });
    }

    Ok(results)
}

/// Remove a worktree by name from the parent repository.
pub fn remove_worktree(repo: &Repository, name: &str) -> Result<()> {
    let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();

    let wt = repo.find_worktree(name).map_err(|e| Error::Git {
        path: repo_path.clone(),
        source: e,
    })?;

    // Remove the worktree directory
    let wt_path = wt.path().to_path_buf();
    if wt_path.is_dir() {
        std::fs::remove_dir_all(&wt_path)?;
    }

    // Prune the worktree reference
    wt.prune(Some(
        &mut git2::WorktreePruneOptions::new()
            .working_tree(true)
            .valid(false),
    ))
    .map_err(|e| Error::Git {
        path: repo_path,
        source: e,
    })?;

    Ok(())
}

fn resolve_main_oid(repo: &Repository, repo_path: &std::path::Path) -> Result<Option<git2::Oid>> {
    // Try "main" first, then "master"
    for branch_name in &["main", "master"] {
        if let Ok(reference) = repo.find_branch(branch_name, git2::BranchType::Local) {
            if let Some(oid) = reference.get().target() {
                return Ok(Some(oid));
            }
        }
    }

    // If neither exists, try HEAD
    match repo.head() {
        Ok(head) => Ok(head.target()),
        Err(e) => Err(Error::Git {
            path: repo_path.to_path_buf(),
            source: e,
        }),
    }
}

fn current_branch_name(repo: &Repository) -> Option<String> {
    let head = repo.head().ok()?;
    if head.is_branch() {
        head.shorthand().map(|s| s.to_string())
    } else {
        Some(format!(
            "(detached {})",
            head.target()
                .map(|oid| oid.to_string()[..7].to_string())
                .unwrap_or_else(|| "?".to_string())
        ))
    }
}

fn count_unmerged_commits(wt_repo: &Repository, main_oid: Option<git2::Oid>) -> usize {
    let main_oid = match main_oid {
        Some(oid) => oid,
        None => return 0,
    };

    let head_oid = match wt_repo.head().ok().and_then(|h| h.target()) {
        Some(oid) => oid,
        None => return 0,
    };

    // If HEAD == main, nothing is unmerged
    if head_oid == main_oid {
        return 0;
    }

    // Find merge base
    let merge_base = match wt_repo.merge_base(main_oid, head_oid) {
        Ok(oid) => oid,
        Err(_) => return 1, // Can't determine, assume unmerged
    };

    // If merge_base == head, then HEAD is an ancestor of main (fully merged)
    if merge_base == head_oid {
        return 0;
    }

    // Count commits from merge_base to HEAD
    let mut revwalk = match wt_repo.revwalk() {
        Ok(rw) => rw,
        Err(_) => return 1,
    };

    if revwalk.push(head_oid).is_err() {
        return 1;
    }
    if revwalk.hide(merge_base).is_err() {
        return 1;
    }

    revwalk.count()
}

fn has_changes(repo: &Repository) -> bool {
    let statuses = match repo.statuses(Some(
        git2::StatusOptions::new()
            .include_untracked(true)
            .recurse_untracked_dirs(false),
    )) {
        Ok(s) => s,
        Err(_) => return true, // Assume dirty if we can't check
    };

    !statuses.is_empty()
}