feat(app): add the ListPaths use case and reader port
- commit
- 7559d0fb77f4fb265c5725f08b0cea3195eac519
- author
- brice · · 2026-10-11 18:30 UTC
- parent
- 39ca311
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +169 -7
M go.mod +1 -1
| @@ -12,7 +12,7 @@ tool ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | - | bnema.dev/ermite-api v0.0.0-20261011052945-e9394b717bc2 | |
| 15 | + | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6 | |
| 16 | 16 | bnema.dev/ermite-git v0.1.0 | |
| 17 | 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 18 | 18 | connectrpc.com/connect v1.21.0 | |
| @@ -12,7 +12,7 @@ tool ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | - | bnema.dev/ermite-api v0.0.0-20261011052945-e9394b717bc2 | 15 | + | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6 |
| 16 | bnema.dev/ermite-git v0.1.0 | 16 | bnema.dev/ermite-git v0.1.0 | ||
| 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 18 | connectrpc.com/connect v1.21.0 | 18 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/ermitegit/ermitegit.go +5 -0
| @@ -315,6 +315,11 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path | |||
| 315 | 315 | }, cli) | |
| 316 | 316 | } | |
| 317 | 317 | ||
| 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 | |
| 321 | + | } | |
| 322 | + | ||
| 318 | 323 | // GetTree lists a directory. | |
| 319 | 324 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | |
| 320 | 325 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | |
| @@ -315,6 +315,11 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path | |||||
| 315 | }, cli) | 315 | }, cli) | ||
| 316 | } | 316 | } | ||
| 317 | 317 | ||||
| 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 | |||
| 321 | + | } | |||
| 322 | + | ||||
| 318 | // GetTree lists a directory. | 323 | // GetTree lists a directory. | ||
| 319 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | 324 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | ||
| 320 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | 325 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | ||
M internal/adapters/gitcli/read.go +5 -0
| @@ -288,6 +288,11 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path | |||
| 288 | 288 | return obj, err | |
| 289 | 289 | } | |
| 290 | 290 | ||
| 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 | |
| 294 | + | } | |
| 295 | + | ||
| 291 | 296 | // GetTree lists a directory with `git ls-tree -z -l`. | |
| 292 | 297 | // | |
| 293 | 298 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| @@ -288,6 +288,11 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path | |||||
| 288 | return obj, err | 288 | return obj, err | ||
| 289 | } | 289 | } | ||
| 290 | 290 | ||||
| 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 | |||
| 294 | + | } | |||
| 295 | + | ||||
| 291 | // GetTree lists a directory with `git ls-tree -z -l`. | 296 | // GetTree lists a directory with `git ls-tree -z -l`. | ||
| 292 | // | 297 | // | ||
| 293 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | 298 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | ||
M internal/adapters/rpc/git.go +5 -0
| @@ -85,6 +85,11 @@ func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTre | |||
| 85 | 85 | return connect.NewResponse(out), nil | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | + | // ListPaths is not served yet. | |
| 89 | + | func (s *gitService) ListPaths(context.Context, *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |
| 90 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ListPaths is not implemented")) | |
| 91 | + | } | |
| 92 | + | ||
| 88 | 93 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | |
| 89 | 94 | // carries the metadata and the first chunk. | |
| 90 | 95 | func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error { | |
| @@ -85,6 +85,11 @@ func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTre | |||||
| 85 | return connect.NewResponse(out), nil | 85 | return connect.NewResponse(out), nil | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | + | // ListPaths is not served yet. | |||
| 89 | + | func (s *gitService) ListPaths(context.Context, *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |||
| 90 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ListPaths is not implemented")) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 88 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | 93 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | ||
| 89 | // carries the metadata and the first chunk. | 94 | // carries the metadata and the first chunk. | ||
| 90 | func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error { | 95 | func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error { | ||
M internal/app/browse.go +9 -0
| @@ -174,6 +174,15 @@ func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.Re | |||
| 174 | 174 | return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit) | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | + | // ListPaths lists the paths of every file and symbolic link of a revision, at | |
| 178 | + | // most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths. | |
| 179 | + | func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) { | |
| 180 | + | if err := b.authorize(ctx, p, repo); err != nil { | |
| 181 | + | return domain.PathList{}, err | |
| 182 | + | } | |
| 183 | + | return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 184 | + | } | |
| 185 | + | ||
| 177 | 186 | // OpenBlob returns the description of a file and a reader of its content. The | |
| 178 | 187 | // caller closes the reader. | |
| 179 | 188 | func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |
| @@ -174,6 +174,15 @@ func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.Re | |||||
| 174 | return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit) | 174 | return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit) | ||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | + | // ListPaths lists the paths of every file and symbolic link of a revision, at | |||
| 178 | + | // most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths. | |||
| 179 | + | func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) { | |||
| 180 | + | if err := b.authorize(ctx, p, repo); err != nil { | |||
| 181 | + | return domain.PathList{}, err | |||
| 182 | + | } | |||
| 183 | + | return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 177 | // OpenBlob returns the description of a file and a reader of its content. The | 186 | // OpenBlob returns the description of a file and a reader of its content. The | ||
| 178 | // caller closes the reader. | 187 | // caller closes the reader. | ||
| 179 | func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | 188 | func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | ||
M internal/app/browse_test.go +6 -0
| @@ -76,6 +76,12 @@ func browseOps() []browseOp { | |||
| 76 | 76 | }, func(r *mocks.RepoReader) { | |
| 77 | 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | |
| 78 | 78 | }}, | |
| 79 | + | {"ListPaths", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 80 | + | _, err := b.ListPaths(ctx, p, repo, domain.Revision{}) | |
| 81 | + | return err | |
| 82 | + | }, func(r *mocks.RepoReader) { | |
| 83 | + | r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil) | |
| 84 | + | }}, | |
| 79 | 85 | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 80 | 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | |
| 81 | 87 | if rc != nil { | |
| @@ -76,6 +76,12 @@ func browseOps() []browseOp { | |||||
| 76 | }, func(r *mocks.RepoReader) { | 76 | }, func(r *mocks.RepoReader) { | ||
| 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | ||
| 78 | }}, | 78 | }}, | ||
| 79 | + | {"ListPaths", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |||
| 80 | + | _, err := b.ListPaths(ctx, p, repo, domain.Revision{}) | |||
| 81 | + | return err | |||
| 82 | + | }, func(r *mocks.RepoReader) { | |||
| 83 | + | r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil) | |||
| 84 | + | }}, | |||
| 79 | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 85 | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | ||
| 80 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | ||
| 81 | if rc != nil { | 87 | if rc != nil { | ||
M internal/domain/git.go +37 -4
| @@ -155,20 +155,34 @@ func ParseTreePath(s string) (TreePath, error) { | |||
| 155 | 155 | if s == "" { | |
| 156 | 156 | return TreePath{}, nil | |
| 157 | 157 | } | |
| 158 | + | if why := treePathProblem(s); why != "" { | |
| 159 | + | return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why) | |
| 160 | + | } | |
| 161 | + | return TreePath{s: s}, nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // IsAddressablePath reports whether s is a non-root path that ParseTreePath | |
| 165 | + | // accepts as it is (no trailing slash). It allocates nothing. | |
| 166 | + | func IsAddressablePath(s string) bool { | |
| 167 | + | return s != "" && treePathProblem(s) == "" | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // treePathProblem returns why s cannot be a non-root tree path, or "". | |
| 171 | + | func treePathProblem(s string) string { | |
| 158 | 172 | if len(s) > maxTreePathLen || !utf8.ValidString(s) { | |
| 159 | - | return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s)) | |
| 173 | + | return "too long or not valid UTF-8" | |
| 160 | 174 | } | |
| 161 | 175 | for _, c := range s { | |
| 162 | 176 | if c < 0x20 || c == 0x7f { | |
| 163 | - | return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s)) | |
| 177 | + | return "control character" | |
| 164 | 178 | } | |
| 165 | 179 | } | |
| 166 | 180 | for part := range strings.SplitSeq(s, "/") { | |
| 167 | 181 | if part == "" || part == "." || part == ".." { | |
| 168 | - | return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s)) | |
| 182 | + | return "empty, . or .. component" | |
| 169 | 183 | } | |
| 170 | 184 | } | |
| 171 | - | return TreePath{s: s}, nil | |
| 185 | + | return "" | |
| 172 | 186 | } | |
| 173 | 187 | ||
| 174 | 188 | func truncate(s string) string { | |
| @@ -325,6 +339,25 @@ type Tree struct { | |||
| 325 | 339 | // MaxTreeEntries bounds the entries of a listed directory. | |
| 326 | 340 | const MaxTreeEntries = 5000 | |
| 327 | 341 | ||
| 342 | + | // MaxPathEntries bounds the paths ListPaths returns. | |
| 343 | + | const MaxPathEntries = 100_000 | |
| 344 | + | ||
| 345 | + | // MaxPathBytes bounds the total bytes of the paths ListPaths returns. | |
| 346 | + | const MaxPathBytes = 8 << 20 | |
| 347 | + | ||
| 348 | + | // PathList is the recursive list of the file paths of a revision. | |
| 349 | + | type PathList struct { | |
| 350 | + | // CommitID is the commit the revision resolved to. | |
| 351 | + | CommitID ObjectID | |
| 352 | + | // TreeID is the root tree of that commit. | |
| 353 | + | TreeID ObjectID | |
| 354 | + | // Paths are the paths of the files and symbolic links, in tree order (the | |
| 355 | + | // order of `git ls-tree -r`). | |
| 356 | + | Paths []string | |
| 357 | + | // Truncated is true when more paths exist than the ones in Paths. | |
| 358 | + | Truncated bool | |
| 359 | + | } | |
| 360 | + | ||
| 328 | 361 | // MaxLastCommitEntries bounds the entries of one directory that get a last | |
| 329 | 362 | // commit: each costs a history lookup. | |
| 330 | 363 | const MaxLastCommitEntries = 200 | |
| @@ -155,20 +155,34 @@ func ParseTreePath(s string) (TreePath, error) { | |||||
| 155 | if s == "" { | 155 | if s == "" { | ||
| 156 | return TreePath{}, nil | 156 | return TreePath{}, nil | ||
| 157 | } | 157 | } | ||
| 158 | + | if why := treePathProblem(s); why != "" { | |||
| 159 | + | return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why) | |||
| 160 | + | } | |||
| 161 | + | return TreePath{s: s}, nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // IsAddressablePath reports whether s is a non-root path that ParseTreePath | |||
| 165 | + | // accepts as it is (no trailing slash). It allocates nothing. | |||
| 166 | + | func IsAddressablePath(s string) bool { | |||
| 167 | + | return s != "" && treePathProblem(s) == "" | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // treePathProblem returns why s cannot be a non-root tree path, or "". | |||
| 171 | + | func treePathProblem(s string) string { | |||
| 158 | if len(s) > maxTreePathLen || !utf8.ValidString(s) { | 172 | if len(s) > maxTreePathLen || !utf8.ValidString(s) { | ||
| 159 | - | return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s)) | 173 | + | return "too long or not valid UTF-8" |
| 160 | } | 174 | } | ||
| 161 | for _, c := range s { | 175 | for _, c := range s { | ||
| 162 | if c < 0x20 || c == 0x7f { | 176 | if c < 0x20 || c == 0x7f { | ||
| 163 | - | return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s)) | 177 | + | return "control character" |
| 164 | } | 178 | } | ||
| 165 | } | 179 | } | ||
| 166 | for part := range strings.SplitSeq(s, "/") { | 180 | for part := range strings.SplitSeq(s, "/") { | ||
| 167 | if part == "" || part == "." || part == ".." { | 181 | if part == "" || part == "." || part == ".." { | ||
| 168 | - | return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s)) | 182 | + | return "empty, . or .. component" |
| 169 | } | 183 | } | ||
| 170 | } | 184 | } | ||
| 171 | - | return TreePath{s: s}, nil | 185 | + | return "" |
| 172 | } | 186 | } | ||
| 173 | 187 | ||||
| 174 | func truncate(s string) string { | 188 | func truncate(s string) string { | ||
| @@ -325,6 +339,25 @@ type Tree struct { | |||||
| 325 | // MaxTreeEntries bounds the entries of a listed directory. | 339 | // MaxTreeEntries bounds the entries of a listed directory. | ||
| 326 | const MaxTreeEntries = 5000 | 340 | const MaxTreeEntries = 5000 | ||
| 327 | 341 | ||||
| 342 | + | // MaxPathEntries bounds the paths ListPaths returns. | |||
| 343 | + | const MaxPathEntries = 100_000 | |||
| 344 | + | ||||
| 345 | + | // MaxPathBytes bounds the total bytes of the paths ListPaths returns. | |||
| 346 | + | const MaxPathBytes = 8 << 20 | |||
| 347 | + | ||||
| 348 | + | // PathList is the recursive list of the file paths of a revision. | |||
| 349 | + | type PathList struct { | |||
| 350 | + | // CommitID is the commit the revision resolved to. | |||
| 351 | + | CommitID ObjectID | |||
| 352 | + | // TreeID is the root tree of that commit. | |||
| 353 | + | TreeID ObjectID | |||
| 354 | + | // Paths are the paths of the files and symbolic links, in tree order (the | |||
| 355 | + | // order of `git ls-tree -r`). | |||
| 356 | + | Paths []string | |||
| 357 | + | // Truncated is true when more paths exist than the ones in Paths. | |||
| 358 | + | Truncated bool | |||
| 359 | + | } | |||
| 360 | + | ||||
| 328 | // MaxLastCommitEntries bounds the entries of one directory that get a last | 361 | // MaxLastCommitEntries bounds the entries of one directory that get a last | ||
| 329 | // commit: each costs a history lookup. | 362 | // commit: each costs a history lookup. | ||
| 330 | const MaxLastCommitEntries = 200 | 363 | const MaxLastCommitEntries = 200 | ||
M internal/domain/git_test.go +10 -0
| @@ -80,6 +80,16 @@ func TestParseTreePath(t *testing.T) { | |||
| 80 | 80 | } | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | + | func TestIsAddressablePath(t *testing.T) { | |
| 84 | + | for _, ok := range []string{"a", "a/b.txt", "dir/ünï.go", "a b/c", ".github/ci.yml"} { | |
| 85 | + | assert.True(t, domain.IsAddressablePath(ok), "%q", ok) | |
| 86 | + | } | |
| 87 | + | for _, bad := range []string{"", "/", "a/", "/a", "a//b", "a/../b", "./a", "..", "tab\tname", "a\nb", "a\x00b", "a\x7fb", "\xff", "a/\xfe/b", strings.Repeat("a", 4097)} { | |
| 88 | + | assert.False(t, domain.IsAddressablePath(bad), "%q", bad) | |
| 89 | + | } | |
| 90 | + | assert.True(t, domain.IsAddressablePath(strings.Repeat("a", 4096))) | |
| 91 | + | } | |
| 92 | + | ||
| 83 | 93 | func TestEntryKindOf(t *testing.T) { | |
| 84 | 94 | tests := map[uint32]domain.EntryKind{ | |
| 85 | 95 | 0o100644: domain.EntryFile, | |
| @@ -80,6 +80,16 @@ func TestParseTreePath(t *testing.T) { | |||||
| 80 | } | 80 | } | ||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | + | func TestIsAddressablePath(t *testing.T) { | |||
| 84 | + | for _, ok := range []string{"a", "a/b.txt", "dir/ünï.go", "a b/c", ".github/ci.yml"} { | |||
| 85 | + | assert.True(t, domain.IsAddressablePath(ok), "%q", ok) | |||
| 86 | + | } | |||
| 87 | + | for _, bad := range []string{"", "/", "a/", "/a", "a//b", "a/../b", "./a", "..", "tab\tname", "a\nb", "a\x00b", "a\x7fb", "\xff", "a/\xfe/b", strings.Repeat("a", 4097)} { | |||
| 88 | + | assert.False(t, domain.IsAddressablePath(bad), "%q", bad) | |||
| 89 | + | } | |||
| 90 | + | assert.True(t, domain.IsAddressablePath(strings.Repeat("a", 4096))) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 83 | func TestEntryKindOf(t *testing.T) { | 93 | func TestEntryKindOf(t *testing.T) { | ||
| 84 | tests := map[uint32]domain.EntryKind{ | 94 | tests := map[uint32]domain.EntryKind{ | ||
| 85 | 0o100644: domain.EntryFile, | 95 | 0o100644: domain.EntryFile, | ||
M internal/ports/mocks/RepoReader.go +84 -0
| @@ -283,6 +283,90 @@ func (_c *RepoReader_ListCommits_Call) RunAndReturn(run func(ctx context.Context | |||
| 283 | 283 | return _c | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | + | // ListPaths provides a mock function for the type RepoReader | |
| 287 | + | func (_mock *RepoReader) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error) { | |
| 288 | + | ret := _mock.Called(ctx, repo, rev, maxPaths, maxBytes) | |
| 289 | + | ||
| 290 | + | if len(ret) == 0 { | |
| 291 | + | panic("no return value specified for ListPaths") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | var r0 domain.PathList | |
| 295 | + | var r1 error | |
| 296 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) (domain.PathList, error)); ok { | |
| 297 | + | return returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |
| 298 | + | } | |
| 299 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) domain.PathList); ok { | |
| 300 | + | r0 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |
| 301 | + | } else { | |
| 302 | + | r0 = ret.Get(0).(domain.PathList) | |
| 303 | + | } | |
| 304 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, int, int) error); ok { | |
| 305 | + | r1 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |
| 306 | + | } else { | |
| 307 | + | r1 = ret.Error(1) | |
| 308 | + | } | |
| 309 | + | return r0, r1 | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // RepoReader_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths' | |
| 313 | + | type RepoReader_ListPaths_Call struct { | |
| 314 | + | *mock.Call | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // ListPaths is a helper method to define mock.On call | |
| 318 | + | // - ctx context.Context | |
| 319 | + | // - repo domain.RepoName | |
| 320 | + | // - rev domain.Revision | |
| 321 | + | // - maxPaths int | |
| 322 | + | // - maxBytes int | |
| 323 | + | func (_e *RepoReader_Expecter) ListPaths(ctx any, repo any, rev any, maxPaths any, maxBytes any) *RepoReader_ListPaths_Call { | |
| 324 | + | return &RepoReader_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, repo, rev, maxPaths, maxBytes)} | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func (_c *RepoReader_ListPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int)) *RepoReader_ListPaths_Call { | |
| 328 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 329 | + | var arg0 context.Context | |
| 330 | + | if args[0] != nil { | |
| 331 | + | arg0 = args[0].(context.Context) | |
| 332 | + | } | |
| 333 | + | var arg1 domain.RepoName | |
| 334 | + | if args[1] != nil { | |
| 335 | + | arg1 = args[1].(domain.RepoName) | |
| 336 | + | } | |
| 337 | + | var arg2 domain.Revision | |
| 338 | + | if args[2] != nil { | |
| 339 | + | arg2 = args[2].(domain.Revision) | |
| 340 | + | } | |
| 341 | + | var arg3 int | |
| 342 | + | if args[3] != nil { | |
| 343 | + | arg3 = args[3].(int) | |
| 344 | + | } | |
| 345 | + | var arg4 int | |
| 346 | + | if args[4] != nil { | |
| 347 | + | arg4 = args[4].(int) | |
| 348 | + | } | |
| 349 | + | run( | |
| 350 | + | arg0, | |
| 351 | + | arg1, | |
| 352 | + | arg2, | |
| 353 | + | arg3, | |
| 354 | + | arg4, | |
| 355 | + | ) | |
| 356 | + | }) | |
| 357 | + | return _c | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func (_c *RepoReader_ListPaths_Call) Return(pathList domain.PathList, err error) *RepoReader_ListPaths_Call { | |
| 361 | + | _c.Call.Return(pathList, err) | |
| 362 | + | return _c | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func (_c *RepoReader_ListPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error)) *RepoReader_ListPaths_Call { | |
| 366 | + | _c.Call.Return(run) | |
| 367 | + | return _c | |
| 368 | + | } | |
| 369 | + | ||
| 286 | 370 | // ListRefs provides a mock function for the type RepoReader | |
| 287 | 371 | func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | |
| 288 | 372 | ret := _mock.Called(ctx, repo) | |
| @@ -283,6 +283,90 @@ func (_c *RepoReader_ListCommits_Call) RunAndReturn(run func(ctx context.Context | |||||
| 283 | return _c | 283 | return _c | ||
| 284 | } | 284 | } | ||
| 285 | 285 | ||||
| 286 | + | // ListPaths provides a mock function for the type RepoReader | |||
| 287 | + | func (_mock *RepoReader) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error) { | |||
| 288 | + | ret := _mock.Called(ctx, repo, rev, maxPaths, maxBytes) | |||
| 289 | + | ||||
| 290 | + | if len(ret) == 0 { | |||
| 291 | + | panic("no return value specified for ListPaths") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | var r0 domain.PathList | |||
| 295 | + | var r1 error | |||
| 296 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) (domain.PathList, error)); ok { | |||
| 297 | + | return returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |||
| 298 | + | } | |||
| 299 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) domain.PathList); ok { | |||
| 300 | + | r0 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |||
| 301 | + | } else { | |||
| 302 | + | r0 = ret.Get(0).(domain.PathList) | |||
| 303 | + | } | |||
| 304 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, int, int) error); ok { | |||
| 305 | + | r1 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |||
| 306 | + | } else { | |||
| 307 | + | r1 = ret.Error(1) | |||
| 308 | + | } | |||
| 309 | + | return r0, r1 | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // RepoReader_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths' | |||
| 313 | + | type RepoReader_ListPaths_Call struct { | |||
| 314 | + | *mock.Call | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // ListPaths is a helper method to define mock.On call | |||
| 318 | + | // - ctx context.Context | |||
| 319 | + | // - repo domain.RepoName | |||
| 320 | + | // - rev domain.Revision | |||
| 321 | + | // - maxPaths int | |||
| 322 | + | // - maxBytes int | |||
| 323 | + | func (_e *RepoReader_Expecter) ListPaths(ctx any, repo any, rev any, maxPaths any, maxBytes any) *RepoReader_ListPaths_Call { | |||
| 324 | + | return &RepoReader_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, repo, rev, maxPaths, maxBytes)} | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func (_c *RepoReader_ListPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int)) *RepoReader_ListPaths_Call { | |||
| 328 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 329 | + | var arg0 context.Context | |||
| 330 | + | if args[0] != nil { | |||
| 331 | + | arg0 = args[0].(context.Context) | |||
| 332 | + | } | |||
| 333 | + | var arg1 domain.RepoName | |||
| 334 | + | if args[1] != nil { | |||
| 335 | + | arg1 = args[1].(domain.RepoName) | |||
| 336 | + | } | |||
| 337 | + | var arg2 domain.Revision | |||
| 338 | + | if args[2] != nil { | |||
| 339 | + | arg2 = args[2].(domain.Revision) | |||
| 340 | + | } | |||
| 341 | + | var arg3 int | |||
| 342 | + | if args[3] != nil { | |||
| 343 | + | arg3 = args[3].(int) | |||
| 344 | + | } | |||
| 345 | + | var arg4 int | |||
| 346 | + | if args[4] != nil { | |||
| 347 | + | arg4 = args[4].(int) | |||
| 348 | + | } | |||
| 349 | + | run( | |||
| 350 | + | arg0, | |||
| 351 | + | arg1, | |||
| 352 | + | arg2, | |||
| 353 | + | arg3, | |||
| 354 | + | arg4, | |||
| 355 | + | ) | |||
| 356 | + | }) | |||
| 357 | + | return _c | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func (_c *RepoReader_ListPaths_Call) Return(pathList domain.PathList, err error) *RepoReader_ListPaths_Call { | |||
| 361 | + | _c.Call.Return(pathList, err) | |||
| 362 | + | return _c | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func (_c *RepoReader_ListPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error)) *RepoReader_ListPaths_Call { | |||
| 366 | + | _c.Call.Return(run) | |||
| 367 | + | return _c | |||
| 368 | + | } | |||
| 369 | + | ||||
| 286 | // ListRefs provides a mock function for the type RepoReader | 370 | // ListRefs provides a mock function for the type RepoReader | ||
| 287 | func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | 371 | func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | ||
| 288 | ret := _mock.Called(ctx, repo) | 372 | ret := _mock.Called(ctx, repo) | ||
M internal/ports/ports.go +5 -0
| @@ -156,6 +156,11 @@ type RepoReader interface { | |||
| 156 | 156 | // domain.MaxLastCommitEntries entries get their last commit. It returns | |
| 157 | 157 | // domain.ErrNotDirectory if path names something else. | |
| 158 | 158 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | |
| 159 | + | // ListPaths lists the file and symlink paths of rev recursively in tree | |
| 160 | + | // order, at most maxPaths paths and maxBytes bytes of paths (PathList.Truncated | |
| 161 | + | // tells), skipping submodules and paths that are not addressable | |
| 162 | + | // (domain.IsAddressablePath). | |
| 163 | + | ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) | |
| 159 | 164 | // OpenBlob returns the description of the file at path and a reader of its | |
| 160 | 165 | // content, which streams without holding the whole file in memory. The | |
| 161 | 166 | // caller must close the reader. It returns domain.ErrNotFile if path names | |
| @@ -156,6 +156,11 @@ type RepoReader interface { | |||||
| 156 | // domain.MaxLastCommitEntries entries get their last commit. It returns | 156 | // domain.MaxLastCommitEntries entries get their last commit. It returns | ||
| 157 | // domain.ErrNotDirectory if path names something else. | 157 | // domain.ErrNotDirectory if path names something else. | ||
| 158 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | 158 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | ||
| 159 | + | // ListPaths lists the file and symlink paths of rev recursively in tree | |||
| 160 | + | // order, at most maxPaths paths and maxBytes bytes of paths (PathList.Truncated | |||
| 161 | + | // tells), skipping submodules and paths that are not addressable | |||
| 162 | + | // (domain.IsAddressablePath). | |||
| 163 | + | ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) | |||
| 159 | // OpenBlob returns the description of the file at path and a reader of its | 164 | // OpenBlob returns the description of the file at path and a reader of its | ||
| 160 | // content, which streams without holding the whole file in memory. The | 165 | // content, which streams without holding the whole file in memory. The | ||
| 161 | // caller must close the reader. It returns domain.ErrNotFile if path names | 166 | // caller must close the reader. It returns domain.ErrNotFile if path names | ||