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