From dfc9c2a0c91b5ca3d11786aba60bd2c8bf5eea77 Mon Sep 17 00:00:00 2001 From: Alex Younger Date: Wed, 5 Aug 2026 16:37:34 -0500 Subject: [PATCH] feat: Add opt-in linked worktree cleanup Allow finish and delete to remove a linked worktree before deleting its branch when explicitly enabled by flag or branch-type config. Preserve existing behavior by default. Refuse to remove dirty worktrees unless force removal is explicitly requested. Relates #175 --- cmd/delete.go | 46 +++++- cmd/finish.go | 37 +++-- cmd/shorthand.go | 22 ++- cmd/topicbranch.go | 30 +++- cmd/worktree_cleanup.go | 69 +++++++++ docs/git-flow-config.1.md | 12 ++ docs/git-flow-delete.1.md | 21 +++ docs/git-flow-finish.1.md | 17 +++ docs/gitflow-config.5.md | 20 +++ internal/config/resolver.go | 63 ++++++++- internal/git/repo.go | 55 ++++++++ test/cmd/delete_worktree_test.go | 125 +++++++++++++++++ test/cmd/finish_worktree_test.go | 217 +++++++++++++++++++++++++++++ test/internal/git/worktree_test.go | 180 ++++++++++++++++++++++++ 14 files changed, 883 insertions(+), 31 deletions(-) create mode 100644 cmd/worktree_cleanup.go create mode 100644 test/cmd/delete_worktree_test.go create mode 100644 test/cmd/finish_worktree_test.go diff --git a/cmd/delete.go b/cmd/delete.go index 8dff4a84..59405611 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -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() @@ -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. @@ -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 { @@ -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) { @@ -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} diff --git a/cmd/finish.go b/cmd/finish.go index 2ba3b32b..b43b0ba3 100644 --- a/cmd/finish.go +++ b/cmd/finish.go @@ -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 @@ -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 } @@ -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 { @@ -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 } diff --git a/cmd/shorthand.go b/cmd/shorthand.go index fe466262..0dd2c739 100644 --- a/cmd/shorthand.go +++ b/cmd/shorthand.go @@ -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 }, } @@ -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 @@ -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{ diff --git a/cmd/topicbranch.go b/cmd/topicbranch.go index 55c11d06..4df98c48 100644 --- a/cmd/topicbranch.go +++ b/cmd/topicbranch.go @@ -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") @@ -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 @@ -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 }, } @@ -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) @@ -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)") diff --git a/cmd/worktree_cleanup.go b/cmd/worktree_cleanup.go new file mode 100644 index 00000000..a93a7e88 --- /dev/null +++ b/cmd/worktree_cleanup.go @@ -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 +} diff --git a/docs/git-flow-config.1.md b/docs/git-flow-config.1.md index 88c202c7..8a29374a 100644 --- a/docs/git-flow-config.1.md +++ b/docs/git-flow-config.1.md @@ -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 diff --git a/docs/git-flow-delete.1.md b/docs/git-flow-delete.1.md index 3da71c72..0c85a9ed 100644 --- a/docs/git-flow-delete.1.md +++ b/docs/git-flow-delete.1.md @@ -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..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..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: @@ -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** diff --git a/docs/git-flow-finish.1.md b/docs/git-flow-finish.1.md index 145e53f6..1bdc5d0e 100644 --- a/docs/git-flow-finish.1.md +++ b/docs/git-flow-finish.1.md @@ -95,6 +95,18 @@ The operation maintains a persistent state file that allows it to resume after c **--no-force-delete** : Don't force delete the branch (default) +**--remove-worktree** +: Remove a linked worktree holding the branch before deleting it (overrides `gitflow..finish.remove-worktree`). Git refuses to delete a branch checked out in a linked worktree; this option removes that worktree first so the finish 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..finish.force-remove-worktree`. + +**--no-force-remove-worktree** +: Don't force-remove a dirty linked worktree (default); error instead of discarding changes + ### Merge Strategy Control **--rebase** @@ -402,6 +414,11 @@ Clean up both local and remote: git flow feature finish my-feature --no-keeplocal --no-keepremote ``` +Finish a feature whose branch lives in a linked worktree (removes the worktree first): +```bash +git flow feature finish my-feature --remove-worktree +``` + ### Bypassing Hooks Skip pre-commit and commit-msg hooks during finish: diff --git a/docs/gitflow-config.5.md b/docs/gitflow-config.5.md index 4a0bb3af..4ea97596 100644 --- a/docs/gitflow-config.5.md +++ b/docs/gitflow-config.5.md @@ -445,6 +445,16 @@ The finish command supports extensive merge strategy configuration through comma : *Type*: boolean : *Default*: false +**gitflow.*type*.finish.remove-worktree** +: Remove a linked worktree holding the branch before the branch is deleted on finish. Git refuses to delete a branch checked out in a linked worktree; enabling this removes that worktree first so the finish can complete. Corresponds to `--remove-worktree`/`--no-remove-worktree`. +: *Type*: boolean +: *Default*: false + +**gitflow.*type*.finish.force-remove-worktree** +: Force-remove a linked worktree that has uncommitted or untracked changes (those changes are lost) instead of failing. Only meaningful when `gitflow.*type*.finish.remove-worktree` is enabled. Corresponds to `--force-remove-worktree`/`--no-force-remove-worktree`. +: *Type*: boolean +: *Default*: false + ### Merge Strategy Options **gitflow.*type*.finish.rebase** @@ -505,6 +515,16 @@ The finish command supports extensive merge strategy configuration through comma : *Type*: boolean : *Default*: false +**gitflow.*type*.delete.remove-worktree** +: Remove a linked worktree holding the branch before the branch is deleted. Git refuses to delete a branch checked out in a linked worktree; enabling this removes that worktree first so deletion can proceed. Corresponds to `--remove-worktree`/`--no-remove-worktree`. +: *Type*: boolean +: *Default*: false + +**gitflow.*type*.delete.force-remove-worktree** +: Force-remove a linked worktree that has uncommitted or untracked changes (those changes are lost) instead of failing. Only meaningful when `gitflow.*type*.delete.remove-worktree` is enabled. Corresponds to `--force-remove-worktree`/`--no-force-remove-worktree`. +: *Type*: boolean +: *Default*: false + ### Remote Push Options **gitflow.*type*.finish.push** diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 8501e8a2..077715c6 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -18,6 +18,10 @@ type ResolvedFinishOptions struct { KeepLocal bool ForceDelete bool + // Worktree removal options + RemoveWorktree bool // Whether to remove a linked worktree holding the branch before deleting it + ForceRemoveWorktree bool // Whether to force-remove a dirty (uncommitted/untracked) linked worktree + // Merge strategy options MergeStrategy string // Final resolved strategy (merge/rebase/squash) UseRebase bool // Whether to use rebase @@ -55,10 +59,12 @@ type TagOptions struct { // BranchRetentionOptions represents command-line retention options // Note: This should match the BranchRetentionOptions type in cmd package type BranchRetentionOptions struct { - Keep *bool - KeepRemote *bool - KeepLocal *bool - ForceDelete *bool + Keep *bool + KeepRemote *bool + KeepLocal *bool + ForceDelete *bool + RemoveWorktree *bool + ForceRemoveWorktree *bool } // MergeStrategyOptions represents command-line merge strategy options @@ -107,6 +113,10 @@ func ResolveFinishOptions(cfg *Config, branchType string, branchName string, tag KeepLocal: resolveFinishKeepLocal(cfg, branchType, retentionOpts), ForceDelete: resolveFinishForceDelete(cfg, branchType, retentionOpts), + // Worktree removal resolution + RemoveWorktree: resolveFinishRemoveWorktree(cfg, branchType, retentionOpts), + ForceRemoveWorktree: resolveFinishForceRemoveWorktree(cfg, branchType, retentionOpts), + // Merge strategy resolution MergeStrategy: strategy, UseRebase: useRebase, @@ -308,6 +318,51 @@ func resolveFinishForceDelete(cfg *Config, branchType string, retentionOpts *Bra return forceDelete } +// resolveFinishRemoveWorktree resolves whether to remove a linked worktree that +// has the branch checked out before the branch is deleted. Default is opt-out +// (false): without configuration, a branch held by a worktree fails to delete, +// as git does by default. Enable it per branch type with +// gitflow..finish.remove-worktree, or override per invocation with +// --remove-worktree/--no-remove-worktree. +func resolveFinishRemoveWorktree(cfg *Config, branchType string, retentionOpts *BranchRetentionOptions) bool { + // Layer 1: Default is not to remove worktrees + removeWorktree := false + + // Layer 2: Check command-specific config + if rwConfig := getCommandConfigBool(cfg, fmt.Sprintf("gitflow.%s.finish.remove-worktree", branchType)); rwConfig { + removeWorktree = true + } + + // Layer 3: Command-line flags override config + if retentionOpts != nil && retentionOpts.RemoveWorktree != nil { + removeWorktree = *retentionOpts.RemoveWorktree + } + + return removeWorktree +} + +// resolveFinishForceRemoveWorktree resolves whether removing a linked worktree +// that has uncommitted or untracked changes should be force-removed. Default is +// false: a dirty worktree errors instead of silently discarding work. Enable +// per branch type with gitflow..finish.force-remove-worktree, or +// override per invocation with --force-remove-worktree/--no-force-remove-worktree. +func resolveFinishForceRemoveWorktree(cfg *Config, branchType string, retentionOpts *BranchRetentionOptions) bool { + // Layer 1: Default is not to force-remove + forceRemoveWorktree := false + + // Layer 2: Check command-specific config + if frwConfig := getCommandConfigBool(cfg, fmt.Sprintf("gitflow.%s.finish.force-remove-worktree", branchType)); frwConfig { + forceRemoveWorktree = true + } + + // Layer 3: Command-line flags override config + if retentionOpts != nil && retentionOpts.ForceRemoveWorktree != nil { + forceRemoveWorktree = *retentionOpts.ForceRemoveWorktree + } + + return forceRemoveWorktree +} + // getCommandConfigBool gets a boolean config value from preloaded config func getCommandConfigBool(cfg *Config, configKey string) bool { value, exists := cfg.CommandConfig[configKey] diff --git a/internal/git/repo.go b/internal/git/repo.go index 6661c665..77be9de4 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -212,6 +212,61 @@ func (r *Repo) DeleteBranch(branch string, force bool) error { return nil } +// WorktreeForBranch returns the absolute path of the linked worktree that has +// the given local branch checked out, or "" if no other worktree holds it. The +// repository's own worktree is never returned: callers only remove the worktree +// after they have already switched off the branch, so the current worktree can +// never hold it. It parses `git worktree list --porcelain`. +func (r *Repo) WorktreeForBranch(branch string) (string, error) { + output, err := r.gitCmd("worktree", "list", "--porcelain").Output() + if err != nil { + return "", fmt.Errorf("failed to list worktrees: %w", err) + } + + target := "branch refs/heads/" + branch + current := filepath.Clean(r.workTree) + var path string + for _, line := range strings.Split(string(output), "\n") { + switch { + case strings.HasPrefix(line, "worktree "): + path = strings.TrimSpace(strings.TrimPrefix(line, "worktree ")) + case strings.TrimSpace(line) == target: + if path != "" && filepath.Clean(path) != current { + return path, nil + } + case strings.TrimSpace(line) == "": + path = "" + } + } + return "", nil +} + +// RemoveWorktree removes the linked worktree at the given path. If force is +// true, a dirty worktree (uncommitted or untracked changes) is removed anyway. +func (r *Repo) RemoveWorktree(path string, force bool) error { + args := []string{"worktree", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, path) + + output, err := r.gitCmd(args...).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to remove worktree at '%s': %s", path, strings.TrimSpace(string(output))) + } + return nil +} + +// WorktreeHasChanges reports whether the worktree at path has modified, +// staged, or untracked files. +func (r *Repo) WorktreeHasChanges(path string) (bool, error) { + output, err := gitCommand(path, "status", "--porcelain").Output() + if err != nil { + return false, fmt.Errorf("failed to inspect worktree at '%s': %w", path, err) + } + return strings.TrimSpace(string(output)) != "", nil +} + // HasCommits checks if the repository has any commits func (r *Repo) HasCommits() (bool, error) { if err := r.gitCmd("rev-parse", "--verify", "HEAD").Run(); err != nil { diff --git a/test/cmd/delete_worktree_test.go b/test/cmd/delete_worktree_test.go new file mode 100644 index 00000000..78e4757a --- /dev/null +++ b/test/cmd/delete_worktree_test.go @@ -0,0 +1,125 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gittower/git-flow-next/test/testutil" +) + +// createFeatureBranchWithWorktree seeds a committed feature branch held in a +// linked worktree and returns the worktree path. The main checkout is left on +// develop so the feature is available to worktrees. +func createFeatureBranchWithWorktree(t *testing.T, dir, feature string) string { + t.Helper() + + if _, err := testutil.RunGitFlow(t, dir, "init", "--defaults"); err != nil { + t.Fatalf("Failed to initialize git-flow: %v", err) + } + if _, err := testutil.RunGitFlow(t, dir, "feature", "start", feature); err != nil { + t.Fatalf("Failed to start feature branch: %v", err) + } + if err := testutil.WriteFile(t, dir, "feature.txt", "feature content"); err != nil { + t.Fatalf("Failed to write feature file: %v", err) + } + if _, err := testutil.RunGit(t, dir, "add", "feature.txt"); err != nil { + t.Fatalf("Failed to add feature file: %v", err) + } + if _, err := testutil.RunGit(t, dir, "commit", "-m", "Add feature file"); err != nil { + t.Fatalf("Failed to commit feature file: %v", err) + } + + if _, err := testutil.RunGit(t, dir, "checkout", "develop"); err != nil { + t.Fatalf("Failed to checkout develop: %v", err) + } + + wtDir := filepath.Join(t.TempDir(), "wt") + if _, err := testutil.RunGit(t, dir, "worktree", "add", wtDir, "feature/"+feature); err != nil { + t.Fatalf("Failed to add linked worktree: %v", err) + } + return wtDir +} + +// TestDeleteFeatureWithLinkedWorktreeAutoRemoved verifies that delete removes a +// linked worktree holding the branch when remove-worktree is enabled. +func TestDeleteFeatureWithLinkedWorktreeAutoRemoved(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureBranchWithWorktree(t, dir, "delete-wt-feature") + + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.delete.remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set remove-worktree config: %v", err) + } + + // The branch is unmerged (its commit is not in develop), so force is needed. + output, err := testutil.RunGitFlow(t, dir, "feature", "delete", "-f", "delete-wt-feature") + if err != nil { + t.Fatalf("Failed to delete feature with linked worktree: %v\nOutput: %s", err, output) + } + + if _, statErr := os.Stat(wtDir); !os.IsNotExist(statErr) { + t.Errorf("Expected linked worktree directory to be removed, stat error: %v", statErr) + } + if testutil.BranchExists(t, dir, "feature/delete-wt-feature") { + t.Error("Expected feature branch to be deleted") + } +} + +// TestDeleteFeatureWithLinkedWorktreeFailsWithoutOptIn verifies that delete +// fails with git's own error when removal is not enabled. +func TestDeleteFeatureWithLinkedWorktreeFailsWithoutOptIn(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureBranchWithWorktree(t, dir, "delete-no-optin") + + output, err := testutil.RunGitFlow(t, dir, "feature", "delete", "-f", "delete-no-optin") + if err == nil { + t.Fatal("Expected delete to fail when a linked worktree holds the branch") + } + if !strings.Contains(output, "used by worktree") { + t.Errorf("Expected git 'used by worktree' error, got:\n%s", output) + } + if _, statErr := os.Stat(wtDir); statErr != nil { + t.Errorf("Expected linked worktree directory to remain, stat error: %v", statErr) + } + if !testutil.BranchExists(t, dir, "feature/delete-no-optin") { + t.Error("Expected feature branch to remain") + } +} + +// TestDeleteFeatureWithDirtyWorktreeErrors verifies delete does not silently +// discard a dirty worktree. +func TestDeleteFeatureWithDirtyWorktreeErrors(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureBranchWithWorktree(t, dir, "delete-dirty") + + if err := testutil.WriteFile(t, wtDir, "untracked.txt", "dirty"); err != nil { + t.Fatalf("Failed to write untracked file in worktree: %v", err) + } + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.delete.remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set remove-worktree config: %v", err) + } + + output, err := testutil.RunGitFlow(t, dir, "feature", "delete", "-f", "delete-dirty") + if err == nil { + t.Fatal("Expected delete to fail on a dirty worktree") + } + if !strings.Contains(output, "uncommitted or untracked changes") { + t.Errorf("Expected actionable dirty-worktree error, got:\n%s", output) + } + if _, statErr := os.Stat(wtDir); statErr != nil { + t.Errorf("Expected dirty worktree directory to remain, stat error: %v", statErr) + } + if !testutil.BranchExists(t, dir, "feature/delete-dirty") { + t.Error("Expected feature branch to remain") + } +} diff --git a/test/cmd/finish_worktree_test.go b/test/cmd/finish_worktree_test.go new file mode 100644 index 00000000..b7cad969 --- /dev/null +++ b/test/cmd/finish_worktree_test.go @@ -0,0 +1,217 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gittower/git-flow-next/test/testutil" +) + +// createFeatureWithWorktree seeds a committed feature branch and returns a +// linked worktree holding that branch. The main checkout is switched back to +// develop first so the branch can be assigned to the linked worktree, mirroring +// the workflow where a feature is developed in its own worktree and finished +// from the main checkout. +func createFeatureWithWorktree(t *testing.T, dir, feature string) string { + t.Helper() + + if _, err := testutil.RunGitFlow(t, dir, "init", "--defaults"); err != nil { + t.Fatalf("Failed to initialize git-flow: %v", err) + } + if _, err := testutil.RunGitFlow(t, dir, "feature", "start", feature); err != nil { + t.Fatalf("Failed to start feature branch: %v", err) + } + if err := testutil.WriteFile(t, dir, "feature.txt", "feature content"); err != nil { + t.Fatalf("Failed to write feature file: %v", err) + } + if _, err := testutil.RunGit(t, dir, "add", "feature.txt"); err != nil { + t.Fatalf("Failed to add feature file: %v", err) + } + if _, err := testutil.RunGit(t, dir, "commit", "-m", "Add feature file"); err != nil { + t.Fatalf("Failed to commit feature file: %v", err) + } + + // Switch the main checkout off the feature branch so it can live in a + // linked worktree. + if _, err := testutil.RunGit(t, dir, "checkout", "develop"); err != nil { + t.Fatalf("Failed to checkout develop: %v", err) + } + + wtDir := filepath.Join(t.TempDir(), "wt") + if _, err := testutil.RunGit(t, dir, "worktree", "add", wtDir, "feature/"+feature); err != nil { + t.Fatalf("Failed to add linked worktree: %v", err) + } + return wtDir +} + +// TestFinishFeatureWithLinkedWorktreeAutoRemoved verifies that when +// gitflow.feature.finish.remove-worktree is enabled, finishing a feature whose +// branch lives in a linked worktree removes the worktree and deletes the branch. +func TestFinishFeatureWithLinkedWorktreeAutoRemoved(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureWithWorktree(t, dir, "worktree-feature") + + // Opt in via configuration. + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.finish.remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set remove-worktree config: %v", err) + } + + output, err := testutil.RunGitFlow(t, dir, "feature", "finish", "worktree-feature") + if err != nil { + t.Fatalf("Failed to finish feature with linked worktree: %v\nOutput: %s", err, output) + } + + if _, statErr := os.Stat(wtDir); !os.IsNotExist(statErr) { + t.Errorf("Expected linked worktree directory to be removed, stat error: %v", statErr) + } + if testutil.BranchExists(t, dir, "feature/worktree-feature") { + t.Error("Expected feature branch to be deleted") + } +} + +// TestFinishFeatureWithLinkedWorktreeFailsWithoutOptIn verifies the default +// behavior is unchanged: without remove-worktree enabled, finishing fails with +// git's own "used by worktree" error and leaves the worktree and branch intact. +func TestFinishFeatureWithLinkedWorktreeFailsWithoutOptIn(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureWithWorktree(t, dir, "no-optin-feature") + + output, err := testutil.RunGitFlow(t, dir, "feature", "finish", "no-optin-feature") + if err == nil { + t.Fatal("Expected finish to fail when a linked worktree holds the branch") + } + if !strings.Contains(output, "used by worktree") { + t.Errorf("Expected git 'used by worktree' error, got:\n%s", output) + } + if _, statErr := os.Stat(wtDir); statErr != nil { + t.Errorf("Expected linked worktree directory to remain, stat error: %v", statErr) + } + if !testutil.BranchExists(t, dir, "feature/no-optin-feature") { + t.Error("Expected feature branch to remain") + } +} + +// TestFinishFeatureNoRemoveWorktreeFlagOverridesConfig verifies that the +// --no-remove-worktree flag wins over a configuration that enables removal. +func TestFinishFeatureNoRemoveWorktreeFlagOverridesConfig(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + createFeatureWithWorktree(t, dir, "flag-override-feature") + + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.finish.remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set remove-worktree config: %v", err) + } + + output, err := testutil.RunGitFlow(t, dir, "feature", "finish", "flag-override-feature", "--no-remove-worktree") + if err == nil { + t.Fatal("Expected finish to fail when removal is disabled via flag") + } + if !strings.Contains(output, "used by worktree") { + t.Errorf("Expected git 'used by worktree' error, got:\n%s", output) + } +} + +// TestFinishFeatureRemoveWorktreeFlagEnablesRemoval verifies that the +// --remove-worktree flag enables removal without any configuration. +func TestFinishFeatureRemoveWorktreeFlagEnablesRemoval(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureWithWorktree(t, dir, "flag-enable-feature") + + output, err := testutil.RunGitFlow(t, dir, "feature", "finish", "flag-enable-feature", "--remove-worktree") + if err != nil { + t.Fatalf("Failed to finish feature with --remove-worktree: %v\nOutput: %s", err, output) + } + + if _, statErr := os.Stat(wtDir); !os.IsNotExist(statErr) { + t.Errorf("Expected linked worktree directory to be removed, stat error: %v", statErr) + } + if testutil.BranchExists(t, dir, "feature/flag-enable-feature") { + t.Error("Expected feature branch to be deleted") + } +} + +// TestFinishFeatureWithDirtyWorktreeErrors verifies that a linked worktree with +// uncommitted or untracked changes is not silently discarded: finish errors +// with actionable guidance and leaves the worktree and branch intact. +func TestFinishFeatureWithDirtyWorktreeErrors(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureWithWorktree(t, dir, "dirty-feature") + + // Make the worktree dirty with an untracked file. + if err := testutil.WriteFile(t, wtDir, "untracked.txt", "dirty"); err != nil { + t.Fatalf("Failed to write untracked file in worktree: %v", err) + } + + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.finish.remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set remove-worktree config: %v", err) + } + + output, err := testutil.RunGitFlow(t, dir, "feature", "finish", "dirty-feature") + if err == nil { + t.Fatal("Expected finish to fail on a dirty worktree") + } + if !strings.Contains(output, "uncommitted or untracked changes") { + t.Errorf("Expected actionable dirty-worktree error, got:\n%s", output) + } + if !strings.Contains(output, "--force-remove-worktree") { + t.Errorf("Expected error to suggest --force-remove-worktree, got:\n%s", output) + } + if _, statErr := os.Stat(wtDir); statErr != nil { + t.Errorf("Expected dirty worktree directory to remain, stat error: %v", statErr) + } + if !testutil.BranchExists(t, dir, "feature/dirty-feature") { + t.Error("Expected feature branch to remain") + } + if _, statErr := os.Stat(filepath.Join(dir, "feature.txt")); !os.IsNotExist(statErr) { + t.Errorf("Expected finish to abort before merging the feature, stat error: %v", statErr) + } +} + +// TestFinishFeatureWithDirtyWorktreeForceRemoved verifies that enabling +// force-remove-worktree discards the dirty worktree and completes the finish. +func TestFinishFeatureWithDirtyWorktreeForceRemoved(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + wtDir := createFeatureWithWorktree(t, dir, "force-dirty-feature") + + if err := testutil.WriteFile(t, wtDir, "untracked.txt", "dirty"); err != nil { + t.Fatalf("Failed to write untracked file in worktree: %v", err) + } + + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.finish.remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set remove-worktree config: %v", err) + } + if _, err := testutil.RunGit(t, dir, "config", "gitflow.feature.finish.force-remove-worktree", "true"); err != nil { + t.Fatalf("Failed to set force-remove-worktree config: %v", err) + } + + output, err := testutil.RunGitFlow(t, dir, "feature", "finish", "force-dirty-feature") + if err != nil { + t.Fatalf("Failed to finish feature with dirty worktree and force: %v\nOutput: %s", err, output) + } + + if _, statErr := os.Stat(wtDir); !os.IsNotExist(statErr) { + t.Errorf("Expected dirty linked worktree directory to be removed, stat error: %v", statErr) + } + if testutil.BranchExists(t, dir, "feature/force-dirty-feature") { + t.Error("Expected feature branch to be deleted") + } +} diff --git a/test/internal/git/worktree_test.go b/test/internal/git/worktree_test.go index c2a344a0..0d2c0021 100644 --- a/test/internal/git/worktree_test.go +++ b/test/internal/git/worktree_test.go @@ -174,3 +174,183 @@ func TestMergeStateNotSharedBetweenWorktrees(t *testing.T) { t.Error("Worktree1 should still have merge state") } } + +func TestWorktreeForBranchNoWorktree(t *testing.T) { + t.Parallel() + dir := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, dir) + + if _, err := testutil.RunGit(t, dir, "checkout", "-b", "feature/alone"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + // Switch back to main so the branch isn't checked out in the current worktree. + if _, err := testutil.RunGit(t, dir, "checkout", "main"); err != nil { + t.Fatalf("Failed to checkout main: %v", err) + } + + repo := openRepo(t, dir) + path, err := repo.WorktreeForBranch("feature/alone") + if err != nil { + t.Fatalf("WorktreeForBranch returned error: %v", err) + } + if path != "" { + t.Errorf("Expected no worktree for branch, got %q", path) + } +} + +func TestWorktreeForBranchExistingWorktree(t *testing.T) { + t.Parallel() + mainRepo := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, mainRepo) + + if _, err := testutil.RunGit(t, mainRepo, "checkout", "-b", "feature/wt"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + if _, err := testutil.RunGit(t, mainRepo, "checkout", "main"); err != nil { + t.Fatalf("Failed to checkout main: %v", err) + } + + worktreePath, err := os.MkdirTemp("", "git-flow-wt-find-*") + if err != nil { + t.Fatalf("Failed to create temp directory for worktree: %v", err) + } + defer os.RemoveAll(worktreePath) + os.RemoveAll(worktreePath) + + if _, err = testutil.RunGit(t, mainRepo, "worktree", "add", worktreePath, "feature/wt"); err != nil { + t.Fatalf("Failed to add worktree: %v", err) + } + + repo := openRepo(t, mainRepo) + path, err := repo.WorktreeForBranch("feature/wt") + if err != nil { + t.Fatalf("WorktreeForBranch returned error: %v", err) + } + if filepath.Clean(path) != filepath.Clean(worktreePath) { + t.Errorf("Expected worktree path %q, got %q", worktreePath, path) + } +} + +func TestWorktreeForBranchIgnoresCurrentWorktree(t *testing.T) { + t.Parallel() + mainRepo := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, mainRepo) + + // A branch checked out in the *current* worktree is never reported, since + // callers switch off the branch before looking it up. + if _, err := testutil.RunGit(t, mainRepo, "checkout", "-b", "feature/current"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + + repo := openRepo(t, mainRepo) + path, err := repo.WorktreeForBranch("feature/current") + if err != nil { + t.Fatalf("WorktreeForBranch returned error: %v", err) + } + if path != "" { + t.Errorf("Expected no worktree for branch checked out in current worktree, got %q", path) + } +} + +func TestRemoveWorktree(t *testing.T) { + t.Parallel() + mainRepo := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, mainRepo) + + worktreePath, err := os.MkdirTemp("", "git-flow-wt-remove-*") + if err != nil { + t.Fatalf("Failed to create temp directory for worktree: %v", err) + } + // Directory is removed by `git worktree remove` itself. + os.RemoveAll(worktreePath) + + if _, err = testutil.RunGit(t, mainRepo, "worktree", "add", worktreePath, "-b", "feature/wt-remove"); err != nil { + t.Fatalf("Failed to add worktree: %v", err) + } + + repo := openRepo(t, mainRepo) + if err := repo.RemoveWorktree(worktreePath, false); err != nil { + t.Fatalf("RemoveWorktree failed: %v", err) + } + + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Errorf("Expected worktree directory to be removed, stat error: %v", err) + } + + // The branch should now be deletable since no worktree holds it. + if _, err := testutil.RunGit(t, mainRepo, "branch", "-D", "feature/wt-remove"); err != nil { + t.Errorf("Failed to delete branch after removing worktree: %v", err) + } +} + +func TestRemoveWorktreeRefusesDirtyWithoutForce(t *testing.T) { + t.Parallel() + mainRepo := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, mainRepo) + + worktreePath, err := os.MkdirTemp("", "git-flow-wt-dirty-*") + if err != nil { + t.Fatalf("Failed to create temp directory for worktree: %v", err) + } + defer os.RemoveAll(worktreePath) + os.RemoveAll(worktreePath) + + if _, err = testutil.RunGit(t, mainRepo, "worktree", "add", worktreePath, "-b", "feature/wt-dirty"); err != nil { + t.Fatalf("Failed to add worktree: %v", err) + } + + // Make the worktree dirty with an untracked file. + if err := testutil.WriteFile(t, worktreePath, "untracked.txt", "dirty"); err != nil { + t.Fatalf("Failed to write untracked file: %v", err) + } + + repo := openRepo(t, mainRepo) + if err := repo.RemoveWorktree(worktreePath, false); err == nil { + t.Fatal("Expected RemoveWorktree to fail on a dirty worktree without force") + } + if _, err := os.Stat(worktreePath); err != nil { + t.Errorf("Expected dirty worktree to remain, stat error: %v", err) + } + + if err := repo.RemoveWorktree(worktreePath, true); err != nil { + t.Fatalf("RemoveWorktree with force failed: %v", err) + } +} + +func TestWorktreeHasChanges(t *testing.T) { + t.Parallel() + mainRepo := testutil.SetupTestRepo(t) + defer testutil.CleanupTestRepo(t, mainRepo) + + worktreePath, err := os.MkdirTemp("", "git-flow-wt-status-*") + if err != nil { + t.Fatalf("Failed to create temp directory for worktree: %v", err) + } + defer os.RemoveAll(worktreePath) + os.RemoveAll(worktreePath) + + if _, err = testutil.RunGit(t, mainRepo, "worktree", "add", worktreePath, "-b", "feature/wt-status"); err != nil { + t.Fatalf("Failed to add worktree: %v", err) + } + + repo := openRepo(t, mainRepo) + dirty, err := repo.WorktreeHasChanges(worktreePath) + if err != nil { + t.Fatalf("WorktreeHasChanges returned error: %v", err) + } + if dirty { + t.Error("Expected clean worktree to have no changes") + } + + if err := testutil.WriteFile(t, worktreePath, "untracked.txt", "dirty"); err != nil { + t.Fatalf("Failed to write untracked file: %v", err) + } + + dirty, err = repo.WorktreeHasChanges(worktreePath) + if err != nil { + t.Fatalf("WorktreeHasChanges returned error: %v", err) + } + if !dirty { + t.Error("Expected worktree with an untracked file to have changes") + } +}