cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.81.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

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