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