feat(agit): open pull requests by pushing to refs/for
14 files changed, +705 −83
- README.md modified +10 −2
- forge/agit.go added +455 −0
- forge/cli.go modified +2 −2
- forge/edit.go modified +7 −61
- forge/feature_test.go modified +170 −0
- forge/import.go modified +1 −1
- forge/pull.go modified +18 −4
- forge/quota.go modified +12 −3
- forge/shell.go modified +4 −3
- forge/smart.go modified +21 −3
- forge/store.go modified +1 −0
- forge/web.go modified +1 −1
- web/templates/pull.tmpl modified +2 −2
- web/templates/pulls.tmpl modified +1 −1
README.md modified
| @@ -23,6 +23,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS | ||
| 23 | 23 | - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository |
| 24 | 24 | - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account |
| 25 | 25 | - Pull mirrors that fetch a source on a schedule and stay read only and push mirrors that copy every branch and tag to another host after each change |
| 26 | +- Pull requests opened by pushing to `refs/for/<branch>` with no fork needed | |
| 26 | 27 | - Static sites served from `<user>.<host>`, built from a `pages` repository or the `pages` branch of any public repository |
| 27 | 28 | - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories |
| 28 | 29 | - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests |
| @@ -46,7 +47,14 @@ git clone https://git.example.org/~you/repo | ||
| 46 | 47 | git clone [email protected]:~you/repo |
| 47 | 48 | ``` |
| 48 | 49 | |
| 49 | -SSH pushes need a key added under settings and HTTPS pushes take an access token or your password. To contribute to something you do not own, fork it, push a branch to the fork and open a pull request from that branch. | |
| 50 | +SSH pushes need a key added under settings and HTTPS pushes take an access token or your password. To contribute to a repository you do not own you push your commits to `refs/for/` followed by the target branch, which opens a pull request on that repository without any need for a fork. | |
| 51 | + | |
| 52 | +``` | |
| 53 | +git push origin HEAD:refs/for/main -o title="Fix the parser" | |
| 54 | +git push origin HEAD:refs/for/main/parser | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Pushing again with the same topic updates that pull request and records a new revision, and the topic defaults to the target branch when the ref names none. Without `-o title` the first commit subject becomes the title and its body becomes the description, while `-o description=` overrides the body and `-o draft=true` opens the pull request as a draft. Anyone who is not the owner or a collaborator can only push to `refs/for/` and those guest pushes are capped at 20 MB each and 30 an hour. | |
| 50 | 58 | |
| 51 | 59 | ### Pages |
| 52 | 60 | |
| @@ -64,7 +72,7 @@ Ticking *keep it as a read only mirror* on the import page turns the import into | ||
| 64 | 72 | go test ./... |
| 65 | 73 | ``` |
| 66 | 74 | |
| 67 | -The tests drive the real handler through `httptest` and cover each registration mode, cross site posts, private repositories, moved repositories, every merge strategy and Markdown injection payloads. | |
| 75 | +The tests drive the real handler through `httptest` and cover each registration mode, cross site posts, private repositories, moved repositories, every merge strategy and Markdown injection payloads. The `refs/for/` test builds the binary and pushes through the smart HTTP handler with a real git client, which takes several seconds, so `go test -short` skips it when you only want the quick checks. | |
| 68 | 76 | |
| 69 | 77 | ## Licence |
| 70 | 78 | |
forge/agit.go added
| @@ -0,0 +1,455 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bufio" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 10 | + "strconv" | |
| 11 | + "strings" | |
| 12 | +) | |
| 13 | + | |
| 14 | +const ( | |
| 15 | + maxPkt = 65520 | |
| 16 | + maxCommands = 64 | |
| 17 | + maxOptions = 64 | |
| 18 | + maxTopic = 100 | |
| 19 | +) | |
| 20 | + | |
| 21 | +var zeroOID = strings.Repeat("0", 40) | |
| 22 | + | |
| 23 | +func hooksDir(root string) string { return filepath.Join(root, "hooks") } | |
| 24 | + | |
| 25 | +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } | |
| 26 | + | |
| 27 | +func hookScript(root, host, binary, kind string) string { | |
| 28 | + return fmt.Sprintf("#!/bin/sh\nGITPOCKET_HOST=%s exec %s -root %s hook %s\n", shellQuote(host), shellQuote(binary), shellQuote(root), kind) | |
| 29 | +} | |
| 30 | + | |
| 31 | +func writeHook(path, script string) error { | |
| 32 | + if b, err := os.ReadFile(path); err == nil && string(b) == script { | |
| 33 | + return os.Chmod(path, 0o755) | |
| 34 | + } | |
| 35 | + tmp := path + ".tmp" | |
| 36 | + if err := os.WriteFile(tmp, []byte(script), 0o755); err != nil { | |
| 37 | + return err | |
| 38 | + } | |
| 39 | + return os.Rename(tmp, path) | |
| 40 | +} | |
| 41 | + | |
| 42 | +func installCentralHooks(root, host string) error { | |
| 43 | + binary, err := os.Executable() | |
| 44 | + if err != nil { | |
| 45 | + return err | |
| 46 | + } | |
| 47 | + dir := hooksDir(root) | |
| 48 | + if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 49 | + return err | |
| 50 | + } | |
| 51 | + for _, kind := range []string{"pre-receive", "proc-receive"} { | |
| 52 | + if err := writeHook(filepath.Join(dir, kind), hookScript(root, host, binary, kind)); err != nil { | |
| 53 | + return err | |
| 54 | + } | |
| 55 | + } | |
| 56 | + return nil | |
| 57 | +} | |
| 58 | + | |
| 59 | +func hooksReady(root string) bool { | |
| 60 | + for _, kind := range []string{"pre-receive", "proc-receive"} { | |
| 61 | + fi, err := os.Stat(filepath.Join(hooksDir(root), kind)) | |
| 62 | + if err != nil || !fi.Mode().IsRegular() || fi.Mode()&0o111 == 0 { | |
| 63 | + return false | |
| 64 | + } | |
| 65 | + } | |
| 66 | + return true | |
| 67 | +} | |
| 68 | + | |
| 69 | +func receiveArgs(root string) []string { | |
| 70 | + return []string{"-c", "core.hooksPath=" + hooksDir(root), "-c", "receive.procReceiveRefs=refs/for", "-c", "receive.advertisePushOptions=true"} | |
| 71 | +} | |
| 72 | + | |
| 73 | +func receiveEnv(repo Repo, pusher User) []string { | |
| 74 | + return []string{"GITPOCKET_REPO=" + repo.Owner.Name + "/" + repo.Name, "GITPOCKET_PUSHER=" + strconv.FormatInt(pusher.ID, 10)} | |
| 75 | +} | |
| 76 | + | |
| 77 | +type hookContext struct { | |
| 78 | + store *Store | |
| 79 | + repo Repo | |
| 80 | + pusher User | |
| 81 | + writer bool | |
| 82 | + g Git | |
| 83 | +} | |
| 84 | + | |
| 85 | +func loadHook(root string, readonly bool) (hookContext, error) { | |
| 86 | + path := os.Getenv("GITPOCKET_REPO") | |
| 87 | + id, err := strconv.ParseInt(os.Getenv("GITPOCKET_PUSHER"), 10, 64) | |
| 88 | + owner, name, ok := strings.Cut(path, "/") | |
| 89 | + if !ok || err != nil || id <= 0 { | |
| 90 | + return hookContext{}, errors.New("push did not come through gitpocket") | |
| 91 | + } | |
| 92 | + store, err := openStore(filepath.Join(root, "gitpocket.db"), readonly) | |
| 93 | + if err != nil { | |
| 94 | + return hookContext{}, errors.New("database unavailable") | |
| 95 | + } | |
| 96 | + repo, err := store.RepoByPath(owner, name) | |
| 97 | + if err != nil { | |
| 98 | + return hookContext{}, errors.New("unknown repository") | |
| 99 | + } | |
| 100 | + pusher, err := store.userByID(id) | |
| 101 | + if err != nil || store.IsBanned(id) { | |
| 102 | + return hookContext{}, errors.New("unknown account") | |
| 103 | + } | |
| 104 | + if !store.CanRead(repo, &pusher) { | |
| 105 | + return hookContext{}, errors.New("unknown repository") | |
| 106 | + } | |
| 107 | + return hookContext{store: store, repo: repo, pusher: pusher, writer: store.CanWrite(repo, &pusher), g: Git{Dir: filepath.Join(root, "repos", repo.Owner.Name, repo.Name+".git")}}, nil | |
| 108 | +} | |
| 109 | + | |
| 110 | +func hook(root, kind string) int { | |
| 111 | + switch kind { | |
| 112 | + case "pre-receive": | |
| 113 | + return preReceive(root, os.Stdin, os.Stderr) | |
| 114 | + case "proc-receive": | |
| 115 | + return procReceive(root, os.Stdin, os.Stdout, os.Stderr) | |
| 116 | + } | |
| 117 | + return 0 | |
| 118 | +} | |
| 119 | + | |
| 120 | +func preReceive(root string, in io.Reader, errw io.Writer) int { | |
| 121 | + refuse := func(format string, args ...any) int { | |
| 122 | + fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...) | |
| 123 | + return 1 | |
| 124 | + } | |
| 125 | + hc, err := loadHook(root, true) | |
| 126 | + if err != nil { | |
| 127 | + return refuse("%v, push refused", err) | |
| 128 | + } | |
| 129 | + for _, kv := range os.Environ() { | |
| 130 | + if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") { | |
| 131 | + passEnv = append(passEnv, kv) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + mirror := hc.store.IsPullMirror(hc.repo.ID) | |
| 135 | + if !hc.writer && (!hc.store.GuestPushBudget(hc.pusher.ID) || hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID) || hc.repo.Archived || mirror) { | |
| 136 | + return refuse("you cannot open pull requests here right now") | |
| 137 | + } | |
| 138 | + scanner := bufio.NewScanner(in) | |
| 139 | + for scanner.Scan() { | |
| 140 | + f := strings.Fields(scanner.Text()) | |
| 141 | + if len(f) != 3 { | |
| 142 | + return refuse("malformed update") | |
| 143 | + } | |
| 144 | + ref := f[2] | |
| 145 | + if strings.HasPrefix(ref, "refs/for/") { | |
| 146 | + continue | |
| 147 | + } | |
| 148 | + if !hc.writer { | |
| 149 | + return refuse("you can only open pull requests here, push to refs/for/<branch>") | |
| 150 | + } | |
| 151 | + if mirror { | |
| 152 | + return refuse("%s is a pull mirror and read only", hc.repo.Path()) | |
| 153 | + } | |
| 154 | + if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") { | |
| 155 | + return refuse("only branches and tags can be pushed, not %s", ref) | |
| 156 | + } | |
| 157 | + branch, ok := strings.CutPrefix(ref, "refs/heads/") | |
| 158 | + if !ok { | |
| 159 | + continue | |
| 160 | + } | |
| 161 | + prot, ok := hc.store.Protection(hc.repo.ID, branch) | |
| 162 | + if !ok { | |
| 163 | + continue | |
| 164 | + } | |
| 165 | + if f[1] == zeroOID { | |
| 166 | + return refuse("%s is protected and cannot be deleted", branch) | |
| 167 | + } | |
| 168 | + if prot.RequirePR && f[0] != zeroOID { | |
| 169 | + return refuse("%s only accepts changes through pull requests", branch) | |
| 170 | + } | |
| 171 | + if f[0] != zeroOID { | |
| 172 | + if _, err := hc.g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil { | |
| 173 | + return refuse("%s is protected, force pushes are refused", branch) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + return 0 | |
| 178 | +} | |
| 179 | + | |
| 180 | +type pktReader struct{ r *bufio.Reader } | |
| 181 | + | |
| 182 | +func (p pktReader) read() (string, bool, error) { | |
| 183 | + var head [4]byte | |
| 184 | + if _, err := io.ReadFull(p.r, head[:]); err != nil { | |
| 185 | + return "", false, err | |
| 186 | + } | |
| 187 | + n, err := strconv.ParseUint(string(head[:]), 16, 16) | |
| 188 | + if err != nil { | |
| 189 | + return "", false, errors.New("bad pkt-line length") | |
| 190 | + } | |
| 191 | + if n == 0 { | |
| 192 | + return "", true, nil | |
| 193 | + } | |
| 194 | + if n < 4 || n > maxPkt { | |
| 195 | + return "", false, errors.New("bad pkt-line length") | |
| 196 | + } | |
| 197 | + buf := make([]byte, n-4) | |
| 198 | + if _, err := io.ReadFull(p.r, buf); err != nil { | |
| 199 | + return "", false, err | |
| 200 | + } | |
| 201 | + return strings.TrimSuffix(string(buf), "\n"), false, nil | |
| 202 | +} | |
| 203 | + | |
| 204 | +func (p pktReader) section(limit int) ([]string, error) { | |
| 205 | + var out []string | |
| 206 | + for { | |
| 207 | + line, flush, err := p.read() | |
| 208 | + if err != nil { | |
| 209 | + return nil, err | |
| 210 | + } | |
| 211 | + if flush { | |
| 212 | + return out, nil | |
| 213 | + } | |
| 214 | + if len(out) >= limit { | |
| 215 | + return nil, errors.New("too many lines") | |
| 216 | + } | |
| 217 | + out = append(out, line) | |
| 218 | + } | |
| 219 | +} | |
| 220 | + | |
| 221 | +func writePkt(w io.Writer, line string) error { | |
| 222 | + if line == "" { | |
| 223 | + _, err := io.WriteString(w, "0000") | |
| 224 | + return err | |
| 225 | + } | |
| 226 | + _, err := fmt.Fprintf(w, "%04x%s\n", len(line)+5, line) | |
| 227 | + return err | |
| 228 | +} | |
| 229 | + | |
| 230 | +type agitCommand struct{ old, new, ref string } | |
| 231 | + | |
| 232 | +type agitResult struct { | |
| 233 | + ok bool | |
| 234 | + reason string | |
| 235 | + ref, oldOID string | |
| 236 | + newOID string | |
| 237 | +} | |
| 238 | + | |
| 239 | +func procReceive(root string, in io.Reader, out, errw io.Writer) int { | |
| 240 | + pr := pktReader{bufio.NewReader(in)} | |
| 241 | + hello, err := pr.section(4) | |
| 242 | + if err != nil || len(hello) == 0 { | |
| 243 | + return 1 | |
| 244 | + } | |
| 245 | + version, caps, _ := strings.Cut(hello[0], "\x00") | |
| 246 | + if version != "version=1" { | |
| 247 | + return 1 | |
| 248 | + } | |
| 249 | + options := false | |
| 250 | + for _, c := range strings.Fields(caps) { | |
| 251 | + options = options || c == "push-options" | |
| 252 | + } | |
| 253 | + reply := "version=1" | |
| 254 | + if options { | |
| 255 | + reply += "\x00push-options" | |
| 256 | + } | |
| 257 | + if writePkt(out, reply) != nil || writePkt(out, "") != nil { | |
| 258 | + return 1 | |
| 259 | + } | |
| 260 | + lines, err := pr.section(maxCommands) | |
| 261 | + if err != nil { | |
| 262 | + return 1 | |
| 263 | + } | |
| 264 | + var cmds []agitCommand | |
| 265 | + for _, line := range lines { | |
| 266 | + f := strings.Fields(line) | |
| 267 | + if len(f) != 3 { | |
| 268 | + return 1 | |
| 269 | + } | |
| 270 | + cmds = append(cmds, agitCommand{f[0], f[1], f[2]}) | |
| 271 | + } | |
| 272 | + opts := map[string]string{} | |
| 273 | + if options { | |
| 274 | + raw, err := pr.section(maxOptions) | |
| 275 | + if err != nil { | |
| 276 | + return 1 | |
| 277 | + } | |
| 278 | + for _, o := range raw { | |
| 279 | + k, v, _ := strings.Cut(o, "=") | |
| 280 | + opts[k] = v | |
| 281 | + } | |
| 282 | + } | |
| 283 | + hc, herr := loadHook(root, false) | |
| 284 | + if herr == nil && !hc.writer { | |
| 285 | + hc.store.GuestPushed(hc.pusher.ID) | |
| 286 | + } | |
| 287 | + for _, c := range cmds { | |
| 288 | + var res agitResult | |
| 289 | + if herr != nil { | |
| 290 | + res = agitResult{reason: herr.Error()} | |
| 291 | + } else { | |
| 292 | + res = hc.agit(c, opts, errw) | |
| 293 | + } | |
| 294 | + res.ref = c.ref | |
| 295 | + if !res.ok { | |
| 296 | + writePkt(out, "ng "+c.ref+" "+sanitizeReason(res.reason)) | |
| 297 | + continue | |
| 298 | + } | |
| 299 | + writePkt(out, "ok "+c.ref) | |
| 300 | + writePkt(out, "option refname "+res.ref) | |
| 301 | + writePkt(out, "option old-oid "+res.oldOID) | |
| 302 | + writePkt(out, "option new-oid "+res.newOID) | |
| 303 | + } | |
| 304 | + writePkt(out, "") | |
| 305 | + return 0 | |
| 306 | +} | |
| 307 | + | |
| 308 | +func sanitizeReason(s string) string { | |
| 309 | + s = strings.Map(func(r rune) rune { | |
| 310 | + if r < 0x20 || r == 0x7f { | |
| 311 | + return ' ' | |
| 312 | + } | |
| 313 | + return r | |
| 314 | + }, s) | |
| 315 | + if len(s) > 200 { | |
| 316 | + s = s[:200] | |
| 317 | + } | |
| 318 | + return s | |
| 319 | +} | |
| 320 | + | |
| 321 | +func validOID(s string) bool { | |
| 322 | + if len(s) != 40 && len(s) != 64 { | |
| 323 | + return false | |
| 324 | + } | |
| 325 | + for _, c := range s { | |
| 326 | + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |
| 327 | + return false | |
| 328 | + } | |
| 329 | + } | |
| 330 | + return true | |
| 331 | +} | |
| 332 | + | |
| 333 | +func (hc hookContext) agitTarget(rest, option string) (target, topic string, ok bool) { | |
| 334 | + parts := strings.Split(rest, "/") | |
| 335 | + for i := len(parts); i > 0; i-- { | |
| 336 | + branch := strings.Join(parts[:i], "/") | |
| 337 | + if !validBranch(branch) { | |
| 338 | + continue | |
| 339 | + } | |
| 340 | + if _, err := hc.g.Resolve("refs/heads/" + branch); err != nil { | |
| 341 | + continue | |
| 342 | + } | |
| 343 | + topic = strings.Join(parts[i:], "/") | |
| 344 | + if topic == "" { | |
| 345 | + topic = option | |
| 346 | + } | |
| 347 | + if topic == "" { | |
| 348 | + topic = branch | |
| 349 | + } | |
| 350 | + return branch, topic, validBranch(topic) && len(topic) <= maxTopic | |
| 351 | + } | |
| 352 | + return "", "", false | |
| 353 | +} | |
| 354 | + | |
| 355 | +func (hc hookContext) agit(c agitCommand, opts map[string]string, errw io.Writer) agitResult { | |
| 356 | + fail := func(msg string) agitResult { return agitResult{reason: msg} } | |
| 357 | + switch { | |
| 358 | + case !validOID(c.new) || !validOID(c.old): | |
| 359 | + return fail("malformed update") | |
| 360 | + case c.new == zeroOID || strings.Trim(c.new, "0") == "": | |
| 361 | + return fail("pull requests cannot be deleted by push") | |
| 362 | + case hc.repo.Archived: | |
| 363 | + return fail("repository is archived") | |
| 364 | + case hc.store.IsPullMirror(hc.repo.ID): | |
| 365 | + return fail("repository is a pull mirror") | |
| 366 | + case hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID): | |
| 367 | + return fail("you cannot open pull requests here") | |
| 368 | + } | |
| 369 | + rest, _ := strings.CutPrefix(c.ref, "refs/for/") | |
| 370 | + target, topic, ok := hc.agitTarget(rest, strings.TrimSpace(opts["topic"])) | |
| 371 | + if !ok { | |
| 372 | + return fail("push to refs/for/<existing branch>[/<topic>]") | |
| 373 | + } | |
| 374 | + if t, err := hc.g.run(nil, "cat-file", "-t", c.new); err != nil || strings.TrimSpace(string(t)) != "commit" { | |
| 375 | + return fail("not a commit") | |
| 376 | + } | |
| 377 | + if _, err := hc.g.run(nil, "merge-base", "--is-ancestor", c.new, "refs/heads/"+target); err == nil { | |
| 378 | + return fail("no new commits compared to " + target) | |
| 379 | + } | |
| 380 | + if pull, found := hc.store.AgitPull(hc.repo, hc.pusher.ID, target, topic); found { | |
| 381 | + old, _ := hc.g.Resolve(pull.Ref()) | |
| 382 | + if old == c.new { | |
| 383 | + return fail("pull request is already up to date") | |
| 384 | + } | |
| 385 | + if _, err := hc.g.run(nil, "update-ref", pull.Ref(), c.new, old); err != nil { | |
| 386 | + return fail("pull request moved, push again") | |
| 387 | + } | |
| 388 | + hc.g.RecordRevision(pull) | |
| 389 | + hc.store.TouchPull(pull.ID) | |
| 390 | + hc.store.Notify(hc.store.Watchers(hc.repo, "pull", pull.ID, pull.Author.ID), hc.pusher, hc.repo, "pull", pull.Number, pull.Title, "updated") | |
| 391 | + fmt.Fprintf(errw, "\nupdated pull request #%d:\n %s\n\n", pull.Number, pullURL(hc.repo, pull.Number)) | |
| 392 | + return agitResult{ok: true, ref: pull.Ref(), oldOID: old, newOID: c.new} | |
| 393 | + } | |
| 394 | + if !hc.store.ThreadBudget(hc.pusher.ID) { | |
| 395 | + return fail("too many new issues and pull requests, try again later") | |
| 396 | + } | |
| 397 | + title, body := strings.TrimSpace(opts["title"]), strings.TrimSpace(opts["description"]) | |
| 398 | + if title == "" { | |
| 399 | + msg, _ := hc.g.run(nil, "log", "-1", "--format=%B", c.new) | |
| 400 | + subject, rest, _ := strings.Cut(strings.TrimSpace(string(msg)), "\n") | |
| 401 | + title = strings.TrimSpace(subject) | |
| 402 | + if body == "" { | |
| 403 | + body = strings.TrimSpace(rest) | |
| 404 | + } | |
| 405 | + } | |
| 406 | + if len(title) > 200 { | |
| 407 | + title = title[:200] | |
| 408 | + } | |
| 409 | + if len(body) > 20000 { | |
| 410 | + body = body[:20000] | |
| 411 | + } | |
| 412 | + pull, err := hc.store.CreatePull(hc.repo, hc.pusher, title, body, hc.repo, "", target) | |
| 413 | + if err != nil { | |
| 414 | + return fail(err.Error()) | |
| 415 | + } | |
| 416 | + hc.store.SetAgit(pull.ID, topic) | |
| 417 | + pull.Agit = topic | |
| 418 | + if _, err := hc.g.run(nil, "update-ref", pull.Ref(), c.new); err != nil { | |
| 419 | + hc.store.DeletePull(pull.ID) | |
| 420 | + return fail("could not record the pull request") | |
| 421 | + } | |
| 422 | + if opts["draft"] == "true" || opts["draft"] == "1" { | |
| 423 | + hc.store.SetDraft(pull.ID, true) | |
| 424 | + } | |
| 425 | + hc.g.RecordRevision(pull) | |
| 426 | + hc.store.Notify(hc.store.Watchers(hc.repo, "pull", pull.ID, pull.Author.ID), hc.pusher, hc.repo, "pull", pull.Number, pull.Title, "opened") | |
| 427 | + fmt.Fprintf(errw, "\ncreated pull request #%d:\n %s\n\n", pull.Number, pullURL(hc.repo, pull.Number)) | |
| 428 | + return agitResult{ok: true, ref: pull.Ref(), oldOID: zeroOID, newOID: c.new} | |
| 429 | +} | |
| 430 | + | |
| 431 | +func pullURL(repo Repo, n int64) string { | |
| 432 | + host := os.Getenv("GITPOCKET_HOST") | |
| 433 | + if host == "" { | |
| 434 | + return fmt.Sprintf("/%s/pulls/%d", repo.Path(), n) | |
| 435 | + } | |
| 436 | + return fmt.Sprintf("https://%s/%s/pulls/%d", host, repo.Path(), n) | |
| 437 | +} | |
| 438 | + | |
| 439 | +func (s *Store) AgitPull(repo Repo, author int64, target, topic string) (Pull, bool) { | |
| 440 | + var number int64 | |
| 441 | + err := s.db.QueryRow(`select number from pulls where repo = ? and author = ? and agit = ? and target_branch = ? and state = 'open' order by number desc limit 1`, repo.ID, author, topic, target).Scan(&number) | |
| 442 | + if err != nil { | |
| 443 | + return Pull{}, false | |
| 444 | + } | |
| 445 | + p, err := s.Pull(repo, number) | |
| 446 | + return p, err == nil | |
| 447 | +} | |
| 448 | + | |
| 449 | +func (s *Store) SetAgit(id int64, topic string) { | |
| 450 | + s.db.Exec(`update pulls set agit = ? where id = ?`, topic, id) | |
| 451 | +} | |
| 452 | + | |
| 453 | +func (s *Store) DeletePull(id int64) { | |
| 454 | + s.db.Exec(`delete from pulls where id = ?`, id) | |
| 455 | +} | |
forge/cli.go modified
| @@ -48,10 +48,10 @@ func Main() { | ||
| 48 | 48 | binary, _ := os.Executable() |
| 49 | 49 | os.Exit(keys(*root, binary, args[1:])) |
| 50 | 50 | case "hook": |
| 51 | - if len(args) != 3 { | |
| 51 | + if len(args) < 2 { | |
| 52 | 52 | os.Exit(2) |
| 53 | 53 | } |
| 54 | - os.Exit(hook(*root, args[1], args[2])) | |
| 54 | + os.Exit(hook(*root, args[1])) | |
| 55 | 55 | case "shell": |
| 56 | 56 | if len(args) != 2 { |
| 57 | 57 | os.Exit(2) |
forge/edit.go modified
| @@ -1,10 +1,9 @@ | ||
| 1 | 1 | package forge |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "bufio" | |
| 5 | 4 | "errors" |
| 6 | - "fmt" | |
| 7 | 5 | "html/template" |
| 6 | + "log" | |
| 8 | 7 | "net/http" |
| 9 | 8 | "os" |
| 10 | 9 | "path" |
| @@ -12,18 +11,20 @@ import ( | ||
| 12 | 11 | "strings" |
| 13 | 12 | ) |
| 14 | 13 | |
| 15 | -func installHook(root, dir string) { | |
| 14 | +func installHook(root, host, dir string) { | |
| 16 | 15 | binary, err := os.Executable() |
| 17 | 16 | if err != nil { |
| 18 | 17 | return |
| 19 | 18 | } |
| 20 | 19 | hooks := filepath.Join(dir, "hooks") |
| 21 | 20 | os.MkdirAll(hooks, 0o755) |
| 22 | - script := fmt.Sprintf("#!/bin/sh\nd=$(cd \"$(dirname \"$0\")/..\" && pwd)\nexec %s -root %s hook pre-receive \"$(basename \"$(dirname \"$d\")\")|$(basename \"$d\")\"\n", binary, root) | |
| 23 | - os.WriteFile(filepath.Join(hooks, "pre-receive"), []byte(script), 0o755) | |
| 21 | + writeHook(filepath.Join(hooks, "pre-receive"), hookScript(root, host, binary, "pre-receive")) | |
| 24 | 22 | } |
| 25 | 23 | |
| 26 | 24 | func (s *Server) installHooks() { |
| 25 | + if err := installCentralHooks(s.root, s.host); err != nil { | |
| 26 | + log.Printf("hooks: %v", err) | |
| 27 | + } | |
| 27 | 28 | owners, err := os.ReadDir(filepath.Join(s.root, "repos")) |
| 28 | 29 | if err != nil { |
| 29 | 30 | return |
| @@ -32,65 +33,10 @@ func (s *Server) installHooks() { | ||
| 32 | 33 | repos, _ := os.ReadDir(filepath.Join(s.root, "repos", o.Name())) |
| 33 | 34 | for _, r := range repos { |
| 34 | 35 | if strings.HasSuffix(r.Name(), ".git") { |
| 35 | - installHook(s.root, filepath.Join(s.root, "repos", o.Name(), r.Name())) | |
| 36 | - } | |
| 37 | - } | |
| 38 | - } | |
| 39 | -} | |
| 40 | - | |
| 41 | -func hook(root, kind, spec string) int { | |
| 42 | - if kind != "pre-receive" { | |
| 43 | - return 0 | |
| 44 | - } | |
| 45 | - owner, repoDir, ok := strings.Cut(spec, "|") | |
| 46 | - if !ok { | |
| 47 | - return 0 | |
| 48 | - } | |
| 49 | - name := strings.TrimSuffix(repoDir, ".git") | |
| 50 | - store, err := openStore(filepath.Join(root, "gitpocket.db"), true) | |
| 51 | - if err != nil { | |
| 52 | - fmt.Fprintln(os.Stderr, "gitpocket: database unavailable, push refused") | |
| 53 | - return 1 | |
| 54 | - } | |
| 55 | - repo, err := store.RepoByPath(owner, name) | |
| 56 | - if err != nil { | |
| 57 | - fmt.Fprintln(os.Stderr, "gitpocket: unknown repository, push refused") | |
| 58 | - return 1 | |
| 59 | - } | |
| 60 | - for _, kv := range os.Environ() { | |
| 61 | - if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") { | |
| 62 | - passEnv = append(passEnv, kv) | |
| 63 | - } | |
| 64 | - } | |
| 65 | - g := Git{Dir: filepath.Join(root, "repos", owner, repoDir)} | |
| 66 | - zero := strings.Repeat("0", 40) | |
| 67 | - scanner := bufio.NewScanner(os.Stdin) | |
| 68 | - for scanner.Scan() { | |
| 69 | - f := strings.Fields(scanner.Text()) | |
| 70 | - if len(f) != 3 || !strings.HasPrefix(f[2], "refs/heads/") { | |
| 71 | - continue | |
| 72 | - } | |
| 73 | - branch := strings.TrimPrefix(f[2], "refs/heads/") | |
| 74 | - prot, ok := store.Protection(repo.ID, branch) | |
| 75 | - if !ok { | |
| 76 | - continue | |
| 77 | - } | |
| 78 | - if f[1] == zero { | |
| 79 | - fmt.Fprintf(os.Stderr, "gitpocket: %s is protected and cannot be deleted\n", branch) | |
| 80 | - return 1 | |
| 81 | - } | |
| 82 | - if prot.RequirePR && f[0] != zero { | |
| 83 | - fmt.Fprintf(os.Stderr, "gitpocket: %s only accepts changes through pull requests\n", branch) | |
| 84 | - return 1 | |
| 85 | - } | |
| 86 | - if f[0] != zero { | |
| 87 | - if _, err := g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil { | |
| 88 | - fmt.Fprintf(os.Stderr, "gitpocket: %s is protected, force pushes are refused\n", branch) | |
| 89 | - return 1 | |
| 36 | + installHook(s.root, s.host, filepath.Join(s.root, "repos", o.Name(), r.Name())) | |
| 90 | 37 | } |
| 91 | 38 | } |
| 92 | 39 | } |
| 93 | - return 0 | |
| 94 | 40 | } |
| 95 | 41 | |
| 96 | 42 | func (g Git) Commit1(branch, parent, filePath string, content []byte, remove bool, message, name, email string) (string, error) { |
forge/feature_test.go modified
| @@ -179,6 +179,176 @@ func TestReservedNames(t *testing.T) { | ||
| 179 | 179 | } |
| 180 | 180 | } |
| 181 | 181 | |
| 182 | +type pushRig struct { | |
| 183 | + t *testing.T | |
| 184 | + s *Server | |
| 185 | + url string | |
| 186 | + work string | |
| 187 | +} | |
| 188 | + | |
| 189 | +func (p pushRig) git(args ...string) (string, error) { | |
| 190 | + cmd := exec.Command("git", append([]string{"-C", p.work}, args...)...) | |
| 191 | + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=B", "[email protected]", "GIT_COMMITTER_NAME=B", "[email protected]") | |
| 192 | + out, err := cmd.CombinedOutput() | |
| 193 | + return string(out), err | |
| 194 | +} | |
| 195 | + | |
| 196 | +func (p pushRig) must(args ...string) string { | |
| 197 | + p.t.Helper() | |
| 198 | + out, err := p.git(args...) | |
| 199 | + if err != nil { | |
| 200 | + p.t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 201 | + } | |
| 202 | + return out | |
| 203 | +} | |
| 204 | + | |
| 205 | +func (p pushRig) commit(name, content, message string) { | |
| 206 | + p.t.Helper() | |
| 207 | + os.WriteFile(filepath.Join(p.work, name), []byte(content), 0o644) | |
| 208 | + p.must("add", name) | |
| 209 | + p.must("commit", "-q", "-m", message) | |
| 210 | +} | |
| 211 | + | |
| 212 | +func remote(base, user, repo string) string { | |
| 213 | + u, _ := url.Parse(base) | |
| 214 | + u.User = url.UserPassword(user, "correct horse") | |
| 215 | + u.Path = "/~" + repo | |
| 216 | + return u.String() | |
| 217 | +} | |
| 218 | + | |
| 219 | +func TestAgit(t *testing.T) { | |
| 220 | + if _, err := exec.LookPath("git"); err != nil { | |
| 221 | + t.Skip("git not installed") | |
| 222 | + } | |
| 223 | + if testing.Short() { | |
| 224 | + t.Skip("builds the binary") | |
| 225 | + } | |
| 226 | + s, h := testServer(t) | |
| 227 | + bin := filepath.Join(t.TempDir(), "gitpocket") | |
| 228 | + if out, err := exec.Command("go", "build", "-o", bin, "..").CombinedOutput(); err != nil { | |
| 229 | + t.Fatalf("build: %v\n%s", err, out) | |
| 230 | + } | |
| 231 | + for _, kind := range []string{"pre-receive", "proc-receive"} { | |
| 232 | + if err := writeHook(filepath.Join(hooksDir(s.root), kind), hookScript(s.root, "example.org", bin, kind)); err != nil { | |
| 233 | + t.Fatal(err) | |
| 234 | + } | |
| 235 | + } | |
| 236 | + srv := httptest.NewServer(h) | |
| 237 | + defer srv.Close() | |
| 238 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 239 | + bob, _ := s.store.CreateUser("bob", "correct horse") | |
| 240 | + repo, g := newRepo(t, s, alice, "proj", Public) | |
| 241 | + base := commitFile(t, g, g.Head(), "", "a.txt", "one\n") | |
| 242 | + | |
| 243 | + p := pushRig{t: t, s: s, work: t.TempDir()} | |
| 244 | + p.must("init", "-q", "-b", "main") | |
| 245 | + p.must("fetch", "-q", remote(srv.URL, "bob", "alice/proj"), "main") | |
| 246 | + p.must("reset", "-q", "--hard", "FETCH_HEAD") | |
| 247 | + p.commit("b.txt", "two\n", "Add b\n\nBody text") | |
| 248 | + bobURL := remote(srv.URL, "bob", "alice/proj") | |
| 249 | + | |
| 250 | + for _, ref := range []string{"HEAD:refs/heads/main", "HEAD:refs/heads/evil", "HEAD:refs/tags/v1", "HEAD:refs/pull/9/head"} { | |
| 251 | + if out, err := p.git("push", bobURL, ref); err == nil { | |
| 252 | + t.Errorf("non-writer push to %s accepted\n%s", ref, out) | |
| 253 | + } | |
| 254 | + } | |
| 255 | + if head, _ := g.Resolve("refs/heads/main"); head != base { | |
| 256 | + t.Fatal("main moved after refused pushes") | |
| 257 | + } | |
| 258 | + if out, err := p.git("push", "-o", "title=Hello there", bobURL, "HEAD:refs/for/main"); err != nil || !strings.Contains(out, "pulls/1") { | |
| 259 | + t.Fatalf("agit push failed: %v\n%s", err, out) | |
| 260 | + } | |
| 261 | + pull, err := s.store.Pull(repo, 1) | |
| 262 | + if err != nil || pull.Agit != "main" || pull.Author.ID != bob.ID || pull.Title != "Hello there" || pull.TargetBranch != "main" { | |
| 263 | + t.Fatalf("pull request not created as expected: %+v %v", pull, err) | |
| 264 | + } | |
| 265 | + first := strings.TrimSpace(p.must("rev-parse", "HEAD")) | |
| 266 | + if got, _ := g.Resolve(pull.Ref()); got != first { | |
| 267 | + t.Fatalf("pull ref %s, want %s", got, first) | |
| 268 | + } | |
| 269 | + p.commit("b.txt", "three\n", "Add b, again") | |
| 270 | + if out, err := p.git("push", bobURL, "HEAD:refs/for/main"); err != nil || !strings.Contains(out, "updated pull request #1") { | |
| 271 | + t.Fatalf("agit update failed: %v\n%s", err, out) | |
| 272 | + } | |
| 273 | + second := strings.TrimSpace(p.must("rev-parse", "HEAD")) | |
| 274 | + if got, _ := g.Resolve(pull.Ref()); got != second { | |
| 275 | + t.Error("pull ref not updated") | |
| 276 | + } | |
| 277 | + if n := len(g.RecordRevision(pull)); n != 2 { | |
| 278 | + t.Errorf("revisions %d, want 2", n) | |
| 279 | + } | |
| 280 | + if out, err := p.git("push", bobURL, "HEAD:refs/for/main/other"); err != nil { | |
| 281 | + t.Fatalf("topic push failed: %v\n%s", err, out) | |
| 282 | + } | |
| 283 | + if pr, err := s.store.Pull(repo, 2); err != nil || pr.Agit != "other" || pr.Title != "Add b, again" { | |
| 284 | + t.Errorf("topic pull request: %+v %v", pr, err) | |
| 285 | + } | |
| 286 | + if _, err := p.git("push", bobURL, "HEAD:refs/for/nosuch"); err == nil { | |
| 287 | + t.Error("push for a missing branch accepted") | |
| 288 | + } | |
| 289 | + if _, err := p.git("push", bobURL, base+":refs/for/main/stale"); err == nil { | |
| 290 | + t.Error("push without new commits accepted") | |
| 291 | + } | |
| 292 | + | |
| 293 | + aliceURL := remote(srv.URL, "alice", "alice/proj") | |
| 294 | + if _, err := p.git("push", "-f", aliceURL, base+":refs/pull/1/head"); err == nil { | |
| 295 | + t.Error("writer overwrote a pull ref directly") | |
| 296 | + } | |
| 297 | + if _, err := p.git("push", aliceURL, "HEAD:refs/heads/main"); err != nil { | |
| 298 | + t.Error("writer push refused") | |
| 299 | + } | |
| 300 | + | |
| 301 | + own, _ := s.store.CreateSession(alice.ID) | |
| 302 | + if body := get(h, "/~alice/proj/pulls/1", own).Body.String(); !strings.Contains(body, "~bob:main") || strings.Contains(body, "Update from source") { | |
| 303 | + t.Error("agit pull page does not show the topic") | |
| 304 | + } | |
| 305 | + | |
| 306 | + s.store.SetBlock(alice.ID, bob.ID, true) | |
| 307 | + p.commit("c.txt", "c\n", "Add c") | |
| 308 | + if _, err := p.git("push", bobURL, "HEAD:refs/for/main/blocked"); err == nil { | |
| 309 | + t.Error("blocked user opened a pull request") | |
| 310 | + } | |
| 311 | + s.store.SetBlock(alice.ID, bob.ID, false) | |
| 312 | + | |
| 313 | + secret, _ := newRepo(t, s, alice, "secret", Private) | |
| 314 | + commitFile(t, Git{Dir: s.repoDir(secret)}, "main", "", "a.txt", "x\n") | |
| 315 | + if _, err := p.git("push", remote(srv.URL, "bob", "alice/secret"), "HEAD:refs/for/main"); err == nil { | |
| 316 | + t.Error("agit push into a private repository accepted") | |
| 317 | + } | |
| 318 | + | |
| 319 | + s.store.AddMirror(repo.ID, "pull", "https://example.com/x.git", "oauth2", nil, time.Hour) | |
| 320 | + if _, err := p.git("push", aliceURL, "HEAD:refs/heads/mirrored"); err == nil { | |
| 321 | + t.Error("push into a pull mirror accepted") | |
| 322 | + } | |
| 323 | + s.store.db.Exec(`delete from mirrors`) | |
| 324 | + | |
| 325 | + os.Remove(filepath.Join(hooksDir(s.root), "proc-receive")) | |
| 326 | + if _, err := p.git("push", aliceURL, "HEAD:refs/heads/nohooks"); err == nil { | |
| 327 | + t.Error("push accepted without hooks installed") | |
| 328 | + } | |
| 329 | +} | |
| 330 | + | |
| 331 | +func TestProcReceiveParsing(t *testing.T) { | |
| 332 | + var out bytes.Buffer | |
| 333 | + for _, in := range []string{"", "0003", "ffff", "zzzz", "0008abc", "000eversion=2\n0000"} { | |
| 334 | + out.Reset() | |
| 335 | + if procReceive(t.TempDir(), strings.NewReader(in), &out, &out) == 0 { | |
| 336 | + t.Errorf("accepted %q", in) | |
| 337 | + } | |
| 338 | + } | |
| 339 | + var buf bytes.Buffer | |
| 340 | + writePkt(&buf, "version=1\x00push-options") | |
| 341 | + writePkt(&buf, "") | |
| 342 | + writePkt(&buf, zeroOID+" "+strings.Repeat("a", 40)+" refs/for/main") | |
| 343 | + writePkt(&buf, "") | |
| 344 | + writePkt(&buf, "title=x") | |
| 345 | + writePkt(&buf, "") | |
| 346 | + out.Reset() | |
| 347 | + if procReceive(t.TempDir(), &buf, &out, &out) != 0 || !strings.Contains(out.String(), "ng refs/for/main") { | |
| 348 | + t.Errorf("command without gitpocket context not refused: %q", out.String()) | |
| 349 | + } | |
| 350 | +} | |
| 351 | + | |
| 182 | 352 | func TestSeal(t *testing.T) { |
| 183 | 353 | root := t.TempDir() |
| 184 | 354 | box, err := seal(root, "hunter2") |
forge/import.go modified
| @@ -199,7 +199,7 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) { | ||
| 199 | 199 | fail("Could not create the repository") |
| 200 | 200 | return |
| 201 | 201 | } |
| 202 | - installHook(s.root, dir) | |
| 202 | + installHook(s.root, s.host, dir) | |
| 203 | 203 | s.uploads.fail("import:" + u.Name) |
| 204 | 204 | if r.FormValue("mirror") == "on" { |
| 205 | 205 | box, err := seal(s.root, strings.TrimSpace(r.FormValue("token"))) |
forge/pull.go modified
| @@ -67,6 +67,7 @@ type Pull struct { | ||
| 67 | 67 | Merged string |
| 68 | 68 | Comments int |
| 69 | 69 | Draft bool |
| 70 | + Agit string | |
| 70 | 71 | } |
| 71 | 72 | |
| 72 | 73 | type PRReview struct { |
| @@ -165,6 +166,16 @@ func (p Pull) Ref() string { | ||
| 165 | 166 | return fmt.Sprintf("refs/pull/%d/head", p.Number) |
| 166 | 167 | } |
| 167 | 168 | |
| 169 | +func (p Pull) Source() string { | |
| 170 | + if p.Agit != "" { | |
| 171 | + return "~" + p.Author.Name + ":" + p.Agit | |
| 172 | + } | |
| 173 | + if p.SameRepo() { | |
| 174 | + return p.SourceBranch | |
| 175 | + } | |
| 176 | + return p.SourceRepo.Path() + ":" + p.SourceBranch | |
| 177 | +} | |
| 178 | + | |
| 168 | 179 | func (p Pull) SameRepo() bool { |
| 169 | 180 | return p.SourceRepo.ID == p.Repo.ID |
| 170 | 181 | } |
| @@ -202,7 +213,7 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) { | ||
| 202 | 213 | var created, updated, aCreated int64 |
| 203 | 214 | var sourceID int64 |
| 204 | 215 | var draft int |
| 205 | - if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &draft, &p.Author.ID, &p.Author.Name, &aCreated); err != nil { | |
| 216 | + if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &draft, &p.Agit, &p.Author.ID, &p.Author.Name, &aCreated); err != nil { | |
| 206 | 217 | return nil, err |
| 207 | 218 | } |
| 208 | 219 | p.Draft = draft != 0 |
| @@ -214,7 +225,7 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) { | ||
| 214 | 225 | } |
| 215 | 226 | |
| 216 | 227 | const pullColumns = `p.id, p.number, p.title, p.body, p.source_repo, p.source_branch, p.target_branch, p.state, p.created, p.updated, p.merged, |
| 217 | - (select count(*) from comments c where c.pull = p.id), p.draft, u.id, u.name, u.created from pulls p join users u on u.id = p.author` | |
| 228 | + (select count(*) from comments c where c.pull = p.id), p.draft, p.agit, u.id, u.name, u.created from pulls p join users u on u.id = p.author` | |
| 218 | 229 | |
| 219 | 230 | func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) { |
| 220 | 231 | rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and (? = '' or p.state = ?) order by p.number desc`, repo.ID, state, state) |
| @@ -661,7 +672,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 661 | 672 | } |
| 662 | 673 | back += "#diff" |
| 663 | 674 | case "update": |
| 664 | - if (isOwner || isAuthor) && pull.State == "open" { | |
| 675 | + if (isOwner || isAuthor) && pull.State == "open" && pull.Agit == "" { | |
| 665 | 676 | if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil { |
| 666 | 677 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 667 | 678 | return |
| @@ -842,6 +853,9 @@ func (s *Server) notifyPull(pull Pull, actor User, verb, body string) { | ||
| 842 | 853 | } |
| 843 | 854 | |
| 844 | 855 | func mergeTitle(pull Pull) string { |
| 856 | + if pull.Agit != "" { | |
| 857 | + return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.Source()) | |
| 858 | + } | |
| 845 | 859 | return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch) |
| 846 | 860 | } |
| 847 | 861 | |
| @@ -880,7 +894,7 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 880 | 894 | s.fail(w, r, 500, "Could not create the repository") |
| 881 | 895 | return |
| 882 | 896 | } |
| 883 | - installHook(s.root, dir) | |
| 897 | + installHook(s.root, s.host, dir) | |
| 884 | 898 | http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) |
| 885 | 899 | } |
| 886 | 900 | |
forge/quota.go modified
| @@ -68,11 +68,20 @@ func writeQuota(root string, n int64) error { | ||
| 68 | 68 | return os.WriteFile(filepath.Join(root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644) |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | -func pushLimit(root, owner string) []string { | |
| 71 | +const guestPush = 20 << 20 | |
| 72 | + | |
| 73 | +func pushLimit(root, owner string, writer bool) []string { | |
| 74 | + limit := int64(0) | |
| 72 | 75 | if over, used, quota := overQuota(root, owner); quota > 0 && !over { |
| 73 | - return []string{"-c", "receive.maxInputSize=" + strconv.FormatInt(quota-used, 10)} | |
| 76 | + limit = quota - used | |
| 77 | + } | |
| 78 | + if !writer && (limit == 0 || limit > guestPush) { | |
| 79 | + limit = guestPush | |
| 80 | + } | |
| 81 | + if limit == 0 { | |
| 82 | + return nil | |
| 74 | 83 | } |
| 75 | - return nil | |
| 84 | + return []string{"-c", "receive.maxInputSize=" + strconv.FormatInt(limit, 10)} | |
| 76 | 85 | } |
| 77 | 86 | |
| 78 | 87 | func compactSize(n int64) string { |
forge/shell.go modified
| @@ -70,8 +70,8 @@ func shell(root, name string) int { | ||
| 70 | 70 | if repo.Private && !canWrite { |
| 71 | 71 | return fail("repository not found") |
| 72 | 72 | } |
| 73 | - if verb == "git-receive-pack" && !canWrite { | |
| 74 | - return fail("you do not have write access to " + repo.Path()) | |
| 73 | + if verb == "git-receive-pack" && !hooksReady(root) { | |
| 74 | + return fail("pushes are unavailable right now") | |
| 75 | 75 | } |
| 76 | 76 | if verb == "git-receive-pack" && repo.Archived { |
| 77 | 77 | return fail("repository is archived and read only") |
| @@ -92,7 +92,8 @@ func shell(root, name string) int { | ||
| 92 | 92 | } |
| 93 | 93 | argv := []string{"git"} |
| 94 | 94 | if verb == "git-receive-pack" { |
| 95 | - argv = append(argv, pushLimit(root, repo.Owner.Name)...) | |
| 95 | + argv = append(append(argv, receiveArgs(root)...), pushLimit(root, repo.Owner.Name, canWrite)...) | |
| 96 | + env = append(env, receiveEnv(repo, user)...) | |
| 96 | 97 | } |
| 97 | 98 | err = syscall.Exec(git, append(argv, strings.TrimPrefix(verb, "git-"), dir), env) |
| 98 | 99 | return fail(err.Error()) |
forge/smart.go modified
| @@ -55,6 +55,8 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 55 | 55 | return |
| 56 | 56 | } |
| 57 | 57 | needAuth := service == "git-receive-pack" || repo.Private |
| 58 | + var pusher User | |
| 59 | + writer := false | |
| 58 | 60 | if needAuth { |
| 59 | 61 | ip := clientIP(r) |
| 60 | 62 | if s.limiter.blocked(ip) { |
| @@ -62,7 +64,7 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 62 | 64 | return |
| 63 | 65 | } |
| 64 | 66 | u, ok := s.basicUser(r) |
| 65 | - if !ok || !s.store.CanWrite(repo, &u) { | |
| 67 | + if !ok || !s.store.CanRead(repo, &u) { | |
| 66 | 68 | if _, _, given := r.BasicAuth(); given { |
| 67 | 69 | s.limiter.fail(ip) |
| 68 | 70 | } |
| @@ -70,6 +72,11 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 70 | 72 | http.Error(w, "authentication required", 401) |
| 71 | 73 | return |
| 72 | 74 | } |
| 75 | + pusher, writer = u, s.store.CanWrite(repo, &u) | |
| 76 | + } | |
| 77 | + if service == "git-receive-pack" && !hooksReady(s.root) { | |
| 78 | + http.Error(w, "pushes are unavailable right now", 503) | |
| 79 | + return | |
| 73 | 80 | } |
| 74 | 81 | if service == "git-receive-pack" && repo.Archived { |
| 75 | 82 | http.Error(w, "repository is archived and read only", 403) |
| @@ -86,7 +93,12 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 86 | 93 | ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) |
| 87 | 94 | defer cancel() |
| 88 | 95 | if rest == "info/refs" { |
| 89 | - cmd := exec.CommandContext(ctx, "git", strings.TrimPrefix(service, "git-"), "--stateless-rpc", "--advertise-refs", dir) | |
| 96 | + argv := []string{strings.TrimPrefix(service, "git-"), "--stateless-rpc", "--advertise-refs", dir} | |
| 97 | + if service == "git-receive-pack" { | |
| 98 | + argv = append(receiveArgs(s.root), argv...) | |
| 99 | + } | |
| 100 | + cmd := exec.CommandContext(ctx, "git", argv...) | |
| 101 | + cmd.Env = []string{"HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} | |
| 90 | 102 | out, err := cmd.Output() |
| 91 | 103 | if err != nil { |
| 92 | 104 | http.Error(w, "git failed", 500) |
| @@ -109,14 +121,20 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 109 | 121 | } |
| 110 | 122 | argv := []string{strings.TrimPrefix(service, "git-"), "--stateless-rpc", dir} |
| 111 | 123 | if service == "git-receive-pack" { |
| 112 | - argv = append(pushLimit(s.root, repo.Owner.Name), argv...) | |
| 124 | + argv = append(append(receiveArgs(s.root), pushLimit(s.root, repo.Owner.Name, writer)...), argv...) | |
| 113 | 125 | if over, used, quota := overQuota(s.root, repo.Owner.Name); quota > 0 && !over { |
| 114 | 126 | body = io.LimitReader(body, quota-used+1<<20) |
| 115 | 127 | } |
| 128 | + if !writer { | |
| 129 | + body = io.LimitReader(body, guestPush+1<<20) | |
| 130 | + } | |
| 116 | 131 | } |
| 117 | 132 | cmd := exec.CommandContext(ctx, "git", argv...) |
| 118 | 133 | cmd.Stdin = body |
| 119 | 134 | cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} |
| 135 | + if service == "git-receive-pack" { | |
| 136 | + cmd.Env = append(cmd.Env, receiveEnv(repo, pusher)...) | |
| 137 | + } | |
| 120 | 138 | stdout, err := cmd.StdoutPipe() |
| 121 | 139 | if err != nil { |
| 122 | 140 | http.Error(w, "git failed", 500) |
forge/store.go modified
| @@ -83,6 +83,7 @@ var migrations = []string{ | ||
| 83 | 83 | `alter table repos add column pinned integer not null default 0`, |
| 84 | 84 | `alter table users add column theme text not null default ''`, |
| 85 | 85 | `alter table users add column pages integer not null default 1`, |
| 86 | + `alter table pulls add column agit text not null default ''`, | |
| 86 | 87 | } |
| 87 | 88 | |
| 88 | 89 | const extraSchema = ` |
forge/web.go modified
| @@ -489,7 +489,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) { | ||
| 489 | 489 | s.fail(w, r, 500, "Could not create the repository") |
| 490 | 490 | return |
| 491 | 491 | } |
| 492 | - installHook(s.root, dir) | |
| 492 | + installHook(s.root, s.host, dir) | |
| 493 | 493 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 494 | 494 | } |
| 495 | 495 | |
web/templates/pull.tmpl modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$pr := .Data.Pull}} |
| 4 | 4 | <div class="pulltitle"> |
| 5 | 5 | <h3 class="plain"><span class="state {{$pr.State}}{{if $pr.Draft}} draft{{end}}">{{if and $pr.Draft (eq $pr.State "open")}}draft{{else}}{{$pr.State}}{{end}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3> |
| 6 | -<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if .User}} · <a class="muted" href="/report?kind=pull&target={{$p.Repo.Path}}%23{{$pr.Number}}">report</a>{{end}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p> | |
| 6 | +<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{$pr.Source}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if .User}} · <a class="muted" href="/report?kind=pull&target={{$p.Repo.Path}}%23{{$pr.Number}}">report</a>{{end}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns pullbody"> |
| 9 | 9 | <div class="main"> |
| @@ -45,7 +45,7 @@ | ||
| 45 | 45 | <div class="actions left"><button class="btn primary">Confirm merge ▸</button><a class="btn" href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}">Cancel</a></div> |
| 46 | 46 | </form> |
| 47 | 47 | {{else}}{{if .Data.Mergeable}}<a class="btn primary block" href="?merge=1">Merge pull request ▸</a>{{else}}<button class="btn primary block" disabled>Merge pull request ▸</button>{{end}}{{end}}{{end}} |
| 48 | -{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/update"><button class="btn block">Update from source ▸</button></form> | |
| 48 | +{{if or .Data.Owner .Data.Author}}{{if not $pr.Agit}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/update"><button class="btn block">Update from source ▸</button></form>{{end}} | |
| 49 | 49 | <form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/close"><button class="btn danger block">Close ▸</button></form>{{end}} |
| 50 | 50 | {{else if eq $pr.State "closed"}}<p>Closed without merging.</p>{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}} |
| 51 | 51 | {{else}}<p>Merged into <code>{{$pr.TargetBranch}}</code>.</p>{{end}} |
web/templates/pulls.tmpl modified
| @@ -6,5 +6,5 @@ | ||
| 6 | 6 | <span><a class="muted small feedlink" href="/{{$p.Repo.Path}}/pulls/feed">atom feed</a>{{if $p.User}} <a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}}</span> |
| 7 | 7 | </div> |
| 8 | 8 | {{if .Data.Labels}}<p class="labelbar">{{range .Data.Labels}}<a class="label{{if eq .ID $.Data.Filter}} on{{end}}" style="background:{{.Colour}}" href="?state={{$.Data.State}}&label={{.ID}}">{{.Name}}</a> {{end}}{{if .Data.Filter}}<a class="muted small" href="?state={{.Data.State}}">clear</a>{{end}}</p>{{end}} |
| 9 | -{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}{{if .Draft}} draft{{end}}">{{if and .Draft (eq .State "open")}}draft{{else}}{{.State}}{{end}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span>{{range index $.Data.ItemLabels .ID}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h4><p class="muted small">{{if .SameRepo}}{{.SourceBranch}}{{else}}{{.SourceRepo.Path}}:{{.SourceBranch}}{{end}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}} | |
| 9 | +{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}{{if .Draft}} draft{{end}}">{{if and .Draft (eq .State "open")}}draft{{else}}{{.State}}{{end}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span>{{range index $.Data.ItemLabels .ID}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h4><p class="muted small">{{.Source}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}} | |
| 10 | 10 | {{end}} |