diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-12 13:51:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-12 13:51:48 +0300 |
| commit | ceb164c6a8826db1e763aecca49098a8c2584b7f (patch) | |
| tree | a228040641666f4a6567e2d2e4544fd7daecc2e7 /internal | |
| parent | 9d171e6b30a589240a16d8fef15e2195189e52a9 (diff) | |
feat: add automatic release checking and creation
- Add --check-releases flag for manual release checking
- Enable automatic release checking after sync operations by default
- Add --no-check-releases flag to disable automatic checking
- Add --auto-create-releases flag for unattended release creation
- Generate release notes from commit history
- Support version tag formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X
- Use tokens from gitsyncer config (with fallback to env vars and files)
- Show release notes preview before creating releases
- Group commits by type (features, fixes, other) in release notes
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/flags.go | 6 | ||||
| -rw-r--r-- | internal/cli/release.go | 315 | ||||
| -rw-r--r-- | internal/cli/sync_handlers.go | 81 | ||||
| -rw-r--r-- | internal/release/release.go | 461 | ||||
| -rw-r--r-- | internal/sync/branch_analyzer.go | 401 |
5 files changed, 1247 insertions, 17 deletions
diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 3a9990f..72ffa1a 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -30,6 +30,9 @@ type Flags struct { Showcase bool Force bool BatchRun bool + CheckReleases bool + NoCheckReleases bool + AutoCreateReleases bool // Internal fields for batch run state management (not set by flags) BatchRunStateManager *state.Manager @@ -62,6 +65,9 @@ func ParseFlags() *Flags { flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using Claude after syncing") flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data") flag.BoolVar(&f.BatchRun, "batch-run", false, "enable --full and --showcase (runs only once per week)") + flag.BoolVar(&f.CheckReleases, "check-releases", false, "manually check for version tags without releases and create them (with confirmation)") + flag.BoolVar(&f.NoCheckReleases, "no-check-releases", false, "disable automatic release checking after sync operations") + flag.BoolVar(&f.AutoCreateReleases, "auto-create-releases", false, "automatically create releases without confirmation prompts") flag.Parse() diff --git a/internal/cli/release.go b/internal/cli/release.go new file mode 100644 index 0000000..a83283d --- /dev/null +++ b/internal/cli/release.go @@ -0,0 +1,315 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/gitsyncer/internal/config" + "codeberg.org/snonux/gitsyncer/internal/release" +) + +// HandleCheckReleases checks for version tags without releases and creates them with confirmation +func HandleCheckReleases(cfg *config.Config, flags *Flags) int { + releaseManager := release.NewManager(flags.WorkDir) + + // Set tokens from config with fallback to environment variables and files + githubOrg := cfg.FindGitHubOrg() + if githubOrg != nil { + fmt.Printf("Found GitHub org: %s\n", githubOrg.Name) + + // Try config token first, then fallback to env var and file + token := githubOrg.GitHubToken + if token == "" { + fmt.Println("No GitHub token in config, checking environment variable...") + token = os.Getenv("GITHUB_TOKEN") + } + if token == "" { + fmt.Println("No GITHUB_TOKEN env var, checking ~/.gitsyncer_github_token file...") + home, err := os.UserHomeDir() + if err == nil { + tokenFile := filepath.Join(home, ".gitsyncer_github_token") + data, err := os.ReadFile(tokenFile) + if err == nil { + token = strings.TrimSpace(string(data)) + } + } + } + + if token != "" { + fmt.Printf("Setting GitHub token (length: %d)\n", len(token)) + releaseManager.SetGitHubToken(token) + } else { + fmt.Println("No GitHub token found") + } + } else { + fmt.Println("No GitHub organization found in config") + } + + codebergOrg := cfg.FindCodebergOrg() + if codebergOrg != nil { + fmt.Printf("Found Codeberg org: %s\n", codebergOrg.Name) + + // Try config token first, then fallback to env var and file + token := codebergOrg.CodebergToken + if token == "" { + fmt.Println("No Codeberg token in config, checking environment variable...") + token = os.Getenv("CODEBERG_TOKEN") + } + if token == "" { + fmt.Println("No CODEBERG_TOKEN env var, checking ~/.gitsyncer_codeberg_token file...") + home, err := os.UserHomeDir() + if err == nil { + tokenFile := filepath.Join(home, ".gitsyncer_codeberg_token") + data, err := os.ReadFile(tokenFile) + if err == nil { + token = strings.TrimSpace(string(data)) + } + } + } + + if token != "" { + fmt.Printf("Setting Codeberg token (length: %d)\n", len(token)) + releaseManager.SetCodebergToken(token) + } else { + fmt.Println("No Codeberg token found") + } + } else { + fmt.Println("No Codeberg organization found in config") + } + + // Process all configured repositories + for _, repoName := range cfg.Repositories { + fmt.Printf("\nChecking releases for repository: %s\n", repoName) + + // Check if the repository is cloned locally + repoPath := filepath.Join(flags.WorkDir, repoName) + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + fmt.Printf(" Repository not found locally at %s, skipping...\n", repoPath) + continue + } + + // Get local tags + localTags, err := releaseManager.GetLocalTags(repoPath) + if err != nil { + fmt.Printf(" Error getting local tags: %v\n", err) + continue + } + + if len(localTags) == 0 { + fmt.Println(" No version tags found") + continue + } + + fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", ")) + + // Check GitHub releases if GitHub is configured + var missingGitHub []string + githubOrg := cfg.FindGitHubOrg() + if githubOrg != nil && githubOrg.Name != "" { + githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName) + if err != nil { + fmt.Printf(" Error checking GitHub releases: %v\n", err) + } else { + missingGitHub = releaseManager.FindMissingReleases(localTags, githubReleases) + if len(missingGitHub) > 0 { + fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", ")) + } + } + } + + // Check Codeberg releases if Codeberg is configured + var missingCodeberg []string + codebergOrg := cfg.FindCodebergOrg() + if codebergOrg != nil && codebergOrg.Name != "" { + codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName) + if err != nil { + fmt.Printf(" Error checking Codeberg releases: %v\n", err) + } else { + missingCodeberg = releaseManager.FindMissingReleases(localTags, codebergReleases) + if len(missingCodeberg) > 0 { + fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", ")) + } + } + } + + // Create missing releases with confirmation + if len(missingGitHub) > 0 && githubOrg != nil { + for _, tag := range missingGitHub { + // Generate release notes + releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + + msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag) + + // Check if auto-create is enabled + createRelease := false + if flags.AutoCreateReleases { + fmt.Printf(" Auto-creating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag) + createRelease = true + } else { + createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes) + } + + if createRelease { + if err := releaseManager.CreateGitHubRelease(githubOrg.Name, repoName, tag, releaseNotes); err != nil { + fmt.Printf(" Error creating GitHub release: %v\n", err) + } else { + fmt.Printf(" Created GitHub release for tag %s\n", tag) + } + } + } + } + + if len(missingCodeberg) > 0 && codebergOrg != nil { + for _, tag := range missingCodeberg { + // Generate release notes + releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + + msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag) + + // Check if auto-create is enabled + createRelease := false + if flags.AutoCreateReleases { + fmt.Printf(" Auto-creating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag) + createRelease = true + } else { + createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes) + } + + if createRelease { + if err := releaseManager.CreateCodebergRelease(codebergOrg.Name, repoName, tag, releaseNotes); err != nil { + fmt.Printf(" Error creating Codeberg release: %v\n", err) + } else { + fmt.Printf(" Created Codeberg release for tag %s\n", tag) + } + } + } + } + } + + // Also check public repositories if they're synced + if flags.SyncGitHubPublic || flags.SyncCodebergPublic { + fmt.Println("\nChecking public repositories...") + + // Process synced public repos from work directory + entries, err := os.ReadDir(flags.WorkDir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + repoName := entry.Name() + repoPath := filepath.Join(flags.WorkDir, repoName) + + // Skip if it's already in configured repositories + isConfigured := false + for _, configuredRepo := range cfg.Repositories { + if configuredRepo == repoName { + isConfigured = true + break + } + } + + if isConfigured { + continue + } + + // Check if it's a git repository + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + continue + } + + fmt.Printf("\nChecking releases for public repository: %s\n", repoName) + + // Get local tags + localTags, err := releaseManager.GetLocalTags(repoPath) + if err != nil { + fmt.Printf(" Error getting local tags: %v\n", err) + continue + } + + if len(localTags) == 0 { + fmt.Println(" No version tags found") + continue + } + + fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", ")) + + // Check releases on both platforms + githubOrg := cfg.FindGitHubOrg() + if githubOrg != nil && githubOrg.Name != "" { + githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName) + if err == nil { + missingGitHub := releaseManager.FindMissingReleases(localTags, githubReleases) + if len(missingGitHub) > 0 { + fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", ")) + + for _, tag := range missingGitHub { + // Generate release notes + releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + + msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag) + + // Check if auto-create is enabled + createRelease := false + if flags.AutoCreateReleases { + fmt.Printf(" Auto-creating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag) + createRelease = true + } else { + createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes) + } + + if createRelease { + if err := releaseManager.CreateGitHubRelease(githubOrg.Name, repoName, tag, releaseNotes); err != nil { + fmt.Printf(" Error creating GitHub release: %v\n", err) + } else { + fmt.Printf(" Created GitHub release for tag %s\n", tag) + } + } + } + } + } + } + + codebergOrg := cfg.FindCodebergOrg() + if codebergOrg != nil && codebergOrg.Name != "" { + codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName) + if err == nil { + missingCodeberg := releaseManager.FindMissingReleases(localTags, codebergReleases) + if len(missingCodeberg) > 0 { + fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", ")) + + for _, tag := range missingCodeberg { + // Generate release notes + releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + + msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag) + + // Check if auto-create is enabled + createRelease := false + if flags.AutoCreateReleases { + fmt.Printf(" Auto-creating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag) + createRelease = true + } else { + createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes) + } + + if createRelease { + if err := releaseManager.CreateCodebergRelease(codebergOrg.Name, repoName, tag, releaseNotes); err != nil { + fmt.Printf(" Error creating Codeberg release: %v\n", err) + } else { + fmt.Printf(" Created Codeberg release for tag %s\n", tag) + } + } + } + } + } + } + } + } + } + + return 0 +}
\ No newline at end of file diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 619b01e..d87d771 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -78,6 +78,33 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { fmt.Print(summary) } + // Generate script for abandoned branches + if scriptPath, err := syncer.GenerateDeleteScript(); err != nil { + fmt.Printf("\nā ļø Failed to generate script: %v\n", err) + } else if scriptPath != "" { + fmt.Printf("\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\nš ABANDONED BRANCH MANAGEMENT SCRIPT\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\n") + fmt.Printf("Generated script: %s\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("Usage:\n") + fmt.Printf(" bash %s --review # Review diffs before deletion\n", scriptPath) + fmt.Printf(" bash %s --review-full # Review full diffs\n", scriptPath) + fmt.Printf(" bash %s --dry-run # Preview what will be deleted\n", scriptPath) + fmt.Printf(" bash %s # Delete branches (with confirmation)\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("š” Recommended workflow:\n") + fmt.Printf(" 1. Review branches: bash %s --review\n", scriptPath) + fmt.Printf(" 2. Dry-run delete: bash %s --dry-run\n", scriptPath) + fmt.Printf(" 3. Delete branches: bash %s\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("ā ļø WARNING: Review carefully before deleting branches!\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\n") + } + return 0 } @@ -309,6 +336,33 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi fmt.Print(summary) } + // Generate script for abandoned branches + if scriptPath, err := syncer.GenerateDeleteScript(); err != nil { + fmt.Printf("\nā ļø Failed to generate script: %v\n", err) + } else if scriptPath != "" { + fmt.Printf("\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\nš ABANDONED BRANCH MANAGEMENT SCRIPT\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\n") + fmt.Printf("Generated script: %s\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("Usage:\n") + fmt.Printf(" bash %s --review # Review diffs before deletion\n", scriptPath) + fmt.Printf(" bash %s --review-full # Review full diffs\n", scriptPath) + fmt.Printf(" bash %s --dry-run # Preview what will be deleted\n", scriptPath) + fmt.Printf(" bash %s # Delete branches (with confirmation)\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("š” Recommended workflow:\n") + fmt.Printf(" 1. Review branches: bash %s --review\n", scriptPath) + fmt.Printf(" 2. Dry-run delete: bash %s --dry-run\n", scriptPath) + fmt.Printf(" 3. Delete branches: bash %s\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("ā ļø WARNING: Review carefully before deleting branches!\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\n") + } + if !flags.SyncGitHubPublic { return 0 } @@ -374,6 +428,33 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { fmt.Print(summary) } + + // Generate script for abandoned branches + if scriptPath, err := syncer.GenerateDeleteScript(); err != nil { + fmt.Printf("\nā ļø Failed to generate script: %v\n", err) + } else if scriptPath != "" { + fmt.Printf("\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\nš ABANDONED BRANCH MANAGEMENT SCRIPT\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\n") + fmt.Printf("Generated script: %s\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("Usage:\n") + fmt.Printf(" bash %s --review # Review diffs before deletion\n", scriptPath) + fmt.Printf(" bash %s --review-full # Review full diffs\n", scriptPath) + fmt.Printf(" bash %s --dry-run # Preview what will be deleted\n", scriptPath) + fmt.Printf(" bash %s # Delete branches (with confirmation)\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("š” Recommended workflow:\n") + fmt.Printf(" 1. Review branches: bash %s --review\n", scriptPath) + fmt.Printf(" 2. Dry-run delete: bash %s --dry-run\n", scriptPath) + fmt.Printf(" 3. Delete branches: bash %s\n", scriptPath) + fmt.Printf("\n") + fmt.Printf("ā ļø WARNING: Review carefully before deleting branches!\n") + fmt.Printf(strings.Repeat("=", 70)) + fmt.Printf("\n") + } return 0 } diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 0000000..c66d5f9 --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,461 @@ +package release + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "regexp" + "sort" + "strings" +) + +// Tag represents a git tag +type Tag struct { + Name string +} + +// Release represents a release on a platform +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` +} + +// Manager handles release operations +type Manager struct { + workDir string + githubToken string + codebergToken string +} + +// NewManager creates a new release manager +func NewManager(workDir string) *Manager { + return &Manager{ + workDir: workDir, + } +} + +// SetGitHubToken sets the GitHub token for API authentication +func (m *Manager) SetGitHubToken(token string) { + m.githubToken = token +} + +// SetCodebergToken sets the Codeberg token for API authentication +func (m *Manager) SetCodebergToken(token string) { + m.codebergToken = token +} + +// isVersionTag checks if a tag name is a version tag +// Supports formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X +func isVersionTag(tag string) bool { + // Pattern matches version tags with optional 'v' prefix + pattern := `^v?\d+(\.\d+)?(\.\d+)?$` + matched, _ := regexp.MatchString(pattern, tag) + return matched +} + +// GetLocalTags returns all version tags from the local git repository +func (m *Manager) GetLocalTags(repoPath string) ([]string, error) { + cmd := exec.Command("git", "-C", repoPath, "tag", "--list") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get git tags: %w", err) + } + + var versionTags []string + tags := strings.Split(strings.TrimSpace(string(output)), "\n") + + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" && isVersionTag(tag) { + versionTags = append(versionTags, tag) + } + } + + // Sort tags by version + sort.Slice(versionTags, func(i, j int) bool { + return compareVersions(versionTags[i], versionTags[j]) < 0 + }) + + return versionTags, nil +} + +// compareVersions compares two version strings +// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 +func compareVersions(v1, v2 string) int { + // Remove 'v' prefix if present + v1 = strings.TrimPrefix(v1, "v") + v2 = strings.TrimPrefix(v2, "v") + + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + // Pad with zeros to make equal length + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for i := 0; i < maxLen; i++ { + var n1, n2 int + + if i < len(parts1) { + fmt.Sscanf(parts1[i], "%d", &n1) + } + if i < len(parts2) { + fmt.Sscanf(parts2[i], "%d", &n2) + } + + if n1 < n2 { + return -1 + } else if n1 > n2 { + return 1 + } + } + + return 0 +} + +// GetCommitsSinceTag gets all commits since a specific tag +func (m *Manager) GetCommitsSinceTag(repoPath, fromTag, toTag string) ([]string, error) { + // Use git log to get commits between tags + // If fromTag is empty, get all commits up to toTag + var cmd *exec.Cmd + if fromTag == "" { + cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", toTag) + } else { + cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", fmt.Sprintf("%s..%s", fromTag, toTag)) + } + + output, err := cmd.Output() + if err != nil { + // If error, it might be because fromTag doesn't exist, try without it + cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", toTag) + output, err = cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get commits: %w", err) + } + } + + if len(output) == 0 { + return []string{}, nil + } + + commits := strings.Split(strings.TrimSpace(string(output)), "\n") + // Reverse to show oldest first + for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { + commits[i], commits[j] = commits[j], commits[i] + } + + return commits, nil +} + +// GenerateReleaseNotes generates release notes from commits +func (m *Manager) GenerateReleaseNotes(repoPath, tag string, allTags []string) string { + // Find the previous tag + var prevTag string + tagIndex := -1 + for i, t := range allTags { + if t == tag { + tagIndex = i + break + } + } + + if tagIndex > 0 { + prevTag = allTags[tagIndex-1] + } + + // Get commits since previous tag + commits, err := m.GetCommitsSinceTag(repoPath, prevTag, tag) + if err != nil { + return fmt.Sprintf("Release %s", tag) + } + + if len(commits) == 0 { + return fmt.Sprintf("Release %s", tag) + } + + // Group commits by type + var features, fixes, other []string + + for _, commit := range commits { + lower := strings.ToLower(commit) + if strings.HasPrefix(lower, "feat:") || strings.HasPrefix(lower, "feature:") { + features = append(features, commit) + } else if strings.HasPrefix(lower, "fix:") || strings.HasPrefix(lower, "bugfix:") { + fixes = append(fixes, commit) + } else { + other = append(other, commit) + } + } + + // Build release notes + var notes strings.Builder + notes.WriteString(fmt.Sprintf("Release %s\n\n", tag)) + + if prevTag != "" { + notes.WriteString(fmt.Sprintf("Changes since %s:\n\n", prevTag)) + } + + if len(features) > 0 { + notes.WriteString("## New Features\n\n") + for _, feat := range features { + notes.WriteString(fmt.Sprintf("- %s\n", feat)) + } + notes.WriteString("\n") + } + + if len(fixes) > 0 { + notes.WriteString("## Bug Fixes\n\n") + for _, fix := range fixes { + notes.WriteString(fmt.Sprintf("- %s\n", fix)) + } + notes.WriteString("\n") + } + + if len(other) > 0 { + notes.WriteString("## Other Changes\n\n") + for _, commit := range other { + notes.WriteString(fmt.Sprintf("- %s\n", commit)) + } + notes.WriteString("\n") + } + + notes.WriteString(fmt.Sprintf("\n**Full Changelog**: %s...%s", prevTag, tag)) + + return notes.String() +} + +// GetGitHubReleases fetches releases from GitHub +func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + // Add GitHub token if available + if m.githubToken != "" { + req.Header.Set("Authorization", "Bearer "+m.githubToken) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + // Repository might not exist on GitHub + return []string{}, nil + } + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(body)) + } + + var releases []Release + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, err + } + + var tags []string + for _, release := range releases { + tags = append(tags, release.TagName) + } + + return tags, nil +} + +// GetCodebergReleases fetches releases from Codeberg +func (m *Manager) GetCodebergReleases(owner, repo string) ([]string, error) { + url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + // Add Codeberg token if available + if m.codebergToken != "" { + req.Header.Set("Authorization", "token "+m.codebergToken) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + // Repository might not exist on Codeberg + return []string{}, nil + } + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Codeberg API error: %s - %s", resp.Status, string(body)) + } + + var releases []Release + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, err + } + + var tags []string + for _, release := range releases { + tags = append(tags, release.TagName) + } + + return tags, nil +} + +// FindMissingReleases finds tags that don't have releases +func (m *Manager) FindMissingReleases(localTags, releaseTags []string) []string { + releaseMap := make(map[string]bool) + for _, tag := range releaseTags { + releaseMap[tag] = true + } + + var missing []string + for _, tag := range localTags { + if !releaseMap[tag] { + missing = append(missing, tag) + } + } + + return missing +} + +// CreateGitHubRelease creates a release on GitHub +func (m *Manager) CreateGitHubRelease(owner, repo, tag, releaseNotes string) error { + if m.githubToken == "" { + return fmt.Errorf("GitHub token is required for creating releases") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) + + // Use provided release notes or default + body := releaseNotes + if body == "" { + body = fmt.Sprintf("Release %s", tag) + } + + release := Release{ + TagName: tag, + Name: tag, + Body: body, + } + + jsonData, err := json.Marshal(release) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+m.githubToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to create GitHub release: %s - %s", resp.Status, string(body)) + } + + return nil +} + +// CreateCodebergRelease creates a release on Codeberg +func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) error { + if m.codebergToken == "" { + return fmt.Errorf("Codeberg token is required for creating releases") + } + + url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo) + + // Use provided release notes or default + body := releaseNotes + if body == "" { + body = fmt.Sprintf("Release %s", tag) + } + + release := Release{ + TagName: tag, + Name: tag, + Body: body, + } + + jsonData, err := json.Marshal(release) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+m.codebergToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to create Codeberg release: %s - %s", resp.Status, string(body)) + } + + return nil +} + +// PromptConfirmation asks for user confirmation +func PromptConfirmation(message string) bool { + fmt.Printf("%s [y/N]: ", message) + + var response string + fmt.Scanln(&response) + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes" +} + +// PromptConfirmationWithNotes asks for user confirmation and shows release notes +func PromptConfirmationWithNotes(message, releaseNotes string) bool { + fmt.Printf("\n%s\n", strings.Repeat("-", 70)) + fmt.Printf("Release Notes:\n%s\n", strings.Repeat("-", 70)) + fmt.Println(releaseNotes) + fmt.Printf("%s\n\n", strings.Repeat("-", 70)) + + fmt.Printf("%s [y/N]: ", message) + + var response string + fmt.Scanln(&response) + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes" +}
\ No newline at end of file diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go index f684f60..95827db 100644 --- a/internal/sync/branch_analyzer.go +++ b/internal/sync/branch_analyzer.go @@ -2,7 +2,9 @@ package sync import ( "fmt" + "os" "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -15,6 +17,7 @@ type BranchInfo struct { Remote string IsAbandoned bool AbandonReason string + RemotesWithBranch []string // List of remotes that have this branch } // AbandonedBranchReport holds the analysis results @@ -22,13 +25,16 @@ type AbandonedBranchReport struct { MainBranchUpdated bool MainBranchLastCommit time.Time AbandonedBranches []BranchInfo + AbandonedIgnoredBranches []BranchInfo // Abandoned branches that match exclusion patterns TotalBranches int + TotalIgnoredBranches int } // analyzeAbandonedBranches analyzes branches to find abandoned ones func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { report := &AbandonedBranchReport{ AbandonedBranches: []BranchInfo{}, + AbandonedIgnoredBranches: []BranchInfo{}, } // Get all branches @@ -40,6 +46,10 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { // Filter branches based on exclusion patterns branches := s.branchFilter.FilterBranches(allBranches) report.TotalBranches = len(branches) + + // Get excluded branches for separate analysis + excludedBranches := s.branchFilter.GetExcludedBranches(allBranches) + report.TotalIgnoredBranches = len(excludedBranches) // Check main/master branch status mainBranch := s.findMainBranch(branches) @@ -79,6 +89,27 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { report.AbandonedBranches = append(report.AbandonedBranches, *branchInfo) } } + + // Also analyze ignored branches for abandonment + for _, branch := range excludedBranches { + // Skip main/master branches even if they match exclusion patterns + 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 (ignored branch)", daysSinceCommit) + report.AbandonedIgnoredBranches = append(report.AbandonedIgnoredBranches, *branchInfo) + } + } return report, nil } @@ -97,6 +128,7 @@ func (s *Syncer) findMainBranch(branches []string) string { func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) { info := &BranchInfo{ Name: branch, + RemotesWithBranch: []string{}, } // Find which remote has this branch and get the latest commit @@ -105,9 +137,18 @@ func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) { for i := range s.config.Organizations { org := &s.config.Organizations[i] + + // Skip backup locations if backup is not enabled + if org.BackupLocation && !s.backupEnabled { + continue + } + remoteName := s.getRemoteName(org) if s.remoteBranchExists(remoteName, branch) { + // Add this remote to the list + info.RemotesWithBranch = append(info.RemotesWithBranch, remoteName) + // Get last commit date for this branch on this remote commitTime, err := s.getLastCommitTime(remoteName, branch) if err == nil && (latestCommit.IsZero() || commitTime.After(latestCommit)) { @@ -163,20 +204,32 @@ func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string) return "" // Don't report on inactive repos } - if len(report.AbandonedBranches) == 0 { + if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 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)) + + if len(report.AbandonedBranches) > 0 { + 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)) + } + } + + if len(report.AbandonedIgnoredBranches) > 0 { + sb.WriteString(fmt.Sprintf("\n Found %d abandoned IGNORED branches (no commits for 6+ months):\n\n", len(report.AbandonedIgnoredBranches))) + for _, branch := range report.AbandonedIgnoredBranches { + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", + branch.Name, + branch.LastCommit.Format("2006-01-02"), + branch.AbandonReason)) + } } return sb.String() @@ -189,16 +242,18 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { } totalAbandoned := 0 + totalAbandonedIgnored := 0 reposWithAbandoned := 0 for _, report := range s.abandonedReports { - if len(report.AbandonedBranches) > 0 { + if len(report.AbandonedBranches) > 0 || len(report.AbandonedIgnoredBranches) > 0 { totalAbandoned += len(report.AbandonedBranches) + totalAbandonedIgnored += len(report.AbandonedIgnoredBranches) reposWithAbandoned++ } } - if totalAbandoned == 0 { + if totalAbandoned == 0 && totalAbandonedIgnored == 0 { return "" } @@ -208,20 +263,41 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { 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)) + sb.WriteString(fmt.Sprintf("Found %d abandoned branches", totalAbandoned)) + if totalAbandonedIgnored > 0 { + sb.WriteString(fmt.Sprintf(" + %d ignored branches", totalAbandonedIgnored)) + } + sb.WriteString(fmt.Sprintf(" across %d repositories\n\n", reposWithAbandoned)) // Group by repository for repoName, report := range s.abandonedReports { - if len(report.AbandonedBranches) == 0 { + if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 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"))) + totalBranches := len(report.AbandonedBranches) + len(report.AbandonedIgnoredBranches) + sb.WriteString(fmt.Sprintf("š %s (%d branches):\n", repoName, totalBranches)) + + // Regular abandoned branches + if len(report.AbandonedBranches) > 0 { + sb.WriteString(" Regular branches:\n") + for _, branch := range report.AbandonedBranches { + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", + branch.Name, + branch.LastCommit.Format("2006-01-02"))) + } + } + + // Ignored abandoned branches + if len(report.AbandonedIgnoredBranches) > 0 { + sb.WriteString(" Ignored branches:\n") + for _, branch := range report.AbandonedIgnoredBranches { + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", + branch.Name, + branch.LastCommit.Format("2006-01-02"))) + } } + sb.WriteString("\n") } @@ -231,4 +307,295 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { sb.WriteString("\n") return sb.String() +} + +// GenerateDeleteCommands generates shell commands to delete abandoned branches +func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName string) string { + if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n# Delete commands for abandoned branches in %s\n", repoName)) + sb.WriteString("# Review these commands carefully before running them!\n\n") + + // Process regular abandoned branches + if len(report.AbandonedBranches) > 0 { + sb.WriteString("# === REGULAR BRANCHES ===\n") + for _, branch := range report.AbandonedBranches { + sb.WriteString(fmt.Sprintf("# Branch: %s (last commit: %s)\n", branch.Name, branch.LastCommit.Format("2006-01-02"))) + + // Delete from all remotes that have this branch + if len(branch.RemotesWithBranch) > 0 { + sb.WriteString("# Delete from remotes:\n") + for _, remote := range branch.RemotesWithBranch { + sb.WriteString(fmt.Sprintf("git push %s --delete %s\n", remote, branch.Name)) + } + } + + // Delete local branch + sb.WriteString("# Delete local branch:\n") + sb.WriteString(fmt.Sprintf("git branch -D %s\n\n", branch.Name)) + } + } + + // Process ignored abandoned branches + if len(report.AbandonedIgnoredBranches) > 0 { + sb.WriteString("# === IGNORED BRANCHES ===\n") + for _, branch := range report.AbandonedIgnoredBranches { + sb.WriteString(fmt.Sprintf("# Branch: %s (last commit: %s) [IGNORED]\n", branch.Name, branch.LastCommit.Format("2006-01-02"))) + + // Delete from all remotes that have this branch + if len(branch.RemotesWithBranch) > 0 { + sb.WriteString("# Delete from remotes:\n") + for _, remote := range branch.RemotesWithBranch { + sb.WriteString(fmt.Sprintf("git push %s --delete %s\n", remote, branch.Name)) + } + } + + // Delete local branch + sb.WriteString("# Delete local branch:\n") + sb.WriteString(fmt.Sprintf("git branch -D %s\n\n", branch.Name)) + } + } + + return sb.String() +} + +// GenerateDeleteScript generates a shell script file to delete all abandoned branches +func (s *Syncer) GenerateDeleteScript() (string, error) { + if len(s.abandonedReports) == 0 { + return "", nil + } + + // Count total abandoned branches + totalAbandoned := 0 + totalIgnored := 0 + for _, report := range s.abandonedReports { + totalAbandoned += len(report.AbandonedBranches) + totalIgnored += len(report.AbandonedIgnoredBranches) + } + + if totalAbandoned == 0 && totalIgnored == 0 { + return "", nil + } + + // Generate script filename with timestamp + timestamp := time.Now().Format("20060102_150405") + scriptPath := filepath.Join(s.workDir, fmt.Sprintf("delete_abandoned_branches_%s.sh", timestamp)) + + // Create the script file + file, err := os.Create(scriptPath) + if err != nil { + return "", fmt.Errorf("failed to create script file: %w", err) + } + 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") + + // Process each repository + for repoName, report := range s.abandonedReports { + if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { + continue + } + + fmt.Fprintf(file, "# ======================================\n") + fmt.Fprintf(file, "# Repository: %s\n", repoName) + fmt.Fprintf(file, "# ======================================\n") + fmt.Fprintf(file, "echo\n") + fmt.Fprintf(file, "echo \"š Processing repository: %s\"\n", repoName) + fmt.Fprintf(file, "cd \"%s/%s\" || { echo \"Failed to change to repository directory\"; exit 1; }\n\n", s.workDir, repoName) + + // Find main branch for review mode + fmt.Fprintf(file, "if [[ \"$MODE\" == \"review\" || \"$MODE\" == \"review-full\" ]]; then\n") + fmt.Fprintf(file, " main_branch=$(find_main_branch)\n") + fmt.Fprintf(file, " if [[ -z \"$main_branch\" ]]; then\n") + fmt.Fprintf(file, " echo -e \"${RED}ā ļø No main/master branch found in %s${NC}\"\n", repoName) + fmt.Fprintf(file, " fi\n") + fmt.Fprintf(file, "fi\n\n") + + // 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")) + + // 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") + } + } + + // 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")) + + // 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") + } + } + } + + // 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") + + // Make the script executable + if err := os.Chmod(scriptPath, 0755); err != nil { + return scriptPath, fmt.Errorf("failed to make script executable: %w", err) + } + + return scriptPath, nil }
\ No newline at end of file |
