summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-06-24 01:21:35 +0300
committerPaul Buetow <paul@buetow.org>2025-06-24 01:21:35 +0300
commitbfc52a37f0650e0d8cf727f5998882a3bcebbe0c (patch)
tree1e6d807f6aab2b491f0a4b1c705416b8242e87dd
parent8b007ab16b272a718f1cd04afcd5f4fab02714ce (diff)
feat: add abandoned branch detection and reporting
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 <noreply@anthropic.com>
-rw-r--r--internal/cli/sync_handlers.go16
-rw-r--r--internal/sync/branch_analyzer.go230
-rw-r--r--internal/sync/sync.go16
3 files changed, 262 insertions, 0 deletions
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 <branch-name>\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
}