commit 74143919e2bb9738529f5881f2a0050a448412f7 Author: Christian Zeitner - dotSource SE Date: Wed Jul 1 10:08:24 2026 +0200 Initial Commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1832261 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ + +main: + go build cleanupGoneBranches.go + go install + + diff --git a/cleanupGoneBranches.go b/cleanupGoneBranches.go new file mode 100644 index 0000000..ff128f5 --- /dev/null +++ b/cleanupGoneBranches.go @@ -0,0 +1,333 @@ +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.") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1805786 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module cleanupGoneBranches + +go 1.25.1