Skip to content

Commit 5345d77

Browse files
authored
fix: handle spaces in home directory paths on Windows (#94)
* fix: handle spaces in home directory paths on Windows - expandHome: support ~\ (backslash) prefix and expand $HOME/${HOME}/%USERPROFILE% via os.ExpandEnv - remove: use porcelain worktree list to avoid strings.Fields splitting paths with spaces - shellenv: shell-quote arguments in Linux script(1) invocation to preserve spaces - config template: quote $WT_PATH/$WT_MAIN in hook examples Closes #93 * fix: expand Windows %VAR% style environment variables in config paths os.ExpandEnv only handles $VAR and ${VAR}. Add expandWindowsEnv for %USERPROFILE% and similar Windows-style variables, called on Windows only. Follow-up from Codex review of #94. * fix: expand env vars in tilde paths like ~/$TEAM/worktrees The tilde branch returned early, skipping os.ExpandEnv. Now env expansion runs on all paths including those starting with ~. Follow-up from Codex review round 2. * fix: check return value of os.Stdout.WriteString (errcheck)
1 parent c67a53e commit 5345d77

5 files changed

Lines changed: 217 additions & 20 deletions

File tree

cmd/config.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,12 @@ const defaultConfigTemplate = `# wt configuration file
110110
# $WT_REPO_NAME, $WT_REPO_HOST, $WT_REPO_OWNER
111111
# Pre-hooks abort on failure; post-hooks warn only.
112112
# Set WT_HOOKS_DISABLED=1 to skip all hooks.
113+
# NOTE: Always quote path variables ("$WT_PATH") to handle spaces in paths.
113114
#
114115
# [hooks]
115-
# post_create = ["test -f $WT_MAIN/.env && cp $WT_MAIN/.env $WT_PATH/.env || true"]
116-
# post_checkout = ["cd $WT_PATH && npm install"]
117-
# pre_remove = ["echo Removing $WT_PATH"]
116+
# post_create = ["test -f \"$WT_MAIN/.env\" && cp \"$WT_MAIN/.env\" \"$WT_PATH/.env\" || true"]
117+
# post_checkout = ["cd \"$WT_PATH\" && npm install"]
118+
# pre_remove = ["echo \"Removing $WT_PATH\""]
118119
`
119120

120121
// configDir returns the directory where wt config files are stored.
@@ -271,14 +272,41 @@ func loadWorktreeConfig() {
271272
}
272273
}
273274

274-
// expandHome replaces a leading ~ with the user's home directory.
275+
// expandHome replaces a leading ~ with the user's home directory
276+
// and expands environment variables ($VAR, ${VAR}, and %VAR% on Windows).
275277
func expandHome(path string) string {
276-
if strings.HasPrefix(path, "~/") || path == "~" {
278+
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) || path == "~" {
277279
home, err := os.UserHomeDir()
278280
if err != nil {
279281
return path
280282
}
281-
return filepath.Join(home, path[1:])
283+
path = filepath.Join(home, path[1:])
284+
}
285+
expanded := os.ExpandEnv(path)
286+
if runtime.GOOS == "windows" {
287+
expanded = expandWindowsEnv(expanded)
288+
}
289+
return expanded
290+
}
291+
292+
// expandWindowsEnv expands %VAR% style environment variables.
293+
func expandWindowsEnv(path string) string {
294+
for {
295+
start := strings.Index(path, "%")
296+
if start == -1 {
297+
break
298+
}
299+
end := strings.Index(path[start+1:], "%")
300+
if end == -1 {
301+
break
302+
}
303+
end += start + 1
304+
varName := path[start+1 : end]
305+
if val, ok := os.LookupEnv(varName); ok {
306+
path = path[:start] + val + path[end+1:]
307+
} else {
308+
path = path[:start] + path[end+1:]
309+
}
282310
}
283311
return path
284312
}

