cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.55.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

298lines · 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 Commits []string
66 Path PathDiff
67 Body BodyDiff
68}
69
70func Changes(cmd CommandRunner, cr CommitRangeResults, filter PathFilter) ([]*FileChange, error) {
71 out, err := cmd("log", "--reverse", "--no-merges", "--first-parent", "--format=%H", "--name-status", cr.String())
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 // ignore directories
106 // FIXME move all files instead?
107 if isDir, _ := isDirectoryPath(dstPath); isDir {
108 slog.Debug("Skipping directory entry change", slog.String("path", dstPath))
109 continue
110 }
111
112 change := getChangeByPath(changes, dstPath)
113 if change == nil {
114 beforeType := getTypeForPath(cmd, commit+"^", srcPath)
115 change = &FileChange{
116 Path: PathDiff{
117 Before: Path{
118 Name: srcPath,
119 Type: beforeType,
120 SymlinkTarget: resolveSymlinkTarget(cmd, commit+"^", srcPath, beforeType),
121 },
122 After: Path{
123 Name: dstPath,
124 },
125 },
126 }
127 switch status {
128 case FileAdded:
129 // newly added file, there's no "BEFORE" version
130 case FileCopied:
131 // file copied from other location, there's no "BEFORE" version
132 case FileDeleted:
133 // delete file, there's no "AFTER" version
134 change.Body.Before = getContentAtCommit(cmd, commit+"^", change.Path.Before.SymlinkTarget)
135 case FileModified:
136 // modified file, there's both "BEFORE" and "AFTER"
137 change.Body.Before = getContentAtCommit(cmd, commit+"^", change.Path.Before.SymlinkTarget)
138 case FileRenamed:
139 // rename could be only partial so there's both "BEFORE" and "AFTER"
140 change.Body.Before = getContentAtCommit(cmd, commit+"^", change.Path.Before.SymlinkTarget)
141 case FileTypeChanged:
142 // type change, could be file -> dir or symlink -> file
143 // so there's both "BEFORE" and "AFTER"
144 change.Body.Before = getContentAtCommit(cmd, commit+"^", change.Path.Before.SymlinkTarget)
145 default:
146 slog.Debug("Unknown git change", slog.String("path", dstPath), slog.String("commit", commit), slog.String("change", parts[0]))
147 }
148 changes = append(changes, change)
149 }
150 change.Commits = append(change.Commits, commit)
151 }
152 slog.Debug("Parsed git log", slog.Int("changes", len(changes)))
153
154 for _, change := range changes {
155 lastCommit := change.Commits[len(change.Commits)-1]
156
157 change.Path.After.Type = getTypeForPath(cmd, lastCommit, change.Path.After.Name)
158 change.Path.After.SymlinkTarget = resolveSymlinkTarget(cmd, lastCommit, change.Path.After.Name, change.Path.After.Type)
159 change.Body.After = getContentAtCommit(cmd, lastCommit, change.Path.After.EffectivePath())
160
161 switch {
162 case change.Path.Before.Type != Missing && change.Path.After.Type == Symlink:
163 // file was turned into a symlink, every source line is modification
164 change.Body.ModifiedLines = CountLines(change.Body.After)
165 case change.Path.Before.Type != Missing && change.Path.After.Type != Missing && change.Path.After.Type != Symlink:
166 change.Body.ModifiedLines, err = getModifiedLines(cmd, change.Commits, change.Path.After.EffectivePath(), lastCommit)
167 if err != nil {
168 return nil, fmt.Errorf("failed to run git blame for %s: %w", change.Path.After.EffectivePath(), err)
169 }
170 case change.Path.Before.Type == Symlink && change.Path.After.Type == Symlink:
171 // symlink was modified, every source line is modification
172 change.Body.ModifiedLines = CountLines(change.Body.After)
173 case change.Path.Before.Type == Missing && change.Path.After.Type != Missing:
174 // old file body is empty, meaning that every line was modified
175 change.Body.ModifiedLines = CountLines(change.Body.After)
176 case change.Path.Before.Type != Missing && change.Path.After.Type == Missing:
177 // new file body is empty, meaning that every line was modified
178 change.Body.ModifiedLines = CountLines(change.Body.Before)
179 default:
180 slog.Debug("Unhandled change", slog.String("change", fmt.Sprintf("+%v", change)))
181 }
182
183 if change.Path.Before.Name == change.Path.Before.SymlinkTarget {
184 change.Path.Before.SymlinkTarget = ""
185 }
186 if change.Path.After.Name == change.Path.After.SymlinkTarget {
187 change.Path.After.SymlinkTarget = ""
188 }
189 }
190
191 return changes, nil
192}
193
194func getChangeByPath(changes []*FileChange, fpath string) *FileChange {
195 for _, c := range changes {
196 if c.Path.After.Name == fpath {
197 return c
198 }
199 }
200 return nil
201}
202
203func getModifiedLines(cmd CommandRunner, commits []string, fpath, atCommit string) ([]int, error) {
204 slog.Debug("Getting list of modified lines", slog.String("commits", fmt.Sprint(commits)), slog.String("path", fpath))
205 lines, err := Blame(cmd, fpath, atCommit)
206 if err != nil {
207 return nil, err
208 }
209
210 modLines := make([]int, 0, len(lines))
211 for _, line := range lines {
212 if !slices.Contains(commits, line.Commit) {
213 continue
214 }
215 modLines = append(modLines, line.Line)
216 }
217 return modLines, nil
218}
219
220func getTypeForPath(cmd CommandRunner, commit, fpath string) PathType {
221 args := []string{"ls-tree", "--format=%(objectmode) %(objecttype) %(path)", commit, fpath}
222 out, err := cmd(args...)
223 if err != nil {
224 slog.Debug("git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
225 return Missing
226 }
227
228 s := bufio.NewScanner(bytes.NewReader(out))
229 for s.Scan() {
230 parts := strings.SplitN(s.Text(), " ", 3)
231 if len(parts) != 3 {
232 continue
233 }
234 objmode := parts[0]
235 objtype := parts[1]
236 objpath := parts[2]
237
238 // not our file
239 if objpath != fpath {
240 continue
241 }
242 if objtype == "tree" {
243 return Dir
244 }
245 // not a blob - could be a tree or a tag
246 if objtype != "blob" {
247 continue
248 }
249
250 if objmode == "120000" {
251 return Symlink
252 }
253
254 return File
255 }
256
257 return Missing
258}
259
260// recursively find the final target of a symlink.
261func resolveSymlinkTarget(cmd CommandRunner, commit, fpath string, typ PathType) string {
262 if typ != Symlink {
263 return fpath
264 }
265 raw := string(getContentAtCommit(cmd, commit, fpath))
266 spath := path.Clean(path.Join(path.Dir(fpath), raw))
267 stype := getTypeForPath(cmd, commit, spath)
268 return resolveSymlinkTarget(cmd, commit, spath, stype)
269}
270
271func getContentAtCommit(cmd CommandRunner, commit, fpath string) []byte {
272 args := []string{"cat-file", "blob", fmt.Sprintf("%s:%s", commit, fpath)}
273 body, err := cmd(args...)
274 if err != nil {
275 slog.Debug("git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args)))
276 return nil
277 }
278 return body
279}
280
281func CountLines(body []byte) (lines []int) {
282 var line int
283 s := bufio.NewScanner(bytes.NewReader(body))
284 for s.Scan() {
285 line++
286 lines = append(lines, line)
287 }
288 return lines
289}
290
291func isDirectoryPath(path string) (bool, error) {
292 fileInfo, err := os.Stat(path)
293 if err != nil {
294 return false, err
295 }
296
297 return fileInfo.IsDir(), err
298}
299