repos
/ orchard main

orchard

mirror

Every 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

6.9 KB · 279 lines · Go Raw History
  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}