detached 27e453c

push_trees.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6	"bufio"
7	"context"
8	"errors"
9	"fmt"
10	"io"
11	"path/filepath"
12	"strconv"
13	"strings"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19var _ ports.PushTreeReader = (*Git)(nil)
20
21const (
22	maxDiffPath    = 4096
23	maxRevListLine = 8192
24)
25
26// PushSubtrees peels push endpoints, walks new commits, and reads entries at path.
27func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
28	out := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}}
29	dir, err := g.repoDir(repo)
30	if err != nil {
31		return out, err
32	}
33	extra, err := quarantineEnv(dir, objects)
34	if err != nil {
35		return out, err
36	}
37	var kept []domain.RefUpdate
38	for _, up := range updates {
39		if up.New != domain.ZeroOID {
40			kept = append(kept, up)
41		}
42	}
43	if len(kept) == 0 {
44		return out, nil
45	}
46	return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out)
47}
48
49// readPushSubtrees batches the endpoint and walked-object cat-file queries.
50func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) {
51	types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
52	if err != nil {
53		return out, err
54	}
55	tips := pushCommitTips(kept, types, out.Ends)
56	if len(tips) != 0 {
57		out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds)
58		if err != nil {
59			return out, err
60		}
61	}
62	ids := commitAndParentIDs(out.Commits)
63	ws, err := g.subtreesAt(ctx, dir, extra, ids, path)
64	if err != nil {
65		return out, err
66	}
67	for id, w := range ws {
68		out.Subtrees[id] = w
69	}
70	ids = pushEndSubtreeIDs(kept, out.Ends)
71	ws, err = g.subtreesAt(ctx, dir, extra, ids, path)
72	if err != nil {
73		return out, err
74	}
75	for id, w := range ws {
76		out.Subtrees[id] = w
77	}
78	return out, nil
79}
80
81// pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips.
82func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushObjectType) []string {
83	var tips []string
84	seen := map[string]bool{}
85	for i, up := range kept {
86		for j, id := range []string{up.Old, up.New} {
87			if id != domain.ZeroOID {
88				ends[id] = domain.PushObjectType(types[2*i+j])
89			}
90		}
91		if types[2*i+1] == "commit" && !seen[up.New] {
92			seen[up.New] = true
93			tips = append(tips, up.New)
94		}
95	}
96	return tips
97}
98
99// commitAndParentIDs deduplicates the object names for a cat-file batch.
100func commitAndParentIDs(commits []domain.PushedCommit) []string {
101	var ids []string
102	asked := map[string]bool{}
103	for _, c := range commits {
104		for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) {
105			key := id.String()
106			if !asked[key] {
107				asked[key] = true
108				ids = append(ids, key)
109			}
110		}
111	}
112	return ids
113}
114
115// pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates.
116func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushObjectType) []string {
117	var ids []string
118	for _, up := range kept {
119		for _, id := range []string{up.Old, up.New} {
120			if id != domain.ZeroOID && ends[id] != domain.PushObjectBlob {
121				ids = append(ids, id)
122			}
123		}
124	}
125	// HEAD is needed only for a new tree-ish ref. Keep it in the end batch.
126	for _, up := range kept {
127		if up.Old == domain.ZeroOID && ends[up.New] != domain.PushObjectBlob {
128			ids = append(ids, domain.HeadKey)
129			break
130		}
131	}
132	return ids
133}
134
135// newCommits lists the commits reachable from the tips and from no ref
136// (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more
137// fails closed.
138func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) {
139	if err := revListLimitsError(bounds); err != nil {
140		return nil, err
141	}
142	args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...)
143	var out []domain.PushedCommit
144	err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
145		var err error
146		out, err = parseRevList(r, bounds.Commits, bounds.IDs)
147		return err
148	})
149	if err != nil {
150		return nil, fmt.Errorf("commits of a push: %w", err)
151	}
152	return out, nil
153}
154
155// revListLimitsError checks the numeric range of rev-list and parser limits.
156func revListLimitsError(bounds domain.PushWalkBounds) error {
157	if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) {
158		return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid)
159	}
160	return nil
161}
162
163// parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."):
164// at most maxCommits lines and maxIDs ids in all (a commit and each of its
165// parents count, repeats too). A line over maxRevListLine, a limit crossed or an
166// id that is not one is an error, and it is raised as soon as the line that
167// crosses it is read: nothing past it is read or kept.
168func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) {
169	var out []domain.PushedCommit
170	ids := 0
171	for {
172		line, err := r.ReadSlice('\n')
173		switch {
174		case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine:
175			return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid)
176		case errors.Is(err, io.EOF) && len(line) == 0:
177			return out, nil
178		case err != nil && !errors.Is(err, io.EOF):
179			return nil, err
180		}
181		f := strings.Fields(string(line))
182		if len(f) == 0 {
183			continue
184		}
185		if len(out) == maxCommits {
186			return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits)
187		}
188		if ids += len(f); ids > maxIDs {
189			return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs)
190		}
191		for _, id := range f {
192			if _, err := domain.ParseObjectID(id); err != nil {
193				return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid)
194			}
195		}
196		id, _ := domain.ParseObjectID(f[0])
197		c := domain.PushedCommit{ID: id}
198		for _, p := range f[1:] {
199			id, _ := domain.ParseObjectID(p)
200			c.Parents = append(c.Parents, id)
201		}
202		out = append(out, c)
203	}
204}
205
206// subtreesAt reads the mode, id and type of entries without traversing them.
207func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) {
208	out := make(map[string]domain.Subtree, len(ids))
209	if len(ids) == 0 {
210		return out, nil
211	}
212	specs := make([]string, len(ids))
213	for i, id := range ids {
214		specs[i] = id + ":" + path
215	}
216	args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)")
217	err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error {
218		for _, key := range ids {
219			line, err := r.ReadSlice('\n')
220			if err != nil {
221				return fmt.Errorf("read object answers: %w", err)
222			}
223			f := strings.Fields(string(line))
224			if len(f) != 3 {
225				continue
226			} // missing entry
227			mode, err := strconv.ParseUint(f[0], 8, 32)
228			if err != nil {
229				continue
230			}
231			id, err := domain.ParseObjectID(f[1])
232			if err != nil {
233				return err
234			}
235			kind := domain.EntryKindOf(uint32(mode))
236			if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") {
237				kind = ""
238			}
239			out[key] = domain.Subtree{Kind: kind, ID: id}
240		}
241		return nil
242	})
243	if err != nil {
244		return nil, fmt.Errorf("subtrees of a push: %w", err)
245	}
246	return out, nil
247}
248
249// FirstDifferentEntry streams the first differing top-level entry of two trees.
250func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) {
251	dir, err := g.repoDir(repo)
252	if err != nil {
253		return "", err
254	}
255	extra, err := quarantineEnv(dir, objects)
256	if err != nil {
257		return "", err
258	}
259	from := oldID.String()
260	if from == "" {
261		from = emptyTree
262	}
263	to := newID.String()
264	args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
265	var name string
266	err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
267		n, found, err := firstPath(r)
268		name = n
269		if err != nil || !found {
270			return err // no name: git's exit status is checked, so a failure refuses
271		}
272		return errStop // the first name is all that is needed: stop git
273	})
274	if err != nil {
275		return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err)
276	}
277	return name, nil
278}
279
280// firstPath reads the first NUL-terminated path of r; found is false at the end
281// of the output without one. A path longer than maxDiffPath is an error.
282func firstPath(r *bufio.Reader) (path string, found bool, err error) {
283	var b []byte
284	for {
285		c, err := r.ReadByte()
286		switch {
287		case err != nil:
288			return "", false, nil // the end of the output; a failing exit status is reported by pipe
289		case c == 0:
290			return string(b), true, nil
291		case len(b) >= maxDiffPath:
292			return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath)
293		}
294		b = append(b, c)
295	}
296}
297
298// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
299// old (unless the ref is created) and the new end of each update and returns
300// the answers in that order, two per update, "" for the old end of a created
301// ref. With suffix "^{}" an object that is missing, malformed or neither a
302// commit, a tree nor a blob is an error (the push is refused); with any other
303// suffix an answer "<input> missing" is kept as the answer.
304func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
305	var in strings.Builder
306	want := 0
307	for _, up := range ups {
308		for _, oid := range []string{up.Old, up.New} {
309			if oid == domain.ZeroOID {
310				continue
311			}
312			if _, err := domain.ParseObjectID(oid); err != nil {
313				return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
314			}
315			in.WriteString(oid + suffix + "\n")
316			want++
317		}
318	}
319	got := make([]string, 0, want)
320	args := readArgs(dir, "cat-file", "--batch-check="+format)
321	err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
322		for range want {
323			line, err := r.ReadSlice('\n')
324			if err != nil {
325				if errors.Is(err, bufio.ErrBufferFull) {
326					return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
327				}
328				return fmt.Errorf("read object answers: %w", err)
329			}
330			got = append(got, strings.TrimSuffix(string(line), "\n"))
331		}
332		return nil
333	})
334	if err != nil {
335		return nil, fmt.Errorf("objects of a push: %w", err)
336	}
337	out := make([]string, 0, 2*len(ups))
338	i := 0
339	for _, up := range ups {
340		for _, oid := range []string{up.Old, up.New} {
341			if oid == domain.ZeroOID {
342				out = append(out, "")
343				continue
344			}
345			if suffix == "^{}" {
346				switch got[i] {
347				case "commit", "tree", "blob":
348				default: // "<input> missing", or a type that cannot be compared
349					return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
350				}
351			}
352			out = append(out, got[i])
353			i++
354		}
355	}
356	return out, nil
357}
358
359// quarantineEnv returns the environment that makes git read the objects of a
360// push in quarantine. Each directory must exist and, once symbolic links are
361// resolved, be inside the objects directory of the repository dir (itself
362// resolved): a hook request cannot send git to read anything else. The resolved
363// paths are the ones given to git.
364func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
365	if o.Dir == "" && o.Alternates == "" {
366		return nil, nil
367	}
368	root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
369	if err != nil {
370		return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
371	}
372	inside := func(p string) (string, bool) {
373		if !filepath.IsAbs(p) {
374			return "", false
375		}
376		real, err := filepath.EvalSymlinks(p)
377		if err != nil {
378			return "", false
379		}
380		rel, err := filepath.Rel(root, real)
381		return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
382	}
383	dir, ok := inside(o.Dir)
384	if o.Dir == "" || !ok {
385		return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
386	}
387	env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
388	if o.Alternates != "" {
389		var alts []string
390		for _, a := range filepath.SplitList(o.Alternates) {
391			real, ok := inside(a)
392			if !ok {
393				return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
394			}
395			alts = append(alts, real)
396		}
397		env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
398	}
399	return env, nil
400}

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