Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,12 @@ wtp add feature/remote-only

# If branch exists in multiple remotes, shows helpful error:
# Error: branch 'feature/shared' exists in multiple remotes: origin, upstream
# Please specify the remote explicitly (e.g., --track origin/feature/shared)
# Create a local branch for the remote you want, then run wtp add again
wtp add feature/shared

# Explicitly specify which remote to track
wtp add -b feature/shared upstream/feature/shared
# Example manual disambiguation:
git switch --track upstream/feature/shared
wtp add feature/shared
```

### Management Commands
Expand Down
54 changes: 9 additions & 45 deletions cmd/wtp/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ import (
wtpio "github.com/satococoa/wtp/v2/internal/io"
)

const addUsageText = "wtp add <existing-branch> [--quiet]\n" +
" wtp add -b <new-branch> [<commit>] [--quiet]"

// NewAddCommand creates the add command definition
func NewAddCommand() *cli.Command {
return &cli.Command{
Name: "add",
Usage: "Create a new worktree",
UsageText: "wtp add <existing-branch>\n" +
" wtp add -b <new-branch> [<commit>]\n" +
" wtp add -b <new-branch> --quiet",
Name: "add",
Usage: "Create a new worktree",
UsageText: addUsageText,
Description: "Creates a new worktree for the specified branch. If the branch doesn't exist locally " +
"but exists on a remote, it will be automatically tracked.\n\n" +
"Examples:\n" +
Expand Down Expand Up @@ -190,7 +191,6 @@ func buildWorktreeCommand(
Branch: cmd.String("branch"),
}

// Use resolved track if provided
if resolvedTrack != "" {
opts.Track = resolvedTrack
}
Expand All @@ -199,11 +199,11 @@ func buildWorktreeCommand(

// Handle different argument patterns based on flags
if resolvedTrack != "" {
// When using resolved tracking, the commitish is the remote branch
// When tracking, the commitish is the remote branch reference.
commitish = resolvedTrack
// If there's an argument, it's the local branch name (not used as commitish)
if cmd.Args().Len() > 0 && opts.Branch == "" {
// The first argument is the branch name when using resolved tracking without -b
// The first argument is the local branch name when using tracking without -b.
opts.Branch = cmd.Args().Get(0)
}
} else if cmd.Args().Len() > 0 {
Expand Down Expand Up @@ -249,13 +249,6 @@ func analyzeGitWorktreeError(workTreePath, branchName string, gitError error, gi
}
}

if isMultipleBranchesError(errorOutput) {
return &MultipleBranchesError{
BranchName: branchName,
GitError: gitError,
}
}

if isInvalidPathError(errorOutput, workTreePath, gitOutput) {
return fmt.Errorf(`failed to create worktree at '%s'

Expand Down Expand Up @@ -307,10 +300,6 @@ func isPathAlreadyExistsError(errorOutput string) bool {
return strings.Contains(errorOutput, "already exists")
}

func isMultipleBranchesError(errorOutput string) bool {
return strings.Contains(errorOutput, "more than one remote") || strings.Contains(errorOutput, "ambiguous")
}

