Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ wtp add -b hotfix/urgent abc1234
# → Useful for bootstrap steps (supports interactive commands when TTY is available)
wtp add -b feature/new-feature --exec "npm test"

# Script-friendly output: print only the created absolute path
wtp add -b feature/new-feature --quiet

# Create new branch tracking a different remote branch
# → Creates worktree at ../worktrees/feature/test with branch tracking origin/main
wtp add -b feature/test origin/main
Expand Down
65 changes: 52 additions & 13 deletions cmd/wtp/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@
return &cli.Command{
Name: "add",
Usage: "Create a new worktree",
UsageText: "wtp add <existing-branch>\n wtp add -b <new-branch> [<commit>]",
UsageText: "wtp add <existing-branch>\n wtp add -b <new-branch> [<commit>]\n wtp add -b <new-branch> --quiet",

Check failure on line 31 in cmd/wtp/add.go

View workflow job for this annotation

GitHub Actions / Lint

The line is 124 characters long, which exceeds the maximum of 120 characters. (lll)
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" +
" wtp add feature/auth # Create worktree from existing branch\n" +
" wtp add -b new-feature # Create new branch and worktree\n" +
" wtp add -b hotfix/urgent main # Create new branch from main commit\n" +
" wtp add -b feature/x --quiet # Output only the created path\n" +
" wtp add -b feature/x --exec \"npm test\" # Execute command in the new worktree",
ShellComplete: completeBranches,
Flags: []cli.Flag{
Expand All @@ -47,19 +48,22 @@
Name: "exec",
Usage: "Execute command in newly created worktree after hooks",
},
&cli.BoolFlag{
Name: "quiet",
Aliases: []string{"q"},
Usage: "Output only worktree path to stdout",
},
},
Action: addCommand,
}
}

