Diff
1diff --git a/git/repo.go b/git/repo.go
2index 382f07ee0df1b9d0e2df26940ec37b8cf4b1a4e4..107fc571aa8f595443b838883d236fb8a0d08d38 100644
3--- a/git/repo.go
4+++ b/git/repo.go
5@@ -1,6 +1,7 @@
6 package git
7
8 import (
9+ gopath "path"
10 "path/filepath"
11 "strings"
12
13@@ -123,8 +124,9 @@ func (r *Repository) Tree(ref *Reference) (*Tree, error) {
14 }
15
16 // TreePath returns the tree for the given path.
17+// Git paths always use forward slashes regardless of OS.
18 func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
19- path = filepath.Clean(path)
20+ path = gopath.Clean(filepath.ToSlash(path))
21 if path == "." {
22 path = ""
23 }
24diff --git a/git/repo_test.go b/git/repo_test.go
25new file mode 100644
26index 0000000000000000000000000000000000000000..2e414762a2777e6bf4330818079e567f48ae5694
27--- /dev/null
28+++ b/git/repo_test.go
29@@ -0,0 +1,94 @@
30+package git
31+
32+import (
33+ "context"
34+ "os"
35+ "os/exec"
36+ "path/filepath"
37+ "strings"
38+ "testing"
39+)
40+
41+// setupTestRepo creates a temp git repo containing dot_config/bat and returns
42+// the opened repository and its HEAD reference.
43+func setupTestRepo(t *testing.T) (*Repository, *Reference) {
44+ t.Helper()
45+ ctx := context.Background()
46+
47+ repoPath := filepath.Join(t.TempDir(), "test-repo")
48+ nestedDir := filepath.Join(repoPath, "dot_config")
49+ if err := os.MkdirAll(nestedDir, 0o755); err != nil {
50+ t.Fatal(err)
51+ }
52+ if err := os.WriteFile(filepath.Join(nestedDir, "bat"), []byte("test content"), 0o644); err != nil {
53+ t.Fatal(err)
54+ }
55+
56+ for _, args := range [][]string{
57+ {"init"},
58+ {"add", "."},
59+ {"-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "init"},
60+ } {
61+ cmd := exec.CommandContext(ctx, "git", args...)
62+ cmd.Dir = repoPath
63+ if out, err := cmd.CombinedOutput(); err != nil {
64+ t.Fatalf("git %s failed: %v\n%s", args[0], err, out)
65+ }
66+ }
67+
68+ repo, err := Open(repoPath)
69+ if err != nil {
70+ t.Fatalf("failed to open repository: %v", err)
71+ }
72+ ref, err := repo.HEAD()
73+ if err != nil {
74+ t.Fatalf("failed to get HEAD: %v", err)
75+ }
76+ return repo, ref
77+}
78+
79+func TestTreePathForwardSlashes(t *testing.T) {
80+ repo, ref := setupTestRepo(t)
81+
82+ // TreePath should clean the double slash and resolve the directory.
83+ tree, err := repo.TreePath(ref, "dot_config//")
84+ if err != nil {
85+ t.Fatalf("TreePath failed: %v", err)
86+ }
87+
88+ entries, err := tree.Entries()
89+ if err != nil {
90+ t.Fatalf("failed to get entries: %v", err)
91+ }
92+
93+ for _, e := range entries {
94+ path := e.File().Path()
95+ if strings.Contains(path, `\`) {
96+ t.Errorf("entry path contains backslash: %q", path)
97+ }
98+ }
99+}
100+
101+func TestTreeEntryPathForwardSlashes(t *testing.T) {
102+ repo, ref := setupTestRepo(t)
103+
104+ tree, err := repo.TreePath(ref, "dot_config")
105+ if err != nil {
106+ t.Fatalf("TreePath failed: %v", err)
107+ }
108+
109+ entries, err := tree.Entries()
110+ if err != nil {
111+ t.Fatalf("failed to get entries: %v", err)
112+ }
113+
114+ for _, e := range entries {
115+ path := e.File().Path()
116+ if strings.Contains(path, `\`) {
117+ t.Errorf("entry path contains backslash: %q", path)
118+ }
119+ if !strings.Contains(path, "/") {
120+ t.Errorf("expected forward slash in path: %q", path)
121+ }
122+ }
123+}
124diff --git a/git/tree.go b/git/tree.go
125index edee17a197955b3f71930007217fe2c239125270..539cac2d98e6d042e795cc1e013aadc871b7ff21 100644
126--- a/git/tree.go
127+++ b/git/tree.go
128@@ -5,7 +5,7 @@ import (
129 "bytes"
130 "io"
131 "io/fs"
132- "path/filepath"
133+ gopath "path"
134 "sort"
135
136 "github.com/aymanbagabas/git-module"
137@@ -103,7 +103,7 @@ func (t *Tree) Entries() (Entries, error) {
138 for i, e := range entries {
139 ret[i] = &TreeEntry{
140 TreeEntry: e,
141- path: filepath.Join(t.Path, e.Name()),
142+ path: gopath.Join(t.Path, e.Name()),
143 }
144 }
145 return ret, nil
146@@ -117,7 +117,7 @@ func (t *Tree) TreeEntry(path string) (*TreeEntry, error) {
147 }
148 return &TreeEntry{
149 TreeEntry: entry,
150- path: filepath.Join(t.Path, entry.Name()),
151+ path: gopath.Join(t.Path, entry.Name()),
152 }, nil
153 }
154
155diff --git a/git/utils.go b/git/utils.go
156index b4ca50fc62d987eb823dc65cff0572a500f057d6..5e1e72559703a2762e5a6cad777fc316f631b7b5 100644
157--- a/git/utils.go
158+++ b/git/utils.go
159@@ -2,6 +2,7 @@ package git
160
161 import (
162 "os"
163+ "path"
164 "path/filepath"
165
166 "github.com/gobwas/glob"
167@@ -10,7 +11,7 @@ import (
168 // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path.
169 func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) {
170 g := glob.MustCompile(pattern)
171- dir := filepath.Dir(pattern)
172+ dir := path.Dir(pattern)
173 if ref == nil {
174 head, err := repo.HEAD()
175 if err != nil {
176@@ -28,7 +29,7 @@ func LatestFile(repo *Repository, ref *Reference, pattern string) (string, strin
177 }
178 for _, e := range ents {
179 te := e
180- fp := filepath.Join(dir, te.Name())
181+ fp := path.Join(dir, te.Name())
182 if te.IsTree() {
183 continue
184 }
185diff --git a/pkg/ui/pages/repo/files.go b/pkg/ui/pages/repo/files.go
186index 4c51e3561b2ffc69b5d047c043f8b25ff1be5085..77926e2e9bf9dedefa0576a37946ea610b21c803 100644
187--- a/pkg/ui/pages/repo/files.go
188+++ b/pkg/ui/pages/repo/files.go
189@@ -3,7 +3,7 @@ package repo
190 import (
191 "errors"
192 "fmt"
193- "path/filepath"
194+ "path"
195 "strings"
196
197 "charm.land/bubbles/v2/key"
198@@ -253,7 +253,7 @@ func (f *Files) Update(msg tea.Msg) (common.Model, tea.Cmd) {
199 switch sel := msg.IdentifiableItem.(type) {
200 case FileItem:
201 f.currentItem = &sel
202- f.path = filepath.Join(f.path, sel.entry.Name())
203+ f.path = path.Join(f.path, sel.entry.Name())
204 if sel.entry.IsTree() {
205 cmds = append(cmds, f.selectTreeCmd)
206 } else {
207@@ -458,19 +458,19 @@ func (f *Files) selectFileCmd() tea.Msg {
208 if !bin {
209 bin, err = fi.IsBinary()
210 if err != nil {
211- f.path = filepath.Dir(f.path)
212+ f.path = path.Dir(f.path)
213 return common.ErrorMsg(err)
214 }
215 }
216
217 if bin {
218- f.path = filepath.Dir(f.path)
219+ f.path = path.Dir(f.path)
220 return common.ErrorMsg(errBinaryFile)
221 }
222
223 c, err := fi.Bytes()
224 if err != nil {
225- f.path = filepath.Dir(f.path)
226+ f.path = path.Dir(f.path)
227 return common.ErrorMsg(err)
228 }
229
230@@ -527,7 +527,7 @@ func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {
231 }
232
233 func (f *Files) deselectItemCmd() tea.Cmd {
234- f.path = filepath.Dir(f.path)
235+ f.path = path.Dir(f.path)
236 index := 0
237 if len(f.lastSelected) > 0 {
238 index = f.lastSelected[len(f.lastSelected)-1]
239diff --git a/pkg/ui/pages/repo/readme.go b/pkg/ui/pages/repo/readme.go
240index e7f1b9087fccebac3c401f00f393cc9b72294193..cc650d8293600784c9e209613143071795cf9fc3 100644
241--- a/pkg/ui/pages/repo/readme.go
242+++ b/pkg/ui/pages/repo/readme.go
243@@ -1,7 +1,7 @@
244 package repo
245
246 import (
247- "path/filepath"
248+ "path"
249
250 "charm.land/bubbles/v2/key"
251 "charm.land/bubbles/v2/spinner"
252@@ -147,7 +147,7 @@ func (r *Readme) SpinnerID() int {
253
254 // StatusBarValue implements statusbar.StatusBar.
255 func (r *Readme) StatusBarValue() string {
256- dir := filepath.Dir(r.readmePath)
257+ dir := path.Dir(r.readmePath)
258 if dir == "." || dir == "" {
259 return " "
260 }