From 256fbe80d5ece5afa32d8d3aecabdddf1c7fe5db Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Tue, 10 Mar 2026 11:55:30 -0400 Subject: [PATCH 1/2] feat(switch): add command to quickly switch between worktrees Adds `stack switch` for fast worktree navigation: - `stack switch ` prints `cd ` for that branch's worktree - `stack switch` (no args) shows an interactive picker - `--init` outputs an `ss()` shell function wrapping switch with cd - `--install` appends the init line to shell config Co-Authored-By: Claude Opus 4.6 --- cmd/root.go | 1 + cmd/switch.go | 260 +++++++++++++++++++++++++++++++++++++++++++++ cmd/switch_test.go | 101 ++++++++++++++++++ decision-log.md | 8 ++ 4 files changed, 370 insertions(+) create mode 100644 cmd/switch.go create mode 100644 cmd/switch_test.go diff --git a/cmd/root.go b/cmd/root.go index a16eedc..790d314 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,6 +85,7 @@ func init() { rootCmd.AddCommand(configCmd) rootCmd.AddCommand(upCmd) rootCmd.AddCommand(downCmd) + rootCmd.AddCommand(switchCmd) } // Execute runs the root command diff --git a/cmd/switch.go b/cmd/switch.go new file mode 100644 index 0000000..79a8cd0 --- /dev/null +++ b/cmd/switch.go @@ -0,0 +1,260 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/javoire/stackinator/internal/git" + "github.com/javoire/stackinator/internal/stack" + "github.com/spf13/cobra" +) + +var switchInit bool +var switchInstall bool + +var switchCmd = &cobra.Command{ + Use: "switch [branch]", + Short: "Print cd command to switch to a branch's worktree", + Long: `Print a cd command to switch to the worktree for a given branch. + +With no arguments, shows an interactive picker of all worktrees. +Use --init to output a shell function that wraps this command with actual cd. +Use --install to add the shell function to your shell config.`, + Example: ` # Switch to a branch's worktree + eval "$(stack switch my-feature)" + + # Interactive picker + eval "$(stack switch)" + + # Output shell function + stack switch --init + + # Install shell function to ~/.zshrc + stack switch --install`, + Annotations: map[string]string{}, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if switchInit { + runSwitchInit() + return + } + + if switchInstall { + if err := runSwitchInstall(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + + gitClient := git.NewGitClient() + + var err error + if len(args) == 1 { + err = runSwitch(gitClient, args[0]) + } else { + err = runSwitchInteractive(gitClient) + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + switchCmd.Flags().BoolVar(&switchInit, "init", false, "Output shell function for wrapping switch with cd") + switchCmd.Flags().BoolVar(&switchInstall, "install", false, "Add shell function to shell config") + + // Skip git validation for --init and --install + switchCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + // Run root's PersistentPreRun only if we need git + if !switchInit && !switchInstall { + rootCmd.PersistentPreRun(cmd, args) + } + } +} + +func runSwitchInit() { + fmt.Print(`ss() { + local dir + dir=$(command stack switch "$@" 2>/dev/null) + if [ $? -eq 0 ] && [ -n "$dir" ]; then + eval "$dir" + else + command stack switch "$@" + fi +} +`) +} + +func runSwitch(gitClient git.GitClient, branchName string) error { + path, err := resolveWorktreePath(gitClient, branchName) + if err != nil { + return err + } + fmt.Printf("cd %s\n", path) + return nil +} + +func resolveWorktreePath(gitClient git.GitClient, branchName string) (string, error) { + // Check if it's the base branch + baseBranch := stack.GetBaseBranch(gitClient) + if branchName == baseBranch { + repoRoot, err := gitClient.GetRepoRoot() + if err != nil { + return "", fmt.Errorf("failed to get repo root: %w", err) + } + return repoRoot, nil + } + + // Look up in worktree branches + worktreeBranches, err := gitClient.GetWorktreeBranches() + if err != nil { + return "", fmt.Errorf("failed to get worktree branches: %w", err) + } + + if path, ok := worktreeBranches[branchName]; ok { + return path, nil + } + + return "", fmt.Errorf("no worktree found for branch %s", branchName) +} + +func runSwitchInteractive(gitClient git.GitClient) error { + worktreesBaseDir, err := getWorktreesBaseDir(gitClient) + if err != nil { + return err + } + + repoName, err := gitClient.GetRepoName() + if err != nil { + return fmt.Errorf("failed to get repo name: %w", err) + } + + worktreesDir := filepath.Join(worktreesBaseDir, repoName) + + // Get worktree branches + worktreeBranches, err := gitClient.GetWorktreeBranches() + if err != nil { + return fmt.Errorf("failed to get worktree branches: %w", err) + } + + // Get current worktree path for marking + currentPath, _ := gitClient.GetCurrentWorktreePath() + + // Collect options: worktrees in the worktrees dir + main repo + type option struct { + branch string + path string + } + var options []option + + // Add main repo + baseBranch := stack.GetBaseBranch(gitClient) + repoRoot, err := gitClient.GetRepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + options = append(options, option{branch: baseBranch, path: repoRoot}) + + // Add worktrees filtered to this repo's worktrees dir + var sortedBranches []string + for branch, path := range worktreeBranches { + if pathWithinDir(path, worktreesDir) { + sortedBranches = append(sortedBranches, branch) + } + } + sort.Strings(sortedBranches) + for _, branch := range sortedBranches { + options = append(options, option{branch: branch, path: worktreeBranches[branch]}) + } + + if len(options) <= 1 { + return fmt.Errorf("no worktrees found") + } + + // Display options + fmt.Fprintf(os.Stderr, "Select a worktree:\n\n") + for i, opt := range options { + marker := " " + if opt.path == currentPath { + marker = "*" + } + fmt.Fprintf(os.Stderr, " %s %d) %s\n", marker, i+1, opt.branch) + fmt.Fprintf(os.Stderr, " %s\n", opt.path) + } + fmt.Fprintf(os.Stderr, "\n> ") + + // Read selection + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + input = strings.TrimSpace(input) + + idx, err := strconv.Atoi(input) + if err != nil || idx < 1 || idx > len(options) { + return fmt.Errorf("invalid selection: %s", input) + } + + selected := options[idx-1] + fmt.Printf("cd %s\n", selected.path) + return nil +} + +func runSwitchInstall() error { + // Detect shell + shell := os.Getenv("SHELL") + var rcFile string + switch { + case strings.HasSuffix(shell, "/bash"): + homeDir, err := getHomeDir() + if err != nil { + return err + } + rcFile = filepath.Join(homeDir, ".bashrc") + default: + // Default to zsh + homeDir, err := getHomeDir() + if err != nil { + return err + } + rcFile = filepath.Join(homeDir, ".zshrc") + } + + // Read existing file + content, err := os.ReadFile(rcFile) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", rcFile, err) + } + + // Check if already installed + initLine := `eval "$(stack switch --init)"` + if strings.Contains(string(content), "stack switch --init") { + fmt.Fprintf(os.Stderr, "Already installed in %s\n", rcFile) + return nil + } + + // Append + f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open %s: %w", rcFile, err) + } + defer f.Close() + + entry := fmt.Sprintf("\n# stackinator: ss() function for quick worktree switching\n%s\n", initLine) + if _, err := f.WriteString(entry); err != nil { + return fmt.Errorf("failed to write to %s: %w", rcFile, err) + } + + fmt.Fprintf(os.Stderr, "Added ss() function to %s\n", rcFile) + fmt.Fprintf(os.Stderr, "Run 'source %s' or start a new shell to use it.\n", rcFile) + return nil +} diff --git a/cmd/switch_test.go b/cmd/switch_test.go new file mode 100644 index 0000000..9050d59 --- /dev/null +++ b/cmd/switch_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/javoire/stackinator/internal/testutil" + "github.com/stretchr/testify/assert" +) + +func TestRunSwitch(t *testing.T) { + testutil.SetupTest() + defer testutil.TeardownTest() + + t.Run("resolves worktree branch to path", func(t *testing.T) { + mockGit := new(testutil.MockGitClient) + + mockGit.On("GetConfig", "stack.baseBranch").Return("") + mockGit.On("GetDefaultBranch").Return("main") + mockGit.On("GetWorktreeBranches").Return(map[string]string{ + "feature-a": "/home/user/.stack/worktrees/repo/feature-a", + "feature-b": "/home/user/.stack/worktrees/repo/feature-b", + }, nil) + + // Capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runSwitch(mockGit, "feature-a") + + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + + assert.NoError(t, err) + assert.Equal(t, "cd /home/user/.stack/worktrees/repo/feature-a\n", buf.String()) + mockGit.AssertExpectations(t) + }) + + t.Run("resolves base branch to repo root", func(t *testing.T) { + mockGit := new(testutil.MockGitClient) + + mockGit.On("GetConfig", "stack.baseBranch").Return("") + mockGit.On("GetDefaultBranch").Return("main") + mockGit.On("GetRepoRoot").Return("/home/user/code/repo", nil) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runSwitch(mockGit, "main") + + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + + assert.NoError(t, err) + assert.Equal(t, "cd /home/user/code/repo\n", buf.String()) + mockGit.AssertExpectations(t) + }) + + t.Run("errors for unknown branch", func(t *testing.T) { + mockGit := new(testutil.MockGitClient) + + mockGit.On("GetConfig", "stack.baseBranch").Return("") + mockGit.On("GetDefaultBranch").Return("main") + mockGit.On("GetWorktreeBranches").Return(map[string]string{}, nil) + + err := runSwitch(mockGit, "nonexistent") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no worktree found") + mockGit.AssertExpectations(t) + }) +} + +func TestRunSwitchInit(t *testing.T) { + testutil.SetupTest() + defer testutil.TeardownTest() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + runSwitchInit() + + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + + output := buf.String() + assert.Contains(t, output, "ss()") + assert.Contains(t, output, "command stack switch") + assert.Contains(t, output, "eval") +} diff --git a/decision-log.md b/decision-log.md index 202fe38..4c77ba3 100644 --- a/decision-log.md +++ b/decision-log.md @@ -2,6 +2,14 @@ Architectural and design decisions for Stackinator. +## 2026-03-10 — Add `stack switch` command for worktree navigation + +**Decision**: Add a `switch` command that outputs `cd ` for quick worktree navigation. + +**Context**: Users work across multiple worktrees and the main repo. `stack worktree --list` shows paths but requires manual copy-paste. A faster way to jump between worktrees was needed. + +**Resolution**: `stack switch ` resolves a branch to its worktree path and prints `cd `. No-args mode shows an interactive picker. `--init` outputs a shell function `ss()` that wraps the command with actual `cd`. `--install` appends the init line to the shell config. The command reuses existing helpers (`getWorktreesBaseDir`, `pathWithinDir`, `GetWorktreeBranches`). + ## 2026-03-06 — Add merged-parent detection to `stack status` **Decision**: Add merged-parent detection to `stack status`. From 60ef600e06a1a45197de4acca813ccb54311e2a0 Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Tue, 10 Mar 2026 12:25:46 -0400 Subject: [PATCH 2/2] fix(switch): address PR review feedback - Quote paths in cd output for shell safety - Use skipGitValidation annotation instead of PersistentPreRun override - Use initLine variable in contains check - DRY up getHomeDir in runSwitchInstall - Add dry-run support to runSwitchInstall Co-Authored-By: Claude Opus 4.6 --- cmd/switch.go | 38 ++++++++++++++++++-------------------- cmd/switch_test.go | 4 ++-- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/cmd/switch.go b/cmd/switch.go index 79a8cd0..b92776f 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -36,7 +36,7 @@ Use --install to add the shell function to your shell config.`, # Install shell function to ~/.zshrc stack switch --install`, - Annotations: map[string]string{}, + Annotations: map[string]string{"skipGitValidation": "true"}, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { if switchInit { @@ -52,6 +52,9 @@ Use --install to add the shell function to your shell config.`, return } + // Run root's PersistentPreRun for git validation + rootCmd.PersistentPreRun(cmd, args) + gitClient := git.NewGitClient() var err error @@ -70,14 +73,6 @@ Use --install to add the shell function to your shell config.`, func init() { switchCmd.Flags().BoolVar(&switchInit, "init", false, "Output shell function for wrapping switch with cd") switchCmd.Flags().BoolVar(&switchInstall, "install", false, "Add shell function to shell config") - - // Skip git validation for --init and --install - switchCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - // Run root's PersistentPreRun only if we need git - if !switchInit && !switchInstall { - rootCmd.PersistentPreRun(cmd, args) - } - } } func runSwitchInit() { @@ -98,7 +93,7 @@ func runSwitch(gitClient git.GitClient, branchName string) error { if err != nil { return err } - fmt.Printf("cd %s\n", path) + fmt.Printf("cd '%s'\n", path) return nil } @@ -205,27 +200,24 @@ func runSwitchInteractive(gitClient git.GitClient) error { } selected := options[idx-1] - fmt.Printf("cd %s\n", selected.path) + fmt.Printf("cd '%s'\n", selected.path) return nil } func runSwitchInstall() error { + homeDir, err := getHomeDir() + if err != nil { + return err + } + // Detect shell shell := os.Getenv("SHELL") var rcFile string switch { case strings.HasSuffix(shell, "/bash"): - homeDir, err := getHomeDir() - if err != nil { - return err - } rcFile = filepath.Join(homeDir, ".bashrc") default: // Default to zsh - homeDir, err := getHomeDir() - if err != nil { - return err - } rcFile = filepath.Join(homeDir, ".zshrc") } @@ -237,11 +229,17 @@ func runSwitchInstall() error { // Check if already installed initLine := `eval "$(stack switch --init)"` - if strings.Contains(string(content), "stack switch --init") { + if strings.Contains(string(content), initLine) { fmt.Fprintf(os.Stderr, "Already installed in %s\n", rcFile) return nil } + // Dry-run: show what would be appended + if dryRun { + fmt.Fprintf(os.Stderr, "Would append to %s:\n%s\n", rcFile, initLine) + return nil + } + // Append f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { diff --git a/cmd/switch_test.go b/cmd/switch_test.go index 9050d59..1abc0b6 100644 --- a/cmd/switch_test.go +++ b/cmd/switch_test.go @@ -37,7 +37,7 @@ func TestRunSwitch(t *testing.T) { io.Copy(&buf, r) assert.NoError(t, err) - assert.Equal(t, "cd /home/user/.stack/worktrees/repo/feature-a\n", buf.String()) + assert.Equal(t, "cd '/home/user/.stack/worktrees/repo/feature-a'\n", buf.String()) mockGit.AssertExpectations(t) }) @@ -60,7 +60,7 @@ func TestRunSwitch(t *testing.T) { io.Copy(&buf, r) assert.NoError(t, err) - assert.Equal(t, "cd /home/user/code/repo\n", buf.String()) + assert.Equal(t, "cd '/home/user/code/repo'\n", buf.String()) mockGit.AssertExpectations(t) })