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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 59m ago 4b8c9e1d
history blame raw 10.2 KiB
1package client
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "math/rand/v2"
9 "os"
10 "os/exec"
11 "strings"
12 "time"
13
14 "rodent.software/vole/core/git"
15 "rodent.software/vole/metadata"
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.
22type 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.
30func 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.
50func 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.
69func (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.
87func (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
100 const attempts = 12
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 }
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)))
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
135func (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
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.
155func 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",
162 "cannot lock ref",
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.
174func (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
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.
183func (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.
190func (b *Branch) ResolveIssueID(given string) (string, error) {
191 given = strings.ToUpper(metadata.TrimReference(given))
192 if given == "" {
193 return "", fmt.Errorf("an issue id is required")
194 }
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 }
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.
238func (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
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.
265func (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
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.
289func (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.
324func (b *Branch) RepoName() string {
325 if _, _, path, err := SSHTarget(b.Repo.Dir, b.Remote); err == nil {
326 return RepoNameFromPath(path)
327 }
328 return ""
329}
330