detached 973e682

diff.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6	"bufio"
7	"bytes"
8	"context"
9	"errors"
10	"fmt"
11	"io"
12	"strconv"
13	"strings"
14
15	"bnema.dev/ermite/internal/domain"
16)
17
18// emptyTreeID is the SHA-1 id of the empty tree, known to Git without the
19// object being stored. It is the "parent" of a root commit.
20const emptyTreeID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
21
22// maxDiffLine bounds the text of one diff line; a file with a longer line is
23// reported truncated rather than cut, which also bounds the memory of a read.
24const maxDiffLine = 64 << 10
25
26// GetCommit returns a commit with its diff against its first parent (the empty
27// tree for a root commit), read in one pass of
28//
29//	git diff-tree -r -M -z --raw --numstat -p <parent> <commit>
30//
31// which prints the changed files, then their line counts, then the patch.
32// Statistics come from the line counts, binary files from their "-" counts.
33// The patch is parsed as it streams: nothing beyond the limits is kept, and
34// git is stopped once nothing more is needed.
35func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
36	dir, err := g.repoDir(repo)
37	if err != nil {
38		return domain.CommitDetail{}, err
39	}
40	var res domain.ResolvedRevision
41	err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
42		res, err = resolve(c, rev)
43		return err
44	})
45	if err != nil {
46		return domain.CommitDetail{}, fmt.Errorf("commit %q of %q: %w", rev, repo, err)
47	}
48	commit, err := g.lastCommit(ctx, dir, res.CommitID, "")
49	if err != nil {
50		return domain.CommitDetail{}, fmt.Errorf("commit %q of %q: %w", rev, repo, err)
51	}
52	base := emptyTreeID
53	if len(commit.Parents) > 0 {
54		base = commit.Parents[0].String()
55	}
56	detail := domain.CommitDetail{Commit: commit}
57	detail.Stats, detail.Diff, err = g.diffTrees(ctx, dir, base, commit.ID.String(), limits)
58	if err != nil {
59		return domain.CommitDetail{}, fmt.Errorf("diff of %s in %q: %w", commit.ID, repo, err)
60	}
61	return detail, nil
62}
63
64// diffTrees reads the diff from base to head, two commit ids or tree ids.
65func (g *Git) diffTrees(ctx context.Context, dir, base, head string, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
66	var (
67		diff  domain.Diff
68		stats domain.DiffStats
69	)
70	args := readArgs(dir, "diff-tree", "-r", "-M", "-z", "--raw", "--numstat", "-p", "-U3",
71		"--no-ext-diff", "--no-textconv", "--no-color", "--end-of-options", base, head)
72	err := g.pipe(ctx, args, func(r *bufio.Reader) error {
73		var err error
74		diff, stats, err = readDiff(r, limits)
75		return err
76	})
77	if err != nil {
78		return domain.DiffStats{}, domain.Diff{}, err
79	}
80	return stats, diff, nil
81}
82
83// rawEntry is one file of the diff: a `--raw` record and its `--numstat`.
84type rawEntry struct {
85	file       domain.FileDiff
86	typeChange bool
87	added, del int
88}
89
90// readDiff parses the output of diff-tree --raw --numstat -p.
91//
92//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
93func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
94	entries, err := readRaw(r)
95	if err != nil {
96		return domain.Diff{}, domain.DiffStats{}, err
97	}
98	if err := readNumstat(r, entries); err != nil {
99		return domain.Diff{}, domain.DiffStats{}, err
100	}
101	var stats domain.DiffStats
102	stats.FilesChanged = len(entries)
103	for i := range entries {
104		stats.Additions += entries[i].added
105		stats.Deletions += entries[i].del
106	}
107	var diff domain.Diff
108	budget := domain.NewDiffBudget(limits)
109	kept, from, to, truncated := budget.Window(len(entries))
110	entries = entries[:kept]
111	diff.Truncated = truncated
112
113	// The patch holds one section per file, two for a type change, in the
114	// order of the entries. Entries without lines to show (binary, pure
115	// rename, mode change) have a section without hunks.
116	lastRead := -1
117	for i := from; i < to; i++ {
118		if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119			lastRead = i
120		}
121	}
122	if lastRead >= 0 {
123		// A single NUL separates the numstat from the patch.
124		if b, err := r.Peek(1); err == nil && b[0] == 0 {
125			_, _ = r.Discard(1)
126		}
127	}
128	pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget}
129	diff.Files = make([]domain.FileDiff, 0, len(entries))
130	for i := range entries {
131		e := &entries[i]
132		sections := 1
133		if e.typeChange {
134			sections = 2
135		}
136		inWindow := i >= from && i < to
137		read, why := false, domain.OmitNone
138		if inWindow {
139			read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
140		}
141		switch {
142		case !inWindow:
143			// Nothing to show.
144		case !read:
145			e.file.Omitted = why
146		default:
147			before := pr.budget
148			pr.budget.StartFile()
149			hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
150			if err != nil {
151				return domain.Diff{}, domain.DiffStats{}, err
152			}
153			e.file.Hunks, e.file.Omitted = hunks, why
154			if why != domain.OmitNone {
155				pr.budget = before // nothing of this file is kept
156			}
157			sections = 0
158		}
159		if sections > 0 && i < lastRead {
160			if err := pr.skip(sections); err != nil {
161				return domain.Diff{}, domain.DiffStats{}, err
162			}
163		}
164		diff.Files = append(diff.Files, e.file)
165	}
166	if lastRead < len(entries)-1 {
167		// What follows has no hunks to read: stop git instead of draining it.
168		return diff, stats, errStop
169	}
170	return diff, stats, nil
171}
172
173// readRaw reads the `--raw -z` records, which start with ':'.
174func readRaw(r *bufio.Reader) ([]rawEntry, error) {
175	var entries []rawEntry
176	for {
177		b, err := r.Peek(1)
178		if err != nil {
179			if errors.Is(err, io.EOF) {
180				return entries, nil
181			}
182			return nil, err
183		}
184		if b[0] != ':' {
185			return entries, nil
186		}
187		meta, err := readToken(r)
188		if err != nil {
189			return nil, unexpectedEOF(err)
190		}
191		e, npaths, err := parseRawMeta(meta)
192		if err != nil {
193			return nil, err
194		}
195		paths := make([]string, npaths)
196		for i := range paths {
197			if paths[i], err = readToken(r); err != nil {
198				return nil, unexpectedEOF(err)
199			}
200		}
201		switch e.file.Status {
202		case domain.FileAdded:
203			e.file.NewPath = paths[0]
204		case domain.FileDeleted:
205			e.file.OldPath = paths[0]
206		case domain.FileRenamed, domain.FileCopied:
207			e.file.OldPath, e.file.NewPath = paths[0], paths[1]
208		default:
209			e.file.OldPath, e.file.NewPath = paths[0], paths[0]
210		}
211		entries = append(entries, e)
212	}
213}
214
215func unexpectedEOF(err error) error {
216	if errors.Is(err, io.EOF) {
217		return io.ErrUnexpectedEOF
218	}
219	return err
220}
221
222// parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and
223// returns the number of paths that follow.
224func parseRawMeta(meta string) (rawEntry, int, error) {
225	f := strings.Fields(strings.TrimPrefix(meta, ":"))
226	if len(f) != 5 || f[4] == "" {
227		return rawEntry{}, 0, fmt.Errorf("git diff-tree: unexpected record %q", truncateText(meta))
228	}
229	var e rawEntry
230	for i, dst := range []*uint32{&e.file.OldMode, &e.file.NewMode} {
231		m, err := strconv.ParseUint(f[i], 8, 32)
232		if err != nil {
233			return rawEntry{}, 0, fmt.Errorf("git diff-tree: mode in %q: %w", truncateText(meta), err)
234		}
235		*dst = uint32(m)
236	}
237	for i, dst := range []*domain.ObjectID{&e.file.OldID, &e.file.NewID} {
238		if strings.Trim(f[2+i], "0") == "" {
239			continue // the null id: no such side
240		}
241		id, err := domain.ParseObjectID(f[2+i])
242		if err != nil {
243			return rawEntry{}, 0, fmt.Errorf("git diff-tree: %w", err)
244		}
245		*dst = id
246	}
247	npaths := 1
248	switch f[4][0] {
249	case 'A':
250		e.file.Status = domain.FileAdded
251	case 'D':
252		e.file.Status = domain.FileDeleted
253	case 'R', 'C':
254		e.file.Status = domain.FileRenamed
255		if f[4][0] == 'C' {
256			e.file.Status = domain.FileCopied
257		}
258		npaths = 2
259		if len(f[4]) > 1 {
260			sim, err := strconv.Atoi(f[4][1:])
261			if err != nil {
262				return rawEntry{}, 0, fmt.Errorf("git diff-tree: similarity in %q: %w", truncateText(meta), err)
263			}
264			e.file.Similarity = sim
265		}
266	case 'T':
267		e.file.Status = domain.FileTypeChanged
268		e.typeChange = true
269	default:
270		e.file.Status = domain.FileModified
271	}
272	return e, npaths, nil
273}
274
275// readNumstat reads one `--numstat -z` record per entry. A binary file has
276// "-" counts.
277func readNumstat(r *bufio.Reader, entries []rawEntry) error {
278	for i := range entries {
279		tok, err := readToken(r)
280		if err != nil {
281			return unexpectedEOF(err)
282		}
283		add, rest, ok1 := strings.Cut(tok, "\t")
284		del, path, ok2 := strings.Cut(rest, "\t")
285		if !ok1 || !ok2 {
286			return fmt.Errorf("git diff-tree: unexpected numstat record %q", truncateText(tok))
287		}
288		if path == "" { // a rename: the two paths follow
289			for range 2 {
290				if _, err := readToken(r); err != nil {
291					return unexpectedEOF(err)
292				}
293			}
294		}
295		if add == "-" && del == "-" {
296			entries[i].file.Binary = true
297			continue
298		}
299		if entries[i].added, err = strconv.Atoi(add); err != nil {
300			return fmt.Errorf("git diff-tree: numstat %q: %w", truncateText(tok), err)
301		}
302		if entries[i].del, err = strconv.Atoi(del); err != nil {
303			return fmt.Errorf("git diff-tree: numstat %q: %w", truncateText(tok), err)
304		}
305		entries[i].file.Additions, entries[i].file.Deletions = entries[i].added, entries[i].del
306	}
307	return nil
308}
309
310// patchReader walks the patch section by section, in file order. A section
311// starts with "diff --git"; hunk lines start with ' ', '+', '-' or '\', so
312// the marker never appears inside one.
313type patchReader struct {
314	lr      *lineReader
315	pending []byte // a "diff --git" line read ahead
316	hasPend bool
317	eof     bool
318	cut     bool // the last line read was cut
319
320	budget domain.DiffBudget
321}
322
323const sectionMarker = "diff --git "
324
325// line returns the next line, or ok=false at the end of the patch.
326func (p *patchReader) line() (line []byte, ok bool, err error) {
327	if p.hasPend {
328		p.hasPend = false
329		return p.pending, true, nil
330	}
331	if p.eof {
332		return nil, false, nil
333	}
334	line, cut, err := p.lr.next()
335	if errors.Is(err, io.EOF) {
336		p.eof = true
337		return nil, false, nil
338	}
339	p.cut = cut
340	return line, err == nil, err
341}
342
343func (p *patchReader) unread(line []byte) {
344	p.pending = append(p.pending[:0], line...)
345	p.hasPend = true
346}
347
348// enterSection reads up to and including the next "diff --git" line.
349func (p *patchReader) enterSection() (bool, error) {
350	for {
351		line, ok, err := p.line()
352		if err != nil || !ok {
353			return false, err
354		}
355		if bytes.HasPrefix(line, []byte(sectionMarker)) {
356			return true, nil
357		}
358	}
359}
360
361// skip moves past n sections without keeping anything.
362func (p *patchReader) skip(n int) error {
363	for range n {
364		ok, err := p.enterSection()
365		if err != nil || !ok {
366			return err
367		}
368		for {
369			line, ok, err := p.line()
370			if err != nil {
371				return err
372			}
373			if !ok {
374				break
375			}
376			if bytes.HasPrefix(line, []byte(sectionMarker)) {
377				p.unread(line)
378				break
379			}
380		}
381	}
382	return nil
383}
384
385// file reads the sections of one file and returns its hunks, the number of
386// changed lines, and why it was omitted: it exceeded max changed lines or the
387// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
388// skipped.
389func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
390	for done := range sections {
391		ok, err := p.enterSection()
392		if err != nil {
393			return nil, 0, domain.OmitNone, err
394		}
395		if !ok {
396			break
397		}
398		h, n, why, err := p.section(max - lines)
399		if err != nil {
400			return nil, 0, domain.OmitNone, err
401		}
402		hunks = append(hunks, h...)
403		lines += n
404		if why != domain.OmitNone {
405			// Leave the stream at the start of the next file.
406			return nil, lines, why, p.skip(sections - done - 1)
407		}
408	}
409	return hunks, lines, domain.OmitNone, nil
410}
411
412// section reads the hunks of the current section up to the next marker, which
413// stays pending. It gives up (with a reason) after more than max changed lines.
414func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
415	var cur *domain.Hunk
416	var oldN, newN int
417	for {
418		line, ok, err := p.line()
419		if err != nil {
420			return nil, 0, domain.OmitNone, err
421		}
422		if !ok {
423			return hunks, lines, domain.OmitNone, nil
424		}
425		if bytes.HasPrefix(line, []byte(sectionMarker)) {
426			p.unread(line)
427			return hunks, lines, domain.OmitNone, nil
428		}
429		if p.cut {
430			return nil, lines, domain.OmitUnavailable, nil
431		}
432		if bytes.HasPrefix(line, []byte("@@ ")) {
433			h, err := parseHunkHeader(string(line))
434			if err != nil {
435				return nil, 0, domain.OmitNone, err
436			}
437			hunks = append(hunks, h)
438			cur = &hunks[len(hunks)-1]
439			oldN, newN = h.OldStart, h.NewStart
440			continue
441		}
442		if cur == nil || len(line) == 0 {
443			continue // header lines of the section
444		}
445		if line[0] == '\\' { // "\ No newline at end of file"
446			if n := len(cur.Lines); n > 0 {
447				cur.Lines[n-1].NoNewlineAtEOF = true
448			}
449			continue
450		}
451		var l domain.Line
452		text := strings.ToValidUTF8(string(line[min(1, len(line)):]), "\uFFFD")
453		switch line[0] {
454		case ' ':
455			l = domain.Line{Kind: domain.LineContext, OldNumber: oldN, NewNumber: newN, Text: text}
456			oldN++
457			newN++
458		case '+':
459			l = domain.Line{Kind: domain.LineAddition, NewNumber: newN, Text: text}
460			newN++
461		case '-':
462			l = domain.Line{Kind: domain.LineDeletion, OldNumber: oldN, Text: text}
463			oldN++
464		default:
465			continue
466		}
467		changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
468		if changed {
469			lines++
470		}
471		if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
472			return nil, lines, why, nil
473		}
474		if lines > max {
475			return nil, lines, domain.OmitLarge, nil
476		}
477		cur.Lines = append(cur.Lines, l)
478	}
479}
480
481// parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]".
482func parseHunkHeader(s string) (domain.Hunk, error) {
483	bad := func() (domain.Hunk, error) {
484		return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s))
485	}
486	rest, ok := strings.CutPrefix(s, "@@ -")
487	if !ok {
488		return bad()
489	}
490	ranges, header, ok := strings.Cut(rest, " @@")
491	if !ok {
492		return bad()
493	}
494	oldR, newR, ok := strings.Cut(ranges, " +")
495	if !ok {
496		return bad()
497	}
498	var h domain.Hunk
499	var err1, err2 error
500	h.OldStart, h.OldLines, err1 = parseRange(oldR)
501	h.NewStart, h.NewLines, err2 = parseRange(newR)
502	if err1 != nil || err2 != nil {
503		return bad()
504	}
505	h.Header = strings.ToValidUTF8(strings.TrimPrefix(header, " "), "\uFFFD")
506	return h, nil
507}
508
509// parseRange parses "start" or "start,count"; a missing count is 1.
510func parseRange(s string) (start, count int, err error) {
511	a, b, hasCount := strings.Cut(s, ",")
512	if start, err = strconv.Atoi(a); err != nil {
513		return 0, 0, err
514	}
515	count = 1
516	if hasCount {
517		if count, err = strconv.Atoi(b); err != nil {
518			return 0, 0, err
519		}
520	}
521	return start, count, nil
522}

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 43

↑↓ moveenter openesc close