cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.79.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

387lines · modecode

1package git
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "fmt"
8 "log/slog"
9 "os"
10 "path"
11 "slices"
12 "strings"
13)
14
15type FileStatus rune
16
17const (
18 FileAdded FileStatus = 'A'
19 FileCopied FileStatus = 'C'
20 FileDeleted FileStatus = 'D'
21 FileRenamed FileStatus = 'R'
22 FileModified FileStatus = 'M'
23 FileTypeChanged FileStatus = 'T'
24)
25
26type PathType uint8
27
28const (
29 Missing PathType = iota
30 Dir
31 File
32 Symlink
33)
34
35type TypeDiff struct {
36 Before PathType
37 After PathType
38}
39
40type BodyDiff struct {
41 Before []byte
42 After []byte
43 ModifiedLines []int
44}
45
46type Path struct {
47 Name string
48 SymlinkTarget string
49 Type PathType
50}
51
52func (p Path) EffectivePath() string {
53 if p.SymlinkTarget != "" && p.Name != p.SymlinkTarget {
54 return p.SymlinkTarget
55 }
56 return p.Name
57}
58
59type PathDiff struct {
60 Before Path
61 After Path
62}
63
64type FileChange struct {
65 Path PathDiff
66 Body BodyDiff
67 Commits []string
68 Status FileStatus
69}
70
71func Changes(cmd CommandRunner, baseBranch string, filter PathFilter) ([]*FileChange, error) {
72 out, err := cmd("log", "--reverse", "--no-merges", "--first-parent", "--format=%H", "--name-status", baseBranch+"..HEAD")
73 if err != nil {
74 return nil, fmt.Errorf("failed to get the list of modified files from git: %w", err)
75 }
76
77 var changes []*FileChange
78 var commit string
79 s := bufio.NewScanner(bytes.NewReader(out))
80 for s.Scan() {
81 line := s.Text()
82
83 parts := strings.Split(line, "\t")
84 // Split always returns at least 1 element slice.
85 if len(parts) == 1 {
86 if parts[0] != "" {
87 commit = parts[0]
88 }
89 continue
90 }
91
92 status := FileStatus(parts[0][0])
93 srcPath := parts[1]
94 dstPath := parts[len(parts)-1]
95 slog.LogAttrs(context.Background(), slog.LevelDebug, "Git file change", slog.String("change", parts[0]), slog.String("path", dstPath), slog.String("commit", commit))
96
97 if !filter.IsPathAllowed(dstPath) {
98 slog.LogAttrs(context.Background(), slog.LevelDebug, "Skipping file due to include/exclude rules", slog.String("path", dstPath))
99 continue
100 }
101
102 // This should never really happen since git doesn't track directories, only files.
103 if isDir, _ := isDirectoryPath(dstPath); isDir {
104 slog.LogAttrs(context.Background(), slog.LevelDebug, "Skipping directory entry change", slog.String("path", dstPath))
105 continue
106 }
107
108 // Rest is populated inside the next loop.
109 change := &FileChange{ // nolint: exhaustruct
110 Status: status,
111 Path: PathDiff{ // nolint: exhaustruct
112 After: Path{ // nolint: exhaustruct
113 Name: dstPath,
114 },
115 },
116 }
117
118 prev := getChangeByPath(changes, srcPath)
119 slog.LogAttrs(context.Background(), slog.LevelDebug, "Looking for previous changes",
120 slog.String("src", srcPath),
121 slog.String("dst", dstPath),
122 slog.String("commit", commit),
123 )
124 if prev != nil {
125 slog.LogAttrs(context.Background(), slog.LevelDebug, "Found a previous change",
126 slog.Any("commits", prev.Commits),
127 slog.String("status", string(prev.Status)),
128 slog.String("path", prev.Path.Before.Name),
129 slog.String("target", prev.Path.Before.SymlinkTarget),
130 slog.Any("type", prev.Path.Before.Type),
131 )
132 change.Commits = append(change.Commits, prev.Commits...)
133 change.Path.Before = prev.Path.Before
134 // Remove any changes for "BEFORE" path we might already have
135 changes = changesWithout(changes, srcPath)
136 } else {
137 slog.LogAttrs(context.Background(), slog.LevelDebug, "No previous change found")
138 switch change.Status {
139 case FileAdded, FileCopied:
140 change.Path.Before.Name = ""
141 change.Path.Before.SymlinkTarget = ""
142 // If a path changed type we'll see A but we can still query for old type.
143 change.Path.Before.Type = getTypeForPath(cmd, commit+"^", srcPath)
144 if change.Path.Before.Type != Missing {
145 // If it was a type change then
146 change.Path.Before.Name = srcPath
147 change.Path.Before.Type = getTypeForPath(cmd, commit+"^", srcPath)
148 }
149 case FileDeleted, FileRenamed, FileModified, FileTypeChanged:
150 change.Path.Before.Name = srcPath
151 change.Path.Before.Type = getTypeForPath(cmd, commit+"^", srcPath)
152 change.Path.Before.SymlinkTarget = resolveSymlinkTarget(cmd, commit+"^", srcPath, change.Path.Before.Type)
153 }
154 }
155
156 change.Commits = append(change.Commits, commit)
157
158 changes = append(changes, change)
159 }
160
161 slog.LogAttrs(context.Background(), slog.LevelDebug, "Parsed git log", slog.Int("changes", len(changes)))
162
163 for _, change := range changes {
164 slog.LogAttrs(context.Background(), slog.LevelDebug,
165 "File change",
166 slog.Any("commits", change.Commits),
167 slog.String("status", string(change.Status)),
168 slog.String("before", change.Path.Before.Name),
169 slog.String("after", change.Path.After.Name),
170 )
171
172 if change.Path.Before.Name != "" {
173 change.Path.Before.Type = getTypeForPath(cmd, change.Commits[0]+"^", change.Path.Before.Name)
174 change.Path.Before.SymlinkTarget = resolveSymlinkTarget(cmd, change.Commits[0]+"^", change.Path.Before.Name, change.Path.Before.Type)
175 change.Body.Before = getContentAtCommit(cmd, change.Commits[0]+"^", change.Path.Before.EffectivePath())
176 }
177
178 lastCommit := change.Commits[len(change.Commits)-1]
179 if change.Path.After.Name != "" && change.Status != FileDeleted {
180 change.Path.After.Type = getTypeForPath(cmd, lastCommit, change.Path.After.Name)
181 change.Path.After.SymlinkTarget = resolveSymlinkTarget(cmd, lastCommit, change.Path.After.Name, change.Path.After.Type)
182 change.Body.After = getContentAtCommit(cmd, lastCommit, change.Path.After.EffectivePath())
183 }
184
185 slog.LogAttrs(context.Background(), slog.LevelDebug,
186 "Updated file change",
187 slog.Any("commits", change.Commits),
188 slog.String("before.path", change.Path.Before.Name),
189 slog.String("before.target", change.Path.Before.SymlinkTarget),
190 slog.Any("before.type", change.Path.Before.Type),
191 slog.String("before.body", string(change.Body.Before)),
192 slog.String("after.path", change.Path.After.Name),
193 slog.String("after.target", change.Path.After.SymlinkTarget),
194 slog.Any("after.type", change.Path.After.Type),
195 slog.String("after.body", string(change.Body.After)),
196 slog.Any("modifiedLines", change.Body.ModifiedLines),
197 )
198
199 switch {
200 case change.Path.Before.Type != Missing && change.Path.Before.Type != Symlink && change.Path.After.Type == Symlink:
201 slog.LogAttrs(context.Background(), slog.LevelDebug, "Path was turned into a symlink", slog.String("path", change.Path.After.Name))
202 change.Body.ModifiedLines = CountLines(change.Body.After)
203 case change.Path.Before.Type != Missing && change.Path.After.Type != Missing && change.Path.After.Type != Symlink:
204 change.Body.ModifiedLines, err = getModifiedLines(cmd, change.Commits, change.Path.After.EffectivePath(), lastCommit, change.Body.Before, change.Body.After)
205 if err != nil {
206 return nil, fmt.Errorf("failed to run git blame for %s: %w", change.Path.After.EffectivePath(), err)
207 }
208 if len(change.Body.ModifiedLines) == 0 && change.Path.Before.EffectivePath() != change.Path.After.EffectivePath() {
209 // File was moved or renamed. Mark it all as modified.
210 change.Body.ModifiedLines = CountLines(change.Body.After)
211 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was moved or renamed", slog.String("path", change.Path.After.Name))
212 } else {
213 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was modified", slog.String("path", change.Path.After.Name), slog.Any("lines", change.Body.ModifiedLines))
214 }
215 case change.Path.Before.Type == Symlink && change.Path.After.Type == Symlink:
216 slog.LogAttrs(context.Background(), slog.LevelDebug, "Symlink was modified", slog.String("path", change.Path.After.Name))
217 // symlink was modified, every source line is modification
218 change.Body.ModifiedLines = CountLines(change.Body.After)
219 case change.Path.Before.Type == Missing && change.Path.After.Type != Missing:
220 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was added", slog.String("path", change.Path.After.Name))
221 // old file body is empty, meaning that every line was modified
222 change.Body.ModifiedLines = CountLines(change.Body.After)
223 case change.Path.Before.Type != Missing && change.Path.After.Type == Missing:
224 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was removed", slog.String("path", change.Path.After.Name))
225 // new file body is empty, meaning that every line was modified
226 change.Body.ModifiedLines = CountLines(change.Body.Before)
227 case change.Path.Before.Type == Missing && change.Path.After.Type == Missing:
228 slog.LogAttrs(context.Background(), slog.LevelDebug, "File was added and removed", slog.String("path", change.Path.After.Name))
229 // file was added and then removed
230 change.Body.ModifiedLines = []int{}
231 default:
232 slog.LogAttrs(context.Background(), slog.LevelWarn, "Unhandled change", slog.String("change", fmt.Sprintf("+%v", change)))
233 }
234
235 if change.Path.Before.Name == change.Path.Before.SymlinkTarget {
236 change.Path.Before.SymlinkTarget = ""
237 }
238 if change.Path.After.Name == change.Path.After.SymlinkTarget {
239 change.Path.After.SymlinkTarget = ""
240 }
241 }
242
243 return changes, nil
244}
245
246func changesWithout(changes []*FileChange, fpath string) []*FileChange {
247 return slices.DeleteFunc(changes, func(e *FileChange) bool {
248 return e.Path.After.Name == fpath
249 })
250}
251
252func getChangeByPath(changes []*FileChange, fpath string) *FileChange {
253 for _, c := range changes {
254 if c.Path.After.Name == fpath {
255 return c
256 }
257 }
258 return nil
259}
260
261func getModifiedLines(cmd CommandRunner, commits []string, fpath, atCommit string, bodyBefore, bodyAfter []byte) ([]int, error) {
262 slog.LogAttrs(context.Background(), slog.LevelDebug, "Getting list of modified lines",
263 slog.Any("commits", commits),
264 slog.String("path", fpath),
265 )
266 lines, err := Blame(cmd, fpath, atCommit)
267 if err != nil {
268 return nil, err
269 }
270
271 linesBefore := bytes.Split(bodyBefore, []byte("\n"))
272 linesAfter := bytes.Split(bodyAfter, []byte("\n"))
273 slog.LogAttrs(context.Background(), slog.LevelDebug, "Number of lines", slog.Int("before", len(linesBefore)), slog.Int("after", len(linesAfter)))
274
275 modLines := make([]int, 0, len(lines))
276 for _, line := range lines {
277 slog.LogAttrs(context.Background(), slog.LevelDebug, "Checking line", slog.String("commit", line.Commit), slog.Int("prev", line.PrevLine), slog.Int("line", line.Line))
278 if !slices.Contains(commits, line.Commit) {
279 continue
280 }
281
282 if line.PrevLine <= len(linesBefore) && line.Line <= len(linesAfter) {
283 slog.LogAttrs(context.Background(), slog.LevelDebug, "Checking line content", slog.String("before", string(linesBefore[line.PrevLine-1])), slog.String("after", string(linesAfter[line.Line-1])))
284 if bytes.Equal(linesBefore[line.PrevLine-1], linesAfter[line.Line-1]) {
285 continue
286 }
287 }
288
289 modLines = append(modLines, line.Line)
290 }
291 slog.LogAttrs(context.Background(), slog.LevelDebug, "List of modified lines",
292 slog.Any("commits", commits),
293 slog.String("path", fpath),
294 slog.Any("lines", modLines),
295 )
296 return modLines, nil
297}
298
299func getTypeForPath(cmd CommandRunner, commit, fpath string) PathType {
300 args := []string{"ls-tree", commit, fpath}
301 out, err := cmd(args...)
302 if err != nil {
303 slog.LogAttrs(context.Background(), slog.LevelDebug, "git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
304 return Missing
305 }
306
307 s := bufio.NewScanner(bytes.NewReader(out))
308 for s.Scan() {
309 parts := strings.SplitN(s.Text(), " ", 3)
310 if len(parts) != 3 {
311 continue
312 }
313 objmode := parts[0]
314 objtype := parts[1]
315
316 parts = strings.SplitN(parts[2], "\t", 2)
317 if len(parts) != 2 {
318 continue
319 }
320 objpath := parts[1]
321 slog.LogAttrs(context.Background(), slog.LevelDebug, "ls-tree line",
322 slog.String("mode", objmode),
323 slog.String("type", objtype),
324 slog.String("path", objpath),
325 )
326
327 // not our file
328 if objpath != fpath {
329 continue
330 }
331 if objtype == "tree" {
332 return Dir
333 }
334 // not a blob - could be a tree or a tag
335 if objtype != "blob" {
336 continue
337 }
338
339 if objmode == "120000" {
340 return Symlink
341 }
342
343 return File
344 }
345
346 return Missing
347}
348
349// recursively find the final target of a symlink.
350func resolveSymlinkTarget(cmd CommandRunner, commit, fpath string, pathType PathType) string {
351 if pathType != Symlink {
352 return fpath
353 }
354 raw := string(getContentAtCommit(cmd, commit, fpath))
355 spath := path.Clean(path.Join(path.Dir(fpath), raw))
356 stype := getTypeForPath(cmd, commit, spath)
357 return resolveSymlinkTarget(cmd, commit, spath, stype)
358}
359
360func getContentAtCommit(cmd CommandRunner, commit, fpath string) []byte {
361 args := []string{"cat-file", "blob", fmt.Sprintf("%s:%s", commit, fpath)}
362 body, err := cmd(args...)
363 if err != nil {
364 slog.LogAttrs(context.Background(), slog.LevelDebug, "git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
365 return nil
366 }
367 return body
368}
369
370func CountLines(body []byte) (lines []int) {
371 var line int
372 s := bufio.NewScanner(bytes.NewReader(body))
373 for s.Scan() {
374 line++
375 lines = append(lines, line)
376 }
377 return lines
378}
379
380func isDirectoryPath(path string) (bool, error) {
381 fileInfo, err := os.Stat(path)
382 if err != nil {
383 return false, err
384 }
385
386 return fileInfo.IsDir(), err
387}
388