From 1a3cd25a17b7f9cac2ff077f899c3aaa511e0c6b Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Mon, 13 Jul 2026 18:18:56 -0500 Subject: [PATCH] Update remove command to make name argument optional --- README.md | 6 +- internal/gitwt/gitwt_remove.go | 39 +++++++++-- internal/gitwt/gitwt_test.go | 118 ++++++++++++++++++++++++++++++++- internal/gitwt/repository.go | 18 +++++ internal/gitwt/worktree.go | 11 +++ 5 files changed, 184 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0eaf900..b88c69a 100644 --- a/README.md +++ b/README.md @@ -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 ` +### `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 ``` diff --git a/internal/gitwt/gitwt_remove.go b/internal/gitwt/gitwt_remove.go index 96cfc53..308513a 100644 --- a/internal/gitwt/gitwt_remove.go +++ b/internal/gitwt/gitwt_remove.go @@ -3,6 +3,7 @@ package gitwt import ( "fmt" "os" + "path/filepath" "strings" "github.com/samber/lo" @@ -17,9 +18,9 @@ func NewRemoveCommand() *cobra.Command { options := &removeCommandOptions{} command := &cobra.Command{ - Use: "remove [-f|--force] ", + Use: "remove [-f|--force] [name]", Short: "Remove a managed Git worktree", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: options.Execute, ValidArgsFunction: completeManagedWorktreeNames, } @@ -52,7 +53,11 @@ 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 { @@ -60,16 +65,40 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name strin 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 { diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index 154285c..87fb53f 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -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" @@ -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) @@ -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() { diff --git a/internal/gitwt/repository.go b/internal/gitwt/repository.go index 7ed48b8..dc0d9e5 100644 --- a/internal/gitwt/repository.go +++ b/internal/gitwt/repository.go @@ -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 { diff --git a/internal/gitwt/worktree.go b/internal/gitwt/worktree.go index 95fd208..3329efc 100644 --- a/internal/gitwt/worktree.go +++ b/internal/gitwt/worktree.go @@ -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") +}