Skip to content
Merged
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
40 changes: 34 additions & 6 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,12 @@ const defaultConfigTemplate = `# wt configuration file
# $WT_REPO_NAME, $WT_REPO_HOST, $WT_REPO_OWNER
# Pre-hooks abort on failure; post-hooks warn only.
# Set WT_HOOKS_DISABLED=1 to skip all hooks.
# NOTE: Always quote path variables ("$WT_PATH") to handle spaces in paths.
#
# [hooks]
# post_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]
# post_checkout = ["cd $WT_PATH && npm install"]
# pre_remove = ["echo Removing $WT_PATH"]
# post_create = ["test -f \"$WT_MAIN/.env\" && cp \"$WT_MAIN/.env\" \"$WT_PATH/.env\" || true"]
# post_checkout = ["cd \"$WT_PATH\" && npm install"]
# pre_remove = ["echo \"Removing $WT_PATH\""]
`

// configDir returns the directory where wt config files are stored.
Expand Down Expand Up @@ -271,14 +272,41 @@ func loadWorktreeConfig() {
}
}

// expandHome replaces a leading ~ with the user's home directory.
// expandHome replaces a leading ~ with the user's home directory
// and expands environment variables ($VAR, ${VAR}, and %VAR% on Windows).
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") || path == "~" {
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) || path == "~" {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
path = filepath.Join(home, path[1:])
}
expanded := os.ExpandEnv(path)
if runtime.GOOS == "windows" {
expanded = expandWindowsEnv(expanded)
}
return expanded
}

// expandWindowsEnv expands %VAR% style environment variables.
func expandWindowsEnv(path string) string {
for {
start := strings.Index(path, "%")
if start == -1 {
break
}
end := strings.Index(path[start+1:], "%")
if end == -1 {
break
}
end += start + 1
varName := path[start+1 : end]
if val, ok := os.LookupEnv(varName); ok {
path = path[:start] + val + path[end+1:]
} else {
path = path[:start] + path[end+1:]
}
}
return path
}
Expand Down
102 changes: 102 additions & 0 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,63 @@ func TestExpandHome(t *testing.T) {
path: "",
want: "",
},
{
name: "expands ~\\ backslash path",
path: `~\projects\worktrees`,
want: filepath.Join(home, `\projects\worktrees`),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandHome(tt.path)
if got != tt.want {
t.Errorf("expandHome(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}

func TestExpandHomeEnvVars(t *testing.T) {
t.Setenv("WT_TEST_DIR", "/custom/path")
t.Setenv("WT_TEST_TEAM", "myteam")

tests := []struct {
name string
path string
want string
}{
{
name: "expands $VAR",
path: "$WT_TEST_DIR/worktrees",
want: "/custom/path/worktrees",
},
{
name: "expands ${VAR}",
path: "${WT_TEST_DIR}/worktrees",
want: "/custom/path/worktrees",
},
{
name: "tilde with env var in rest of path",
path: "~/$WT_TEST_TEAM/worktrees",
want: func() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "myteam", "worktrees")
}(),
},
{
name: "tilde alone still works",
path: "~/worktrees",
want: func() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "worktrees")
}(),
},
{
name: "unset var expands to empty",
path: "$WT_NONEXISTENT_VAR/worktrees",
want: "/worktrees",
},
}

for _, tt := range tests {
Expand All @@ -129,6 +186,51 @@ func TestExpandHome(t *testing.T) {
}
}

