334 lines
7.8 KiB
Go
334 lines
7.8 KiB
Go
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 <branchname>")
|
|
fmt.Println()
|
|
fmt.Println("4) Erzwingen (auch wenn nicht gemerged):")
|
|
fmt.Println(" git branch -D <branchname>")
|
|
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.")
|
|
}
|
|
}
|