cloudflare/pint

Public

mirrored from https://github.com/cloudflare/pintAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e622a47c6fc350caabb3fe75c25cc3bfbd6feab6

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

internal/git/changes.go

653lines · modecode

1package git
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "fmt"
8 "log/slog"
9 "os"
10 "path"
11 "regexp"
12 "slices"
13 "strconv"
14 "strings"
15)
16
17type FileStatus rune
18
19const (
20 FileAdded FileStatus = 'A'
21 FileCopied FileStatus = 'C'
22 FileDeleted FileStatus = 'D'
23 FileRenamed FileStatus = 'R'
24 FileModified FileStatus = 'M'
25 FileTypeChanged FileStatus = 'T'
26)
27
28type PathType uint8
29
30const (
31 Missing PathType = iota
32 Dir
33 File
34 Symlink
35)
36
37type TypeDiff struct {
38 Before PathType
39 After PathType
40}
41
42type LineNumber struct {
43 Before int
44 After int
45 Modified bool
46}
47
48func (ln LineNumber) String() string {
49 switch {
50 case ln.Before == 0 && ln.After > 0:
51 return fmt.Sprintf("+%d", ln.After)
52 case ln.Before > 0 && ln.After == 0:
53 return fmt.Sprintf("-%d", ln.Before)
54 case ln.Before == ln.After:
55 return strconv.Itoa(ln.Before)
56 default:
57 return fmt.Sprintf("%d->%d", ln.Before, ln.After)
58 }
59}
60
61type LineNumbers []LineNumber
62
63func (lns LineNumbers) String() string {
64 parts := make([]string, len(lns))
65 for i, ln := range lns {
66 parts[i] = ln.String()
67 }
68 return strings.Join(parts, " ")
69}
70
71func (lns LineNumbers) HasAfter(line int) bool {
72 for _, ln := range lns {
73 if ln.After == line && ln.Modified {
74 return true
75 }
76 }
77 return false
78}
79
80func (lns LineNumbers) HasAnyAfter(line int) bool {
81 for _, ln := range lns {
82 if ln.After == line {
83 return true
84 }
85 }
86 return false
87}
88
89func (lns LineNumbers) HasBefore(line int) bool {
90 for _, ln := range lns {
91 if ln.Before == line {
92 return true
93 }
94 }
95 return false
96}
97
98// Nearest finds the closest line to the target within changedLines.
99// It prefers lines after the target over lines before and lines within the range,
100// but can select nearest line outside of the range if needed.
101// Returns (0, false) if there are no usable entries.
102func (lns LineNumbers) Nearest(target, rangeFirst, rangeLast int) (int, bool) {
103 // Prefer modified After lines — these are added/changed lines on the new side of the diff.
104 if line := lns.nearestFrom(target, rangeFirst, rangeLast, func(ln LineNumber) int {
105 if ln.After > 0 && ln.Modified {
106 return ln.After
107 }
108 return 0
109 }); line > 0 {
110 return line, false
111 }
112 // Fall back to Before lines — deleted lines on the old side of the diff.
113 if line := lns.nearestFrom(target, rangeFirst, rangeLast, func(ln LineNumber) int {
114 return ln.Before
115 }); line > 0 {
116 return line, true
117 }
118 return 0, false
119}
120
121// nearestFrom finds the line closest to target among candidates returned by lineFunc.
122// It prefers in-range lines over out-of-range lines, and among each group picks
123// the one with the smallest distance to target.
124func (lns LineNumbers) nearestFrom(target, rangeFirst, rangeLast int, lineFunc func(LineNumber) int) int {
125 var bestInRange, bestOutRange int
126 bestInRangeDist := -1
127 bestOutRangeDist := -1
128
129 for _, ln := range lns {
130 v := lineFunc(ln)
131 if v <= 0 {
132 continue
133 }
134 dist := v - target
135 if dist < 0 {
136 dist = -dist
137 }
138 if v >= rangeFirst && v <= rangeLast {
139 if bestInRangeDist < 0 || dist < bestInRangeDist {
140 bestInRange = v
141 bestInRangeDist = dist
142 }
143 } else if bestOutRangeDist < 0 || dist < bestOutRangeDist {
144 bestOutRange = v
145 bestOutRangeDist = dist
146 }
147 }
148
149 // Prefer in-range match, fall back to closest out-of-range match.
150 if bestInRange > 0 {
151 return bestInRange
152 }
153 return bestOutRange
154}
155
156// BeforeForAfter returns the old (before the change) line number for a given
157// new (after the change) line number.
158//
159// LineNumbers contains explicit mappings for changed, deleted, added,
160// and shifted lines. Unchanged lines that were not shifted are not stored.
161//
162// Returns:
163// - Before value if the line is present in LineNumbers.
164// - Line itself if no mapping exists (unchanged and unshifted).
165func (lns LineNumbers) BeforeForAfter(line int) int {
166 for _, ln := range lns {
167 if ln.After == line {
168 return ln.Before
169 }
170 }
171 return line
172}
173
174type LineRangeSide uint8
175
176const (
177 LinesBefore LineRangeSide = iota
178 LinesAfter
179 LinesBoth
180)
181
182type BodyDiff struct {
183 Before []byte
184 After []byte
185 Lines LineNumbers
186}
187
188type Path struct {
189 Name string
190 SymlinkTarget string
191 Type PathType
192}
193
194func (p Path) EffectivePath() string {
195 if p.SymlinkTarget != "" && p.Name != p.SymlinkTarget {
196 return p.SymlinkTarget
197 }
198 return p.Name
199}
200
201type PathDiff struct {
202 Before Path
203 After Path
204}
205
206type FileChange struct {
207 Path PathDiff
208 Body BodyDiff
209 Commits []string
210 Status FileStatus
211}
212
213func Changes(ctx context.Context, cmd CommandRunner, baseBranch string, filter PathFilter) ([]*FileChange, error) {
214 out, err := cmd(ctx, "log", "--reverse", "--no-merges", "--first-parent", "--format=%H", "--name-status", baseBranch+"..HEAD")
215 if err != nil {
216 return nil, fmt.Errorf("failed to get the list of modified files from git: %w", err)
217 }
218
219 var changes []*FileChange
220 var commit string
221 s := bufio.NewScanner(bytes.NewReader(out))
222 for s.Scan() {
223 line := s.Text()
224
225 parts := strings.Split(line, "\t")
226 // Split always returns at least 1 element slice.
227 if len(parts) == 1 {
228 if parts[0] != "" {
229 commit = parts[0]
230 }
231 continue
232 }
233
234 status := FileStatus(parts[0][0])
235 srcPath := parts[1]
236 dstPath := parts[len(parts)-1]
237 slog.LogAttrs(context.Background(), slog.LevelDebug, "Git file change", slog.String("change", parts[0]), slog.String("path", dstPath), slog.String("commit", commit))
238
239 if !filter.IsPathAllowed(dstPath) {
240 slog.LogAttrs(context.Background(), slog.LevelDebug, "Skipping file due to include/exclude rules", slog.String("path", dstPath))
241 continue
242 }
243
244 // This should never really happen since git doesn't track directories, only files.
245 if isDir, _ := isDirectoryPath(dstPath); isDir {
246 slog.LogAttrs(context.Background(), slog.LevelDebug, "Skipping directory entry change", slog.String("path", dstPath))
247 continue
248 }
249
250 // Rest is populated inside the next loop.
251 change := &FileChange{ // nolint: exhaustruct
252 Status: status,
253 Path: PathDiff{ // nolint: exhaustruct
254 After: Path{ // nolint: exhaustruct
255 Name: dstPath,
256 },
257 },
258 }
259
260 prev := getChangeByPath(changes, srcPath)
261 slog.LogAttrs(
262 context.Background(), slog.LevelDebug, "Looking for previous changes",
263 slog.String("src", srcPath),
264 slog.String("dst", dstPath),
265 slog.String("commit", commit),
266 )
267 if prev != nil {
268 slog.LogAttrs(
269 context.Background(), slog.LevelDebug, "Found a previous change",
270 slog.Any("commits", prev.Commits),
271 slog.String("status", string(prev.Status)),
272 slog.String("path", prev.Path.Before.Name),
273 slog.String("target", prev.Path.Before.SymlinkTarget),
274 slog.Any("type", prev.Path.Before.Type),
275 )
276 change.Commits = append(change.Commits, prev.Commits...)
277 change.Path.Before = prev.Path.Before
278 // Remove any changes for "BEFORE" path we might already have
279 changes = changesWithout(changes, srcPath)
280 } else {
281 slog.LogAttrs(context.Background(), slog.LevelDebug, "No previous change found")
282 switch change.Status {
283 case FileAdded, FileCopied:
284 change.Path.Before.Name = ""
285 change.Path.Before.SymlinkTarget = ""
286 // If a path changed type we'll see A but we can still query for old type.
287 change.Path.Before.Type = getTypeForPath(ctx, cmd, commit+"^", srcPath)
288 if change.Path.Before.Type != Missing {
289 // If it was a type change then
290 change.Path.Before.Name = srcPath
291 change.Path.Before.Type = getTypeForPath(ctx, cmd, commit+"^", srcPath)
292 }
293 case FileDeleted, FileRenamed, FileModified, FileTypeChanged:
294 change.Path.Before.Name = srcPath
295 change.Path.Before.Type = getTypeForPath(ctx, cmd, commit+"^", srcPath)
296 change.Path.Before.SymlinkTarget = resolveSymlinkTarget(ctx, cmd, commit+"^", srcPath, change.Path.Before.Type)
297 }
298 }
299
300 change.Commits = append(change.Commits, commit)
301
302 changes = append(changes, change)
303 }
304
305 slog.LogAttrs(context.Background(), slog.LevelDebug, "Parsed git log", slog.Int("changes", len(changes)))
306
307 for _, change := range changes {
308 slog.LogAttrs(
309 context.Background(), slog.LevelDebug,
310 "File change",
311 slog.Any("commits", change.Commits),
312 slog.String("status", string(change.Status)),
313 slog.String("before", change.Path.Before.Name),
314 slog.String("after", change.Path.After.Name),
315 )
316
317 if change.Path.Before.Name != "" {
318 change.Path.Before.Type = getTypeForPath(ctx, cmd, change.Commits[0]+"^", change.Path.Before.Name)
319 change.Path.Before.SymlinkTarget = resolveSymlinkTarget(ctx, cmd, change.Commits[0]+"^", change.Path.Before.Name, change.Path.Before.Type)
320 change.Body.Before = getContentAtCommit(ctx, cmd, change.Commits[0]+"^", change.Path.Before.EffectivePath())
321 }
322
323 lastCommit := change.Commits[len(change.Commits)-1]
324 if change.Path.After.Name != "" && change.Status != FileDeleted {
325 change.Path.After.Type = getTypeForPath(ctx, cmd, lastCommit, change.Path.After.Name)
326 change.Path.After.SymlinkTarget = resolveSymlinkTarget(ctx, cmd, lastCommit, change.Path.After.Name, change.Path.After.Type)
327 change.Body.After = getContentAtCommit(ctx, cmd, lastCommit, change.Path.After.EffectivePath())
328 }
329
330 slog.LogAttrs(
331 context.Background(), slog.LevelDebug,
332 "Updated file change",
333 slog.Any("commits", change.Commits),
334 slog.String("before.path", change.Path.Before.Name),
335 slog.String("before.target", change.Path.Before.SymlinkTarget),
336 slog.Any("before.type", change.Path.Before.Type),
337 slog.String("before.body", string(change.Body.Before)),
338 slog.String("after.path", change.Path.After.Name),
339 slog.String("after.target", change.Path.After.SymlinkTarget),
340 slog.Any("after.type", change.Path.After.Type),
341 slog.String("after.body", string(change.Body.After)),
342 slog.Any("lines", change.Body.Lines),
343 )
344
345 switch {
346 case change.Path.Before.Type != Missing && change.Path.Before.Type != Symlink && change.Path.After.Type == Symlink:
347 slog.LogAttrs(context.Background(), slog.LevelDebug, "Path was turned into a symlink", slog.String("path", change.Path.After.Name))
348 change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.After), LinesAfter)
349 case change.Path.Before.Type != Missing && change.Path.After.Type != Missing && change.Path.After.Type != Symlink:
350 change.Body.Lines, err = getModifiedLines(ctx, cmd, change.Commits, change.Path.Before.EffectivePath(), change.Path.After.EffectivePath())
351 if err != nil {
352 return nil, fmt.Errorf("failed to run git diff for %s: %w", change.Path.After.EffectivePath(), err)
353 }
354 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was modified", slog.String("path", change.Path.After.Name), slog.Any("lines", change.Body.Lines))
355 case change.Path.Before.Type == Symlink && change.Path.After.Type == Symlink:
356 slog.LogAttrs(context.Background(), slog.LevelDebug, "Symlink was modified", slog.String("path", change.Path.After.Name))
357 // symlink was modified, every source line is modification
358 change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.After), LinesAfter)
359 case change.Path.Before.Type == Missing && change.Path.After.Type != Missing:
360 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was added", slog.String("path", change.Path.After.Name))
361 // old file body is empty, meaning that every line was modified
362 change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.After), LinesAfter)
363 case change.Path.Before.Type != Missing && change.Path.After.Type == Missing:
364 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was removed", slog.String("path", change.Path.After.Name))
365 // new file body is empty, meaning that every line was modified
366 change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.Before), LinesBefore)
367 case change.Path.Before.Type == Missing && change.Path.After.Type == Missing:
368 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was added and removed", slog.String("path", change.Path.After.Name))
369 // file was added and then removed
370 change.Body.Lines = []LineNumber{}
371 }
372
373 if change.Path.Before.Name == change.Path.Before.SymlinkTarget {
374 change.Path.Before.SymlinkTarget = ""
375 }
376 if change.Path.After.Name == change.Path.After.SymlinkTarget {
377 change.Path.After.SymlinkTarget = ""
378 }
379 }
380
381 return changes, nil
382}
383
384func changesWithout(changes []*FileChange, fpath string) []*FileChange {
385 return slices.DeleteFunc(changes, func(e *FileChange) bool {
386 return e.Path.After.Name == fpath
387 })
388}
389
390func getChangeByPath(changes []*FileChange, fpath string) *FileChange {
391 for _, c := range changes {
392 if c.Path.After.Name == fpath {
393 return c
394 }
395 }
396 return nil
397}
398
399func getModifiedLines(ctx context.Context, cmd CommandRunner, commits []string, beforePath, afterPath string) (LineNumbers, error) {
400 slog.LogAttrs(
401 context.Background(), slog.LevelDebug, "Getting list of modified lines",
402 slog.Any("commits", commits),
403 slog.String("beforePath", beforePath),
404 slog.String("afterPath", afterPath),
405 )
406
407 output, err := cmd(ctx, "diff", "-M", commits[0]+"^.."+commits[len(commits)-1], "--", beforePath, afterPath)
408 if err != nil {
409 return nil, fmt.Errorf("git diff for %s: %w", afterPath, err)
410 }
411
412 lineNumbers := parseDiff(output, afterPath)
413
414 slog.LogAttrs(
415 context.Background(), slog.LevelDebug, "List of modified lines",
416 slog.Any("commits", commits),
417 slog.String("beforePath", beforePath),
418 slog.String("afterPath", afterPath),
419 slog.Any("lines", lineNumbers),
420 )
421 return lineNumbers, nil
422}
423
424var diffHunkRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
425
426// parseDiff parses unified diff output from git and returns a list of line
427// number mappings between old and new file versions.
428//
429// A unified diff contains one or more hunks, each starting with a header:
430//
431// @@ -oldStart,oldCount +newStart,newCount @@
432//
433// Lines in the hunk body are prefixed with:
434// - " " (space) -- unchanged context line, present in both old and new file.
435// - "-" -- line removed from the old file.
436// - "+" -- line added in the new file.
437//
438// When a line is modified, git represents it as a deletion followed by an
439// addition. We pair consecutive delete/add sequences to detect modifications:
440// - Paired delete+add -> edited line: {Before: oldLine, After: newLine}.
441// - Unpaired delete -> removed line: {Before: oldLine, After: 0}.
442// - Unpaired add -> added line: {Before: 0, After: newLine}.
443//
444// Context lines are tracked so shifted lines appear in the output.
445//
446// When the diff contains multiple files (e.g. renames), only the block
447// matching targetPath is processed.
448func parseDiff(diff []byte, targetPath string) LineNumbers {
449 var (
450 oldLine, newLine int
451 lineNumbers = LineNumbers{}
452 // Pending deleted lines waiting to be paired with additions.
453 pendingDeletes []int
454 // Only process hunks from the diff block matching targetPath.
455 inTargetBlock bool
456 // True once we've entered a hunk (after @@). Reset on "diff "
457 // which starts a new file. Inside a hunk, all lines are content
458 // -- we must not match diff/+++/--- headers.
459 inHunk bool
460 )
461
462 // flushDeletes emits any pending deleted lines that were not paired
463 // with a subsequent addition. Unpaired deletes are line removals
464 // (Before set, After=0). This is called when we hit a boundary that
465 // ends a contiguous delete/add block: a new hunk, a context line,
466 // or a new diff section.
467 flushDeletes := func() {
468 for _, d := range pendingDeletes {
469 lineNumbers = append(lineNumbers, LineNumber{Before: d, After: 0, Modified: false})
470 }
471 pendingDeletes = pendingDeletes[:0]
472 }
473
474 sc := bufio.NewScanner(bytes.NewReader(diff))
475 for sc.Scan() {
476 line := sc.Text()
477 switch {
478 case strings.HasPrefix(line, "diff "):
479 flushDeletes()
480 inTargetBlock = false
481 inHunk = false
482 case !inHunk && strings.HasPrefix(line, "+++ "):
483 // +++ b/path or +++ /dev/null
484 p := strings.TrimPrefix(line, "+++ b/")
485 inTargetBlock = p == targetPath
486 case !inTargetBlock:
487 continue
488 case strings.HasPrefix(line, "@@"):
489 flushDeletes()
490 pendingDeletes = pendingDeletes[:0]
491 inHunk = true
492 matches := diffHunkRe.FindStringSubmatch(line)
493 if len(matches) >= 4 {
494 oldLine, _ = strconv.Atoi(matches[1])
495 oldLine--
496 newLine, _ = strconv.Atoi(matches[3])
497 newLine--
498 }
499
500 case strings.HasPrefix(line, `\ `):
501 // Diff metadata like "\ No newline at end of file".
502 // Not a real file line — skip without touching counters
503 // or pending state.
504 continue
505
506 case strings.HasPrefix(line, "-"):
507 oldLine++
508 pendingDeletes = append(pendingDeletes, oldLine)
509
510 case strings.HasPrefix(line, "+"):
511 // Added line -- if there's a pending delete, pair them as a
512 // modification (old line replaced by new line). Otherwise it's
513 // a new line (Before=0).
514 newLine++
515 if len(pendingDeletes) > 0 {
516 lineNumbers = append(lineNumbers, LineNumber{
517 Before: pendingDeletes[0],
518 After: newLine,
519 Modified: true,
520 })
521 pendingDeletes = pendingDeletes[1:]
522 } else {
523 lineNumbers = append(lineNumbers, LineNumber{
524 Before: 0,
525 After: newLine,
526 Modified: true,
527 })
528 }
529 default:
530 // Context (unchanged) line -- flush any unpaired deletes and
531 // emit the line mapping only if it is shifted.
532 flushDeletes()
533 oldLine++
534 newLine++
535 if oldLine != newLine {
536 lineNumbers = append(lineNumbers, LineNumber{Before: oldLine, After: newLine, Modified: false})
537 }
538 }
539 }
540 flushDeletes()
541
542 return lineNumbers
543}
544
545func getTypeForPath(ctx context.Context, cmd CommandRunner, commit, fpath string) PathType {
546 args := []string{"ls-tree", commit, fpath}
547 out, err := cmd(ctx, args...)
548 if err != nil {
549 slog.LogAttrs(context.Background(), slog.LevelDebug, "git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
550 return Missing
551 }
552
553 s := bufio.NewScanner(bytes.NewReader(out))
554 for s.Scan() {
555 parts := strings.SplitN(s.Text(), " ", 3)
556 if len(parts) != 3 {
557 continue
558 }
559 objmode := parts[0]
560 objtype := parts[1]
561
562 parts = strings.SplitN(parts[2], "\t", 2)
563 if len(parts) != 2 {
564 continue
565 }
566 objpath := parts[1]
567 slog.LogAttrs(
568 context.Background(), slog.LevelDebug, "ls-tree line",
569 slog.String("mode", objmode),
570 slog.String("type", objtype),
571 slog.String("path", objpath),
572 )
573
574 // not our file
575 if objpath != fpath {
576 continue
577 }
578 if objtype == "tree" {
579 return Dir
580 }
581 // not a blob - could be a tree or a tag
582 if objtype != "blob" {
583 continue
584 }
585
586 if objmode == "120000" {
587 return Symlink
588 }
589
590 return File
591 }
592
593 return Missing
594}
595
596// recursively find the final target of a symlink.
597func resolveSymlinkTarget(ctx context.Context, cmd CommandRunner, commit, fpath string, pathType PathType) string {
598 if pathType != Symlink {
599 return fpath
600 }
601 raw := string(getContentAtCommit(ctx, cmd, commit, fpath))
602 spath := path.Clean(path.Join(path.Dir(fpath), raw))
603 stype := getTypeForPath(ctx, cmd, commit, spath)
604 return resolveSymlinkTarget(ctx, cmd, commit, spath, stype)
605}
606
607func getContentAtCommit(ctx context.Context, cmd CommandRunner, commit, fpath string) []byte {
608 args := []string{"cat-file", "blob", fmt.Sprintf("%s:%s", commit, fpath)}
609 body, err := cmd(ctx, args...)
610 if err != nil {
611 slog.LogAttrs(context.Background(), slog.LevelDebug, "git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
612 return nil
613 }
614 return body
615}
616
617func CountLines(body []byte) int {
618 var count int
619 s := bufio.NewScanner(bytes.NewReader(body))
620 for s.Scan() {
621 count++
622 }
623 return count
624}
625
626func MakeLineRangeFromTo(first, last int, side LineRangeSide) LineNumbers {
627 n := last - first + 1
628 if n <= 0 {
629 return LineNumbers{}
630 }
631 lineNumbers := make(LineNumbers, n)
632 for i := range n {
633 l := first + i
634 switch side {
635 case LinesBefore:
636 lineNumbers[i] = LineNumber{Before: l, After: 0, Modified: false}
637 case LinesAfter:
638 lineNumbers[i] = LineNumber{Before: 0, After: l, Modified: true}
639 case LinesBoth:
640 lineNumbers[i] = LineNumber{Before: l, After: l, Modified: false}
641 }
642 }
643 return lineNumbers
644}
645
646func isDirectoryPath(path string) (bool, error) {
647 fileInfo, err := os.Stat(path)
648 if err != nil {
649 return false, err
650 }
651
652 return fileInfo.IsDir(), err
653}
654