cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.76.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

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