feat(rpc): serve GitService.ListPaths
- commit
- 576bc4ac80f9c97f59f472cd8d50260fdd91f6ea
- author
- brice · · 2026-10-11 18:42 UTC
- parent
- 329477a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +57 -6
M cmd/ermite/api_test.go +39 -1
| @@ -68,6 +68,40 @@ func newAccessRig(t *testing.T) *accessRig { | |||
| 68 | 68 | // every principal in a stable order. | |
| 69 | 69 | var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad} | |
| 70 | 70 | ||
| 71 | + | // TestListPathsRPC: the owner lists public and private repositories, an | |
| 72 | + | // anonymous caller sees the private one as missing, and so is a bad revision. | |
| 73 | + | func TestListPathsRPC(t *testing.T) { | |
| 74 | + | r := newAccessRig(t) | |
| 75 | + | r.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt") | |
| 76 | + | r.seed("priv", domain.VisibilityPrivate, "README.md") | |
| 77 | + | ctx := r.ctx | |
| 78 | + | root, anon := r.who[pRoot], r.who[pAnon] | |
| 79 | + | ||
| 80 | + | list := func(c *clients, repo, rev string) (*pb.ListPathsResponse, error) { | |
| 81 | + | resp, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: rev})) | |
| 82 | + | if err != nil { | |
| 83 | + | return nil, err | |
| 84 | + | } | |
| 85 | + | return resp.Msg, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub, err := list(root, "pub", "") | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | assert.Equal(t, []string{"README.md", "docs/a.txt"}, pub.GetPaths()) | |
| 91 | + | assert.NotEmpty(t, pub.GetCommitId()) | |
| 92 | + | assert.NotEmpty(t, pub.GetTreeId()) | |
| 93 | + | assert.False(t, pub.GetTruncated()) | |
| 94 | + | ||
| 95 | + | priv, err := list(root, "priv", "") | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | assert.Equal(t, []string{"README.md"}, priv.GetPaths()) | |
| 98 | + | ||
| 99 | + | _, err = list(anon, "priv", "") | |
| 100 | + | assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) | |
| 101 | + | _, err = list(root, "pub", "nope") | |
| 102 | + | assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) | |
| 103 | + | } | |
| 104 | + | ||
| 71 | 105 | // TestAC2ReadAccess: every read RPC on a public repository, a private one and | |
| 72 | 106 | // one inside the limited token's list. | |
| 73 | 107 | func TestAC2ReadAccess(t *testing.T) { | |
| @@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 90 | 124 | _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo})) | |
| 91 | 125 | return err | |
| 92 | 126 | }, | |
| 127 | + | "ListPaths": func(c *clients, repo string) error { | |
| 128 | + | _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo})) | |
| 129 | + | return err | |
| 130 | + | }, | |
| 93 | 131 | "GetBlob": func(c *clients, repo string) error { | |
| 94 | 132 | _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"}) | |
| 95 | 133 | return err | |
| @@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 158 | 196 | for _, who := range allPrincipals { | |
| 159 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 160 | 198 | require.NoError(t, err, who) | |
| 161 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who) | |
| 162 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 163 | 201 | } | |
| 164 | 202 | }) | |
| @@ -68,6 +68,40 @@ func newAccessRig(t *testing.T) *accessRig { | |||||
| 68 | // every principal in a stable order. | 68 | // every principal in a stable order. | ||
| 69 | var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad} | 69 | var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad} | ||
| 70 | 70 | ||||
| 71 | + | // TestListPathsRPC: the owner lists public and private repositories, an | |||
| 72 | + | // anonymous caller sees the private one as missing, and so is a bad revision. | |||
| 73 | + | func TestListPathsRPC(t *testing.T) { | |||
| 74 | + | r := newAccessRig(t) | |||
| 75 | + | r.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt") | |||
| 76 | + | r.seed("priv", domain.VisibilityPrivate, "README.md") | |||
| 77 | + | ctx := r.ctx | |||
| 78 | + | root, anon := r.who[pRoot], r.who[pAnon] | |||
| 79 | + | ||||
| 80 | + | list := func(c *clients, repo, rev string) (*pb.ListPathsResponse, error) { | |||
| 81 | + | resp, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: rev})) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return nil, err | |||
| 84 | + | } | |||
| 85 | + | return resp.Msg, nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | pub, err := list(root, "pub", "") | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | assert.Equal(t, []string{"README.md", "docs/a.txt"}, pub.GetPaths()) | |||
| 91 | + | assert.NotEmpty(t, pub.GetCommitId()) | |||
| 92 | + | assert.NotEmpty(t, pub.GetTreeId()) | |||
| 93 | + | assert.False(t, pub.GetTruncated()) | |||
| 94 | + | ||||
| 95 | + | priv, err := list(root, "priv", "") | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | assert.Equal(t, []string{"README.md"}, priv.GetPaths()) | |||
| 98 | + | ||||
| 99 | + | _, err = list(anon, "priv", "") | |||
| 100 | + | assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) | |||
| 101 | + | _, err = list(root, "pub", "nope") | |||
| 102 | + | assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 71 | // TestAC2ReadAccess: every read RPC on a public repository, a private one and | 105 | // TestAC2ReadAccess: every read RPC on a public repository, a private one and | ||
| 72 | // one inside the limited token's list. | 106 | // one inside the limited token's list. | ||
| 73 | func TestAC2ReadAccess(t *testing.T) { | 107 | func TestAC2ReadAccess(t *testing.T) { | ||
| @@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 90 | _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo})) | 124 | _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo})) | ||
| 91 | return err | 125 | return err | ||
| 92 | }, | 126 | }, | ||
| 127 | + | "ListPaths": func(c *clients, repo string) error { | |||
| 128 | + | _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo})) | |||
| 129 | + | return err | |||
| 130 | + | }, | |||
| 93 | "GetBlob": func(c *clients, repo string) error { | 131 | "GetBlob": func(c *clients, repo string) error { | ||
| 94 | _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"}) | 132 | _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"}) | ||
| 95 | return err | 133 | return err | ||
| @@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 158 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 159 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 160 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 161 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who) |
| 162 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 163 | } | 201 | } | ||
| 164 | }) | 202 | }) | ||
M cmd/ermite/cli_e2e_test.go +1 -1
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 324 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 325 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 326 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, ver.Server.Features) | |
| 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | |
| 328 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 329 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 330 | require.Equal(t, 0, code, stderr) | |
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 324 | ver := decode[versionOut](t, res.stdout) | 324 | ver := decode[versionOut](t, res.stdout) | ||
| 325 | assert.Equal(t, c.url, ver.Server.URL) | 325 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 326 | assert.NotEmpty(t, ver.Server.Version) | 326 | assert.NotEmpty(t, ver.Server.Version) | ||
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, ver.Server.Features) | 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) |
| 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | ||
| 329 | stdout, stderr, code = c.bin(t, secret, "version") | 329 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 330 | require.Equal(t, 0, code, stderr) | 330 | require.Equal(t, 0, code, stderr) | ||
M internal/adapters/rpc/api.go +1 -1
| @@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 27 | 27 | ||
| 28 | 28 | // Features are the optional capabilities of the contract that this server | |
| 29 | 29 | // implements, as GetVersion lists them. | |
| 30 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | |
| 30 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | |
| 31 | 31 | ||
| 32 | 32 | // Authenticator turns a token secret into a principal, or | |
| 33 | 33 | // domain.ErrUnauthenticated. app.Auth fits. | |
| @@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 27 | 27 | ||||
| 28 | // Features are the optional capabilities of the contract that this server | 28 | // Features are the optional capabilities of the contract that this server | ||
| 29 | // implements, as GetVersion lists them. | 29 | // implements, as GetVersion lists them. | ||
| 30 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | 30 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} |
| 31 | 31 | ||||
| 32 | // Authenticator turns a token secret into a principal, or | 32 | // Authenticator turns a token secret into a principal, or | ||
| 33 | // domain.ErrUnauthenticated. app.Auth fits. | 33 | // domain.ErrUnauthenticated. app.Auth fits. | ||
M internal/adapters/rpc/git.go +16 -3
| @@ -85,9 +85,22 @@ 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")) | |
| 88 | + | // ListPaths lists the paths of every file of a revision. | |
| 89 | + | func (s *gitService) ListPaths(ctx context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |
| 90 | + | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, connectError(ctx, err) | |
| 93 | + | } | |
| 94 | + | list, err := s.browse.ListPaths(ctx, caller(ctx), name, rev) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, connectError(ctx, err) | |
| 97 | + | } | |
| 98 | + | return connect.NewResponse(&pb.ListPathsResponse{ | |
| 99 | + | CommitId: list.CommitID.String(), | |
| 100 | + | TreeId: list.TreeID.String(), | |
| 101 | + | Paths: list.Paths, | |
| 102 | + | Truncated: list.Truncated, | |
| 103 | + | }), nil | |
| 91 | 104 | } | |
| 92 | 105 | ||
| 93 | 106 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | |
| @@ -85,9 +85,22 @@ 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. | 88 | + | // ListPaths lists the paths of every file of a revision. |
| 89 | - | func (s *gitService) ListPaths(context.Context, *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | 89 | + | func (s *gitService) ListPaths(ctx context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { |
| 90 | - | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ListPaths is not implemented")) | 90 | + | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) |
| 91 | + | if err != nil { | |||
| 92 | + | return nil, connectError(ctx, err) | |||
| 93 | + | } | |||
| 94 | + | list, err := s.browse.ListPaths(ctx, caller(ctx), name, rev) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return nil, connectError(ctx, err) | |||
| 97 | + | } | |||
| 98 | + | return connect.NewResponse(&pb.ListPathsResponse{ | |||
| 99 | + | CommitId: list.CommitID.String(), | |||
| 100 | + | TreeId: list.TreeID.String(), | |||
| 101 | + | Paths: list.Paths, | |||
| 102 | + | Truncated: list.Truncated, | |||
| 103 | + | }), nil | |||
| 91 | } | 104 | } | ||
| 92 | 105 | ||||
| 93 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | 106 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | ||