1002e92f5a7df5e01bf911b52929f052546c787f

Author
石岳峰 <132282304+syf2211@users.noreply.github.com>
Committer
GitHub <noreply@github.com>
Date

Message

fix(ui): use path.Join for git tree paths on Windows (#903)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kieran Klukas <kieran@dunkirk.sh>

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 	}