use anyhow::{Context, Result};
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, PartialEq)]
pub enum MergeType {
Ancestor,
#[allow(dead_code)]
SquashMerge,
}
#[derive(Debug, Clone)]
pub struct MergedBranchInfo {
pub branch: String,
pub merge_type: MergeType,
}
#[derive(Debug, Clone)]
pub struct StaleBranchInfo {
pub branch: String,
pub days_old: u64,
}
pub fn find_merged_branches_all(
workdir: &Path,
trunk: &str,
remote_trunk_ref: Option<&str>,
) -> Result<Vec<MergedBranchInfo>> {
let mut merged: Vec<MergedBranchInfo> = Vec::new();
let output = Command::new("git")
.args(["branch", "--merged", trunk])
.current_dir(workdir)
.output()
.context("Failed to list merged branches")?;
for line in String::from_utf8_lossy(&output.stdout).lines() {
let branch = line.trim().trim_start_matches("* ");
if branch.is_empty() || branch == trunk {
continue;
}
merged.push(MergedBranchInfo {
branch: branch.to_string(),
merge_type: MergeType::Ancestor,
});
}
if let Some(remote_ref) = remote_trunk_ref {
let output = Command::new("git")
.args(["branch", "--merged", remote_ref])
.current_dir(workdir)
.output();
if let Ok(output) = output {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let branch = line.trim().trim_start_matches("* ");
if branch.is_empty() || branch == trunk {
continue;
}
if !merged.iter().any(|m| m.branch == branch) {
merged.push(MergedBranchInfo {
branch: branch.to_string(),
merge_type: MergeType::Ancestor,
});
}
}
}
}
Ok(merged)
}
pub fn find_upstream_gone_branches(workdir: &Path, trunk: &str) -> Result<Vec<String>> {
let output = Command::new("git")
.args([
"for-each-ref",
"--format=%(refname:short)%00%(upstream:short)%00%(upstream:track)",
"refs/heads",
])
.current_dir(workdir)
.output()
.context("Failed to list local branches with upstream tracking info")?;
let mut branches: std::collections::BTreeSet<String> = Default::default();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let mut fields = line.split('\0');
let branch = fields.next().unwrap_or("").trim();
let _upstream = fields.next().unwrap_or("").trim();
let tracking = fields.next().unwrap_or("").trim();
if branch.is_empty() || branch == trunk {
continue;
}
if tracking.contains("[gone]") {
branches.insert(branch.to_string());
}
}
Ok(branches.into_iter().collect())
}
pub fn find_stale_branches(
workdir: &Path,
trunk: &str,
current: &str,
stale_days: u64,
exclude_set: &HashSet<String>,
) -> Result<Vec<StaleBranchInfo>> {
let output = Command::new("git")
.args([
"for-each-ref",
"--format=%(refname:short)%00%(committerdate:unix)",
"refs/heads",
])
.current_dir(workdir)
.output()
.context("Failed to list branches with commit dates")?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let stale_threshold_secs = stale_days * 86_400;
let mut result = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let mut fields = line.splitn(2, '\0');
let branch = fields.next().unwrap_or("").trim();
let ts_str = fields.next().unwrap_or("").trim();
if branch.is_empty() || branch == trunk || branch == current {
continue;
}
if exclude_set.contains(branch) {
continue;
}
let commit_ts: u64 = ts_str.parse().unwrap_or(0);
let age_secs = now_secs.saturating_sub(commit_ts);
if age_secs >= stale_threshold_secs {
let days_old = age_secs / 86_400;
result.push(StaleBranchInfo {
branch: branch.to_string(),
days_old,
});
}
}
Ok(result)
}