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