v0.10.0 ce48ee3

gitremote.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package gitremote lists the refs of repositories on github.com with
4// `git ls-remote`, for the resolution of actions (ports.GitRemote). It sends no
5// credentials, never prompts, and bounds the time and the output of git.
6package gitremote
7
8import (
9	"bytes"
10	"context"
11	"errors"
12	"fmt"
13	"os/exec"
14	"strings"
15	"time"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19)
20
21// Defaults of the bounds.
22const (
23	// DefaultTimeout is how long one ls-remote may take.
24	DefaultTimeout = 20 * time.Second
25	// DefaultMaxOutput is the most bytes of ls-remote output read: a repository
26	// with 100,000 tags lists about 8 MB. More is refused as unavailable.
27	DefaultMaxOutput = 16 << 20
28	// DefaultBaseURL is where the repositories are.
29	DefaultBaseURL = "https://github.com"
30)
31
32// Config configures a Remote.
33type Config struct {
34	// Bin is the git executable. Required.
35	Bin string
36	// Path is the PATH given to git, which it uses to find its helpers.
37	Path string
38	// Home is the HOME of git; it should be an empty directory the process
39	// owns, so that no user configuration or credential is read.
40	Home string
41	// BaseURL, Timeout and MaxOutput default to the constants above; tests
42	// replace them.
43	BaseURL   string
44	Timeout   time.Duration
45	MaxOutput int
46	// AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default.
47	AllowedProtocols string
48	// Proxy is the proxy environment forwarded to git, as NAME=value pairs: see
49	// ProxyEnv. Nothing else of the environment of ermite reaches git.
50	Proxy []string
51}
52
53// proxyVars are the variables git (through libcurl) reads to reach github.com
54// through a proxy. They are the only ones of the environment of ermite that
55// the ls-remote processes get.
56var proxyVars = []string{
57	"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY",
58	"https_proxy", "http_proxy", "no_proxy", "all_proxy",
59}
60
61// ProxyEnv picks the proxy variables from an environment lookup (os.Getenv),
62// for Config.Proxy. Empty ones are left out.
63func ProxyEnv(get func(string) string) []string {
64	var out []string
65	for _, name := range proxyVars {
66		if v := get(name); v != "" {
67			out = append(out, name+"="+v)
68		}
69	}
70	return out
71}
72
73// Remote implements ports.GitRemote over the git binary.
74type Remote struct{ cfg Config }
75
76var _ ports.GitRemote = (*Remote)(nil)
77
78// New builds a Remote.
79func New(cfg Config) *Remote {
80	if cfg.BaseURL == "" {
81		cfg.BaseURL = DefaultBaseURL
82	}
83	if cfg.Timeout <= 0 {
84		cfg.Timeout = DefaultTimeout
85	}
86	if cfg.MaxOutput <= 0 {
87		cfg.MaxOutput = DefaultMaxOutput
88	}
89	if cfg.AllowedProtocols == "" {
90		cfg.AllowedProtocols = "https"
91	}
92	return &Remote{cfg: cfg}
93}
94
95// validSegment reports whether s can be an owner or a repository name on
96// GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'.
97func validSegment(s string) bool {
98	if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' {
99		return false
100	}
101	for _, r := range s {
102		switch {
103		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
104		default:
105			return false
106		}
107	}
108	return true
109}
110
111// limitWriter keeps at most max bytes and records that more came.
112type limitWriter struct {
113	buf      bytes.Buffer
114	max      int
115	exceeded bool
116	cancel   context.CancelFunc
117}
118
119func (w *limitWriter) Write(p []byte) (int, error) {
120	if w.buf.Len()+len(p) > w.max {
121		w.exceeded = true
122		w.cancel()
123		return 0, errors.New("output limit reached")
124	}
125	return w.buf.Write(p)
126}
127
128// ListRefs lists the branches and tags of owner/repo, with the peeled commit
129// of the annotated tags.
130func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
131	if !validSegment(owner) || !validSegment(repo) {
132		return nil, domain.ErrNotFound
133	}
134	ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
135	defer cancel()
136	out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel}
137	var stderr bytes.Buffer
138	url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo
139	cmd := exec.CommandContext(ctx, r.cfg.Bin,
140		"-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false",
141		"ls-remote", "--", url, "refs/heads/*", "refs/tags/*")
142	cmd.Env = []string{
143		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home,
144		"GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
145		"GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
146	}
147	cmd.Env = append(cmd.Env, r.cfg.Proxy...)
148	cmd.Dir = r.cfg.Home
149	cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
150	cmd.WaitDelay = 2 * time.Second
151	err := cmd.Run()
152	switch {
153	case out.exceeded:
154		return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput)
155	case errors.Is(ctx.Err(), context.DeadlineExceeded):
156		return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout)
157	case ctx.Err() != nil:
158		return nil, ctx.Err()
159	case err != nil:
160		msg := stderr.String()
161		if notFound(msg) {
162			return nil, domain.ErrNotFound
163		}
164		return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg))
165	}
166	return parse(out.buf.String()), nil
167}
168
169// notFound recognises what git says when github.com answers 404 or asks for
170// credentials, which it does for a repository that does not exist as for a
171// private one, and the prompt being disabled.
172func notFound(stderr string) bool {
173	for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} {
174		if strings.Contains(stderr, s) {
175			return true
176		}
177	}
178	return false
179}
180
181func firstLine(s string) string {
182	s = strings.TrimSpace(s)
183	if i := strings.IndexByte(s, '\n'); i >= 0 {
184		s = s[:i]
185	}
186	return s
187}
188
189// limitedBuffer keeps the first max bytes written and drops the rest.
190type limitedBuffer struct {
191	b   *bytes.Buffer
192	max int
193}
194
195func (l *limitedBuffer) Write(p []byte) (int, error) {
196	if room := l.max - l.b.Len(); room > 0 {
197		l.b.Write(p[:min(room, len(p))])
198	}
199	return len(p), nil
200}
201
202// parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives
203// the peeled commit of the annotated tag listed before it.
204func parse(out string) []ports.RemoteRef {
205	var refs []ports.RemoteRef
206	index := map[string]int{}
207	for line := range strings.SplitSeq(out, "\n") {
208		id, name, ok := strings.Cut(line, "\t")
209		if !ok || len(id) != domain.ObjectIDLen {
210			continue
211		}
212		if base, peeled := strings.CutSuffix(name, "^{}"); peeled {
213			if i, ok := index[base]; ok {
214				refs[i].Peeled = id
215			}
216			continue
217		}
218		index[name] = len(refs)
219		refs = append(refs, ports.RemoteRef{Name: name, ID: id})
220	}
221	return refs
222}

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 44

↑↓ moveenter openesc close