detached 329477a

feat(readers): list the file paths of a revision

commit
329477a21d9e558d97f746268249936f3fb4db8d
author
brice · · 2026-10-11 18:42 UTC
parent
7559d0f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +337 -13
M internal/adapters/ermitegit/bench_test.go +4 -0
@@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) {
163163 _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)
164164 return
165165 }},
166+ {"ListPaths", func(r ports.RepoReader) (err error) {
167+ _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
168+ return
169+ }},
166170 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
167171 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
168172 {"ListCommits/page1", func(r ports.RepoReader) (err error) {
@@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) {
163 _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)163 _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)
164 return164 return
165 }},165 }},
166+ {"ListPaths", func(r ports.RepoReader) (err error) {
167+ _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
168+ return
169+ }},
166 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},170 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
167 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},171 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
168 {"ListCommits/page1", func(r ports.RepoReader) (err error) {172 {"ListCommits/page1", func(r ports.RepoReader) (err error) {
M internal/adapters/ermitegit/convert.go +110 -0
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "strings"
1112 "time"
1213
1314 git "bnema.dev/ermite-git"
@@ -326,6 +327,115 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.
326327 return tree, nil
327328 }
328329
330+// pathChunk is the size of the blocks that hold the bytes of the paths.
331+const pathChunk = 32 << 10
332+
333+// pathWalker lists the files below a tree, depth first, in tree order.
334+type pathWalker struct {
335+ ctx context.Context
336+ r *git.Repo
337+ maxPaths int
338+ maxBytes int
339+
340+ // paths are the paths kept. Their bytes live in chunks: a chunk is filled
341+ // by a strings.Builder, so a path is read back as a string without a copy,
342+ // and a full chunk is left to the paths that point into it instead of
343+ // being grown and copied.
344+ paths []string
345+ chunk strings.Builder
346+ prefix []byte // "dir/sub/" of the directory being read
347+ size int // bytes of the paths kept
348+ truncated bool
349+ bad error
350+}
351+
352+// listPaths lists the files and symbolic links of a revision recursively.
353+//
354+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
355+func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
356+ commit, root, err := commitTree(r, rev)
357+ if err != nil {
358+ return domain.PathList{}, err
359+ }
360+ treeID, err := domainID(root)
361+ if err != nil {
362+ return domain.PathList{}, err
363+ }
364+ w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes}
365+ if err := w.walk(root); err != nil {
366+ return domain.PathList{}, err
367+ }
368+ list := domain.PathList{CommitID: commit, TreeID: treeID, Truncated: w.truncated}
369+ list.Paths = w.paths
370+ return list, nil
371+}
372+
373+// walk lists one directory. The recursion is inside the callback of ReadTree,
374+// where the entries of a subdirectory come at the place of the subdirectory,
375+// like in `git ls-tree -r`; the callback can only say stop, so the reasons to
376+// stop are kept in the walker. Every ReadTree takes its own pooled buffer, so
377+// the nesting is safe.
378+func (w *pathWalker) walk(tree git.ObjectID) error {
379+ if err := w.ctx.Err(); err != nil {
380+ return err
381+ }
382+ err := w.r.ReadTree(tree, func(e git.Entry) bool {
383+ switch {
384+ case e.Mode.IsGitlink():
385+ return true
386+ case e.Mode.IsTree():
387+ mark := len(w.prefix)
388+ w.prefix = append(w.prefix, e.Name...)
389+ // The paths below a directory that is not addressable are not
390+ // either: this also bounds the depth of the recursion.
391+ if !domain.IsAddressablePath(string(w.prefix)) {
392+ w.prefix = w.prefix[:mark]
393+ return true
394+ }
395+ w.prefix = append(w.prefix, '/')
396+ err := w.walk(e.ID)
397+ w.prefix = w.prefix[:mark]
398+ if err != nil {
399+ w.bad = err
400+ return false
401+ }
402+ return !w.truncated
403+ case e.Mode.IsRegular(), e.Mode.IsSymlink():
404+ return w.add(e.Name)
405+ }
406+ w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
407+ return false
408+ })
409+ if err == nil {
410+ err = w.bad
411+ }
412+ return err
413+}
414+
415+// add lists the file name of the current directory, and says whether the walk
416+// goes on.
417+func (w *pathWalker) add(name []byte) bool {
418+ n := len(w.prefix) + len(name)
419+ if w.chunk.Cap()-w.chunk.Len() < n {
420+ w.chunk = strings.Builder{}
421+ w.chunk.Grow(max(pathChunk, n))
422+ }
423+ start := w.chunk.Len()
424+ w.chunk.Write(w.prefix)
425+ w.chunk.Write(name)
426+ path := w.chunk.String()[start:]
427+ switch {
428+ case !domain.IsAddressablePath(path):
429+ case len(w.paths) >= w.maxPaths || w.size+n > w.maxBytes:
430+ w.truncated = true
431+ return false
432+ default:
433+ w.size += n
434+ w.paths = append(w.paths, path)
435+ }
436+ return true
437+}
438+
329439 // fillLastCommits sets the last commit of the first MaxLastCommitEntries
330440 // entries with one walk.
331441 //
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "io"10 "io"
11+ "strings"
11 "time"12 "time"
12 13
13 git "bnema.dev/ermite-git"14 git "bnema.dev/ermite-git"
@@ -326,6 +327,115 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.
326 return tree, nil327 return tree, nil
327 }328 }
328 329
330+// pathChunk is the size of the blocks that hold the bytes of the paths.
331+const pathChunk = 32 << 10
332+
333+// pathWalker lists the files below a tree, depth first, in tree order.
334+type pathWalker struct {
335+ ctx context.Context
336+ r *git.Repo
337+ maxPaths int
338+ maxBytes int
339+
340+ // paths are the paths kept. Their bytes live in chunks: a chunk is filled
341+ // by a strings.Builder, so a path is read back as a string without a copy,
342+ // and a full chunk is left to the paths that point into it instead of
343+ // being grown and copied.
344+ paths []string
345+ chunk strings.Builder
346+ prefix []byte // "dir/sub/" of the directory being read
347+ size int // bytes of the paths kept
348+ truncated bool
349+ bad error
350+}
351+
352+// listPaths lists the files and symbolic links of a revision recursively.
353+//
354+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
355+func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
356+ commit, root, err := commitTree(r, rev)
357+ if err != nil {
358+ return domain.PathList{}, err
359+ }
360+ treeID, err := domainID(root)
361+ if err != nil {
362+ return domain.PathList{}, err
363+ }
364+ w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes}
365+ if err := w.walk(root); err != nil {
366+ return domain.PathList{}, err
367+ }
368+ list := domain.PathList{CommitID: commit, TreeID: treeID, Truncated: w.truncated}
369+ list.Paths = w.paths
370+ return list, nil
371+}
372+
373+// walk lists one directory. The recursion is inside the callback of ReadTree,
374+// where the entries of a subdirectory come at the place of the subdirectory,
375+// like in `git ls-tree -r`; the callback can only say stop, so the reasons to
376+// stop are kept in the walker. Every ReadTree takes its own pooled buffer, so
377+// the nesting is safe.
378+func (w *pathWalker) walk(tree git.ObjectID) error {
379+ if err := w.ctx.Err(); err != nil {
380+ return err
381+ }
382+ err := w.r.ReadTree(tree, func(e git.Entry) bool {
383+ switch {
384+ case e.Mode.IsGitlink():
385+ return true
386+ case e.Mode.IsTree():
387+ mark := len(w.prefix)
388+ w.prefix = append(w.prefix, e.Name...)
389+ // The paths below a directory that is not addressable are not
390+ // either: this also bounds the depth of the recursion.
391+ if !domain.IsAddressablePath(string(w.prefix)) {
392+ w.prefix = w.prefix[:mark]
393+ return true
394+ }
395+ w.prefix = append(w.prefix, '/')
396+ err := w.walk(e.ID)
397+ w.prefix = w.prefix[:mark]
398+ if err != nil {
399+ w.bad = err
400+ return false
401+ }
402+ return !w.truncated
403+ case e.Mode.IsRegular(), e.Mode.IsSymlink():
404+ return w.add(e.Name)
405+ }
406+ w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
407+ return false
408+ })
409+ if err == nil {
410+ err = w.bad
411+ }
412+ return err
413+}
414+
415+// add lists the file name of the current directory, and says whether the walk
416+// goes on.
417+func (w *pathWalker) add(name []byte) bool {
418+ n := len(w.prefix) + len(name)
419+ if w.chunk.Cap()-w.chunk.Len() < n {
420+ w.chunk = strings.Builder{}
421+ w.chunk.Grow(max(pathChunk, n))
422+ }
423+ start := w.chunk.Len()
424+ w.chunk.Write(w.prefix)
425+ w.chunk.Write(name)
426+ path := w.chunk.String()[start:]
427+ switch {
428+ case !domain.IsAddressablePath(path):
429+ case len(w.paths) >= w.maxPaths || w.size+n > w.maxBytes:
430+ w.truncated = true
431+ return false
432+ default:
433+ w.size += n
434+ w.paths = append(w.paths, path)
435+ }
436+ return true
437+}
438+
329 // fillLastCommits sets the last commit of the first MaxLastCommitEntries439 // fillLastCommits sets the last commit of the first MaxLastCommitEntries
330 // entries with one walk.440 // entries with one walk.
331 //441 //
M internal/adapters/ermitegit/ermitegit.go +16 -4
@@ -94,12 +94,14 @@ type Routes struct {
9494 ListCommits Backend
9595 // ListCommitsPath is a page of the history of a file or a directory.
9696 ListCommitsPath Backend
97+ // ListPaths is the recursive list of the file paths of a revision.
98+ ListPaths Backend
9799 }
98100
99101 // AllRoutes returns routes that send every method to b. The tests and the
100102 // benchmarks use it to exercise a backend whatever the production routes are.
101103 func AllRoutes(b Backend) Routes {
102- return Routes{b, b, b, b, b, b, b, b, b}
104+ return Routes{b, b, b, b, b, b, b, b, b, b}
103105 }
104106
105107 // Config configures an Adapter.
@@ -315,9 +317,19 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path
315317 }, cli)
316318 }
317319
318-// ListPaths is not implemented yet.
319-func (a *Adapter) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) {
320- return domain.PathList{}, errors.ErrUnsupported
320+// ListPaths lists the file and symbolic link paths of a revision.
321+func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
322+ cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) }
323+ if a.routes.ListPaths == CLI {
324+ return cli()
325+ }
326+ return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) {
327+ list, err := listPaths(ctx, r, rev, maxPaths, maxBytes)
328+ if err != nil {
329+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
330+ }
331+ return list, nil
332+ }, cli)
321333 }
322334
323335 // GetTree lists a directory.
@@ -94,12 +94,14 @@ type Routes struct {
94 ListCommits Backend94 ListCommits Backend
95 // ListCommitsPath is a page of the history of a file or a directory.95 // ListCommitsPath is a page of the history of a file or a directory.
96 ListCommitsPath Backend96 ListCommitsPath Backend
97+ // ListPaths is the recursive list of the file paths of a revision.
98+ ListPaths Backend
97 }99 }
98 100
99 // AllRoutes returns routes that send every method to b. The tests and the101 // AllRoutes returns routes that send every method to b. The tests and the
100 // benchmarks use it to exercise a backend whatever the production routes are.102 // benchmarks use it to exercise a backend whatever the production routes are.
101 func AllRoutes(b Backend) Routes {103 func AllRoutes(b Backend) Routes {
102- return Routes{b, b, b, b, b, b, b, b, b}104+ return Routes{b, b, b, b, b, b, b, b, b, b}
103 }105 }
104 106
105 // Config configures an Adapter.107 // Config configures an Adapter.
@@ -315,9 +317,19 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path
315 }, cli)317 }, cli)
316 }318 }
317 319
318-// ListPaths is not implemented yet.320+// ListPaths lists the file and symbolic link paths of a revision.
319-func (a *Adapter) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) {321+func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
320- return domain.PathList{}, errors.ErrUnsupported322+ cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) }
323+ if a.routes.ListPaths == CLI {
324+ return cli()
325+ }
326+ return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) {
327+ list, err := listPaths(ctx, r, rev, maxPaths, maxBytes)
328+ if err != nil {
329+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
330+ }
331+ return list, nil
332+ }, cli)
321 }333 }
322 334
323 // GetTree lists a directory.335 // GetTree lists a directory.
M internal/adapters/ermitegit/routes.go +17 -6
@@ -12,7 +12,8 @@ package ermitegit
1212 // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and
1313 // ListCommitsPath cover directories of any size and files of any history. A
1414 // method that does not win stays on the CLI. GetCommit is not routable: the
15-// diff stays on git.
15+// diff stays on git. ListPaths has one shape: the fixture lists 131 files and
16+// lazygit 2,180.
1617 //
1718 // Warm is a repository that is open; cold closes it before every call, the
1819 // price of the first read after the idle TTL, an eviction or a restart (a
@@ -67,6 +68,10 @@ package ermitegit
6768 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win
6869 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win
6970 // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win
71+// ListPaths fixture warm 760µs 118.4K 225 41µs 37.9K 30 win
72+// ListPaths fixture cold 771µs 118.4K 225 107µs 54.7K 160 win
73+// ListPaths lazygit warm 1.8ms 432.9K 2280 842µs 250.3K 477 win
74+// ListPaths lazygit cold 1.8ms 432.9K 2280 1.2ms 298.7K 749 win
7075 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win
7176 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba
7277 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba
@@ -103,11 +108,16 @@ package ermitegit
103108 // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200
104109 // against 60 to 140), and in time too for the ones that cost a few
105110 // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs).
106-// - Only the history of a revision (ListCommits page 1 and deep) wins every
107-// line, so it is the only method routed. GetTree dir+last also wins every
108-// line, but GetTreeLastCommits covers GetTree root+last too, which loses
109-// cold allocations on lazygit (4204 against 3073): it is the nearest
110-// miss, and the first to flip once opening a repository allocates less.
111+// - Only the history of a revision (ListCommits page 1 and deep) and the
112+// recursive path list (ListPaths) win every line, so they are the only
113+// methods routed. ListPaths wins even cold because one walk replaces a
114+// process and the parsing of its output (the bytes of the paths are kept in
115+// 32 KiB blocks, which is what brought the lazygit bytes under the CLI's:
116+// a single growing buffer lost them, 699K against 433K). GetTree dir+last
117+// also wins every line, but GetTreeLastCommits covers GetTree root+last
118+// too, which loses cold allocations on lazygit (4204 against 3073): it is
119+// the nearest miss, and the first to flip once opening a repository
120+// allocates less.
111121 // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit,
112122 // warm bytes and allocations; above, it loses time. No size makes it win,
113123 // so there is no size-based route.
@@ -126,5 +136,6 @@ func DefaultRoutes() Routes {
126136 OpenBlob: CLI,
127137 ListCommits: ErmiteGit,
128138 ListCommitsPath: CLI,
139+ ListPaths: ErmiteGit,
129140 }
130141 }
@@ -12,7 +12,8 @@ package ermitegit
12 // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and12 // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and
13 // ListCommitsPath cover directories of any size and files of any history. A13 // ListCommitsPath cover directories of any size and files of any history. A
14 // method that does not win stays on the CLI. GetCommit is not routable: the14 // method that does not win stays on the CLI. GetCommit is not routable: the
15-// diff stays on git.15+// diff stays on git. ListPaths has one shape: the fixture lists 131 files and
16+// lazygit 2,180.
16 //17 //
17 // Warm is a repository that is open; cold closes it before every call, the18 // Warm is a repository that is open; cold closes it before every call, the
18 // price of the first read after the idle TTL, an eviction or a restart (a19 // price of the first read after the idle TTL, an eviction or a restart (a
@@ -67,6 +68,10 @@ package ermitegit
67 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win68 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win
68 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win69 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win
69 // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win70 // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win
71+// ListPaths fixture warm 760µs 118.4K 225 41µs 37.9K 30 win
72+// ListPaths fixture cold 771µs 118.4K 225 107µs 54.7K 160 win
73+// ListPaths lazygit warm 1.8ms 432.9K 2280 842µs 250.3K 477 win
74+// ListPaths lazygit cold 1.8ms 432.9K 2280 1.2ms 298.7K 749 win
70 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win75 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win
71 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba76 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba
72 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba77 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba
@@ -103,11 +108,16 @@ package ermitegit
103 // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200108 // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200
104 // against 60 to 140), and in time too for the ones that cost a few109 // against 60 to 140), and in time too for the ones that cost a few
105 // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs).110 // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs).
106-// - Only the history of a revision (ListCommits page 1 and deep) wins every111+// - Only the history of a revision (ListCommits page 1 and deep) and the
107-// line, so it is the only method routed. GetTree dir+last also wins every112+// recursive path list (ListPaths) win every line, so they are the only
108-// line, but GetTreeLastCommits covers GetTree root+last too, which loses113+// methods routed. ListPaths wins even cold because one walk replaces a
109-// cold allocations on lazygit (4204 against 3073): it is the nearest114+// process and the parsing of its output (the bytes of the paths are kept in
110-// miss, and the first to flip once opening a repository allocates less.115+// 32 KiB blocks, which is what brought the lazygit bytes under the CLI's:
116+// a single growing buffer lost them, 699K against 433K). GetTree dir+last
117+// also wins every line, but GetTreeLastCommits covers GetTree root+last
118+// too, which loses cold allocations on lazygit (4204 against 3073): it is
119+// the nearest miss, and the first to flip once opening a repository
120+// allocates less.
111 // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit,121 // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit,
112 // warm bytes and allocations; above, it loses time. No size makes it win,122 // warm bytes and allocations; above, it loses time. No size makes it win,
113 // so there is no size-based route.123 // so there is no size-based route.
@@ -126,5 +136,6 @@ func DefaultRoutes() Routes {
126 OpenBlob: CLI,136 OpenBlob: CLI,
127 ListCommits: ErmiteGit,137 ListCommits: ErmiteGit,
128 ListCommitsPath: CLI,138 ListCommitsPath: CLI,
139+ ListPaths: ErmiteGit,
129 }140 }
130 }141 }
M internal/adapters/gitcli/read.go +49 -3
@@ -288,9 +288,55 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path
288288 return obj, err
289289 }
290290
291-// ListPaths is not implemented yet.
292-func (g *Git) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) {
293- return domain.PathList{}, errors.ErrUnsupported
291+// ListPaths lists the files and symbolic links of a revision with
292+// `git ls-tree -r -z`, in its order, up to the bounds of the port.
293+//
294+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
295+func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
296+ dir, err := g.repoDir(repo)
297+ if err != nil {
298+ return domain.PathList{}, err
299+ }
300+ obj, err := g.lookup(ctx, dir, rev, domain.TreePath{})
301+ if err != nil {
302+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
303+ }
304+ treeID, err := domain.ParseObjectID(obj.info.ID)
305+ if err != nil {
306+ return domain.PathList{}, err
307+ }
308+ list := domain.PathList{CommitID: obj.commit, TreeID: treeID}
309+ size := 0
310+ err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error {
311+ for {
312+ tok, err := readToken(r)
313+ if errors.Is(err, io.EOF) {
314+ return nil
315+ }
316+ if err != nil {
317+ return err
318+ }
319+ meta, path, ok := strings.Cut(tok, "\t")
320+ if !ok {
321+ return fmt.Errorf("malformed ls-tree entry %q", tok)
322+ }
323+ // meta is "<mode> <type> <id>": a submodule is a commit.
324+ _, rest, _ := strings.Cut(meta, " ")
325+ if typ, _, _ := strings.Cut(rest, " "); typ == "commit" || !domain.IsAddressablePath(path) {
326+ continue
327+ }
328+ if len(list.Paths) >= maxPaths || size+len(path) > maxBytes {
329+ list.Truncated = true
330+ return errStop
331+ }
332+ size += len(path)
333+ list.Paths = append(list.Paths, path)
334+ }
335+ })
336+ if err != nil {
337+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
338+ }
339+ return list, nil
294340 }
295341
296342 // GetTree lists a directory with `git ls-tree -z -l`.
@@ -288,9 +288,55 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path
288 return obj, err288 return obj, err
289 }289 }
290 290
291-// ListPaths is not implemented yet.291+// ListPaths lists the files and symbolic links of a revision with
292-func (g *Git) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) {292+// `git ls-tree -r -z`, in its order, up to the bounds of the port.
293- return domain.PathList{}, errors.ErrUnsupported293+//
294+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
295+func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
296+ dir, err := g.repoDir(repo)
297+ if err != nil {
298+ return domain.PathList{}, err
299+ }
300+ obj, err := g.lookup(ctx, dir, rev, domain.TreePath{})
301+ if err != nil {
302+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
303+ }
304+ treeID, err := domain.ParseObjectID(obj.info.ID)
305+ if err != nil {
306+ return domain.PathList{}, err
307+ }
308+ list := domain.PathList{CommitID: obj.commit, TreeID: treeID}
309+ size := 0
310+ err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error {
311+ for {
312+ tok, err := readToken(r)
313+ if errors.Is(err, io.EOF) {
314+ return nil
315+ }
316+ if err != nil {
317+ return err
318+ }
319+ meta, path, ok := strings.Cut(tok, "\t")
320+ if !ok {
321+ return fmt.Errorf("malformed ls-tree entry %q", tok)
322+ }
323+ // meta is "<mode> <type> <id>": a submodule is a commit.
324+ _, rest, _ := strings.Cut(meta, " ")
325+ if typ, _, _ := strings.Cut(rest, " "); typ == "commit" || !domain.IsAddressablePath(path) {
326+ continue
327+ }
328+ if len(list.Paths) >= maxPaths || size+len(path) > maxBytes {
329+ list.Truncated = true
330+ return errStop
331+ }
332+ size += len(path)
333+ list.Paths = append(list.Paths, path)
334+ }
335+ })
336+ if err != nil {
337+ return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err)
338+ }
339+ return list, nil
294 }340 }
295 341
296 // GetTree lists a directory with `git ls-tree -z -l`.342 // GetTree lists a directory with `git ls-tree -z -l`.
M internal/readertest/compare.go +15 -0
@@ -264,6 +264,17 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir
264264 }
265265 }
266266 })
267+ t.Run("ListPaths", func(t *testing.T) {
268+ for _, rev := range revs {
269+ for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} {
270+ w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1])
271+ g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1])
272+ if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) {
273+ assert.Equal(t, w, g, "paths at %q %v", rev, lim)
274+ }
275+ }
276+ }
277+ })
267278 t.Run("OpenBlob", func(t *testing.T) {
268279 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
269280 info, rc, err := r.OpenBlob(ctx, repo, rev, path)
@@ -338,6 +349,10 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir
338349 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
339350 return err
340351 },
352+ "ListPaths": func(r ports.RepoReader) error {
353+ _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
354+ return err
355+ },
341356 "OpenBlob": func(r ports.RepoReader) error {
342357 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
343358 return err
@@ -264,6 +264,17 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir
264 }264 }
265 }265 }
266 })266 })
267+ t.Run("ListPaths", func(t *testing.T) {
268+ for _, rev := range revs {
269+ for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} {
270+ w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1])
271+ g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1])
272+ if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) {
273+ assert.Equal(t, w, g, "paths at %q %v", rev, lim)
274+ }
275+ }
276+ }
277+ })
267 t.Run("OpenBlob", func(t *testing.T) {278 t.Run("OpenBlob", func(t *testing.T) {
268 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {279 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
269 info, rc, err := r.OpenBlob(ctx, repo, rev, path)280 info, rc, err := r.OpenBlob(ctx, repo, rev, path)
@@ -338,6 +349,10 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir
338 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)349 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
339 return err350 return err
340 },351 },
352+ "ListPaths": func(r ports.RepoReader) error {
353+ _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
354+ return err
355+ },
341 "OpenBlob": func(r ports.RepoReader) error {356 "OpenBlob": func(r ports.RepoReader) error {
342 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})357 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
343 return err358 return err
M internal/readertest/conformance.go +108 -0
@@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) {
4646 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
4747 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
4848 {"GetTreeIsBounded", testGetTreeIsBounded},
49+ {"ListPaths", testListPaths},
4950 } {
5051 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
5152 }
@@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) {
701702 require.NoError(t, err)
702703 assert.False(t, small.Truncated)
703704 }
705+
706+func testListPaths(t *testing.T, mk Maker) {
707+ ctx := context.Background()
708+ g, data := mk(t)
709+ f := buildPaths(t, data)
710+
711+ // git ls-tree -r in order, minus the submodule and the path no address can
712+ // carry.
713+ var want []string
714+ for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") {
715+ meta, p, _ := strings.Cut(rec, "\t")
716+ if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") {
717+ want = append(want, p)
718+ }
719+ }
720+ require.Equal(t, []string{
721+ "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt",
722+ }, want, "the fixture")
723+
724+ got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
725+ require.NoError(t, err)
726+ assert.Equal(t, want, got.Paths)
727+ assert.False(t, got.Truncated)
728+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String())
729+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String())
730+
731+ t.Run("revisions", func(t *testing.T) {
732+ for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} {
733+ list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
734+ require.NoError(t, err, s)
735+ assert.Equal(t, want, list.Paths, s)
736+ }
737+ // The first commit has no submodule: the same files.
738+ first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes)
739+ require.NoError(t, err)
740+ assert.Equal(t, want, first.Paths)
741+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String())
742+ assert.NotEqual(t, got.TreeID, first.TreeID)
743+ })
744+ t.Run("not found", func(t *testing.T) {
745+ for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} {
746+ _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
747+ assert.ErrorIs(t, err, domain.ErrNotFound, s)
748+ }
749+ _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
750+ assert.ErrorIs(t, err, domain.ErrNotFound)
751+ })
752+ t.Run("empty repository", func(t *testing.T) {
753+ e := NewFixture(t, data, "empty-paths")
754+ _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
755+ assert.ErrorIs(t, err, domain.ErrNotFound)
756+ })
757+ t.Run("bounds", func(t *testing.T) {
758+ list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes)
759+ require.NoError(t, err)
760+ assert.Equal(t, want[:3], list.Paths)
761+ assert.True(t, list.Truncated)
762+ assert.Equal(t, got.CommitID, list.CommitID)
763+ assert.Equal(t, got.TreeID, list.TreeID)
764+
765+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes)
766+ require.NoError(t, err)
767+ assert.Equal(t, want, list.Paths)
768+ assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated")
769+
770+ sum := len(want[0]) + len(want[1]) + len(want[2])
771+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1)
772+ require.NoError(t, err)
773+ assert.Equal(t, want[:2], list.Paths)
774+ assert.True(t, list.Truncated)
775+
776+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum)
777+ require.NoError(t, err)
778+ assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit")
779+ assert.True(t, list.Truncated)
780+
781+ total := 0
782+ for _, p := range want {
783+ total += len(p)
784+ }
785+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total)
786+ require.NoError(t, err)
787+ assert.Equal(t, want, list.Paths)
788+ assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated")
789+ })
790+ t.Run("cancelled context", func(t *testing.T) {
791+ ctx, cancel := context.WithCancel(ctx)
792+ cancel()
793+ _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
794+ assert.ErrorIs(t, err, context.Canceled)
795+ })
796+ t.Run("a large tree", func(t *testing.T) {
797+ w := NewFixture(t, data, "paths-wide")
798+ for i := range 300 {
799+ w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x")
800+ }
801+ w.Commit("wide")
802+ order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD"))
803+ list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
804+ require.NoError(t, err)
805+ assert.Equal(t, order, list.Paths)
806+ list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes)
807+ require.NoError(t, err)
808+ assert.Equal(t, order[:100], list.Paths)
809+ assert.True(t, list.Truncated)
810+ })
811+}
@@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) {
46 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},46 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
47 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},47 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
48 {"GetTreeIsBounded", testGetTreeIsBounded},48 {"GetTreeIsBounded", testGetTreeIsBounded},
49+ {"ListPaths", testListPaths},
49 } {50 } {
50 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })51 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
51 }52 }
@@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) {
701 require.NoError(t, err)702 require.NoError(t, err)
702 assert.False(t, small.Truncated)703 assert.False(t, small.Truncated)
703 }704 }
705+
706+func testListPaths(t *testing.T, mk Maker) {
707+ ctx := context.Background()
708+ g, data := mk(t)
709+ f := buildPaths(t, data)
710+
711+ // git ls-tree -r in order, minus the submodule and the path no address can
712+ // carry.
713+ var want []string
714+ for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") {
715+ meta, p, _ := strings.Cut(rec, "\t")
716+ if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") {
717+ want = append(want, p)
718+ }
719+ }
720+ require.Equal(t, []string{
721+ "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt",
722+ }, want, "the fixture")
723+
724+ got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
725+ require.NoError(t, err)
726+ assert.Equal(t, want, got.Paths)
727+ assert.False(t, got.Truncated)
728+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String())
729+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String())
730+
731+ t.Run("revisions", func(t *testing.T) {
732+ for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} {
733+ list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
734+ require.NoError(t, err, s)
735+ assert.Equal(t, want, list.Paths, s)
736+ }
737+ // The first commit has no submodule: the same files.
738+ first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes)
739+ require.NoError(t, err)
740+ assert.Equal(t, want, first.Paths)
741+ assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String())
742+ assert.NotEqual(t, got.TreeID, first.TreeID)
743+ })
744+ t.Run("not found", func(t *testing.T) {
745+ for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} {
746+ _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
747+ assert.ErrorIs(t, err, domain.ErrNotFound, s)
748+ }
749+ _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
750+ assert.ErrorIs(t, err, domain.ErrNotFound)
751+ })
752+ t.Run("empty repository", func(t *testing.T) {
753+ e := NewFixture(t, data, "empty-paths")
754+ _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
755+ assert.ErrorIs(t, err, domain.ErrNotFound)
756+ })
757+ t.Run("bounds", func(t *testing.T) {
758+ list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes)
759+ require.NoError(t, err)
760+ assert.Equal(t, want[:3], list.Paths)
761+ assert.True(t, list.Truncated)
762+ assert.Equal(t, got.CommitID, list.CommitID)
763+ assert.Equal(t, got.TreeID, list.TreeID)
764+
765+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes)
766+ require.NoError(t, err)
767+ assert.Equal(t, want, list.Paths)
768+ assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated")
769+
770+ sum := len(want[0]) + len(want[1]) + len(want[2])
771+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1)
772+ require.NoError(t, err)
773+ assert.Equal(t, want[:2], list.Paths)
774+ assert.True(t, list.Truncated)
775+
776+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum)
777+ require.NoError(t, err)
778+ assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit")
779+ assert.True(t, list.Truncated)
780+
781+ total := 0
782+ for _, p := range want {
783+ total += len(p)
784+ }
785+ list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total)
786+ require.NoError(t, err)
787+ assert.Equal(t, want, list.Paths)
788+ assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated")
789+ })
790+ t.Run("cancelled context", func(t *testing.T) {
791+ ctx, cancel := context.WithCancel(ctx)
792+ cancel()
793+ _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
794+ assert.ErrorIs(t, err, context.Canceled)
795+ })
796+ t.Run("a large tree", func(t *testing.T) {
797+ w := NewFixture(t, data, "paths-wide")
798+ for i := range 300 {
799+ w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x")
800+ }
801+ w.Commit("wide")
802+ order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD"))
803+ list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
804+ require.NoError(t, err)
805+ assert.Equal(t, order, list.Paths)
806+ list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes)
807+ require.NoError(t, err)
808+ assert.Equal(t, order[:100], list.Paths)
809+ assert.True(t, list.Truncated)
810+ })
811+}
M internal/readertest/fixtures.go +18 -0
@@ -63,6 +63,7 @@ func Cases() []Case {
6363 {"refs", buildRefs},
6464 {"dangling ref", buildDangling},
6565 {"submodule", buildSubmodule},
66+ {"paths", buildPaths},
6667 {"wide directory", buildWide},
6768 {"detached HEAD", func(t *testing.T, data string) *Fixture {
6869 h := NewHistory(t, data)
@@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture {
265266 return f
266267 }
267268
269+// buildPaths has what ListPaths must order and skip: nested directories whose
270+// names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a
271+// symlink, a submodule, and a name with a tab that no address can carry. The
272+// tag v1 points at the commit.
273+func buildPaths(t *testing.T, data string) *Fixture {
274+ f := NewFixture(t, data, "paths")
275+ f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n")
276+ f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link")
277+ f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n")
278+ f.Commit("base")
279+ f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod")
280+ f.Git("commit", "--quiet", "-m", "add submodule")
281+ f.Git("tag", "v1")
282+ f.Maintain()
283+ return f
284+}
285+
268286 // buildWide has a directory over MaxTreeEntries entries and one over
269287 // MaxLastCommitEntries, with commits that touch some of them.
270288 func buildWide(t *testing.T, data string) *Fixture {
@@ -63,6 +63,7 @@ func Cases() []Case {
63 {"refs", buildRefs},63 {"refs", buildRefs},
64 {"dangling ref", buildDangling},64 {"dangling ref", buildDangling},
65 {"submodule", buildSubmodule},65 {"submodule", buildSubmodule},
66+ {"paths", buildPaths},
66 {"wide directory", buildWide},67 {"wide directory", buildWide},
67 {"detached HEAD", func(t *testing.T, data string) *Fixture {68 {"detached HEAD", func(t *testing.T, data string) *Fixture {
68 h := NewHistory(t, data)69 h := NewHistory(t, data)
@@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture {
265 return f266 return f
266 }267 }
267 268
269+// buildPaths has what ListPaths must order and skip: nested directories whose
270+// names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a
271+// symlink, a submodule, and a name with a tab that no address can carry. The
272+// tag v1 points at the commit.
273+func buildPaths(t *testing.T, data string) *Fixture {
274+ f := NewFixture(t, data, "paths")
275+ f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n")
276+ f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link")
277+ f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n")
278+ f.Commit("base")
279+ f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod")
280+ f.Git("commit", "--quiet", "-m", "add submodule")
281+ f.Git("tag", "v1")
282+ f.Maintain()
283+ return f
284+}
285+
268 // buildWide has a directory over MaxTreeEntries entries and one over286 // buildWide has a directory over MaxTreeEntries entries and one over
269 // MaxLastCommitEntries, with commits that touch some of them.287 // MaxLastCommitEntries, with commits that touch some of them.
270 func buildWide(t *testing.T, data string) *Fixture {288 func buildWide(t *testing.T, data string) *Fixture {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close