From ab4b51d7a0b7f1fd056174cee7910c69e01370c9 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 7 Sep 2025 13:54:42 -0700 Subject: deletes git branches that are merged back into the main branch --- cmd/git-balai/main.go | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 cmd/git-balai/main.go diff --git a/cmd/git-balai/main.go b/cmd/git-balai/main.go new file mode 100644 index 0000000..4de5cdb --- /dev/null +++ b/cmd/git-balai/main.go @@ -0,0 +1,213 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "os/exec" + "regexp" + "strings" +) + +var preferredRemotes = []string{"origin", "github", "work"} + +type GitRepository struct { + WorkingDirectory string + RemoteName string + RemoteURL string + PrimaryBranch string +} + +func NewGitRepository(workingDir string) (*GitRepository, error) { + repo := &GitRepository{ + WorkingDirectory: workingDir, + } + + if err := repo.isGitRepository(); err != nil { + return nil, err + } + + if err := repo.workdirIsClean(); err != nil { + return nil, err + } + + if err := repo.guessRemote(); err != nil { + return nil, err + } + + if err := repo.guessPrimaryBranch(); err != nil { + return nil, err + } + + return repo, nil +} + +func (r *GitRepository) runGitCommand(args ...string) ([]byte, error) { + cmd := exec.Command("git", append([]string{"-C", r.WorkingDirectory}, args...)...) + return cmd.Output() +} + +func (r *GitRepository) guessPrimaryBranch() error { + output, err := r.runGitCommand("ls-remote", "--symref", r.RemoteName, "HEAD") + if err != nil { + return fmt.Errorf("failed to run git ls-remote: %w", err) + } + + re := regexp.MustCompile(`ref: refs/heads/(\S+)\s+HEAD`) + matches := re.FindStringSubmatch(string(output)) + + if len(matches) < 2 { + return fmt.Errorf("could not determine primary branch") + } + + r.PrimaryBranch = matches[1] + return nil +} + +func (r *GitRepository) guessRemote() error { + output, err := r.runGitCommand("config", "--get-regexp", `remote\.[a-z0-9]+\.url`) + if err != nil { + return fmt.Errorf("failed to run git config: %w", err) + } + + remotes := make(map[string]string) + scanner := bufio.NewScanner(strings.NewReader(string(output))) + + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + configParts := strings.Split(parts[0], ".") + if len(configParts) < 2 { + continue + } + + remoteName := configParts[1] + remoteURL := parts[1] + remotes[remoteName] = remoteURL + } + + for _, preferred := range preferredRemotes { + if url, exists := remotes[preferred]; exists { + r.RemoteName = preferred + r.RemoteURL = url + return nil + } + } + + return fmt.Errorf("no preferred remote configured") +} + +func (r *GitRepository) isGitRepository() error { + _, err := r.runGitCommand("rev-parse", "--show-toplevel") + if err != nil { + return fmt.Errorf("%s is not a git repository", r.WorkingDirectory) + } + return nil +} + +func (r *GitRepository) workdirIsClean() error { + _, err := r.runGitCommand("diff", "--quiet", "--exit-code") + if err != nil { + return fmt.Errorf("%s has unstaged changes, commit or stash them first", r.WorkingDirectory) + } + return nil +} + +// getMergedBranches returns a list of local branches that have been merged into the primary branch +func (r *GitRepository) getMergedBranches() ([]string, error) { + output, err := r.runGitCommand("branch", "--merged", r.PrimaryBranch) + if err != nil { + return nil, fmt.Errorf("failed to get merged branches: %w", err) + } + + var branches []string + scanner := bufio.NewScanner(strings.NewReader(string(output))) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip current branch indicator and the primary branch itself + if strings.HasPrefix(line, "*") { + line = strings.TrimSpace(line[1:]) + } + + // Don't delete the primary branch + if line != r.PrimaryBranch && line != "" { + branches = append(branches, line) + } + } + + return branches, scanner.Err() +} + +// deleteBranch deletes a local branch +func (r *GitRepository) deleteBranch(branch string) error { + _, err := r.runGitCommand("branch", "-d", branch) + if err != nil { + // Try force delete if normal delete fails + _, forceErr := r.runGitCommand("branch", "-D", branch) + if forceErr != nil { + return fmt.Errorf("failed to delete branch %s: %w", branch, err) + } + log.Printf("Force deleted branch: %s", branch) + return nil + } + log.Printf("Deleted branch: %s", branch) + return nil +} + +// cleanupMergedBranches deletes all local branches that have been merged into the primary branch +func (r *GitRepository) cleanupMergedBranches(dryRun bool) error { + mergedBranches, err := r.getMergedBranches() + if err != nil { + return err + } + + if len(mergedBranches) == 0 { + log.Println("No merged branches to clean up") + return nil + } + + log.Printf("Found %d merged branches:", len(mergedBranches)) + for _, branch := range mergedBranches { + if dryRun { + log.Printf("Would delete: %s", branch) + } else { + if err := r.deleteBranch(branch); err != nil { + log.Printf("Error deleting branch %s: %v", branch, err) + } + } + } + + return nil +} + +func main() { + wd, err := os.Getwd() + if err != nil { + log.Fatalf("Failed to get working directory: %v", err) + } + + repo, err := NewGitRepository(wd) + if err != nil { + log.Fatalf("Error: %v", err) + } + + log.Printf("Working in: %s", repo.WorkingDirectory) + log.Printf("Main branch is: %s", repo.PrimaryBranch) + log.Printf("Remote is named %s and is at %s", repo.RemoteName, repo.RemoteURL) + + dryRun := len(os.Args) > 1 && os.Args[1] == "--dry-run" + + if dryRun { + log.Println("Running in dry-run mode") + } + + if err := repo.cleanupMergedBranches(dryRun); err != nil { + log.Fatalf("Failed to cleanup merged branches: %v", err) + } +} -- cgit v1.2.3