Skip to content

Commit 2349aff

Browse files
authored
feat: add stack config command (#57)
1 parent 205c8ed commit 2349aff

7 files changed

Lines changed: 384 additions & 20 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ See [Commands Reference](docs/commands.md) for full documentation.
9090
- `stack reparent <new-parent>` - Change the parent of the current branch
9191
- `stack worktree <branch-name>` - Create a worktree for a branch
9292

93+
## Configuration
94+
95+
Configure the base branch and worktrees directory with git config:
96+
97+
```bash
98+
git config stack.baseBranch develop # Default is "main"
99+
git config stack.worktreesDir ~/worktrees
100+
```
101+
102+
Or use the interactive helper:
103+
104+
```bash
105+
stack config set
106+
```
107+
93108
## Documentation
94109

95110
- [How It Works](docs/how-it-works.md) - Stack tracking and sync algorithm

cmd/config.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/javoire/stackinator/internal/git"
12+
"github.com/javoire/stackinator/internal/ui"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var configStdin io.Reader = os.Stdin
17+
18+
var configCmd = &cobra.Command{
19+
Use: "config",
20+
Short: "Show stackinator configuration for this repository",
21+
Long: `Show stackinator configuration for this repository.
22+
23+
Use 'stack config set' to update settings.`,
24+
Run: func(cmd *cobra.Command, args []string) {
25+
gitClient := git.NewGitClient()
26+
if err := runConfigShow(gitClient); err != nil {
27+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
28+
os.Exit(1)
29+
}
30+
},
31+
}
32+
33+
var configGetCmd = &cobra.Command{
34+
Use: "get",
35+
Short: "Show stackinator configuration for this repository",
36+
Run: func(cmd *cobra.Command, args []string) {
37+
gitClient := git.NewGitClient()
38+
if err := runConfigShow(gitClient); err != nil {
39+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
40+
os.Exit(1)
41+
}
42+
},
43+
}
44+
45+
var configSetCmd = &cobra.Command{
46+
Use: "set",
47+
Short: "Interactively configure stackinator settings",
48+
Long: `Interactively configure stackinator settings.
49+
50+
Currently supports the worktrees directory location.`,
51+
Run: func(cmd *cobra.Command, args []string) {
52+
gitClient := git.NewGitClient()
53+
if err := runConfigSet(gitClient); err != nil {
54+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
55+
os.Exit(1)
56+
}
57+
},
58+
}
59+
60+
func init() {
61+
configCmd.AddCommand(configGetCmd)
62+
configCmd.AddCommand(configSetCmd)
63+
}
64+
65+
func runConfigShow(gitClient git.GitClient) error {
66+
configured := strings.TrimSpace(gitClient.GetConfig("stack.worktreesDir"))
67+
if configured == "" {
68+
configured = "(default)"
69+
}
70+
71+
effective, err := getWorktreesBaseDir(gitClient)
72+
if err != nil {
73+
return err
74+
}
75+
76+
fmt.Println("Worktrees directory:")
77+
fmt.Printf(" Configured: %s\n", configured)
78+
fmt.Printf(" Effective: %s\n", effective)
79+
80+
return nil
81+
}
82+
83+
func runConfigSet(gitClient git.GitClient) error {
84+
defaultDir, err := getDefaultWorktreesBaseDir()
85+
if err != nil {
86+
return err
87+
}
88+
89+
repoRoot, err := gitClient.GetRepoRoot()
90+
if err != nil {
91+
return err
92+
}
93+
94+
projectDir := filepath.Join(repoRoot, ".worktrees")
95+
96+
fmt.Println("Choose worktrees directory:")
97+
fmt.Printf(" 1) %s (default)\n", defaultDir)
98+
fmt.Printf(" 2) %s (this repo)\n", projectDir)
99+
fmt.Printf("Select [1/2] (default 1): ")
100+
101+
reader := bufio.NewReader(configStdin)
102+
input, err := reader.ReadString('\n')
103+
if err != nil {
104+
return fmt.Errorf("failed to read input: %w", err)
105+
}
106+
107+
choice := strings.TrimSpace(input)
108+
if choice == "" {
109+
choice = "1"
110+
}
111+
112+
var configValue string
113+
switch choice {
114+
case "1":
115+
configValue = "~/.stack/worktrees"
116+
case "2":
117+
configValue = ".worktrees"
118+
default:
119+
return fmt.Errorf("invalid selection: %s", choice)
120+
}
121+
122+
if err := gitClient.SetConfig("stack.worktreesDir", configValue); err != nil {
123+
return fmt.Errorf("failed to set worktrees directory: %w", err)
124+
}
125+
126+
if !dryRun {
127+
fmt.Println(ui.Success(fmt.Sprintf("Worktrees directory set to %s", configValue)))
128+
}
129+
130+
return nil
131+
}

cmd/config_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
8+
"github.com/javoire/stackinator/internal/testutil"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestGetWorktreesBaseDir(t *testing.T) {
13+
t.Setenv("HOME", "/home/test")
14+
15+
tests := []struct {
16+
name string
17+
config string
18+
repoRoot string
19+
repoErr error
20+
expected string
21+
}{
22+
{
23+
name: "default when no config",
24+
config: "",
25+
repoRoot: "/repo",
26+
expected: "/home/test/.stack/worktrees",
27+
},
28+
{
29+
name: "tilde expands",
30+
config: "~/worktrees",
31+
repoRoot: "/repo",
32+
expected: "/home/test/worktrees",
33+
},
34+
{
35+
name: "env expands",
36+
config: "$HOME/custom",
37+
repoRoot: "/repo",
38+
expected: "/home/test/custom",
39+
},
40+
{
41+
name: "relative uses repo root",
42+
config: ".worktrees",
43+
repoRoot: "/repo",
44+
expected: "/repo/.worktrees",
45+
},
46+
{
47+
name: "relative falls back to home when repo root missing",
48+
config: ".worktrees",
49+
repoErr: errors.New("no repo"),
50+
expected: "/home/test/.worktrees",
51+
},
52+
{
53+
name: "absolute kept as is",
54+
config: "/abs/worktrees",
55+
repoRoot: "/repo",
56+
expected: "/abs/worktrees",
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
tt := tt
62+
t.Run(tt.name, func(t *testing.T) {
63+
mockGit := &testutil.MockGitClient{}
64+
mockGit.On("GetRepoRoot").Return(tt.repoRoot, tt.repoErr)
65+
mockGit.On("GetConfig", "stack.worktreesDir").Return(tt.config)
66+
67+
dir, err := getWorktreesBaseDir(mockGit)
68+
assert.NoError(t, err)
69+
assert.Equal(t, tt.expected, dir)
70+
mockGit.AssertExpectations(t)
71+
})
72+
}
73+
}
74+
75+
func TestRunConfigSet(t *testing.T) {
76+
t.Setenv("HOME", "/home/test")
77+
78+
tests := []struct {
79+
name string
80+
input string
81+
expectValue string
82+
expectErr bool
83+
}{
84+
{
85+
name: "default selection",
86+
input: "\n",
87+
expectValue: "~/.stack/worktrees",
88+
},
89+
{
90+
name: "select repo local",
91+
input: "2\n",
92+
expectValue: ".worktrees",
93+
},
94+
{
95+
name: "invalid selection",
96+
input: "3\n",
97+
expectErr: true,
98+
},
99+
}
100+
101+
for _, tt := range tests {
102+
tt := tt
103+
t.Run(tt.name, func(t *testing.T) {
104+
mockGit := &testutil.MockGitClient{}
105+
mockGit.On("GetRepoRoot").Return("/repo", nil)
106+
if !tt.expectErr {
107+
mockGit.On("SetConfig", "stack.worktreesDir", tt.expectValue).Return(nil)
108+
}
109+
110+
previousStdin := configStdin
111+
configStdin = strings.NewReader(tt.input)
112+
defer func() { configStdin = previousStdin }()
113+
114+
err := runConfigSet(mockGit)
115+
if tt.expectErr {
116+
assert.Error(t, err)
117+
} else {
118+
assert.NoError(t, err)
119+
}
120+
mockGit.AssertExpectations(t)
121+
})
122+
}
123+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func init() {
7878
rootCmd.AddCommand(renameCmd)
7979
rootCmd.AddCommand(reparentCmd)
8080
rootCmd.AddCommand(worktreeCmd)
81+
rootCmd.AddCommand(configCmd)
8182
rootCmd.AddCommand(upCmd)
8283
rootCmd.AddCommand(downCmd)
8384
}

0 commit comments

Comments
 (0)