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}