func addCommand(_ context.Context, cmd *cli.Command) error {
// Get the writer from cli.Command
w := cmd.Root().Writer
if w == nil {
w = os.Stdout
}
// Wrap in FlushingWriter to ensure real-time output for all operations
fw := wtpio.NewFlushingWriter(w)
stdoutWriter, statusWriter := resolveAddWriters(cmd)
// Wrap in FlushingWriter to ensure real-time output for all operations.
stdoutWriter = wtpio.NewFlushingWriter(stdoutWriter)
statusWriter = wtpio.NewFlushingWriter(statusWriter)

// Validate inputs
if err := validateAddInput(cmd); err != nil {
return err
Expand All @@ -74,12 +78,23 @@
// Create command executor
executor := command.NewRealExecutor()

return addCommandWithCommandExecutor(cmd, fw, executor, cfg, mainRepoPath)
return addCommandWithCommandExecutorWithWriters(cmd, stdoutWriter, statusWriter, executor, cfg, mainRepoPath)
}

// addCommandWithCommandExecutor is the new implementation using CommandExecutor
func addCommandWithCommandExecutor(
cmd *cli.Command, w io.Writer, cmdExec command.Executor, cfg *config.Config, mainRepoPath string,

Check failure on line 86 in cmd/wtp/add.go

View workflow job for this annotation

GitHub Actions / Lint

addCommandWithCommandExecutor - mainRepoPath always receives "/test/repo" (unparam)
) error {
return addCommandWithCommandExecutorWithWriters(cmd, w, w, cmdExec, cfg, mainRepoPath)
}

func addCommandWithCommandExecutorWithWriters(
cmd *cli.Command,
stdoutWriter io.Writer,
statusWriter io.Writer,
cmdExec command.Executor,
cfg *config.Config,
mainRepoPath string,
) error {
// Resolve worktree path and branch name
var firstArg string
Expand Down Expand Up @@ -113,23 +128,47 @@
return analyzeGitWorktreeError(workTreePath, branchName, gitError, gitOutput)
}

if err := executePostCreateHooks(w, cfg, mainRepoPath, workTreePath); err != nil {
if _, warnErr := fmt.Fprintf(w, "Warning: Hook execution failed: %v\n", err); warnErr != nil {
if err := executePostCreateHooks(statusWriter, cfg, mainRepoPath, workTreePath); err != nil {
if _, warnErr := fmt.Fprintf(statusWriter, "Warning: Hook execution failed: %v\n", err); warnErr != nil {
return warnErr
}
}

if err := executePostCreateCommand(w, cmdExec, cmd.String("exec"), workTreePath); err != nil {
if err := executePostCreateCommand(statusWriter, cmdExec, cmd.String("exec"), workTreePath); err != nil {
return fmt.Errorf("worktree was created at '%s', but --exec command failed: %w", workTreePath, err)
}

if err := displaySuccessMessage(w, branchName, workTreePath, cfg, mainRepoPath); err != nil {
if cmd.Bool("quiet") {
if _, err := fmt.Fprintln(stdoutWriter, workTreePath); err != nil {
return err
}
return nil
}

if err := displaySuccessMessage(stdoutWriter, branchName, workTreePath, cfg, mainRepoPath); err != nil {
return err
}

return nil
}

func resolveAddWriters(cmd *cli.Command) (io.Writer, io.Writer) {

Check failure on line 155 in cmd/wtp/add.go

View workflow job for this annotation

GitHub Actions / Lint

unnamedResult: consider giving a name to these results (gocritic)
stdoutWriter := cmd.Root().Writer
if stdoutWriter == nil {
stdoutWriter = os.Stdout
}

statusWriter := stdoutWriter
if cmd.Bool("quiet") {
statusWriter = cmd.Root().ErrWriter
if statusWriter == nil {
statusWriter = os.Stderr
}
}

return stdoutWriter, statusWriter
}

// buildWorktreeCommand builds a git worktree command using the new command package
func buildWorktreeCommand(
cmd *cli.Command, workTreePath, _, resolvedTrack string,
Expand Down
94 changes: 93 additions & 1 deletion cmd/wtp/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"runtime"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -27,7 +28,7 @@ func TestNewAddCommand(t *testing.T) {
assert.NotNil(t, cmd.ShellComplete)

// Check simplified flags exist
flagNames := []string{"branch", "exec"}
flagNames := []string{"branch", "exec", "quiet"}
for _, name := range flagNames {
found := false
for _, flag := range cmd.Flags {
Expand Down Expand Up @@ -422,6 +423,96 @@ func TestAddCommand_SuccessMessage(t *testing.T) {
}
}

func TestAddCommand_QuietModeOutput(t *testing.T) {
t.Run("success should print only path to stdout", func(t *testing.T) {
cmd := createTestCLICommand(map[string]any{
"branch": "feature/quiet",
"quiet": true,
}, []string{})
mockExec := &mockCommandExecutor{}
cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "/test/worktrees"},
}

var stdout bytes.Buffer
var stderr bytes.Buffer
err := addCommandWithCommandExecutorWithWriters(cmd, &stdout, &stderr, mockExec, cfg, "/test/repo")

require.NoError(t, err)
assert.Equal(t, "/test/worktrees/feature/quiet", strings.TrimSpace(stdout.String()))
assert.Empty(t, stderr.String())
})

t.Run("hook failure should keep path on stdout and warnings on stderr", func(t *testing.T) {
cmd := createTestCLICommand(map[string]any{
"branch": "feature/hook-fail",
"quiet": true,
}, []string{})
mockExec := &mockCommandExecutor{}
cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "/test/worktrees"},
Hooks: config.Hooks{
PostCreate: []config.Hook{
{Type: "command", Command: "nonexistent-command-xyz test"},
},
},
}

var stdout bytes.Buffer
var stderr bytes.Buffer
err := addCommandWithCommandExecutorWithWriters(cmd, &stdout, &stderr, mockExec, cfg, "/test/repo")

require.NoError(t, err)
assert.Equal(t, "/test/worktrees/feature/hook-fail", strings.TrimSpace(stdout.String()))
assert.Contains(t, stderr.String(), "Warning: Hook execution failed")
})

t.Run("exec output should go to stderr and path to stdout", func(t *testing.T) {
cmd := createTestCLICommand(map[string]any{
"branch": "feature/exec",
"quiet": true,
"exec": "echo hi",
}, []string{})
exec := &sequencedCommandExecutor{
results: []command.Result{
{Output: "worktree created"},
{Output: "exec output"},
},
}
cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "/test/worktrees"},
}

var stdout bytes.Buffer
var stderr bytes.Buffer
err := addCommandWithCommandExecutorWithWriters(cmd, &stdout, &stderr, exec, cfg, "/test/repo")

require.NoError(t, err)
assert.Equal(t, "/test/worktrees/feature/exec", strings.TrimSpace(stdout.String()))
assert.Contains(t, stderr.String(), "Executing --exec command: echo hi")
assert.Contains(t, stderr.String(), "exec output")
assert.Contains(t, stderr.String(), "✓ --exec command completed")
})

t.Run("worktree creation failure should not print path", func(t *testing.T) {
cmd := createTestCLICommand(map[string]any{
"branch": "feature/fail",
"quiet": true,
}, []string{})
mockExec := &mockCommandExecutor{shouldFail: true}
cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "/test/worktrees"},
}

var stdout bytes.Buffer
var stderr bytes.Buffer
err := addCommandWithCommandExecutorWithWriters(cmd, &stdout, &stderr, mockExec, cfg, "/test/repo")

require.Error(t, err)
assert.Empty(t, stdout.String())
})
}

