coverage~bigbes/sr-ht-spec3cb1c03dgitx/write.go

Coverage
85.7% 186/217 statements
Δ
Blob
c3aa835
1 package gitx
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "sort"
8 "strings"
9
10 "github.com/go-git/go-git/v5/plumbing"
11 "github.com/go-git/go-git/v5/plumbing/filemode"
12 "github.com/go-git/go-git/v5/plumbing/object"
13 "github.com/go-git/go-git/v5/plumbing/storer"
14 "github.com/go-git/go-git/v5/storage"
15
16 "sourcecraft.dev/bigbes/sr-ht-spec/core"
17 )
18
19 // Trailer is one git trailer line, "Key: Value".
20 //
21 // This package renders trailers; it does not decide which ones exist. Which
22 // keys are required, what an agent identity string looks like and what goes in
23 // X-Agent-Session are authn/'s to own — putting that policy here would give the
24 // git layer an opinion about identity and give the two write surfaces two
25 // places to drift apart. What is enforced here is only that the rendered
26 // message cannot be forged: a value carrying a newline could otherwise
27 // manufacture trailers nobody supplied.
28 type Trailer struct {
29 Key string
30 Value string
31 }
32
33 35 func (t Trailer) validate() error {
34 35 if t.Key == "" {
35 0 return fmt.Errorf("gitx: trailer key is required")
36 0 }
37 503 for i := 0; i < len(t.Key); i++ {
38 503 c := t.Key[i]
39 503 ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'
40 503 if !ok {
41 1 return fmt.Errorf("gitx: trailer key %q contains a disallowed byte %q", t.Key, c)
42 1 }
43 }
44 34 if strings.ContainsAny(t.Value, "\n\r\x00") {
45 1 return fmt.Errorf("gitx: trailer %q value must be a single line", t.Key)
46 1 }
47 33 return nil
48 }
49
50 // CommitMeta is everything a commit records besides its tree and parents. The
51 // caller supplies all of it: provenance is the product here, so nothing about
52 // authorship is defaulted or derived.
53 type CommitMeta struct {
54 // Message is the commit subject and body, without a trailer block.
55 Message string
56
57 // Trailers are appended after a blank line, in order. Provenance lives here
58 // rather than in a Postgres-only audit table so it is visible in plain
59 // git log on any clone and cannot drift from the content it describes.
60 Trailers []Trailer
61
62 // Author is who wrote the change — for an agent commit, the agent. Committer
63 // is who applied it, which is the service acting for the owner.
64 Author Signature
65 Committer Signature
66 }
67
68 165 func (m CommitMeta) validate() error {
69 165 if strings.TrimSpace(m.Message) == "" {
70 2 return fmt.Errorf("gitx: commit message is required")
71 2 }
72 163 if strings.TrimSpace(strings.SplitN(m.Message, "\n", 2)[0]) == "" {
73 1 return fmt.Errorf("gitx: commit message must open with a non-empty subject line")
74 1 }
75 162 if err := m.Author.validate("author"); err != nil {
76 1 return err
77 1 }
78 161 if err := m.Committer.validate("committer"); err != nil {
79 0 return err
80 0 }
81 161 for _, t := range m.Trailers {
82 35 if err := t.validate(); err != nil {
83 2 return err
84 2 }
85 }
86 159 return nil
87 }
88
89 // text renders the full commit message: the body, then a blank line, then the
90 // trailer block, then a trailing newline.
91 111 func (m CommitMeta) text() string {
92 111 var b strings.Builder
93 111 b.WriteString(strings.TrimRight(strings.ReplaceAll(m.Message, "\r\n", "\n"), "\n"))
94 111 if len(m.Trailers) > 0 {
95 10 b.WriteString("\n\n")
96 12 for i, t := range m.Trailers {
97 12 if i > 0 {
98 2 b.WriteByte('\n')
99 2 }
100 12 b.WriteString(t.Key)
101 12 b.WriteString(": ")
102 12 b.WriteString(t.Value)
103 }
104 }
105 111 b.WriteByte('\n')
106 111 return b.String()
107 }
108
109 // Write is a whole-document replacement at a path. There is no patch form: the
110 // write plane takes whole documents because that is how agents work, and it is
111 // what makes the merge model pure plumbing.
112 type Write struct {
113 Path string
114 Content []byte
115 }
116
117 // CommitResult describes a commit this package created.
118 type CommitResult struct {
119 Commit plumbing.Hash
120 Tree plumbing.Hash
121 Parents []plumbing.Hash
122 // Blobs maps each written path to its blob sha — the render cache key for
123 // the content that was just committed.
124 Blobs map[string]plumbing.Hash
125 }
126
127 // mutableTree is a tree being built: subdirectories by name, plus the non-
128 // directory entries at this level. Trees are loaded whole and rewritten whole,
129 // which at this service's volume (tens of documents a day) costs a handful of
130 // tree-object reads and removes every incremental-rewrite bug class.
131 type mutableTree struct {
132 subs map[string]*mutableTree
133 files map[string]object.TreeEntry
134 }
135
136 174 func newMutableTree() *mutableTree {
137 174 return &mutableTree{subs: map[string]*mutableTree{}, files: map[string]object.TreeEntry{}}
138 174 }
139
140 // loadTree reads an existing tree into a mutableTree, recursively.
141 120 func (r *Repo) loadTree(t *object.Tree, depth int) (*mutableTree, error) {
142 120 if depth > maxTreeDepth {
143 0 return nil, fmt.Errorf("%w: tree nesting deeper than %d", ErrTooLarge, maxTreeDepth)
144 0 }
145 120 n := newMutableTree()
146 120 for _, e := range t.Entries {
147 97 if e.Mode == filemode.Dir {
148 46 sub, err := object.GetTree(r.repo.Storer, e.Hash)
149 46 if err != nil {
150 0 return nil, fmt.Errorf("gitx: read tree %s in %s: %w", e.Hash, r.ref, err)
151 0 }
152 46 child, err := r.loadTree(sub, depth+1)
153 46 if err != nil {
154 0 return nil, err
155 0 }
156 46 n.subs[e.Name] = child
157 46 continue
158 }
159 51 n.files[e.Name] = e
160 }
161 120 return n, nil
162 }
163
164 // set places a blob at path, creating intermediate trees. A component that
165 // collides with an existing file, or a path whose final component is an
166 // existing directory, is an error: silently shadowing one would replace a
167 // document with something that is not one.
168 85 func (n *mutableTree) set(path string, hash plumbing.Hash) error {
169 85 comps := strings.Split(path, "/")
170 85 cur := n
171 86 for i, comp := range comps[:len(comps)-1] {
172 86 if _, clash := cur.files[comp]; clash {
173 1 return fmt.Errorf("gitx: cannot write %q: %q is a file", path, strings.Join(comps[:i+1], "/"))
174 1 }
175 85 next, ok := cur.subs[comp]
176 85 if !ok {
177 52 next = newMutableTree()
178 52 cur.subs[comp] = next
179 52 }
180 85 cur = next
181 }
182 84 last := comps[len(comps)-1]
183 84 if _, clash := cur.subs[last]; clash {
184 1 return fmt.Errorf("gitx: cannot write %q: it is a directory", path)
185 1 }
186 83 cur.files[last] = object.TreeEntry{Name: last, Mode: filemode.Regular, Hash: hash}
187 83 return nil
188 }
189
190 // remove deletes the blob at path if present, pruning nothing else. It reports
191 // whether anything was removed.
192 6 func (n *mutableTree) remove(path string) bool {
193 6 comps := strings.Split(path, "/")
194 6 cur := n
195 8 for _, comp := range comps[:len(comps)-1] {
196 8 next, ok := cur.subs[comp]
197 8 if !ok {
198 0 return false
199 0 }
200 8 cur = next
201 }
202 6 last := comps[len(comps)-1]
203 6 if _, ok := cur.files[last]; !ok {
204 1 return false
205 1 }
206 5 delete(cur.files, last)
207 5 return true
208 }
209
210 // empty reports whether the tree would encode to nothing. Git has no
211 // representation for an empty subtree, so those are dropped on write.
212 96 func (n *mutableTree) empty() bool {
213 96 if len(n.files) > 0 {
214 88 return false
215 88 }
216 8 for _, sub := range n.subs {
217 4 if !sub.empty() {
218 3 return false
219 3 }
220 }
221 5 return true
222 }
223
224 // write encodes the tree and every non-empty subtree, returning the root hash.
225 200 func (n *mutableTree) write(store storer.EncodedObjectStorer) (plumbing.Hash, error) {
226 200 entries := make([]object.TreeEntry, 0, len(n.files)+len(n.subs))
227 200 for name, e := range n.files {
228 107 e.Name = name
229 107 entries = append(entries, e)
230 107 }
231 200 for name, sub := range n.subs {
232 92 if sub.empty() {
233 4 continue
234 }
235 88 h, err := sub.write(store)
236 88 if err != nil {
237 0 return plumbing.ZeroHash, err
238 0 }
239 88 entries = append(entries, object.TreeEntry{Name: name, Mode: filemode.Dir, Hash: h})
240 }
241 // Encode refuses unsorted entries, and git compares directory names as if
242 // they carried a trailing slash — TreeEntrySorter is that comparison.
243 200 sort.Sort(object.TreeEntrySorter(entries))
244 200
245 200 t := &object.Tree{Entries: entries}
246 200 obj := store.NewEncodedObject()
247 200 if err := t.Encode(obj); err != nil {
248 0 return plumbing.ZeroHash, fmt.Errorf("gitx: encode tree: %w", err)
249 0 }
250 200 h, err := store.SetEncodedObject(obj)
251 200 if err != nil {
252 0 return plumbing.ZeroHash, fmt.Errorf("gitx: store tree: %w", err)
253 0 }
254 200 return h, nil
255 }
256
257 // writeBlob stores content as a blob, refusing anything over the document cap.
258 77 func (r *Repo) writeBlob(path string, content []byte) (plumbing.Hash, error) {
259 77 if limit := r.blobLimit(); int64(len(content)) > limit {
260 1 return plumbing.ZeroHash, fmt.Errorf("%w: %q is %d bytes (limit %d)",
261 1 ErrTooLarge, path, len(content), limit)
262 1 }
263 76 obj := r.repo.Storer.NewEncodedObject()
264 76 obj.SetType(plumbing.BlobObject)
265 76 obj.SetSize(int64(len(content)))
266 76 w, err := obj.Writer()
267 76 if err != nil {
268 0 return plumbing.ZeroHash, fmt.Errorf("gitx: write blob for %q: %w", path, err)
269 0 }
270 76 if _, err := w.Write(content); err != nil {
271 0 w.Close()
272 0 return plumbing.ZeroHash, fmt.Errorf("gitx: write blob for %q: %w", path, err)
273 0 }
274 76 if err := w.Close(); err != nil {
275 0 return plumbing.ZeroHash, fmt.Errorf("gitx: write blob for %q: %w", path, err)
276 0 }
277 76 h, err := r.repo.Storer.SetEncodedObject(obj)
278 76 if err != nil {
279 0 return plumbing.ZeroHash, fmt.Errorf("gitx: store blob for %q: %w", path, err)
280 0 }
281 76 return h, nil
282 }
283
284 // writeCommit stores a commit object. Parents are written in the order given,
285 // which is load-bearing for a merge: the first parent is the approved head.
286 110 func (r *Repo) writeCommit(meta CommitMeta, tree plumbing.Hash, parents []plumbing.Hash) (plumbing.Hash, error) {
287 110 if err := meta.validate(); err != nil {
288 0 return plumbing.ZeroHash, err
289 0 }
290 110 c := &object.Commit{
291 110 Author: meta.Author.toGit(),
292 110 Committer: meta.Committer.toGit(),
293 110 Message: meta.text(),
294 110 TreeHash: tree,
295 110 ParentHashes: parents,
296 110 }
297 110 obj := r.repo.Storer.NewEncodedObject()
298 110 if err := c.Encode(obj); err != nil {
299 0 return plumbing.ZeroHash, fmt.Errorf("gitx: encode commit: %w", err)
300 0 }
301 110 h, err := r.repo.Storer.SetEncodedObject(obj)
302 110 if err != nil {
303 0 return plumbing.ZeroHash, fmt.Errorf("gitx: store commit: %w", err)
304 0 }
305 110 return h, nil
306 }
307
308 // CreateProposalBranch cuts a new proposal branch at base.
309 //
310 // base is the agent's If-Match value: the space's approved-head sha at the time
311 // it read. Whether that value is still an ancestor of the approved head is the
312 // caller's 409 to raise (Repo.IsAncestor answers it); this function only cuts
313 // the branch, because the same check has to be spelled identically for REST and
314 // MCP and so belongs above the git layer.
315 35 func (r *Repo) CreateProposalBranch(ctx context.Context, branch, base string) (plumbing.Hash, error) {
316 35 ctx, cancel := r.withTimeout(ctx)
317 35 defer cancel()
318 35
319 35 if !IsProposalBranch(branch) {
320 4 return plumbing.ZeroHash, fmt.Errorf("%w: %q is not a %s* branch", ErrBadRev, branch, ProposalPrefix)
321 4 }
322 31 head, err := r.ResolveRev(ctx, base)
323 31 if err != nil {
324 1 return plumbing.ZeroHash, err
325 1 }
326
327 30 unlock, err := r.lock(ctx)
328 30 if err != nil {
329 0 return plumbing.ZeroHash, err
330 0 }
331 30 defer unlock()
332 30
333 30 name := plumbing.NewBranchReferenceName(branch)
334 30 if _, err := r.repo.Reference(name, false); err == nil {
335 1 return plumbing.ZeroHash, fmt.Errorf("%w: branch %q in %s", ErrExists, branch, r.ref)
336 29 } else if !errors.Is(err, plumbing.ErrReferenceNotFound) {
337 0 return plumbing.ZeroHash, fmt.Errorf("gitx: read %s in %s: %w", name, r.ref, err)
338 0 }
339 29 if err := r.repo.Storer.SetReference(plumbing.NewHashReference(name, head)); err != nil {
340 0 return plumbing.ZeroHash, fmt.Errorf("gitx: create %s in %s: %w", name, r.ref, err)
341 0 }
342 29 return head, nil
343 }
344
345 // DeleteProposalBranch removes a proposal branch, under the space write lock so
346 // it excludes the merge path and any other in-process write.
347 //
348 // It refuses anything outside proposals/*. That check is not defence in depth,
349 // it is the only thing standing between a caller bug and a deleted approved
350 // branch — the approved branch is deleted by nobody, ever, which is also what
351 // CheckRefUpdate tells receive-pack.
352 //
353 // An already-absent branch is success rather than ErrNotFound. The caller is
354 // the reconciler, whose repair is a postcondition ("this unreferenced ref does
355 // not exist") and not an action, and the ref can legitimately vanish between
356 // the listing that found it and this call — a native receive-pack push deleting
357 // it, or an earlier pass that raced this one. Reporting that as a failure would
358 // fill the reconcile report with failures for repairs that in fact hold.
359 //
360 // There is no compare-and-swap here and no lost-race retry: unlike a ref move,
361 // a delete does not depend on the value it is replacing, so a concurrent writer
362 // cannot make it do the wrong thing. It can only make it redundant, which is
363 // the case above.
364 8 func (r *Repo) DeleteProposalBranch(ctx context.Context, branch string) error {
365 8 ctx, cancel := r.withTimeout(ctx)
366 8 defer cancel()
367 8
368 8 if !IsProposalBranch(branch) {
369 5 return fmt.Errorf("%w: %q is not a %s* branch; only a proposal branch may be deleted",
370 5 ErrBadRev, branch, ProposalPrefix)
371 5 }
372
373 3 unlock, err := r.lock(ctx)
374 3 if err != nil {
375 0 return err
376 0 }
377 3 defer unlock()
378 3
379 3 name := plumbing.NewBranchReferenceName(branch)
380 3 if err := r.repo.Storer.RemoveReference(name); err != nil {
381 0 return fmt.Errorf("gitx: delete %s in %s: %w", name, r.ref, err)
382 0 }
383 3 return nil
384 }
385
386 // CommitProposal commits whole-document blobs onto a proposal branch.
387 //
388 // It refuses any branch outside proposals/*: the approved branch moves in
389 // exactly two ways — a human push through receive-pack, or Merge — and a third
390 // door into it would be a way to land unreviewed agent output without a merge
391 // commit recording that it happened.
392 //
393 // The branch head is read, spliced and compare-and-swapped under the space
394 // lock, and the whole build is retried if the swap loses to a concurrent
395 // writer.
396 35 func (r *Repo) CommitProposal(ctx context.Context, branch string, writes []Write, meta CommitMeta) (CommitResult, error) {
397 35 ctx, cancel := r.withTimeout(ctx)
398 35 defer cancel()
399 35
400 35 if !IsProposalBranch(branch) {
401 4 return CommitResult{}, fmt.Errorf("%w: %q is not a %s* branch; only Merge writes the approved branch",
402 4 ErrBadRev, branch, ProposalPrefix)
403 4 }
404 31 if len(writes) == 0 {
405 1 return CommitResult{}, fmt.Errorf("gitx: commit to %q has no writes", branch)
406 1 }
407 30 if err := meta.validate(); err != nil {
408 1 return CommitResult{}, err
409 1 }
410 29 seen := make(map[string]bool, len(writes))
411 32 for _, w := range writes {
412 32 if err := core.ValidateDocPath(w.Path); err != nil {
413 5 return CommitResult{}, err
414 5 }
415 27 if seen[w.Path] {
416 1 return CommitResult{}, fmt.Errorf("gitx: commit to %q writes %q twice", branch, w.Path)
417 1 }
418 26 seen[w.Path] = true
419 }
420
421 23 unlock, err := r.lock(ctx)
422 23 if err != nil {
423 0 return CommitResult{}, err
424 0 }
425 23 defer unlock()
426 23
427 23 name := plumbing.NewBranchReferenceName(branch)
428 23 var lastErr error
429 24 for attempt := 0; attempt < r.casBudget(); attempt++ {
430 24 if err := ctx.Err(); err != nil {
431 0 return CommitResult{}, err
432 0 }
433 24 old, err := r.repo.Reference(name, false)
434 24 if err != nil {
435 1 return CommitResult{}, fmt.Errorf("%w: branch %q in %s: %v", ErrNotFound, branch, r.ref, err)
436 1 }
437 23 tree, err := r.treeOf(old.Hash())
438 23 if err != nil {
439 0 return CommitResult{}, err
440 0 }
441 23 node, err := r.loadTree(tree, 0)
442 23 if err != nil {
443 0 return CommitResult{}, err
444 0 }
445 23 blobs := make(map[string]plumbing.Hash, len(writes))
446 25 for _, w := range writes {
447 25 h, err := r.writeBlob(w.Path, w.Content)
448 25 if err != nil {
449 1 return CommitResult{}, err
450 1 }
451 24 if err := node.set(w.Path, h); err != nil {
452 0 return CommitResult{}, err
453 0 }
454 24 blobs[w.Path] = h
455 }
456 22 treeHash, err := node.write(r.repo.Storer)
457 22 if err != nil {
458 0 return CommitResult{}, err
459 0 }
460 22 parents := []plumbing.Hash{old.Hash()}
461 22 commit, err := r.writeCommit(meta, treeHash, parents)
462 22 if err != nil {
463 0 return CommitResult{}, err
464 0 }
465 22 r.raceHook()
466 22 err = r.repo.Storer.CheckAndSetReference(plumbing.NewHashReference(name, commit), old)
467 22 if err == nil {
468 21 return CommitResult{Commit: commit, Tree: treeHash, Parents: parents, Blobs: blobs}, nil
469 21 }
470 1 if !errors.Is(err, storage.ErrReferenceHasChanged) {
471 0 return CommitResult{}, fmt.Errorf("gitx: update %s in %s: %w", name, r.ref, err)
472 0 }
473 1 lastErr = err
474 }
475 0 return CommitResult{}, fmt.Errorf("%w: %s in %s after %d attempts: %v",
476 0 ErrRefRace, name, r.ref, r.casBudget(), lastErr)
477 }