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 · client/branch.go

Line attribution on main · 329 line(s)

c5902451
Keenan Nemetz
16d ago
1 package client
2
3 import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "math/rand/v2"
9 "os"
10 "os/exec"
11 "strings"
12 "time"
13
d37d301f
Keenan Nemetz
17h ago
14 "rodent.software/vole/core/git"
15 "rodent.software/vole/metadata"
c5902451
Keenan Nemetz
16d ago
16 )
17
18 // Branch operates on the clone in the current directory: one branch, one remote,
19 // one author. Issue commands use it for the metadata branch; site and release
20 // commands use the same plumbing for their ref, because a write over SSH is a
21 // write over SSH whichever ref it moves.
22 type Branch struct {
23 Repo *git.Repo
24 Remote string
25 Author string
26 Ref string
27 }
28
29 // NewBranch opens the metadata branch in the clone the process is running in.
30 func NewBranch(remote, ref string) (*Branch, error) {
31 dir, err := CurrentGitDir()
32 if err != nil {
33 return nil, err
34 }
35 b, err := OpenBranch(dir, remote, ref, "")
36 if err != nil {
37 return nil, err
38 }
39 branch := strings.TrimPrefix(b.Ref, "refs/heads/")
40 if cur, err := b.Repo.CurrentBranch(); err == nil && cur == branch {
41 return nil, fmt.Errorf("the %s branch is checked out here; git will not fetch into it. "+
42 "Switch to another branch (git switch main) and run the command again", branch)
43 }
44 return b, nil
45 }
46
47 // OpenBranch opens a branch in an explicit git directory. That form is what a CI
48 // runner uses: it has a checkout it did not inherit from the process's cwd, and
49 // the branch is never the checked-out one.
50 func OpenBranch(gitDir, remote, ref, author string) (*Branch, error) {
51 if remote == "" {
52 remote = "origin"
53 }
54 if !strings.HasPrefix(ref, "refs/") {
55 ref = "refs/heads/" + ref
56 }
57 if author == "" {
58 author = DefaultAuthor(gitDir)
59 }
60 b := &Branch{Repo: git.Open(gitDir), Remote: remote, Author: author, Ref: ref}
61 _ = b.Fetch() // may legitimately fail on the first run: no branch yet
62 return b, nil
63 }
64
65 // Fetch resets the local branch to the remote's. It is called before a write and
66 // again after a lost push race, where discarding our local commit and re-applying
67 // the change on the new tip is cheaper than merging: every entity is its own
68 // file, so re-applying cannot conflict.
69 func (b *Branch) Fetch() error {
70 if !IsRemoteURL(b.Remote) && !HasRemote(b.Repo.Dir, b.Remote) {
71 return nil // nothing named to fetch from; the commit stays local
72 }
73 cmd := exec.Command("git", "fetch", "--quiet", b.Remote, "+"+b.Ref+":"+b.Ref)
74 cmd.Dir = b.Repo.Dir
75 var stderr bytes.Buffer
76 cmd.Stderr = &stderr
77 if err := cmd.Run(); err != nil {
78 return fmt.Errorf("fetch %s: %w: %s", b.Remote, err, strings.TrimSpace(stderr.String()))
79 }
80 return nil
81 }
82
83 // CommitAndPush pushes, and on a lost race re-applies the change on the new tip
84 // and tries again. Two authors editing different entities is normal and must not
85 // surface as an error the user has to resolve by hand; re-applying cannot conflict
86 // because every entity is its own file.
87 func (b *Branch) CommitAndPush(noPush bool, reapply func() error) error {
88 if noPush {
89 fmt.Fprintf(os.Stderr, "committed locally; push with: git push %s %s\n", b.Remote, b.Ref)
90 return nil
91 }
92 // A URL is a remote in its own right: a CI runner pushes to one without the clone
93 // having configured it as a named remote.
94 if !IsRemoteURL(b.Remote) && !HasRemote(b.Repo.Dir, b.Remote) {
95 fmt.Fprintf(os.Stderr, "no remote named %q; committed locally. Push with: git push <remote> %s\n",
96 b.Remote, b.Ref)
97 return nil
98 }
99
3b39a84c
Keenan Nemetz
14d ago
100 const attempts = 12
c5902451
Keenan Nemetz
16d ago
101 announced := false
102 for attempt := 1; ; attempt++ {
103 err := b.pushOnce()
104 if err == nil {
105 return nil
106 }
107 if !isLostRace(err) {
108 return err
109 }
110 if attempt == attempts {
111 return fmt.Errorf("metadata kept moving; gave up after %d attempts: %w", attempts, err)
112 }
113 if !announced {
114 fmt.Fprintln(os.Stderr, "metadata moved underneath us; re-applying on the new tip")
115 announced = true
116 }
3b39a84c
Keenan Nemetz
14d ago
117 // Back off exponentially and spread the jitter across the whole interval.
118 // Concurrent writers that sleep the same amount collide again on the next
119 // round, and a linear delay gives the last of a crowd too little room to win
120 // before the budget runs out.
121 wait := 25 * time.Millisecond << min(attempt-1, 4)
122 if wait > 400*time.Millisecond {
123 wait = 400 * time.Millisecond
124 }
125 time.Sleep(wait/2 + time.Duration(rand.IntN(int(wait/2)+1)))
c5902451
Keenan Nemetz
16d ago
126 if ferr := b.Fetch(); ferr != nil {
127 return ferr
128 }
129 if rerr := reapply(); rerr != nil {
130 return fmt.Errorf("re-applying after a concurrent push failed: %w", rerr)
131 }
132 }
133 }
134
135 func (b *Branch) pushOnce() error {
136 cmd := exec.Command("git", "push", "--quiet", b.Remote, b.Ref+":"+b.Ref)
137 cmd.Dir = b.Repo.Dir
138 var stderr bytes.Buffer
139 cmd.Stdout = os.Stdout
140 cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
141 if err := cmd.Run(); err != nil {
142 return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
143 }
144 return nil
145 }
146
147 // isLostRace distinguishes a lost race, which is retryable, from a policy
3b39a84c
Keenan Nemetz
14d ago
148 // rejection, which is not. A lost race has several shapes: our commit is no
149 // longer a fast-forward, our push arrives after someone else moved the ref and
150 // carries a stale old-oid ("incorrect old value provided"), the branch is created
151 // by two writers at once ("reference already exists"), or the server is holding
152 // the ref lock for another push while its value changes ("cannot lock ref"). The
153 // last one is why this has to look at more than the obvious rejections: it is a
154 // lock, not a policy, and it clears on its own.
c5902451
Keenan Nemetz
16d ago
155 func isLostRace(err error) bool {
156 msg := err.Error()
157 for _, marker := range []string{
158 "non-fast-forward",
159 "fetch first",
160 "incorrect old value provided",
161 "reference already exists",
3b39a84c
Keenan Nemetz
14d ago
162 "cannot lock ref",
c5902451
Keenan Nemetz
16d ago
163 "stale info",
164 "Updates were rejected",
165 } {
166 if strings.Contains(msg, marker) {
167 return true
168 }
169 }
170 return false
171 }
172
173 // Issues loads the issues on this branch.
174 func (b *Branch) Issues() ([]*metadata.Issue, error) {
175 if sha, err := b.Repo.RevParse(metadata.Ref); err == nil && sha == "" {
176 return nil, nil
177 }
178 return metadata.LoadIssues(b.Repo, metadata.Ref)
179 }
180
e05f1c48
Keenan Nemetz
13d ago
181 // Numbers returns the `#42` aliases of this branch. The numbering is derived from
182 // history, so a clone that has the branch can answer without the server.
183 func (b *Branch) Numbers() (*metadata.Numbers, error) {
184 return metadata.LoadNumbers(b.Repo, metadata.Ref)
185 }
186
187 // ResolveIssueID turns what a human typed into a full id. `list` prints numbers and
188 // short ids, so every id-taking command has to accept a number or a prefix or the
189 // output is not usable.
c5902451
Keenan Nemetz
16d ago
190 func (b *Branch) ResolveIssueID(given string) (string, error) {
e05f1c48
Keenan Nemetz
13d ago
191 given = strings.ToUpper(metadata.TrimReference(given))
c5902451
Keenan Nemetz
16d ago
192 if given == "" {
193 return "", fmt.Errorf("an issue id is required")
194 }
e05f1c48
Keenan Nemetz
13d ago
195 if num, ok := metadata.ParseNumber(given); ok {
196 nums, err := b.Numbers()
197 if err != nil {
198 return "", err
199 }
200 found, kind, ok := nums.At(num)
201 if !ok {
202 return "", fmt.Errorf("no issue or change request is #%d", num)
203 }
204 if kind != metadata.KindIssue {
205 return "", fmt.Errorf("#%d is a change request, not an issue: %s", num, found)
206 }
207 return found, nil
208 }
c5902451
Keenan Nemetz
16d ago
209 list, err := b.Issues()
210 if err != nil {
211 return "", err
212 }
213 for _, iss := range list {
214 if iss.ID == given {
215 return iss.ID, nil
216 }
217 }
218 var matches []string
219 for _, iss := range list {
220 if strings.HasPrefix(iss.ID, given) {
221 matches = append(matches, iss.ID)
222 }
223 }
224 switch len(matches) {
225 case 1:
226 return matches[0], nil
227 case 0:
228 return "", fmt.Errorf("no issue matching %q", given)
229 default:
230 return "", fmt.Errorf("%q matches %d issues: %s", given, len(matches), strings.Join(matches, ", "))
231 }
232 }
233
234 // ApplyIssue runs mutate against one issue and pushes the result. "The branch
235 // already says this" is success, not an error: a rerun of a command must stay
236 // idempotent without growing a linear log, so Update reports ErrNoChange and the
237 // branch does not move. The returned issue is nil when nothing was committed.
238 func (b *Branch) ApplyIssue(id string, mutate func(*metadata.Issue) error, actor string, noPush bool) (*metadata.Issue, error) {
239 var updated *metadata.Issue
240 reapply := func() error {
241 var uerr error
242 updated, uerr = metadata.UpdateIssue(b.Repo, id, mutate, actor)
243 if errors.Is(uerr, metadata.ErrNoChange) {
244 // On a retry the change may already be on the branch, which is the
245 // outcome the caller wanted rather than a failure.
246 return nil
247 }
248 return uerr
249 }
250 if err := reapply(); err != nil {
251 return nil, err
252 }
253 if updated == nil {
254 return nil, nil
255 }
256 if err := b.CommitAndPush(noPush, reapply); err != nil {
257 return nil, err
258 }
259 return updated, nil
260 }
261
8e93a8f0
Keenan Nemetz
15d ago
262 // SetIssueState closes or reopens one issue. It is a dedicated path rather than
263 // ApplyIssue so the metadata commit names the state, which is what the activity
264 // feed reads.
265 func (b *Branch) SetIssueState(id, state, actor string, noPush bool) (*metadata.Issue, error) {
266 var updated *metadata.Issue
267 reapply := func() error {
268 var uerr error
269 updated, uerr = metadata.SetIssueState(b.Repo, id, state, actor)
270 if errors.Is(uerr, metadata.ErrNoChange) {
271 return nil
272 }
273 return uerr
274 }
275 if err := reapply(); err != nil {
276 return nil, err
277 }
278 if updated == nil {
279 return nil, nil
280 }
281 if err := b.CommitAndPush(noPush, reapply); err != nil {
282 return nil, err
283 }
284 return updated, nil
285 }
286
c5902451
Keenan Nemetz
16d ago
287 // ResolveCommentID turns a typed comment id into a full one, the way
288 // ResolveIssueID does for issues: a prefix has to work or printed ids are unusable.
289 func (b *Branch) ResolveCommentID(issueID, given string) (string, error) {
290 given = strings.ToUpper(strings.TrimSpace(given))
291 if given == "" {
292 return "", fmt.Errorf("a comment id is required")
293 }
294 list, err := b.Issues()
295 if err != nil {
296 return "", err
297 }
298 for _, iss := range list {
299 if iss.ID != issueID {
300 continue
301 }
302 var matches []string
303 for _, cm := range iss.Comments {
304 if cm.ID == given {
305 return cm.ID, nil
306 }
307 if strings.HasPrefix(cm.ID, given) {
308 matches = append(matches, cm.ID)
309 }
310 }
311 switch len(matches) {
312 case 1:
313 return matches[0], nil
314 case 0:
315 return "", fmt.Errorf("no comment matching %q on %s", given, issueID)
316 default:
317 return "", fmt.Errorf("%q matches %d comments: %s", given, len(matches), strings.Join(matches, ", "))
318 }
319 }
320 return "", fmt.Errorf("no issue matching %q", issueID)
321 }
322
323 // RepoName is the repository this clone's remote names, for building page URLs.
324 func (b *Branch) RepoName() string {
325 if _, _, path, err := SSHTarget(b.Repo.Dir, b.Remote); err == nil {
326 return RepoNameFromPath(path)
327 }
328 return ""
329 }