Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <branch-name> [flags]
```

**Arguments:**

| Argument | Description |
|---|---|
| `<branch-name>` | 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 <branch-name>` — deletes locally (`-D` when `--force`).
- `git push origin --delete <branch-name>` — 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.
Expand Down Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion internal/cli/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 <branch-name>",
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 <branch-name> (local delete; -D when --force)
2. git push origin --delete <branch-name> (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
}
98 changes: 98 additions & 0 deletions internal/cli/branch_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"strings"

"github.com/alexandreafj/gitm/internal/db"
"github.com/alexandreafj/gitm/internal/git"
Expand Down Expand Up @@ -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 <path>` 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
Comment thread
alexandreafj marked this conversation as resolved.
}

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 "", "branch has unmerged commits — re-run with --force to delete anyway", nil
}
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
}
Loading
Loading