From a2242a4f6e65434701e23827e02da71590365a58 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 28 May 2026 10:14:26 +0300 Subject: refactor(sync): template delete script generation (eq) --- internal/sync/branch_analyzer.go | 330 +++++++++++++--------------------- internal/sync/branch_analyzer_test.go | 124 +++++++++++++ internal/sync/delete_script.tmpl | 127 +++++++++++++ 3 files changed, 380 insertions(+), 201 deletions(-) create mode 100644 internal/sync/delete_script.tmpl diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go index 6c07f77..eaa6cdc 100644 --- a/internal/sync/branch_analyzer.go +++ b/internal/sync/branch_analyzer.go @@ -1,14 +1,31 @@ package sync import ( + _ "embed" "fmt" + "io" "os" "path/filepath" "strconv" "strings" + "text/template" "time" ) +//go:embed delete_script.tmpl +var deleteScriptTemplateText string + +var deleteScriptTemplate = template.Must(template.New("deleteScript").Parse(deleteScriptTemplateText)) + +type deleteScriptTemplateData struct { + GeneratedAt string + TotalAbandoned int + TotalIgnored int + TotalBranches int + RepositoryCount int + ScriptBaseName string +} + // BranchInfo holds information about a branch type BranchInfo struct { Name string @@ -409,6 +426,98 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName return sb.String() } +func writeDeleteScriptTemplate(writer io.Writer, templateName string, data deleteScriptTemplateData) error { + if err := deleteScriptTemplate.ExecuteTemplate(writer, templateName, data); err != nil { + return fmt.Errorf("failed to execute %s template: %w", templateName, err) + } + + return nil +} + +func writeBranchDeletionBlock(writer io.Writer, branches []BranchInfo, reviewBranchType, deleteMessagePrefix string) error { + for _, branch := range branches { + if _, err := fmt.Fprintf(writer, "if [[ \"$MODE\" == \"review\" || \"$MODE\" == \"review-full\" ]]; then\n"); err != nil { + return fmt.Errorf("failed to write review mode condition for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " if [[ -n \"$main_branch\" ]]; then\n"); err != nil { + return fmt.Errorf("failed to write main branch check for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " review_branch \"%s\" \"$main_branch\" \"%s\" \"%s\"\n", branch.Name, branch.LastCommit.Format("2006-01-02"), reviewBranchType); err != nil { + return fmt.Errorf("failed to write review command for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " fi\n"); err != nil { + return fmt.Errorf("failed to write review branch end for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, "else\n"); err != nil { + return fmt.Errorf("failed to write delete branch condition for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " echo \" %s%s (last commit: %s)\"\n", deleteMessagePrefix, branch.Name, branch.LastCommit.Format("2006-01-02")); err != nil { + return fmt.Errorf("failed to write delete message for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " # Check if we're on the branch to be deleted\n"); err != nil { + return fmt.Errorf("failed to write current branch comment for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " current_branch=$(git branch --show-current)\n"); err != nil { + return fmt.Errorf("failed to write current branch command for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " if [[ \"$current_branch\" == \"%s\" ]]; then\n", branch.Name); err != nil { + return fmt.Errorf("failed to write current branch condition for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " echo \" Switching from %s to main/master branch before deletion...\"\n", branch.Name); err != nil { + return fmt.Errorf("failed to write branch switch message for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " main_branch=$(find_main_branch)\n"); err != nil { + return fmt.Errorf("failed to write find main branch command for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " if [[ -n \"$main_branch\" ]]; then\n"); err != nil { + return fmt.Errorf("failed to write branch switch check for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " execute_cmd git checkout \"$main_branch\"\n"); err != nil { + return fmt.Errorf("failed to write checkout command for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " else\n"); err != nil { + return fmt.Errorf("failed to write missing main branch else block for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " echo \" ⚠️ No main/master branch found to switch to!\"\n"); err != nil { + return fmt.Errorf("failed to write missing main branch message for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " echo \" Skipping deletion of %s\"\n", branch.Name); err != nil { + return fmt.Errorf("failed to write skip branch message for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " fi\n"); err != nil { + return fmt.Errorf("failed to write main branch switch end for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " fi\n"); err != nil { + return fmt.Errorf("failed to write current branch condition end for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " # Skip to next branch if we couldn't switch\n"); err != nil { + return fmt.Errorf("failed to write skip comment for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name); err != nil { + return fmt.Errorf("failed to write skip condition for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " continue\n"); err != nil { + return fmt.Errorf("failed to write continue for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, " fi\n"); err != nil { + return fmt.Errorf("failed to write skip condition end for branch %s: %w", branch.Name, err) + } + for _, remote := range branch.RemotesWithBranch { + if _, err := fmt.Fprintf(writer, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name); err != nil { + return fmt.Errorf("failed to write remote delete command for branch %s: %w", branch.Name, err) + } + } + if _, err := fmt.Fprintf(writer, " execute_cmd git branch -D \"%s\"\n", branch.Name); err != nil { + return fmt.Errorf("failed to write local delete command for branch %s: %w", branch.Name, err) + } + if _, err := fmt.Fprintf(writer, "fi\n\n"); err != nil { + return fmt.Errorf("failed to write branch block end for branch %s: %w", branch.Name, err) + } + } + + return nil +} + // GenerateDeleteScript generates a shell script file to delete all abandoned branches func (s *Syncer) GenerateDeleteScript() (string, error) { if len(s.abandonedReports) == 0 { @@ -431,6 +540,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { // Generate script filename with timestamp timestamp := time.Now().Format("20060102_150405") scriptPath := filepath.Join(s.workDir, fmt.Sprintf("delete_abandoned_branches_%s.sh", timestamp)) + scriptBaseName := filepath.Base(scriptPath) // Create the script file file, err := os.Create(scriptPath) @@ -439,123 +549,16 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { } defer file.Close() - // Write script header - fmt.Fprintf(file, "#!/bin/bash\n") - fmt.Fprintf(file, "# Gitsyncer - Delete Abandoned Branches Script\n") - fmt.Fprintf(file, "# Generated on: %s\n", time.Now().Format("2006-01-02 15:04:05")) - fmt.Fprintf(file, "# Total branches to delete: %d regular + %d ignored = %d total\n", totalAbandoned, totalIgnored, totalAbandoned+totalIgnored) - fmt.Fprintf(file, "#\n") - fmt.Fprintf(file, "# ⚠️ WARNING: This script will permanently delete branches!\n") - fmt.Fprintf(file, "# Review carefully before executing.\n") - fmt.Fprintf(file, "#\n") - fmt.Fprintf(file, "# Usage:\n") - fmt.Fprintf(file, "# bash %s # Delete branches (with confirmation)\n", filepath.Base(scriptPath)) - fmt.Fprintf(file, "# bash %s --dry-run # Preview what will be deleted\n", filepath.Base(scriptPath)) - fmt.Fprintf(file, "# bash %s --review # Review diffs before deletion\n", filepath.Base(scriptPath)) - fmt.Fprintf(file, "# bash %s --review-full # Review full diffs\n", filepath.Base(scriptPath)) - fmt.Fprintf(file, "\n") - - // Add mode detection - fmt.Fprintf(file, "# Parse command line arguments\n") - fmt.Fprintf(file, "MODE=\"delete\"\n") - fmt.Fprintf(file, "if [[ \"$1\" == \"--dry-run\" ]]; then\n") - fmt.Fprintf(file, " MODE=\"dry-run\"\n") - fmt.Fprintf(file, "elif [[ \"$1\" == \"--review\" ]]; then\n") - fmt.Fprintf(file, " MODE=\"review\"\n") - fmt.Fprintf(file, "elif [[ \"$1\" == \"--review-full\" ]]; then\n") - fmt.Fprintf(file, " MODE=\"review-full\"\n") - fmt.Fprintf(file, "fi\n\n") - - // Add color support for review mode - fmt.Fprintf(file, "# Color codes for better readability\n") - fmt.Fprintf(file, "RED='\\033[0;31m'\n") - fmt.Fprintf(file, "GREEN='\\033[0;32m'\n") - fmt.Fprintf(file, "YELLOW='\\033[0;33m'\n") - fmt.Fprintf(file, "BLUE='\\033[0;34m'\n") - fmt.Fprintf(file, "PURPLE='\\033[0;35m'\n") - fmt.Fprintf(file, "CYAN='\\033[0;36m'\n") - fmt.Fprintf(file, "NC='\\033[0m' # No Color\n\n") - - // Add helper functions - fmt.Fprintf(file, "# Helper function to execute or print commands\n") - fmt.Fprintf(file, "execute_cmd() {\n") - fmt.Fprintf(file, " if [[ \"$MODE\" == \"dry-run\" ]]; then\n") - fmt.Fprintf(file, " echo \" [DRY RUN] $@\"\n") - fmt.Fprintf(file, " else\n") - fmt.Fprintf(file, " echo \" Executing: $@\"\n") - fmt.Fprintf(file, " \"$@\"\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, "}\n\n") - - // Add function to find main branch - fmt.Fprintf(file, "# Function to find main/master branch\n") - fmt.Fprintf(file, "find_main_branch() {\n") - fmt.Fprintf(file, " if git rev-parse --verify main >/dev/null 2>&1; then\n") - fmt.Fprintf(file, " echo \"main\"\n") - fmt.Fprintf(file, " elif git rev-parse --verify master >/dev/null 2>&1; then\n") - fmt.Fprintf(file, " echo \"master\"\n") - fmt.Fprintf(file, " else\n") - fmt.Fprintf(file, " echo \"\"\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, "}\n\n") - - // Add review function - fmt.Fprintf(file, "# Function to review branch diff\n") - fmt.Fprintf(file, "review_branch() {\n") - fmt.Fprintf(file, " local branch=\"$1\"\n") - fmt.Fprintf(file, " local main_branch=\"$2\"\n") - fmt.Fprintf(file, " local last_commit=\"$3\"\n") - fmt.Fprintf(file, " local branch_type=\"$4\"\n") - fmt.Fprintf(file, " \n") - fmt.Fprintf(file, " echo -e \"${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\n") - fmt.Fprintf(file, " echo -e \"${YELLOW}Branch:${NC} $branch ${PURPLE}[$branch_type]${NC}\"\n") - fmt.Fprintf(file, " echo -e \"${YELLOW}Last commit:${NC} $last_commit\"\n") - fmt.Fprintf(file, " echo -e \"${YELLOW}Comparing against:${NC} $main_branch\"\n") - fmt.Fprintf(file, " echo -e \"${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\n") - fmt.Fprintf(file, " \n") - fmt.Fprintf(file, " # Check if branch exists locally\n") - fmt.Fprintf(file, " if ! git rev-parse --verify \"$branch\" >/dev/null 2>&1; then\n") - fmt.Fprintf(file, " echo -e \"${RED}⚠️ Branch '$branch' not found locally${NC}\"\n") - fmt.Fprintf(file, " return\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " \n") - fmt.Fprintf(file, " echo -e \"${GREEN}📊 Diff statistics:${NC}\"\n") - fmt.Fprintf(file, " git diff --stat \"$main_branch\"...\"$branch\"\n") - fmt.Fprintf(file, " echo\n") - fmt.Fprintf(file, " echo -e \"${GREEN}📝 Commits in this branch:${NC}\"\n") - fmt.Fprintf(file, " git log --oneline --graph \"$main_branch\"..\"$branch\" | head -20\n") - fmt.Fprintf(file, " \n") - fmt.Fprintf(file, " if [[ \"$MODE\" == \"review-full\" ]]; then\n") - fmt.Fprintf(file, " echo\n") - fmt.Fprintf(file, " echo -e \"${GREEN}🔍 Full diff:${NC}\"\n") - fmt.Fprintf(file, " git diff \"$main_branch\"...\"$branch\"\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " echo\n") - fmt.Fprintf(file, "}\n\n") - - // Start main logic - fmt.Fprintf(file, "# Main script logic\n") - fmt.Fprintf(file, "case \"$MODE\" in\n") - fmt.Fprintf(file, " \"dry-run\")\n") - fmt.Fprintf(file, " echo \"🔍 DRY RUN MODE - No branches will be deleted\"\n") - fmt.Fprintf(file, " echo\n") - fmt.Fprintf(file, " ;;\n") - fmt.Fprintf(file, " \"review\"|\"review-full\")\n") - fmt.Fprintf(file, " echo -e \"${CYAN}🔍 Gitsyncer - Abandoned Branch Review${NC}\"\n") - fmt.Fprintf(file, " echo -e \"${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\n") - fmt.Fprintf(file, " echo -e \"Found ${YELLOW}%d${NC} abandoned branches to review\"\n", totalAbandoned+totalIgnored) - fmt.Fprintf(file, " echo\n") - fmt.Fprintf(file, " ;;\n") - fmt.Fprintf(file, " \"delete\")\n") - fmt.Fprintf(file, " echo \"⚠️ This script will delete %d abandoned branches across %d repositories.\"\n", totalAbandoned+totalIgnored, len(s.abandonedReports)) - fmt.Fprintf(file, " read -p \"Are you sure you want to continue? (yes/no): \" confirm\n") - fmt.Fprintf(file, " if [[ \"$confirm\" != \"yes\" ]]; then\n") - fmt.Fprintf(file, " echo \"Aborted.\"\n") - fmt.Fprintf(file, " exit 0\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " echo\n") - fmt.Fprintf(file, " ;;\n") - fmt.Fprintf(file, "esac\n\n") + if err := writeDeleteScriptTemplate(file, "deleteScriptPreamble", deleteScriptTemplateData{ + GeneratedAt: time.Now().Format("2006-01-02 15:04:05"), + TotalAbandoned: totalAbandoned, + TotalIgnored: totalIgnored, + TotalBranches: totalAbandoned + totalIgnored, + RepositoryCount: len(s.abandonedReports), + ScriptBaseName: scriptBaseName, + }); err != nil { + return scriptPath, err + } // Process each repository for repoName, report := range s.abandonedReports { @@ -582,100 +585,25 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { // Process regular abandoned branches if len(report.AbandonedBranches) > 0 { fmt.Fprintf(file, "# Regular abandoned branches\n") - for _, branch := range report.AbandonedBranches { - fmt.Fprintf(file, "if [[ \"$MODE\" == \"review\" || \"$MODE\" == \"review-full\" ]]; then\n") - fmt.Fprintf(file, " if [[ -n \"$main_branch\" ]]; then\n") - fmt.Fprintf(file, " review_branch \"%s\" \"$main_branch\" \"%s\" \"regular\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, "else\n") - fmt.Fprintf(file, " echo \" 🔸 Deleting branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - - // Check if we're on the branch to be deleted, and switch to main/master if so - fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n") - fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n") - fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]]; then\n", branch.Name) - fmt.Fprintf(file, " echo \" Switching from %s to main/master branch before deletion...\"\n", branch.Name) - fmt.Fprintf(file, " main_branch=$(find_main_branch)\n") - fmt.Fprintf(file, " if [[ -n \"$main_branch\" ]]; then\n") - fmt.Fprintf(file, " execute_cmd git checkout \"$main_branch\"\n") - fmt.Fprintf(file, " else\n") - fmt.Fprintf(file, " echo \" ⚠️ No main/master branch found to switch to!\"\n") - fmt.Fprintf(file, " echo \" Skipping deletion of %s\"\n", branch.Name) - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " # Skip to next branch if we couldn't switch\n") - fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name) - fmt.Fprintf(file, " continue\n") - fmt.Fprintf(file, " fi\n") - - // Delete from remotes - for _, remote := range branch.RemotesWithBranch { - fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name) - } - - // Delete local branch - fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name) - fmt.Fprintf(file, "fi\n\n") + if err := writeBranchDeletionBlock(file, report.AbandonedBranches, "regular", "🔸 Deleting branch: "); err != nil { + return scriptPath, err } } // Process ignored abandoned branches if len(report.AbandonedIgnoredBranches) > 0 { fmt.Fprintf(file, "# Ignored abandoned branches\n") - for _, branch := range report.AbandonedIgnoredBranches { - fmt.Fprintf(file, "if [[ \"$MODE\" == \"review\" || \"$MODE\" == \"review-full\" ]]; then\n") - fmt.Fprintf(file, " if [[ -n \"$main_branch\" ]]; then\n") - fmt.Fprintf(file, " review_branch \"%s\" \"$main_branch\" \"%s\" \"ignored\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, "else\n") - fmt.Fprintf(file, " echo \" 🔹 Deleting ignored branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - - // Check if we're on the branch to be deleted, and switch to main/master if so - fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n") - fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n") - fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]]; then\n", branch.Name) - fmt.Fprintf(file, " echo \" Switching from %s to main/master branch before deletion...\"\n", branch.Name) - fmt.Fprintf(file, " main_branch=$(find_main_branch)\n") - fmt.Fprintf(file, " if [[ -n \"$main_branch\" ]]; then\n") - fmt.Fprintf(file, " execute_cmd git checkout \"$main_branch\"\n") - fmt.Fprintf(file, " else\n") - fmt.Fprintf(file, " echo \" ⚠️ No main/master branch found to switch to!\"\n") - fmt.Fprintf(file, " echo \" Skipping deletion of %s\"\n", branch.Name) - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " fi\n") - fmt.Fprintf(file, " # Skip to next branch if we couldn't switch\n") - fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name) - fmt.Fprintf(file, " continue\n") - fmt.Fprintf(file, " fi\n") - - // Delete from remotes - for _, remote := range branch.RemotesWithBranch { - fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name) - } - - // Delete local branch - fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name) - fmt.Fprintf(file, "fi\n\n") + if err := writeBranchDeletionBlock(file, report.AbandonedIgnoredBranches, "ignored", "🔹 Deleting ignored branch: "); err != nil { + return scriptPath, err } } } - // Add completion message - fmt.Fprintf(file, "echo\n") - fmt.Fprintf(file, "echo \"✅ Script completed!\"\n") - fmt.Fprintf(file, "case \"$MODE\" in\n") - fmt.Fprintf(file, " \"dry-run\")\n") - fmt.Fprintf(file, " echo \"This was a dry run. No branches were deleted.\"\n") - fmt.Fprintf(file, " echo \"To actually delete branches, run: bash %s\"\n", filepath.Base(scriptPath)) - fmt.Fprintf(file, " ;;\n") - fmt.Fprintf(file, " \"review\"|\"review-full\")\n") - fmt.Fprintf(file, " echo \"Review completed. No branches were deleted.\"\n") - fmt.Fprintf(file, " echo \"To delete branches, run: bash %s\"\n", filepath.Base(scriptPath)) - fmt.Fprintf(file, " ;;\n") - fmt.Fprintf(file, " \"delete\")\n") - fmt.Fprintf(file, " echo \"All abandoned branches have been deleted.\"\n") - fmt.Fprintf(file, " ;;\n") - fmt.Fprintf(file, "esac\n") + if err := writeDeleteScriptTemplate(file, "deleteScriptFooter", deleteScriptTemplateData{ + ScriptBaseName: scriptBaseName, + }); err != nil { + return scriptPath, err + } // Make the script executable if err := os.Chmod(scriptPath, 0755); err != nil { diff --git a/internal/sync/branch_analyzer_test.go b/internal/sync/branch_analyzer_test.go index 2a51bc7..4949c07 100644 --- a/internal/sync/branch_analyzer_test.go +++ b/internal/sync/branch_analyzer_test.go @@ -1,11 +1,20 @@ package sync import ( + "errors" + "os" + "path/filepath" "strings" "testing" "time" ) +type failingWriter struct{} + +func (f failingWriter) Write(_ []byte) (int, error) { + return 0, errors.New("write failed") +} + func TestFilterProtectedAbandonedBranchReport_SkipsProtectedBranches(t *testing.T) { report := &AbandonedBranchReport{ AbandonedBranches: []BranchInfo{ @@ -87,3 +96,118 @@ func TestGenerateDeleteScript_ReturnsEmptyWhenOnlyProtectedBranchesRemain(t *tes t.Fatalf("expected no delete script for protected branches, got %q", scriptPath) } } + +func TestGenerateDeleteScript_WritesRegularAndIgnoredBlocks(t *testing.T) { + workDir := t.TempDir() + syncer := &Syncer{ + workDir: workDir, + abandonedReports: map[string]*AbandonedBranchReport{ + "repo-a": { + AbandonedBranches: []BranchInfo{ + { + Name: "feature/old", + LastCommit: time.Date(2024, time.January, 3, 0, 0, 0, 0, time.UTC), + RemotesWithBranch: []string{"origin", "backup"}, + }, + }, + AbandonedIgnoredBranches: []BranchInfo{ + { + Name: "ignored/old", + LastCommit: time.Date(2024, time.January, 4, 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.Fatal("expected script path to be returned") + } + + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("expected generated script to be readable, got %v", err) + } + + script := string(content) + expectedBase := filepath.Base(scriptPath) + expectedSnippets := []string{ + "# bash " + expectedBase + " --review-full # Review full diffs", + "# Regular abandoned branches", + "review_branch \"feature/old\" \"$main_branch\" \"2024-01-03\" \"regular\"", + "echo \" 🔸 Deleting branch: feature/old (last commit: 2024-01-03)\"", + "execute_cmd git push origin --delete \"feature/old\"", + "execute_cmd git push backup --delete \"feature/old\"", + "# Ignored abandoned branches", + "review_branch \"ignored/old\" \"$main_branch\" \"2024-01-04\" \"ignored\"", + "echo \" 🔹 Deleting ignored branch: ignored/old (last commit: 2024-01-04)\"", + "execute_cmd git push origin --delete \"ignored/old\"", + "To delete branches, run: bash " + expectedBase, + } + for _, snippet := range expectedSnippets { + if !strings.Contains(script, snippet) { + t.Fatalf("expected script to contain %q, got:\n%s", snippet, script) + } + } + + info, err := os.Stat(scriptPath) + if err != nil { + t.Fatalf("expected generated script to be stat-able, got %v", err) + } + if info.Mode().Perm() != 0755 { + t.Fatalf("expected script permissions to be 0755, got %o", info.Mode().Perm()) + } +} + +func TestGenerateDeleteScript_ReturnsErrorWhenWorkDirIsFile(t *testing.T) { + tempDir := t.TempDir() + workDirFile := filepath.Join(tempDir, "work-dir-file") + if err := os.WriteFile(workDirFile, []byte("not a directory"), 0600); err != nil { + t.Fatalf("failed to create temp file for test setup: %v", err) + } + + syncer := &Syncer{ + workDir: workDirFile, + abandonedReports: map[string]*AbandonedBranchReport{ + "repo-a": { + AbandonedBranches: []BranchInfo{ + { + Name: "feature/old", + LastCommit: time.Date(2024, time.January, 3, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + } + + scriptPath, err := syncer.GenerateDeleteScript() + if err == nil { + t.Fatal("expected an error when workDir is not a directory") + } + if scriptPath != "" { + t.Fatalf("expected empty script path on creation failure, got %q", scriptPath) + } + if !strings.Contains(err.Error(), "failed to create script file") { + t.Fatalf("expected create-file error, got %v", err) + } +} + +func TestWriteBranchDeletionBlock_ReturnsWriteError(t *testing.T) { + err := writeBranchDeletionBlock( + failingWriter{}, + []BranchInfo{{Name: "feature/broken", LastCommit: time.Date(2024, time.January, 3, 0, 0, 0, 0, time.UTC)}}, + "regular", + "🔸 Deleting branch: ", + ) + if err == nil { + t.Fatal("expected write error") + } + if !strings.Contains(err.Error(), "failed to write review mode condition") { + t.Fatalf("expected write context in error, got %v", err) + } +} diff --git a/internal/sync/delete_script.tmpl b/internal/sync/delete_script.tmpl new file mode 100644 index 0000000..5deb647 --- /dev/null +++ b/internal/sync/delete_script.tmpl @@ -0,0 +1,127 @@ +{{define "deleteScriptPreamble"}}#!/bin/bash +# Gitsyncer - Delete Abandoned Branches Script +# Generated on: {{.GeneratedAt}} +# Total branches to delete: {{.TotalAbandoned}} regular + {{.TotalIgnored}} ignored = {{.TotalBranches}} total +# +# ⚠️ WARNING: This script will permanently delete branches! +# Review carefully before executing. +# +# Usage: +# bash {{.ScriptBaseName}} # Delete branches (with confirmation) +# bash {{.ScriptBaseName}} --dry-run # Preview what will be deleted +# bash {{.ScriptBaseName}} --review # Review diffs before deletion +# bash {{.ScriptBaseName}} --review-full # Review full diffs + +# Parse command line arguments +MODE="delete" +if [[ "$1" == "--dry-run" ]]; then + MODE="dry-run" +elif [[ "$1" == "--review" ]]; then + MODE="review" +elif [[ "$1" == "--review-full" ]]; then + MODE="review-full" +fi + +# Color codes for better readability +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Helper function to execute or print commands +execute_cmd() { + if [[ "$MODE" == "dry-run" ]]; then + echo " [DRY RUN] $@" + else + echo " Executing: $@" + "$@" + fi +} + +# Function to find main/master branch +find_main_branch() { + if git rev-parse --verify main >/dev/null 2>&1; then + echo "main" + elif git rev-parse --verify master >/dev/null 2>&1; then + echo "master" + else + echo "" + fi +} + +# Function to review branch diff +review_branch() { + local branch="$1" + local main_branch="$2" + local last_commit="$3" + local branch_type="$4" + + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}Branch:${NC} $branch ${PURPLE}[$branch_type]${NC}" + echo -e "${YELLOW}Last commit:${NC} $last_commit" + echo -e "${YELLOW}Comparing against:${NC} $main_branch" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + # Check if branch exists locally + if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then + echo -e "${RED}⚠️ Branch '$branch' not found locally${NC}" + return + fi + + echo -e "${GREEN}📊 Diff statistics:${NC}" + git diff --stat "$main_branch"..."$branch" + echo + echo -e "${GREEN}📝 Commits in this branch:${NC}" + git log --oneline --graph "$main_branch".."$branch" | head -20 + + if [[ "$MODE" == "review-full" ]]; then + echo + echo -e "${GREEN}🔍 Full diff:${NC}" + git diff "$main_branch"..."$branch" + fi + echo +} + +# Main script logic +case "$MODE" in + "dry-run") + echo "🔍 DRY RUN MODE - No branches will be deleted" + echo + ;; + "review"|"review-full") + echo -e "${CYAN}🔍 Gitsyncer - Abandoned Branch Review${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "Found ${YELLOW}{{.TotalBranches}}${NC} abandoned branches to review" + echo + ;; + "delete") + echo "⚠️ This script will delete {{.TotalBranches}} abandoned branches across {{.RepositoryCount}} repositories." + read -p "Are you sure you want to continue? (yes/no): " confirm + if [[ "$confirm" != "yes" ]]; then + echo "Aborted." + exit 0 + fi + echo + ;; +esac + +{{end}} +{{define "deleteScriptFooter"}}echo +echo "✅ Script completed!" +case "$MODE" in + "dry-run") + echo "This was a dry run. No branches were deleted." + echo "To actually delete branches, run: bash {{.ScriptBaseName}}" + ;; + "review"|"review-full") + echo "Review completed. No branches were deleted." + echo "To delete branches, run: bash {{.ScriptBaseName}}" + ;; + "delete") + echo "All abandoned branches have been deleted." + ;; +esac +{{end}} -- cgit v1.2.3