v0.4.0 3027a40

git.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"io"
10
11	"connectrpc.com/connect"
12
13	pb "bnema.dev/ermite-api/gen/ermite"
14	"bnema.dev/ermite/internal/app"
15	"bnema.dev/ermite/internal/domain"
16)
17
18// BlobChunkSize is the largest chunk of a GetBlob stream.
19const BlobChunkSize = 64 << 10
20
21// gitService implements ermiteconnect.GitServiceHandler.
22type gitService struct{ browse *app.Browse }
23
24// target is the repository and revision most requests start with.
25func target(repo, revision string) (domain.RepoName, domain.Revision, error) {
26	name, err := domain.ParseRepoName(repo)
27	if err != nil {
28		return domain.RepoName{}, domain.Revision{}, err
29	}
30	rev, err := domain.ParseRevision(revision)
31	if err != nil {
32		return domain.RepoName{}, domain.Revision{}, err
33	}
34	return name, rev, nil
35}
36
37func (s *gitService) ListRefs(ctx context.Context, req *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) {
38	name, err := domain.ParseRepoName(req.Msg.GetRepo())
39	if err != nil {
40		return nil, connectError(ctx, err)
41	}
42	params := app.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()}
43	for _, k := range req.Msg.GetKinds() {
44		kind, err := refKindFromPB(k)
45		if err != nil {
46			return nil, connectError(ctx, err)
47		}
48		params.Kinds = append(params.Kinds, kind)
49	}
50	page, err := s.browse.ListRefs(ctx, caller(ctx), name, params)
51	if err != nil {
52		return nil, connectError(ctx, err)
53	}
54	out := &pb.ListRefsResponse{NextPageToken: page.NextPageToken, Refs: make([]*pb.Ref, 0, len(page.Refs))}
55	for _, r := range page.Refs {
56		out.Refs = append(out.Refs, refToPB(r))
57	}
58	return connect.NewResponse(out), nil
59}
60
61func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTreeRequest]) (*connect.Response[pb.GetTreeResponse], error) {
62	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
63	if err != nil {
64		return nil, connectError(ctx, err)
65	}
66	path, err := domain.ParseTreePath(req.Msg.GetPath())
67	if err != nil {
68		return nil, connectError(ctx, err)
69	}
70	tree, err := s.browse.GetTree(ctx, caller(ctx), name, rev, path, req.Msg.GetIncludeLastCommit())
71	if err != nil {
72		return nil, connectError(ctx, err)
73	}
74	out := &pb.GetTreeResponse{
75		CommitId: tree.CommitID.String(),
76		TreeId:   tree.ID.String(),
77		Entries:  make([]*pb.TreeEntry, 0, len(tree.Entries)),
78
79		LastCommitsTruncated: tree.LastCommitsOmitted,
80		Truncated:            tree.Truncated,
81	}
82	for _, e := range tree.Entries {
83		out.Entries = append(out.Entries, treeEntryToPB(e))
84	}
85	return connect.NewResponse(out), nil
86}
87
88// GetBlob streams the blob in chunks of BlobChunkSize. The first message
89// carries the metadata and the first chunk.
90func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
91	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
92	if err != nil {
93		return connectError(ctx, err)
94	}
95	if req.Msg.GetPath() == "" {
96		return connectError(ctx, invalidPath("path is required"))
97	}
98	path, err := domain.ParseTreePath(req.Msg.GetPath())
99	if err != nil {
100		return connectError(ctx, err)
101	}
102	info, body, err := s.browse.OpenBlob(ctx, caller(ctx), name, rev, path)
103	if err != nil {
104		return connectError(ctx, err)
105	}
106	defer func() { _ = body.Close() }()
107
108	// Send marshals the message before it returns, so the buffer is reused.
109	buf := make([]byte, BlobChunkSize)
110	first := true
111	for {
112		n, rerr := io.ReadFull(body, buf)
113		eof := errors.Is(rerr, io.EOF) || errors.Is(rerr, io.ErrUnexpectedEOF)
114		if rerr != nil && !eof {
115			return connectError(ctx, rerr)
116		}
117		// An empty file still gets the message with the metadata.
118		if n > 0 || first {
119			msg := &pb.GetBlobResponse{Chunk: buf[:n]}
120			if first {
121				msg.Info = blobInfoToPB(info)
122				first = false
123			}
124			if err := stream.Send(msg); err != nil {
125				return err
126			}
127		}
128		if eof {
129			return nil
130		}
131	}
132}
133
134func invalidPath(msg string) error {
135	return fmt.Errorf("%w: %s", domain.ErrInvalidPath, msg)
136}
137
138func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
139	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
140	if err != nil {
141		return nil, connectError(ctx, err)
142	}
143	path, err := domain.ParseTreePath(req.Msg.GetPath())
144	if err != nil {
145		return nil, connectError(ctx, err)
146	}
147	page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{
148		Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
149	})
150	if err != nil {
151		return nil, connectError(ctx, err)
152	}
153	out := &pb.ListCommitsResponse{NextPageToken: page.NextPageToken, Commits: make([]*pb.Commit, 0, len(page.Commits))}
154	for _, c := range page.Commits {
155		out.Commits = append(out.Commits, commitToPB(c))
156	}
157	return connect.NewResponse(out), nil
158}
159
160func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetCommitRequest]) (*connect.Response[pb.GetCommitResponse], error) {
161	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
162	if err != nil {
163		return nil, connectError(ctx, err)
164	}
165	detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
166	if err != nil {
167		return nil, connectError(ctx, err)
168	}
169	return connect.NewResponse(commitDetailToPB(detail)), nil
170}
171
172//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
173func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
174	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
175	if err != nil {
176		return nil, connectError(ctx, err)
177	}
178	res, err := s.browse.ResolveRevision(ctx, caller(ctx), name, rev)
179	if err != nil {
180		return nil, connectError(ctx, err)
181	}
182	return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: res.CommitID.String(), Ref: res.Ref}), nil
183}

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