Skip to content

Commit df8cc63

Browse files
committed
feat: warn about legacy worktree layout
1 parent 3d81425 commit df8cc63

File tree

7 files changed

+382
-25
lines changed

7 files changed

+382
-25
lines changed

cmd/wtp/cd.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ func cdToWorktree(_ context.Context, cmd *cli.Command) error {
6262
return errors.NotInGitRepository()
6363
}
6464

65+
rootCmd := cmd.Root()
66+
6567
// Get the writer from cli.Command
66-
w := cmd.Root().Writer
68+
w := rootCmd.Writer
6769
if w == nil {
6870
w = os.Stdout
6971
}
@@ -74,7 +76,7 @@ func cdToWorktree(_ context.Context, cmd *cli.Command) error {
7476
}
7577

7678
func cdCommandWithCommandExecutor(
77-
_ *cli.Command,
79+
cmd *cli.Command,
7880
w io.Writer,
7981
executor command.Executor,
8082
_ string,
@@ -93,6 +95,25 @@ func cdCommandWithCommandExecutor(
9395
// Find the main worktree path
9496
mainWorktreePath := findMainWorktreePath(worktrees)
9597

98+
errWriter := io.Discard
99+
if cmd != nil {
100+
if root := cmd.Root(); root != nil && root.ErrWriter != nil {
101+
errWriter = root.ErrWriter
102+
} else if root != nil && root.ErrWriter == nil {
103+
errWriter = os.Stderr
104+
}
105+
}
106+
107+
if mainWorktreePath != "" {
108+
cfgForWarning, cfgErr := config.LoadConfig(mainWorktreePath)
109+
if cfgErr != nil {
110+
cfgForWarning = &config.Config{
111+
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
112+
}
113+
}
114+
maybeWarnLegacyWorktreeLayout(errWriter, mainWorktreePath, cfgForWarning, worktrees)
115+
}
116+
96117
// Find the worktree using multiple resolution strategies
97118
targetPath := resolveCdWorktreePath(worktreeName, worktrees, mainWorktreePath)
98119

cmd/wtp/legacy_warning.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/satococoa/wtp/internal/config"
11+
"github.com/satococoa/wtp/internal/git"
12+
)
13+
14+
const legacyWarningExampleLimit = 3
15+
16+
type legacyWorktreeMigration struct {
17+
currentRel string
18+
suggestedRel string
19+
}
20+
21+
func maybeWarnLegacyWorktreeLayout(
22+
w io.Writer,
23+
mainRepoPath string,
24+
cfg *config.Config,
25+
worktrees []git.Worktree,
26+
) {
27+
if w == nil {
28+
w = os.Stderr
29+
}
30+
31+
if cfg == nil || mainRepoPath == "" || len(worktrees) == 0 {
32+
return
33+
}
34+
35+
if hasConfigFile(mainRepoPath) {
36+
return
37+
}
38+
39+
migrations := detectLegacyWorktreeMigrations(mainRepoPath, cfg, worktrees)
40+
if len(migrations) == 0 {
41+
return
42+
}
43+
44+
repoBase := filepath.Base(mainRepoPath)
45+
fmt.Fprintln(w, "⚠️ Legacy worktree layout detected.")
46+
fmt.Fprintf(w, " wtp now expects worktrees under '../worktrees/%s/...'\n", repoBase)
47+
fmt.Fprintln(w, " Move existing worktrees to the new layout (run from the repository root):")
48+
49+
limit := len(migrations)
50+
if limit > legacyWarningExampleLimit {
51+
limit = legacyWarningExampleLimit
52+
}
53+
for i := 0; i < limit; i++ {
54+
migration := migrations[i]
55+
fmt.Fprintf(w, " git worktree move %s %s\n", migration.currentRel, migration.suggestedRel)
56+
}
57+
58+
if len(migrations) > legacyWarningExampleLimit {
59+
fmt.Fprintf(w, " ... and %d more\n", len(migrations)-legacyWarningExampleLimit)
60+
}
61+
62+
fmt.Fprintln(w, " (Alternatively, run 'wtp init' and set defaults.base_dir to keep a custom layout.)")
63+
fmt.Fprintln(w)
64+
}
65+
66+
func detectLegacyWorktreeMigrations(
67+
mainRepoPath string,
68+
cfg *config.Config,
69+
worktrees []git.Worktree,
70+
) []legacyWorktreeMigration {
71+
if cfg == nil || mainRepoPath == "" {
72+
return nil
73+
}
74+
75+
mainRepoPath = filepath.Clean(mainRepoPath)
76+
77+
newBaseDir := filepath.Clean(cfg.ResolveWorktreePath(mainRepoPath, ""))
78+
legacyBaseDir := filepath.Clean(filepath.Join(filepath.Dir(mainRepoPath), "worktrees"))
79+
repoBase := filepath.Base(mainRepoPath)
80+
81+
if legacyBaseDir == newBaseDir {
82+
return nil
83+
}
84+
85+
var migrations []legacyWorktreeMigration
86+
for _, wt := range worktrees {
87+
if wt.IsMain {
88+
continue
89+
}
90+
91+
worktreePath := filepath.Clean(wt.Path)
92+
93+
if strings.HasPrefix(worktreePath, newBaseDir+string(os.PathSeparator)) ||
94+
worktreePath == newBaseDir {
95+
continue
96+
}
97+
98+
if !strings.HasPrefix(worktreePath, legacyBaseDir+string(os.PathSeparator)) {
99+
continue
100+
}
101+
102+
legacyRel, err := filepath.Rel(legacyBaseDir, worktreePath)
103+
if err != nil || legacyRel == "." {
104+
continue
105+
}
106+
107+
if strings.HasPrefix(legacyRel, repoBase+string(os.PathSeparator)) {
108+
// Already under the new structure (worktrees/<repo>/...)
109+
continue
110+
}
111+
112+
suggestedPath := filepath.Join(legacyBaseDir, repoBase, legacyRel)
113+
114+
currentRel := relativeToRepo(mainRepoPath, worktreePath)
115+
suggestedRel := relativeToRepo(mainRepoPath, suggestedPath)
116+
117+
migrations = append(migrations, legacyWorktreeMigration{
118+
currentRel: currentRel,
119+
suggestedRel: suggestedRel,
120+
})
121+
}
122+
123+
return migrations
124+
}
125+
126+
func relativeToRepo(mainRepoPath, targetPath string) string {
127+
rel, err := filepath.Rel(mainRepoPath, targetPath)
128+
if err != nil {
129+
return targetPath
130+
}
131+
if !strings.HasPrefix(rel, "..") {
132+
rel = filepath.Join(".", rel)
133+
}
134+
return filepath.Clean(rel)
135+
}
136+
137+
func hasConfigFile(mainRepoPath string) bool {
138+
configPath := filepath.Join(mainRepoPath, config.ConfigFileName)
139+
_, err := os.Stat(configPath)
140+
return err == nil
141+
}

cmd/wtp/legacy_warning_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
12+
"github.com/satococoa/wtp/internal/config"
13+
"github.com/satococoa/wtp/internal/git"
14+
)
15+
16+
func TestDetectLegacyWorktreeMigrations(t *testing.T) {
17+
tempDir := t.TempDir()
18+
mainRepoPath := filepath.Join(tempDir, "repo")
19+
requireNoErr(t, os.MkdirAll(mainRepoPath, 0o755))
20+
21+
cfg := &config.Config{
22+
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
23+
}
24+
25+
legacyWorktreePath := filepath.Join(filepath.Dir(mainRepoPath), "worktrees", "feature", "foo")
26+
modernWorktreePath := filepath.Join(
27+
filepath.Dir(mainRepoPath),
28+
"worktrees",
29+
filepath.Base(mainRepoPath),
30+
"feature",
31+
"bar",
32+
)
33+
34+
worktrees := []git.Worktree{
35+
{Path: mainRepoPath, IsMain: true},
36+
{Path: legacyWorktreePath},
37+
{Path: modernWorktreePath},
38+
}
39+
40+
migrations := detectLegacyWorktreeMigrations(mainRepoPath, cfg, worktrees)
41+
42+
assert.Len(t, migrations, 1)
43+
expectedCurrent := filepath.Join("..", "worktrees", "feature", "foo")
44+
expectedSuggested := filepath.Join("..", "worktrees", filepath.Base(mainRepoPath), "feature", "foo")
45+
46+
assert.Equal(t, filepath.Clean(expectedCurrent), migrations[0].currentRel)
47+
assert.Equal(t, filepath.Clean(expectedSuggested), migrations[0].suggestedRel)
48+
}
49+
50+
func TestMaybeWarnLegacyWorktreeLayout_EmitsWarningWithoutConfig(t *testing.T) {
51+
tempDir := t.TempDir()
52+
mainRepoPath := filepath.Join(tempDir, "repo")
53+
requireNoErr(t, os.MkdirAll(mainRepoPath, 0o755))
54+
55+
cfg := &config.Config{
56+
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
57+
}
58+
59+
legacyWorktree := filepath.Join(filepath.Dir(mainRepoPath), "worktrees", "feature", "foo")
60+
worktrees := []git.Worktree{
61+
{Path: mainRepoPath, IsMain: true},
62+
{Path: legacyWorktree},
63+
}
64+
65+
var buf bytes.Buffer
66+
maybeWarnLegacyWorktreeLayout(&buf, mainRepoPath, cfg, worktrees)
67+
68+
output := buf.String()
69+
assert.NotEmpty(t, output)
70+
assert.Contains(t, output, "Legacy worktree layout detected")
71+
72+
moveCommand := strings.Join([]string{
73+
"git worktree move",
74+
filepath.Join("..", "worktrees", "feature", "foo"),
75+
filepath.Join("..", "worktrees", filepath.Base(mainRepoPath), "feature", "foo"),
76+
}, " ")
77+
assert.Contains(t, output, moveCommand)
78+
}
79+
80+
func TestMaybeWarnLegacyWorktreeLayout_SuppressedWhenConfigPresent(t *testing.T) {
81+
tempDir := t.TempDir()
82+
mainRepoPath := filepath.Join(tempDir, "repo")
83+
requireNoErr(t, os.MkdirAll(mainRepoPath, 0o755))
84+
85+
configPath := filepath.Join(mainRepoPath, config.ConfigFileName)
86+
requireNoErr(t, os.WriteFile(configPath, []byte("version: 1.0\n"), 0o600))
87+
88+
cfg := &config.Config{
89+
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
90+
}
91+
92+
worktrees := []git.Worktree{
93+
{Path: mainRepoPath, IsMain: true},
94+
{Path: filepath.Join(filepath.Dir(mainRepoPath), "worktrees", "feature", "foo")},
95+
}
96+
97+
var buf bytes.Buffer
98+
maybeWarnLegacyWorktreeLayout(&buf, mainRepoPath, cfg, worktrees)
99+
100+
assert.Empty(t, buf.String())
101+
}
102+
103+
func requireNoErr(t *testing.T, err error) {
104+
t.Helper()
105+
if err != nil {
106+
t.Fatalf("unexpected error: %v", err)
107+
}
108+
}

cmd/wtp/list.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,10 @@ func listCommand(_ context.Context, cmd *cli.Command) error {
103103
return errors.GitCommandFailed("get main worktree path", err.Error())
104104
}
105105

106+
rootCmd := cmd.Root()
107+
106108
// Get the writer from cli.Command
107-
w := cmd.Root().Writer
109+
w := rootCmd.Writer
108110
if w == nil {
109111
w = os.Stdout
110112
}
@@ -124,7 +126,7 @@ func listCommand(_ context.Context, cmd *cli.Command) error {
124126
}
125127

126128
func listCommandWithCommandExecutor(
127-
_ *cli.Command, w io.Writer, executor command.Executor, cfg *config.Config, mainRepoPath string, quiet bool,
129+
cmd *cli.Command, w io.Writer, executor command.Executor, cfg *config.Config, mainRepoPath string, quiet bool,
128130
opts listDisplayOptions,
129131
) error {
130132
// Get current working directory
@@ -150,6 +152,16 @@ func listCommandWithCommandExecutor(
150152
return nil
151153
}
152154

155+
errWriter := io.Discard
156+
if cmd != nil {
157+
if root := cmd.Root(); root != nil && root.ErrWriter != nil {
158+
errWriter = root.ErrWriter
159+
} else if root != nil && root.ErrWriter == nil {
160+
errWriter = os.Stderr
161+
}
162+
}
163+
maybeWarnLegacyWorktreeLayout(errWriter, mainRepoPath, cfg, worktrees)
164+
153165
// Display worktrees
154166
if quiet {
155167
displayWorktreesQuiet(w, worktrees, cfg, mainRepoPath)

0 commit comments

Comments
 (0)