cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.65.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

378lines · modecode

1package git
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "log/slog"
8 "os"
9 "path"
10 "strings"
11
12 "golang.org/x/exp/slices"
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 int
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.Debug("Git file change", slog.String("change", parts[0]), slog.String("path", dstPath), slog.String("commit", commit))
100
101 if !filter.IsPathAllowed(dstPath) {
102 slog.Debug("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.Debug("Skipping directory entry change", slog.String("path", dstPath))
109 continue
110 }
111
112 // Rest is populated inside the next loop.
113 change := &FileChange{
114 Status: status,
115 Path: PathDiff{
116 After: Path{
117 Name: dstPath,
118 },
119 },
120 }
121
122 prev := getChangeByPath(changes, srcPath)
123 slog.Debug("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.Debug("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.Debug("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.Debug("Parsed git log", slog.Int("changes", len(changes)))
166
167 for _, change := range changes {
168 slog.Debug(
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.Debug(
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.Debug("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)
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.Debug("File was moved or renamed", slog.String("path", change.Path.After.Name))
216 } else {
217 slog.Debug("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.Debug("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.Debug("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.Debug("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.Debug("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.Warn("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) ([]int, error) {
266 slog.Debug("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 modLines := make([]int, 0, len(lines))
276 for _, line := range lines {
277 if !slices.Contains(commits, line.Commit) && line.Line == line.PrevLine {
278 continue
279 }
280 modLines = append(modLines, line.Line)
281 }
282 slog.Debug("List of modified lines",
283 slog.Any("commits", commits),
284 slog.String("path", fpath),
285 slog.Any("lines", modLines),
286 )
287 return modLines, nil
288}
289
290func getTypeForPath(cmd CommandRunner, commit, fpath string) PathType {
291 args := []string{"ls-tree", commit, fpath}
292 out, err := cmd(args...)
293 if err != nil {
294 slog.Debug("git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
295 return Missing
296 }
297
298 s := bufio.NewScanner(bytes.NewReader(out))
299 for s.Scan() {
300 parts := strings.SplitN(s.Text(), " ", 3)
301 if len(parts) != 3 {
302 continue
303 }
304 objmode := parts[0]
305 objtype := parts[1]
306
307 parts = strings.SplitN(parts[2], "\t", 2)
308 if len(parts) != 2 {
309 continue
310 }
311 objpath := parts[1]
312 slog.Debug("ls-tree line",
313 slog.String("mode", objmode),
314 slog.String("type", objtype),
315 slog.String("path", objpath),
316 )
317
318 // not our file
319 if objpath != fpath {
320 continue
321 }
322 if objtype == "tree" {
323 return Dir
324 }
325 // not a blob - could be a tree or a tag
326 if objtype != "blob" {
327 continue
328 }
329
330 if objmode == "120000" {
331 return Symlink
332 }
333
334 return File
335 }
336
337 return Missing
338}
339
340// recursively find the final target of a symlink.
341func resolveSymlinkTarget(cmd CommandRunner, commit, fpath string, typ PathType) string {
342 if typ != Symlink {
343 return fpath
344 }
345 raw := string(getContentAtCommit(cmd, commit, fpath))
346 spath := path.Clean(path.Join(path.Dir(fpath), raw))
347 stype := getTypeForPath(cmd, commit, spath)
348 return resolveSymlinkTarget(cmd, commit, spath, stype)
349}
350
351func getContentAtCommit(cmd CommandRunner, commit, fpath string) []byte {
352 args := []string{"cat-file", "blob", fmt.Sprintf("%s:%s", commit, fpath)}
353 body, err := cmd(args...)
354 if err != nil {
355 slog.Debug("git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
356 return nil
357 }
358 return body
359}
360
361func CountLines(body []byte) (lines []int) {
362 var line int
363 s := bufio.NewScanner(bytes.NewReader(body))
364 for s.Scan() {
365 line++
366 lines = append(lines, line)
367 }
368 return lines
369}
370
371func isDirectoryPath(path string) (bool, error) {
372 fileInfo, err := os.Stat(path)
373 if err != nil {
374 return false, err
375 }
376
377 return fileInfo.IsDir(), err
378}
379