diff --git a/README.md b/README.md index 194b4cd..90779f7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/wtp/add.go b/cmd/wtp/add.go index 6315269..cb8cef7 100644 --- a/cmd/wtp/add.go +++ b/cmd/wtp/add.go @@ -26,15 +26,18 @@ import ( // NewAddCommand creates the add command definition func NewAddCommand() *cli.Command { return &cli.Command{ - Name: "add", - Usage: "Create a new worktree", - UsageText: "wtp add \n wtp add -b []", + Name: "add", + Usage: "Create a new worktree", + UsageText: "wtp add \n" + + " wtp add -b []\n" + + " wtp add -b --quiet", 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{ @@ -47,19 +50,22 @@ func NewAddCommand() *cli.Command { 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 @@ -74,12 +80,28 @@ func addCommand(_ context.Context, cmd *cli.Command) error { // Create command executor executor := command.NewRealExecutor() - return addCommandWithCommandExecutor(cmd, fw, executor, cfg, mainRepoPath) + return addCommandWithCommandExecutor(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, + cmd *cli.Command, + stdoutWriter io.Writer, + statusWriter io.Writer, + cmdExec command.Executor, + cfg *config.Config, + mainRepoPath string, +) error { + return addCommandWithCommandExecutorWithWriters(cmd, stdoutWriter, statusWriter, 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 @@ -113,23 +135,53 @@ func addCommandWithCommandExecutor( 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, + !cmd.Bool("quiet"), + ); 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) (stdoutWriter, statusWriter io.Writer) { + 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, @@ -360,7 +412,13 @@ func executePostCreateHooks(w io.Writer, cfg *config.Config, repoPath, workTreeP return nil } -func executePostCreateCommand(w io.Writer, cmdExec command.Executor, execCommand, workTreePath string) error { +func executePostCreateCommand( + w io.Writer, + cmdExec command.Executor, + execCommand string, + workTreePath string, + interactive bool, +) error { if strings.TrimSpace(execCommand) == "" { return nil } @@ -371,7 +429,7 @@ func executePostCreateCommand(w io.Writer, cmdExec command.Executor, execCommand commandToRun := command.Command{ WorkDir: workTreePath, - Interactive: true, + Interactive: interactive, } if runtime.GOOS == "windows" { commandToRun.Name = "cmd" diff --git a/cmd/wtp/add_test.go b/cmd/wtp/add_test.go index 435e7e7..f7d15dc 100644 --- a/cmd/wtp/add_test.go +++ b/cmd/wtp/add_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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 { @@ -373,7 +374,7 @@ func TestAddCommand_CommandConstruction(t *testing.T) { }, } - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") if tt.expectError { assert.Error(t, err) @@ -414,7 +415,7 @@ func TestAddCommand_SuccessMessage(t *testing.T) { Defaults: config.Defaults{BaseDir: "/test/worktrees"}, } - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") assert.NoError(t, err) assert.Contains(t, buf.String(), tt.expectedOutput) @@ -422,6 +423,98 @@ 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") + require.Len(t, exec.executedCommands, 2) + assert.False(t, exec.executedCommands[1].Interactive) + }) + + 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) { @@ -457,7 +550,7 @@ func TestAddCommand_ExecutionError(t *testing.T) { Defaults: config.Defaults{BaseDir: "/test/worktrees"}, } - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") assert.Error(t, err) assert.Len(t, mockExec.executedCommands, 1) @@ -479,7 +572,7 @@ func TestAddCommand_ExecFailureKeepsCreationContext(t *testing.T) { Defaults: config.Defaults{BaseDir: "/test/worktrees"}, } - err := addCommandWithCommandExecutor(cmd, &buf, exec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, exec, cfg, "/test/repo") require.Error(t, err) assert.Contains(t, err.Error(), "worktree was created") @@ -520,7 +613,7 @@ func TestAddCommand_InternationalCharacters(t *testing.T) { Defaults: config.Defaults{BaseDir: "/test/worktrees"}, } - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") assert.NoError(t, err) assert.Len(t, mockExec.executedCommands, 1) @@ -544,6 +637,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"}, }, @@ -586,7 +680,7 @@ func TestAddCommand_SimplifiedInterface(t *testing.T) { } // When: running add command with existing branch (mock mode - skip repo check) - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") // Then: should create worktree successfully (in mock mode, branch tracking will fail but command should work) // Note: This test will fail with "not in git repository" because resolveBranchTracking calls git.NewRepository @@ -605,7 +699,7 @@ func TestAddCommand_SimplifiedInterface(t *testing.T) { } // When: running add command with -b flag (this should work without git repo) - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") // Then: should create new branch and worktree assert.NoError(t, err) @@ -625,7 +719,7 @@ func TestAddCommand_SimplifiedInterface(t *testing.T) { } // When: running add command with -b flag and commit - err := addCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo") + err := addCommandWithCommandExecutor(cmd, &buf, &buf, mockExec, cfg, "/test/repo") // Then: should create new branch from commit and worktree assert.NoError(t, err) @@ -773,7 +867,7 @@ func TestExecutePostCreateCommand(t *testing.T) { var buf bytes.Buffer mockExec := &mockCommandExecutor{} - err := executePostCreateCommand(&buf, mockExec, "", "/test/worktree") + err := executePostCreateCommand(&buf, mockExec, "", "/test/worktree", true) require.NoError(t, err) assert.Empty(t, buf.String()) assert.Empty(t, mockExec.executedCommands) @@ -783,7 +877,7 @@ func TestExecutePostCreateCommand(t *testing.T) { var buf bytes.Buffer mockExec := &mockCommandExecutor{} - err := executePostCreateCommand(&buf, mockExec, "echo hello", "/test/worktree") + err := executePostCreateCommand(&buf, mockExec, "echo hello", "/test/worktree", true) require.NoError(t, err) require.Len(t, mockExec.executedCommands, 1) assert.Equal(t, "/test/worktree", mockExec.executedCommands[0].WorkDir) @@ -797,6 +891,16 @@ func TestExecutePostCreateCommand(t *testing.T) { assert.Equal(t, []string{"-c", "echo hello"}, mockExec.executedCommands[0].Args) } }) + + t.Run("should execute command as non-interactive when requested", func(t *testing.T) { + var buf bytes.Buffer + mockExec := &mockCommandExecutor{} + + err := executePostCreateCommand(&buf, mockExec, "echo hello", "/test/worktree", false) + require.NoError(t, err) + require.Len(t, mockExec.executedCommands, 1) + assert.False(t, mockExec.executedCommands[0].Interactive) + }) } func TestDisplaySuccessMessage_Integration(t *testing.T) { diff --git a/test/e2e/worktree_creation_test.go b/test/e2e/worktree_creation_test.go index b44947c..1d3186b 100644 --- a/test/e2e/worktree_creation_test.go +++ b/test/e2e/worktree_creation_test.go @@ -1,6 +1,8 @@ package e2e import ( + "path/filepath" + "strings" "testing" "github.com/satococoa/wtp/v2/test/e2e/framework" @@ -66,6 +68,35 @@ func TestUserCreatesWorktree_WithNewBranchFlag_ShouldCreateBranchAndWorktree(t * framework.AssertWorktreeExists(t, repo, "feature/payment") } +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") + + 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. //