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 with prune...") cmd := exec.Command("git", "fetch", "--prune") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func parseGoneBranches(branchVV string) ([]string, error) { 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 } 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("No Selection") } 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("Invalid Range: %s", p) } start, err := strconv.Atoi(strings.TrimSpace(b[0])) if err != nil { return nil, fmt.Errorf("Invalid Number: %s", b[0]) } end, err := strconv.Atoi(strings.TrimSpace(b[1])) if err != nil { return nil, fmt.Errorf("Invalid Number: %s", b[1]) } if start < 1 || end > max || start > end { return nil, fmt.Errorf("Range outside of 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("Invalid Number: %s", p) } if n < 1 || n > max { return nil, fmt.Errorf("Range outside of 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 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 only displays gone branches") fmt.Println(" --info displays the manual Git commands for the same workflow") fmt.Println(" --all deletes all gone branches without prompting for selection, but still asks for confirmation") fmt.Println(" --force uses git branch -D instead of -d") os.Exit(0) default: fmt.Fprintf(os.Stderr, "Unkown Argument: %s\n", a) os.Exit(2) } } if info { fmt.Println("Manual Git commands for the same workflow:") fmt.Println() fmt.Println("1) Update remote branches and remove stale references:") fmt.Println(" git fetch --prune") fmt.Println() fmt.Println("2) Show gone branches:") fmt.Println(" git branch -vv") fmt.Println(" (Branches marked with '[origin/xyz: gone]' are orphaned local branches)") fmt.Println() fmt.Println("3) Delete a single branch (safe, only if it has been merged):") fmt.Println(" git branch -d ") fmt.Println() fmt.Println("4) Force deletion (even if it has not been merged):") fmt.Println(" git branch -D ") fmt.Println() fmt.Println("5) Automatically delete all gone branches:") fmt.Println(` git branch -vv | awk '/: gone]/{print $1}' | xargs -r git branch -D`) fmt.Println() return } if !inGitRepo() { fmt.Fprintln(os.Stderr, "Error: This is not a Git repository (or you are not inside the working tree).") os.Exit(1) } if err := fetchPrune(); err != nil { fmt.Fprintln(os.Stderr, "Fetch failed:", err) os.Exit(1) } vv, err := runGit("branch", "-vv") if err != nil { fmt.Fprintln(os.Stderr, "Failed to run git branch -vv:", err) os.Exit(1) } gone, err := parseGoneBranches(vv) if err != nil { fmt.Fprintln(os.Stderr, "Failed to parse gone branches:", err) os.Exit(1) } if len(gone) == 0 { fmt.Println("No gone branches found.") return } cur, _ := currentBranch() fmt.Println() fmt.Println("Local branches found with a deleted remote (gone):") fmt.Println() for i, b := range gone { marker := "" if b == cur { marker = " (current branch, skipped)" } fmt.Printf(" %2d) %s%s\n", i+1, b, marker) } if showOnly { return } 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("Enter your selection, e.g. 1,3-5 or all. Leave blank to cancel.") in, rerr := readLine("Selection: ") if rerr != nil { fmt.Fprintln(os.Stderr, "Input failed:", rerr) os.Exit(1) } if strings.TrimSpace(in) == "" { fmt.Println("Cancelled, nothing was deleted.") return } selected, err = parseSelection(in, len(indexToBranch)) if err != nil { fmt.Fprintln(os.Stderr, "Invalid selection:", err) os.Exit(2) } } 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("Nothing to delete (current branch will not be deleted).") return } fmt.Println() fmt.Println("The following branches will be deleted:") for _, b := range toDelete { fmt.Println(" -", b) } fmt.Println() confirm, _ := readLine("Really delete? (y/N): ") if !strings.EqualFold(confirm, "y") { fmt.Println("Cancelled, nothing was deleted.") return } fmt.Println() fmt.Println("Deleting 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("Done, but these branches could not be deleted:") fmt.Print(failed.String()) fmt.Println("Tip: without --force, Git only deletes branches it considers safe.") } else { fmt.Println("Done.") } }