cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.59.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

363lines · 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 // file was turned into a symlink, every source line is modification
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 case change.Path.Before.Type == Symlink && change.Path.After.Type == Symlink:
214 // symlink was modified, every source line is modification
215 change.Body.ModifiedLines = CountLines(change.Body.After)
216 case change.Path.Before.Type == Missing && change.Path.After.Type != Missing:
217 // old file body is empty, meaning that every line was modified
218 change.Body.ModifiedLines = CountLines(change.Body.After)
219 case change.Path.Before.Type != Missing && change.Path.After.Type == Missing:
220 // new file body is empty, meaning that every line was modified
221 change.Body.ModifiedLines = CountLines(change.Body.Before)
222 case change.Path.Before.Type == Missing && change.Path.After.Type == Missing:
223 // file was added and then removed
224 change.Body.ModifiedLines = []int{}
225 default:
226 slog.Warn("Unhandled change", slog.String("change", fmt.Sprintf("+%v", change)))
227 }
228
229 if change.Path.Before.Name == change.Path.Before.SymlinkTarget {
230 change.Path.Before.SymlinkTarget = ""
231 }
232 if change.Path.After.Name == change.Path.After.SymlinkTarget {
233 change.Path.After.SymlinkTarget = ""
234 }
235 }
236
237 return changes, nil
238}
239
240func changesWithout(changes []*FileChange, fpath string) []*FileChange {
241 return slices.DeleteFunc(changes, func(e *FileChange) bool {
242 return e.Path.After.Name == fpath
243 })
244}
245
246func getChangeByPath(changes []*FileChange, fpath string) *FileChange {
247 for _, c := range changes {
248 if c.Path.After.Name == fpath {
249 return c
250 }
251 }
252 return nil
253}
254
255func getModifiedLines(cmd CommandRunner, commits []string, fpath, atCommit string) ([]int, error) {
256 slog.Debug("Getting list of modified lines",
257 slog.Any("commits", commits),
258 slog.String("path", fpath),
259 )
260 lines, err := Blame(cmd, fpath, atCommit)
261 if err != nil {
262 return nil, err
263 }
264
265 modLines := make([]int, 0, len(lines))
266 for _, line := range lines {
267 if !slices.Contains(commits, line.Commit) && line.Line == line.PrevLine {
268 continue
269 }
270 modLines = append(modLines, line.Line)
271 }
272 return modLines, nil
273}
274
275func getTypeForPath(cmd CommandRunner, commit, fpath string) PathType {
276 args := []string{"ls-tree", commit, fpath}
277 out, err := cmd(args...)
278 if err != nil {
279 slog.Debug("git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
280 return Missing
281 }
282
283 s := bufio.NewScanner(bytes.NewReader(out))
284 for s.Scan() {
285 parts := strings.SplitN(s.Text(), " ", 3)
286 if len(parts) != 3 {
287 continue
288 }
289 objmode := parts[0]
290 objtype := parts[1]
291
292 parts = strings.SplitN(parts[2], "\t", 2)
293 if len(parts) != 2 {
294 continue
295 }
296 objpath := parts[1]
297 slog.Debug("ls-tree line",
298 slog.String("mode", objmode),
299 slog.String("type", objtype),
300 slog.String("path", objpath),
301 )
302
303 // not our file
304 if objpath != fpath {
305 continue
306 }
307 if objtype == "tree" {
308 return Dir
309 }
310 // not a blob - could be a tree or a tag
311 if objtype != "blob" {
312 continue
313 }
314
315 if objmode == "120000" {
316 return Symlink
317 }
318
319 return File
320 }
321
322 return Missing
323}
324
325// recursively find the final target of a symlink.
326func resolveSymlinkTarget(cmd CommandRunner, commit, fpath string, typ PathType) string {
327 if typ != Symlink {
328 return fpath
329 }
330 raw := string(getContentAtCommit(cmd, commit, fpath))
331 spath := path.Clean(path.Join(path.Dir(fpath), raw))
332 stype := getTypeForPath(cmd, commit, spath)
333 return resolveSymlinkTarget(cmd, commit, spath, stype)
334}
335
336func getContentAtCommit(cmd CommandRunner, commit, fpath string) []byte {
337 args := []string{"cat-file", "blob", fmt.Sprintf("%s:%s", commit, fpath)}
338 body, err := cmd(args...)
339 if err != nil {
340 slog.Debug("git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
341 return nil
342 }
343 return body
344}
345
346func CountLines(body []byte) (lines []int) {
347 var line int
348 s := bufio.NewScanner(bytes.NewReader(body))
349 for s.Scan() {
350 line++
351 lines = append(lines, line)
352 }
353 return lines
354}
355
356func isDirectoryPath(path string) (bool, error) {
357 fileInfo, err := os.Stat(path)
358 if err != nil {
359 return false, err
360 }
361
362 return fileInfo.IsDir(), err
363}
364