From dfc195e564224c9cb3ea1b92f39563524db2e3e8 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 21 Mar 2026 07:27:41 -0500 Subject: [PATCH 1/4] feat: parallel sync + dry-run scaffold preview - Process repos concurrently (semaphore of 5) within each sync file, buffering output per repo and printing in order after all complete. Uses sync.WaitGroup + semaphore pattern matching cmd/settings.go. - When --dry-run is set and a missing file has skip_if_exists = true, show the first 5 lines of the scaffolded content as a dimmed preview. Closes #9 Closes #11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/sync.go | 122 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 42 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index d0947bb..d671cac 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -6,11 +6,17 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/maxbeizer/gh-fleet/internal/fleet" gh "github.com/maxbeizer/gh-fleet/internal/github" ) +type syncResult struct { + lines []string + changes int +} + func runSync(args []string) error { fs := flag.NewFlagSet("sync", flag.ContinueOnError) configDir := fs.String("config", ".", "directory containing fleet.toml") @@ -58,59 +64,91 @@ func runSync(args []string) error { fmt.Printf("\n%s → %s\n", boldStyle.Render(sf.Canon), sf.Target) - for _, r := range repos { - content := string(canonContent) - - // Apply template variables - if sf.Template { - extName := strings.TrimPrefix(r.Name, "gh-") - content = strings.ReplaceAll(content, "extension-template", extName) - for k, v := range cfg.Sync.TemplateVars { - content = strings.ReplaceAll(content, fmt.Sprintf("${%s}", k), v) + results := make([]syncResult, len(repos)) + var wg sync.WaitGroup + sem := make(chan struct{}, 5) + + for i, r := range repos { + wg.Add(1) + go func(idx int, r gh.Repo, sf fleet.SyncFile) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + res := &results[idx] + content := string(canonContent) + + // Apply template variables + if sf.Template { + extName := strings.TrimPrefix(r.Name, "gh-") + content = strings.ReplaceAll(content, "extension-template", extName) + for k, v := range cfg.Sync.TemplateVars { + content = strings.ReplaceAll(content, fmt.Sprintf("${%s}", k), v) + } } - } - - // Fetch current content - remote, err := gh.FetchFileContent(cfg.Owner, r.Name, sf.Target) - if err != nil { - // File doesn't exist — needs sync - fmt.Printf(" %s %s %s\n", errStyle.Render("➕"), r.Name, dimStyle.Render("(missing)")) - totalChanges++ - if !*dryRun { - fileContent := content - if sf.SkipIfExists { - fileContent = scaffoldCopilotInstructions(r) + // Fetch current content + remote, err := gh.FetchFileContent(cfg.Owner, r.Name, sf.Target) + if err != nil { + // File doesn't exist — needs sync + res.lines = append(res.lines, fmt.Sprintf(" %s %s %s", errStyle.Render("➕"), r.Name, dimStyle.Render("(missing)"))) + res.changes++ + + if sf.SkipIfExists && *dryRun { + preview := scaffoldCopilotInstructions(r) + previewLines := strings.SplitN(preview, "\n", 6) + if len(previewLines) > 5 { + previewLines = previewLines[:5] + } + for _, pl := range previewLines { + res.lines = append(res.lines, fmt.Sprintf(" %s", dimStyle.Render(pl))) + } } - if err := syncFile(cfg.Owner, r.Name, sf.Target, fileContent); err != nil { - fmt.Fprintf(os.Stderr, " ❌ %v\n", err) - } else { - fmt.Printf(" %s PR created\n", okStyle.Render("✅")) + + if !*dryRun { + fileContent := content + if sf.SkipIfExists { + fileContent = scaffoldCopilotInstructions(r) + } + if err := syncFile(cfg.Owner, r.Name, sf.Target, fileContent); err != nil { + res.lines = append(res.lines, fmt.Sprintf(" ❌ %v", err)) + } else { + res.lines = append(res.lines, fmt.Sprintf(" %s PR created", okStyle.Render("✅"))) + } } + return } - continue - } - // Compare - if strings.TrimSpace(remote) != strings.TrimSpace(content) { - if sf.SkipIfExists { - fmt.Printf(" %s %s %s\n", dimStyle.Render("⊘"), r.Name, dimStyle.Render("(exists, skipped)")) - continue - } + // Compare + if strings.TrimSpace(remote) != strings.TrimSpace(content) { + if sf.SkipIfExists { + res.lines = append(res.lines, fmt.Sprintf(" %s %s %s", dimStyle.Render("⊘"), r.Name, dimStyle.Render("(exists, skipped)"))) + return + } - fmt.Printf(" %s %s %s\n", warnStyle.Render("⇄"), r.Name, dimStyle.Render("(differs)")) - totalChanges++ + res.lines = append(res.lines, fmt.Sprintf(" %s %s %s", warnStyle.Render("⇄"), r.Name, dimStyle.Render("(differs)"))) + res.changes++ - if !*dryRun { - if err := syncFile(cfg.Owner, r.Name, sf.Target, content); err != nil { - fmt.Fprintf(os.Stderr, " ❌ %v\n", err) - } else { - fmt.Printf(" %s PR created\n", okStyle.Render("✅")) + if !*dryRun { + if err := syncFile(cfg.Owner, r.Name, sf.Target, content); err != nil { + res.lines = append(res.lines, fmt.Sprintf(" ❌ %v", err)) + } else { + res.lines = append(res.lines, fmt.Sprintf(" %s PR created", okStyle.Render("✅"))) + } } + } else { + res.lines = append(res.lines, fmt.Sprintf(" %s %s", okStyle.Render("✅"), r.Name)) } - } else { - fmt.Printf(" %s %s\n", okStyle.Render("✅"), r.Name) + }(i, r, sf) + } + wg.Wait() + + // Print results in order and tally changes + for _, res := range results { + for _, line := range res.lines { + fmt.Println(line) } + totalChanges += res.changes } } From 6986f8cb1b205ed52b888378c2ac72a841da7fe0 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 21 Mar 2026 07:28:39 -0500 Subject: [PATCH 2/4] feat: add fleet pr command to list and manage open fleet PRs Implements issue #8. Adds the 'gh fleet pr' command that: - Lists all open PRs with fleet/sync-* head branches across managed repos - Groups output by synced file - Supports --merge (with --admin) to squash-merge all listed PRs - Supports --close to close all listed PRs with a comment - Supports --file filter and --dry-run preview - Uses concurrent fetching with semaphore (limit 10) New files: - cmd/pr.go: command implementation - internal/github/pr.go: FleetPR type, ListFleetPRs, FetchFleetPRs, MergePR, and ClosePR helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/pr.go | 163 ++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 3 + internal/github/pr.go | 101 ++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 cmd/pr.go create mode 100644 internal/github/pr.go diff --git a/cmd/pr.go b/cmd/pr.go new file mode 100644 index 0000000..a11bdec --- /dev/null +++ b/cmd/pr.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "sort" + "strings" + + gh "github.com/maxbeizer/gh-fleet/internal/github" +) + +func runPR(args []string) error { + fs := flag.NewFlagSet("pr", flag.ContinueOnError) + configDir := fs.String("config", ".", "directory containing fleet.toml") + fileFilter := fs.String("file", "", "only show/act on PRs for this synced file") + merge := fs.Bool("merge", false, "squash-merge all listed PRs") + admin := fs.Bool("admin", false, "use --admin to bypass branch protection (with --merge)") + close := fs.Bool("close", false, "close all listed PRs with a comment") + dryRun := fs.Bool("dry-run", false, "preview merge/close actions without executing") + if err := fs.Parse(args); err != nil { + return err + } + + if *merge && *close { + return fmt.Errorf("cannot use --merge and --close together") + } + + cfg, err := loadConfig(*configDir) + if err != nil { + return err + } + + repos, err := discoverRepos(cfg) + if err != nil { + return err + } + + repoNames := make([]string, len(repos)) + for i, r := range repos { + repoNames[i] = r.Name + } + + fmt.Fprintf(os.Stderr, "Fetching fleet PRs across %d repos...\n", len(repoNames)) + allPRs := gh.FetchFleetPRs(cfg.Owner, repoNames) + + if len(allPRs) == 0 { + fmt.Println(okStyle.Render("No open fleet PRs found.")) + return nil + } + + // Group PRs by synced file (derived from branch name "fleet/sync-") + grouped := groupPRsByFile(allPRs) + + // Apply file filter + if *fileFilter != "" { + filtered := make(map[string][]gh.FleetPR) + for file, prs := range grouped { + if strings.EqualFold(file, *fileFilter) { + filtered[file] = prs + } + } + grouped = filtered + if len(grouped) == 0 { + fmt.Printf("No fleet PRs found for file %s\n", dimStyle.Render(*fileFilter)) + return nil + } + } + + // Display grouped PRs + files := sortedFileKeys(grouped) + total := 0 + for _, file := range files { + prs := grouped[file] + total += len(prs) + fmt.Printf("\n%s %s\n", boldStyle.Render("●"), boldStyle.Render(file)) + for _, pr := range prs { + fmt.Printf(" %s %s %s\n", + warnStyle.Render(fmt.Sprintf("#%d", pr.Number)), + pr.Repo, + dimStyle.Render(pr.URL), + ) + } + } + fmt.Printf("\n%s\n", dimStyle.Render(fmt.Sprintf("%d open fleet PR(s) across %d file(s)", total, len(files)))) + + // Handle --merge + if *merge { + fmt.Println() + for _, file := range files { + for _, pr := range grouped[file] { + label := fmt.Sprintf("%s/%s#%d", cfg.Owner, pr.Repo, pr.Number) + if *dryRun { + fmt.Printf(" %s %s\n", dimStyle.Render("[dry-run] would merge"), label) + continue + } + fmt.Fprintf(os.Stderr, " Merging %s...\n", label) + if err := gh.MergePR(cfg.Owner, pr.Repo, pr.Number, *admin); err != nil { + fmt.Printf(" %s %s: %v\n", errStyle.Render("✗"), label, err) + } else { + fmt.Printf(" %s %s\n", okStyle.Render("✓ merged"), label) + } + } + } + } + + // Handle --close + if *close { + comment := "Closed by gh-fleet pr --close" + fmt.Println() + for _, file := range files { + for _, pr := range grouped[file] { + label := fmt.Sprintf("%s/%s#%d", cfg.Owner, pr.Repo, pr.Number) + if *dryRun { + fmt.Printf(" %s %s\n", dimStyle.Render("[dry-run] would close"), label) + continue + } + fmt.Fprintf(os.Stderr, " Closing %s...\n", label) + if err := gh.ClosePR(cfg.Owner, pr.Repo, pr.Number, comment); err != nil { + fmt.Printf(" %s %s: %v\n", errStyle.Render("✗"), label, err) + } else { + fmt.Printf(" %s %s\n", okStyle.Render("✓ closed"), label) + } + } + } + } + + return nil +} + +// groupPRsByFile groups PRs by synced file name derived from the branch name. +// Branch "fleet/sync-makefile" → file "Makefile" (best-effort reconstruction). +func groupPRsByFile(prs []gh.FleetPR) map[string][]gh.FleetPR { + grouped := make(map[string][]gh.FleetPR) + for _, pr := range prs { + file := fileFromBranch(pr.Branch) + grouped[file] = append(grouped[file], pr) + } + // Sort PRs within each group by repo name + for file := range grouped { + sort.Slice(grouped[file], func(i, j int) bool { + return grouped[file][i].Repo < grouped[file][j].Repo + }) + } + return grouped +} + +// fileFromBranch extracts the synced file name from a branch like "fleet/sync-makefile". +func fileFromBranch(branch string) string { + name := strings.TrimPrefix(branch, "fleet/sync-") + // The sync command replaces dots with hyphens, but we can't perfectly + // reverse that. Return as-is for grouping. + return name +} + +func sortedFileKeys(m map[string][]gh.FleetPR) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/root.go b/cmd/root.go index 0e329fe..d1ff643 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,6 +40,8 @@ func Execute(version string) error { return runStatus(args) case "settings": return runSettings(args) + case "pr": + return runPR(args) default: return fmt.Errorf("unknown command: %s\nRun 'gh fleet -h' for usage", subcmd) } @@ -54,6 +56,7 @@ Usage: Commands: catalog Regenerate README with extension catalog drift Detect configuration drift across repos + pr List and manage open fleet PRs across repos settings Enforce repo settings across the fleet sync Push canonical files to out-of-sync repos status Quick health matrix across all extension repos diff --git a/internal/github/pr.go b/internal/github/pr.go new file mode 100644 index 0000000..37695ff --- /dev/null +++ b/internal/github/pr.go @@ -0,0 +1,101 @@ +package github + +import ( + "encoding/json" + "fmt" + "os/exec" + "strconv" + "strings" + "sync" +) + +// FleetPR represents an open pull request created by gh-fleet sync. +type FleetPR struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Repo string `json:"-"` + Branch string `json:"headRefName"` +} + +// ListFleetPRs returns all open PRs whose head branch starts with "fleet/sync-". +func ListFleetPRs(owner, repo string) ([]FleetPR, error) { + out, err := exec.Command("gh", "pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--head", "fleet/sync-", + "--state", "open", + "--json", "number,title,url,headRefName", + ).Output() + if err != nil { + return nil, fmt.Errorf("listing PRs for %s/%s: %w", owner, repo, err) + } + + var prs []FleetPR + if err := json.Unmarshal(out, &prs); err != nil { + return nil, fmt.Errorf("parsing PRs for %s/%s: %w", owner, repo, err) + } + for i := range prs { + prs[i].Repo = repo + } + return prs, nil +} + +// FetchFleetPRs fetches fleet PRs from multiple repos concurrently. +func FetchFleetPRs(owner string, repos []string) []FleetPR { + var mu sync.Mutex + var all []FleetPR + var wg sync.WaitGroup + sem := make(chan struct{}, 10) + + for _, repo := range repos { + wg.Add(1) + go func(r string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + prs, err := ListFleetPRs(owner, r) + if err != nil { + return // skip repos with errors + } + mu.Lock() + all = append(all, prs...) + mu.Unlock() + }(repo) + } + wg.Wait() + return all +} + +// MergePR squash-merges a pull request using admin privileges. +func MergePR(owner, repo string, number int, admin bool) error { + args := []string{"pr", "merge", + strconv.Itoa(number), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--squash", + } + if admin { + args = append(args, "--admin") + } + out, err := exec.Command("gh", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("merging %s/%s#%d: %w\n%s", owner, repo, number, err, strings.TrimSpace(string(out))) + } + return nil +} + +// ClosePR closes a pull request with an optional comment. +func ClosePR(owner, repo string, number int, comment string) error { + args := []string{"pr", "close", + strconv.Itoa(number), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + } + if comment != "" { + args = append(args, "--comment", comment) + } + out, err := exec.Command("gh", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("closing %s/%s#%d: %w\n%s", owner, repo, number, err, strings.TrimSpace(string(out))) + } + return nil +} From e4ed1af955383b60ee9ca635b52f4e6976f0b35f Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 21 Mar 2026 07:29:30 -0500 Subject: [PATCH 3/4] feat: add fleet doctor command for config validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #10. The doctor command validates fleet.toml by checking: - owner is non-empty - catalog.output is set and header file exists - all canon files exist on disk - template files contain extension-template placeholder - template_vars keys are used in template files - no undefined variables referenced in templates Supports --config flag. Prints ✅/❌ checklist and exits 1 on failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/doctor.go | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 6 +++ 2 files changed, 135 insertions(+) create mode 100644 cmd/doctor.go diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..3d93a57 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +func runDoctor(args []string) error { + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + configDir := fs.String("config", ".", "directory containing fleet.toml") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := loadConfig(*configDir) + if err != nil { + return err + } + + fmt.Println(boldStyle.Render("Fleet Doctor")) + fmt.Println() + + failed := false + pass := func(msg string) { + fmt.Printf(" %s %s\n", okStyle.Render("✅"), msg) + } + fail := func(msg string) { + fmt.Printf(" %s %s\n", errStyle.Render("❌"), msg) + failed = true + } + + // owner is non-empty + if cfg.Owner != "" { + pass("owner is set") + } else { + fail("owner is empty") + } + + // catalog.output is set + if cfg.Catalog.Output != "" { + pass("catalog.output is set") + } else { + fail("catalog.output is not set") + } + + // catalog.header file exists if configured + if cfg.Catalog.Header != "" { + headerPath := filepath.Join(cfg.Dir, cfg.Catalog.Header) + if _, err := os.Stat(headerPath); err == nil { + pass(fmt.Sprintf("catalog.header file exists (%s)", cfg.Catalog.Header)) + } else { + fail(fmt.Sprintf("catalog.header file missing: %s", cfg.Catalog.Header)) + } + } + + // All canonical files exist on disk + for _, sf := range cfg.Sync.Files { + canonPath := filepath.Join(cfg.Dir, sf.Canon) + if _, err := os.Stat(canonPath); err == nil { + pass(fmt.Sprintf("canon file exists: %s", sf.Canon)) + } else { + fail(fmt.Sprintf("canon file missing: %s", sf.Canon)) + } + } + + // Template files contain the "extension-template" placeholder + for _, sf := range cfg.Sync.Files { + if !sf.Template { + continue + } + canonPath := filepath.Join(cfg.Dir, sf.Canon) + data, err := os.ReadFile(canonPath) + if err != nil { + continue // already reported as missing above + } + if strings.Contains(string(data), "extension-template") { + pass(fmt.Sprintf("template has extension-template placeholder: %s", sf.Canon)) + } else { + fail(fmt.Sprintf("template missing extension-template placeholder: %s", sf.Canon)) + } + } + + // Collect all template file contents for variable checks + varRefPattern := regexp.MustCompile(`\$\{([A-Z_][A-Z0-9_]*)\}`) + referencedVars := map[string]bool{} + for _, sf := range cfg.Sync.Files { + if !sf.Template { + continue + } + canonPath := filepath.Join(cfg.Dir, sf.Canon) + data, err := os.ReadFile(canonPath) + if err != nil { + continue + } + for _, match := range varRefPattern.FindAllStringSubmatch(string(data), -1) { + referencedVars[match[1]] = true + } + } + + // All template_vars keys are used in at least one template file + for key := range cfg.Sync.TemplateVars { + if referencedVars[key] { + pass(fmt.Sprintf("template_var %s is used", key)) + } else { + fail(fmt.Sprintf("template_var %s is defined but never referenced in any template", key)) + } + } + + // No template files reference variables not defined in template_vars + for varName := range referencedVars { + if _, ok := cfg.Sync.TemplateVars[varName]; ok { + pass(fmt.Sprintf("template ref ${%s} is defined", varName)) + } else { + fail(fmt.Sprintf("template ref ${%s} is not defined in template_vars", varName)) + } + } + + fmt.Println() + if failed { + fmt.Println(errStyle.Render("Some checks failed.")) + return fmt.Errorf("doctor found issues") + } + fmt.Println(okStyle.Render("All checks passed.")) + return nil +} diff --git a/cmd/root.go b/cmd/root.go index d1ff643..ebc4f03 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,6 +40,10 @@ func Execute(version string) error { return runStatus(args) case "settings": return runSettings(args) + case "doctor": + return runDoctor(args) + case "clean": + return runClean(args) case "pr": return runPR(args) default: @@ -55,6 +59,8 @@ Usage: Commands: catalog Regenerate README with extension catalog + clean Delete stale fleet/sync-* branches with no open PRs + doctor Validate fleet.toml configuration drift Detect configuration drift across repos pr List and manage open fleet PRs across repos settings Enforce repo settings across the fleet From 9acd91cdec9bcca13a75cdc8bfdd49d11ab12a41 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 21 Mar 2026 07:30:22 -0500 Subject: [PATCH 4/4] feat: add fleet clean command to delete stale sync branches Closes #7 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/clean.go | 167 +++++++++++++++++++++++++++++++++++++++++ internal/github/api.go | 39 ++++++++++ 2 files changed, 206 insertions(+) create mode 100644 cmd/clean.go diff --git a/cmd/clean.go b/cmd/clean.go new file mode 100644 index 0000000..5b090ed --- /dev/null +++ b/cmd/clean.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "sync" + + gh "github.com/maxbeizer/gh-fleet/internal/github" +) + +func runClean(args []string) error { + fs := flag.NewFlagSet("clean", flag.ContinueOnError) + configDir := fs.String("config", ".", "directory containing fleet.toml") + dryRun := fs.Bool("dry-run", false, "preview deletions without applying") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := loadConfig(*configDir) + if err != nil { + return err + } + + repos, err := discoverRepos(cfg) + if err != nil { + return err + } + + fmt.Println(boldStyle.Render("Fleet Clean — stale sync branches")) + if *dryRun { + fmt.Println(dimStyle.Render(" (dry-run mode)")) + } + fmt.Println() + + type branchResult struct { + repo string + branch string + hasOpenPR bool + deleted bool + err error + } + + // Collect branches concurrently + type repoBranches struct { + repo string + branches []string + err error + } + rb := make([]repoBranches, len(repos)) + + var wg sync.WaitGroup + sem := make(chan struct{}, 10) + + for i, r := range repos { + rb[i].repo = r.Name + wg.Add(1) + go func(idx int, repo gh.Repo) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + branches, err := gh.ListSyncBranches(cfg.Owner, repo.Name) + rb[idx].branches = branches + rb[idx].err = err + }(i, r) + } + wg.Wait() + + // Process each branch + var results []branchResult + for _, r := range rb { + if r.err != nil { + fmt.Fprintf(os.Stderr, " %s %s %s\n", + errStyle.Render("❌"), r.repo, dimStyle.Render(r.err.Error())) + continue + } + for _, b := range r.branches { + results = append(results, branchResult{repo: r.repo, branch: b}) + } + } + + if len(results) == 0 { + fmt.Printf(" %s No stale sync branches found.\n", okStyle.Render("✅")) + return nil + } + + // Check open PRs and delete concurrently + var wg2 sync.WaitGroup + sem2 := make(chan struct{}, 10) + + for i := range results { + wg2.Add(1) + go func(idx int) { + defer wg2.Done() + sem2 <- struct{}{} + defer func() { <-sem2 }() + + r := &results[idx] + hasPR, err := gh.BranchHasOpenPR(cfg.Owner, r.repo, r.branch) + if err != nil { + r.err = err + return + } + r.hasOpenPR = hasPR + + if !hasPR && !*dryRun { + gh.DeleteBranch(cfg.Owner, r.repo, r.branch) + r.deleted = true + } + }(i) + } + wg2.Wait() + + // Print results + totalDeleted := 0 + totalSkipped := 0 + totalErrors := 0 + + for _, r := range results { + if r.err != nil { + fmt.Fprintf(os.Stderr, " %s %s %s %s\n", + errStyle.Render("❌"), r.repo, r.branch, dimStyle.Render(r.err.Error())) + totalErrors++ + continue + } + + if r.hasOpenPR { + fmt.Printf(" %s %s %s %s\n", + warnStyle.Render("⏭"), r.repo, r.branch, dimStyle.Render("(has open PR)")) + totalSkipped++ + continue + } + + if *dryRun { + fmt.Printf(" %s %s %s %s\n", + warnStyle.Render("🗑"), r.repo, r.branch, dimStyle.Render("(would delete)")) + totalDeleted++ + } else { + fmt.Printf(" %s %s %s %s\n", + okStyle.Render("🗑"), r.repo, r.branch, dimStyle.Render("(deleted)")) + totalDeleted++ + } + } + + fmt.Println() + if *dryRun && totalDeleted > 0 { + fmt.Printf("%s stale branches to delete. Run without --dry-run to apply.\n", + warnStyle.Render(fmt.Sprintf("%d", totalDeleted))) + } else if !*dryRun && totalDeleted > 0 { + fmt.Printf("%s Deleted %d stale branches.\n", + okStyle.Render("✅"), totalDeleted) + } + if totalSkipped > 0 { + fmt.Printf("%s Skipped %d branches with open PRs.\n", + warnStyle.Render("⏭"), totalSkipped) + } + if totalErrors > 0 { + fmt.Fprintf(os.Stderr, "%s %d branches had errors.\n", + errStyle.Render("⚠️"), totalErrors) + } + if totalDeleted == 0 && totalSkipped == 0 && totalErrors == 0 { + fmt.Printf(" %s No stale sync branches found.\n", okStyle.Render("✅")) + } + + return nil +} diff --git a/internal/github/api.go b/internal/github/api.go index a39d5ef..3a7a278 100644 --- a/internal/github/api.go +++ b/internal/github/api.go @@ -215,6 +215,45 @@ func UpdateRepoSettings(owner, repo string, settings RepoSettings) error { return nil } +// ListSyncBranches returns branch names matching fleet/sync-* for a repo. +func ListSyncBranches(owner, repo string) ([]string, error) { + out, err := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/%s/git/matching-refs/heads/fleet/sync-", owner, repo), + "-q", ".[].ref", + ).Output() + if err != nil { + return nil, fmt.Errorf("listing sync branches for %s/%s: %w", owner, repo, err) + } + + var branches []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Strip refs/heads/ prefix + branches = append(branches, strings.TrimPrefix(line, "refs/heads/")) + } + return branches, nil +} + +// BranchHasOpenPR checks whether a branch has an open pull request. +func BranchHasOpenPR(owner, repo, branch string) (bool, error) { + out, err := exec.Command("gh", "pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--head", branch, + "--state", "open", + "--json", "number", + "-q", "length", + ).Output() + if err != nil { + return false, fmt.Errorf("checking PRs for %s/%s branch %s: %w", owner, repo, branch, err) + } + + count := strings.TrimSpace(string(out)) + return count != "" && count != "0", nil +} + // DeleteBranch deletes a remote branch, ignoring errors if it doesn't exist. func DeleteBranch(owner, repo, branch string) { exec.Command("gh", "api",