From bfc52a37f0650e0d8cf727f5998882a3bcebbe0c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 24 Jun 2025 01:21:35 +0300 Subject: feat: add abandoned branch detection and reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatically detects and reports abandoned branches during sync operations: - Branches are considered abandoned if they have no commits for 6+ months - Only reports on active repositories (main/master updated within last year) - Shows individual reports during sync of each repository - Displays comprehensive summary after sync-all operations - Provides helpful cleanup commands for removing old branches This helps maintain cleaner repositories by identifying stale branches that may no longer be needed. Example output: šŸ” Abandoned branches in dtail: Main branch last updated: 2025-04-17 Found 1 abandoned branches (no commits for 6+ months): - develop (last commit: 2023-10-05, No commits for 628 days) šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/cli/sync_handlers.go | 16 +++ internal/sync/branch_analyzer.go | 230 +++++++++++++++++++++++++++++++++++++++ internal/sync/sync.go | 16 +++ 3 files changed, 262 insertions(+) create mode 100644 internal/sync/branch_analyzer.go diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index d14255e..dd0dcf9 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -66,6 +66,12 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { } fmt.Printf("\nSuccessfully synced all %d repositories!\n", successCount) + + // Print abandoned branches summary + if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { + fmt.Print(summary) + } + return 0 } @@ -269,6 +275,11 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) + // Print abandoned branches summary + if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { + fmt.Print(summary) + } + if !flags.SyncGitHubPublic { return 0 } @@ -304,6 +315,11 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) + // Print abandoned branches summary + if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { + fmt.Print(summary) + } + return 0 } diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go new file mode 100644 index 0000000..50e44c7 --- /dev/null +++ b/internal/sync/branch_analyzer.go @@ -0,0 +1,230 @@ +package sync + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// BranchInfo holds information about a branch +type BranchInfo struct { + Name string + LastCommit time.Time + Remote string + IsAbandoned bool + AbandonReason string +} + +// AbandonedBranchReport holds the analysis results +type AbandonedBranchReport struct { + MainBranchUpdated bool + MainBranchLastCommit time.Time + AbandonedBranches []BranchInfo + TotalBranches int +} + +// analyzeAbandonedBranches analyzes branches to find abandoned ones +func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { + report := &AbandonedBranchReport{ + AbandonedBranches: []BranchInfo{}, + } + + // Get all branches + branches, err := s.getAllBranches() + if err != nil { + return nil, fmt.Errorf("failed to get branches: %w", err) + } + report.TotalBranches = len(branches) + + // Check main/master branch status + mainBranch := s.findMainBranch(branches) + if mainBranch != "" { + mainInfo, err := s.getBranchInfo(mainBranch) + if err == nil { + report.MainBranchUpdated = mainInfo.LastCommit.After(time.Now().AddDate(-1, 0, 0)) + report.MainBranchLastCommit = mainInfo.LastCommit + } + } + + // Only analyze if main branch is active + if !report.MainBranchUpdated { + return report, nil + } + + // Analyze each branch + sixMonthsAgo := time.Now().AddDate(0, -6, 0) + + for _, branch := range branches { + // Skip main/master branches + if branch == "main" || branch == "master" { + continue + } + + branchInfo, err := s.getBranchInfo(branch) + if err != nil { + continue + } + + // Check if branch is abandoned (no commits for 6+ months) + if branchInfo.LastCommit.Before(sixMonthsAgo) { + branchInfo.IsAbandoned = true + daysSinceCommit := int(time.Since(branchInfo.LastCommit).Hours() / 24) + branchInfo.AbandonReason = fmt.Sprintf("No commits for %d days", daysSinceCommit) + report.AbandonedBranches = append(report.AbandonedBranches, *branchInfo) + } + } + + return report, nil +} + +// findMainBranch finds the main or master branch +func (s *Syncer) findMainBranch(branches []string) string { + for _, branch := range branches { + if branch == "main" || branch == "master" { + return branch + } + } + return "" +} + +// getBranchInfo gets information about a specific branch +func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) { + info := &BranchInfo{ + Name: branch, + } + + // Find which remote has this branch and get the latest commit + var latestCommit time.Time + var latestRemote string + + for i := range s.config.Organizations { + org := &s.config.Organizations[i] + remoteName := s.getRemoteName(org) + + if s.remoteBranchExists(remoteName, branch) { + // Get last commit date for this branch on this remote + commitTime, err := s.getLastCommitTime(remoteName, branch) + if err == nil && (latestCommit.IsZero() || commitTime.After(latestCommit)) { + latestCommit = commitTime + latestRemote = remoteName + } + } + } + + if latestCommit.IsZero() { + // If no remote has the branch, check local + commitTime, err := s.getLastCommitTime("", branch) + if err != nil { + return nil, fmt.Errorf("failed to get commit time for branch %s: %w", branch, err) + } + latestCommit = commitTime + latestRemote = "local" + } + + info.LastCommit = latestCommit + info.Remote = latestRemote + return info, nil +} + +// getLastCommitTime gets the last commit time for a branch +func (s *Syncer) getLastCommitTime(remoteName, branch string) (time.Time, error) { + var ref string + if remoteName != "" { + ref = fmt.Sprintf("%s/%s", remoteName, branch) + } else { + ref = branch + } + + // Get Unix timestamp of last commit + cmd := exec.Command("git", "log", "-1", "--format=%ct", ref) + output, err := cmd.Output() + if err != nil { + return time.Time{}, err + } + + timestampStr := strings.TrimSpace(string(output)) + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse timestamp: %w", err) + } + + return time.Unix(timestamp, 0), nil +} + +// formatAbandonedBranchReport formats the report for display +func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string) string { + if !report.MainBranchUpdated { + return "" // Don't report on inactive repos + } + + if len(report.AbandonedBranches) == 0 { + return "" // No abandoned branches + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\nšŸ” Abandoned branches in %s:\n", repoName)) + sb.WriteString(fmt.Sprintf(" Main branch last updated: %s\n", report.MainBranchLastCommit.Format("2006-01-02"))) + sb.WriteString(fmt.Sprintf(" Found %d abandoned branches (no commits for 6+ months):\n\n", len(report.AbandonedBranches))) + + for _, branch := range report.AbandonedBranches { + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", + branch.Name, + branch.LastCommit.Format("2006-01-02"), + branch.AbandonReason)) + } + + return sb.String() +} + +// GenerateAbandonedBranchSummary generates a summary of all abandoned branches across repos +func (s *Syncer) GenerateAbandonedBranchSummary() string { + if len(s.abandonedReports) == 0 { + return "" + } + + totalAbandoned := 0 + reposWithAbandoned := 0 + + for _, report := range s.abandonedReports { + if len(report.AbandonedBranches) > 0 { + totalAbandoned += len(report.AbandonedBranches) + reposWithAbandoned++ + } + } + + if totalAbandoned == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(strings.Repeat("=", 70)) + sb.WriteString("\nšŸ“Š ABANDONED BRANCHES SUMMARY\n") + sb.WriteString(strings.Repeat("=", 70)) + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf("Found %d abandoned branches across %d repositories\n\n", totalAbandoned, reposWithAbandoned)) + + // Group by repository + for repoName, report := range s.abandonedReports { + if len(report.AbandonedBranches) == 0 { + continue + } + + sb.WriteString(fmt.Sprintf("šŸ“ %s (%d branches):\n", repoName, len(report.AbandonedBranches))) + for _, branch := range report.AbandonedBranches { + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", + branch.Name, + branch.LastCommit.Format("2006-01-02"))) + } + sb.WriteString("\n") + } + + sb.WriteString("šŸ’” Tip: Consider deleting these branches if they're no longer needed:\n") + sb.WriteString(" git push origin --delete \n") + sb.WriteString(strings.Repeat("=", 70)) + sb.WriteString("\n") + + return sb.String() +} \ No newline at end of file diff --git a/internal/sync/sync.go b/internal/sync/sync.go index c7f17e0..4d46ca8 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -15,6 +15,7 @@ type Syncer struct { config *config.Config workDir string repoName string + abandonedReports map[string]*AbandonedBranchReport // Collects reports across repos } // CLAUDE: Is there a reason, we return a pointer to Syncer? @@ -23,6 +24,7 @@ func New(cfg *config.Config, workDir string) *Syncer { return &Syncer{ config: cfg, workDir: workDir, + abandonedReports: make(map[string]*AbandonedBranchReport), } } @@ -68,6 +70,20 @@ func (s *Syncer) SyncRepository(repoName string) error { return err } + // Analyze abandoned branches + report, err := s.analyzeAbandonedBranches() + if err != nil { + // Don't fail sync, just log the error + fmt.Printf("Warning: Failed to analyze abandoned branches: %v\n", err) + } else { + // Store the report for summary + s.abandonedReports[repoName] = report + // Print individual report if not empty + if reportStr := formatAbandonedBranchReport(report, repoName); reportStr != "" { + fmt.Print(reportStr) + } + } + fmt.Printf("\nRepository %s synchronized successfully!\n", repoName) return nil } -- cgit v1.2.3