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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,18 @@ Remove managed worktrees that are both clean, no uncommitted changes, and merged

Use `--prompt` | `-p` to choose which worktrees to prune interactively.

### `git-wt remove <name>`
### `git-wt remove [name]`

Remove a managed worktree and delete its branch.

It refuses to remove dirty or unmerged worktrees by default.
When `name` is omitted, removes the managed worktree that contains the current directory.
It refuses to remove the main worktree, and refuses dirty or unmerged worktrees by default.
Use `--force` | `-f` to force (destructive) removal.

Example:

```bash
git-wt remove
git-wt remove feature/login
git-wt remove --force feature/login
```
Expand Down
39 changes: 34 additions & 5 deletions internal/gitwt/gitwt_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gitwt
import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/samber/lo"
Expand All @@ -17,9 +18,9 @@ func NewRemoveCommand() *cobra.Command {
options := &removeCommandOptions{}

command := &cobra.Command{
Use: "remove [-f|--force] <name>",
Use: "remove [-f|--force] [name]",
Short: "Remove a managed Git worktree",
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
RunE: options.Execute,
ValidArgsFunction: completeManagedWorktreeNames,
}
Expand Down Expand Up @@ -52,24 +53,52 @@ func completeManagedWorktreeNames(command *cobra.Command, args []string, toCompl
}

func (x *removeCommandOptions) Execute(command *cobra.Command, args []string) error {
return x.removeWorktree(command, args[0], x.force)
var name string
if len(args) == 1 {
name = args[0]
}
return x.removeWorktree(command, name, x.force)
}

func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name string, force bool) error {
repository, err := PlainOpenWithOptions(".")
if err != nil {
return err
}
currentWorkTree := repository.WorkTree

worktrees, _, err := managedWorktreesFromRepository(repository)
worktrees, mainPath, err := managedWorktreesFromRepository(repository)
if err != nil {
return err
}

worktree, err := managedWorktreeByName(worktrees, name)
if filepath.Clean(currentWorkTree) != filepath.Clean(mainPath) {
repository, err = PlainOpenWithOptions(mainPath)
if err != nil {
return err
}
}

var worktree managedWorktree
if name == "" {
if filepath.Clean(currentWorkTree) == filepath.Clean(mainPath) {
return fmt.Errorf("cannot remove main worktree")
}
worktree, err = managedWorktreeForPath(worktrees, currentWorkTree)
} else {
mainBranch, mainBranchErr := repository.mainWorktreeBranch()
if mainBranchErr != nil {
return mainBranchErr
}
if name == mainBranch {
return fmt.Errorf("cannot remove main worktree")
}
worktree, err = managedWorktreeByName(worktrees, name)
}
if err != nil {
return err
}
name = worktree.Name

worktree, err = enrichManagedWorktree(repository, worktree)
if err != nil {
Expand Down
118 changes: 117 additions & 1 deletion internal/gitwt/gitwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,117 @@ func TestRemoveFailsWhenDirtyWithoutForce(t *testing.T) {
}
}

func TestRemoveWithNoArgsRemovesCurrentWorktree(t *testing.T) {
const branchName = "feature/current"

testRepository := newTestRepository(t)
testRepository.runGitWT(t, "create", branchName)
testRepository.mergeWorktreeBranch(t, branchName)
mergedCommitHash := strings.TrimSpace(runGitCommand(t, testRepository.mainPath, "rev-parse", "--short=7", branchName))

result := testRepository.runGitWTFrom(t, testRepository.worktreePath(branchName), "remove")
if result.err != nil {
t.Fatalf("remove failed: %v\n%s", result.err, result.stderr)
}
if !strings.Contains(result.stderr, mergedCommitHash) {
t.Fatalf("remove output missing commit hash %s: %s", mergedCommitHash, result.stderr)
}

testRepository.assertBranchMissing(t, branchName)
testRepository.assertPathMissing(t, testRepository.worktreePath(branchName))
}

func TestRemoveWithNoArgsFromSubdirectoryRemovesCurrentWorktree(t *testing.T) {
const branchName = "feature/subdir"
const subDirectoryName = "nested"

testRepository := newTestRepository(t)
testRepository.runGitWT(t, "create", branchName)
testRepository.mergeWorktreeBranch(t, branchName)

worktreePath := testRepository.worktreePath(branchName)
subDirectoryPath := filepath.Join(worktreePath, subDirectoryName)
if err := os.MkdirAll(subDirectoryPath, 0o755); err != nil {
t.Fatalf("create subdirectory: %v", err)
}

result := testRepository.runGitWTFrom(t, subDirectoryPath, "remove")
if result.err != nil {
t.Fatalf("remove failed: %v\n%s", result.err, result.stderr)
}

testRepository.assertBranchMissing(t, branchName)
testRepository.assertPathMissing(t, worktreePath)
}

func TestRemoveWithNoArgsFailsFromMain(t *testing.T) {
testRepository := newTestRepository(t)

result := testRepository.runGitWT(t, "remove")
if result.err == nil {
t.Fatal("expected remove to fail from main worktree")
}
if !strings.Contains(result.err.Error(), "cannot remove main worktree") {
t.Fatalf("expected main worktree error, got: %v", result.err)
}
}

func TestRemoveFailsForMainWorktreeByName(t *testing.T) {
testRepository := newTestRepository(t)

result := testRepository.runGitWT(t, "remove", "main")
if result.err == nil {
t.Fatal("expected remove to fail for main worktree")
}
if !strings.Contains(result.err.Error(), "cannot remove main worktree") {
t.Fatalf("expected main worktree error, got: %v", result.err)
}
testRepository.assertPathPresent(t, testRepository.mainPath)
testRepository.assertBranchPresent(t, "main")
}

func TestRemoveWithNoArgsFailsWhenDirtyWithoutForce(t *testing.T) {
const branchName = "feature/dirty-current"
const dirtyFileName = "dirty.txt"
const dirtyFileContents = "dirty"

testRepository := newTestRepository(t)
testRepository.runGitWT(t, "create", branchName)
worktreePath := testRepository.worktreePath(branchName)
testRepository.writeFile(t, filepath.Join(worktreePath, dirtyFileName), dirtyFileContents)

result := testRepository.runGitWTFrom(t, worktreePath, "remove")
if result.err == nil {
t.Fatal("expected remove to fail for dirty worktree")
}
testRepository.assertPathPresent(t, worktreePath)
testRepository.assertBranchPresent(t, branchName)
}

func TestRemoveWithNoArgsForceRemovesDirtyUnmergedWorktree(t *testing.T) {
const branchName = "feature/force-current"
const workFileName = "work.txt"
const workFileContents = "change"
const dirtyFileName = "dirty.txt"
const dirtyFileContents = "dirty"

testRepository := newTestRepository(t)
testRepository.runGitWT(t, "create", branchName)
worktreePath := testRepository.worktreePath(branchName)
t.Chdir(worktreePath)
testRepository.commitFileInWorktree(t, workFileName, workFileContents)
testRepository.writeFile(t, dirtyFileName, dirtyFileContents)
t.Chdir(testRepository.mainPath)

result := testRepository.runGitWTFrom(t, worktreePath, "remove", "--force")
if result.err != nil {
t.Fatalf("force remove failed: %v\n%s", result.err, result.stderr)
}

testRepository.assertBranchMissing(t, branchName)
testRepository.assertPathMissing(t, worktreePath)
}

func TestRemoveFailsWhenUnmergedWithoutForce(t *testing.T) {
const branchName = "feature/unmerged"
const workFileName = "work.txt"
Expand Down Expand Up @@ -460,6 +571,11 @@ func (x testRepository) createLocalBranch(t *testing.T, branchName string) {

func (x testRepository) runGitWT(t *testing.T, args ...string) commandResult {
t.Helper()
return x.runGitWTFrom(t, x.mainPath, args...)
}

func (x testRepository) runGitWTFrom(t *testing.T, directory string, args ...string) commandResult {
t.Helper()

command := NewRootCommand()
command.SetArgs(args)
Expand All @@ -474,7 +590,7 @@ func (x testRepository) runGitWT(t *testing.T, args ...string) commandResult {
if err != nil {
t.Fatalf("get current directory: %v", err)
}
if err := os.Chdir(x.mainPath); err != nil {
if err := os.Chdir(directory); err != nil {
t.Fatalf("change directory: %v", err)
}
defer func() {
Expand Down
18 changes: 18 additions & 0 deletions internal/gitwt/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,24 @@ func (x *Repository) mainWorktreePath() (string, error) {
return worktrees[0].Path, nil
}

func (x *Repository) mainWorktreeBranch() (string, error) {
worktrees, err := x.listPorcelainWorktrees()
if err != nil {
return "", err
}

if len(worktrees) == 0 {
return "", errors.New("no worktrees found")
}

branchName := worktrees[0].branchName()
if branchName == "" {
return "", errors.New("main worktree is not on a branch")
}

return branchName, nil
}

func (x *Repository) remoteHeadBranch() (string, error) {
remoteHeadRef, err := x.Reference(plumbing.NewRemoteHEADReferenceName(remoteName), false)
if err == nil && remoteHeadRef.Type() == plumbing.SymbolicReference {
Expand Down
11 changes: 11 additions & 0 deletions internal/gitwt/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,14 @@ func managedWorktreeByName(worktrees []managedWorktree, name string) (managedWor

return managedWorktree{}, fmt.Errorf("unknown worktree %q", name)
}

func managedWorktreeForPath(worktrees []managedWorktree, path string) (managedWorktree, error) {
cleanedPath := filepath.Clean(path)
for _, worktree := range worktrees {
if filepath.Clean(worktree.Path) == cleanedPath {
return worktree, nil
}
}

return managedWorktree{}, fmt.Errorf("not inside a managed worktree")
}