From 11eea6a82cbfdde40ec1457c6ea080da4da6b7dc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 31 Oct 2025 20:13:32 +0200 Subject: feat: implement amp AI tool support and replace Taskfile with Mage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add amp as default AI tool for release notes and showcase generation - Fallback chain: amp → hexai → claude → aichat - Replace Taskfile.yaml with magefile.go for build automation - Update all documentation (README.md, AGENTS.md, doc/development.md) - Update version to 0.10.0 Amp-Thread-ID: https://ampcode.com/threads/T-735ba1e2-0255-4b43-8ed1-6c0d2f78301b Co-authored-by: Amp --- internal/sync/branch_analyzer.go | 94 +++++++++++++++++++-------------------- internal/sync/branch_filter.go | 8 ++-- internal/sync/branch_sync.go | 12 ++--- internal/sync/git_operations.go | 22 ++++----- internal/sync/repository_setup.go | 14 +++--- internal/sync/sync.go | 40 ++++++++--------- 6 files changed, 95 insertions(+), 95 deletions(-) (limited to 'internal/sync') diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go index 3038fcc..7499996 100644 --- a/internal/sync/branch_analyzer.go +++ b/internal/sync/branch_analyzer.go @@ -12,28 +12,28 @@ import ( // BranchInfo holds information about a branch type BranchInfo struct { - Name string - LastCommit time.Time - Remote string - IsAbandoned bool - AbandonReason string + Name string + LastCommit time.Time + Remote string + IsAbandoned bool + AbandonReason string RemotesWithBranch []string // List of remotes that have this branch } // AbandonedBranchReport holds the analysis results type AbandonedBranchReport struct { - MainBranchUpdated bool - MainBranchLastCommit time.Time - AbandonedBranches []BranchInfo + MainBranchUpdated bool + MainBranchLastCommit time.Time + AbandonedBranches []BranchInfo AbandonedIgnoredBranches []BranchInfo // Abandoned branches that match exclusion patterns - TotalBranches int - TotalIgnoredBranches int + TotalBranches int + TotalIgnoredBranches int } // analyzeAbandonedBranches analyzes branches to find abandoned ones func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { report := &AbandonedBranchReport{ - AbandonedBranches: []BranchInfo{}, + AbandonedBranches: []BranchInfo{}, AbandonedIgnoredBranches: []BranchInfo{}, } @@ -42,11 +42,11 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { if err != nil { return nil, fmt.Errorf("failed to get branches: %w", err) } - + // 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) @@ -69,7 +69,7 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { // Analyze each branch sixMonthsAgo := time.Now().AddDate(0, -6, 0) - + for _, branch := range branches { // Skip main/master branches if branch == "main" || branch == "master" { @@ -89,7 +89,7 @@ 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 @@ -127,7 +127,7 @@ func (s *Syncer) findMainBranch(branches []string) string { // getBranchInfo gets information about a specific branch func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) { info := &BranchInfo{ - Name: branch, + Name: branch, RemotesWithBranch: []string{}, } @@ -137,18 +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)) { @@ -211,22 +211,22 @@ func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string) 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"))) - + 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, + 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, + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", + branch.Name, branch.LastCommit.Format("2006-01-02"), branch.AbandonReason)) } @@ -244,7 +244,7 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { totalAbandoned := 0 totalAbandonedIgnored := 0 reposWithAbandoned := 0 - + for _, report := range s.abandonedReports { if len(report.AbandonedBranches) > 0 || len(report.AbandonedIgnoredBranches) > 0 { totalAbandoned += len(report.AbandonedBranches) @@ -274,30 +274,30 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { continue } - + 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, + 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, + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", + branch.Name, branch.LastCommit.Format("2006-01-02"))) } } - + sb.WriteString("\n") } @@ -324,7 +324,7 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName 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") @@ -332,19 +332,19 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName 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") @@ -352,7 +352,7 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName 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)) @@ -375,7 +375,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { totalAbandoned += len(report.AbandonedBranches) totalIgnored += len(report.AbandonedIgnoredBranches) } - + if totalAbandoned == 0 && totalIgnored == 0 { return "", nil } @@ -540,7 +540,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " fi\n") fmt.Fprintf(file, "else\n") fmt.Fprintf(file, " echo \" šŸ”ø Deleting branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - + // Check if we're on the branch to be deleted, and switch to main/master if so fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n") fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n") @@ -558,12 +558,12 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name) fmt.Fprintf(file, " continue\n") fmt.Fprintf(file, " fi\n") - + // Delete from remotes for _, remote := range branch.RemotesWithBranch { fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name) } - + // Delete local branch fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name) fmt.Fprintf(file, "fi\n\n") @@ -580,7 +580,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " fi\n") fmt.Fprintf(file, "else\n") fmt.Fprintf(file, " echo \" šŸ”¹ Deleting ignored branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - + // Check if we're on the branch to be deleted, and switch to main/master if so fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n") fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n") @@ -598,12 +598,12 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name) fmt.Fprintf(file, " continue\n") fmt.Fprintf(file, " fi\n") - + // Delete from remotes for _, remote := range branch.RemotesWithBranch { fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name) } - + // Delete local branch fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name) fmt.Fprintf(file, "fi\n\n") @@ -634,4 +634,4 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { } return scriptPath, nil -} \ No newline at end of file +} diff --git a/internal/sync/branch_filter.go b/internal/sync/branch_filter.go index 3a4fd40..33f7895 100644 --- a/internal/sync/branch_filter.go +++ b/internal/sync/branch_filter.go @@ -77,7 +77,7 @@ func FormatExclusionReport(excludedBranches []string, patterns []string) string var sb strings.Builder sb.WriteString(fmt.Sprintf("\n🚫 Excluded %d branches based on patterns:\n", len(excludedBranches))) - + // Show patterns sb.WriteString(" Patterns: ") for i, pattern := range patterns { @@ -87,12 +87,12 @@ func FormatExclusionReport(excludedBranches []string, patterns []string) string sb.WriteString(fmt.Sprintf("'%s'", pattern)) } sb.WriteString("\n") - + // Show excluded branches sb.WriteString(" Excluded branches:\n") for _, branch := range excludedBranches { sb.WriteString(fmt.Sprintf(" - %s\n", branch)) } - + return sb.String() -} \ No newline at end of file +} diff --git a/internal/sync/branch_sync.go b/internal/sync/branch_sync.go index 02f8964..eafb551 100644 --- a/internal/sync/branch_sync.go +++ b/internal/sync/branch_sync.go @@ -9,7 +9,7 @@ import ( // trackRemotesWithBranch finds which remotes have a specific branch func (s *Syncer) trackRemotesWithBranch(branch string, remotes map[string]*config.Organization) map[string]bool { remotesWithBranch := make(map[string]bool) - + for remoteName, org := range remotes { // Skip checking backup locations as we don't sync from them if org.BackupLocation { @@ -19,7 +19,7 @@ func (s *Syncer) trackRemotesWithBranch(branch string, remotes map[string]*confi remotesWithBranch[remoteName] = true } } - + return remotesWithBranch } @@ -29,14 +29,14 @@ func mergeFromRemotes(branch string, remotesWithBranch map[string]bool) error { fmt.Printf(" Branch %s is local only, will push to all remotes\n", branch) return nil } - + // Merge changes from all remotes that have this branch for remoteName := range remotesWithBranch { if err := mergeBranch(remoteName, branch); err != nil { return err } } - + return nil } @@ -56,7 +56,7 @@ func pushToAllRemotes(branch string, remotes map[string]*config.Organization, re return err } } - + return nil } @@ -69,4 +69,4 @@ func (s *Syncer) syncAllBranches(branches []string, remotes map[string]*config.O } } return nil -} \ No newline at end of file +} diff --git a/internal/sync/git_operations.go b/internal/sync/git_operations.go index efa27f5..0bd698e 100644 --- a/internal/sync/git_operations.go +++ b/internal/sync/git_operations.go @@ -242,24 +242,24 @@ func createSSHBareRepository(sshHost, repoPath string) error { if len(parts) != 2 { return fmt.Errorf("invalid SSH host format: %s", sshHost) } - + userHost := parts[0] basePath := parts[1] - + // Full path to the repository fullRepoPath := fmt.Sprintf("%s/%s.git", basePath, repoPath) - + fmt.Printf("Creating bare repository at %s:%s\n", userHost, fullRepoPath) - + // Create the repository directory and initialize as bare commands := fmt.Sprintf("mkdir -p %s && cd %s && git init --bare", fullRepoPath, fullRepoPath) cmd := exec.Command("ssh", userHost, commands) output, err := cmd.CombinedOutput() - + if err != nil { return fmt.Errorf("failed to create bare repository: %w\n%s", err, string(output)) } - + fmt.Printf("Successfully created bare repository at %s:%s\n", userHost, fullRepoPath) return nil } @@ -280,18 +280,18 @@ func pushBranchWithBackupSupport(remoteName, branch string, remoteHasBranch bool if err != nil { return fmt.Errorf("failed to get remote URL: %w", err) } - + // Extract repo name from URL repoName := extractRepoName(remoteURL) if repoName == "" { return fmt.Errorf("failed to extract repository name from URL: %s", remoteURL) } - + // Create the bare repository if err := createSSHBareRepository(org.Host, repoName); err != nil { return fmt.Errorf("failed to create SSH repository: %w", err) } - + // Try pushing again cmd = exec.Command("git", "push", remoteName, branch, "--tags") if err := cmd.Run(); err != nil { @@ -300,7 +300,7 @@ func pushBranchWithBackupSupport(remoteName, branch string, remoteHasBranch bool fmt.Printf(" Successfully pushed to newly created backup repository\n") return nil } - + fmt.Printf(" Note: Remote repository %s does not exist - must be created manually\n", remoteName) fmt.Printf(" Skipping push to %s\n", remoteName) return nil // Not an error, just skip @@ -341,7 +341,7 @@ func getRemoteURL(remoteName string) (string, error) { func extractRepoName(url string) string { // Remove .git suffix if present url = strings.TrimSuffix(url, ".git") - + // Extract the last component of the path parts := strings.Split(url, "/") if len(parts) > 0 { diff --git a/internal/sync/repository_setup.go b/internal/sync/repository_setup.go index 3ebafbd..7e2c40e 100644 --- a/internal/sync/repository_setup.go +++ b/internal/sync/repository_setup.go @@ -54,12 +54,12 @@ func (s *Syncer) setupNewRepository(repoPath string) error { continue // Skip the first org we already cloned from } org := &s.config.Organizations[i] - + // Skip backup locations if backup is not enabled if org.BackupLocation && !s.backupEnabled { continue } - + if err := s.addRemote(repoPath, org); err != nil { return fmt.Errorf("failed to add remote %s: %w", s.getRemoteName(org), err) } @@ -75,12 +75,12 @@ func (s *Syncer) setupExistingRepository(repoPath string) error { // Check and add any missing remotes 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) // Check if remote exists @@ -115,14 +115,14 @@ func (s *Syncer) getRemotesMap() map[string]*config.Organization { remotes := make(map[string]*config.Organization) 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) remotes[remoteName] = org } return remotes -} \ No newline at end of file +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0f51689..0f2f479 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -12,9 +12,9 @@ import ( // Syncer handles repository synchronization between organizations type Syncer struct { - config *config.Config - workDir string - repoName string + config *config.Config + workDir string + repoName string abandonedReports map[string]*AbandonedBranchReport // Collects reports across repos branchFilter *BranchFilter // Filter for excluding branches backupEnabled bool // Whether to sync to backup locations @@ -32,8 +32,8 @@ func New(cfg *config.Config, workDir string) *Syncer { } return &Syncer{ - config: cfg, - workDir: workDir, + config: cfg, + workDir: workDir, abandonedReports: make(map[string]*AbandonedBranchReport), branchFilter: branchFilter, backupEnabled: false, // Default to false, will be set via SetBackupEnabled @@ -82,7 +82,7 @@ func (s *Syncer) SyncRepository(repoName string) error { // Filter branches based on exclusion patterns branches := s.branchFilter.FilterBranches(allBranches) excludedBranches := s.branchFilter.GetExcludedBranches(allBranches) - + // Report excluded branches if any if exclusionReport := FormatExclusionReport(excludedBranches, s.config.ExcludeBranches); exclusionReport != "" { fmt.Print(exclusionReport) @@ -118,12 +118,12 @@ func (s *Syncer) SyncRepository(repoName string) error { // This is used for showcase-only mode func (s *Syncer) EnsureRepositoryCloned(repoName string) error { s.repoName = repoName - + // Create work directory if it doesn't exist if err := os.MkdirAll(s.workDir, 0755); err != nil { return fmt.Errorf("failed to create work directory: %w", err) } - + // Check if repository already exists repoPath := filepath.Join(s.workDir, repoName) if _, err := os.Stat(repoPath); err == nil { @@ -131,10 +131,10 @@ func (s *Syncer) EnsureRepositoryCloned(repoName string) error { fmt.Printf(" Repository %s already exists locally\n", repoName) return nil } - + // Repository doesn't exist, clone it fmt.Printf(" Cloning %s...\n", repoName) - + // Find first non-backup organization to clone from var sourceOrg *config.Organization for i := range s.config.Organizations { @@ -143,16 +143,16 @@ func (s *Syncer) EnsureRepositoryCloned(repoName string) error { break } } - + if sourceOrg == nil { return fmt.Errorf("no non-backup organizations configured to clone from") } - + // Clone the repository if err := s.cloneRepository(sourceOrg, repoPath); err != nil { return fmt.Errorf("failed to clone repository: %w", err) } - + fmt.Printf(" Successfully cloned %s\n", repoName) return nil } @@ -282,7 +282,7 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati if stashed { defer popStash() } - + // Create or checkout the branch if err := s.checkoutBranch(branch); err != nil { return fmt.Errorf("failed to checkout branch %s: %w", branch, err) @@ -307,7 +307,7 @@ func (s *Syncer) handleWorkingDirectoryState() (bool, error) { if err != nil || statusStr == "" { return false, nil } - + if hasConflicts { // Get absolute path for clarity absPath, err := filepath.Abs(s.workDir) @@ -316,7 +316,7 @@ func (s *Syncer) handleWorkingDirectoryState() (bool, error) { } return false, fmt.Errorf("repository has unresolved merge conflicts\nPlease resolve conflicts in: %s\nOr delete the directory to start fresh: rm -rf %s", absPath, absPath) } - + // If we have uncommitted changes but no conflicts, try to stash them if err := stashChanges(); err != nil { return false, fmt.Errorf("failed to stash changes: %w", err) @@ -380,13 +380,13 @@ func (s *Syncer) getRemoteName(org *config.Organization) string { func (s *Syncer) filterBackupBranches(output []byte) []byte { lines := strings.Split(string(output), "\n") var filtered []string - + for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } - + // Check if this branch is from a backup remote isBackup := false for i := range s.config.Organizations { @@ -399,11 +399,11 @@ func (s *Syncer) filterBackupBranches(output []byte) []byte { } } } - + if !isBackup { filtered = append(filtered, line) } } - + return []byte(strings.Join(filtered, "\n")) } -- cgit v1.2.3