Skip to content
Open
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
13 changes: 13 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,19 @@ gitflow.release.finish.signingkey=ABC123DEF
gitflow.hotfix.finish.keeplocal=true
```

### Delete Command Options

| Option | Description | Values | Default |
|--------|-------------|--------|---------|
| `fetch` | Fetch and fast-forward parent before delete | `true`, `false` | `false` |

#### Examples

```bash
# Always fetch before deleting feature branches
gitflow.feature.delete.fetch=true
```

### Update Command Options

| Option | Description | Values | Default |
Expand Down
72 changes: 67 additions & 5 deletions cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
)

// DeleteCommand handles the deletion of a topic branch
func DeleteCommand(branchType string, name string, force *bool, remote *bool) {
if err := executeDelete(branchType, name, force, remote); err != nil {
func DeleteCommand(branchType string, name string, force *bool, remote *bool, fetch *bool) {
if err := executeDelete(branchType, name, force, remote, fetch); err != nil {
var exitCode errors.ExitCode
if flowErr, ok := err.(errors.Error); ok {
exitCode = flowErr.ExitCode()
Expand All @@ -25,7 +25,7 @@ func DeleteCommand(branchType string, name string, force *bool, remote *bool) {
}

// executeDelete performs the actual branch deletion logic and returns any errors
func executeDelete(branchType string, name string, force *bool, remote *bool) error {
func executeDelete(branchType string, name string, force *bool, remote *bool, fetch *bool) error {
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
Expand Down Expand Up @@ -73,12 +73,38 @@ func executeDelete(branchType string, name string, force *bool, remote *bool) er

// Run delete operation wrapped with hooks
return hooks.WithHooks(gitDir, branchType, hooks.HookActionDelete, hookCtx, func() error {
return performDelete(branchType, name, fullBranchName, branchConfig, force, remote, cfg)
return performDelete(branchType, name, fullBranchName, branchConfig, force, remote, fetch, cfg)
})
}

// performDelete performs the actual delete operation (called within hooks wrapper)
func performDelete(branchType, name, fullBranchName string, branchConfig config.BranchConfig, force *bool, remote *bool, cfg *config.Config) error {
func performDelete(branchType, name, fullBranchName string, branchConfig config.BranchConfig, force *bool, remote *bool, fetch *bool, cfg *config.Config) error {
// Determine if we should fetch before deleting
shouldFetch := false
if fetch != nil {
shouldFetch = *fetch
} else {
configKey := fmt.Sprintf("gitflow.%s.delete.fetch", branchType)
fetchConfig, err := git.GetConfig(configKey)
if err == nil && fetchConfig == "true" {
shouldFetch = true
}
}

if shouldFetch && git.RemoteExists(cfg.Remote) {
fmt.Printf("Fetching from remote '%s'...\n", cfg.Remote)
// Fetch parent branch
if err := git.FetchBranch(cfg.Remote, branchConfig.Parent); err != nil {
// Non-fatal: remote branch might not exist
fmt.Fprintf(os.Stderr, "Note: Could not fetch parent branch '%s': %v\n", branchConfig.Parent, err)
}
// Fetch topic branch
if err := git.FetchBranch(cfg.Remote, fullBranchName); err != nil {
// Non-fatal: remote branch might not exist (may have been deleted after merge)
fmt.Fprintf(os.Stderr, "Note: Could not fetch topic branch '%s': %v\n", fullBranchName, err)
}
}
Comment on lines +94 to +106
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current --fetch implementation only runs git fetch <remote>, which updates remote-tracking refs but does not update the local base branch (e.g., develop). Since deletion still relies on git branch -d/-D (merge check against local refs), fetching alone will not change whether Git considers the branch “fully merged”, so this likely won’t resolve the remotely-merged-PR scenario described in the PR/Issue #88.

To make --fetch achieve the intended behavior, consider checking merge status against the remote-tracking base (e.g., refs/remotes/<remote>/<parent>) after fetching and, when it’s merged there, proceed with a safe force-delete (or an explicit, well-documented alternative).

Copilot uses AI. Check for mistakes.

// Determine if we should force delete the branch
forceDelete := false
if force != nil {
Expand All @@ -93,6 +119,30 @@ func performDelete(branchType, name, fullBranchName string, branchConfig config.
}
}

// Check if local branch is in sync with remote (unless --force)
if shouldFetch && !forceDelete {
status, commitCount, err := git.CompareBranchWithRemote(fullBranchName)
if err == nil { // Only check if we can get tracking info
switch status {
case git.SyncStatusBehind, git.SyncStatusDiverged:
trackingBranch, err := git.GetTrackingBranch(fullBranchName)
if err != nil {
trackingBranch = "remote tracking branch"
}
return &errors.BranchBehindRemoteError{
BranchName: fullBranchName,
RemoteBranch: trackingBranch,
CommitCount: commitCount,
BranchType: branchType,
}
case git.SyncStatusAhead:
fmt.Printf("Note: Local branch is %d commit(s) ahead of remote\n", commitCount)
}
// SyncStatusEqual or SyncStatusNoTracking - proceed normally
}
// If err != nil (no tracking branch), proceed normally
}

// Determine if we should delete remote branch
deleteRemote := false
if remote != nil {
Expand Down Expand Up @@ -125,11 +175,23 @@ func performDelete(branchType, name, fullBranchName string, branchConfig config.
if err := git.Checkout(parentBranch); err != nil {
return &errors.GitError{Operation: fmt.Sprintf("checkout parent branch '%s'", parentBranch), Err: err}
}
currentBranch = parentBranch
} else {
return &errors.GitError{Operation: "delete branch", Err: fmt.Errorf("cannot delete the current branch without a parent branch configured")}
}
}

// After fetch, fast-forward the parent branch so git branch -d can detect
// branches merged remotely (e.g., via GitHub PR merge).
// Only do this when we're on the parent branch, since git branch -d checks against HEAD.
if shouldFetch && currentBranch == branchConfig.Parent && git.RemoteExists(cfg.Remote) {
remoteBranch := fmt.Sprintf("%s/%s", cfg.Remote, branchConfig.Parent)
if err := git.MergeFFOnly(remoteBranch); err != nil {
// Non-fatal: parent may have diverged or remote branch may not exist
fmt.Fprintf(os.Stderr, "Note: Could not fast-forward '%s': %v\n", branchConfig.Parent, err)
}
}

// Delete the branch with appropriate flag
deleteErr := git.DeleteBranch(fullBranchName, forceDelete)
if deleteErr != nil {
Expand Down
29 changes: 12 additions & 17 deletions cmd/shorthand.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,25 @@ func RegisterShorthandCommands() {
if err != nil {
return err
}
var force *bool
if cmd.Flags().Changed("force") {
f, _ := cmd.Flags().GetBool("force")
force = &f
} else if cmd.Flags().Changed("no-force") {
f := false
force = &f
}
var remote *bool
if cmd.Flags().Changed("remote") {
r, _ := cmd.Flags().GetBool("remote")
remote = &r
} else if cmd.Flags().Changed("no-remote") {
f := false
remote = &f
}
DeleteCommand(branchType, name, force, remote)
forceFlag, _ := cmd.Flags().GetBool("force")
noForceFlag, _ := cmd.Flags().GetBool("no-force")
force := getBoolFlag(forceFlag, noForceFlag)
remoteFlag, _ := cmd.Flags().GetBool("remote")
noRemoteFlag, _ := cmd.Flags().GetBool("no-remote")
remote := getBoolFlag(remoteFlag, noRemoteFlag)
fetchFlag, _ := cmd.Flags().GetBool("fetch")
noFetchFlag, _ := cmd.Flags().GetBool("no-fetch")
fetch := getBoolFlag(fetchFlag, noFetchFlag)
DeleteCommand(branchType, name, force, remote, fetch)
return nil
},
}
deleteCmd.Flags().BoolP("force", "f", false, "Force delete even if unmerged")
deleteCmd.Flags().Bool("no-force", false, "Don't force delete (overrides config)")
deleteCmd.Flags().BoolP("remote", "r", false, "Delete remote tracking branch")
deleteCmd.Flags().Bool("no-remote", false, "Don't delete remote tracking branch")
deleteCmd.Flags().Bool("fetch", false, "Fetch from remote before deleting")
deleteCmd.Flags().Bool("no-fetch", false, "Don't fetch from remote before deleting")
rootCmd.AddCommand(deleteCmd)

// Update
Expand Down
6 changes: 5 additions & 1 deletion cmd/topicbranch.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ func registerBranchCommand(branchType string) {
noForce, _ := cmd.Flags().GetBool("no-force")
remote, _ := cmd.Flags().GetBool("remote")
noRemote, _ := cmd.Flags().GetBool("no-remote")
fetch, _ := cmd.Flags().GetBool("fetch")
noFetch, _ := cmd.Flags().GetBool("no-fetch")

// Convert force flags to a single *bool
var forcePtr *bool
Expand All @@ -301,7 +303,7 @@ func registerBranchCommand(branchType string) {
remotePtr = &falseBool
}

DeleteCommand(branchType, args[0], forcePtr, remotePtr)
DeleteCommand(branchType, args[0], forcePtr, remotePtr, getBoolFlag(fetch, noFetch))
return nil
},
}
Expand All @@ -311,6 +313,8 @@ func registerBranchCommand(branchType string) {
deleteCmd.Flags().Bool("no-force", false, "Don't force delete the branch (overrides config)")
deleteCmd.Flags().BoolP("remote", "r", false, "Delete the remote tracking branch")
deleteCmd.Flags().Bool("no-remote", false, "Don't delete the remote tracking branch")
deleteCmd.Flags().Bool("fetch", false, "Fetch from remote before deleting")
deleteCmd.Flags().Bool("no-fetch", false, "Don't fetch from remote before deleting")

branchCmd.AddCommand(deleteCmd)

Expand Down
19 changes: 19 additions & 0 deletions docs/git-flow-delete.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ The delete operation removes the specified topic branch from the local repositor
**--no-remote**
: Don't delete the remote tracking branch (default behavior)

**--fetch**
: Fetch from remote before deleting. This updates local refs so that Git can correctly detect whether the branch has been merged remotely (e.g., via a GitHub PR merge).

**--no-fetch**
: Don't fetch from remote before deleting (overrides config)

## SAFETY CHECKS

By default, Git prevents deletion of branches with unmerged changes. The delete command:
Expand Down Expand Up @@ -67,6 +73,13 @@ Delete with remote cleanup:
git flow feature delete my-feature --remote
```

