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