cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.43.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

292lines · modecode

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