Skip to content

Commit 36bae3b

Browse files
committed
Fix Windows compatibility in path handling and test fixtures
Replace hardcoded Unix root fallbacks (dir = "/") with a cross-platform fsx.HomeOrRoot() helper. Fix shortenHomePath to use filepath.Separator instead of "/". Expand expandHome to handle bare ~ and ~\ in addition to ~/. Make EvalSymlinks in scope resolution fall back gracefully when the path does not exist. Replace ~80 hardcoded Unix paths in test fixtures with filepath.Join-based portable construction.
1 parent add77cd commit 36bae3b

12 files changed

Lines changed: 259 additions & 124 deletions

File tree

cmd/loadout/cmd/init.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,11 @@ func defaultRepoPath() string {
372372
}
373373

374374
func expandHome(path string) string {
375-
if strings.HasPrefix(path, "~/") {
375+
if path == "~" {
376+
home, _ := os.UserHomeDir()
377+
return home
378+
}
379+
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~"+string(filepath.Separator)) {
376380
home, _ := os.UserHomeDir()
377381
return filepath.Join(home, path[2:])
378382
}

cmd/loadout/cmd/init_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,12 @@ func TestExpandHome(t *testing.T) {
233233
in string
234234
want string
235235
}{
236+
{"~", home},
236237
{"~/foo", filepath.Join(home, "foo")},
238+
{"~" + string(filepath.Separator) + "bar", filepath.Join(home, "bar")},
237239
{"/abs/path", "/abs/path"},
238240
{"relative", "relative"},
241+
{"~user", "~user"},
239242
}
240243
for _, tt := range tests {
241244
got := expandHome(tt.in)

internal/config/config_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ func TestSaveLoad(t *testing.T) {
1111
path := filepath.Join(dir, "config.json")
1212

1313
cfg := Config{
14-
RepoPath: "/tmp/repo",
14+
RepoPath: filepath.Join(dir, "repo"),
1515
Targets: TargetPaths{
16-
Claude: TargetConfig{Enabled: true, Path: "/tmp/claude"},
17-
Codex: TargetConfig{Enabled: true, Path: "/tmp/codex"},
16+
Claude: TargetConfig{Enabled: true, Path: filepath.Join(dir, "claude")},
17+
Codex: TargetConfig{Enabled: true, Path: filepath.Join(dir, "codex")},
1818
},
1919
RepoActions: RepoActions{
2020
ImportAutoCommit: false,

internal/fsx/file.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ func EnsureDir(path string) error {
6464
return os.MkdirAll(path, 0o755)
6565
}
6666

67+
// HomeOrRoot returns the user's home directory, or the filesystem root
68+
// if the home directory cannot be determined.
69+
func HomeOrRoot() string {
70+
if home, err := os.UserHomeDir(); err == nil {
71+
return home
72+
}
73+
return string(filepath.Separator)
74+
}
75+
6776
// ListDirs returns the names of immediate subdirectories in path.
6877
func ListDirs(path string) ([]string, error) {
6978
entries, err := os.ReadDir(path)

internal/fsx/file_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package fsx
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestHomeOrRoot(t *testing.T) {
10+
got := HomeOrRoot()
11+
if got == "" {
12+
t.Fatal("HomeOrRoot() returned empty string")
13+
}
14+
15+
// On a normal system, should return the home directory.
16+
home, err := os.UserHomeDir()
17+
if err == nil && got != home {
18+
t.Errorf("HomeOrRoot() = %q, want %q", got, home)
19+
}
20+
21+
// Result should always be a valid absolute path or the separator root.
22+
if !filepath.IsAbs(got) && got != string(filepath.Separator) {
23+
t.Errorf("HomeOrRoot() = %q, want absolute path or root", got)
24+
}
25+
}

internal/scope/scope.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package scope
22

33
import (
4+
"errors"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -52,12 +53,18 @@ func Resolve(projectFlag string) (Scope, error) {
5253
if err != nil {
5354
return Scope{}, fmt.Errorf("resolve path: %w", err)
5455
}
55-
// Resolve symlinks for consistent path comparison
56+
// Resolve symlinks for consistent path comparison.
57+
// Fall back to the absolute path if it does not exist yet
58+
// (e.g. Windows without elevated symlink privileges).
5659
resolved, err := filepath.EvalSymlinks(abs)
57-
if err != nil {
60+
switch {
61+
case err == nil:
62+
startDir = resolved
63+
case errors.Is(err, os.ErrNotExist):
64+
startDir = abs
65+
default:
5866
return Scope{}, fmt.Errorf("resolve symlinks: %w", err)
5967
}
60-
startDir = resolved
6168
}
6269

6370
root, err := DetectProjectRoot(startDir)

internal/scope/scope_test.go

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package scope
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78

89
"github.com/sethdeckard/loadout/internal/config"
@@ -136,6 +137,44 @@ func TestResolve_ExplicitPath(t *testing.T) {
136137
}
137138
}
138139

140+
func TestResolve_ExplicitPath_NoSymlinks(t *testing.T) {
141+
// A real directory that is not a symlink should resolve without error.
142+
dir := resolveSymlinks(t, t.TempDir())
143+
gitDir := filepath.Join(dir, ".git")
144+
claudeDir := filepath.Join(dir, ".claude")
145+
if err := os.MkdirAll(gitDir, 0o755); err != nil {
146+
t.Fatalf("setup .git: %v", err)
147+
}
148+
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
149+
t.Fatalf("setup .claude: %v", err)
150+
}
151+
152+
sc, err := Resolve(dir)
153+
if err != nil {
154+
t.Fatalf("Resolve(%q) error = %v", dir, err)
155+
}
156+
if sc.Project != dir {
157+
t.Errorf("Project = %q, want %q", sc.Project, dir)
158+
}
159+
}
160+
161+
func TestResolve_ExplicitPath_NonexistentFallsBackToAbs(t *testing.T) {
162+
// When the explicit path does not exist, EvalSymlinks returns
163+
// os.ErrNotExist and Resolve should fall back to the absolute path
164+
// rather than returning an error.
165+
missing := filepath.Join(t.TempDir(), "does-not-exist")
166+
_, err := Resolve(missing)
167+
// The path doesn't contain .git so DetectProjectRoot will fail,
168+
// but the important thing is we get past the EvalSymlinks step.
169+
if err == nil {
170+
t.Fatal("expected error from DetectProjectRoot, got nil")
171+
}
172+
// Should NOT be a "resolve symlinks" error.
173+
if strings.Contains(err.Error(), "resolve symlinks") {
174+
t.Errorf("error = %q, should not fail at symlink resolution for missing path", err)
175+
}
176+
}
177+
139178
func TestResolve_ExplicitPath_Invalid(t *testing.T) {
140179
dir := t.TempDir()
141180
// No .git
@@ -146,37 +185,40 @@ func TestResolve_ExplicitPath_Invalid(t *testing.T) {
146185
}
147186

148187
func TestTargetRoot_User(t *testing.T) {
188+
claudePath := filepath.Join(os.TempDir(), "user", ".claude", "skills")
189+
codexPath := filepath.Join(os.TempDir(), "user", ".codex", "skills")
149190
sc := Scope{Project: ""}
150191
paths := config.TargetPaths{
151-
Claude: config.TargetConfig{Enabled: true, Path: "/home/user/.claude/skills"},
152-
Codex: config.TargetConfig{Enabled: true, Path: "/home/user/.codex/skills"},
192+
Claude: config.TargetConfig{Enabled: true, Path: claudePath},
193+
Codex: config.TargetConfig{Enabled: true, Path: codexPath},
153194
}
154195

155196
got := sc.TargetRoot(domain.TargetClaude, paths)
156-
if got != "/home/user/.claude/skills" {
197+
if got != claudePath {
157198
t.Errorf("TargetRoot(claude) = %q, want user path", got)
158199
}
159200
got = sc.TargetRoot(domain.TargetCodex, paths)
160-
if got != "/home/user/.codex/skills" {
201+
if got != codexPath {
161202
t.Errorf("TargetRoot(codex) = %q, want user path", got)
162203
}
163204
}
164205

165206
func TestTargetRoot_Project(t *testing.T) {
166-
sc := Scope{Project: "/projects/my-app"}
207+
projectDir := filepath.Join(os.TempDir(), "projects", "my-app")
208+
sc := Scope{Project: projectDir}
167209
paths := config.TargetPaths{
168-
Claude: config.TargetConfig{Enabled: true, Path: "/home/user/.claude/skills"},
169-
Codex: config.TargetConfig{Enabled: true, Path: "/home/user/.codex/skills"},
210+
Claude: config.TargetConfig{Enabled: true, Path: filepath.Join(os.TempDir(), "user", ".claude", "skills")},
211+
Codex: config.TargetConfig{Enabled: true, Path: filepath.Join(os.TempDir(), "user", ".codex", "skills")},
170212
}
171213

172214
got := sc.TargetRoot(domain.TargetClaude, paths)
173-
want := filepath.Join("/projects/my-app", ".claude", "skills")
215+
want := filepath.Join(projectDir, ".claude", "skills")
174216
if got != want {
175217
t.Errorf("TargetRoot(claude) = %q, want %q", got, want)
176218
}
177219

178220
got = sc.TargetRoot(domain.TargetCodex, paths)
179-
want = filepath.Join("/projects/my-app", ".codex", "skills")
221+
want = filepath.Join(projectDir, ".codex", "skills")
180222
if got != want {
181223
t.Errorf("TargetRoot(codex) = %q, want %q", got, want)
182224
}

internal/tui/commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/sethdeckard/loadout/internal/app"
1212
"github.com/sethdeckard/loadout/internal/config"
1313
"github.com/sethdeckard/loadout/internal/domain"
14+
"github.com/sethdeckard/loadout/internal/fsx"
1415
)
1516

1617
// Messages
@@ -176,7 +177,7 @@ func startImportCmd(svc *app.Service, projectRoot string) tea.Cmd {
176177
return func() tea.Msg {
177178
dir, err := os.Getwd()
178179
if err != nil {
179-
dir = "/"
180+
dir = fsx.HomeOrRoot()
180181
}
181182
if projectRoot != "" {
182183
dir = projectRoot

internal/tui/model.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/sethdeckard/loadout/internal/app"
1010
"github.com/sethdeckard/loadout/internal/config"
1111
"github.com/sethdeckard/loadout/internal/domain"
12+
"github.com/sethdeckard/loadout/internal/fsx"
1213
)
1314

1415
type paneID int
@@ -298,7 +299,7 @@ func (m *Model) openBrowse() tea.Cmd {
298299
dir, _ = os.Getwd()
299300
}
300301
if dir == "" {
301-
dir = "/"
302+
dir = fsx.HomeOrRoot()
302303
}
303304
return m.openBrowseAt(dir)
304305
}
@@ -343,7 +344,7 @@ func (m *Model) startImportForCurrentScope() tea.Cmd {
343344
dir, _ = os.Getwd()
344345
}
345346
if dir == "" {
346-
dir = "/"
347+
dir = fsx.HomeOrRoot()
347348
}
348349
m.loading = false
349350
m.importStartDir = dir

0 commit comments

Comments
 (0)