use anyhow::{Context, Result};
use chrono::{Local, TimeZone};
use git2::Repository;
use super::types::{
BranchRecommendation, BranchRecommendations, BranchRelation, BranchStatus, BranchTopology,
RecommendedAction, TopologyBranch, TopologyConfig,
};
const GIT_SHORT_HASH_LEN: usize = 7;
const PRIORITY_MERGED: u8 = 90;
const PRIORITY_LONG_STALE: u8 = 80;
const PRIORITY_FAR_BEHIND: u8 = 70;
const PRIORITY_LARGE_DIVERGENCE: u8 = 65;
const PRIORITY_MERGE_CANDIDATE: u8 = 60;
const PRIORITY_REVIEW: u8 = 50;
const PRIORITY_KEEP: u8 = 10;
const MERGE_CANDIDATE_MAX_BEHIND: usize = 10;
pub fn analyze_topology(config: &TopologyConfig) -> Result<BranchTopology> {
let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
analyze_topology_from_repo(&repo, config)
}
pub fn analyze_topology_from_repo(
repo: &Repository,
config: &TopologyConfig,
) -> Result<BranchTopology> {
let main_branch = detect_main_branch(repo);
let mut topology = BranchTopology::new(main_branch.clone());
topology.config = config.clone();
let current_branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()));
let branches = repo.branches(Some(git2::BranchType::Local))?;
for branch_result in branches {
let (branch, _) = branch_result?;
let name = match branch.name()? {
Some(n) => n.to_string(),
None => continue,
};
let commit = match branch.get().peel_to_commit() {
Ok(c) => c,
Err(_) => continue,
};
let head_hash = commit.id().to_string()[..GIT_SHORT_HASH_LEN].to_string();
let last_activity = Local
.timestamp_opt(commit.time().seconds(), 0)
.single()
.unwrap_or_else(Local::now);
let mut topo_branch = TopologyBranch::new(name.clone(), head_hash, last_activity);
let status = if current_branch.as_ref() == Some(&name) {
BranchStatus::Active
} else if topo_branch.is_stale(config.stale_threshold_days) {
BranchStatus::Stale
} else {
BranchStatus::Normal
};
topo_branch = topo_branch.with_status(status);
if name != main_branch {
if let Some(relation) = analyze_branch_relation(repo, &main_branch, &name) {
if relation.is_merged && status != BranchStatus::Active {
topo_branch = topo_branch.with_status(BranchStatus::Merged);
}
topo_branch = topo_branch.with_relation(relation);
}
}
topology.add_branch(topo_branch);
}
topology.branches.sort_by(|a, b| {
if a.status == BranchStatus::Active {
return std::cmp::Ordering::Less;
}
if b.status == BranchStatus::Active {
return std::cmp::Ordering::Greater;
}
if a.name == topology.main_branch {
return std::cmp::Ordering::Less;
}
if b.name == topology.main_branch {
return std::cmp::Ordering::Greater;
}
if a.status == BranchStatus::Stale && b.status != BranchStatus::Stale {
return std::cmp::Ordering::Greater;
}
if b.status == BranchStatus::Stale && a.status != BranchStatus::Stale {
return std::cmp::Ordering::Less;
}
b.last_activity.cmp(&a.last_activity)
});
if topology.branches.len() > config.max_branches {
topology.branches.truncate(config.max_branches);
}
topology.calculate_all_health();
Ok(topology)
}
fn detect_main_branch(repo: &Repository) -> String {
if repo.find_branch("main", git2::BranchType::Local).is_ok() {
return "main".to_string();
}
if repo.find_branch("master", git2::BranchType::Local).is_ok() {
return "master".to_string();
}
"main".to_string()
}
fn analyze_branch_relation(
repo: &Repository,
base_name: &str,
branch_name: &str,
) -> Option<BranchRelation> {
let base_ref = format!("refs/heads/{}", base_name);
let branch_ref = format!("refs/heads/{}", branch_name);
let base_oid = repo.revparse_single(&base_ref).ok()?.id();
let branch_oid = repo.revparse_single(&branch_ref).ok()?.id();
let merge_base = repo.merge_base(base_oid, branch_oid).ok()?;
let merge_base_hash = merge_base.to_string()[..GIT_SHORT_HASH_LEN].to_string();
let mut relation = BranchRelation::new(base_name.to_string(), branch_name.to_string());
relation.merge_base = merge_base_hash;
let (ahead, behind) = repo.graph_ahead_behind(branch_oid, base_oid).ok()?;
relation.ahead_count = ahead;
relation.behind_count = behind;
relation.is_merged = ahead == 0 && merge_base == branch_oid;
Some(relation)
}
pub fn analyze_branch_recommendations(topology: &BranchTopology) -> BranchRecommendations {
let mut recommendations = BranchRecommendations::new();
recommendations.total_branches = topology.branches.len();
let now = Local::now();
for branch in &topology.branches {
if branch.name == topology.main_branch || branch.status == BranchStatus::Active {
continue;
}
let days_inactive = now.signed_duration_since(branch.last_activity).num_days();
let (ahead, behind) = branch
.relation
.as_ref()
.map(|r| (r.ahead_count, r.behind_count))
.unwrap_or((0, 0));
let (action, reason, priority) =
determine_recommendation(branch, days_inactive, ahead, behind, &topology.config);
let rec = BranchRecommendation::new(branch.name.clone(), action, reason, priority)
.with_counts(ahead, behind)
.with_days_inactive(days_inactive);
recommendations.add(rec);
}
recommendations.sort_by_priority();
recommendations
}
fn determine_recommendation(
branch: &TopologyBranch,
days_inactive: i64,
ahead: usize,
behind: usize,
config: &TopologyConfig,
) -> (RecommendedAction, String, u8) {
if branch.status == BranchStatus::Merged {
return (
RecommendedAction::Delete,
"Branch has been merged".to_string(),
PRIORITY_MERGED,
);
}
if days_inactive >= config.long_lived_threshold_days {
return (
RecommendedAction::Delete,
format!("No activity for {} days", days_inactive),
PRIORITY_LONG_STALE,
);
}
if behind >= config.far_behind_threshold {
return (
RecommendedAction::Rebase,
format!("{} commits behind main", behind),
PRIORITY_FAR_BEHIND,
);
}
if ahead > 0 && behind < MERGE_CANDIDATE_MAX_BEHIND {
return (
RecommendedAction::Merge,
format!("{} commits ahead, ready to merge", ahead),
PRIORITY_MERGE_CANDIDATE,
);
}
if days_inactive >= config.stale_threshold_days
&& days_inactive < config.long_lived_threshold_days
{
return (
RecommendedAction::Review,
format!("Branch is {} days old, needs attention", days_inactive),
PRIORITY_REVIEW,
);
}
if ahead >= config.divergence_threshold && behind >= config.divergence_threshold {
return (
RecommendedAction::Rebase,
format!("Large divergence: {} ahead, {} behind", ahead, behind),
PRIORITY_LARGE_DIVERGENCE,
);
}
(
RecommendedAction::Keep,
"Branch is in good condition".to_string(),
PRIORITY_KEEP,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn init_test_repo() -> (TempDir, Repository) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "test content").unwrap();
index.add_path(Path::new("test.txt")).unwrap();
index.write().unwrap();
index.write_tree().unwrap()
};
{
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
}
(temp_dir, repo)
}
#[test]
fn test_detect_main_branch_prefers_main() {
let (_temp_dir, repo) = init_test_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("main", &head, false).unwrap();
let main = detect_main_branch(&repo);
assert_eq!(main, "main");
}
#[test]
fn test_detect_main_branch_falls_back_to_master() {
let (_temp_dir, repo) = init_test_repo();
let main = detect_main_branch(&repo);
assert!(main == "master" || main == "main");
}
#[test]
fn test_analyze_topology_from_repo_returns_topology() {
let (_temp_dir, repo) = init_test_repo();
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
assert!(!topology.branches.is_empty());
}
#[test]
fn test_analyze_topology_from_repo_includes_current_branch() {
let (_temp_dir, repo) = init_test_repo();
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
assert!(topology.active_branch().is_some());
}
#[test]
fn test_analyze_topology_with_feature_branch() {
let (temp_dir, repo) = init_test_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
let obj = repo.revparse_single("refs/heads/feature").unwrap();
repo.checkout_tree(&obj, None).unwrap();
repo.set_head("refs/heads/feature").unwrap();
let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
let test_file = temp_dir.path().join("feature.txt");
fs::write(&test_file, "feature content").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("feature.txt")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
"Feature commit",
&tree,
&[&parent],
)
.unwrap();
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let active = topology.active_branch().unwrap();
assert_eq!(active.name, "feature");
}
}