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