cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.74.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

377lines · modecode

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