summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-12 13:51:48 +0300
committerPaul Buetow <paul@buetow.org>2025-07-12 13:51:48 +0300
commitceb164c6a8826db1e763aecca49098a8c2584b7f (patch)
treea228040641666f4a6567e2d2e4544fd7daecc2e7
parent9d171e6b30a589240a16d8fef15e2195189e52a9 (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
-rw-r--r--CLAUDE.md23
-rw-r--r--cmd/gitsyncer/main.go69
-rw-r--r--internal/cli/flags.go6
-rw-r--r--internal/cli/release.go315
-rw-r--r--internal/cli/sync_handlers.go81
-rw-r--r--internal/release/release.go461
-rw-r--r--internal/sync/branch_analyzer.go401
7 files changed, 1316 insertions, 40 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 2a5b22e..efcee08 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,8 +30,20 @@ task clean
# Delete a repository from all configured organizations (with confirmation)
./gitsyncer --delete-repo <repository-name>
+
+# Manually check for version tags without releases
+./gitsyncer --check-releases
+
+# Disable automatic release checking during sync operations
+./gitsyncer --sync-all --no-check-releases
+
+# Automatically create releases without confirmation prompts
+./gitsyncer --check-releases --auto-create-releases
+./gitsyncer --sync-all --auto-create-releases
```
+Note: Release checking is enabled by default after sync operations. It will check for version tags (formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X) that don't have corresponding releases on GitHub/Codeberg and prompt for confirmation before creating them.
+
Note: The Taskfile.yaml is configured for [go-task](https://taskfile.dev/). Install with:
```bash
# macOS
@@ -68,7 +80,9 @@ This follows the standard Go project layout with:
The application currently provides:
- Version information system (internal/version)
- CLI flag parsing for --version
-- Placeholder for main gitsyncer functionality
+- Automatic release checking and creation (internal/release)
+- Repository syncing across GitHub, Codeberg, and other platforms
+- Project showcase generation
## Next Steps
@@ -76,4 +90,9 @@ The project needs:
1. Support for other platforms (GitLab, Gitea, etc.)
2. Webhook support for automatic syncing
3. Conflict resolution strategies
-4. Better handling of large repositories \ No newline at end of file
+4. Better handling of large repositories
+
+## Release Process
+
+- When releasing a version, increment the version in version.go, commit all changes to git and push. and tag the version and push to git.
+- Gitsyncer will automatically detect the new version tag and prompt to create releases on GitHub and Codeberg using the tokens from your gitsyncer configuration file. \ No newline at end of file
diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go
index f27cff6..d4a3e6f 100644
--- a/cmd/gitsyncer/main.go
+++ b/cmd/gitsyncer/main.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"codeberg.org/snonux/gitsyncer/internal/cli"
+ "codeberg.org/snonux/gitsyncer/internal/config"
"codeberg.org/snonux/gitsyncer/internal/state"
)
@@ -23,6 +24,15 @@ func saveBatchRunState(flags *cli.Flags) {
}
}
+// runReleaseCheckIfEnabled runs release checking after successful sync operations
+func runReleaseCheckIfEnabled(cfg *config.Config, flags *cli.Flags) {
+ // Run release checks automatically unless disabled
+ if !flags.NoCheckReleases {
+ fmt.Println("\nChecking for missing releases...")
+ cli.HandleCheckReleases(cfg, flags)
+ }
+}
+
func main() {
// Parse command-line flags
flags := cli.ParseFlags()
@@ -102,10 +112,13 @@ func main() {
// Handle sync operation
if flags.SyncRepo != "" {
exitCode := cli.HandleSync(cfg, flags)
- if exitCode == 0 && flags.Showcase {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
+ if exitCode == 0 {
+ runReleaseCheckIfEnabled(cfg, flags)
+ if flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
}
}
os.Exit(exitCode)
@@ -114,10 +127,13 @@ func main() {
// Handle sync all operation
if flags.SyncAll {
exitCode := cli.HandleSyncAll(cfg, flags)
- if exitCode == 0 && flags.Showcase {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
+ if exitCode == 0 {
+ runReleaseCheckIfEnabled(cfg, flags)
+ if flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
}
}
os.Exit(exitCode)
@@ -127,10 +143,13 @@ func main() {
if flags.SyncCodebergPublic {
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode != 0 || !flags.SyncGitHubPublic {
- if exitCode == 0 && flags.Showcase && !flags.SyncGitHubPublic {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
+ if exitCode == 0 {
+ runReleaseCheckIfEnabled(cfg, flags)
+ if flags.Showcase && !flags.SyncGitHubPublic {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
}
}
os.Exit(exitCode)
@@ -141,22 +160,30 @@ func main() {
if flags.SyncGitHubPublic {
exitCode := cli.HandleSyncGitHubPublic(cfg, flags)
- // Run showcase generation if requested and sync was successful
- if exitCode == 0 && flags.Showcase {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
- }
- }
-
- // Save batch run state if this was a successful batch run
if exitCode == 0 {
+ // Run release checks after successful sync
+ runReleaseCheckIfEnabled(cfg, flags)
+
+ // Run showcase generation if requested
+ if flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+
+ // Save batch run state if this was a successful batch run
saveBatchRunState(flags)
}
os.Exit(exitCode)
}
+ // Handle check releases flag
+ if flags.CheckReleases {
+ os.Exit(cli.HandleCheckReleases(cfg, flags))
+ }
+
// Handle standalone showcase mode (no sync operations specified)
if flags.Showcase {
fmt.Println("Running showcase generation for all repositories (clone-only mode)...")
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