// ===== Error Handling Tests =====

func TestAddCommand_ValidationErrors(t *testing.T) {
Expand Down Expand Up @@ -544,6 +635,7 @@ func createTestCLICommand(flags map[string]any, args []string) *cli.Command {
&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"},
},
Expand Down
31 changes: 31 additions & 0 deletions test/e2e/worktree_creation_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package e2e

import (
"path/filepath"
"strings"
"testing"

"github.com/satococoa/wtp/v2/test/e2e/framework"
Expand Down Expand Up @@ -66,6 +68,35 @@ func TestUserCreatesWorktree_WithNewBranchFlag_ShouldCreateBranchAndWorktree(t *
framework.AssertWorktreeExists(t, repo, "feature/payment")
}

Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

E2E tests in this codebase follow a specific documentation pattern with User Story and Business Value comments. This test should include a full header comment similar to other tests in this file, explaining:

  1. What the test validates
  2. User Story: describing the user's need
  3. Business Value: explaining the benefit

For example:
// TestUserCreatesWorktree_WithQuietFlag_ShouldOutputPathOnly tests
// script integration with machine-readable output.
//
// User Story: As a developer integrating wtp with automation tools, I want
// machine-readable output so scripts can reliably parse the worktree path.
//
// Business Value: Enables integration with external tools like Claude Code hooks,
// CI/CD pipelines, and other automation that needs to process the worktree path.

Suggested change
// TestUserCreatesWorktree_WithQuietFlag_ShouldOutputPathOnly tests
// script integration with machine-readable output from quiet mode.
//
// User Story: As a developer integrating wtp with automation tools, I want
// quiet mode to output only the worktree path so scripts can reliably parse
// and consume it without additional text processing.
//
// Business Value: Enables robust integration with external tools like CI/CD
// pipelines, editor extensions, and other automation that depend on stable,
// machine-readable output from wtp commands.

Copilot uses AI. Check for mistakes.
func TestUserCreatesWorktree_WithQuietFlag_ShouldOutputPathOnly(t *testing.T) {
env := framework.NewTestEnvironment(t)
defer env.Cleanup()

repo := env.CreateTestRepo("user-creates-quiet-worktree")

output, err := repo.RunWTP("add", "--branch", "feature/quiet", "--quiet")
Comment on lines +72 to +77
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

E2E tests in this codebase use Given-When-Then comments to structure the test narrative. The test should include:

  • Given: Initial conditions (User wants machine-readable output, User is in a git repository)
  • When: User action (User runs "wtp add --branch feature/quiet --quiet")
  • Then: Expected outcome assertions (Output should contain only the absolute path)

This pattern is consistently followed in all other E2E tests in this file and is documented as a requirement.

Copilot uses AI. Check for mistakes.

framework.AssertNoError(t, err)

actualPath := strings.TrimSpace(output)
expectedPath := filepath.Join(repo.Path(), "..", "worktrees", "feature", "quiet")

resolvedExpected := expectedPath
if path, resolveErr := filepath.EvalSymlinks(expectedPath); resolveErr == nil {
resolvedExpected = path
}

resolvedActual := actualPath
if path, resolveErr := filepath.EvalSymlinks(actualPath); resolveErr == nil {
resolvedActual = path
}

framework.AssertEqual(t, filepath.Clean(resolvedExpected), filepath.Clean(resolvedActual))
framework.AssertWorktreeExists(t, repo, resolvedActual)
framework.AssertFalse(t, strings.Contains(output, "✅ Worktree created successfully!"),
"quiet mode should not include human-friendly success text")
}

// TestUserCreatesWorktree_WithCustomPath_ShouldCreateAtSpecifiedLocation tests
// the flexibility to specify custom worktree locations.
//
Expand Down
Loading