| 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 |
|
} |