detached 576bc4a

feat(rpc): serve GitService.ListPaths

commit
576bc4ac80f9c97f59f472cd8d50260fdd91f6ea
author
brice · · 2026-10-11 18:42 UTC
parent
329477a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
6868 // every principal in a stable order.
6969 var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad}
7070
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+
71105 // TestAC2ReadAccess: every read RPC on a public repository, a private one and
72106 // one inside the limited token's list.
73107 func TestAC2ReadAccess(t *testing.T) {
@@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) {
90124 _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo}))
91125 return err
92126 },
127+ "ListPaths": func(c *clients, repo string) error {
128+ _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo}))
129+ return err
130+ },
93131 "GetBlob": func(c *clients, repo string) error {
94132 _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"})
95133 return err
@@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
158196 for _, who := range allPrincipals {
159197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160198 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)
162200 assert.NotEmpty(t, resp.Msg.GetVersion())
163201 }
164202 })
@@ -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 and105 // 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 err125 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 err133 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) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 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)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 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
2727
2828 // Features are the optional capabilities of the contract that this server
2929 // 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"}
3131
3232 // Authenticator turns a token secret into a principal, or
3333 // 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 server28 // 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, or32 // 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
8585 return connect.NewResponse(out), nil
8686 }
8787
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
91104 }
92105
93106 // 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), nil85 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 message106 // GetBlob streams the blob in chunks of BlobChunkSize. The first message

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 3

Tags 43

↑↓ moveenter openesc close