Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ functionality with automated setup, branch tracking, and project-specific hooks.
solution:** `wtp add feature/auth`

wtp automatically generates sensible paths based on branch names. Your
`feature/auth` branch goes to `../worktrees/feature/auth` - no redundant typing,
`feature/auth` branch goes to `../worktrees/<repo-name>/feature/auth` - no redundant typing,
no path errors.

### 🧹 Clean Branch Management
Expand Down Expand Up @@ -127,20 +127,20 @@ sudo mv wtp /usr/local/bin/ # or add to PATH

```bash
# Create worktree from existing branch (local or remote)
# → Creates worktree at ../worktrees/feature/auth
# → Creates worktree at ../worktrees/<repo-name>/feature/auth
# Automatically tracks remote branch if not found locally
wtp add feature/auth

# Create worktree with new branch
# → Creates worktree at ../worktrees/feature/new-feature
# → Creates worktree at ../worktrees/<repo-name>/feature/new-feature
wtp add -b feature/new-feature

# Create new branch from specific commit
# → Creates worktree at ../worktrees/hotfix/urgent
# → Creates worktree at ../worktrees/<repo-name>/hotfix/urgent
wtp add -b hotfix/urgent abc1234

# Create new branch tracking a different remote branch
# → Creates worktree at ../worktrees/feature/test with branch tracking origin/main
# → Creates worktree at ../worktrees/<repo-name>/feature/test with branch tracking origin/main
wtp add -b feature/test origin/main

# Remote branch handling examples:
Expand Down Expand Up @@ -188,7 +188,8 @@ wtp uses `.wtp.yml` for project-specific configuration:
version: "1.0"
defaults:
# Base directory for worktrees (relative to project root)
base_dir: "../worktrees"
# ${WTP_REPO_BASENAME} expands to the repository directory name
base_dir: "../worktrees/${WTP_REPO_BASENAME}"

hooks:
post_create:
Expand All @@ -213,6 +214,17 @@ hooks:
work_dir: "."
```

The `${WTP_REPO_BASENAME}` placeholder expands to the repository's directory
name when resolving paths, ensuring zero-config isolation between different
repositories. You can combine it with additional path segments as needed.

> **Breaking change (vNEXT):** If you relied on the previous implicit default
> of `../worktrees` without a `.wtp.yml`, existing worktrees will now appear
> unmanaged because the new default expects
> `../worktrees/${WTP_REPO_BASENAME}`. Add a `.wtp.yml` with
> `base_dir: "../worktrees"` (or reorganize your worktrees) before upgrading
> to keep the legacy layout working.

### Copy Hooks: Main Worktree Reference

Copy hooks are designed to help you bootstrap new worktrees using files from
Expand Down Expand Up @@ -326,7 +338,7 @@ evaluates `wtp shell-init <shell>` once for your session—tab completion and

## Worktree Structure

With the default configuration (`base_dir: "../worktrees"`):
With the default configuration (`base_dir: "../worktrees/${WTP_REPO_BASENAME}"`):

```
<project-root>/
Expand All @@ -335,12 +347,13 @@ With the default configuration (`base_dir: "../worktrees"`):
└── src/

../worktrees/
├── main/
├── feature/
│ ├── auth/ # wtp add feature/auth
│ └── payment/ # wtp add feature/payment
└── hotfix/
└── bug-123/ # wtp add hotfix/bug-123
└── <repo-name>/
├── main/
├── feature/
│ ├── auth/ # wtp add feature/auth
│ └── payment/ # wtp add feature/payment
└── hotfix/
└── bug-123/ # wtp add hotfix/bug-123
```

Branch names with slashes are preserved as directory structure, automatically
Expand Down
30 changes: 24 additions & 6 deletions cmd/wtp/cd.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ func cdToWorktree(_ context.Context, cmd *cli.Command) error {
return errors.NotInGitRepository()
}

rootCmd := cmd.Root()

// Get the writer from cli.Command
w := cmd.Root().Writer
w := rootCmd.Writer
if w == nil {
w = os.Stdout
}
Expand All @@ -74,7 +76,7 @@ func cdToWorktree(_ context.Context, cmd *cli.Command) error {
}