func isInvalidPathError(errorOutput, workTreePath, gitOutput string) bool {
return strings.Contains(errorOutput, "could not create directory") ||
strings.Contains(errorOutput, "unable to create") ||
Expand All @@ -333,7 +322,6 @@ func (e *WorktreeAlreadyExistsError) Error() string {
The branch '%s' is already checked out in another worktree.

Solutions:
• Use '--force' flag to allow multiple checkouts
• Choose a different branch
• Remove the existing worktree first

Expand Down Expand Up @@ -371,29 +359,12 @@ func (e *PathAlreadyExistsError) Error() string {
The target directory already exists and is not empty.

Solutions:
• Use --force flag to overwrite existing directory
• Remove the existing directory
• Use a different branch name

Original error: %v`, e.Path, e.GitError)
}

// MultipleBranchesError reports that a branch name resolves to multiple remotes and needs disambiguation.
type MultipleBranchesError struct {
BranchName string
GitError error
}

func (e *MultipleBranchesError) Error() string {
return fmt.Sprintf(`branch '%s' exists in multiple remotes

Use the --track flag to specify which remote to use:
• wtp add --track origin/%s %s
• wtp add --track upstream/%s %s

Original error: %v`, e.BranchName, e.BranchName, e.BranchName, e.BranchName, e.BranchName, e.GitError)
}

func executePostCreateHooks(w io.Writer, cfg *config.Config, repoPath, workTreePath string) error {
if cfg.HasHooks() {
if _, err := fmt.Fprintln(w, "\nExecuting post-create hooks..."); err != nil {
Expand Down Expand Up @@ -467,7 +438,7 @@ func executePostCreateCommand(

func validateAddInput(cmd *cli.Command) error {
if cmd.Args().Len() == 0 && cmd.String("branch") == "" {
return errors.BranchNameRequired("wtp add <existing-branch> | -b <new-branch> [<commit>]")
return errors.BranchNameRequired(addUsageText)
}

return nil
Expand Down Expand Up @@ -674,13 +645,6 @@ func resolveBranchTracking(
// Check if branch exists locally or in remotes
resolvedBranch, isRemote, err := repo.ResolveBranch(branchName)
if err != nil {
// Check if it's a multiple branches error
if strings.Contains(err.Error(), "exists in multiple remotes") {
return "", &MultipleBranchesError{
BranchName: branchName,
GitError: err,
}
}
return "", err
}

Expand Down
105 changes: 12 additions & 93 deletions cmd/wtp/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ func TestNewAddCommand(t *testing.T) {
assert.NotNil(t, cmd)
assert.Equal(t, "add", cmd.Name)
assert.Equal(t, "Create a new worktree", cmd.Usage)
assert.Equal(t, addUsageText, cmd.UsageText)
assert.NotEmpty(t, cmd.Description)
assert.NotNil(t, cmd.Action)
assert.NotNil(t, cmd.ShellComplete)

// Check simplified flags exist
// Check supported flags exist
flagNames := []string{"branch", "exec", "quiet"}
for _, name := range flagNames {
found := false
Expand Down Expand Up @@ -59,7 +60,6 @@ func TestWorkTreeAlreadyExistsError(t *testing.T) {
// Then: should contain branch name, solutions, and original error
assert.Contains(t, message, "feature/awesome")
assert.Contains(t, message, "already checked out in another worktree")
assert.Contains(t, message, "--force")
assert.Contains(t, message, "Choose a different branch")
assert.Contains(t, message, "Remove the existing worktree")
assert.Contains(t, message, "branch already checked out")
Expand Down Expand Up @@ -133,7 +133,6 @@ func TestPathAlreadyExistsError(t *testing.T) {
// Then: should contain path, solutions, and original error
assert.Contains(t, message, "/existing/path")
assert.Contains(t, message, "already exists and is not empty")
assert.Contains(t, message, "--force flag")
assert.Contains(t, message, "Remove the existing directory")
assert.Contains(t, message, "directory not empty")
})
Expand All @@ -154,43 +153,6 @@ func TestPathAlreadyExistsError(t *testing.T) {
})
}

func TestMultipleBranchesError(t *testing.T) {
t.Run("should format error message with branch name and track suggestions", func(t *testing.T) {
// Given: a MultipleBranchesError with branch name
originalErr := &MockGitError{msg: "multiple remotes found"}
err := &MultipleBranchesError{
BranchName: "feature/shared",
GitError: originalErr,
}

// When: getting error message
message := err.Error()

// Then: should contain branch name, track suggestions, and original error
assert.Contains(t, message, "feature/shared")
assert.Contains(t, message, "exists in multiple remotes")
assert.Contains(t, message, "--track origin/feature/shared")
assert.Contains(t, message, "--track upstream/feature/shared")
assert.Contains(t, message, "multiple remotes found")
})

t.Run("should handle special characters in branch name", func(t *testing.T) {
// Given: error with special characters in branch name
err := &MultipleBranchesError{
BranchName: "feature/fix-bugs-#123",
GitError: &MockGitError{msg: "test error"},
}

// When: getting error message
message := err.Error()

// Then: should properly format all instances of branch name
assert.Contains(t, message, "feature/fix-bugs-#123")
assert.Contains(t, message, "--track origin/feature/fix-bugs-#123")
assert.Contains(t, message, "--track upstream/feature/fix-bugs-#123")
})
}

// Mock error for testing
type MockGitError struct {
msg string
Expand Down Expand Up @@ -276,6 +238,7 @@ func TestValidateAddInput(t *testing.T) {
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), "branch name is required")
assert.Contains(t, err.Error(), addUsageText)
} else {
assert.NoError(t, err)
}
Expand Down Expand Up @@ -626,45 +589,11 @@ func TestAddCommand_InternationalCharacters(t *testing.T) {
// ===== Helper Functions =====

func createTestCLICommand(flags map[string]any, args []string) *cli.Command {
app := &cli.Command{
Name: "test",
Commands: []*cli.Command{
{
Name: "add",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "force"},
&cli.BoolFlag{Name: "detach"},
&cli.StringFlag{Name: "branch"},
&cli.StringFlag{Name: "track"},
&cli.StringFlag{Name: "exec"},
&cli.BoolFlag{Name: "quiet"},
&cli.BoolFlag{Name: "cd"},
&cli.BoolFlag{Name: "no-cd"},
},
Action: func(_ context.Context, _ *cli.Command) error {
return nil
},
},
},
}

cmdArgs := []string{"test", "add"}
for key, value := range flags {
switch v := value.(type) {
case bool:
if v {
cmdArgs = append(cmdArgs, "--"+key)
}
case string:
cmdArgs = append(cmdArgs, "--"+key, v)
}
}
cmdArgs = append(cmdArgs, args...)

ctx := context.Background()
_ = app.Run(ctx, cmdArgs)

return app.Commands[0]
return createTestSubcommand("add", []cli.Flag{
&cli.StringFlag{Name: "branch"},
&cli.StringFlag{Name: "exec"},
&cli.BoolFlag{Name: "quiet"},
}, flags, args)
}

// ===== Integration Tests =====
Expand Down Expand Up @@ -771,14 +700,9 @@ func TestAddCommand_Integration(t *testing.T) {
{
Name: "add",
Flags: []cli.Flag{
&cli.StringFlag{Name: "path"},
&cli.BoolFlag{Name: "force"},
&cli.BoolFlag{Name: "detach"},
&cli.StringFlag{Name: "branch", Aliases: []string{"b"}},
&cli.StringFlag{Name: "track", Aliases: []string{"t"}},
&cli.StringFlag{Name: "exec"},
&cli.BoolFlag{Name: "cd"},
&cli.BoolFlag{Name: "no-cd"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}},
},
Action: addCommand,
},
Expand All @@ -802,14 +726,9 @@ func TestAddCommand_Integration(t *testing.T) {
{
Name: "add",
Flags: []cli.Flag{
&cli.StringFlag{Name: "path"},
&cli.BoolFlag{Name: "force"},
&cli.BoolFlag{Name: "detach"},
&cli.StringFlag{Name: "branch", Aliases: []string{"b"}},
&cli.StringFlag{Name: "track", Aliases: []string{"t"}},
&cli.StringFlag{Name: "exec"},
&cli.BoolFlag{Name: "cd"},
&cli.BoolFlag{Name: "no-cd"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}},
},
Action: addCommand,
},
Expand Down Expand Up @@ -1111,8 +1030,8 @@ func TestAnalyzeGitWorktreeError(t *testing.T) {
workTreePath: "/path/to/worktree",
branchName: "ambiguous-branch",
gitOutput: "fatal: 'ambiguous-branch' matched multiple branches",
expectedError: "",
expectedType: &MultipleBranchesError{},
expectedError: "matched multiple branches",
expectedType: nil,
},
{
name: "invalid path error",
Expand Down
39 changes: 5 additions & 34 deletions cmd/wtp/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -793,40 +793,11 @@ branch refs/heads/test-feature
// ===== Helper Functions =====

func createRemoveTestCLICommand(flags map[string]any, args []string) *cli.Command {
app := &cli.Command{
Name: "test",
Commands: []*cli.Command{
{
Name: "remove",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "force"},
&cli.BoolFlag{Name: "branch"},
&cli.BoolFlag{Name: "force-branch"},
},
Action: func(_ context.Context, _ *cli.Command) error {
return nil
},
},
},
}

cmdArgs := []string{"test", "remove"}
for key, value := range flags {
switch v := value.(type) {
case bool:
if v {
cmdArgs = append(cmdArgs, "--"+key)
}
case string:
cmdArgs = append(cmdArgs, "--"+key, v)
}
}
cmdArgs = append(cmdArgs, args...)

ctx := context.Background()
_ = app.Run(ctx, cmdArgs)

return app.Commands[0]
return createTestSubcommand("remove", []cli.Flag{
&cli.BoolFlag{Name: "force"},
&cli.BoolFlag{Name: "branch"},
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createRemoveTestCLICommand defines a branch flag, but the actual remove CLI flag is named with-branch (see NewRemoveCommand in cmd/wtp/remove.go). Using a non-existent flag name makes the test helper diverge from the real CLI surface; consider renaming this flag to with-branch and updating any test inputs that set flags["branch"] accordingly.

Suggested change
&cli.BoolFlag{Name: "branch"},
&cli.BoolFlag{Name: "with-branch"},

Copilot uses AI. Check for mistakes.
&cli.BoolFlag{Name: "force-branch"},
}, flags, args)
}

// ===== Mock Implementations =====
Expand Down
Loading
Loading