coverage~bigbes/sr-ht-spec64cae3afgitx/read.go

Coverage
87.9% 145/165 statements
Δ
+0.0
Blob
024d3e0
1 package gitx
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "sort"
9 "strings"
10
11 "github.com/go-git/go-git/v5/plumbing"
12 "github.com/go-git/go-git/v5/plumbing/filemode"
13 "github.com/go-git/go-git/v5/plumbing/object"
14
15 "sourcecraft.dev/bigbes/sr-ht-spec/core"
16 )
17
18 // Document is one markdown document as it exists at a revision: its path in the
19 // tree, the sha of its blob, and the blob's bytes.
20 //
21 // Blob is the render cache key. It is content-addressed, so a cache entry keyed
22 // by it can never go stale and the cache can be dropped at any moment.
23 type Document struct {
24 Path string
25 Blob plumbing.Hash
26 Data []byte
27 }
28
29 // docEntry is a document located in a tree, before its blob is read.
30 type docEntry struct {
31 path string
32 hash plumbing.Hash
33 }
34
35 // ValidateRev checks a revision string before it reaches go-git's parser.
36 //
37 // What is accepted is a ref name or a full-or-abbreviated hex object id. What
38 // is rejected is revision arithmetic — "main^2", "HEAD~3", "main@{yesterday}" —
39 // because the read contract is a pinned immutable ?rev= or a branch, and every
40 // extra accepted spelling is one more thing the review UI, the index stamp and
41 // the agent's If-Match have to agree about.
42 //
43 // It deliberately does not try to tell an object id from a ref name. The two
44 // grammars overlap ("cafe" is both a plausible abbreviation and a perfectly
45 // legal branch name), so imposing a minimum length on "things that look hex"
46 // would reject real branches. Resolution decides which one it is; this function
47 // only decides whether it could be either.
48 109 func ValidateRev(rev string) error {
49 109 if rev == "" {
50 2 return fmt.Errorf("%w: empty revision", ErrBadRev)
51 2 }
52 107 if len(rev) > maxRevLen {
53 0 return fmt.Errorf("%w: revision is too long (%d > %d)", ErrBadRev, len(rev), maxRevLen)
54 0 }
55 107 if rev == "HEAD" {
56 1 return nil
57 1 }
58 106 return validateRefComponent("revision", rev)
59 }
60
61 // ResolveRev resolves a revision to the commit it names. Anything that is not a
62 // commit in this repository — a tree sha, an unknown branch, a truncated id —
63 // is ErrNotFound; anything that is not a usable revision string at all is
64 // ErrBadRev.
65 96 func (r *Repo) ResolveRev(ctx context.Context, rev string) (plumbing.Hash, error) {
66 96 _, cancel := r.withTimeout(ctx)
67 96 defer cancel()
68 96
69 96 if err := ValidateRev(rev); err != nil {
70 7 return plumbing.ZeroHash, err
71 7 }
72 89 h, err := r.repo.ResolveRevision(plumbing.Revision(rev))
73 89 if err != nil {
74 5 return plumbing.ZeroHash, fmt.Errorf("%w: revision %q in %s: %v", ErrNotFound, rev, r.ref, err)
75 5 }
76 84 if _, err := r.repo.CommitObject(*h); err != nil {
77 0 return plumbing.ZeroHash, fmt.Errorf("%w: revision %q in %s does not name a commit: %v",
78 0 ErrNotFound, rev, r.ref, err)
79 0 }
80 84 return *h, nil
81 }
82
83 // ApprovedHead returns the current tip of the approved branch. This is the
84 // value an agent's If-Match carries and the value a stale merge reports back.
85 23 func (r *Repo) ApprovedHead(ctx context.Context) (plumbing.Hash, error) {
86 23 return r.BranchHead(ctx, r.approved)
87 23 }
88
89 // BranchHead returns the tip of a branch. A branch that does not exist is
90 // ErrNotFound.
91 46 func (r *Repo) BranchHead(ctx context.Context, branch string) (plumbing.Hash, error) {
92 46 _, cancel := r.withTimeout(ctx)
93 46 defer cancel()
94 46
95 46 if err := ValidateBranch(branch); err != nil {
96 0 return plumbing.ZeroHash, err
97 0 }
98 46 ref, err := r.repo.Reference(plumbing.NewBranchReferenceName(branch), true)
99 46 if err != nil {
100 2 return plumbing.ZeroHash, fmt.Errorf("%w: branch %q in %s: %v", ErrNotFound, branch, r.ref, err)
101 2 }
102 44 return ref.Hash(), nil
103 }
104
105 // IsAncestor reports whether a is reachable from b. It is what the service uses
106 // to decide an If-Match is still valid, and what the update hook uses to tell a
107 // fast-forward from a force-update before calling CheckRefUpdate.
108 24 func (r *Repo) IsAncestor(ctx context.Context, a, b plumbing.Hash) (bool, error) {
109 24 _, cancel := r.withTimeout(ctx)
110 24 defer cancel()
111 24
112 24 if a == b {
113 13 return true, nil
114 13 }
115 11 ca, err := r.repo.CommitObject(a)
116 11 if err != nil {
117 1 return false, fmt.Errorf("%w: commit %s in %s: %v", ErrNotFound, a, r.ref, err)
118 1 }
119 10 cb, err := r.repo.CommitObject(b)
120 10 if err != nil {
121 0 return false, fmt.Errorf("%w: commit %s in %s: %v", ErrNotFound, b, r.ref, err)
122 0 }
123 10 return ca.IsAncestor(cb)
124 }
125
126 // ListProposalBranches returns every proposals/* branch with its tip, sorted by
127 // name. Refs are the source of truth for whether a proposal exists, so this is
128 // what the reconciler scans to rebuild rows it lost.
129 2 func (r *Repo) ListProposalBranches(ctx context.Context) ([]Branch, error) {
130 2 _, cancel := r.withTimeout(ctx)
131 2 defer cancel()
132 2
133 2 iter, err := r.repo.Branches()
134 2 if err != nil {
135 0 return nil, fmt.Errorf("gitx: list branches of %s: %w", r.ref, err)
136 0 }
137 2 var out []Branch
138 4 err = iter.ForEach(func(ref *plumbing.Reference) error {
139 4 name := ref.Name().Short()
140 4 if !IsProposalBranch(name) {
141 2 return nil
142 2 }
143 2 out = append(out, Branch{Name: name, Head: ref.Hash()})
144 2 return nil
145 })
146 2 if err != nil {
147 0 return nil, fmt.Errorf("gitx: list branches of %s: %w", r.ref, err)
148 0 }
149 2 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
150 2 return out, nil
151 }
152
153 // Branch is a branch name paired with its tip.
154 type Branch struct {
155 Name string
156 Head plumbing.Hash
157 }
158
159 // treeAt resolves a revision to its commit's tree.
160 33 func (r *Repo) treeAt(ctx context.Context, rev string) (*object.Tree, error) {
161 33 h, err := r.ResolveRev(ctx, rev)
162 33 if err != nil {
163 2 return nil, err
164 2 }
165 31 return r.treeOf(h)
166 }
167
168 // treeOf returns the tree of a commit already resolved to a hash.
169 150 func (r *Repo) treeOf(commit plumbing.Hash) (*object.Tree, error) {
170 150 c, err := r.repo.CommitObject(commit)
171 150 if err != nil {
172 0 return nil, fmt.Errorf("%w: commit %s in %s: %v", ErrNotFound, commit, r.ref, err)
173 0 }
174 150 t, err := c.Tree()
175 150 if err != nil {
176 0 return nil, fmt.Errorf("gitx: tree of %s in %s: %w", commit, r.ref, err)
177 0 }
178 150 return t, nil
179 }
180
181 // walkBudget tracks the per-walk entry and byte caps.
182 type walkBudget struct {
183 entries int
184 maxEntries int
185 bytes int64
186 maxBytes int64
187 }
188
189 99 func (b *walkBudget) entry(path string) error {
190 99 b.entries++
191 99 if b.entries > b.maxEntries {
192 1 return fmt.Errorf("%w: tree has more than %d entries (at %q)", ErrTooLarge, b.maxEntries, path)
193 1 }
194 98 return nil
195 }
196
197 46 func (b *walkBudget) read(path string, n int64) error {
198 46 b.bytes += n
199 46 if b.bytes > b.maxBytes {
200 1 return fmt.Errorf("%w: walk exceeded %d bytes (at %q)", ErrTooLarge, b.maxBytes, path)
201 1 }
202 45 return nil
203 }
204
205 39 func (r *Repo) newBudget() *walkBudget {
206 39 return &walkBudget{maxEntries: r.entryCap(), maxBytes: r.totalLimit()}
207 39 }
208
209 // collectDocs lists every markdown document in a tree, depth-first and in tree
210 // order, without reading any blob.
211 //
212 // Entries that are not documents — attachments, .spec.yml, anything without the
213 // .md extension — are skipped, because they are legitimately not documents. An
214 // entry that occupies a document path but cannot be one is an error, not a
215 // skip: a symlinked or submoduled *.md would otherwise vanish from the index
216 // and the merge with nothing recording that it was ever there.
217 80 func (r *Repo) collectDocs(ctx context.Context, t *object.Tree, prefix string, depth int, b *walkBudget, out *[]docEntry) error {
218 80 if err := ctx.Err(); err != nil {
219 1 return err
220 1 }
221 79 if depth > maxTreeDepth {
222 0 return fmt.Errorf("%w: tree nesting deeper than %d at %q", ErrTooLarge, maxTreeDepth, prefix)
223 0 }
224 99 for _, e := range t.Entries {
225 99 path := e.Name
226 99 if prefix != "" {
227 56 path = prefix + "/" + e.Name
228 56 }
229 99 if err := b.entry(path); err != nil {
230 1 return err
231 1 }
232 98 switch e.Mode {
233 41 case filemode.Dir:
234 41 sub, err := object.GetTree(r.repo.Storer, e.Hash)
235 41 if err != nil {
236 0 return fmt.Errorf("gitx: read tree %s at %q in %s: %w", e.Hash, path, r.ref, err)
237 0 }
238 41 if err := r.collectDocs(ctx, sub, path, depth+1, b, out); err != nil {
239 1 return err
240 1 }
241 56 case filemode.Regular, filemode.Executable:
242 56 if !strings.HasSuffix(e.Name, core.DocExt) {
243 2 continue // an attachment, or .spec.yml
244 }
245 54 if err := core.ValidateDocPath(path); err != nil {
246 0 return fmt.Errorf("gitx: %s carries an unusable document path: %w", r.ref, err)
247 0 }
248 54 *out = append(*out, docEntry{path: path, hash: e.Hash})
249 1 default:
250 1 if strings.HasSuffix(e.Name, core.DocExt) {
251 1 return fmt.Errorf("%w: %q in %s is a %s, not a document blob",
252 1 ErrUnsupportedEntry, path, r.ref, e.Mode)
253 1 }
254 }
255 }
256 76 return nil
257 }
258
259 // readBlob reads a blob, refusing anything over the per-blob cap. The cap is
260 // checked against the object header first so an oversized blob is never
261 // materialized, and again against what was actually read so a lying header
262 // cannot get past it.
263 79 func (r *Repo) readBlob(h plumbing.Hash, path string) ([]byte, error) {
264 79 obj, err := r.repo.Storer.EncodedObject(plumbing.BlobObject, h)
265 79 if err != nil {
266 0 return nil, fmt.Errorf("%w: blob %s at %q in %s: %v", ErrNotFound, h, path, r.ref, err)
267 0 }
268 79 limit := r.blobLimit()
269 79 if obj.Size() > limit {
270 3 return nil, fmt.Errorf("%w: %q in %s is %d bytes (limit %d)",
271 3 ErrTooLarge, path, r.ref, obj.Size(), limit)
272 3 }
273 76 rd, err := obj.Reader()
274 76 if err != nil {
275 0 return nil, fmt.Errorf("gitx: read blob %s at %q in %s: %w", h, path, r.ref, err)
276 0 }
277 76 defer rd.Close()
278 76
279 76 data, err := io.ReadAll(io.LimitReader(rd, limit+1))
280 76 if err != nil {
281 0 return nil, fmt.Errorf("gitx: read blob %s at %q in %s: %w", h, path, r.ref, err)
282 0 }
283 76 if int64(len(data)) > limit {
284 0 return nil, fmt.Errorf("%w: %q in %s exceeds %d bytes", ErrTooLarge, path, r.ref, limit)
285 0 }
286 76 return data, nil
287 }
288
289 // WalkDocuments calls fn for every markdown document at rev, in tree order.
290 // This is the seam that replaces warren's filesystem scan: the caller feeds the
291 // yielded documents to vault.FromPages and nothing downstream of Archive
292 // changes.
293 //
294 // fn's error stops the walk and is returned unwrapped, so a caller can use a
295 // sentinel of its own to stop early.
296 15 func (r *Repo) WalkDocuments(ctx context.Context, rev string, fn func(Document) error) error {
297 15 ctx, cancel := r.withTimeout(ctx)
298 15 defer cancel()
299 15
300 15 t, err := r.treeAt(ctx, rev)
301 15 if err != nil {
302 2 return err
303 2 }
304 13 return r.walkTreeDocs(ctx, t, fn)
305 }
306
307 13 func (r *Repo) walkTreeDocs(ctx context.Context, t *object.Tree, fn func(Document) error) error {
308 13 budget := r.newBudget()
309 13 var entries []docEntry
310 13 if err := r.collectDocs(ctx, t, "", 0, budget, &entries); err != nil {
311 3 return err
312 3 }
313 16 for _, e := range entries {
314 16 if err := ctx.Err(); err != nil {
315 0 return err
316 0 }
317 16 data, err := r.readBlob(e.hash, e.path)
318 16 if err != nil {
319 1 return err
320 1 }
321 15 if err := budget.read(e.path, int64(len(data))); err != nil {
322 1 return err
323 1 }
324 14 if err := fn(Document{Path: e.path, Blob: e.hash, Data: data}); err != nil {
325 0 return err
326 0 }
327 }
328 8 return nil
329 }
330
331 // ListDocuments returns every markdown document at rev. It is WalkDocuments
332 // with the collection done for you; prefer WalkDocuments when the caller can
333 // stream.
334 14 func (r *Repo) ListDocuments(ctx context.Context, rev string) ([]Document, error) {
335 14 var docs []Document
336 14 if err := r.WalkDocuments(ctx, rev, func(d Document) error {
337 14 docs = append(docs, d)
338 14 return nil
339 14 }); err != nil {
340 6 return nil, err
341 6 }
342 8 return docs, nil
343 }
344
345 // ReadDocument reads one markdown document by path at a revision. The path must
346 // be a valid document path; a path that exists but is not a document blob is
347 // ErrUnsupportedEntry rather than a silent miss.
348 18 func (r *Repo) ReadDocument(ctx context.Context, rev, path string) (Document, error) {
349 18 if err := core.ValidateDocPath(path); err != nil {
350 4 return Document{}, err
351 4 }
352 14 data, hash, err := r.ReadBlob(ctx, rev, path)
353 14 if err != nil {
354 2 return Document{}, err
355 2 }
356 12 return Document{Path: path, Blob: hash, Data: data}, nil
357 }
358
359 // ReadBlob reads any blob by path at a revision — a document, .spec.yml, or an
360 // attachment — and returns its bytes and sha. Directories and non-blob entries
361 // are refused rather than reported as missing, because "you asked for a file
362 // and that is a directory" is a different bug from "it is not there".
363 18 func (r *Repo) ReadBlob(ctx context.Context, rev, path string) ([]byte, plumbing.Hash, error) {
364 18 ctx, cancel := r.withTimeout(ctx)
365 18 defer cancel()
366 18
367 18 if err := core.ValidatePath(path); err != nil {
368 0 return nil, plumbing.ZeroHash, err
369 0 }
370 18 t, err := r.treeAt(ctx, rev)
371 18 if err != nil {
372 0 return nil, plumbing.ZeroHash, err
373 0 }
374 18 entry, err := t.FindEntry(path)
375 18 if err != nil {
376 1 if errors.Is(err, object.ErrEntryNotFound) || errors.Is(err, object.ErrDirectoryNotFound) {
377 1 return nil, plumbing.ZeroHash, fmt.Errorf("%w: %q at %q in %s", ErrNotFound, path, rev, r.ref)
378 1 }
379 0 return nil, plumbing.ZeroHash, fmt.Errorf("gitx: find %q at %q in %s: %w", path, rev, r.ref, err)
380 }
381 17 switch entry.Mode {
382 case filemode.Regular, filemode.Executable:
383 1 default:
384 1 return nil, plumbing.ZeroHash, fmt.Errorf("%w: %q at %q in %s is a %s",
385 1 ErrUnsupportedEntry, path, rev, r.ref, entry.Mode)
386 }
387 16 data, err := r.readBlob(entry.Hash, path)
388 16 if err != nil {
389 2 return nil, plumbing.ZeroHash, err
390 2 }
391 14 return data, entry.Hash, nil
392 }