Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · forge/landing/candidate.go

Line attribution on main · 202 line(s)

e5478085
Keenan Nemetz
11d ago
1 package landing
2
3 // The combined result: the commit a non-fast-forward landing would write, published
4 // so CI can build it before anybody merges.
5 //
6 // A landing cannot be tested by testing the request head. Once the base has moved,
7 // the tree that lands is MergeTree(baseTip, head), a commit that exists nowhere until
8 // this file writes it. The commit is published under refs/vole/candidates/ because a
9 // runner fetches a commit by id and an unreferenced commit is not fetchable; the ref
10 // is outside refs/heads and refs/tags, so it cannot be pushed and never appears in a
11 // branch listing.
12 //
13 // The ref name is the identity: it carries the request, the base tip and the head,
14 // so publishing the same landing twice reuses the first commit and a base move needs
15 // no bookkeeping to know the old result is stale.
16
17 import (
18 "fmt"
19 "strings"
20
d37d301f
Keenan Nemetz
17h ago
21 "rodent.software/vole/core/git"
22 "rodent.software/vole/forge/access"
23 "rodent.software/vole/metadata"
e5478085
Keenan Nemetz
11d ago
24 )
25
26 // CandidateRoot is the server-only namespace a combined result is published under.
27 const CandidateRoot = "refs/vole/candidates/"
28
29 // Candidate is one published combined result.
30 type Candidate struct {
31 // Ref is the server-only ref whose tip is SHA.
32 Ref string
33 // SHA is the merge commit: its parents are BaseTip and Head, in that order.
34 SHA string
35 // BaseTip and Head are the two sides that were merged.
36 BaseTip string
37 Head string
38 }
39
40 // CandidateRef is the ref a combined result for this request and these two tips is
41 // published under. A contributor's head keeps its own namespace, so the runner's
42 // cache separation treats the build as untrusted without a second field on the run.
43 func CandidateRef(req *metadata.Request, baseTip, head string) string {
44 if name, ok := access.ContributorRefName("refs/heads/" + req.HeadRef); ok {
45 return fmt.Sprintf("%scontrib/%s/%s/%s-%s", CandidateRoot, name, req.ID, baseTip, head)
46 }
47 return fmt.Sprintf("%s%s/%s-%s", CandidateRoot, req.ID, baseTip, head)
48 }
49
50 // EnsureCandidate publishes the combined result for req, or returns the one already
51 // published for these two tips. conflicts is non-empty when the two sides do not
52 // merge cleanly; nothing is published in that case, because a server-side conflict
53 // resolution would be a commit nobody reviewed.
54 //
55 // It is create-only: two callers racing to publish the same landing both end up
56 // using the commit the first one wrote, and neither has to know the other exists.
57 func EnsureCandidate(repo *git.Repo, req *metadata.Request, baseTip, head string) (*Candidate, []string, error) {
58 if repo == nil {
59 return nil, nil, fmt.Errorf("no repository to publish a combined result in")
60 }
61 if req == nil || req.ID == "" {
62 return nil, nil, fmt.Errorf("no change request to publish a combined result for")
63 }
64 if baseTip == "" || head == "" {
65 return nil, nil, fmt.Errorf("a combined result needs both a base tip and a head")
66 }
67 ref := CandidateRef(req, baseTip, head)
68 existing, err := repo.RevParseRaw(ref)
69 if err != nil {
70 return nil, nil, err
71 }
72 if existing != "" {
73 if ok, err := validCandidate(repo, existing, baseTip, head); err != nil {
74 return nil, nil, err
75 } else if ok {
76 return &Candidate{Ref: ref, SHA: existing, BaseTip: baseTip, Head: head}, nil, nil
77 }
78 }
79
80 tree, conflicts, err := repo.MergeTree(baseTip, head)
81 if err != nil {
82 return nil, nil, err
83 }
84 if len(conflicts) > 0 {
85 return nil, conflicts, nil
86 }
87 msg := fmt.Sprintf("Merge change request %s: %s", req.ID, req.Title)
88 commit, err := repo.CommitMergeTree(tree, []string{baseTip, head}, msg, req.Author)
89 if err != nil {
90 return nil, nil, err
91 }
92
93 // Publish with a compare-and-swap against the value we read. A lost race means
94 // somebody published the same landing first, which is the good outcome; read
95 // their commit rather than overwriting it.
96 ok, err := repo.UpdateRefCAS(ref, commit, existing)
97 if err != nil {
98 return nil, nil, err
99 }
100 if !ok {
101 tip, err := repo.RevParseRaw(ref)
102 if err != nil {
103 return nil, nil, err
104 }
105 if tip == "" {
106 return nil, nil, fmt.Errorf("the combined result %s vanished while it was being published", ref)
107 }
108 valid, err := validCandidate(repo, tip, baseTip, head)
109 if err != nil {
110 return nil, nil, err
111 }
112 if !valid {
113 return nil, nil, fmt.Errorf("the combined result %s changed to a commit that does not merge these two tips", ref)
114 }
115 commit = tip
116 }
117 return &Candidate{Ref: ref, SHA: commit, BaseTip: baseTip, Head: head}, nil, nil
118 }
119
120 // LookupCandidate reads the combined result for req against the current base and
121 // head without writing anything. It is the pre-receive hook's version: a hook may
122 // not publish a ref before the push it is deciding has been accepted.
123 func LookupCandidate(repo *git.Repo, req *metadata.Request) (*Candidate, bool, error) {
124 if repo == nil || req == nil {
125 return nil, false, nil
126 }
127 baseTip, err := repo.RevParse("refs/heads/" + req.Base)
128 if err != nil {
129 return nil, false, err
130 }
131 head, err := repo.RevParse(req.Head)
132 if err != nil {
133 return nil, false, err
134 }
135 if baseTip == "" || head == "" {
136 return nil, false, nil
137 }
138 return LookupCandidateAt(repo, req, baseTip, head)
139 }
140
141 // LookupCandidateAt is LookupCandidate for a caller that already resolved the two
142 // tips.
143 func LookupCandidateAt(repo *git.Repo, req *metadata.Request, baseTip, head string) (*Candidate, bool, error) {
144 ref := CandidateRef(req, baseTip, head)
145 sha, err := repo.RevParseRaw(ref)
146 if err != nil {
147 return nil, false, err
148 }
149 if sha == "" {
150 return nil, false, nil
151 }
152 ok, err := validCandidate(repo, sha, baseTip, head)
153 if err != nil {
154 return nil, false, err
155 }
156 if !ok {
157 return nil, false, nil
158 }
159 return &Candidate{Ref: ref, SHA: sha, BaseTip: baseTip, Head: head}, true, nil
160 }
161
162 // validCandidate reports whether sha is the commit this landing should have: two
163 // parents in base-then-head order, and the tree a clean merge of those parents
164 // produces. The tree check is what makes a stale ref, or a ref somebody rewrote,
165 // unusable instead of silently trusted.
166 func validCandidate(repo *git.Repo, sha, baseTip, head string) (bool, error) {
167 parents, err := repo.CommitParents(sha)
168 if err != nil {
169 return false, err
170 }
171 if len(parents) != 2 || parents[0] != baseTip || parents[1] != head {
172 return false, nil
173 }
174 expected, conflicts, err := repo.MergeTree(baseTip, head)
175 if err != nil {
176 return false, err
177 }
178 if len(conflicts) > 0 {
179 return false, nil
180 }
181 actual, err := repo.TreeOf(sha)
182 if err != nil {
183 return false, err
184 }
185 return actual == expected, nil
186 }
187
188 // CandidateRefs lists the published combined results for one request. The caller
189 // decides which is current by asking CandidateRef for today's two tips.
190 func CandidateRefs(repo *git.Repo, requestID string) (map[string]string, error) {
191 refs, err := repo.ListRefs(CandidateRoot)
192 if err != nil {
193 return nil, err
194 }
195 out := map[string]string{}
196 for ref, sha := range refs {
197 if strings.Contains(ref, "/"+requestID+"/") {
198 out[ref] = sha
199 }
200 }
201 return out, nil
202 }