cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.60.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

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