133728e barerepo 1mo
1
// Package gitread turns git output into template values, and only ever reads.
133728e barerepo 1mo
2
package gitread
133728e barerepo 1mo
4
import (
133728e barerepo 1mo
5
"context"
133728e barerepo 1mo
6
"fmt"
133728e barerepo 1mo
7
"strconv"
133728e barerepo 1mo
8
"strings"
133728e barerepo 1mo
9
"time"
133728e barerepo 1mo
11
"github.com/barerepo/server/internal/gitx"
133728e barerepo 1mo
14
type Commit struct {
133728e barerepo 1mo
15
SHA string
133728e barerepo 1mo
16
Short string
133728e barerepo 1mo
17
Author string
133728e barerepo 1mo
18
When time.Time
133728e barerepo 1mo
19
Subject string
133728e barerepo 1mo
20
Body string
133728e barerepo 1mo
21
Files []FileDiff
133728e barerepo 1mo
22
Add int
133728e barerepo 1mo
23
Del int
133728e barerepo 1mo
24
// Signed is read from the commit object, so a server with no gpg still knows a signature is there.
133728e barerepo 1mo
25
Signed bool
133728e barerepo 1mo
26
// SigState is what git says verification found, and only the commit page asks for it.
133728e barerepo 1mo
27
SigState string
133728e barerepo 1mo
28
Signer string
133728e barerepo 1mo
29
// SigKey is the fingerprint, which is the part of a signature a reader can actually compare.
133728e barerepo 1mo
30
SigKey string
133728e barerepo 1mo
33
type FileDiff struct {
133728e barerepo 1mo
34
Path string
133728e barerepo 1mo
35
OldPath string // set on a rename
133728e barerepo 1mo
36
Add int
133728e barerepo 1mo
37
Del int
133728e barerepo 1mo
38
Binary bool
133728e barerepo 1mo
39
// Gone marks a deletion, so a link to the file at this commit is not offered where it would 404.
133728e barerepo 1mo
40
Gone bool
133728e barerepo 1mo
41
Hunks []Hunk
133728e barerepo 1mo
44
type Hunk struct {
133728e barerepo 1mo
45
Header string
133728e barerepo 1mo
46
Lines []Line
133728e barerepo 1mo
49
// Line.Kind is ' ', '+' or '-', and New is the new-side number chapter 35.3 comments on.
133728e barerepo 1mo
50
type Line struct {
133728e barerepo 1mo
51
Kind byte
133728e barerepo 1mo
52
Text string
133728e barerepo 1mo
53
New int
133728e barerepo 1mo
56
// A commit message holds anything but NUL, so NUL separates records and \x1e separates fields.
133728e barerepo 1mo
57
const logFormat = "%x00%H\x1e%h\x1e%an\x1e%at\x1e%s\x1e%b\x1e"
133728e barerepo 1mo
59
// Log reads n commits and what each touched, since the log page draws no diff. Chapter 24.
133728e barerepo 1mo
60
func Log(ctx context.Context, dir, ref, path string, n int) ([]Commit, error) {
133728e barerepo 1mo
61
if !gitx.ValidRev(ref) {
133728e barerepo 1mo
62
return nil, nil
133728e barerepo 1mo
64
args := append([]string{"log", "--max-count=" + strconv.Itoa(n), "--format=" + logFormat,
133728e barerepo 1mo
65
"--numstat", "--no-renames", ref, "--"}, only(path)...)
133728e barerepo 1mo
66
// The metadata walk alone, because a warm cache answers the rest without a tree diff.
133728e barerepo 1mo
67
meta, err := gitx.Run(ctx, dir, append(append([]string{}, args[:3]...),
133728e barerepo 1mo
68
append([]string{ref, "--"}, only(path)...)...)...)
133728e barerepo 1mo
69
if err != nil {
133728e barerepo 1mo
70
return nil, err
133728e barerepo 1mo
72
commits := parseLog(meta, func(string) []FileDiff { return nil })
133728e barerepo 1mo
73
if path == "" && fromCache(commits) {
133728e barerepo 1mo
74
return commits, nil
133728e barerepo 1mo
77
out, err := gitx.Run(ctx, dir, args...)
133728e barerepo 1mo
78
if err != nil {
133728e barerepo 1mo
79
return nil, err
133728e barerepo 1mo
81
commits = parseLog(out, parseNumstat)
133728e barerepo 1mo
82
markSigned(ctx, dir, commits)
133728e barerepo 1mo
83
if path == "" {
133728e barerepo 1mo
84
toCache(commits)
133728e barerepo 1mo
86
return commits, nil
133728e barerepo 1mo
89
// only is the pathspec, kept in one place because two commands take it.
133728e barerepo 1mo
90
func only(path string) []string {
133728e barerepo 1mo
91
if path == "" {
133728e barerepo 1mo
92
return nil
133728e barerepo 1mo
94
return []string{path}
133728e barerepo 1mo
97
// fromCache fills every commit's stats and signature, or reports that one of them was missing.
133728e barerepo 1mo
98
func fromCache(commits []Commit) bool {
133728e barerepo 1mo
99
if Cache == nil {
133728e barerepo 1mo
100
return false
133728e barerepo 1mo
101
}
133728e barerepo 1mo
102
for i := range commits {
133728e barerepo 1mo
103
body, ok := Cache.Get("stat", commits[i].SHA)
133728e barerepo 1mo
104
if !ok {
133728e barerepo 1mo
105
return false
133728e barerepo 1mo
106
}
133728e barerepo 1mo
107
signed, rest, _ := strings.Cut(string(body), "\n")
133728e barerepo 1mo
108
commits[i].Signed = signed == "1"
133728e barerepo 1mo
109
commits[i].Files = parseNumstat(rest)
133728e barerepo 1mo
110
for _, f := range commits[i].Files {
133728e barerepo 1mo
111
commits[i].Add += f.Add
133728e barerepo 1mo
112
commits[i].Del += f.Del
133728e barerepo 1mo
113
}
133728e barerepo 1mo
114
}
133728e barerepo 1mo
115
return true
133728e barerepo 1mo
118
// toCache keeps what a log row needs, which is a commit's counts and whether it carries a signature.
133728e barerepo 1mo
119
func toCache(commits []Commit) {
133728e barerepo 1mo
120
if Cache == nil {
133728e barerepo 1mo
121
return
133728e barerepo 1mo
122
}
133728e barerepo 1mo
123
for _, c := range commits {
133728e barerepo 1mo
124
var b strings.Builder
133728e barerepo 1mo
125
if c.Signed {
133728e barerepo 1mo
126
b.WriteString("1\n")
133728e barerepo 1mo
127
} else {
133728e barerepo 1mo
128
b.WriteString("0\n")
133728e barerepo 1mo
129
}
133728e barerepo 1mo
130
for _, f := range c.Files {
133728e barerepo 1mo
131
fmt.Fprintf(&b, "%d\t%d\t%s\n", f.Add, f.Del, f.Path)
133728e barerepo 1mo
132
}
133728e barerepo 1mo
133
Cache.Put("stat", c.SHA, []byte(b.String()))
133728e barerepo 1mo
134
}
133728e barerepo 1mo
137
// markSigned reads the commit objects in one process, because a signature is a header and not a check.
133728e barerepo 1mo
138
func markSigned(ctx context.Context, dir string, commits []Commit) {
133728e barerepo 1mo
139
specs := make([]string, 0, len(commits))
133728e barerepo 1mo
140
for _, c := range commits {
133728e barerepo 1mo
141
specs = append(specs, c.SHA)
133728e barerepo 1mo
142
}
133728e barerepo 1mo
143
objs, err := gitx.Batch(ctx, dir, specs)
133728e barerepo 1mo
144
if err != nil {
133728e barerepo 1mo
145
return
133728e barerepo 1mo
146
}
133728e barerepo 1mo
147
for i := range commits {
133728e barerepo 1mo
148
if obj := objs[commits[i].SHA]; obj != nil {
133728e barerepo 1mo
149
commits[i].Signed = hasSignature(obj.Body)
133728e barerepo 1mo
150
}
133728e barerepo 1mo
151
}
133728e barerepo 1mo
154
// hasSignature looks for the header git writes for both gpg and ssh signatures.
133728e barerepo 1mo
155
func hasSignature(body string) bool {
133728e barerepo 1mo
156
for _, line := range strings.Split(body, "\n") {
133728e barerepo 1mo
157
if line == "" {
133728e barerepo 1mo
158
// The headers end at the first blank line, and the message can say anything it likes.
133728e barerepo 1mo
159
return false
133728e barerepo 1mo
160
}
133728e barerepo 1mo
161
if strings.HasPrefix(line, "gpgsig") {
133728e barerepo 1mo
162
return true
133728e barerepo 1mo
163
}
133728e barerepo 1mo
164
}
133728e barerepo 1mo
165
return false
133728e barerepo 1mo
168
// parseNumstat reads the counts git prints per file, which is all a log row says about a commit.
133728e barerepo 1mo
169
func parseNumstat(body string) []FileDiff {
133728e barerepo 1mo
170
var files []FileDiff
133728e barerepo 1mo
171
for _, line := range strings.Split(body, "\n") {
133728e barerepo 1mo
172
parts := strings.SplitN(strings.TrimSpace(line), "\t", 3)
133728e barerepo 1mo
173
if len(parts) != 3 || parts[2] == "" {
133728e barerepo 1mo
174
continue
133728e barerepo 1mo
175
}
133728e barerepo 1mo
176
f := FileDiff{Path: parts[2]}
133728e barerepo 1mo
177
// git writes a dash for each count of a binary file, which has no lines to count.
133728e barerepo 1mo
178
f.Binary = parts[0] == "-" || parts[1] == "-"
133728e barerepo 1mo
179
f.Add, _ = strconv.Atoi(parts[0])
133728e barerepo 1mo
180
f.Del, _ = strconv.Atoi(parts[1])
133728e barerepo 1mo
181
// A file with only removals is one this commit deleted, and it has no page at this ref.
133728e barerepo 1mo
182
f.Gone = !f.Binary && f.Add == 0 && f.Del > 0
133728e barerepo 1mo
183
files = append(files, f)
133728e barerepo 1mo
184
}
133728e barerepo 1mo
185
return files
133728e barerepo 1mo
188
func parseLog(out string, files func(string) []FileDiff) []Commit {
133728e barerepo 1mo
189
var commits []Commit
133728e barerepo 1mo
190
for _, record := range strings.Split(out, "\x00") {
133728e barerepo 1mo
191
if strings.TrimSpace(record) == "" {
133728e barerepo 1mo
192
continue
133728e barerepo 1mo
193
}
133728e barerepo 1mo
194
fields := strings.SplitN(record, "\x1e", 7)
133728e barerepo 1mo
195
if len(fields) < 7 {
133728e barerepo 1mo
196
continue
133728e barerepo 1mo
197
}
133728e barerepo 1mo
198
c := Commit{
133728e barerepo 1mo
199
SHA: fields[0],
133728e barerepo 1mo
200
Short: fields[1],
133728e barerepo 1mo
201
Author: fields[2],
133728e barerepo 1mo
202
Subject: fields[4],
133728e barerepo 1mo
203
Body: strings.TrimSpace(fields[5]),
133728e barerepo 1mo
204
}
133728e barerepo 1mo
205
if secs, err := strconv.ParseInt(fields[3], 10, 64); err == nil {
133728e barerepo 1mo
206
c.When = time.Unix(secs, 0)
133728e barerepo 1mo
207
}
133728e barerepo 1mo
208
c.Files = files(fields[6])
133728e barerepo 1mo
209
for _, f := range c.Files {
133728e barerepo 1mo
210
c.Add += f.Add
133728e barerepo 1mo
211
c.Del += f.Del
133728e barerepo 1mo
212
}
133728e barerepo 1mo
213
commits = append(commits, c)
133728e barerepo 1mo
214
}
133728e barerepo 1mo
215
return commits
133728e barerepo 1mo
218
// parsePatch reads unified diff output into files and hunks.
133728e barerepo 1mo
219
func parsePatch(patch string) []FileDiff {
133728e barerepo 1mo
220
var files []FileDiff
133728e barerepo 1mo
221
var cur *FileDiff
133728e barerepo 1mo
222
var hunk *Hunk
133728e barerepo 1mo
223
newLine := 0