use chrono::{DateTime, Local};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchStatus {
Active,
Normal,
#[allow(dead_code)]
Wip,
Stale,
Merged,
}
impl BranchStatus {
pub fn label(&self) -> &'static str {
match self {
Self::Active => "HEAD",
Self::Normal => "",
Self::Wip => "WIP",
Self::Stale => "stale",
Self::Merged => "merged",
}
}
pub fn color_index(&self) -> usize {
match self {
Self::Active => 0, Self::Normal => 1, Self::Wip => 2, Self::Stale => 3, Self::Merged => 4, }
}
}
#[derive(Debug, Clone)]
pub struct BranchRelation {
pub base: String,
pub branch: String,
pub merge_base: String,
pub ahead_count: usize,
pub behind_count: usize,
pub is_merged: bool,
}
impl BranchRelation {
pub fn new(base: String, branch: String) -> Self {
Self {
base,
branch,
merge_base: String::new(),
ahead_count: 0,
behind_count: 0,
is_merged: false,
}
}
pub fn summary(&self) -> String {
if self.is_merged {
"merged".to_string()
} else if self.ahead_count == 0 && self.behind_count == 0 {
"up to date".to_string()
} else {
let mut parts = Vec::new();
if self.ahead_count > 0 {
parts.push(format!("{} ahead", self.ahead_count));
}
if self.behind_count > 0 {
parts.push(format!("{} behind", self.behind_count));
}
parts.join(", ")
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthWarning {
Stale,
LongLived,
FarBehind,
LargeDivergence,
}
impl HealthWarning {
pub fn description(&self) -> &'static str {
match self {
Self::Stale => "No activity for 30+ days",
Self::LongLived => "Branch exists for 60+ days",
Self::FarBehind => "50+ commits behind main",
Self::LargeDivergence => "Large divergence from main",
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::Stale => "⚠",
Self::LongLived => "⏳",
Self::FarBehind => "⬇",
Self::LargeDivergence => "⚡",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct BranchHealth {
pub warnings: Vec<HealthWarning>,
}
impl BranchHealth {
pub fn new() -> Self {
Self {
warnings: Vec::new(),
}
}
pub fn add_warning(&mut self, warning: HealthWarning) {
if !self.warnings.contains(&warning) {
self.warnings.push(warning);
}
}
pub fn is_healthy(&self) -> bool {
self.warnings.is_empty()
}
pub fn warning_count(&self) -> usize {
self.warnings.len()
}
pub fn warning_icons(&self) -> String {
self.warnings
.iter()
.map(|w| w.icon())
.collect::<Vec<_>>()
.join("")
}
}
#[derive(Debug, Clone)]
pub struct TopologyBranch {
pub name: String,
pub head_hash: String,
pub status: BranchStatus,
pub last_activity: DateTime<Local>,
pub relation: Option<BranchRelation>,
pub commit_count: usize,
pub health: BranchHealth,
}
impl TopologyBranch {
pub fn new(name: String, head_hash: String, last_activity: DateTime<Local>) -> Self {
Self {
name,
head_hash,
status: BranchStatus::Normal,
last_activity,
relation: None,
commit_count: 0,
health: BranchHealth::new(),
}
}
pub fn with_status(mut self, status: BranchStatus) -> Self {
self.status = status;
self
}
pub fn with_relation(mut self, relation: BranchRelation) -> Self {
self.relation = Some(relation);
self
}
pub fn with_commit_count(mut self, count: usize) -> Self {
self.commit_count = count;
self
}
pub fn is_stale(&self, threshold_days: i64) -> bool {
let now = Local::now();
let diff = now.signed_duration_since(self.last_activity);
diff.num_days() >= threshold_days
}
pub fn is_ahead(&self) -> bool {
self.relation.as_ref().is_some_and(|r| r.ahead_count > 0)
}
pub fn is_behind(&self) -> bool {
self.relation.as_ref().is_some_and(|r| r.behind_count > 0)
}
pub fn calculate_health(&mut self, config: &TopologyConfig) {
self.health = BranchHealth::new();
if self.is_stale(config.stale_threshold_days) {
self.health.add_warning(HealthWarning::Stale);
}
let now = Local::now();
let age_days = now.signed_duration_since(self.last_activity).num_days();
if age_days >= config.long_lived_threshold_days {
self.health.add_warning(HealthWarning::LongLived);
}
if let Some(ref relation) = self.relation {
if relation.behind_count >= config.far_behind_threshold {
self.health.add_warning(HealthWarning::FarBehind);
}
if relation.ahead_count >= config.divergence_threshold
&& relation.behind_count >= config.divergence_threshold
{
self.health.add_warning(HealthWarning::LargeDivergence);
}
}
}
}
#[derive(Debug, Clone)]
pub struct TopologyConfig {
pub stale_threshold_days: i64,
pub long_lived_threshold_days: i64,
pub far_behind_threshold: usize,
pub divergence_threshold: usize,
pub max_branches: usize,
}
impl Default for TopologyConfig {
fn default() -> Self {
Self {
stale_threshold_days: 30,
long_lived_threshold_days: 60,
far_behind_threshold: 50,
divergence_threshold: 20,
max_branches: 50,
}
}
}
#[derive(Debug, Clone)]
pub struct BranchTopology {
pub main_branch: String,
pub branches: Vec<TopologyBranch>,
pub max_column: usize,
pub config: TopologyConfig,
}
impl BranchTopology {
pub fn new(main_branch: String) -> Self {
Self {
main_branch,
branches: Vec::new(),
max_column: 0,
config: TopologyConfig::default(),
}
}
pub fn add_branch(&mut self, branch: TopologyBranch) {
self.branches.push(branch);
}
pub fn branch_count(&self) -> usize {
self.branches.len()
}
pub fn active_branch(&self) -> Option<&TopologyBranch> {
self.branches
.iter()
.find(|b| b.status == BranchStatus::Active)
}
pub fn stale_count(&self) -> usize {
self.branches
.iter()
.filter(|b| b.status == BranchStatus::Stale)
.count()
}
pub fn merged_count(&self) -> usize {
self.branches
.iter()
.filter(|b| b.status == BranchStatus::Merged)
.count()
}
pub fn calculate_all_health(&mut self) {
let config = self.config.clone();
for branch in &mut self.branches {
if branch.name != self.main_branch && branch.status != BranchStatus::Merged {
branch.calculate_health(&config);
}
}
}
pub fn unhealthy_count(&self) -> usize {
self.branches
.iter()
.filter(|b| !b.health.is_healthy())
.count()
}
pub fn warning_count(&self, warning: HealthWarning) -> usize {
self.branches
.iter()
.filter(|b| b.health.warnings.contains(&warning))
.count()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecommendedAction {
Delete,
Rebase,
Merge,
Review,
Keep,
}
impl RecommendedAction {
pub fn label(&self) -> &'static str {
match self {
Self::Delete => "Delete",
Self::Rebase => "Rebase",
Self::Merge => "Merge",
Self::Review => "Review",
Self::Keep => "Keep",
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::Delete => "🗑",
Self::Rebase => "↻",
Self::Merge => "⤵",
Self::Review => "👁",
Self::Keep => "✓",
}
}
pub fn color(&self) -> &'static str {
match self {
Self::Delete => "red",
Self::Rebase => "yellow",
Self::Merge => "green",
Self::Review => "cyan",
Self::Keep => "white",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Delete => "Branch is merged or inactive for 60+ days",
Self::Rebase => "Branch is significantly behind the base branch",
Self::Merge => "Branch has changes ready to merge",
Self::Review => "Long-lived branch needs attention",
Self::Keep => "Branch is in good condition",
}
}
}
#[derive(Debug, Clone)]
pub struct BranchRecommendation {
pub branch_name: String,
pub action: RecommendedAction,
pub reason: String,
pub priority: u8,
pub ahead: usize,
pub behind: usize,
pub days_inactive: i64,
}
impl BranchRecommendation {
pub fn new(
branch_name: String,
action: RecommendedAction,
reason: impl Into<String>,
priority: u8,
) -> Self {
Self {
branch_name,
action,
reason: reason.into(),
priority,
ahead: 0,
behind: 0,
days_inactive: 0,
}
}
pub fn with_counts(mut self, ahead: usize, behind: usize) -> Self {
self.ahead = ahead;
self.behind = behind;
self
}
pub fn with_days_inactive(mut self, days: i64) -> Self {
self.days_inactive = days;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct BranchRecommendations {
pub recommendations: Vec<BranchRecommendation>,
pub delete_count: usize,
pub rebase_count: usize,
pub merge_count: usize,
pub review_count: usize,
pub total_branches: usize,
}
impl BranchRecommendations {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, recommendation: BranchRecommendation) {
match recommendation.action {
RecommendedAction::Delete => self.delete_count += 1,
RecommendedAction::Rebase => self.rebase_count += 1,
RecommendedAction::Merge => self.merge_count += 1,
RecommendedAction::Review => self.review_count += 1,
RecommendedAction::Keep => {}
}
self.recommendations.push(recommendation);
}
pub fn sort_by_priority(&mut self) {
self.recommendations
.sort_by(|a, b| b.priority.cmp(&a.priority));
}
pub fn by_action(&self, action: RecommendedAction) -> Vec<&BranchRecommendation> {
self.recommendations
.iter()
.filter(|r| r.action == action)
.collect()
}
pub fn deletable_branches(&self) -> Vec<&str> {
self.recommendations
.iter()
.filter(|r| r.action == RecommendedAction::Delete)
.map(|r| r.branch_name.as_str())
.collect()
}
pub fn get_recommendation(&self, branch_name: &str) -> Option<&BranchRecommendation> {
self.recommendations
.iter()
.find(|r| r.branch_name == branch_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_branch(name: &str) -> TopologyBranch {
TopologyBranch::new(name.to_string(), "abc1234".to_string(), Local::now())
}
#[test]
fn test_branch_status_label() {
assert_eq!(BranchStatus::Active.label(), "HEAD");
assert_eq!(BranchStatus::Normal.label(), "");
assert_eq!(BranchStatus::Stale.label(), "stale");
assert_eq!(BranchStatus::Merged.label(), "merged");
}
#[test]
fn test_branch_relation_summary_merged() {
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.is_merged = true;
assert_eq!(relation.summary(), "merged");
}
#[test]
fn test_branch_relation_summary_up_to_date() {
let relation = BranchRelation::new("main".to_string(), "feature".to_string());
assert_eq!(relation.summary(), "up to date");
}
#[test]
fn test_branch_relation_summary_ahead() {
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.ahead_count = 3;
assert_eq!(relation.summary(), "3 ahead");
}
#[test]
fn test_branch_relation_summary_behind() {
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.behind_count = 2;
assert_eq!(relation.summary(), "2 behind");
}
#[test]
fn test_branch_relation_summary_ahead_and_behind() {
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.ahead_count = 3;
relation.behind_count = 2;
assert_eq!(relation.summary(), "3 ahead, 2 behind");
}
#[test]
fn test_topology_branch_new() {
let branch = create_test_branch("feature");
assert_eq!(branch.name, "feature");
assert_eq!(branch.status, BranchStatus::Normal);
}
#[test]
fn test_topology_branch_with_status() {
let branch = create_test_branch("feature").with_status(BranchStatus::Active);
assert_eq!(branch.status, BranchStatus::Active);
}
#[test]
fn test_topology_branch_is_ahead() {
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.ahead_count = 3;
let branch = create_test_branch("feature").with_relation(relation);
assert!(branch.is_ahead());
}
#[test]
fn test_topology_branch_is_behind() {
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.behind_count = 2;
let branch = create_test_branch("feature").with_relation(relation);
assert!(branch.is_behind());
}
#[test]
fn test_branch_topology_new() {
let topology = BranchTopology::new("main".to_string());
assert_eq!(topology.main_branch, "main");
assert_eq!(topology.branch_count(), 0);
}
#[test]
fn test_branch_topology_add_branch() {
let mut topology = BranchTopology::new("main".to_string());
topology.add_branch(create_test_branch("feature"));
assert_eq!(topology.branch_count(), 1);
}
#[test]
fn test_branch_topology_active_branch() {
let mut topology = BranchTopology::new("main".to_string());
topology.add_branch(create_test_branch("feature"));
topology.add_branch(create_test_branch("main").with_status(BranchStatus::Active));
let active = topology.active_branch();
assert!(active.is_some());
assert_eq!(active.unwrap().name, "main");
}
#[test]
fn test_branch_topology_stale_count() {
let mut topology = BranchTopology::new("main".to_string());
topology.add_branch(create_test_branch("feature1"));
topology.add_branch(create_test_branch("feature2").with_status(BranchStatus::Stale));
topology.add_branch(create_test_branch("feature3").with_status(BranchStatus::Stale));
assert_eq!(topology.stale_count(), 2);
}
#[test]
fn test_branch_topology_merged_count() {
let mut topology = BranchTopology::new("main".to_string());
topology.add_branch(create_test_branch("feature1"));
topology.add_branch(create_test_branch("feature2").with_status(BranchStatus::Merged));
assert_eq!(topology.merged_count(), 1);
}
}