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
7 changes: 2 additions & 5 deletions cmd/wtp/cd.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func isWorktreeManagedCd(worktreePath string, cfg *config.Config, mainRepoPath s
// Create default config when none is available
defaultCfg := &config.Config{
Defaults: config.Defaults{
BaseDir: "../worktrees",
BaseDir: config.DefaultBaseDir,
},
}
cfg = defaultCfg
Expand Down Expand Up @@ -278,10 +278,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
2 changes: 1 addition & 1 deletion cmd/wtp/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func isWorktreeManagedList(worktreePath string, cfg *config.Config, mainRepoPath
// Create default config when none is available
defaultCfg := &config.Config{
Defaults: config.Defaults{
BaseDir: "../worktrees",
BaseDir: config.DefaultBaseDir,
},
}
cfg = defaultCfg
Expand Down
22 changes: 11 additions & 11 deletions cmd/wtp/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestListCommand_CommandConstruction(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -178,7 +178,7 @@ func TestListCommand_Output(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -223,7 +223,7 @@ func TestListCommand_ExecutionError(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand All @@ -245,7 +245,7 @@ func TestListCommand_NoWorktrees(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -306,7 +306,7 @@ func TestListCommand_InternationalCharacters(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -374,7 +374,7 @@ func TestListCommand_LongPaths(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -424,7 +424,7 @@ branch refs/heads/feature/test
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -455,7 +455,7 @@ func TestListCommand_HeaderFormatting(t *testing.T) {
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/test/repo")

Expand Down Expand Up @@ -558,7 +558,7 @@ branch refs/heads/feature/awesome
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/repo")

Expand Down Expand Up @@ -693,7 +693,7 @@ branch refs/heads/hoge
}

// Special handling for the base_dir test case
baseDir := "../worktrees"
baseDir := config.DefaultBaseDir
if tt.name == "paths relative to main worktree when in subdirectory" {
baseDir = ".worktrees"
mainRepoPath = "/Users/satoshi/dev/src/github.com/satococoa/giselle"
Expand Down Expand Up @@ -784,7 +784,7 @@ branch refs/heads/feature/long-branch-name-that-might-also-be-truncated
cmd := &cli.Command{}

cfg := &config.Config{
Defaults: config.Defaults{BaseDir: "../worktrees"},
Defaults: config.Defaults{BaseDir: config.DefaultBaseDir},
}
err := listCommandWithCommandExecutor(cmd, &buf, mockExec, cfg, "/repo")

Expand Down
7 changes: 2 additions & 5 deletions cmd/wtp/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func isWorktreeManaged(worktreePath string, cfg *config.Config, mainRepoPath str
// Create default config when none is available
defaultCfg := &config.Config{
Defaults: config.Defaults{
BaseDir: "../worktrees",
BaseDir: config.DefaultBaseDir,
},
}
cfg = defaultCfg
Expand Down Expand Up @@ -288,10 +288,7 @@ func getWorktreeNameFromPath(worktreePath string, cfg *config.Config, mainRepoPa
}

// 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
Loading
Loading