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}