### Fetch Before Delete

Fetch and fast-forward the parent branch before deleting, so Git can detect branches merged remotely (e.g., via a GitHub PR merge):
```bash
git flow feature delete my-feature --fetch
```
Comment on lines +76 to +81
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs claim --fetch lets users “Delete a remotely-merged branch without needing --force”. With the current implementation only doing git fetch before git branch -d, this may not be true because fetching doesn’t update the local base branch used by Git’s “fully merged” check. Either adjust the implementation to match this behavior, or reword the documentation to set correct expectations (e.g., that users may still need to update/fast-forward their base branch).

Copilot uses AI. Check for mistakes.

### Force Deletion

Delete branch with unmerged changes:
Expand Down Expand Up @@ -144,6 +157,12 @@ git config gitflow.hotfix.delete.force true
git config gitflow.branch.feature.deleteRemote true
```

### Fetch Settings
```bash
# Always fetch before deleting feature branches
git config gitflow.feature.delete.fetch true
```

## SAFETY CONSIDERATIONS

**Unmerged Changes**
Expand Down
5 changes: 5 additions & 0 deletions docs/gitflow-config.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,11 @@ The finish command supports extensive merge strategy configuration through comma
: *Type*: boolean
: *Default*: true

**gitflow.*type*.delete.fetch**
: Fetch and fast-forward the parent branch before deleting a topic branch. When enabled, updates the parent branch so that Git can correctly detect whether the topic branch has been merged remotely (e.g., via a GitHub PR merge).
: *Type*: boolean
: *Default*: false

Comment on lines +458 to +462
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new configuration key gitflow.<type>.delete.fetch is introduced here, but it’s not documented in CONFIGURATION.md (which appears to be the central config reference). Please add the same key there to keep the two configuration docs in sync.

Copilot uses AI. Check for mistakes.
### Merge Message Options

**gitflow.*type*.finish.mergemessage**
Expand Down
11 changes: 11 additions & 0 deletions internal/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,17 @@ func RebaseWithOptions(targetBranch string, preserveMerges bool) error {
return nil
}

// MergeFFOnly attempts a fast-forward-only merge of the given branch into the current branch.
// Returns an error if the merge cannot be fast-forwarded.
func MergeFFOnly(branch string) error {
cmd := exec.Command("git", "merge", "--ff-only", branch)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("fast-forward merge of %q failed: %w: %s", branch, err, strings.TrimSpace(string(output)))
}
return nil
}

// MergeWithOptions merges a branch into current branch with optional no-fast-forward
func MergeWithOptions(branchName string, noFF bool, noVerify bool) error {
args := []string{"merge"}
Expand Down
Loading