v0.21.0 ef38ad0

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

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