| 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/storage" |
| 14 |
|
"github.com/go-git/go-git/v5/utils/merkletrie" |
| 15 |
|
|
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 17 |
|
) |
| 18 |
|
|
| 19 |
|
// MergeRequest asks for a proposal branch to be spliced onto the approved head. |
| 20 |
|
type MergeRequest struct { |
| 21 |
|
// Branch is the proposal branch, "proposals/42". |
| 22 |
|
Branch string |
| 23 |
|
|
| 24 |
|
// Base is the proposal's base revision B: the approved-head sha the agent |
| 25 |
|
// held when it opened the proposal. It does not move as the proposal |
| 26 |
|
// accumulates edits, and it is what staleness is measured against. |
| 27 |
|
// |
| 28 |
|
// It is required rather than derived from a merge base. The caller has it |
| 29 |
|
// (it is the recorded base_rev, the same value the agent keeps sending as |
| 30 |
|
// If-Match), and inferring it would let a rewritten approved branch quietly |
| 31 |
|
// change which revision the merge believes it was proposed against. |
| 32 |
|
Base string |
| 33 |
|
|
| 34 |
|
// Meta is the merge commit's message, trailers and identities. |
| 35 |
|
Meta CommitMeta |
| 36 |
|
} |
| 37 |
|
|
| 38 |
|
// MergedDoc records what the merge did with one document. |
| 39 |
|
type MergedDoc struct { |
| 40 |
|
// DocID is the document's frontmatter id — the key the whole merge turns |
| 41 |
|
// on, since paths move and ids do not. |
| 42 |
|
DocID string |
| 43 |
|
|
| 44 |
|
// Path is where the blob landed in the new approved tree. |
| 45 |
|
Path string |
| 46 |
|
|
| 47 |
|
// ProposalPath is where the proposal held it. It differs from Path exactly |
| 48 |
|
// when the approved branch moved the document between the base and the |
| 49 |
|
// head, which is the case that must not become a conflict. |
| 50 |
|
ProposalPath string |
| 51 |
|
|
| 52 |
|
// Blob is the document's blob sha, taken unchanged from the proposal. |
| 53 |
|
Blob plumbing.Hash |
| 54 |
|
|
| 55 |
|
// New is set when the document did not exist on the approved head. |
| 56 |
|
New bool |
| 57 |
|
} |
| 58 |
|
|
| 59 |
|
// Renamed reports whether the approved branch had moved this document under the |
| 60 |
|
// proposal, so the proposal's blob followed the document to its new path. |
| 61 |
2 |
func (d MergedDoc) Renamed() bool { return !d.New && d.Path != d.ProposalPath } |
| 62 |
|
|
| 63 |
|
// MergeResult describes a completed merge. |
| 64 |
|
type MergeResult struct { |
| 65 |
|
// Commit is the merge commit and the new approved head. |
| 66 |
|
Commit plumbing.Hash |
| 67 |
|
Tree plumbing.Hash |
| 68 |
|
|
| 69 |
|
// ApprovedHead is the head this merged onto — the first parent. |
| 70 |
|
ApprovedHead plumbing.Hash |
| 71 |
|
// ProposalHead is the proposal branch tip — the second parent. |
| 72 |
|
ProposalHead plumbing.Hash |
| 73 |
|
|
| 74 |
|
// Docs is every document the merge carried over, sorted by path. |
| 75 |
|
Docs []MergedDoc |
| 76 |
|
} |
| 77 |
|
|
| 78 |
|
// Merge splices a proposal onto the approved head and moves the approved branch |
| 79 |
|
// to the resulting two-parent merge commit. |
| 80 |
|
// |
| 81 |
|
// The model, exactly as designed: |
| 82 |
|
// |
| 83 |
|
// for d in F: # F is document ids, not paths |
| 84 |
|
// if blob(path(d)@H) != blob(path(d)@B): # changed under us since B |
| 85 |
|
// return 409 stale |
| 86 |
|
// newTree = tree(H) with each d's blob replaced (at its path in H) |
| 87 |
|
// commit newTree with parents [H, P.head] |
| 88 |
|
// |
| 89 |
|
// Two things about it are load-bearing and easy to get wrong: |
| 90 |
|
// |
| 91 |
|
// - Each changed document is resolved to its path *on the approved head* |
| 92 |
|
// through its frontmatter id before its blob is compared. A rename between |
| 93 |
|
// B and H therefore neither raises a conflict nor resurrects the document |
| 94 |
|
// at the path it was moved away from: the proposal's blob is written at the |
| 95 |
|
// head's path, and the old path is never touched. |
| 96 |
|
// - There is no text merge. go-git v5 supports only FastForwardMerge, and the |
| 97 |
|
// whole-document write grain makes three-way merging unnecessary anyway. |
| 98 |
|
// A conflict is always "your base moved, re-propose" (*StaleError), which |
| 99 |
|
// is trivial for an agent and comprehensible for a human. |
| 100 |
|
// |
| 101 |
|
// The build runs under the space write lock and ends in a compare-and-swap on |
| 102 |
|
// the approved ref. Losing that swap means a human push landed underneath, so |
| 103 |
|
// the merge is rebuilt against the new head rather than failing — up to a |
| 104 |
|
// bounded number of attempts, after which it is ErrRefRace and nothing moved. |
| 105 |
22 |
func (r *Repo) Merge(ctx context.Context, req MergeRequest) (MergeResult, error) { |
| 106 |
22 |
ctx, cancel := r.withTimeout(ctx) |
| 107 |
22 |
defer cancel() |
| 108 |
22 |
|
| 109 |
22 |
if !IsProposalBranch(req.Branch) { |
| 110 |
1 |
return MergeResult{}, fmt.Errorf("%w: %q is not a %s* branch", ErrBadRev, req.Branch, ProposalPrefix) |
| 111 |
1 |
} |
| 112 |
21 |
if req.Base == "" { |
| 113 |
1 |
return MergeResult{}, fmt.Errorf("gitx: merge of %q needs the proposal's base revision", req.Branch) |
| 114 |
1 |
} |
| 115 |
20 |
if err := req.Meta.validate(); err != nil { |
| 116 |
1 |
return MergeResult{}, err |
| 117 |
1 |
} |
| 118 |
|
|
| 119 |
19 |
unlock, err := r.lock(ctx) |
| 120 |
19 |
if err != nil { |
| 121 |
0 |
return MergeResult{}, err |
| 122 |
0 |
} |
| 123 |
19 |
defer unlock() |
| 124 |
19 |
|
| 125 |
19 |
approvedRef := plumbing.NewBranchReferenceName(r.approved) |
| 126 |
19 |
var lastErr error |
| 127 |
21 |
for attempt := 0; attempt < r.casBudget(); attempt++ { |
| 128 |
21 |
if err := ctx.Err(); err != nil { |
| 129 |
0 |
return MergeResult{}, err |
| 130 |
0 |
} |
| 131 |
21 |
old, err := r.repo.Reference(approvedRef, false) |
| 132 |
21 |
if err != nil { |
| 133 |
0 |
return MergeResult{}, fmt.Errorf("%w: approved branch %q in %s: %v", |
| 134 |
0 |
ErrNotFound, r.approved, r.ref, err) |
| 135 |
0 |
} |
| 136 |
21 |
res, err := r.buildMerge(ctx, req, old.Hash()) |
| 137 |
21 |
if err != nil { |
| 138 |
13 |
return MergeResult{}, err |
| 139 |
13 |
} |
| 140 |
8 |
r.raceHook() |
| 141 |
8 |
err = r.repo.Storer.CheckAndSetReference(plumbing.NewHashReference(approvedRef, res.Commit), old) |
| 142 |
8 |
if err == nil { |
| 143 |
5 |
return res, nil |
| 144 |
5 |
} |
| 145 |
3 |
if !errors.Is(err, storage.ErrReferenceHasChanged) { |
| 146 |
0 |
return MergeResult{}, fmt.Errorf("gitx: update %s in %s: %w", approvedRef, r.ref, err) |
| 147 |
0 |
} |
| 148 |
3 |
lastErr = err |
| 149 |
|
} |
| 150 |
1 |
return MergeResult{}, fmt.Errorf("%w: %s in %s after %d attempts: %v", |
| 151 |
1 |
ErrRefRace, approvedRef, r.ref, r.casBudget(), lastErr) |
| 152 |
|
} |
| 153 |
|
|
| 154 |
|
// buildMerge does everything except moving the ref: it is called afresh on each |
| 155 |
|
// compare-and-swap attempt, against the head it was handed. |
| 156 |
21 |
func (r *Repo) buildMerge(ctx context.Context, req MergeRequest, head plumbing.Hash) (MergeResult, error) { |
| 157 |
21 |
proposalHead, err := r.BranchHead(ctx, req.Branch) |
| 158 |
21 |
if err != nil { |
| 159 |
1 |
return MergeResult{}, err |
| 160 |
1 |
} |
| 161 |
20 |
base, err := r.ResolveRev(ctx, req.Base) |
| 162 |
20 |
if err != nil { |
| 163 |
0 |
return MergeResult{}, err |
| 164 |
0 |
} |
| 165 |
|
|
| 166 |
|
// The base must still be on the approved branch. If it is not, the approved |
| 167 |
|
// branch was rewritten under the proposal and every comparison below would |
| 168 |
|
// be against a revision that is no longer part of the history. |
| 169 |
20 |
onBranch, err := r.IsAncestor(ctx, base, head) |
| 170 |
20 |
if err != nil { |
| 171 |
0 |
return MergeResult{}, err |
| 172 |
0 |
} |
| 173 |
20 |
if !onBranch { |
| 174 |
1 |
return MergeResult{}, &StaleError{Reason: StaleBaseDetached, Base: base, Head: head} |
| 175 |
1 |
} |
| 176 |
|
|
| 177 |
19 |
baseTree, err := r.treeOf(base) |
| 178 |
19 |
if err != nil { |
| 179 |
0 |
return MergeResult{}, err |
| 180 |
0 |
} |
| 181 |
19 |
headTree, err := r.treeOf(head) |
| 182 |
19 |
if err != nil { |
| 183 |
0 |
return MergeResult{}, err |
| 184 |
0 |
} |
| 185 |
19 |
proposalTree, err := r.treeOf(proposalHead) |
| 186 |
19 |
if err != nil { |
| 187 |
0 |
return MergeResult{}, err |
| 188 |
0 |
} |
| 189 |
|
|
| 190 |
19 |
changed, err := r.changedDocs(ctx, req.Branch, baseTree, proposalTree) |
| 191 |
19 |
if err != nil { |
| 192 |
5 |
return MergeResult{}, err |
| 193 |
5 |
} |
| 194 |
14 |
if len(changed) == 0 { |
| 195 |
1 |
return MergeResult{}, fmt.Errorf("%w: %q changes no document against its base %s", |
| 196 |
1 |
ErrUnsupportedChange, req.Branch, base) |
| 197 |
1 |
} |
| 198 |
|
|
| 199 |
13 |
baseIdx, err := r.buildDocIndex(ctx, baseTree) |
| 200 |
13 |
if err != nil { |
| 201 |
0 |
return MergeResult{}, fmt.Errorf("gitx: index documents at base %s: %w", base, err) |
| 202 |
0 |
} |
| 203 |
13 |
headIdx, err := r.buildDocIndex(ctx, headTree) |
| 204 |
13 |
if err != nil { |
| 205 |
0 |
return MergeResult{}, fmt.Errorf("gitx: index documents at approved head %s: %w", head, err) |
| 206 |
0 |
} |
| 207 |
|
|
| 208 |
13 |
node, err := r.loadTree(headTree, 0) |
| 209 |
13 |
if err != nil { |
| 210 |
0 |
return MergeResult{}, err |
| 211 |
0 |
} |
| 212 |
|
|
| 213 |
13 |
docs := make([]MergedDoc, 0, len(changed)) |
| 214 |
13 |
for _, c := range changed { |
| 215 |
13 |
if baseIdx.duplicated[c.docID] || headIdx.duplicated[c.docID] { |
| 216 |
0 |
return MergeResult{}, fmt.Errorf("%w: %s appears more than once on the approved branch", |
| 217 |
0 |
ErrDuplicateDocID, c.docID) |
| 218 |
0 |
} |
| 219 |
13 |
basePath, inBase := baseIdx.byID[c.docID] |
| 220 |
13 |
headPath, inHead := headIdx.byID[c.docID] |
| 221 |
13 |
|
| 222 |
13 |
switch { |
| 223 |
4 |
case inBase && inHead: |
| 224 |
4 |
// The design's comparison, resolved through the id rather than the |
| 225 |
4 |
// path: a rename between B and H is not a change to the document. |
| 226 |
4 |
baseBlob, err := blobAt(baseTree, basePath) |
| 227 |
4 |
if err != nil { |
| 228 |
0 |
return MergeResult{}, err |
| 229 |
0 |
} |
| 230 |
4 |
headBlob, err := blobAt(headTree, headPath) |
| 231 |
4 |
if err != nil { |
| 232 |
0 |
return MergeResult{}, err |
| 233 |
0 |
} |
| 234 |
4 |
if baseBlob != headBlob { |
| 235 |
1 |
return MergeResult{}, &StaleError{ |
| 236 |
1 |
Reason: StaleDocChanged, DocID: c.docID, Path: headPath, Base: base, Head: head, |
| 237 |
1 |
} |
| 238 |
1 |
} |
| 239 |
1 |
case inBase && !inHead: |
| 240 |
1 |
return MergeResult{}, &StaleError{ |
| 241 |
1 |
Reason: StaleDocRemoved, DocID: c.docID, Path: basePath, Base: base, Head: head, |
| 242 |
1 |
} |
| 243 |
1 |
case !inBase && inHead: |
| 244 |
1 |
return MergeResult{}, &StaleError{ |
| 245 |
1 |
Reason: StaleDocAppeared, DocID: c.docID, Path: headPath, Base: base, Head: head, |
| 246 |
1 |
} |
| 247 |
7 |
default: |
| 248 |
7 |
// A genuinely new document. Its path on the head must be free, or |
| 249 |
7 |
// the splice would overwrite a document the proposal never read. |
| 250 |
7 |
if _, taken := headIdx.byPath[c.path]; taken { |
| 251 |
2 |
return MergeResult{}, &StaleError{ |
| 252 |
2 |
Reason: StalePathTaken, DocID: c.docID, Path: c.path, Base: base, Head: head, |
| 253 |
2 |
} |
| 254 |
2 |
} |
| 255 |
|
} |
| 256 |
|
|
| 257 |
8 |
target := c.path |
| 258 |
8 |
if inHead { |
| 259 |
3 |
target = headPath |
| 260 |
3 |
} |
| 261 |
8 |
if err := node.set(target, c.blob); err != nil { |
| 262 |
0 |
return MergeResult{}, err |
| 263 |
0 |
} |
| 264 |
8 |
docs = append(docs, MergedDoc{ |
| 265 |
8 |
DocID: c.docID, |
| 266 |
8 |
Path: target, |
| 267 |
8 |
ProposalPath: c.path, |
| 268 |
8 |
Blob: c.blob, |
| 269 |
8 |
New: !inHead, |
| 270 |
8 |
}) |
| 271 |
|
} |
| 272 |
8 |
sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path }) |
| 273 |
|
|
| 274 |
8 |
treeHash, err := node.write(r.repo.Storer) |
| 275 |
8 |
if err != nil { |
| 276 |
0 |
return MergeResult{}, err |
| 277 |
0 |
} |
| 278 |
|
// Two parents, approved head first. This is the whole of the "merge": a |
| 279 |
|
// real merge commit, so the proposal stays visible in git log, built with |
| 280 |
|
// explicit ParentHashes rather than any merge strategy. |
| 281 |
8 |
parents := []plumbing.Hash{head, proposalHead} |
| 282 |
8 |
commit, err := r.writeCommit(req.Meta, treeHash, parents) |
| 283 |
8 |
if err != nil { |
| 284 |
0 |
return MergeResult{}, err |
| 285 |
0 |
} |
| 286 |
8 |
return MergeResult{ |
| 287 |
8 |
Commit: commit, |
| 288 |
8 |
Tree: treeHash, |
| 289 |
8 |
ApprovedHead: head, |
| 290 |
8 |
ProposalHead: proposalHead, |
| 291 |
8 |
Docs: docs, |
| 292 |
8 |
}, nil |
| 293 |
|
} |
| 294 |
|
|
| 295 |
|
// changedDoc is one document the proposal touched, as it exists on the proposal |
| 296 |
|
// branch. |
| 297 |
|
type changedDoc struct { |
| 298 |
|
path string |
| 299 |
|
blob plumbing.Hash |
| 300 |
|
docID string |
| 301 |
|
} |
| 302 |
|
|
| 303 |
|
// changedDocs is F: the documents a proposal changed against its base. |
| 304 |
|
// |
| 305 |
|
// Only additions and modifications of markdown documents are expressible. A |
| 306 |
|
// deletion, a rename, or an edit to a non-document path (an attachment, or |
| 307 |
|
// .spec.yml) is refused rather than guessed at — the write plane is a |
| 308 |
|
// whole-document PUT and gives an agent no way to say "delete this" or "move |
| 309 |
|
// this", and those operations are human-push-only by design. |
| 310 |
19 |
func (r *Repo) changedDocs(ctx context.Context, branch string, baseTree, proposalTree *object.Tree) ([]changedDoc, error) { |
| 311 |
19 |
changes, err := object.DiffTreeWithOptions(ctx, baseTree, proposalTree, object.DefaultDiffTreeOptions) |
| 312 |
19 |
if err != nil { |
| 313 |
0 |
return nil, fmt.Errorf("gitx: diff %q against its base in %s: %w", branch, r.ref, err) |
| 314 |
0 |
} |
| 315 |
19 |
out := make([]changedDoc, 0, len(changes)) |
| 316 |
19 |
seenID := make(map[string]string, len(changes)) |
| 317 |
19 |
for _, c := range changes { |
| 318 |
19 |
action, err := c.Action() |
| 319 |
19 |
if err != nil { |
| 320 |
0 |
return nil, fmt.Errorf("gitx: classify change in %q: %w", branch, err) |
| 321 |
0 |
} |
| 322 |
19 |
switch action { |
| 323 |
1 |
case merkletrie.Delete: |
| 324 |
1 |
return nil, fmt.Errorf("%w: %q deletes %q; deletion is human-push-only", |
| 325 |
1 |
ErrUnsupportedChange, branch, c.From.Name) |
| 326 |
7 |
case merkletrie.Modify: |
| 327 |
7 |
if c.From.Name != c.To.Name { |
| 328 |
1 |
return nil, fmt.Errorf("%w: %q renames %q to %q; rename is human-push-only", |
| 329 |
1 |
ErrUnsupportedChange, branch, c.From.Name, c.To.Name) |
| 330 |
1 |
} |
| 331 |
|
} |
| 332 |
|
|
| 333 |
17 |
path := c.To.Name |
| 334 |
17 |
if !strings.HasSuffix(path, core.DocExt) { |
| 335 |
1 |
return nil, fmt.Errorf("%w: %q changes %q, which is not a document; the merge is keyed by document id", |
| 336 |
1 |
ErrUnsupportedChange, branch, path) |
| 337 |
1 |
} |
| 338 |
16 |
if err := core.ValidateDocPath(path); err != nil { |
| 339 |
0 |
return nil, err |
| 340 |
0 |
} |
| 341 |
16 |
switch c.To.TreeEntry.Mode { |
| 342 |
|
case filemode.Regular, filemode.Executable: |
| 343 |
0 |
default: |
| 344 |
0 |
return nil, fmt.Errorf("%w: %q at %q in %q is not a document blob", |
| 345 |
0 |
ErrUnsupportedEntry, c.To.TreeEntry.Mode, path, branch) |
| 346 |
|
} |
| 347 |
|
|
| 348 |
16 |
data, err := r.readBlob(c.To.TreeEntry.Hash, path) |
| 349 |
16 |
if err != nil { |
| 350 |
0 |
return nil, err |
| 351 |
0 |
} |
| 352 |
16 |
fm, _, err := core.ParseDocument(data) |
| 353 |
16 |
if err != nil { |
| 354 |
0 |
return nil, fmt.Errorf("gitx: %q in %q: %w", path, branch, err) |
| 355 |
0 |
} |
| 356 |
16 |
if err := core.ValidateDocID(fm.ID); err != nil { |
| 357 |
1 |
return nil, fmt.Errorf("gitx: %q in %q: %w", path, branch, err) |
| 358 |
1 |
} |
| 359 |
15 |
if prev, dup := seenID[fm.ID]; dup { |
| 360 |
1 |
return nil, fmt.Errorf("%w: %q changes %s at both %q and %q", |
| 361 |
1 |
ErrDuplicateDocID, branch, fm.ID, prev, path) |
| 362 |
1 |
} |
| 363 |
14 |
seenID[fm.ID] = path |
| 364 |
14 |
out = append(out, changedDoc{path: path, blob: c.To.TreeEntry.Hash, docID: fm.ID}) |
| 365 |
|
} |
| 366 |
14 |
sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path }) |
| 367 |
14 |
return out, nil |
| 368 |
|
} |
| 369 |
|
|
| 370 |
|
// docIndex maps a tree's documents both ways. |
| 371 |
|
type docIndex struct { |
| 372 |
|
// byID maps a document id to its path. Documents whose frontmatter is |
| 373 |
|
// missing, unparseable or carries no valid id are absent from it. |
| 374 |
|
byID map[string]string |
| 375 |
|
|
| 376 |
|
// byPath maps every document path to its id, "" when it has none. It is |
| 377 |
|
// what stops a new document from being spliced over a path that is already |
| 378 |
|
// occupied by something this merge cannot see. |
| 379 |
|
byPath map[string]string |
| 380 |
|
|
| 381 |
|
// duplicated names ids that appear more than once in the tree. |
| 382 |
|
duplicated map[string]bool |
| 383 |
|
} |
| 384 |
|
|
| 385 |
|
// buildDocIndex walks a tree and resolves every document's id. |
| 386 |
|
// |
| 387 |
|
// A document whose frontmatter will not parse, or that carries no valid id, is |
| 388 |
|
// recorded by path and left out of the id map rather than failing the walk. The |
| 389 |
|
// registry and the update hook are what keep those out of the approved branch; |
| 390 |
|
// making one malformed document — which --push-option=skip-validation can |
| 391 |
|
// always produce — block every future merge in the space would turn a typo into |
| 392 |
|
// an outage. It is still not silently overwritten: byPath keeps its path |
| 393 |
|
// occupied, so a proposal targeting it is refused as stale. |
| 394 |
|
// |
| 395 |
|
// The same reasoning applies to duplicated ids: they are recorded, and only |
| 396 |
|
// refused when the merge actually needs to resolve one of them. |
| 397 |
26 |
func (r *Repo) buildDocIndex(ctx context.Context, t *object.Tree) (*docIndex, error) { |
| 398 |
26 |
budget := r.newBudget() |
| 399 |
26 |
var entries []docEntry |
| 400 |
26 |
if err := r.collectDocs(ctx, t, "", 0, budget, &entries); err != nil { |
| 401 |
0 |
return nil, err |
| 402 |
0 |
} |
| 403 |
26 |
idx := &docIndex{ |
| 404 |
26 |
byID: make(map[string]string, len(entries)), |
| 405 |
26 |
byPath: make(map[string]string, len(entries)), |
| 406 |
26 |
duplicated: map[string]bool{}, |
| 407 |
26 |
} |
| 408 |
31 |
for _, e := range entries { |
| 409 |
31 |
if err := ctx.Err(); err != nil { |
| 410 |
0 |
return nil, err |
| 411 |
0 |
} |
| 412 |
31 |
data, err := r.readBlob(e.hash, e.path) |
| 413 |
31 |
if err != nil { |
| 414 |
0 |
return nil, err |
| 415 |
0 |
} |
| 416 |
31 |
if err := budget.read(e.path, int64(len(data))); err != nil { |
| 417 |
0 |
return nil, err |
| 418 |
0 |
} |
| 419 |
31 |
idx.byPath[e.path] = "" |
| 420 |
31 |
fm, _, err := core.ParseDocument(data) |
| 421 |
31 |
if err != nil { |
| 422 |
4 |
continue |
| 423 |
|
} |
| 424 |
27 |
if err := core.ValidateDocID(fm.ID); err != nil { |
| 425 |
0 |
continue |
| 426 |
|
} |
| 427 |
27 |
idx.byPath[e.path] = fm.ID |
| 428 |
27 |
if _, dup := idx.byID[fm.ID]; dup { |
| 429 |
0 |
idx.duplicated[fm.ID] = true |
| 430 |
0 |
continue |
| 431 |
|
} |
| 432 |
27 |
idx.byID[fm.ID] = e.path |
| 433 |
|
} |
| 434 |
26 |
return idx, nil |
| 435 |
|
} |
| 436 |
|
|
| 437 |
|
// blobAt returns the blob sha of a document already known to be in the tree. |
| 438 |
8 |
func blobAt(t *object.Tree, path string) (plumbing.Hash, error) { |
| 439 |
8 |
entry, err := t.FindEntry(path) |
| 440 |
8 |
if err != nil { |
| 441 |
0 |
return plumbing.ZeroHash, fmt.Errorf("gitx: %q vanished from tree %s: %w", path, t.Hash, err) |
| 442 |
0 |
} |
| 443 |
8 |
return entry.Hash, nil |
| 444 |
|
} |