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
97 changes: 90 additions & 7 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, worktreeOpts WorktreeCleanupOptions) {
repo := mustOpenRepo()
if err := executeDelete(repo, branchType, name, force, remote, fetch); err != nil {
if err := executeDelete(repo, branchType, name, force, remote, fetch, worktreeOpts); 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, worktreeOpts WorktreeCleanupOptions) 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 @@ -77,14 +77,36 @@ func executeDelete(repo *git.Repo, branchType string, name string, force *bool,
hookCtx.Version = name
}

// Redirect away from the branch's own worktree (#175) before anything else
// below, including the hooks: run from inside that worktree, the "switch
// to parent if currently on the branch" step further down would either
// fail outright (the parent is commonly checked out elsewhere) or silently
// repurpose the worktree onto the parent, leaving nothing there for the
// free step to act on. Doing the redirect here, rather than inside
// performDelete as originally written, matters for the hooks: WithHooks
// captures repo once, up front, and runs the post-delete hook against that
// SAME handle after the operation completes — an internal redirect inside
// performDelete would leave WithHooks still holding the pre-redirect repo,
// so a delete that just removed the worktree the user was standing in
// would then run its post-hook with a working directory that no longer
// exists. Redirecting before WithHooks is called means every stage — pre-
// hook, the delete itself, post-hook — agrees on the same surviving repo.
redirectedRepo, redirected, err := redirectPreferringParentWorktree(repo, fullBranchName, branchConfig.Parent)
if err != nil {
return &errors.GitError{Operation: "resolve worktree for branch", Err: err}
}
repo = redirectedRepo

// 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, worktreeOpts, redirected)
})
}

// 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 {
// performDelete performs the actual delete operation (called within hooks
// wrapper). redirected reports whether executeDelete already redirected repo
// away from fullBranchName's own worktree (#175) before calling in.
func performDelete(repo *git.Repo, branchType, name, fullBranchName string, branchConfig config.BranchConfig, force *bool, remote *bool, fetch *bool, cfg *config.Config, worktreeOpts WorktreeCleanupOptions, redirected bool) error {
// Determine if we should fetch before deleting (flag > config, default false).
shouldFetch := false
if fetch != nil {
Expand Down Expand Up @@ -131,14 +153,33 @@ func performDelete(repo *git.Repo, branchType, name, fullBranchName string, bran
return &errors.RemoteNotConfiguredError{Remote: cfg.Remote, Operation: "delete remote branch"}
}

// Worktree pre-flight (#175): refuse before ANY destructive step, not just
// immediately before the free step. Moved here — ahead of the parent
// checkout and the ffParent fast-forward below, both of which mutate
// state — so a dirty or mid-operation worktree is caught before anything
// else happens, matching finish's own ordering and the "pre-flight before
// any destructive step" promise.
if err := preflightWorktreeCleanup(repo, fullBranchName, worktreeOpts); err != nil {
return err
}

// If we're on the branch to be deleted, switch to its parent first. This happens before the
// fetch/sync preflight so that fast-forwarding the parent (see below) operates on HEAD, which
// is what `git branch -d` checks a topic against when it has no upstream.
//
// currentBranch == fullBranchName is the ordinary case: deleting your current branch in a
// single-worktree repo. It can never be true anymore, though, after a #175 redirect — the
// redirect exists precisely because we WERE on fullBranchName, in its own linked worktree, and
// redirecting moved repo somewhere else. That somewhere is either the parent's own worktree
// (HEAD is already the parent — nothing to do) or the main worktree (HEAD is whatever was last
// checked out there — not necessarily the parent). The second clause below catches that case:
// without it, the mergedness check and the ffParent fast-forward further down would silently
// run against an unrelated branch, and a genuinely merged branch could be refused as unmerged.
currentBranch, err := repo.GetCurrentBranch()
if err != nil {
return &errors.GitError{Operation: "get current branch", Err: err}
}
if currentBranch == fullBranchName {
if currentBranch == fullBranchName || (redirected && currentBranch != branchConfig.Parent) {
parentBranch := branchConfig.Parent
if parentBranch != "" {
if err := repo.Checkout(parentBranch); err != nil {
Expand All @@ -163,6 +204,48 @@ func performDelete(repo *git.Repo, branchType, name, fullBranchName string, bran
return err
}

// Confirm the branch can actually be deleted BEFORE freeing its worktree
// (#175): freeing is a one-way trip (a git-flow-created worktree is
// removed outright; even a hand-made one, detached, does not un-detach
// itself), and 'git branch -d' below would otherwise be the first thing
// to notice a clean-but-unmerged branch — by which point the worktree is
// already gone. This mirrors 'git branch -d's own mergedness check
// exactly: against the branch's configured upstream when it has one,
// else against whatever is now checked out (which the steps above
// already arranged to be the parent whenever that mattered) — matching
// both branches of git's own rule, not just the no-upstream one, closes
// the gap where this pre-check could pass and the real deletion still
// fail afterward, worktree already gone.
if !forceDelete {
mergeTarget, err := repo.GetTrackingBranch(fullBranchName)
if err != nil {
// No upstream configured — not a real failure, git's own rule
// falls back to HEAD in exactly this case.
mergeTarget, err = repo.GetCurrentBranch()
if err != nil {
return &errors.GitError{Operation: "get current branch", Err: err}
}
}
merged, err := repo.IsAncestor(fullBranchName, mergeTarget)
if err != nil {
return &errors.GitError{Operation: "check whether branch is merged", Err: err}
}
if !merged {
return &errors.GitError{Operation: fmt.Sprintf("delete branch '%s'", fullBranchName), Err: fmt.Errorf("the branch is not fully merged into '%s'; use --force to delete it anyway", mergeTarget)}
}
}

// Free the branch's worktree (#175), right before the branch itself goes
// away: a branch checked out in a linked worktree cannot be deleted while
// checked out there. Delete always prefers the main worktree as the
// navigation destination — unlike finish, it has no merge target to
// prefer instead.
freedRepo, err := freeWorktreeForBranch(repo, fullBranchName, worktreeOpts, "")
if err != nil {
return err
}
repo = freedRepo

// Delete the branch with appropriate flag
deleteErr := repo.DeleteBranch(fullBranchName, forceDelete)
if deleteErr != nil {
Expand Down
Loading