func cdCommandWithCommandExecutor(
_ *cli.Command,
cmd *cli.Command,
w io.Writer,
executor command.Executor,
_ string,
Expand All @@ -93,6 +95,25 @@ func cdCommandWithCommandExecutor(
// Find the main worktree path
mainWorktreePath := findMainWorktreePath(worktrees)

errWriter := io.Discard
if cmd != nil {
if root := cmd.Root(); root != nil && root.ErrWriter != nil {
errWriter = root.ErrWriter
} else if root != nil && root.ErrWriter == nil {
errWriter = os.Stderr
}
}

if mainWorktreePath != "" {
cfgForWarning, cfgErr := config.LoadConfig(mainWorktreePath)
if cfgErr != nil {
cfgForWarning = &config.Config{
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
}
maybeWarnLegacyWorktreeLayout(errWriter, mainWorktreePath, cfgForWarning, worktrees)
}

// Find the worktree using multiple resolution strategies
targetPath := resolveCdWorktreePath(worktreeName, worktrees, mainWorktreePath)

Expand Down Expand Up @@ -240,10 +261,7 @@ func getWorktreeNameFromPathCd(worktreePath string, cfg *config.Config, mainRepo
}

// Get base_dir path
baseDir := cfg.Defaults.BaseDir
if !filepath.IsAbs(baseDir) {
baseDir = filepath.Join(mainRepoPath, baseDir)
}
baseDir := cfg.ResolveWorktreePath(mainRepoPath, "")

// Calculate relative path from base_dir
relPath, err := filepath.Rel(baseDir, worktreePath)
Expand Down
6 changes: 3 additions & 3 deletions cmd/wtp/cd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestCdCommand_AlwaysOutputsAbsolutePath(t *testing.T) {
HEAD abc123
branch refs/heads/main

worktree /Users/dev/project/worktrees/feature/auth
worktree /Users/dev/project/worktrees/main/feature/auth
HEAD def456
branch refs/heads/feature/auth

Expand All @@ -41,13 +41,13 @@ branch refs/heads/feature/auth
{
name: "feature worktree by branch name",
worktreeName: "feature/auth",
expectedPath: "/Users/dev/project/worktrees/feature/auth",
expectedPath: "/Users/dev/project/worktrees/main/feature/auth",
shouldSucceed: true,
},
{
name: "feature worktree by directory name",
worktreeName: "auth",
expectedPath: "/Users/dev/project/worktrees/feature/auth",
expectedPath: "/Users/dev/project/worktrees/main/feature/auth",
shouldSucceed: true, // Directory-based resolution works as expected
},
{
Expand Down
6 changes: 4 additions & 2 deletions cmd/wtp/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const configFileMode = 0o600

// Variable to allow mocking in tests
var osGetwd = os.Getwd
var writeFile = os.WriteFile

// NewInitCommand creates the init command definition
func NewInitCommand() *cli.Command {
Expand Down Expand Up @@ -54,7 +55,8 @@ version: "1.0"
# Default settings for worktrees
defaults:
# Base directory for worktrees (relative to repository root)
base_dir: ../worktrees
# ${WTP_REPO_BASENAME} expands to the repository directory name
base_dir: ../worktrees/${WTP_REPO_BASENAME}

# Hooks that run after creating a worktree
hooks:
Expand Down Expand Up @@ -87,7 +89,7 @@ hooks:
`

// Write configuration file with comments
if err := os.WriteFile(configPath, []byte(configContent), configFileMode); err != nil {
if err := writeFile(configPath, []byte(configContent), configFileMode); err != nil {
return errors.DirectoryAccessFailed("create configuration file", configPath, err)
}

Expand Down
8 changes: 7 additions & 1 deletion cmd/wtp/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func TestInitCommand_Success(t *testing.T) {
// Check for required sections
assert.Contains(t, contentStr, "version: \"1.0\"")
assert.Contains(t, contentStr, "defaults:")
assert.Contains(t, contentStr, "base_dir: ../worktrees")
assert.Contains(t, contentStr, "base_dir: ../worktrees/${WTP_REPO_BASENAME}")
assert.Contains(t, contentStr, "hooks:")
assert.Contains(t, contentStr, "post_create:")

Expand Down Expand Up @@ -198,6 +198,12 @@ func TestInitCommand_WriteFileError(t *testing.T) {

cmd := NewInitCommand()
ctx := context.Background()
originalWriteFile := writeFile
writeFile = func(string, []byte, os.FileMode) error {
return assert.AnError
}
defer func() { writeFile = originalWriteFile }()

err = cmd.Action(ctx, &cli.Command{})

assert.Error(t, err)
Expand Down
141 changes: 141 additions & 0 deletions cmd/wtp/legacy_warning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/satococoa/wtp/internal/config"
"github.com/satococoa/wtp/internal/git"
)

const legacyWarningExampleLimit = 3

type legacyWorktreeMigration struct {
currentRel string
suggestedRel string
}

func maybeWarnLegacyWorktreeLayout(
w io.Writer,
mainRepoPath string,
cfg *config.Config,
worktrees []git.Worktree,
) {
if w == nil {
w = os.Stderr
}

if cfg == nil || mainRepoPath == "" || len(worktrees) == 0 {
return
}

if hasConfigFile(mainRepoPath) {
return
}

migrations := detectLegacyWorktreeMigrations(mainRepoPath, cfg, worktrees)
if len(migrations) == 0 {
return
}

repoBase := filepath.Base(mainRepoPath)
fmt.Fprintln(w, "⚠️ Legacy worktree layout detected.")
fmt.Fprintf(w, " wtp now expects worktrees under '../worktrees/%s/...'\n", repoBase)
fmt.Fprintln(w, " Move existing worktrees to the new layout (run from the repository root):")

limit := len(migrations)
if limit > legacyWarningExampleLimit {
limit = legacyWarningExampleLimit
}
for i := 0; i < limit; i++ {
migration := migrations[i]
fmt.Fprintf(w, " git worktree move %s %s\n", migration.currentRel, migration.suggestedRel)
}
Comment on lines +55 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Quote the suggested git worktree move command.

If the repo name or worktree path contains spaces (totally valid on macOS/Windows), this suggestion becomes git worktree move ../worktrees/My Repo/foo ..., which breaks the one-liner the warning asks the user to run. Please emit shell-safe quoting so the guidance works everywhere.

-		fmt.Fprintf(w, "      git worktree move %s %s\n", migration.currentRel, migration.suggestedRel)
+		fmt.Fprintf(w, "      git worktree move %q %q\n", migration.currentRel, migration.suggestedRel)

This uses Go’s %q formatter to escape spaces and special characters consistently.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fmt.Fprintf(w, " git worktree move %s %s\n", migration.currentRel, migration.suggestedRel)
}
fmt.Fprintf(w, " git worktree move %q %q\n", migration.currentRel, migration.suggestedRel)
}
🤖 Prompt for AI Agents
In cmd/wtp/legacy_warning.go around lines 55-56, the formatted suggestion for
the git worktree move command doesn't quote paths, so names with spaces or
special characters will break the one-line suggestion; change the fmt.Fprintf
call to use Go's %q formatter for the path arguments (e.g., replace %s with %q
for migration.currentRel and migration.suggestedRel) so the output is shell-safe
and properly escaped.


if len(migrations) > legacyWarningExampleLimit {
fmt.Fprintf(w, " ... and %d more\n", len(migrations)-legacyWarningExampleLimit)
}

fmt.Fprintln(w, " (Alternatively, run 'wtp init' and set defaults.base_dir to keep a custom layout.)")
fmt.Fprintln(w)
}

func detectLegacyWorktreeMigrations(
mainRepoPath string,
cfg *config.Config,
worktrees []git.Worktree,
) []legacyWorktreeMigration {
if cfg == nil || mainRepoPath == "" {
return nil
}

mainRepoPath = filepath.Clean(mainRepoPath)

newBaseDir := filepath.Clean(cfg.ResolveWorktreePath(mainRepoPath, ""))
legacyBaseDir := filepath.Clean(filepath.Join(filepath.Dir(mainRepoPath), "worktrees"))
repoBase := filepath.Base(mainRepoPath)

if legacyBaseDir == newBaseDir {
return nil
}

var migrations []legacyWorktreeMigration
for _, wt := range worktrees {
if wt.IsMain {
continue
}

worktreePath := filepath.Clean(wt.Path)

if strings.HasPrefix(worktreePath, newBaseDir+string(os.PathSeparator)) ||
worktreePath == newBaseDir {
continue
}

if !strings.HasPrefix(worktreePath, legacyBaseDir+string(os.PathSeparator)) {
continue
}

legacyRel, err := filepath.Rel(legacyBaseDir, worktreePath)
if err != nil || legacyRel == "." {
continue
}

if strings.HasPrefix(legacyRel, repoBase+string(os.PathSeparator)) {
// Already under the new structure (worktrees/<repo>/...)
continue
}

suggestedPath := filepath.Join(legacyBaseDir, repoBase, legacyRel)

currentRel := relativeToRepo(mainRepoPath, worktreePath)
suggestedRel := relativeToRepo(mainRepoPath, suggestedPath)

migrations = append(migrations, legacyWorktreeMigration{
currentRel: currentRel,
suggestedRel: suggestedRel,
})
}

return migrations
}

func relativeToRepo(mainRepoPath, targetPath string) string {
rel, err := filepath.Rel(mainRepoPath, targetPath)
if err != nil {
return targetPath
}
if !strings.HasPrefix(rel, "..") {
rel = filepath.Join(".", rel)
}
return filepath.Clean(rel)
}

func hasConfigFile(mainRepoPath string) bool {
configPath := filepath.Join(mainRepoPath, config.ConfigFileName)
_, err := os.Stat(configPath)
return err == nil
}
Loading
Loading