detached 973e682

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/domain"
15	"bnema.dev/ermite/internal/ports"
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 ports.BrowseService }
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 := ports.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// ListPaths lists the paths of every file of a revision.
89func (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
104}
105
106// GetBlob streams the blob in chunks of BlobChunkSize. The first message
107// carries the metadata and the first chunk.
108func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
109	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
110	if err != nil {
111		return connectError(ctx, err)
112	}
113	if req.Msg.GetPath() == "" {
114		return connectError(ctx, invalidPath("path is required"))
115	}
116	path, err := domain.ParseTreePath(req.Msg.GetPath())
117	if err != nil {
118		return connectError(ctx, err)
119	}
120	info, body, err := s.browse.OpenBlob(ctx, caller(ctx), name, rev, path)
121	if err != nil {
122		return connectError(ctx, err)
123	}
124	defer func() { _ = body.Close() }()
125
126	// Send marshals the message before it returns, so the buffer is reused.
127	buf := make([]byte, BlobChunkSize)
128	first := true
129	for {
130		n, rerr := io.ReadFull(body, buf)
131		eof := errors.Is(rerr, io.EOF) || errors.Is(rerr, io.ErrUnexpectedEOF)
132		if rerr != nil && !eof {
133			return connectError(ctx, rerr)
134		}
135		// An empty file still gets the message with the metadata.
136		if n > 0 || first {
137			msg := &pb.GetBlobResponse{Chunk: buf[:n]}
138			if first {
139				msg.Info = blobInfoToPB(info)
140				first = false
141			}
142			if err := stream.Send(msg); err != nil {
143				return err
144			}
145		}
146		if eof {
147			return nil
148		}
149	}
150}
151
152func invalidPath(msg string) error {
153	return fmt.Errorf("%w: %s", domain.ErrInvalidPath, msg)
154}
155
156func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
157	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
158	if err != nil {
159		return nil, connectError(ctx, err)
160	}
161	path, err := domain.ParseTreePath(req.Msg.GetPath())
162	if err != nil {
163		return nil, connectError(ctx, err)
164	}
165	page, err := s.browse.ListCommits(ctx, caller(ctx), name, ports.ListCommitsParams{
166		Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
167	})
168	if err != nil {
169		return nil, connectError(ctx, err)
170	}
171	out := &pb.ListCommitsResponse{NextPageToken: page.NextPageToken, Commits: make([]*pb.Commit, 0, len(page.Commits))}
172	for _, c := range page.Commits {
173		out.Commits = append(out.Commits, commitToPB(c))
174	}
175	return connect.NewResponse(out), nil
176}
177
178func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetCommitRequest]) (*connect.Response[pb.GetCommitResponse], error) {
179	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
180	if err != nil {
181		return nil, connectError(ctx, err)
182	}
183	detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184	if err != nil {
185		return nil, connectError(ctx, err)
186	}
187	return connect.NewResponse(commitDetailToPB(detail)), nil
188}
189
190func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
191	name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
192	if err != nil {
193		return nil, connectError(ctx, err)
194	}
195	res, err := s.browse.ResolveRevision(ctx, caller(ctx), name, rev)
196	if err != nil {
197		return nil, connectError(ctx, err)
198	}
199	return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: res.CommitID.String(), Ref: res.Ref}), nil
200}

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 4

Tags 45

↑↓ moveenter openesc close