diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-12 20:08:33 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-12 20:08:33 +0200 |
| commit | 8e20bba7dc5e877cc5328cc57cd4d73d8e638b47 (patch) | |
| tree | c38b38d2a0024192bf9c634baf795fadf54f9feb | |
| parent | ccaff1ddf6e883bb1ad8753feede413326e488b0 (diff) | |
fix(sync): protect xerl hosts branch from auto-delete
| -rw-r--r-- | internal/sync/branch_analyzer.go | 56 | ||||
| -rw-r--r-- | internal/sync/branch_analyzer_test.go | 89 |
2 files changed, 142 insertions, 3 deletions
diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go index 04a60d7..6c07f77 100644 --- a/internal/sync/branch_analyzer.go +++ b/internal/sync/branch_analyzer.go @@ -29,6 +29,50 @@ type AbandonedBranchReport struct { TotalIgnoredBranches int } +var defaultAutoDeleteProtectedBranches = map[string]map[string]struct{}{ + "xerl": { + "hosts": {}, + }, +} + +func isProtectedFromAutoDelete(repoName, branchName string) bool { + branches, ok := defaultAutoDeleteProtectedBranches[repoName] + if !ok { + return false + } + + _, ok = branches[branchName] + return ok +} + +func filterProtectedBranchInfos(repoName string, branches []BranchInfo) []BranchInfo { + if len(branches) == 0 { + return branches + } + + filtered := make([]BranchInfo, 0, len(branches)) + for _, branch := range branches { + if isProtectedFromAutoDelete(repoName, branch.Name) { + continue + } + filtered = append(filtered, branch) + } + + return filtered +} + +func filterProtectedAbandonedBranchReport(repoName string, report *AbandonedBranchReport) *AbandonedBranchReport { + if report == nil { + return nil + } + + filtered := *report + filtered.AbandonedBranches = filterProtectedBranchInfos(repoName, report.AbandonedBranches) + filtered.AbandonedIgnoredBranches = filterProtectedBranchInfos(repoName, report.AbandonedIgnoredBranches) + + return &filtered +} + // analyzeAbandonedBranches analyzes branches to find abandoned ones func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { report := &AbandonedBranchReport{ @@ -110,7 +154,7 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { } } - return report, nil + return filterProtectedAbandonedBranchReport(s.repoName, report), nil } // findMainBranch finds the main or master branch @@ -199,6 +243,7 @@ func (s *Syncer) getLastCommitTime(remoteName, branch string) (time.Time, error) // formatAbandonedBranchReport formats the report for display func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string) string { + report = filterProtectedAbandonedBranchReport(repoName, report) if !report.MainBranchUpdated { return "" // Don't report on inactive repos } @@ -244,7 +289,8 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { totalAbandonedIgnored := 0 reposWithAbandoned := 0 - for _, report := range s.abandonedReports { + for repoName, report := range s.abandonedReports { + report = filterProtectedAbandonedBranchReport(repoName, report) if len(report.AbandonedBranches) > 0 || len(report.AbandonedIgnoredBranches) > 0 { totalAbandoned += len(report.AbandonedBranches) totalAbandonedIgnored += len(report.AbandonedIgnoredBranches) @@ -270,6 +316,7 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { // Group by repository for repoName, report := range s.abandonedReports { + report = filterProtectedAbandonedBranchReport(repoName, report) if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { continue } @@ -310,6 +357,7 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { // GenerateDeleteCommands generates shell commands to delete abandoned branches func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName string) string { + report = filterProtectedAbandonedBranchReport(repoName, report) if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { return "" } @@ -370,7 +418,8 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { // Count total abandoned branches totalAbandoned := 0 totalIgnored := 0 - for _, report := range s.abandonedReports { + for repoName, report := range s.abandonedReports { + report = filterProtectedAbandonedBranchReport(repoName, report) totalAbandoned += len(report.AbandonedBranches) totalIgnored += len(report.AbandonedIgnoredBranches) } @@ -510,6 +559,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { // Process each repository for repoName, report := range s.abandonedReports { + report = filterProtectedAbandonedBranchReport(repoName, report) if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { continue } diff --git a/internal/sync/branch_analyzer_test.go b/internal/sync/branch_analyzer_test.go new file mode 100644 index 0000000..2a51bc7 --- /dev/null +++ b/internal/sync/branch_analyzer_test.go @@ -0,0 +1,89 @@ +package sync + +import ( + "strings" + "testing" + "time" +) + +func TestFilterProtectedAbandonedBranchReport_SkipsProtectedBranches(t *testing.T) { + report := &AbandonedBranchReport{ + AbandonedBranches: []BranchInfo{ + {Name: "hosts"}, + {Name: "feature/still-delete"}, + }, + AbandonedIgnoredBranches: []BranchInfo{ + {Name: "hosts"}, + {Name: "ignored/still-delete"}, + }, + } + + filtered := filterProtectedAbandonedBranchReport("xerl", report) + + if len(filtered.AbandonedBranches) != 1 || filtered.AbandonedBranches[0].Name != "feature/still-delete" { + t.Fatalf("expected protected abandoned branch to be filtered, got %#v", filtered.AbandonedBranches) + } + + if len(filtered.AbandonedIgnoredBranches) != 1 || filtered.AbandonedIgnoredBranches[0].Name != "ignored/still-delete" { + t.Fatalf("expected protected ignored branch to be filtered, got %#v", filtered.AbandonedIgnoredBranches) + } + + if len(report.AbandonedBranches) != 2 || len(report.AbandonedIgnoredBranches) != 2 { + t.Fatalf("expected original report to remain unchanged, got %#v", report) + } +} + +func TestGenerateDeleteCommands_SkipsProtectedXerlHostsBranchOnly(t *testing.T) { + syncer := &Syncer{} + report := &AbandonedBranchReport{ + AbandonedBranches: []BranchInfo{ + { + Name: "hosts", + LastCommit: time.Date(2024, time.January, 2, 0, 0, 0, 0, time.UTC), + RemotesWithBranch: []string{"origin"}, + }, + { + Name: "feature/still-delete", + LastCommit: time.Date(2024, time.January, 3, 0, 0, 0, 0, time.UTC), + RemotesWithBranch: []string{"origin"}, + }, + }, + } + + commands := syncer.GenerateDeleteCommands(report, "xerl") + + if strings.Contains(commands, "hosts") { + t.Fatalf("expected protected branch to be omitted from delete commands, got %q", commands) + } + + if !strings.Contains(commands, "feature/still-delete") { + t.Fatalf("expected non-protected branch to remain in delete commands, got %q", commands) + } +} + +func TestGenerateDeleteScript_ReturnsEmptyWhenOnlyProtectedBranchesRemain(t *testing.T) { + syncer := &Syncer{ + workDir: t.TempDir(), + abandonedReports: map[string]*AbandonedBranchReport{ + "xerl": { + MainBranchUpdated: true, + AbandonedBranches: []BranchInfo{ + { + Name: "hosts", + LastCommit: time.Date(2024, time.January, 2, 0, 0, 0, 0, time.UTC), + RemotesWithBranch: []string{"origin"}, + }, + }, + }, + }, + } + + scriptPath, err := syncer.GenerateDeleteScript() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if scriptPath != "" { + t.Fatalf("expected no delete script for protected branches, got %q", scriptPath) + } +} |