func TestExpandWindowsEnv(t *testing.T) {
t.Setenv("WT_WIN_TEST", `C:\Users\TestUser`)

tests := []struct {
name string
path string
want string
}{
{
name: "expands %VAR%",
path: `%WT_WIN_TEST%\worktrees`,
want: `C:\Users\TestUser\worktrees`,
},
{
name: "expands multiple %VAR%",
path: `%WT_WIN_TEST%\%WT_WIN_TEST%`,
want: `C:\Users\TestUser\C:\Users\TestUser`,
},
{
name: "unset %VAR% expands to empty",
path: `%WT_NONEXISTENT%\worktrees`,
want: `\worktrees`,
},
{
name: "no percent signs unchanged",
path: `C:\plain\path`,
want: `C:\plain\path`,
},
{
name: "single percent sign unchanged",
path: `50% done`,
want: `50% done`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandWindowsEnv(tt.path)
if got != tt.want {
t.Errorf("expandWindowsEnv(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}

func TestWriteDefaultConfig(t *testing.T) {
t.Run("creates config file", func(t *testing.T) {
tmpDir := t.TempDir()
Expand Down
13 changes: 3 additions & 10 deletions cmd/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,9 @@ var removeCmd = &cobra.Command{
// Find the main worktree path (for cd after removal)
var mainWorktreePath string
if inRemovedWorktree {
listCmd := exec.Command("git", "worktree", "list")
output, err := listCmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
if len(lines) > 0 {
fields := strings.Fields(lines[0])
if len(fields) > 0 {
mainWorktreePath = fields[0]
}
}
entries, err := getWorktreeListPorcelain()
if err == nil && len(entries) > 0 {
mainWorktreePath = entries[0].Path
}
}

Expand Down
70 changes: 69 additions & 1 deletion cmd/repo_case_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import "testing"
import (
"strings"
"testing"
)

func TestFindWorktreeByBranchCaseInsensitiveFallback(t *testing.T) {
entries := []worktreeListEntry{
Expand Down Expand Up @@ -34,3 +37,68 @@ func TestFindWorktreeByBranchExactMatchWins(t *testing.T) {
t.Fatalf("lookup path = %q, want exact path %q", got, want)
}
}

func TestFindWorktreeByBranchPathWithSpaces(t *testing.T) {
entries := []worktreeListEntry{
{Path: "/Users/John Doe/dev/worktrees/repo/main", Branch: "main"},
{Path: "/Users/John Doe/dev/worktrees/repo/feature-x", Branch: "feature-x"},
}

got, ok := findWorktreeByBranch(entries, "feature-x", false)
if !ok {
t.Fatal("lookup did not find worktree with spaces in path")
}
if want := "/Users/John Doe/dev/worktrees/repo/feature-x"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
}

func TestParsePorcelainWithSpacesInPath(t *testing.T) {
// Simulate git worktree list --porcelain output with spaces in paths.
// The old non-porcelain approach using strings.Fields would split
// "C:\Users\John Doe\dev\repo" into ["C:\Users\John", "Doe\dev\repo"].
// The porcelain format handles this correctly.
porcelainOutput := "worktree /Users/John Doe/dev/repo\nHEAD abc123\nbranch refs/heads/main\n\n" +
"worktree /Users/John Doe/dev/worktrees/repo/feature-x\nHEAD def456\nbranch refs/heads/feature-x\n\n"

var entries []worktreeListEntry
current := worktreeListEntry{}
for _, rawLine := range strings.Split(porcelainOutput, "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
if current.Path != "" {
entries = append(entries, current)
current = worktreeListEntry{}
}
continue
}
switch {
case strings.HasPrefix(line, "worktree "):
if current.Path != "" {
entries = append(entries, current)
}
current = worktreeListEntry{Path: strings.TrimPrefix(line, "worktree ")}
case strings.HasPrefix(line, "HEAD "):
current.HEAD = strings.TrimPrefix(line, "HEAD ")
case strings.HasPrefix(line, "branch "):
branch := strings.TrimPrefix(line, "branch ")
current.Branch = strings.TrimPrefix(branch, "refs/heads/")
}
}
if current.Path != "" {
entries = append(entries, current)
}

if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
if entries[0].Path != "/Users/John Doe/dev/repo" {
t.Errorf("entries[0].Path = %q, want path with space preserved", entries[0].Path)
}
if entries[1].Path != "/Users/John Doe/dev/worktrees/repo/feature-x" {
t.Errorf("entries[1].Path = %q, want path with space preserved", entries[1].Path)
}
if entries[0].Branch != "main" {
t.Errorf("entries[0].Branch = %q, want %q", entries[0].Branch, "main")
}
}
12 changes: 9 additions & 3 deletions cmd/shellenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"runtime"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -112,7 +113,7 @@ Register-ArgumentCompleter -CommandName wt -ScriptBlock {
}

// Bash/Zsh integration for Unix systems
fmt.Print(`wt() {
_, _ = os.Stdout.WriteString(`wt() {
# Avoid wrapping shellenv generation itself through script(1)
# to prevent control characters in process substitution output.
if [ "$1" = "shellenv" ]; then
Expand All @@ -138,8 +139,13 @@ Register-ArgumentCompleter -CommandName wt -ScriptBlock {
# macOS: script -q file command args
script -q "$log_file" /bin/sh -c 'command wt "$@"' wt "$@"
else
# Linux: script -q -c "command wt $*" "$log_file"
script -q -c "command wt $*" "$log_file"
# Linux: script -q -c "..." file — must pass command as single string,
# so we shell-quote each argument to preserve spaces and special chars.
local quoted_args=""
for arg in "$@"; do
quoted_args="$quoted_args $(printf '%q' "$arg")"
done
script -q -c "command wt$quoted_args" "$log_file"
fi
exit_code=$?

Expand Down
Loading