orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3// The unified diff parser: git hands back the text format, and this turns it into
4// the structs every template downstream works with.
5
6import (
7 "bufio"
8 "bytes"
9 "context"
10 "strconv"
11 "strings"
12)
13
14// maxDiffSize caps what is parsed; past it the page shows counts and a link to
15// the raw patch.
16const maxDiffSize = 2 << 20
17
18// Change is the kind of thing that happened to one file.
19type Change string
20
21const (
22 Added Change = "added"
23 Deleted Change = "deleted"
24 Modified Change = "modified"
25 Renamed Change = "renamed"
26 Copied Change = "copied"
27)
28
29// FileDiff is one file's worth of a commit.
30type FileDiff struct {
31 OldPath string
32 NewPath string
33 Status Change
34 Binary bool
35 Additions int
36 Deletions int
37 Hunks []Hunk
38 // Mode is set only when it changed.
39 OldMode string
40 NewMode string
41}
42
43// Path is the new name, or the old one for a delete.
44func (f FileDiff) Path() string {
45 if f.Status == Deleted {
46 return f.OldPath
47 }
48 return f.NewPath
49}
50
51// Hunk is one @@ block.
52type Hunk struct {
53 Header string
54 OldStart int
55 OldLines int
56 NewStart int
57 NewLines int
58 Lines []DiffLine
59}
60
61// DiffLine carries both line numbers; a zero means the line does not exist on
62// that side.
63type DiffLine struct {
64 Kind string // "context", "add", "del"
65 OldNum int
66 NewNum int
67 Text string
68}
69
70// CommitDiff is the whole patch for one commit.
71type CommitDiff struct {
72 Files []FileDiff
73 Additions int
74 Deletions int
75 // Truncated is set when the patch was larger than maxDiffSize.
76 Truncated bool
77}
78
79// Diff reads and parses one commit's patch. -r recurses, -M detects renames, and
80// --root makes the first commit in a repository diff against the empty tree
81// rather than produce nothing.
82func (s *Store) Diff(ctx context.Context, repo Repo, sha string) (CommitDiff, error) {
83 out, err := run(ctx, repo, "diff-tree", "-p", "-r", "-M", "--root",
84 "--no-color", "--patch-with-raw", "--format=", sha)
85 if err != nil {
86 return CommitDiff{}, err
87 }
88 return parseDiff(out), nil
89}
90
91// DiffRange is Diff between two revisions.
92func (s *Store) DiffRange(ctx context.Context, repo Repo, from, to string) (CommitDiff, error) {
93 out, err := run(ctx, repo, "diff", "-p", "-M", "--no-color", from, to, "--")
94 if err != nil {
95 return CommitDiff{}, err
96 }
97 return parseDiff(out), nil
98}
99
100func parseDiff(patch []byte) CommitDiff {
101 var d CommitDiff
102
103 if len(patch) > maxDiffSize {
104 patch = patch[:maxDiffSize]
105 d.Truncated = true
106 }
107
108 sc := bufio.NewScanner(bytes.NewReader(patch))
109 // A minified bundle can hold a megabyte-long line, and the default 64KB token
110 // limit would end the scan mid-file with no error the caller can see.
111 sc.Buffer(make([]byte, 0, 64<<10), maxDiffSize)
112
113 var cur *FileDiff
114 var hunk *Hunk
115 oldNum, newNum := 0, 0
116
117 flushHunk := func() {
118 if cur != nil && hunk != nil {
119 cur.Hunks = append(cur.Hunks, *hunk)
120 hunk = nil
121 }
122 }
123 flushFile := func() {
124 flushHunk()
125 if cur != nil {
126 d.Files = append(d.Files, *cur)
127 d.Additions += cur.Additions
128 d.Deletions += cur.Deletions
129 cur = nil
130 }
131 }
132
133 for sc.Scan() {
134 line := sc.Text()
135
136 switch {
137 case strings.HasPrefix(line, "diff --git "):
138 flushFile()
139 old, new := parseDiffGit(line)
140 cur = &FileDiff{OldPath: old, NewPath: new, Status: Modified}
141
142 case cur == nil:
143 // The raw section --patch-with-raw emits ahead of the first header.
144 continue
145
146 case strings.HasPrefix(line, "old mode "):
147 cur.OldMode = strings.TrimPrefix(line, "old mode ")
148 case strings.HasPrefix(line, "new mode "):
149 cur.NewMode = strings.TrimPrefix(line, "new mode ")
150
151 case strings.HasPrefix(line, "new file mode "):
152 cur.Status = Added
153 cur.OldPath = ""
154 case strings.HasPrefix(line, "deleted file mode "):
155 cur.Status = Deleted
156 cur.NewPath = ""
157
158 case strings.HasPrefix(line, "rename from "):
159 cur.Status = Renamed
160 cur.OldPath = strings.TrimPrefix(line, "rename from ")
161 case strings.HasPrefix(line, "rename to "):
162 cur.Status = Renamed
163 cur.NewPath = strings.TrimPrefix(line, "rename to ")
164 case strings.HasPrefix(line, "copy from "):
165 cur.Status = Copied
166 cur.OldPath = strings.TrimPrefix(line, "copy from ")
167 case strings.HasPrefix(line, "copy to "):
168 cur.Status = Copied
169 cur.NewPath = strings.TrimPrefix(line, "copy to ")
170
171 case strings.HasPrefix(line, "Binary files "),
172 strings.HasPrefix(line, "GIT binary patch"):
173 // No hunks follow, but the file still belongs in the list.
174 cur.Binary = true
175
176 case strings.HasPrefix(line, "@@"):
177 flushHunk()
178 h := parseHunkHeader(line)
179 hunk = &h
180 oldNum, newNum = h.OldStart, h.NewStart
181
182 case hunk == nil:
183 // index lines, --- and +++, and the mode-only tail.
184 continue
185
186 case strings.HasPrefix(line, "+"):
187 hunk.Lines = append(hunk.Lines, DiffLine{
188 Kind: "add", NewNum: newNum, Text: line[1:],
189 })
190 newNum++
191 cur.Additions++
192
193 case strings.HasPrefix(line, "-"):
194 hunk.Lines = append(hunk.Lines, DiffLine{
195 Kind: "del", OldNum: oldNum, Text: line[1:],
196 })
197 oldNum++
198 cur.Deletions++
199
200 case strings.HasPrefix(line, "\\"):
201 // "\ No newline at end of file" annotates the line above rather
202 // than being one; numbering it shifts everything after it.
203 continue
204
205 case strings.HasPrefix(line, " "), line == "":
206 // An empty context line can arrive as a bare "" rather than a single
207 // space, since some tools strip trailing whitespace from a patch.
208 text := ""
209 if line != "" {
210 text = line[1:]
211 }
212 hunk.Lines = append(hunk.Lines, DiffLine{
213 Kind: "context", OldNum: oldNum, NewNum: newNum, Text: text,
214 })
215 oldNum++
216 newNum++
217 }
218 }
219 flushFile()
220
221 return d
222}
223
224// parseDiffGit pulls the two paths out of "diff --git a/x b/y". The split is
225// ambiguous because a path may contain " b/", so every candidate is tried and the
226// one whose halves match wins; a rename is corrected by its own header lines.
227func parseDiffGit(line string) (old, new string) {
228 rest := strings.TrimPrefix(line, "diff --git ")
229
230 var lastA, lastB string
231 found := false
232 for i := 0; i+3 <= len(rest); i++ {
233 if rest[i:i+3] != " b/" {
234 continue
235 }
236 a := strings.TrimPrefix(rest[:i], "a/")
237 b := strings.TrimPrefix(rest[i+1:], "b/")
238 lastA, lastB, found = a, b, true
239 if a == b {
240 return a, b
241 }
242 }
243 if !found {
244 return "", ""
245 }
246 return lastA, lastB
247}
248
249// parseHunkHeader reads "@@ -12,7 +12,9 @@ optional context".
250func parseHunkHeader(line string) Hunk {
251 h := Hunk{Header: line, OldLines: 1, NewLines: 1}
252
253 body := line
254 if i := strings.Index(line[2:], "@@"); i >= 0 {
255 body = line[2 : 2+i]
256 }
257
258 for _, f := range strings.Fields(body) {
259 switch {
260 case strings.HasPrefix(f, "-"):
261 h.OldStart, h.OldLines = parseRange(f[1:])
262 case strings.HasPrefix(f, "+"):
263 h.NewStart, h.NewLines = parseRange(f[1:])
264 }
265 }
266 return h
267}
268
269// parseRange reads "12,7", or "12" which means a single line.
270func parseRange(s string) (start, count int) {
271 a, b, ok := strings.Cut(s, ",")
272 start, _ = strconv.Atoi(a)
273 if !ok {
274 return start, 1
275 }
276 count, _ = strconv.Atoi(b)
277 return start, count
278}