Files
cleanupGoneBranches/cleanupGoneBranches.go
T
2026-07-01 10:21:58 +02:00

325 lines
7.9 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 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 <branch-name>")
fmt.Println()
fmt.Println("4) Force deletion (even if it has not been merged):")
fmt.Println(" git branch -D <branch-name>")
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.")
}
}