Skip to content

Commit fbfb54c

Browse files
authored
Merge pull request #33 from satococoa/codex/design-wtp-remove-behavior-for-current-wotktree
fix: prevent removing current worktree
2 parents 33d4630 + 3c016d7 commit fbfb54c

File tree

7 files changed

+155
-118
lines changed

7 files changed

+155
-118
lines changed

cmd/wtp/cd.go

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,45 +20,7 @@ import (
2020

2121
// isWorktreeManagedCd determines if a worktree is managed by wtp (for cd command)
2222
func isWorktreeManagedCd(worktreePath string, cfg *config.Config, mainRepoPath string, isMain bool) bool {
23-
// Main worktree is always managed
24-
if isMain {
25-
return true
26-
}
27-
28-
// Get base directory - use default config if config is not available
29-
if cfg == nil {
30-
// Create default config when none is available
31-
defaultCfg := &config.Config{
32-
Defaults: config.Defaults{
33-
BaseDir: "../worktrees",
34-
},
35-
}
36-
cfg = defaultCfg
37-
}
38-
39-
baseDir := cfg.ResolveWorktreePath(mainRepoPath, "")
40-
// Remove trailing slash if it exists
41-
baseDir = strings.TrimSuffix(baseDir, "/")
42-
43-
// Check if worktree path is under base directory
44-
absWorktreePath, err := filepath.Abs(worktreePath)
45-
if err != nil {
46-
return false
47-
}
48-
49-
absBaseDir, err := filepath.Abs(baseDir)
50-
if err != nil {
51-
return false
52-
}
53-
54-
// Check if worktree is within base directory
55-
relPath, err := filepath.Rel(absBaseDir, absWorktreePath)
56-
if err != nil {
57-
return false
58-
}
59-
60-
// If relative path starts with "..", it's outside base directory
61-
return !strings.HasPrefix(relPath, "..")
23+
return isWorktreeManagedCommon(worktreePath, cfg, mainRepoPath, isMain)
6224
}
6325

6426
// NewCdCommand creates the cd command definition

cmd/wtp/list.go

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -163,45 +163,7 @@ func parseWorktreesFromOutput(output string) []git.Worktree {
163163

164164
// isWorktreeManagedList determines if a worktree is managed by wtp (for list command)
165165
func isWorktreeManagedList(worktreePath string, cfg *config.Config, mainRepoPath string, isMain bool) bool {
166-
// Main worktree is always managed
167-
if isMain {
168-
return true
169-
}
170-
171-
// Get base directory - use default config if config is not available
172-
if cfg == nil {
173-
// Create default config when none is available
174-
defaultCfg := &config.Config{
175-
Defaults: config.Defaults{
176-
BaseDir: "../worktrees",
177-
},
178-
}
179-
cfg = defaultCfg
180-
}
181-
182-
baseDir := cfg.ResolveWorktreePath(mainRepoPath, "")
183-
// Remove trailing slash if it exists
184-
baseDir = strings.TrimSuffix(baseDir, "/")
185-
186-
// Check if worktree path is under base directory
187-
absWorktreePath, err := filepath.Abs(worktreePath)
188-
if err != nil {
189-
return false
190-
}
191-
192-
absBaseDir, err := filepath.Abs(baseDir)
193-
if err != nil {
194-
return false
195-
}
196-
197-
// Check if worktree is within base directory
198-
relPath, err := filepath.Rel(absBaseDir, absWorktreePath)
199-
if err != nil {
200-
return false
201-
}
202-
203-
// If relative path starts with "..", it's outside base directory
204-
return !strings.HasPrefix(relPath, "..")
166+
return isWorktreeManagedCommon(worktreePath, cfg, mainRepoPath, isMain)
205167
}
206168

207169
// formatBranchDisplay formats branch name for display, following Git conventions

cmd/wtp/remove.go

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,45 +23,7 @@ var removeGetwd = os.Getwd
2323

2424
// isWorktreeManaged determines if a worktree is managed by wtp
2525
func isWorktreeManaged(worktreePath string, cfg *config.Config, mainRepoPath string, isMain bool) bool {
26-
// Main worktree is always managed
27-
if isMain {
28-
return true
29-
}
30-
31-
// Get base directory - use default config if config is not available
32-
if cfg == nil {
33-
// Create default config when none is available
34-
defaultCfg := &config.Config{
35-
Defaults: config.Defaults{
36-
BaseDir: "../worktrees",
37-
},
38-
}
39-
cfg = defaultCfg
40-
}
41-
42-
baseDir := cfg.ResolveWorktreePath(mainRepoPath, "")
43-
// Remove trailing slash if it exists
44-
baseDir = strings.TrimSuffix(baseDir, "/")
45-
46-
// Check if worktree path is under base directory
47-
absWorktreePath, err := filepath.Abs(worktreePath)
48-
if err != nil {
49-
return false
50-
}
51-
52-
absBaseDir, err := filepath.Abs(baseDir)
53-
if err != nil {
54-
return false
55-
}
56-
57-
// Check if worktree is within base directory
58-
relPath, err := filepath.Rel(absBaseDir, absWorktreePath)
59-
if err != nil {
60-
return false
61-
}
62-
63-
// If relative path starts with "..", it's outside base directory
64-
return !strings.HasPrefix(relPath, "..")
26+
return isWorktreeManagedCommon(worktreePath, cfg, mainRepoPath, isMain)
6527
}
6628

6729
// NewRemoveCommand creates the remove command definition
@@ -134,7 +96,7 @@ func removeCommandWithCommandExecutor(
13496
_ *cli.Command,
13597
w io.Writer,
13698
executor command.Executor,
137-
_ string,
99+
cwd string,
138100
worktreeName string,
139101
force, withBranch, forceBranch bool,
140102
) error {
@@ -154,6 +116,20 @@ func removeCommandWithCommandExecutor(
154116
return err
155117
}
156118

119+
absTargetPath, err := filepath.Abs(targetWorktree.Path)
120+
if err != nil {
121+
return errors.WorktreeRemovalFailed(targetWorktree.Path, err)
122+
}
123+
124+
absCwd, err := filepath.Abs(cwd)
125+
if err != nil {
126+
return errors.DirectoryAccessFailed("access current", cwd, err)
127+
}
128+
129+
if isPathWithin(absTargetPath, absCwd) {
130+
return errors.CannotRemoveCurrentWorktree(worktreeName, absTargetPath)
131+
}
132+
157133
// Remove worktree using CommandExecutor
158134
removeCmd := command.GitWorktreeRemove(targetWorktree.Path, force)
159135
result, err = executor.Execute([]command.Command{removeCmd})
@@ -190,6 +166,23 @@ func validateRemoveInput(worktreeName string, withBranch, forceBranch bool) erro
190166
return nil
191167
}
192168

169+
func isPathWithin(basePath, targetPath string) bool {
170+
rel, err := filepath.Rel(basePath, targetPath)
171+
if err != nil {
172+
return false
173+
}
174+
175+
if rel == "." || rel == "" {
176+
return true
177+
}
178+
179+
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
180+
return false
181+
}
182+
183+
return true
184+
}
185+
193186
func removeBranchWithCommandExecutor(
194187
w io.Writer,
195188
executor command.Executor,

cmd/wtp/remove_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"context"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"testing"
@@ -390,6 +391,56 @@ func TestRemoveCommand_WorktreeNotFound_ShowsConsistentNames(t *testing.T) {
390391
assert.Contains(t, err.Error(), "No worktrees found")
391392
}
392393

394+
func TestRemoveCommand_FailsWhenRemovingCurrentWorktree(t *testing.T) {
395+
targetPath := "/worktrees/feature/foo"
396+
mockWorktreeList := fmt.Sprintf(
397+
"worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n"+
398+
"worktree %s\nHEAD def456\nbranch refs/heads/feature/foo\n\n",
399+
targetPath,
400+
)
401+
402+
tests := []struct {
403+
name string
404+
cwd string
405+
}{
406+
{
407+
name: "exact worktree path",
408+
cwd: targetPath,
409+
},
410+
{
411+
name: "nested directory inside worktree",
412+
cwd: filepath.Join(targetPath, "nested"),
413+
},
414+
}
415+
416+
for _, tt := range tests {
417+
t.Run(tt.name, func(t *testing.T) {
418+
mockExec := &mockRemoveCommandExecutor{
419+
results: []command.Result{
420+
{
421+
Output: mockWorktreeList,
422+
Error: nil,
423+
},
424+
},
425+
}
426+
427+
cmd := createRemoveTestCLICommand(map[string]any{}, []string{"feature/foo"})
428+
var buf bytes.Buffer
429+
430+
err := removeCommandWithCommandExecutor(cmd, &buf, mockExec, tt.cwd, "feature/foo", false, false, false)
431+
432+
assert.Error(t, err)
433+
assert.Contains(t, err.Error(), "cannot remove worktree 'feature/foo'")
434+
assert.Equal(t, []command.Command{
435+
{
436+
Name: "git",
437+
Args: []string{"worktree", "list", "--porcelain"},
438+
},
439+
}, mockExec.executedCommands)
440+
})
441+
}
442+
}
443+
393444
func TestRemoveCommand_ExecutionError(t *testing.T) {
394445
mockExec := &mockRemoveCommandExecutor{
395446
results: []command.Result{

cmd/wtp/worktree_managed.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package main
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
7+
"github.com/satococoa/wtp/internal/config"
8+
)
9+
10+
// isWorktreeManagedCommon determines whether a worktree path is considered managed by wtp.
11+
// The logic is shared across multiple commands so that we consistently classify worktrees.
12+
func isWorktreeManagedCommon(worktreePath string, cfg *config.Config, mainRepoPath string, isMain bool) bool {
13+
if isMain {
14+
return true
15+
}
16+
17+
// Fallback to default configuration if none is provided
18+
if cfg == nil {
19+
cfg = &config.Config{
20+
Defaults: config.Defaults{
21+
BaseDir: config.DefaultBaseDir,
22+
},
23+
}
24+
}
25+
26+
baseDir := cfg.ResolveWorktreePath(mainRepoPath, "")
27+
baseDir = strings.TrimSuffix(baseDir, string(filepath.Separator))
28+
29+
absWorktreePath, err := filepath.Abs(worktreePath)
30+
if err != nil {
31+
return false
32+
}
33+
34+
absBaseDir, err := filepath.Abs(baseDir)
35+
if err != nil {
36+
return false
37+
}
38+
39+
relPath, err := filepath.Rel(absBaseDir, absWorktreePath)
40+
if err != nil {
41+
return false
42+
}
43+
44+
if relPath == "." || relPath == "" {
45+
return true
46+
}
47+
48+
if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
49+
return false
50+
}
51+
52+
return true
53+
}

internal/errors/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ func WorktreeRemovalFailed(path string, gitError error) error {
165165
return errors.New(msg)
166166
}
167167

168+
func CannotRemoveCurrentWorktree(worktreeName, path string) error {
169+
msg := fmt.Sprintf("cannot remove worktree '%s' while you are currently inside it", worktreeName)
170+
msg += fmt.Sprintf("\n\nCurrent location: %s", path)
171+
msg += "\n\nTip: Run 'wtp cd @' or 'wtp cd <another-worktree>' to switch before removing."
172+
return errors.New(msg)
173+
}
174+
168175
func BranchRemovalFailed(branchName string, gitError error, isForced bool) error {
169176
msg := fmt.Sprintf("failed to remove branch '%s'", branchName)
170177

internal/errors/errors_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ func TestWorktreeRemovalFailed(t *testing.T) {
211211
assert.Contains(t, err.Error(), "Original error:")
212212
}
213213

214+
func TestCannotRemoveCurrentWorktree(t *testing.T) {
215+
err := CannotRemoveCurrentWorktree("feature/foo", "/repo/.worktrees/feature/foo")
216+
217+
assert.Error(t, err)
218+
assert.Contains(t, err.Error(), "cannot remove worktree 'feature/foo'")
219+
assert.Contains(t, err.Error(), "Current location: /repo/.worktrees/feature/foo")
220+
assert.Contains(t, err.Error(), "wtp cd @")
221+
}
222+
214223
func TestBranchRemovalFailed(t *testing.T) {
215224
tests := []struct {
216225
name string

0 commit comments

Comments
 (0)