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