diff --git a/README.md b/README.md index 5efeeae..114fbc5 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,21 @@ See [Commands Reference](docs/commands.md) for full documentation. - `stack reparent ` - Change the parent of the current branch - `stack worktree ` - Create a worktree for a branch +## Configuration + +Configure the base branch and worktrees directory with git config: + +```bash +git config stack.baseBranch develop # Default is "main" +git config stack.worktreesDir ~/worktrees +``` + +Or use the interactive helper: + +```bash +stack config set +``` + ## Documentation - [How It Works](docs/how-it-works.md) - Stack tracking and sync algorithm diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..958ea3c --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/javoire/stackinator/internal/git" + "github.com/javoire/stackinator/internal/ui" + "github.com/spf13/cobra" +) + +var configStdin io.Reader = os.Stdin + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Show stackinator configuration for this repository", + Long: `Show stackinator configuration for this repository. + +Use 'stack config set' to update settings.`, + Run: func(cmd *cobra.Command, args []string) { + gitClient := git.NewGitClient() + if err := runConfigShow(gitClient); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var configGetCmd = &cobra.Command{ + Use: "get", + Short: "Show stackinator configuration for this repository", + Run: func(cmd *cobra.Command, args []string) { + gitClient := git.NewGitClient() + if err := runConfigShow(gitClient); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var configSetCmd = &cobra.Command{ + Use: "set", + Short: "Interactively configure stackinator settings", + Long: `Interactively configure stackinator settings. + +Currently supports the worktrees directory location.`, + Run: func(cmd *cobra.Command, args []string) { + gitClient := git.NewGitClient() + if err := runConfigSet(gitClient); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + configCmd.AddCommand(configGetCmd) + configCmd.AddCommand(configSetCmd) +} + +func runConfigShow(gitClient git.GitClient) error { + configured := strings.TrimSpace(gitClient.GetConfig("stack.worktreesDir")) + if configured == "" { + configured = "(default)" + } + + effective, err := getWorktreesBaseDir(gitClient) + if err != nil { + return err + } + + fmt.Println("Worktrees directory:") + fmt.Printf(" Configured: %s\n", configured) + fmt.Printf(" Effective: %s\n", effective) + + return nil +} + +func runConfigSet(gitClient git.GitClient) error { + defaultDir, err := getDefaultWorktreesBaseDir() + if err != nil { + return err + } + + repoRoot, err := gitClient.GetRepoRoot() + if err != nil { + return err + } + + projectDir := filepath.Join(repoRoot, ".worktrees") + + fmt.Println("Choose worktrees directory:") + fmt.Printf(" 1) %s (default)\n", defaultDir) + fmt.Printf(" 2) %s (this repo)\n", projectDir) + fmt.Printf("Select [1/2] (default 1): ") + + reader := bufio.NewReader(configStdin) + input, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + + choice := strings.TrimSpace(input) + if choice == "" { + choice = "1" + } + + var configValue string + switch choice { + case "1": + configValue = "~/.stack/worktrees" + case "2": + configValue = ".worktrees" + default: + return fmt.Errorf("invalid selection: %s", choice) + } + + if err := gitClient.SetConfig("stack.worktreesDir", configValue); err != nil { + return fmt.Errorf("failed to set worktrees directory: %w", err) + } + + if !dryRun { + fmt.Println(ui.Success(fmt.Sprintf("Worktrees directory set to %s", configValue))) + } + + return nil +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..e91f1a2 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/javoire/stackinator/internal/testutil" + "github.com/stretchr/testify/assert" +) + +func TestGetWorktreesBaseDir(t *testing.T) { + t.Setenv("HOME", "/home/test") + + tests := []struct { + name string + config string + repoRoot string + repoErr error + expected string + }{ + { + name: "default when no config", + config: "", + repoRoot: "/repo", + expected: "/home/test/.stack/worktrees", + }, + { + name: "tilde expands", + config: "~/worktrees", + repoRoot: "/repo", + expected: "/home/test/worktrees", + }, + { + name: "env expands", + config: "$HOME/custom", + repoRoot: "/repo", + expected: "/home/test/custom", + }, + { + name: "relative uses repo root", + config: ".worktrees", + repoRoot: "/repo", + expected: "/repo/.worktrees", + }, + { + name: "relative falls back to home when repo root missing", + config: ".worktrees", + repoErr: errors.New("no repo"), + expected: "/home/test/.worktrees", + }, + { + name: "absolute kept as is", + config: "/abs/worktrees", + repoRoot: "/repo", + expected: "/abs/worktrees", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + mockGit := &testutil.MockGitClient{} + mockGit.On("GetRepoRoot").Return(tt.repoRoot, tt.repoErr) + mockGit.On("GetConfig", "stack.worktreesDir").Return(tt.config) + + dir, err := getWorktreesBaseDir(mockGit) + assert.NoError(t, err) + assert.Equal(t, tt.expected, dir) + mockGit.AssertExpectations(t) + }) + } +} + +func TestRunConfigSet(t *testing.T) { + t.Setenv("HOME", "/home/test") + + tests := []struct { + name string + input string + expectValue string + expectErr bool + }{ + { + name: "default selection", + input: "\n", + expectValue: "~/.stack/worktrees", + }, + { + name: "select repo local", + input: "2\n", + expectValue: ".worktrees", + }, + { + name: "invalid selection", + input: "3\n", + expectErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + mockGit := &testutil.MockGitClient{} + mockGit.On("GetRepoRoot").Return("/repo", nil) + if !tt.expectErr { + mockGit.On("SetConfig", "stack.worktreesDir", tt.expectValue).Return(nil) + } + + previousStdin := configStdin + configStdin = strings.NewReader(tt.input) + defer func() { configStdin = previousStdin }() + + err := runConfigSet(mockGit) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + mockGit.AssertExpectations(t) + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 350c99b..d849fab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,6 +78,7 @@ func init() { rootCmd.AddCommand(renameCmd) rootCmd.AddCommand(reparentCmd) rootCmd.AddCommand(worktreeCmd) + rootCmd.AddCommand(configCmd) rootCmd.AddCommand(upCmd) rootCmd.AddCommand(downCmd) } diff --git a/cmd/worktree.go b/cmd/worktree.go index 6bd9cb1..b84291a 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -19,15 +19,18 @@ var worktreeList bool var worktreeCmd = &cobra.Command{ Use: "worktree [base-branch]", - Short: "Create a worktree in ~/.stack/worktrees/ directory", - Long: `Create a git worktree in the ~/.stack/worktrees/ directory for the specified branch. + Short: "Create a worktree in the configured worktrees directory", + Long: `Create a git worktree in the configured worktrees directory for the specified branch. If the branch exists locally or on the remote, it will be used. If the branch doesn't exist, a new branch will be created from the current branch (or from base-branch if specified) and stack tracking will be set up automatically. Use --list to show worktrees for this repository, or --list --all for all repos. Use --prune to clean up worktrees for branches with merged PRs. -Use --prune --all to remove all worktrees for this repository.`, +Use --prune --all to remove all worktrees for this repository. + +By default, worktrees are created under ~/.stack/worktrees/. +You can change this with: git config stack.worktreesDir (or use 'stack config set')`, Example: ` # Create worktree for new branch (from current branch, with stack tracking) stack worktree my-feature @@ -100,10 +103,9 @@ func init() { } func runWorktree(gitClient git.GitClient, githubClient github.GitHubClient, branchName, baseBranch string) error { - // Get home directory - homeDir, err := os.UserHomeDir() + worktreesBaseDir, err := getWorktreesBaseDir(gitClient) if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) + return err } // Get repository name @@ -112,8 +114,8 @@ func runWorktree(gitClient git.GitClient, githubClient github.GitHubClient, bran return fmt.Errorf("failed to get repo name: %w", err) } - // Worktree path: ~/.stack/worktrees// - worktreePath := filepath.Join(homeDir, ".stack", "worktrees", repoName, branchName) + // Worktree path: // + worktreePath := filepath.Join(worktreesBaseDir, repoName, branchName) // Check if worktree already exists if _, err := os.Stat(worktreePath); err == nil { @@ -130,14 +132,11 @@ func runWorktree(gitClient git.GitClient, githubClient github.GitHubClient, bran } func runWorktreeList(gitClient git.GitClient) error { - // Get home directory - homeDir, err := os.UserHomeDir() + worktreesBaseDir, err := getWorktreesBaseDir(gitClient) if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) + return err } - worktreesBaseDir := filepath.Join(homeDir, ".stack", "worktrees") - // Check if ~/.stack/worktrees directory exists if _, err := os.Stat(worktreesBaseDir); os.IsNotExist(err) { fmt.Printf("No worktrees found in %s\n", worktreesBaseDir) @@ -175,7 +174,7 @@ func runWorktreeList(gitClient git.GitClient) error { branch string } for branch, path := range worktreeBranches { - if strings.HasPrefix(path, worktreesDir) { + if pathWithinDir(path, worktreesDir) { worktrees = append(worktrees, struct { path string branch string @@ -341,10 +340,9 @@ func createWorktreeForExisting(gitClient git.GitClient, branchName, worktreePath } func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) error { - // Get home directory - homeDir, err := os.UserHomeDir() + worktreesBaseDir, err := getWorktreesBaseDir(gitClient) if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) + return err } // Get repository name @@ -353,7 +351,7 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) return fmt.Errorf("failed to get repo name: %w", err) } - worktreesDir := filepath.Join(homeDir, ".stack", "worktrees", repoName) + worktreesDir := filepath.Join(worktreesBaseDir, repoName) // Check if ~/.stack/worktrees/ directory exists if _, err := os.Stat(worktreesDir); os.IsNotExist(err) { @@ -373,7 +371,7 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) branch string } for branch, path := range worktreeBranches { - if strings.HasPrefix(path, worktreesDir) { + if pathWithinDir(path, worktreesDir) { worktreesToCheck = append(worktreesToCheck, struct { path string branch string @@ -457,3 +455,60 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) return nil } + +func getHomeDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return homeDir, nil +} + +func getDefaultWorktreesBaseDir() (string, error) { + homeDir, err := getHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".stack", "worktrees"), nil +} + +func getWorktreesBaseDir(gitClient git.GitClient) (string, error) { + defaultDir, err := getDefaultWorktreesBaseDir() + if err != nil { + return "", err + } + homeDir, err := getHomeDir() + if err != nil { + return "", err + } + repoRoot, repoErr := gitClient.GetRepoRoot() + + configured := strings.TrimSpace(gitClient.GetConfig("stack.worktreesDir")) + if configured == "" { + return defaultDir, nil + } + + expanded := os.ExpandEnv(configured) + if strings.HasPrefix(expanded, "~") { + trimmed := strings.TrimPrefix(expanded, "~") + trimmed = strings.TrimPrefix(trimmed, string(os.PathSeparator)) + expanded = filepath.Join(homeDir, trimmed) + } + if !filepath.IsAbs(expanded) { + if repoErr == nil { + expanded = filepath.Join(repoRoot, expanded) + } else { + expanded = filepath.Join(homeDir, expanded) + } + } + + return filepath.Clean(expanded), nil +} + +func pathWithinDir(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} diff --git a/docs/commands.md b/docs/commands.md index dd3228e..6686d2c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -116,10 +116,31 @@ stack reparent main --dry-run ## `stack worktree [base-branch]` -Create a git worktree in the `.worktrees/` directory for the specified branch. +Create a git worktree in the configured worktrees directory for the specified branch. If the branch exists locally or on the remote, it will be used. If the branch doesn't exist, a new branch will be created from the current branch (or from base-branch if specified) and stack tracking will be set up automatically. +By default, worktrees are created under `~/.stack/worktrees/`. You can change this with: + +```bash +git config stack.worktreesDir ~/worktrees +``` + +## `stack config` + +Show the current configuration for this repo. + +## `stack config get` + +Show the current configuration for this repo (same as `stack config`). + +## `stack config set` + +Interactively choose where worktrees are created for this repo: + +- `~/.stack/worktrees` (default) +- `./.worktrees` (project-local) + ```bash # Create worktree for new branch (from current branch, with stack tracking) stack worktree my-feature diff --git a/docs/configuration.md b/docs/configuration.md index 31b17e4..fdfa63f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,3 +5,21 @@ Base branch can be configured per-repo: ```bash git config stack.baseBranch develop # Default is "main" ``` + +Worktrees directory can be configured per-repo (or globally): + +```bash +git config stack.worktreesDir ~/worktrees +``` + +View current config: + +```bash +stack config +``` + +Or use the interactive helper: + +```bash +stack config set +```