cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
ae4e045f94eb733fa451c4c87282fdde668508f7

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/git/changes.go

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