Skip to content
Closed
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
46 changes: 40 additions & 6 deletions cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import (
)

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

// executeDelete performs the actual branch deletion logic and returns any errors
func executeDelete(repo *git.Repo, branchType string, name string, force *bool, remote *bool, fetch *bool) error {
func executeDelete(repo *git.Repo, branchType string, name string, force *bool, remote *bool, fetch *bool, removeWorktree *bool, forceRemoveWorktree *bool) error {
// Validate that git-flow is initialized before resolving branch types.
// LoadConfig falls back to DefaultConfig when uninitialized, so this gate
// must run first or the default branch types mask the uninitialized state.
Expand Down Expand Up @@ -79,12 +79,12 @@ func executeDelete(repo *git.Repo, branchType string, name string, force *bool,

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

// performDelete performs the actual delete operation (called within hooks wrapper)
func performDelete(repo *git.Repo, branchType, name, fullBranchName string, branchConfig config.BranchConfig, force *bool, remote *bool, fetch *bool, cfg *config.Config) error {
func performDelete(repo *git.Repo, branchType, name, fullBranchName string, branchConfig config.BranchConfig, force *bool, remote *bool, fetch *bool, cfg *config.Config, removeWorktree *bool, forceRemoveWorktree *bool) error {
// Determine if we should fetch before deleting (flag > config, default false).
shouldFetch := false
if fetch != nil {
Expand Down Expand Up @@ -125,6 +125,32 @@ func performDelete(repo *git.Repo, branchType, name, fullBranchName string, bran
}
}

// Determine if we should remove a linked worktree holding the branch before
// deleting it (flag > config, default false).
removeWorktreeResolved := false
if removeWorktree != nil {
removeWorktreeResolved = *removeWorktree
} else {
configKey := fmt.Sprintf("gitflow.%s.delete.remove-worktree", branchType)
rwConfig, err := repo.GetConfig(configKey)
if err == nil && rwConfig == "true" {
removeWorktreeResolved = true
}
}

// Determine if we should force-remove a dirty linked worktree
// (flag > config, default false).
forceRemoveWorktreeResolved := false
if forceRemoveWorktree != nil {
forceRemoveWorktreeResolved = *forceRemoveWorktree
} else {
configKey := fmt.Sprintf("gitflow.%s.delete.force-remove-worktree", branchType)
frwConfig, err := repo.GetConfig(configKey)
if err == nil && frwConfig == "true" {
forceRemoveWorktreeResolved = true
}
}

// Validate remote exists if remote deletion is requested
// This must happen before any state-changing operations (checkout, branch deletion)
if deleteRemote && !repo.RemoteExists(cfg.Remote) {
Expand Down Expand Up @@ -163,7 +189,15 @@ func performDelete(repo *git.Repo, branchType, name, fullBranchName string, bran
return err
}

// Delete the branch with appropriate flag
// Delete the branch with appropriate flag. If the branch is checked out in a
// linked worktree, git refuses to delete it; when enabled, remove that
// worktree first (refusing to force-remove a dirty one unless requested).
if removeWorktreeResolved {
if err := removeWorktreeForBranch(repo, fullBranchName, forceRemoveWorktreeResolved); err != nil {
return err
}
}

deleteErr := repo.DeleteBranch(fullBranchName, forceDelete)
if deleteErr != nil {
return &errors.GitError{Operation: fmt.Sprintf("delete branch '%s'", fullBranchName), Err: deleteErr}
Expand Down
37 changes: 26 additions & 11 deletions cmd/finish.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ func executeFinish(repo *git.Repo, branchType string, name string, continueOp bo

// Resolve all options once before starting operations
resolvedOptions := config.ResolveFinishOptions(cfg, branchType, shortName, tagOptions, retentionOptions, mergeOptions, fetch, noVerify, push, pushTag)
if resolvedOptions.RemoveWorktree && !resolvedOptions.ForceRemoveWorktree && !resolvedOptions.Keep && !resolvedOptions.KeepLocal {
if err := preflightWorktreeRemoval(repo, name, false); err != nil {
return err
}
}

// Fetch the topic (and parent, best-effort) and verify the topic is in sync with its remote.
// This runs only on the initial finish, never on --continue/--abort (handled above). A fatal
Expand Down Expand Up @@ -786,7 +791,7 @@ func handleDeleteBranchStep(repo *git.Repo, cfg *config.Config, state *mergestat
// Delete branches based on settings
// Use force delete since we've already merged the branch
forceDelete := true
if err := deleteBranchesIfNeeded(repo, state, cfg.Remote, keepRemote, keepLocal, forceDelete); err != nil {
if err := deleteBranchesIfNeeded(repo, state, cfg.Remote, keepRemote, keepLocal, forceDelete, resolvedOptions.RemoveWorktree, resolvedOptions.ForceRemoveWorktree); err != nil {
return err
}

Expand Down Expand Up @@ -1033,10 +1038,27 @@ func updateChildBranch(repo *git.Repo, cfg *config.Config, branchName string, st
}

// deleteBranchesIfNeeded deletes branches based on retention settings
func deleteBranchesIfNeeded(repo *git.Repo, state *mergestate.MergeState, remote string, keepRemote, keepLocal, forceDelete bool) error {
// Delete remote branch if not keeping it and if remote branch exists
func deleteBranchesIfNeeded(repo *git.Repo, state *mergestate.MergeState, remote string, keepRemote, keepLocal, forceDelete bool, removeWorktree, forceRemoveWorktree bool) error {
// Delete local branch if not keeping it
if !keepLocal {
// A branch checked out in a linked worktree cannot be deleted by git.
// When enabled, remove that worktree (refusing to force-remove a dirty
// one unless forceRemoveWorktree is set) before deleting the branch.
if removeWorktree {
if err := removeWorktreeForBranch(repo, state.FullBranchName, forceRemoveWorktree); err != nil {
return err
}
}

if err := repo.DeleteBranch(state.FullBranchName, forceDelete); err != nil {
return &errors.GitError{Operation: fmt.Sprintf("delete branch '%s'", state.FullBranchName), Err: err}
}
}

// Delete the remote branch only after local cleanup succeeds. In particular,
// a dirty linked worktree must not leave the remote branch deleted while the
// local branch and worktree remain.
if !keepRemote {
// Only attempt to delete if the remote branch actually exists
if repo.RemoteBranchExists(remote, state.FullBranchName) {
remoteBranch := fmt.Sprintf("%s/%s", remote, state.FullBranchName)
if err := repo.DeleteRemoteBranch(remote, state.FullBranchName); err != nil {
Expand All @@ -1045,13 +1067,6 @@ func deleteBranchesIfNeeded(repo *git.Repo, state *mergestate.MergeState, remote
}
}

// Delete local branch if not keeping it
if !keepLocal {
if err := repo.DeleteBranch(state.FullBranchName, forceDelete); err != nil {
return &errors.GitError{Operation: fmt.Sprintf("delete branch '%s'", state.FullBranchName), Err: err}
}
}

return nil
}

Expand Down
22 changes: 17 additions & 5 deletions cmd/shorthand.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ func RegisterShorthandCommands() {
fetchFlag, _ := cmd.Flags().GetBool("fetch")
noFetchFlag, _ := cmd.Flags().GetBool("no-fetch")
fetch := getBoolFlag(fetchFlag, noFetchFlag)
DeleteCommand(branchType, name, force, remote, fetch)
removeWorktreeFlag, _ := cmd.Flags().GetBool("remove-worktree")
noRemoveWorktreeFlag, _ := cmd.Flags().GetBool("no-remove-worktree")
removeWorktree := getBoolFlag(removeWorktreeFlag, noRemoveWorktreeFlag)
forceRemoveWorktreeFlag, _ := cmd.Flags().GetBool("force-remove-worktree")
noForceRemoveWorktreeFlag, _ := cmd.Flags().GetBool("no-force-remove-worktree")
forceRemoveWorktree := getBoolFlag(forceRemoveWorktreeFlag, noForceRemoveWorktreeFlag)
DeleteCommand(branchType, name, force, remote, fetch, removeWorktree, forceRemoveWorktree)
return nil
},
}
Expand All @@ -63,6 +69,10 @@ func RegisterShorthandCommands() {
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")
deleteCmd.Flags().Bool("remove-worktree", false, "Remove a linked worktree holding the branch before deleting it (overrides config)")
deleteCmd.Flags().Bool("no-remove-worktree", false, "Don't remove a linked worktree holding the branch")
deleteCmd.Flags().Bool("force-remove-worktree", false, "Force-remove a dirty linked worktree (uncommitted/untracked changes are lost)")
deleteCmd.Flags().Bool("no-force-remove-worktree", false, "Don't force-remove a dirty linked worktree")
rootCmd.AddCommand(deleteCmd)

// Update
Expand Down Expand Up @@ -155,10 +165,12 @@ func RegisterShorthandCommands() {
TagName: cmd.Flag("tagname").Value.String(),
}
retentionOptions := &config.BranchRetentionOptions{
Keep: getBoolPtr(cmd, "keep", "no-keep"),
KeepRemote: getBoolPtr(cmd, "keepremote", "no-keepremote"),
KeepLocal: getBoolPtr(cmd, "keeplocal", "no-keeplocal"),
ForceDelete: getBoolPtr(cmd, "force-delete", "no-force-delete"),
Keep: getBoolPtr(cmd, "keep", "no-keep"),
KeepRemote: getBoolPtr(cmd, "keepremote", "no-keepremote"),
KeepLocal: getBoolPtr(cmd, "keeplocal", "no-keeplocal"),
ForceDelete: getBoolPtr(cmd, "force-delete", "no-force-delete"),
RemoveWorktree: getBoolPtr(cmd, "remove-worktree", "no-remove-worktree"),
ForceRemoveWorktree: getBoolPtr(cmd, "force-remove-worktree", "no-force-remove-worktree"),
}
// Create merge strategy options with squash message support
mergeOptions := &config.MergeStrategyOptions{
Expand Down
30 changes: 25 additions & 5 deletions cmd/topicbranch.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ func registerBranchCommand(branchType string) {
noKeepLocal, _ := cmd.Flags().GetBool("no-keeplocal")
forceDelete, _ := cmd.Flags().GetBool("force-delete")
noForceDelete, _ := cmd.Flags().GetBool("no-force-delete")
removeWorktree, _ := cmd.Flags().GetBool("remove-worktree")
noRemoveWorktree, _ := cmd.Flags().GetBool("no-remove-worktree")
forceRemoveWorktree, _ := cmd.Flags().GetBool("force-remove-worktree")
noForceRemoveWorktree, _ := cmd.Flags().GetBool("no-force-remove-worktree")

// Get merge strategy flags
rebase, _ := cmd.Flags().GetBool("rebase")
Expand Down Expand Up @@ -238,10 +242,12 @@ func registerBranchCommand(branchType string) {

// Create branch retention options
retentionOptions := &config.BranchRetentionOptions{
Keep: getBoolFlag(keep, noKeep),
KeepRemote: getBoolFlag(keepRemote, noKeepRemote),
KeepLocal: getBoolFlag(keepLocal, noKeepLocal),
ForceDelete: getBoolFlag(forceDelete, noForceDelete),
Keep: getBoolFlag(keep, noKeep),
KeepRemote: getBoolFlag(keepRemote, noKeepRemote),
KeepLocal: getBoolFlag(keepLocal, noKeepLocal),
ForceDelete: getBoolFlag(forceDelete, noForceDelete),
RemoveWorktree: getBoolFlag(removeWorktree, noRemoveWorktree),
ForceRemoveWorktree: getBoolFlag(forceRemoveWorktree, noForceRemoveWorktree),
}

// Get merge message flags
Expand Down Expand Up @@ -316,8 +322,12 @@ func registerBranchCommand(branchType string) {
noRemote, _ := cmd.Flags().GetBool("no-remote")
fetch, _ := cmd.Flags().GetBool("fetch")
noFetch, _ := cmd.Flags().GetBool("no-fetch")
removeWorktree, _ := cmd.Flags().GetBool("remove-worktree")
noRemoveWorktree, _ := cmd.Flags().GetBool("no-remove-worktree")
forceRemoveWorktree, _ := cmd.Flags().GetBool("force-remove-worktree")
noForceRemoveWorktree, _ := cmd.Flags().GetBool("no-force-remove-worktree")

DeleteCommand(branchType, args[0], getBoolFlag(force, noForce), getBoolFlag(remote, noRemote), getBoolFlag(fetch, noFetch))
DeleteCommand(branchType, args[0], getBoolFlag(force, noForce), getBoolFlag(remote, noRemote), getBoolFlag(fetch, noFetch), getBoolFlag(removeWorktree, noRemoveWorktree), getBoolFlag(forceRemoveWorktree, noForceRemoveWorktree))
return nil
},
}
Expand All @@ -329,6 +339,10 @@ func registerBranchCommand(branchType string) {
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")
deleteCmd.Flags().Bool("remove-worktree", false, "Remove a linked worktree holding the branch before deleting it (overrides config)")
deleteCmd.Flags().Bool("no-remove-worktree", false, "Don't remove a linked worktree holding the branch")
deleteCmd.Flags().Bool("force-remove-worktree", false, "Force-remove a dirty linked worktree (uncommitted/untracked changes are lost)")
deleteCmd.Flags().Bool("no-force-remove-worktree", false, "Don't force-remove a dirty linked worktree")

branchCmd.AddCommand(deleteCmd)

Expand Down Expand Up @@ -459,6 +473,12 @@ func addFinishFlags(cmd *cobra.Command) {
cmd.Flags().BoolP("force-delete", "D", false, "Force delete the branch")
cmd.Flags().Bool("no-force-delete", false, "Don't force delete the branch")

// Worktree Removal Flags
cmd.Flags().Bool("remove-worktree", false, "Remove a linked worktree holding the branch before deleting it (overrides config)")
cmd.Flags().Bool("no-remove-worktree", false, "Don't remove a linked worktree holding the branch")
cmd.Flags().Bool("force-remove-worktree", false, "Force-remove a dirty linked worktree (uncommitted/untracked changes are lost)")
cmd.Flags().Bool("no-force-remove-worktree", false, "Don't force-remove a dirty linked worktree")

// Merge Strategy Flags
cmd.Flags().BoolP("rebase", "r", false, "Rebase topic branch before merging")
cmd.Flags().Bool("no-rebase", false, "Don't rebase topic branch (use configured strategy)")
Expand Down
69 changes: 69 additions & 0 deletions cmd/worktree_cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cmd

import (
"fmt"
"strings"

"github.com/gittower/git-flow-next/internal/errors"
"github.com/gittower/git-flow-next/internal/git"
)

// removeWorktreeForBranch removes the linked worktree that has branch checked
// out, if any, so the branch can be deleted. It is a no-op when no worktree
// holds the branch. A dirty worktree (uncommitted or untracked changes) is only
// removed when force is set; otherwise an actionable error is returned so the
// user can clean up or opt into force removal. Shared by the finish and delete
// commands.
func removeWorktreeForBranch(repo *git.Repo, branch string, force bool) error {
path, err := repo.WorktreeForBranch(branch)
if err != nil {
return &errors.GitError{Operation: fmt.Sprintf("find worktree for branch '%s'", branch), Err: err}
}
if path == "" {
return nil
}

fmt.Printf("Removing worktree at '%s' for branch '%s'\n", path, branch)
if err := repo.RemoveWorktree(path, force); err != nil {
if !force && strings.Contains(err.Error(), "modified or untracked files") {
return &errors.GitError{
Operation: fmt.Sprintf("remove worktree at '%s'", path),
Err: fmt.Errorf("worktree has uncommitted or untracked changes; clean or commit them, or re-run with --force-remove-worktree"),
}
}
return &errors.GitError{Operation: fmt.Sprintf("remove worktree at '%s'", path), Err: err}
}

fmt.Printf("Removed worktree at '%s'\n", path)
return nil
}

// preflightWorktreeRemoval refuses an enabled, non-forced removal when the
// linked worktree is dirty. Finish calls this before beginning its merge flow
// so it does not complete a merge only to discover that cleanup is unsafe.
func preflightWorktreeRemoval(repo *git.Repo, branch string, force bool) error {
if force {
return nil
}

path, err := repo.WorktreeForBranch(branch)
if err != nil {
return &errors.GitError{Operation: fmt.Sprintf("find worktree for branch '%s'", branch), Err: err}
}
if path == "" {
return nil
}

dirty, err := repo.WorktreeHasChanges(path)
if err != nil {
return &errors.GitError{Operation: fmt.Sprintf("inspect worktree at '%s'", path), Err: err}
}
if dirty {
return &errors.GitError{
Operation: fmt.Sprintf("remove worktree at '%s'", path),
Err: fmt.Errorf("worktree has uncommitted or untracked changes; clean or commit them, or re-run with --force-remove-worktree"),
}
}

return nil
}
12 changes: 12 additions & 0 deletions docs/git-flow-config.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ git config --add gitflow.release.publish.push-option "merge_request.target=main"
**gitflow.*type*.finish.keeplocal**
: Keep local branch after finishing

**gitflow.*type*.finish.remove-worktree**
: Remove a linked worktree holding the branch before deleting it on finish

**gitflow.*type*.finish.force-remove-worktree**
: Force-remove a dirty linked worktree (uncommitted/untracked changes are lost)

**gitflow.*type*.delete.remove-worktree**
: Remove a linked worktree holding the branch before deleting it

**gitflow.*type*.delete.force-remove-worktree**
: Force-remove a dirty linked worktree (uncommitted/untracked changes are lost)

**gitflow.*type*.finish.rebase**
: Use rebase strategy when finishing

Expand Down
21 changes: 21 additions & 0 deletions docs/git-flow-delete.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ The delete operation removes the specified topic branch from the local repositor
**--no-fetch**
: Don't fetch from remote before deleting (overrides config). This skips only the fetch; the topic sync check still runs against existing local tracking data.

**--remove-worktree**
: Remove a linked worktree holding the branch before deleting it (overrides `gitflow.<type>.delete.remove-worktree`). Git refuses to delete a branch checked out in a linked worktree; this option removes that worktree first so deletion can proceed.

**--no-remove-worktree**
: Don't remove a linked worktree holding the branch (default)

**--force-remove-worktree**
: Force-remove a linked worktree that has uncommitted or untracked changes (those changes are lost). Overrides `gitflow.<type>.delete.force-remove-worktree`.

**--no-force-remove-worktree**
: Don't force-remove a dirty linked worktree (default); error instead of discarding changes

## SAFETY CHECKS

By default, Git prevents deletion of branches with unmerged changes. The delete command:
Expand Down Expand Up @@ -158,6 +170,15 @@ git config gitflow.branch.feature.deleteRemote true
git config gitflow.feature.delete.fetch true
```

### Worktree Removal Settings
```bash
# Automatically remove a linked worktree holding the branch before deleting it
git config gitflow.feature.delete.remove-worktree true

# Force-remove dirty worktrees (uncommitted/untracked changes are lost)
git config gitflow.feature.delete.force-remove-worktree true
```

## SAFETY CONSIDERATIONS

**Unmerged Changes**
Expand Down
Loading
Loading