Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions cmd/clean.go
Original file line number Diff line number Diff line change
@@ -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
}
129 changes: 129 additions & 0 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading