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
56 changes: 52 additions & 4 deletions cmd/worktree.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -17,6 +18,7 @@ var (
wtForce bool
wtDeleteBranch bool
wtDryRun bool
wtAll bool
wtUpstream string
wtProjectName string
prRemote string
Expand Down Expand Up @@ -69,21 +71,30 @@ var wtListCmd = &cobra.Command{
}

var wtRemoveCmd = &cobra.Command{
Use: "remove <name> [name...]",
Use: "remove [name...]",
Aliases: []string{"rm"},
Short: "Remove worktrees (alias: rm)",
Long: `Remove one or more worktrees.

By default, only removes the worktree directory, keeping the branch.
Use -D to also delete the branch.
Use -D to also delete the branch. Use --all to remove all non-protected worktrees.

Examples:
gmc wt remove feature-login # Remove one worktree
gmc wt rm feat-a feat-b feat-c # Remove multiple worktrees
gmc wt rm feature-login -D # Remove worktree and delete branch
gmc wt rm feature-login -f # Force remove (ignore dirty state)
gmc wt rm feature-login --dry-run # Preview what would be removed`,
Args: cobra.MinimumNArgs(1),
gmc wt rm feature-login --dry-run # Preview what would be removed
gmc wt rm --all -D # Remove all non-protected worktrees and branches`,
Args: func(_ *cobra.Command, args []string) error {
if wtAll && len(args) > 0 {
return errors.New("--all and positional arguments are mutually exclusive")
}
if !wtAll && len(args) < 1 {
return errors.New("requires at least 1 arg(s) or --all flag")
}
return nil
},
RunE: func(_ *cobra.Command, args []string) error {
wtClient := newWorktreeClient()
return runWorktreeRemove(wtClient, args)
Expand Down Expand Up @@ -192,6 +203,7 @@ func init() {
wtRemoveCmd.Flags().BoolVarP(&wtForce, "force", "f", false, "Force removal even if worktree is dirty")
wtRemoveCmd.Flags().BoolVarP(&wtDeleteBranch, "delete-branch", "D", false, "Also delete the branch")
wtRemoveCmd.Flags().BoolVar(&wtDryRun, "dry-run", false, "Preview what would be removed without making changes")
wtRemoveCmd.Flags().BoolVarP(&wtAll, "all", "a", false, "Remove all non-protected worktrees")

// Flags for clone command
wtCloneCmd.Flags().StringVar(&wtUpstream, "upstream", "", "Upstream repository URL (for fork workflow)")
Expand Down Expand Up @@ -335,6 +347,18 @@ func runWorktreeList(wtClient *worktree.Client) error {
}

func runWorktreeRemove(wtClient *worktree.Client, names []string) error {
if wtAll {
resolved, err := resolveAllRemovableWorktrees(wtClient)
if err != nil {
return err
}
if len(resolved) == 0 {
fmt.Fprintln(outWriter(), "No removable worktrees found.")
return nil
}
names = resolved
}

opts := worktree.RemoveOptions{
Force: wtForce,
DeleteBranch: wtDeleteBranch,
Expand All @@ -355,6 +379,30 @@ func runWorktreeRemove(wtClient *worktree.Client, names []string) error {
return nil
}

func resolveAllRemovableWorktrees(wtClient *worktree.Client) ([]string, error) {
all, err := wtClient.List()
if err != nil {
return nil, err
}

pp, err := wtClient.NewProtectionPolicy()
if err != nil {
return nil, err
}
root := getDisplayRoot(wtClient)
var names []string
for _, wt := range all {
if pp.IsProtected(wt) {
continue
}
if isExternalWorktree(root, wt.Path) || isAgentWorktree(wt.Path) {
continue
}
names = append(names, displayWorktreeName(root, wt.Path))
}
return names, nil
}

func runWorktreeClone(wtClient *worktree.Client, url string) error {
opts := worktree.CloneOptions{
Name: wtProjectName,
Expand Down
85 changes: 85 additions & 0 deletions cmd/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,88 @@ func runGitCmd(t *testing.T, dir string, args ...string) string {
var execCommand = func(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}

func TestRemoveAll_SkipsProtected(t *testing.T) {
repoDir := initCmdTestRepo(t)

feat1 := filepath.Join(repoDir, "feat-1")
feat2 := filepath.Join(repoDir, "feat-2")
runGitCmd(t, repoDir, "worktree", "add", "-b", "feat-1", feat1, "main")
runGitCmd(t, repoDir, "worktree", "add", "-b", "feat-2", feat2, "main")

oldCwd, err := os.Getwd()
require.NoError(t, err)
defer func() { _ = os.Chdir(oldCwd) }()
require.NoError(t, os.Chdir(repoDir))

var out bytes.Buffer
oldOut := outWriterFunc
oldErr := errWriterFunc
outWriterFunc = func() io.Writer { return &out }
errWriterFunc = func() io.Writer { return &out }
defer func() {
outWriterFunc = oldOut
errWriterFunc = oldErr
}()

oldAll := wtAll
oldForce := wtForce
oldDelete := wtDeleteBranch
oldDry := wtDryRun
defer func() {
wtAll = oldAll
wtForce = oldForce
wtDeleteBranch = oldDelete
wtDryRun = oldDry
}()

wtAll = true
wtForce = false
wtDeleteBranch = true
wtDryRun = false

client := worktree.NewClient(worktree.Options{})
err = runWorktreeRemove(client, nil)
require.NoError(t, err)

_, err = os.Stat(feat1)
assert.True(t, os.IsNotExist(err), "feat-1 should be removed")
_, err = os.Stat(feat2)
assert.True(t, os.IsNotExist(err), "feat-2 should be removed")

_, err = os.Stat(repoDir)
assert.NoError(t, err, "main worktree (repoDir) must survive --all")

remaining, err := client.List()
require.NoError(t, err)
var mainFound bool
for _, wt := range remaining {
if wt.Branch == "feat-1" || wt.Branch == "feat-2" {
t.Errorf("branch %s should have been deleted", wt.Branch)
}
if wt.Branch == "main" {
mainFound = true
}
}
assert.True(t, mainFound, "main branch worktree must still exist")
}

func TestRemoveAllMutuallyExclusiveWithArgs(t *testing.T) {
oldAll := wtAll
defer func() { wtAll = oldAll }()
wtAll = true

err := wtRemoveCmd.Args(wtRemoveCmd, []string{"some-worktree"})
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}

func TestRemoveRequiresArgsOrAll(t *testing.T) {
oldAll := wtAll
defer func() { wtAll = oldAll }()
wtAll = false

err := wtRemoveCmd.Args(wtRemoveCmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires at least 1 arg")
}
20 changes: 4 additions & 16 deletions internal/worktree/bare.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,23 +129,11 @@ func (c *Client) gitConfig(repoDir string, key string, value string) error {
}

func (c *Client) getDefaultBranch(bareDir string) (string, error) {
args := []string{"-C", bareDir, "symbolic-ref", "--short", "HEAD"}
result, err := c.runner.Run(args...)
if err == nil {
branch := result.StdoutString(true)
if branch != "" {
return branch, nil
}
}

for _, branch := range []string{"main", "master"} {
args := []string{"-C", bareDir, "rev-parse", "--verify", "refs/heads/" + branch}
if _, err := c.runner.Run(args...); err == nil {
return branch, nil
}
branch, err := c.resolveBaseBranchWithPolicy(bareDir, "", true)
if err != nil {
return "", err
}

return "", errors.New("could not determine default branch")
return localBranchName(branch), nil
}

func extractProjectName(repoURL string) (string, error) {
Expand Down
6 changes: 5 additions & 1 deletion internal/worktree/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,13 @@ func (c *Client) collectPruneCandidates(root, baseBranch string, report *Report)
repoDir := repoDirForGit(root)
isBare := repoDir != root

pp, err := c.NewProtectionPolicy()
if err != nil {
return nil, "", err
}
var candidates []pruneCandidate
for _, wt := range worktrees {
if wt.IsBare || filepath.Base(wt.Path) == ".bare" || wt.Path == root {
if pp.IsProtected(wt) {
continue
}
if isBare && isExternalPath(root, wt.Path) {
Expand Down
9 changes: 6 additions & 3 deletions internal/worktree/share_discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,12 @@ func (c *Client) findMainWorktreePath() (string, error) {
return "", fmt.Errorf("failed to list worktrees: %w", err)
}

for _, wt := range worktrees {
if wt.Branch == "main" || wt.Branch == "master" {
return wt.Path, nil
mainBranch, err := c.resolvedMainBranch()
if err == nil && mainBranch != "" {
for _, wt := range worktrees {
if wt.Branch == mainBranch {
return wt.Path, nil
}
}
}

Expand Down
80 changes: 76 additions & 4 deletions internal/worktree/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,12 @@ func (c *Client) prepareRemove(name string) (removeContext, error) {
if !found {
return removeContext{}, fmt.Errorf("worktree not found: %s\nUse 'gmc wt ls' to see available worktrees", name)
}
if wtInfo.IsBare {
return removeContext{}, errors.New("cannot remove the main bare worktree")
pp, err := c.NewProtectionPolicy()
if err != nil {
return removeContext{}, err
}
if pp.IsProtected(wtInfo) {
return removeContext{}, fmt.Errorf("cannot remove protected worktree '%s' (%s)", name, pp.Reason(wtInfo))
}

// Reject agent/external worktrees (outside searchRoot).
Expand Down Expand Up @@ -672,12 +676,10 @@ func (c *Client) Promote(worktreeName, newBranchName string) (Report, error) {

targetPath := filepath.Join(searchRoot, worktreeName)

// Verify worktree exists
if _, err := os.Stat(targetPath); os.IsNotExist(err) {
return report, fmt.Errorf("worktree not found: %s", worktreeName)
}

// Get current branch name
result, err := c.runner.Run("-C", targetPath, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return report, fmt.Errorf("failed to get current branch: %w", err)
Expand All @@ -688,6 +690,15 @@ func (c *Client) Promote(worktreeName, newBranchName string) (Report, error) {
return report, errors.New("worktree is in detached HEAD state, cannot promote")
}

pp, err := c.NewProtectionPolicy()
if err != nil {
return report, err
}
checkWt := Info{Path: targetPath, Branch: oldBranch}
if pp.IsProtected(checkWt) {
return report, fmt.Errorf("cannot promote protected worktree '%s' (%s)", worktreeName, pp.Reason(checkWt))
}

// Rename branch
args := []string{"-C", targetPath, "branch", "-m", newBranchName}
result, err = c.runner.RunLogged(args...)
Expand Down Expand Up @@ -756,6 +767,67 @@ func (c *Client) listGitRefs(errLabel string, gitArgs ...string) ([]string, erro
return strings.Split(output, "\n"), nil
}

type ProtectionPolicy struct {
MainBranch string
RootPath string
}

func (c *Client) NewProtectionPolicy() (ProtectionPolicy, error) {
var p ProtectionPolicy
root, err := c.GetWorktreeRoot()
if err != nil {
return p, fmt.Errorf("failed to get worktree root: %w", err)
}
p.RootPath = root
repoDir := repoDirForGit(root)
isBareLayout := repoDir != root
branch, err := c.resolveBaseBranchWithPolicy(repoDir, "", isBareLayout)
if err != nil {
return p, fmt.Errorf("failed to resolve main branch: %w", err)
}
p.MainBranch = localBranchName(branch)
return p, nil
}

func (p ProtectionPolicy) IsProtected(wt Info) bool {
if wt.IsBare {
return true
}
if p.RootPath != "" && wt.Path == p.RootPath {
return true
}
if p.MainBranch != "" && wt.Branch == p.MainBranch {
return true
}
return false
}

func (p ProtectionPolicy) Reason(wt Info) string {
if wt.IsBare {
return "bare repository"
}
if p.RootPath != "" && wt.Path == p.RootPath {
return "main worktree"
}
return "main branch"
}

func (c *Client) IsProtectedWorktree(wt Info) (bool, error) {
pp, err := c.NewProtectionPolicy()
if err != nil {
return false, err
}
return pp.IsProtected(wt), nil
}

func (c *Client) resolvedMainBranch() (string, error) {
pp, err := c.NewProtectionPolicy()
if err != nil {
return "", err
}
return pp.MainBranch, nil
}

// ListBranches returns all local branch names
func (c *Client) ListBranches() ([]string, error) {
return c.listGitRefs("list branches", "branch", "--format=%(refname:short)")
Expand Down
Loading
Loading