package main import ( "bufio" "bytes" "errors" "fmt" "os" "os/exec" "regexp" "sort" "strconv" "strings" ) func runGit(args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) } return string(out), nil } func inGitRepo() bool { cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") cmd.Stderr = nil out, err := cmd.Output() if err != nil { return false } return strings.TrimSpace(string(out)) == "true" } func currentBranch() (string, error) { out, err := runGit("rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "", err } return strings.TrimSpace(out), nil } func fetchPrune() error { fmt.Println("Git fetch mit prune...") cmd := exec.Command("git", "fetch", "--prune") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func parseGoneBranches(branchVV string) ([]string, error) { // Beispielzeile: // foo 123abc [origin/foo: gone] msg // * developer 123abc [origin/developer] msg lines := strings.Split(branchVV, "\n") var branches []string re := regexp.MustCompile(`^\s*([^\s]+)\s+[0-9a-f]+\s+\[[^\]]*: gone\]`) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Entferne führendes "* " line = strings.TrimPrefix(line, "* ") m := re.FindStringSubmatch(line) if len(m) == 2 { branches = append(branches, m[1]) } } sort.Strings(branches) return branches, nil } func readLine(prompt string) (string, error) { fmt.Print(prompt) r := bufio.NewReader(os.Stdin) s, err := r.ReadString('\n') if err != nil { return "", err } return strings.TrimSpace(s), nil } func parseSelection(input string, max int) (map[int]bool, error) { input = strings.TrimSpace(input) if input == "" { return nil, errors.New("keine Auswahl") } // Erlaube: all, 1,2,5, 2-4, 1,3-6 if strings.EqualFold(input, "all") { sel := make(map[int]bool, max) for i := 1; i <= max; i++ { sel[i] = true } return sel, nil } sel := make(map[int]bool) parts := strings.Split(input, ",") for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } if strings.Contains(p, "-") { b := strings.SplitN(p, "-", 2) if len(b) != 2 { return nil, fmt.Errorf("ungültiger Bereich: %s", p) } start, err := strconv.Atoi(strings.TrimSpace(b[0])) if err != nil { return nil, fmt.Errorf("ungültige Zahl: %s", b[0]) } end, err := strconv.Atoi(strings.TrimSpace(b[1])) if err != nil { return nil, fmt.Errorf("ungültige Zahl: %s", b[1]) } if start < 1 || end > max || start > end { return nil, fmt.Errorf("Bereich außerhalb 1..%d: %s", max, p) } for i := start; i <= end; i++ { sel[i] = true } } else { n, err := strconv.Atoi(p) if err != nil { return nil, fmt.Errorf("ungültige Zahl: %s", p) } if n < 1 || n > max { return nil, fmt.Errorf("Auswahl außerhalb 1..%d: %d", max, n) } sel[n] = true } } return sel, nil } func deleteBranch(name string, force bool) error { args := []string{"branch"} if force { args = append(args, "-D") } else { args = append(args, "-d") } args = append(args, name) cmd := exec.Command("git", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func main() { force := false autoAll := false showOnly := false info := false // Minimaler Flag Parser (ohne Dependencies) for _, a := range os.Args[1:] { switch a { case "--force": force = true case "--all": autoAll = true case "--info": info = true case "--show": showOnly = true case "--help", "-h": fmt.Println("Usage: cleanupGoneBranches [--show] [--all] [--force]") fmt.Println(" --show zeigt nur gone Branches") fmt.Println(" --info zeigt die manuellen Git-Befehle für denselben Ablauf") fmt.Println(" --all löscht alle gone Branches ohne Auswahl, fragt aber noch nach") fmt.Println(" --force nutzt git branch -D statt -d") os.Exit(0) default: fmt.Fprintf(os.Stderr, "Unbekanntes Argument: %s\n", a) os.Exit(2) } } if info { fmt.Println("Manuelle Git-Befehle für denselben Ablauf:") fmt.Println() fmt.Println("1) Remote-Branches aktualisieren und verwaiste Referenzen entfernen:") fmt.Println(" git fetch --prune") fmt.Println() fmt.Println("2) Gone-Branches anzeigen:") fmt.Println(" git branch -vv") fmt.Println(" (Branches mit '[origin/xyz: gone]' sind lokal verwaist)") fmt.Println() fmt.Println("3) Einzelnen Branch löschen (sicher, nur wenn gemerged):") fmt.Println(" git branch -d ") fmt.Println() fmt.Println("4) Erzwingen (auch wenn nicht gemerged):") fmt.Println(" git branch -D ") fmt.Println() fmt.Println("5) Alle gone-Branches automatisch löschen:") fmt.Println(` git branch -vv | awk '/: gone]/{print $1}' | xargs -r git branch -D`) fmt.Println() return } if !inGitRepo() { fmt.Fprintln(os.Stderr, "Fehler: Das ist kein Git Repository (oder du bist nicht innerhalb des Worktrees).") os.Exit(1) } if err := fetchPrune(); err != nil { fmt.Fprintln(os.Stderr, "Fetch fehlgeschlagen:", err) os.Exit(1) } vv, err := runGit("branch", "-vv") if err != nil { fmt.Fprintln(os.Stderr, "Konnte git branch -vv nicht ausführen:", err) os.Exit(1) } gone, err := parseGoneBranches(vv) if err != nil { fmt.Fprintln(os.Stderr, "Konnte gone Branches nicht parsen:", err) os.Exit(1) } if len(gone) == 0 { fmt.Println("Keine gone Branches gefunden.") return } cur, _ := currentBranch() fmt.Println() fmt.Println("Gefundene lokale Branches mit gelöschtem Remote (gone):") fmt.Println() for i, b := range gone { marker := "" if b == cur { marker = " (aktueller Branch, wird übersprungen)" } fmt.Printf(" %2d) %s%s\n", i+1, b, marker) } if showOnly { return } // Filter: niemals aktuellen Branch löschen // Wir löschen ihn auch nicht, selbst wenn ausgewählt indexToBranch := make([]string, 0, len(gone)) for _, b := range gone { indexToBranch = append(indexToBranch, b) } var selected map[int]bool if autoAll { selected = make(map[int]bool, len(indexToBranch)) for i := 1; i <= len(indexToBranch); i++ { selected[i] = true } } else { fmt.Println() fmt.Println("Auswahl eingeben, z.B. 1,3-5 oder all. Leer lassen zum Abbrechen.") in, rerr := readLine("Auswahl: ") if rerr != nil { fmt.Fprintln(os.Stderr, "Eingabe fehlgeschlagen:", rerr) os.Exit(1) } if strings.TrimSpace(in) == "" { fmt.Println("Abgebrochen, nichts gelöscht.") return } selected, err = parseSelection(in, len(indexToBranch)) if err != nil { fmt.Fprintln(os.Stderr, "Ungültige Auswahl:", err) os.Exit(2) } } // Erstelle Liste, ohne aktuellen Branch var toDelete []string for idx := range selected { b := indexToBranch[idx-1] if b == cur { continue } toDelete = append(toDelete, b) } sort.Strings(toDelete) if len(toDelete) == 0 { fmt.Println("Nichts zu löschen (aktueller Branch wird nicht gelöscht).") return } fmt.Println() fmt.Println("Es werden diese Branches gelöscht:") for _, b := range toDelete { fmt.Println(" -", b) } fmt.Println() confirm, _ := readLine("Wirklich löschen? (y/N): ") if !strings.EqualFold(confirm, "y") { fmt.Println("Abgebrochen, nichts gelöscht.") return } fmt.Println() fmt.Println("Lösche Branches...") var failed bytes.Buffer for _, b := range toDelete { if err := deleteBranch(b, force); err != nil { failed.WriteString(b) failed.WriteString("\n") } } if failed.Len() > 0 { fmt.Println() fmt.Println("Fertig, aber diese Branches konnten nicht gelöscht werden:") fmt.Print(failed.String()) fmt.Println("Tipp: ohne --force wird nur gelöscht, wenn Git es als sicher ansieht.") } else { fmt.Println("Fertig.") } }