cmd/config_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,63 @@ func TestExpandHome(t *testing.T) {
117117
path: "",
118118
want: "",
119119
},
120+
{
121+
name: "expands ~\\ backslash path",
122+
path: `~\projects\worktrees`,
123+
want: filepath.Join(home, `\projects\worktrees`),
124+
},
125+
}
126+
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
got := expandHome(tt.path)
130+
if got != tt.want {
131+
t.Errorf("expandHome(%q) = %q, want %q", tt.path, got, tt.want)
132+
}
133+
})
134+
}
135+
}
136+
137+
func TestExpandHomeEnvVars(t *testing.T) {
138+
t.Setenv("WT_TEST_DIR", "/custom/path")
139+
t.Setenv("WT_TEST_TEAM", "myteam")
140+
141+
tests := []struct {
142+
name string
143+
path string
144+
want string
145+
}{
146+
{
147+
name: "expands $VAR",
148+
path: "$WT_TEST_DIR/worktrees",
149+
want: "/custom/path/worktrees",
150+
},
151+
{
152+
name: "expands ${VAR}",
153+
path: "${WT_TEST_DIR}/worktrees",
154+
want: "/custom/path/worktrees",
155+
},
156+
{
157+
name: "tilde with env var in rest of path",
158+
path: "~/$WT_TEST_TEAM/worktrees",
159+
want: func() string {
160+
home, _ := os.UserHomeDir()
161+
return filepath.Join(home, "myteam", "worktrees")
162+
}(),
163+
},
164+
{
165+
name: "tilde alone still works",
166+
path: "~/worktrees",
167+
want: func() string {
168+
home, _ := os.UserHomeDir()
169+
return filepath.Join(home, "worktrees")
170+
}(),
171+
},
172+
{
173+
name: "unset var expands to empty",
174+
path: "$WT_NONEXISTENT_VAR/worktrees",
175+
want: "/worktrees",
176+
},
120177
}
121178

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

189+
func TestExpandWindowsEnv(t *testing.T) {
190+
t.Setenv("WT_WIN_TEST", `C:\Users\TestUser`)
191+
192+
tests := []struct {
193+
name string
194+
path string
195+
want string
196+
}{
197+
{
198+
name: "expands %VAR%",
199+
path: `%WT_WIN_TEST%\worktrees`,
200+
want: `C:\Users\TestUser\worktrees`,
201+
},
202+
{
203+
name: "expands multiple %VAR%",
204+
path: `%WT_WIN_TEST%\%WT_WIN_TEST%`,
205+
want: `C:\Users\TestUser\C:\Users\TestUser`,
206+
},
207+
{
208+
name: "unset %VAR% expands to empty",
209+
path: `%WT_NONEXISTENT%\worktrees`,
210+
want: `\worktrees`,
211+
},
212+
{
213+
name: "no percent signs unchanged",
214+
path: `C:\plain\path`,
215+
want: `C:\plain\path`,
216+
},
217+
{
218+
name: "single percent sign unchanged",
219+
path: `50% done`,
220+
want: `50% done`,
221+
},
222+
}
223+
224+
for _, tt := range tests {
225+
t.Run(tt.name, func(t *testing.T) {
226+
got := expandWindowsEnv(tt.path)
227+
if got != tt.want {
228+
t.Errorf("expandWindowsEnv(%q) = %q, want %q", tt.path, got, tt.want)
229+
}
230+
})
231+
}
232+
}
233+
132234
func TestWriteDefaultConfig(t *testing.T) {
133235
t.Run("creates config file", func(t *testing.T) {
134236
tmpDir := t.TempDir()

cmd/remove.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,9 @@ var removeCmd = &cobra.Command{
7171
// Find the main worktree path (for cd after removal)
7272
var mainWorktreePath string
7373
if inRemovedWorktree {
74-
listCmd := exec.Command("git", "worktree", "list")
75-
output, err := listCmd.Output()
76-
if err == nil {
77-
lines := strings.Split(string(output), "\n")
78-
if len(lines) > 0 {
79-
fields := strings.Fields(lines[0])
80-
if len(fields) > 0 {
81-
mainWorktreePath = fields[0]
82-
}
83-
}
74+
entries, err := getWorktreeListPorcelain()
75+
if err == nil && len(entries) > 0 {
76+
mainWorktreePath = entries[0].Path
8477
}
8578
}
8679

cmd/repo_case_test.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cmd
22

3-
import "testing"
3+
import (
4+
"strings"
5+
"testing"
6+
)
47

58
func TestFindWorktreeByBranchCaseInsensitiveFallback(t *testing.T) {
69
entries := []worktreeListEntry{
@@ -34,3 +37,68 @@ func TestFindWorktreeByBranchExactMatchWins(t *testing.T) {
3437
t.Fatalf("lookup path = %q, want exact path %q", got, want)
3538
}
3639
}
40+
41+
func TestFindWorktreeByBranchPathWithSpaces(t *testing.T) {
42+
entries := []worktreeListEntry{
43+
{Path: "/Users/John Doe/dev/worktrees/repo/main", Branch: "main"},
44+
{Path: "/Users/John Doe/dev/worktrees/repo/feature-x", Branch: "feature-x"},
45+
}
46+
47+
got, ok := findWorktreeByBranch(entries, "feature-x", false)
48+
if !ok {
49+
t.Fatal("lookup did not find worktree with spaces in path")
50+
}
51+
if want := "/Users/John Doe/dev/worktrees/repo/feature-x"; got != want {
52+
t.Fatalf("path = %q, want %q", got, want)
53+
}
54+
}
55+
56+
func TestParsePorcelainWithSpacesInPath(t *testing.T) {
57+
// Simulate git worktree list --porcelain output with spaces in paths.
58+
// The old non-porcelain approach using strings.Fields would split
59+
// "C:\Users\John Doe\dev\repo" into ["C:\Users\John", "Doe\dev\repo"].
60+
// The porcelain format handles this correctly.
61+
porcelainOutput := "worktree /Users/John Doe/dev/repo\nHEAD abc123\nbranch refs/heads/main\n\n" +
62+
"worktree /Users/John Doe/dev/worktrees/repo/feature-x\nHEAD def456\nbranch refs/heads/feature-x\n\n"
63+
64+
var entries []worktreeListEntry
65+
current := worktreeListEntry{}
66+
for _, rawLine := range strings.Split(porcelainOutput, "\n") {
67+
line := strings.TrimSpace(rawLine)
68+
if line == "" {
69+
if current.Path != "" {
70+
entries = append(entries, current)
71+
current = worktreeListEntry{}
72+
}
73+
continue
74+
}
75+
switch {
76+
case strings.HasPrefix(line, "worktree "):
77+
if current.Path != "" {
78+
entries = append(entries, current)
79+
}
80+
current = worktreeListEntry{Path: strings.TrimPrefix(line, "worktree ")}
81+
case strings.HasPrefix(line, "HEAD "):
82+
current.HEAD = strings.TrimPrefix(line, "HEAD ")
83+
case strings.HasPrefix(line, "branch "):
84+
branch := strings.TrimPrefix(line, "branch ")
85+
current.Branch = strings.TrimPrefix(branch, "refs/heads/")
86+
}
87+
}
88+
if current.Path != "" {
89+
entries = append(entries, current)
90+
}
91+
92+
if len(entries) != 2 {
93+
t.Fatalf("got %d entries, want 2", len(entries))
94+
}
95+
if entries[0].Path != "/Users/John Doe/dev/repo" {
96+
t.Errorf("entries[0].Path = %q, want path with space preserved", entries[0].Path)
97+
}
98+
if entries[1].Path != "/Users/John Doe/dev/worktrees/repo/feature-x" {
99+
t.Errorf("entries[1].Path = %q, want path with space preserved", entries[1].Path)
100+
}
101+
if entries[0].Branch != "main" {
102+
t.Errorf("entries[0].Branch = %q, want %q", entries[0].Branch, "main")
103+
}
104+
}

cmd/shellenv.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
56
"runtime"
67

78
"github.com/spf13/cobra"
@@ -112,7 +113,7 @@ Register-ArgumentCompleter -CommandName wt -ScriptBlock {
112113
}
113114

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

0 commit comments

Comments
 (0)