From a8cf1ee2450899543bfd6c61181154017267fcf2 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Wed, 20 May 2026 04:52:29 -0300 Subject: [PATCH 1/2] feat: add branch delete command for local + remote cleanup Manually running `git branch -d` and `git push origin --delete` across multiple repos is tedious and error-prone. The new `gitm branch delete` subcommand handles both in one step, with safety checks for the default branch, currently checked-out branches, and unmerged commits. Co-Authored-By: Claude Opus 4.7 --- README.md | 80 ++++++++++++- internal/cli/branch.go | 51 ++++++++- internal/cli/branch_run.go | 98 ++++++++++++++++ internal/cli/branch_run_test.go | 183 ++++++++++++++++++++++++++++++ internal/cli/branch_test.go | 38 ++++++- internal/cli/test_helpers_test.go | 9 ++ internal/cli/ui.go | 18 +++ internal/git/git.go | 11 ++ internal/git/git_more_test.go | 36 ++++++ 9 files changed, 521 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index df8fd72..23076f4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Run git operations across dozens of repositories in parallel — checkout, pull, - [checkout](#gitm-checkout) - [branch create](#gitm-branch-create) - [branch rename](#gitm-branch-rename) + - [branch delete](#gitm-branch-delete) - [status](#gitm-status) - [update](#gitm-update) - [discard](#gitm-discard) @@ -642,6 +643,83 @@ Done: 2 succeeded --- +### `gitm branch delete` + +Delete a branch across selected repositories — both locally and on the remote in one step. Runs in **parallel**. + +``` +gitm branch delete [flags] +``` + +**Arguments:** + +| Argument | Description | +|---|---| +| `` | The branch to delete. | + +**Flags:** + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--all` | `-a` | false | Apply to all repositories that have the branch. | +| `--force` | `-f` | false | Force-delete branches with unmerged commits (`git branch -D` instead of `-d`). | +| `--no-remote` | — | false | Only delete the local branch. Skip deleting the branch on origin. | +| `--repo` | `-r` | _(none)_ | Comma-separated list of repository aliases to target. Bypasses the interactive selection UI. Takes precedence over `--all`. | + +**Behaviour:** + +1. Filters the registered repositories to only those that have the branch (locally, or on origin unless `--no-remote` is set). +2. Selects repositories: + - Interactive: opens the multi-select UI showing only the matching repositories. + - `--all` / `--repo`: skips the UI and asks for a single `y/N` confirmation listing the target repositories. +3. For each selected repo (in parallel): + - `git branch -d ` — deletes locally (`-D` when `--force`). + - `git push origin --delete ` — deletes the remote branch if it exists (skipped with `--no-remote`). +4. Streams results live. + +**Safety:** + +- The local delete uses `git branch -d`, which refuses branches with unmerged commits. Pass `--force` to delete them anyway. +- The repository's default branch (`main`/`master`) is never deleted — it is skipped. +- A branch that is currently checked out is skipped — switch away from it first. + +**Examples:** + +```bash +# Interactive: delete feature/JIRA-123 in selected repos +gitm branch delete feature/JIRA-123 + +# Apply to all repos that have the branch +gitm branch delete feature/JIRA-123 --all + +# Delete only in specific repos by alias (asks for confirmation) +gitm branch delete feature/JIRA-123 --repo api-gateway,auth-service + +# Force-delete a branch with unmerged commits +gitm branch delete feature/JIRA-123 --force + +# Delete only the local branch, keep it on origin +gitm branch delete feature/JIRA-123 --no-remote +``` + +**Example output:** + +``` +Branch "feature/JIRA-123" will be deleted in 2 repository(ies): + - auth-service + - frontend +Delete branch "feature/JIRA-123"? [y/N] y + +Deleting "feature/JIRA-123" in 2 repository(ies)… + +[auth-service ] ✓ deleted feature/JIRA-123 (local + remote) +[frontend ] ✓ deleted feature/JIRA-123 (local + remote) + +Done: 2 succeeded +``` + +--- + ### `gitm status` Show a summary of all registered repositories: current branch, dirty state, and commits ahead/behind origin. Runs in **parallel** with no network calls by default. @@ -1442,7 +1520,7 @@ cli-git-commands/ │ │ ├── root.go # Root cobra command │ │ ├── repo.go # repo add/list/remove/rename │ │ ├── checkout.go # checkout master -│ │ ├── branch.go # branch create/rename +│ │ ├── branch.go # branch create/rename/delete │ │ ├── status.go # status │ │ ├── update.go # update │ │ ├── discard.go # discard diff --git a/internal/cli/branch.go b/internal/cli/branch.go index 5753262..1f779e6 100644 --- a/internal/cli/branch.go +++ b/internal/cli/branch.go @@ -7,10 +7,11 @@ import ( func branchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "branch", - Short: "Create or rename branches across multiple repositories", + Short: "Create, rename, or delete branches across multiple repositories", } cmd.AddCommand(branchCreateCmd()) cmd.AddCommand(branchRenameCmd()) + cmd.AddCommand(branchDeleteCmd()) return cmd } @@ -82,3 +83,51 @@ selection UI entirely.`, return cmd } + +func branchDeleteCmd() *cobra.Command { + var ( + selectAll bool + force bool + noRemote bool + repoAliases []string + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a branch locally and on remote across selected repositories", + Long: `Delete a branch in each selected repository — locally and on origin in one +step, so you never have to run "git branch -d" and "git push origin --delete" +by hand. + +Per repository: + 1. git branch -d (local delete; -D when --force) + 2. git push origin --delete (delete the remote branch) + +Safety: + - The local delete uses "git branch -d", which refuses branches with + unmerged commits. Pass --force to delete them anyway ("git branch -D"). + - The repository's default branch (main/master) is never deleted. + - A branch that is currently checked out is skipped — switch away first. + +Use --no-remote to delete only the local branch. +Use --repo to target specific repositories by alias, bypassing the interactive +selection UI. Non-interactive runs (--all or --repo) ask for confirmation +before deleting.`, + Example: ` gitm branch delete feature/JIRA-123 + gitm branch delete feature/JIRA-123 --all + gitm branch delete feature/JIRA-123 --repo api-gateway,auth-service + gitm branch delete feature/JIRA-123 --force + gitm branch delete feature/JIRA-123 --no-remote`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runBranchDeleteWithUI(liveUI{}, args[0], selectAll, force, noRemote, repoAliases) + }, + } + + cmd.Flags().BoolVarP(&selectAll, "all", "a", false, "Apply to all repositories that have the branch") + cmd.Flags().BoolVarP(&force, "force", "f", false, "Force-delete branches with unmerged commits (git branch -D)") + cmd.Flags().BoolVar(&noRemote, "no-remote", false, "Only delete locally, skip the remote branch") + cmd.Flags().StringSliceVarP(&repoAliases, "repo", "r", nil, "Limit to specific repository aliases (comma-separated), bypasses interactive selection") + + return cmd +} diff --git a/internal/cli/branch_run.go b/internal/cli/branch_run.go index 7eac336..5a48add 100644 --- a/internal/cli/branch_run.go +++ b/internal/cli/branch_run.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/alexandreafj/gitm/internal/db" "github.com/alexandreafj/gitm/internal/git" @@ -145,3 +146,100 @@ func runBranchRenameWithUI(ui ui, oldName, newName string, selectAll, noRemote b return nil } + +func runBranchDeleteWithUI(ui ui, branchName string, selectAll, force, noRemote bool, repoAliases []string) error { + allRepos, err := resolveRepos(repoAliases) + if err != nil { + return err + } + if len(allRepos) == 0 { + fmt.Println("No repositories registered. Run `gitm repo add ` to add one.") + return nil + } + + var reposWithBranch []*db.Repository + for _, r := range allRepos { + has := git.BranchExists(r.Path, branchName) + if !has && !noRemote { + has = git.RemoteBranchExists(r.Path, branchName) + } + if has { + reposWithBranch = append(reposWithBranch, r) + } + } + + if len(reposWithBranch) == 0 { + return fmt.Errorf("no registered repositories have a branch named %q", branchName) + } + + var chosen []*db.Repository + switch { + case len(repoAliases) > 0 || selectAll: + // --repo / --all: non-interactive, so confirm explicitly before deleting. + chosen = reposWithBranch + fmt.Printf("Branch %q will be deleted in %d repository(ies):\n", branchName, len(chosen)) + for _, r := range chosen { + fmt.Printf(" - %s\n", r.Alias) + } + confirmed, confErr := ui.Confirm(fmt.Sprintf("Delete branch %q? [y/N]", branchName)) + if confErr != nil { + return confErr + } + if !confirmed { + fmt.Println("Aborted — no branches deleted.") + return nil + } + default: + chosen, err = ui.MultiSelect( + reposWithBranch, + fmt.Sprintf("Select repositories to delete branch: %s", branchName), + false, + nil, + ) + if err != nil { + return err + } + } + + fmt.Printf("\nDeleting %q in %d repository(ies)…\n\n", branchName, len(chosen)) + + runner.Run(chosen, func(repo *db.Repository) (string, string, error) { + if branchName == repo.DefaultBranch { + return "", fmt.Sprintf("refusing to delete the default branch %q", branchName), nil + } + + current, err := git.CurrentBranch(repo.Path) + if err != nil { + return "", "", fmt.Errorf("current branch: %w", err) + } + if current == branchName { + return "", "branch is currently checked out — switch away first", nil + } + + var deleted []string + if git.BranchExists(repo.Path, branchName) { + if err := git.DeleteLocalBranch(repo.Path, branchName, force); err != nil { + if !force { + return "", "", fmt.Errorf("local delete failed — branch may have unmerged commits, re-run with --force: %w", err) + } + return "", "", fmt.Errorf("local delete: %w", err) + } + deleted = append(deleted, "local") + } + + if !noRemote && git.RemoteBranchExists(repo.Path, branchName) { + if err := git.DeleteRemoteBranch(repo.Path, branchName); err != nil { + return "", "", fmt.Errorf("remote delete: %w", err) + } + deleted = append(deleted, "remote") + } + + if len(deleted) == 0 { + return "", fmt.Sprintf("branch %q not found", branchName), nil + } + + return fmt.Sprintf("deleted %s (%s)", branchName, strings.Join(deleted, " + ")), "", nil + }) + + return nil +} diff --git a/internal/cli/branch_run_test.go b/internal/cli/branch_run_test.go index f03a8a7..66fc08b 100644 --- a/internal/cli/branch_run_test.go +++ b/internal/cli/branch_run_test.go @@ -296,3 +296,186 @@ func TestBranchRename_RepoFlag_UnknownAliasErrors(t *testing.T) { t.Fatal("expected error for unknown alias, got nil") } } + +func TestBranchDelete_RepoFlag_LocalAndRemote(t *testing.T) { + database = setupTestDB(t) + repoDir, _, _ := initRepoWithRemote(t) + mustRunGit(t, repoDir, "branch", "feature/x") + mustRunGit(t, repoDir, "push", "origin", "feature/x") + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runBranchDeleteWithUI(fakeUI{confirm: true}, "feature/x", false, false, false, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if git.BranchExists(repoDir, "feature/x") { + t.Error("expected local feature/x to be deleted") + } + if git.RemoteBranchExists(repoDir, "feature/x") { + t.Error("expected remote feature/x to be deleted") + } +} + +func TestBranchDelete_NoRemote(t *testing.T) { + database = setupTestDB(t) + repoDir, _, _ := initRepoWithRemote(t) + mustRunGit(t, repoDir, "branch", "feature/x") + mustRunGit(t, repoDir, "push", "origin", "feature/x") + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runBranchDeleteWithUI(fakeUI{confirm: true}, "feature/x", false, false, true, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if git.BranchExists(repoDir, "feature/x") { + t.Error("expected local feature/x to be deleted") + } + if !git.RemoteBranchExists(repoDir, "feature/x") { + t.Error("expected remote feature/x to survive with --no-remote") + } +} + +func TestBranchDelete_Interactive(t *testing.T) { + database = setupTestDB(t) + repoDir, _, _ := initRepoWithRemote(t) + mustRunGit(t, repoDir, "branch", "feature/x") + mustRunGit(t, repoDir, "push", "origin", "feature/x") + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + // No --repo / --all: the interactive multi-select path. fakeUI returns + // every offered repo, so feature/x should be deleted. + if err := runBranchDeleteWithUI(fakeUI{}, "feature/x", false, false, false, nil); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if git.BranchExists(repoDir, "feature/x") { + t.Error("expected feature/x to be deleted via interactive selection") + } +} + +func TestBranchDelete_ConfirmationDeclined(t *testing.T) { + database = setupTestDB(t) + repoDir, _, _ := initRepoWithRemote(t) + mustRunGit(t, repoDir, "branch", "feature/x") + mustRunGit(t, repoDir, "push", "origin", "feature/x") + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runBranchDeleteWithUI(fakeUI{confirm: false}, "feature/x", false, false, false, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if !git.BranchExists(repoDir, "feature/x") { + t.Error("expected feature/x to survive when confirmation is declined") + } + if !git.RemoteBranchExists(repoDir, "feature/x") { + t.Error("expected remote feature/x to survive when confirmation is declined") + } +} + +func TestBranchDelete_DefaultBranchProtected(t *testing.T) { + database = setupTestDB(t) + repoDir, _, _ := initRepoWithRemote(t) + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runBranchDeleteWithUI(fakeUI{confirm: true}, "main", false, false, true, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if !git.BranchExists(repoDir, "main") { + t.Error("expected the default branch main to be protected from deletion") + } +} + +func TestBranchDelete_CurrentBranchSkipped(t *testing.T) { + database = setupTestDB(t) + repoDir, _, _ := initRepoWithRemote(t) + mustRunGit(t, repoDir, "checkout", "-b", "feature/current") + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runBranchDeleteWithUI(fakeUI{confirm: true}, "feature/current", false, false, true, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if !git.BranchExists(repoDir, "feature/current") { + t.Error("expected the checked-out branch to be skipped, not deleted") + } +} + +func TestBranchDelete_UnmergedWithoutForceSkipped(t *testing.T) { + database = setupTestDB(t) + repoDir := unmergedBranchRepo(t) + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + // Without --force, git refuses to drop a branch with unmerged commits. + if err := runBranchDeleteWithUI(fakeUI{confirm: true}, "feature/unmerged", false, false, true, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if !git.BranchExists(repoDir, "feature/unmerged") { + t.Error("expected unmerged branch to survive deletion without --force") + } +} + +func TestBranchDelete_UnmergedWithForce(t *testing.T) { + database = setupTestDB(t) + repoDir := unmergedBranchRepo(t) + if _, err := database.AddRepository("repo1", "repo1", repoDir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runBranchDeleteWithUI(fakeUI{confirm: true}, "feature/unmerged", false, true, true, []string{"repo1"}); err != nil { + t.Fatalf("branch delete: %v", err) + } + + if git.BranchExists(repoDir, "feature/unmerged") { + t.Error("expected unmerged branch to be deleted with --force") + } +} + +func TestBranchDelete_NoReposWithBranch(t *testing.T) { + database = setupTestDB(t) + _, _ = newRepo(t, database, "repo1") + + err := runBranchDeleteWithUI(fakeUI{confirm: true}, "ghost", false, false, true, nil) + if err == nil { + t.Fatal("expected error when no repos have the branch") + } +} + +func TestBranchDelete_RepoFlag_UnknownAliasErrors(t *testing.T) { + database = setupTestDB(t) + _, dir := newRepo(t, database, "repo1") + mustRunGit(t, dir, "branch", "feature/x") + + err := runBranchDeleteWithUI(fakeUI{confirm: true}, "feature/x", false, false, true, []string{"repo1", "ghost-repo"}) + if err == nil { + t.Fatal("expected error for unknown alias") + } +} + +// unmergedBranchRepo builds a repo whose feature/unmerged branch carries a +// commit not reachable from main, then returns to main. +func unmergedBranchRepo(t *testing.T) string { + t.Helper() + repoDir, _, _ := initRepoWithRemote(t) + mustRunGit(t, repoDir, "checkout", "-b", "feature/unmerged") + writeFile(t, repoDir, "extra.txt", "extra\n") + mustRunGit(t, repoDir, "add", "extra.txt") + mustRunGit(t, repoDir, "commit", "-m", "unmerged commit") + mustRunGit(t, repoDir, "checkout", "main") + return repoDir +} diff --git a/internal/cli/branch_test.go b/internal/cli/branch_test.go index 7a1dd6d..2fe2675 100644 --- a/internal/cli/branch_test.go +++ b/internal/cli/branch_test.go @@ -17,7 +17,7 @@ func TestBranchCmdHasSubcommands(t *testing.T) { t.Error("branch command has no subcommands") } - expectedSubcommands := []string{"create", "rename"} + expectedSubcommands := []string{"create", "rename", "delete"} actual := make(map[string]bool) for _, sc := range cmd.Commands() { actual[sc.Name()] = true @@ -99,3 +99,39 @@ func TestBranchRenameCmdFlags(t *testing.T) { } } } + +func TestBranchDeleteCmdExists(t *testing.T) { + cmd := branchDeleteCmd() + if cmd == nil { + t.Fatal("branchDeleteCmd() returned nil") + } + + if cmd.Use == "" { + t.Error("branchDeleteCmd has empty Use") + } +} + +func TestBranchDeleteCmdFlags(t *testing.T) { + cmd := branchDeleteCmd() + + flags := []struct { + long string + short string + }{ + {"all", "a"}, + {"force", "f"}, + {"no-remote", ""}, + {"repo", "r"}, + } + + for _, f := range flags { + flag := cmd.Flags().Lookup(f.long) + if flag == nil { + t.Errorf("flag --%s not found on branch delete", f.long) + continue + } + if flag.Shorthand != f.short { + t.Errorf("flag --%s: expected shorthand %q, got %q", f.long, f.short, flag.Shorthand) + } + } +} diff --git a/internal/cli/test_helpers_test.go b/internal/cli/test_helpers_test.go index 171498f..5d44970 100644 --- a/internal/cli/test_helpers_test.go +++ b/internal/cli/test_helpers_test.go @@ -21,6 +21,8 @@ type fakeUI struct { branchSeen *string branchErr error branchName string + confirm bool + confirmErr error } func (f fakeUI) FileSelect(porcelainLines []string, title string) ([]string, error) { @@ -66,6 +68,13 @@ func (f fakeUI) BranchNameInput() (string, error) { return "feature/test", nil } +func (f fakeUI) Confirm(prompt string) (bool, error) { + if f.confirmErr != nil { + return false, f.confirmErr + } + return f.confirm, nil +} + func setupTestDB(t *testing.T) *db.DB { t.Helper() tmpDir := t.TempDir() diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 1eac1c4..1cadba6 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -1,6 +1,11 @@ package cli import ( + "bufio" + "fmt" + "os" + "strings" + "github.com/alexandreafj/gitm/internal/db" "github.com/alexandreafj/gitm/internal/tui" ) @@ -10,6 +15,7 @@ type ui interface { FileSelect(porcelainLines []string, title string) ([]string, error) CommitMessageInput(repoAlias, branchName string) (string, error) BranchNameInput() (string, error) + Confirm(prompt string) (bool, error) } type liveUI struct{} @@ -29,3 +35,15 @@ func (liveUI) CommitMessageInput(repoAlias, branchName string) (string, error) { func (liveUI) BranchNameInput() (string, error) { return tui.BranchNameInput() } + +// Confirm prints a yes/no prompt and reports whether the user answered yes. +// Anything other than "y"/"yes" (case-insensitive) is treated as no. +func (liveUI) Confirm(prompt string) (bool, error) { + fmt.Print(prompt + " ") + answer, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return false, err + } + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/internal/git/git.go b/internal/git/git.go index 2cd33db..9a3164e 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -277,6 +277,17 @@ func DeleteRemoteBranch(path, branch string) error { return err } +// DeleteLocalBranch deletes a local branch. When force is false it uses +// `git branch -d`, which refuses to delete branches with unmerged commits. +func DeleteLocalBranch(path, branch string, force bool) error { + flag := "-d" + if force { + flag = "-D" + } + _, err := run(path, "branch", flag, branch) + return err +} + // PushBranch pushes a local branch to origin and sets upstream tracking. func PushBranch(path, branch string) error { _, err := run(path, "push", "--set-upstream", "origin", branch) diff --git a/internal/git/git_more_test.go b/internal/git/git_more_test.go index d48c0f6..6f2c6d7 100644 --- a/internal/git/git_more_test.go +++ b/internal/git/git_more_test.go @@ -216,6 +216,42 @@ func TestRemoteBranchOps(t *testing.T) { } } +func TestDeleteLocalBranch(t *testing.T) { + repo := initRepo(t) + + // A branch pointing at the same commit as main is fully merged, so the + // safe (-d) delete succeeds. + mustRunGit(t, repo, "branch", "feature/merged") + if err := git.DeleteLocalBranch(repo, "feature/merged", false); err != nil { + t.Fatalf("DeleteLocalBranch(merged, force=false): %v", err) + } + if git.BranchExists(repo, "feature/merged") { + t.Error("expected feature/merged to be gone after safe delete") + } + + // A branch carrying unmerged commits is refused by the safe delete and + // only removed when force is set. + mustRunGit(t, repo, "checkout", "-b", "feature/unmerged") + writeFile(t, repo, "extra.txt", "extra\n") + mustRunGit(t, repo, "add", "extra.txt") + mustRunGit(t, repo, "commit", "-m", "unmerged commit") + mustRunGit(t, repo, "checkout", "main") + + if err := git.DeleteLocalBranch(repo, "feature/unmerged", false); err == nil { + t.Error("expected safe delete to refuse a branch with unmerged commits") + } + if !git.BranchExists(repo, "feature/unmerged") { + t.Error("expected feature/unmerged to survive a refused safe delete") + } + + if err := git.DeleteLocalBranch(repo, "feature/unmerged", true); err != nil { + t.Fatalf("DeleteLocalBranch(unmerged, force=true): %v", err) + } + if git.BranchExists(repo, "feature/unmerged") { + t.Error("expected feature/unmerged to be gone after force delete") + } +} + func TestRenameBranch(t *testing.T) { repo := initRepo(t) mustRunGit(t, repo, "branch", "old-name") From b6eed97a491c02c4fad1c2fd218f1797d7fe8525 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Wed, 20 May 2026 05:36:54 -0300 Subject: [PATCH 2/2] refactor: use skip instead of error for unmerged branch refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `git branch -d` refuses an unmerged branch without --force, show it as ⚠ SKIPPED (a safe guard) rather than ✗ ERROR (a system failure). Matches the default-branch and current-branch skip reasons in the same function and avoids a "broken" runner summary for expected behaviour. Co-Authored-By: Claude Opus 4.7 --- internal/cli/branch_run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/branch_run.go b/internal/cli/branch_run.go index 5a48add..20e715a 100644 --- a/internal/cli/branch_run.go +++ b/internal/cli/branch_run.go @@ -220,7 +220,7 @@ func runBranchDeleteWithUI(ui ui, branchName string, selectAll, force, noRemote if git.BranchExists(repo.Path, branchName) { if err := git.DeleteLocalBranch(repo.Path, branchName, force); err != nil { if !force { - return "", "", fmt.Errorf("local delete failed — branch may have unmerged commits, re-run with --force: %w", err) + return "", "branch has unmerged commits — re-run with --force to delete anyway", nil } return "", "", fmt.Errorf("local delete: %w", err) }