feat(web): pull request range-diff, pickaxe history search, reproducible tarballs and atom feeds
15 files changed, +296 −20
- README.md modified +4 −1
- extra.go modified +131 −5
- git.go modified +3 −2
- pull.go modified +97 −1
- static/style.css modified +16 −0
- store.go modified +2 −2
- templates/interdiff.html added +7 −0
- templates/issues.html modified +1 −1
- templates/log.html modified +3 −1
- templates/pull.html modified +3 −0
- templates/pulls.html modified +1 −1
- templates/refs.html modified +1 −1
- templates/repo.html modified +1 −1
- templates/user.html modified +1 −1
- web.go modified +25 −3
README.md modified
| @@ -15,7 +15,10 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 15 | 15 | - Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp` |
| 16 | 16 | - Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip |
| 17 | 17 | - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title |
| 18 | -- Blame, a compare page for any two refs, tar.gz and zip downloads of any ref from `git archive`, and an Atom feed of commits per branch | |
| 18 | +- Blame, a compare page for any two refs, and tar.gz and zip downloads of any ref. The tar.gz is `git archive --format=tar` passed through a gzip writer with a zeroed header and a fixed level, so the same commit always produces the same bytes and `archive/<ref>.tar.gz.sha256` publishes the checksum that a package recipe can pin | |
| 19 | +- Every pushed revision of a pull request is kept under `refs/pull/<n>/v<k>`, and the request page links a `git range-diff` between any two of them, so a reviewer sees what a force push actually changed in each commit instead of rereading the whole series | |
| 20 | +- History search on the log page through git's pickaxe, either commits that change how often a string occurs (`-S`) or commits whose diff has a line matching a regex (`-G`), which answers when a line arrived or vanished without cloning | |
| 21 | +- Atom feeds of commits per branch, of issues and of pull requests per repository, and of every public commit by a user at `~you/feed`, which stand in for notifications when mail is not configured | |
| 19 | 22 | - Rename, transfer to another user and archive from the repository settings. An archived repository refuses pushes, pull requests and comments but stays readable |
| 20 | 23 | - An optional email address on each account, verified by a link, used for password resets and for notifications about pull requests, reviews and issues on your repositories. Both can be turned off and nothing works without the address, so the whole thing is opt in |
| 21 | 24 | - TOTP two factor authentication with a QR code for the authenticator app and eight single use recovery codes, which is what a password alone cannot give you on something that accepts pushes |
extra.go modified
| @@ -1,14 +1,18 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "compress/gzip" | |
| 4 | 5 | "context" |
| 6 | + "crypto/sha256" | |
| 5 | 7 | "encoding/xml" |
| 6 | 8 | "fmt" |
| 7 | 9 | "html/template" |
| 10 | + "io" | |
| 8 | 11 | "net/http" |
| 9 | 12 | "os" |
| 10 | 13 | "os/exec" |
| 11 | 14 | "path/filepath" |
| 15 | + "slices" | |
| 12 | 16 | "strconv" |
| 13 | 17 | "strings" |
| 14 | 18 | "time" |
| @@ -231,6 +235,8 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request, repo Repo, spec | ||
| 231 | 235 | |
| 232 | 236 | func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { |
| 233 | 237 | g := Git{Dir: s.repoDir(repo)} |
| 238 | + digest := strings.HasSuffix(spec, ".sha256") | |
| 239 | + spec = strings.TrimSuffix(spec, ".sha256") | |
| 234 | 240 | format := "" |
| 235 | 241 | switch { |
| 236 | 242 | case strings.HasSuffix(spec, ".tar.gz"): |
| @@ -248,14 +254,50 @@ func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spe | ||
| 248 | 254 | } |
| 249 | 255 | safe := strings.NewReplacer("/", "-", "\\", "-").Replace(spec) |
| 250 | 256 | name := repo.Name + "-" + safe |
| 257 | + file := name + "." + format | |
| 251 | 258 | ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) |
| 252 | 259 | defer cancel() |
| 253 | - cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "archive", "--format="+format, "--prefix="+name+"/", commit) | |
| 254 | - cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} | |
| 260 | + if digest { | |
| 261 | + sum := sha256.New() | |
| 262 | + if err := writeArchive(ctx, sum, g.Dir, format, name, commit); err != nil { | |
| 263 | + s.fail(w, r, 500, "Could not build the archive") | |
| 264 | + return | |
| 265 | + } | |
| 266 | + w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 267 | + fmt.Fprintf(w, "%x %s\n", sum.Sum(nil), file) | |
| 268 | + return | |
| 269 | + } | |
| 270 | + w.Header().Set("ETag", `"`+commit+"-"+format+`"`) | |
| 271 | + if commit == spec { | |
| 272 | + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 273 | + } | |
| 274 | + if r.Header.Get("If-None-Match") == `"`+commit+"-"+format+`"` { | |
| 275 | + w.WriteHeader(http.StatusNotModified) | |
| 276 | + return | |
| 277 | + } | |
| 255 | 278 | w.Header().Set("Content-Type", "application/octet-stream") |
| 256 | - w.Header().Set("Content-Disposition", `attachment; filename="`+name+"."+format+`"`) | |
| 257 | - cmd.Stdout = w | |
| 258 | - cmd.Run() | |
| 279 | + w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`) | |
| 280 | + writeArchive(ctx, w, g.Dir, format, name, commit) | |
| 281 | +} | |
| 282 | + | |
| 283 | +func writeArchive(ctx context.Context, w io.Writer, dir, format, name, commit string) error { | |
| 284 | + gitFormat := format | |
| 285 | + if format == "tar.gz" { | |
| 286 | + gitFormat = "tar" | |
| 287 | + } | |
| 288 | + cmd := exec.CommandContext(ctx, "git", "-C", dir, "-c", "core.autocrlf=false", "archive", "--format="+gitFormat, "--prefix="+name+"/", commit) | |
| 289 | + cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "TZ=UTC", "PATH=" + os.Getenv("PATH")} | |
| 290 | + if format != "tar.gz" { | |
| 291 | + cmd.Stdout = w | |
| 292 | + return cmd.Run() | |
| 293 | + } | |
| 294 | + zw, _ := gzip.NewWriterLevel(w, gzip.BestCompression) | |
| 295 | + zw.OS = 255 | |
| 296 | + cmd.Stdout = zw | |
| 297 | + if err := cmd.Run(); err != nil { | |
| 298 | + return err | |
| 299 | + } | |
| 300 | + return zw.Close() | |
| 259 | 301 | } |
| 260 | 302 | |
| 261 | 303 | type atomFeed struct { |
| @@ -298,7 +340,91 @@ func (s *Server) feed(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 298 | 340 | feed.Updated = commits[0].Time.UTC().Format(time.RFC3339) |
| 299 | 341 | } |
| 300 | 342 | } |
| 343 | + writeFeed(w, feed) | |
| 344 | +} | |
| 345 | + | |
| 346 | +func writeFeed(w http.ResponseWriter, feed atomFeed) { | |
| 347 | + feed.NS = "http://www.w3.org/2005/Atom" | |
| 348 | + if feed.Updated == "" { | |
| 349 | + feed.Updated = time.Unix(0, 0).UTC().Format(time.RFC3339) | |
| 350 | + } | |
| 301 | 351 | w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") |
| 302 | 352 | w.Write([]byte(xml.Header)) |
| 303 | 353 | xml.NewEncoder(w).Encode(feed) |
| 304 | 354 | } |
| 355 | + | |
| 356 | +func stamp(t time.Time) string { | |
| 357 | + return t.UTC().Format(time.RFC3339) | |
| 358 | +} | |
| 359 | + | |
| 360 | +func (s *Server) issueFeed(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 361 | + issues, _ := s.store.Issues(repo.ID, "") | |
| 362 | + base := "https://" + s.host + "/" + repo.Path() + "/issues" | |
| 363 | + feed := atomFeed{Title: repo.Path() + " issues", ID: base + "/feed", Link: atomLink{Href: base}} | |
| 364 | + for i, is := range issues { | |
| 365 | + if i == 50 { | |
| 366 | + break | |
| 367 | + } | |
| 368 | + if feed.Updated == "" { | |
| 369 | + feed.Updated = stamp(is.Updated) | |
| 370 | + } | |
| 371 | + link := fmt.Sprintf("%s/%d", base, is.Number) | |
| 372 | + feed.Entries = append(feed.Entries, atomEntry{Title: fmt.Sprintf("#%d %s [%s]", is.Number, is.Title, is.State), ID: link, Updated: stamp(is.Updated), Author: "~" + is.Author.Name, Link: atomLink{Href: link}, Content: is.Body}) | |
| 373 | + } | |
| 374 | + writeFeed(w, feed) | |
| 375 | +} | |
| 376 | + | |
| 377 | +func (s *Server) pullFeed(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 378 | + pulls, _ := s.store.Pulls(repo, "") | |
| 379 | + slices.SortStableFunc(pulls, func(a, b Pull) int { return b.Updated.Compare(a.Updated) }) | |
| 380 | + base := "https://" + s.host + "/" + repo.Path() + "/pulls" | |
| 381 | + feed := atomFeed{Title: repo.Path() + " pull requests", ID: base + "/feed", Link: atomLink{Href: base}} | |
| 382 | + for i, p := range pulls { | |
| 383 | + if i == 50 { | |
| 384 | + break | |
| 385 | + } | |
| 386 | + if feed.Updated == "" { | |
| 387 | + feed.Updated = stamp(p.Updated) | |
| 388 | + } | |
| 389 | + link := fmt.Sprintf("%s/%d", base, p.Number) | |
| 390 | + feed.Entries = append(feed.Entries, atomEntry{Title: fmt.Sprintf("#%d %s [%s]", p.Number, p.Title, p.State), ID: link, Updated: stamp(p.Updated), Author: "~" + p.Author.Name, Link: atomLink{Href: link}, Content: p.Body}) | |
| 391 | + } | |
| 392 | + writeFeed(w, feed) | |
| 393 | +} | |
| 394 | + | |
| 395 | +func (s *Server) userFeed(w http.ResponseWriter, r *http.Request, owner User) { | |
| 396 | + repos, _ := s.store.ReposByOwner(owner.ID, false) | |
| 397 | + type entry struct { | |
| 398 | + repo Repo | |
| 399 | + commit Commit | |
| 400 | + } | |
| 401 | + var all []entry | |
| 402 | + for _, repo := range repos { | |
| 403 | + if repo.Unlisted { | |
| 404 | + continue | |
| 405 | + } | |
| 406 | + g := Git{Dir: s.repoDir(repo)} | |
| 407 | + head, err := g.Resolve("HEAD") | |
| 408 | + if err != nil { | |
| 409 | + continue | |
| 410 | + } | |
| 411 | + commits, _ := g.Log(head, 20, "") | |
| 412 | + for _, c := range commits { | |
| 413 | + all = append(all, entry{repo, c}) | |
| 414 | + } | |
| 415 | + } | |
| 416 | + slices.SortStableFunc(all, func(a, b entry) int { return b.commit.Time.Compare(a.commit.Time) }) | |
| 417 | + base := "https://" + s.host + "/~" + owner.Name | |
| 418 | + feed := atomFeed{Title: "~" + owner.Name + " commits", ID: base + "/feed", Link: atomLink{Href: base}} | |
| 419 | + for i, e := range all { | |
| 420 | + if i == 50 { | |
| 421 | + break | |
| 422 | + } | |
| 423 | + if feed.Updated == "" { | |
| 424 | + feed.Updated = stamp(e.commit.Time) | |
| 425 | + } | |
| 426 | + link := "https://" + s.host + "/" + e.repo.Path() + "/commit/" + e.commit.Hash | |
| 427 | + feed.Entries = append(feed.Entries, atomEntry{Title: e.repo.Name + ": " + e.commit.Subject, ID: link, Updated: stamp(e.commit.Time), Author: e.commit.Author, Link: atomLink{Href: link}, Content: e.commit.Body}) | |
| 428 | + } | |
| 429 | + writeFeed(w, feed) | |
| 430 | +} | |
git.go modified
| @@ -332,8 +332,9 @@ func parseCommits(out []byte) []Commit { | ||
| 332 | 332 | return commits |
| 333 | 333 | } |
| 334 | 334 | |
| 335 | -func (g Git) Log(commit string, n int, filePath string) ([]Commit, error) { | |
| 336 | - args := []string{"log", "--format=" + logFormat, "-n", strconv.Itoa(n), commit} | |
| 335 | +func (g Git) Log(commit string, n int, filePath string, extra ...string) ([]Commit, error) { | |
| 336 | + args := append([]string{"log", "--format=" + logFormat, "-n", strconv.Itoa(n)}, extra...) | |
| 337 | + args = append(args, commit) | |
| 337 | 338 | if filePath != "" { |
| 338 | 339 | args = append(args, "--", filePath) |
| 339 | 340 | } |
pull.go modified
| @@ -525,6 +525,7 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 525 | 525 | retry("Could not fetch the source branch: " + err.Error()) |
| 526 | 526 | return |
| 527 | 527 | } |
| 528 | + g.RecordRevision(pull) | |
| 528 | 529 | url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number) |
| 529 | 530 | if repo.Owner.ID != user.ID { |
| 530 | 531 | s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title), |
| @@ -634,6 +635,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 634 | 635 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 635 | 636 | return |
| 636 | 637 | } |
| 638 | + g.RecordRevision(pull) | |
| 637 | 639 | s.store.TouchPull(pull.ID) |
| 638 | 640 | } |
| 639 | 641 | case "merge": |
| @@ -689,7 +691,12 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st | ||
| 689 | 691 | s.fail(w, r, 500, "Pull request head is missing") |
| 690 | 692 | return |
| 691 | 693 | } |
| 692 | - data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor, | |
| 694 | + revisions := g.RecordRevision(pull) | |
| 695 | + if action == "interdiff" { | |
| 696 | + s.interdiff(w, r, rp, g, pull, revisions) | |
| 697 | + return | |
| 698 | + } | |
| 699 | + data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor, "Revisions": revisions, | |
| 693 | 700 | "Body": s.renderMarkdown([]byte(pull.Body)), "Labels": s.store.ItemLabels("pull", pull.ID), "AllLabels": nil, "Assignees": s.store.Assignees("pull", pull.ID)} |
| 694 | 701 | if isOwner { |
| 695 | 702 | data["AllLabels"], _ = s.store.Labels(repo.ID) |
| @@ -829,3 +836,92 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 829 | 836 | installHook(s.root, dir) |
| 830 | 837 | http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther) |
| 831 | 838 | } |
| 839 | + | |
| 840 | +func (p Pull) revisionRef(n int) string { | |
| 841 | + return fmt.Sprintf("refs/pull/%d/v%d", p.Number, n) | |
| 842 | +} | |
| 843 | + | |
| 844 | +func (g Git) RecordRevision(p Pull) []string { | |
| 845 | + var revs []string | |
| 846 | + for n := 1; ; n++ { | |
| 847 | + h, err := g.Resolve(p.revisionRef(n)) | |
| 848 | + if err != nil { | |
| 849 | + break | |
| 850 | + } | |
| 851 | + revs = append(revs, h) | |
| 852 | + } | |
| 853 | + head, err := g.Resolve(p.Ref()) | |
| 854 | + if err != nil || (len(revs) > 0 && revs[len(revs)-1] == head) { | |
| 855 | + return revs | |
| 856 | + } | |
| 857 | + if _, err := g.run(nil, "update-ref", p.revisionRef(len(revs)+1), head, ""); err != nil { | |
| 858 | + return revs | |
| 859 | + } | |
| 860 | + return append(revs, head) | |
| 861 | +} | |
| 862 | + | |
| 863 | +type rangeLine struct { | |
| 864 | + Class string | |
| 865 | + Text string | |
| 866 | +} | |
| 867 | + | |
| 868 | +func (s *Server) interdiff(w http.ResponseWriter, r *http.Request, rp repoPage, g Git, pull Pull, revs []string) { | |
| 869 | + from, _ := strconv.Atoi(r.URL.Query().Get("from")) | |
| 870 | + to, _ := strconv.Atoi(r.URL.Query().Get("to")) | |
| 871 | + if to == 0 { | |
| 872 | + to = len(revs) | |
| 873 | + } | |
| 874 | + if from == 0 { | |
| 875 | + from = to - 1 | |
| 876 | + } | |
| 877 | + if from < 1 || to > len(revs) || from >= to { | |
| 878 | + s.fail(w, r, 404, "No such pair of revisions") | |
| 879 | + return | |
| 880 | + } | |
| 881 | + target := pull.TargetBranch | |
| 882 | + if pull.State == "merged" && pull.Merged != "" { | |
| 883 | + target = pull.Merged + "^1" | |
| 884 | + } | |
| 885 | + baseA, errA := g.MergeBase(target, revs[from-1]) | |
| 886 | + baseB, errB := g.MergeBase(target, revs[to-1]) | |
| 887 | + if errA != nil || errB != nil { | |
| 888 | + s.fail(w, r, 500, "These revisions share no history with the target") | |
| 889 | + return | |
| 890 | + } | |
| 891 | + out, err := g.run(nil, "range-diff", "--no-color", "--creation-factor=80", baseA+".."+revs[from-1], baseB+".."+revs[to-1]) | |
| 892 | + if err != nil { | |
| 893 | + s.fail(w, r, 500, "range-diff failed") | |
| 894 | + return | |
| 895 | + } | |
| 896 | + var lines []rangeLine | |
| 897 | + for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { | |
| 898 | + class := "" | |
| 899 | + if body, ok := strings.CutPrefix(l, " "); ok { | |
| 900 | + switch { | |
| 901 | + case strings.HasPrefix(body, "@@"): | |
| 902 | + class = "hunk" | |
| 903 | + case strings.HasPrefix(body, "+"): | |
| 904 | + class = "add" | |
| 905 | + case strings.HasPrefix(body, "-"): | |
| 906 | + class = "del" | |
| 907 | + } | |
| 908 | + } else { | |
| 909 | + fields := strings.Fields(l) | |
| 910 | + if len(fields) >= 3 { | |
| 911 | + switch fields[2] { | |
| 912 | + case "=": | |
| 913 | + class = "same" | |
| 914 | + case "!": | |
| 915 | + class = "changed" | |
| 916 | + case ">": | |
| 917 | + class = "new" | |
| 918 | + case "<": | |
| 919 | + class = "gone" | |
| 920 | + } | |
| 921 | + } | |
| 922 | + } | |
| 923 | + lines = append(lines, rangeLine{class, l}) | |
| 924 | + } | |
| 925 | + rp.Tab = "pulls" | |
| 926 | + s.render(w, r, "interdiff", page{Title: fmt.Sprintf("%s #%d v%d to v%d", rp.Repo.Path(), pull.Number, from, to), Data: map[string]any{"Page": rp, "Pull": pull, "Revisions": revs, "From": from, "To": to, "Lines": lines}}) | |
| 927 | +} | |
static/style.css modified
| @@ -310,3 +310,19 @@ h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem} | ||
| 310 | 310 | @media (max-width:700px){.featuredgrid{grid-template-columns:1fr}.landing .more .btn{min-width:0;width:100%}} |
| 311 | 311 | .readme video,.readme img{max-width:100%;height:auto} |
| 312 | 312 | .auth{white-space:nowrap} |
| 313 | +.pickaxe{display:flex;gap:.5rem;margin:0 0 1rem;flex-wrap:wrap}.pickaxe input{flex:1;min-width:14rem} | |
| 314 | +.pickaxe select{font:inherit;color:var(--fg);background:#fff;border:1px solid #ced4da;padding:.3rem .4rem} | |
| 315 | +.feedlink{margin-right:.6rem} | |
| 316 | +.revisions{display:flex;flex-direction:column;gap:.2rem;margin:0 0 1rem} | |
| 317 | +.revpick{display:flex;gap:.4rem;flex-wrap:wrap;margin:0 0 .8rem} | |
| 318 | +.revpick a{padding:.15rem .5rem;border:1px solid var(--line);color:var(--fg)} | |
| 319 | +.revpick a.active{background:var(--soft);border-color:#999} | |
| 320 | +pre.rangediff{line-height:1.45} | |
| 321 | +.rangediff span{display:block} | |
| 322 | +.rangediff .add{background:var(--addbg)} | |
| 323 | +.rangediff .del{background:var(--delbg)} | |
| 324 | +.rangediff .hunk{color:var(--muted)} | |
| 325 | +.rangediff .same{color:var(--muted)} | |
| 326 | +.rangediff .changed{font-weight:700;color:#8a5a00} | |
| 327 | +.rangediff .new{font-weight:700;color:var(--add)} | |
| 328 | +.rangediff .gone{font-weight:700;color:var(--del)} | |
store.go modified
| @@ -527,7 +527,7 @@ func (s *Store) UserByToken(name, token string) (User, bool) { | ||
| 527 | 527 | } |
| 528 | 528 | |
| 529 | 529 | func (s *Store) CreateRepo(owner User, name, description string, visibility int, rawTags string) (Repo, error) { |
| 530 | - if !validName(name) || name == "avatar" || name == "stars" { | |
| 530 | + if !validName(name) || name == "avatar" || name == "stars" || name == "feed" { | |
| 531 | 531 | return Repo{}, errors.New("Repository names are lowercase letters, digits, dot, dash or underscore") |
| 532 | 532 | } |
| 533 | 533 | tags, err := parseTags(rawTags) |
| @@ -915,7 +915,7 @@ func (s *Store) DeleteUser(name string) error { | ||
| 915 | 915 | } |
| 916 | 916 | |
| 917 | 917 | func (s *Store) RenameRepo(id int64, name string) error { |
| 918 | - if !validName(name) || name == "avatar" || name == "stars" { | |
| 918 | + if !validName(name) || name == "avatar" || name == "stars" || name == "feed" { | |
| 919 | 919 | return errors.New("Repository names are lowercase letters, digits, dot, dash or underscore") |
| 920 | 920 | } |
| 921 | 921 | _, err := s.db.Exec(`update repos set name = ? where id = ?`, name, id) |
templates/interdiff.html added
| @@ -0,0 +1,7 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$p := .Data.Page}}{{$pr := .Data.Pull}} | |
| 3 | +<h3 class="plain"><a href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}">{{$pr.Title}}</a> <span class="muted">#{{$pr.Number}}</span></h3> | |
| 4 | +<p class="muted">How the series changed between revision {{.Data.From}} (<code>{{short (index .Data.Revisions (sub .Data.From 1))}}</code>) and revision {{.Data.To}} (<code>{{short (index .Data.Revisions (sub .Data.To 1))}}</code>), computed with <code>git range-diff</code>. Lines marked <code>=</code> are unchanged commits, <code>!</code> were rewritten, <code>></code> are new and <code><</code> were dropped.</p> | |
| 5 | +<nav class="revpick">{{range $i, $h := .Data.Revisions}}{{$n := add $i 1}}{{if gt $n 1}}<a href="?from={{sub $n 1}}&to={{$n}}"{{if and (eq $.Data.To $n) (eq $.Data.From (sub $n 1))}} class="active"{{end}}>v{{sub $n 1}} → v{{$n}}</a>{{end}}{{end}}{{if gt (len .Data.Revisions) 2}}<a href="?from=1&to={{len .Data.Revisions}}"{{if and (eq .Data.From 1) (eq .Data.To (len .Data.Revisions))}} class="active"{{end}}>v1 → v{{len .Data.Revisions}}</a>{{end}}</nav> | |
| 6 | +<pre class="rangediff">{{range .Data.Lines}}<span class="{{.Class}}">{{.Text}}</span>{{end}}</pre> | |
| 7 | +{{end}} | |
templates/issues.html modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | <div class="pullhead"> |
| 4 | 4 | <nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav> |
| 5 | -{{if and $p.User (not $p.Repo.Archived)}}<a class="btn primary" href="/{{$p.Repo.Path}}/issues/new">New issue ▸</a>{{end}} | |
| 5 | +<span><a class="muted small feedlink" href="/{{$p.Repo.Path}}/issues/feed">atom feed</a>{{if and $p.User (not $p.Repo.Archived)}} <a class="btn primary" href="/{{$p.Repo.Path}}/issues/new">New issue ▸</a>{{end}}</span> | |
| 6 | 6 | </div> |
| 7 | 7 | {{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}} |
| 8 | 8 | {{if .Data.Issues}}<div class="events">{{range .Data.Issues}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/issues/{{.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">opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}} · updated {{ago .Updated}}</p></div>{{end}}</div>{{else}}<p class="info">No issues here.</p>{{end}} |
templates/log.html modified
| @@ -2,8 +2,10 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{if .Data.Path}}<p class="muted">History of <a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">{{.Data.Path}}</a> on {{$p.Ref}}</p>{{end}} |
| 4 | 4 | {{if $p.Empty}}<p class="muted">No commits.</p>{{else}} |
| 5 | +<form method="get" class="pickaxe">{{if .Data.Path}}<input type="hidden" name="path" value="{{.Data.Path}}">{{end}}<input name="q" value="{{.Data.Query}}" placeholder="Find commits that added or removed some text"><select name="mode"><option value="text">changes its count</option><option value="regex"{{if eq .Data.Mode "regex"}} selected{{end}}>diff matches regex</option></select><button class="btn">Search history ▸</button></form> | |
| 6 | +{{with .Data.Error}}<p class="error">{{.}}</p>{{end}}{{if and .Data.Query (not .Data.Commits) (not .Data.Error)}}<p class="info">No commit on {{$p.Ref}} {{if eq .Data.Mode "regex"}}has a diff line matching{{else}}changes the number of occurrences of{{end}} <code>{{.Data.Query}}</code>.</p>{{end}}{{if and .Data.Query .Data.Commits}}<p class="muted">Commits on {{$p.Ref}} that {{if eq .Data.Mode "regex"}}touch lines matching{{else}}add or remove{{end}} <code>{{.Data.Query}}</code> · <a href="?{{if .Data.Path}}path={{.Data.Path}}{{end}}">clear</a></p>{{end}} | |
| 5 | 7 | <div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{template "badge" .}}</div><div class="muted small">{{template "who" .}} · <span title="{{date .Time}}">{{ago .Time}}</span></div>{{if .Body}}<pre class="body">{{.Body}}</pre>{{end}}</div>{{end}}</div> |
| 6 | -{{with .Data.Next}}<p><a class="btn" href="?from={{.}}{{if $.Data.Path}}&path={{$.Data.Path}}{{end}}">Older ▸</a></p>{{end}} | |
| 8 | +{{with .Data.Next}}<p><a class="btn" href="?from={{.}}{{if $.Data.Path}}&path={{$.Data.Path}}{{end}}{{if $.Data.Query}}&q={{$.Data.Query}}&mode={{$.Data.Mode}}{{end}}">Older ▸</a></p>{{end}} | |
| 7 | 9 | {{end}} |
| 8 | 10 | {{end}} |
| 9 | 11 | {{define "badge"}}{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified ~{{.}}</span>{{else}}{{if and .Signature (ne .Signature "N")}}<span class="verified unknown" title="Signed with an unregistered key">Unverified</span>{{end}}{{end}}{{end}} |
templates/pull.html modified
| @@ -53,6 +53,9 @@ | ||
| 53 | 53 | {{template "meta" (dict "Action" (printf "/%s/pulls/%d/meta" $p.Repo.Path $pr.Number) "Labels" .Data.Labels "AllLabels" .Data.AllLabels "Assignees" .Data.Assignees "Assignable" .Data.Assignable "Owner" .Data.Owner)}} |
| 54 | 54 | </aside> |
| 55 | 55 | </div> |
| 56 | +{{if gt (len .Data.Revisions) 1}}<h3>Revisions</h3> | |
| 57 | +<p class="revisions">{{range $i, $h := .Data.Revisions}}{{$n := add $i 1}}<span class="rev"><code>v{{$n}}</code> <a class="hash" href="/{{$p.Repo.Path}}/commit/{{$h}}">{{short $h}}</a>{{if gt $n 1}} <a href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/interdiff?from={{sub $n 1}}&to={{$n}}">range-diff from v{{sub $n 1}} ▸</a>{{end}}</span>{{end}}</p> | |
| 58 | +{{end}} | |
| 56 | 59 | {{if .Data.Commits}} |
| 57 | 60 | <h3>Commits</h3> |
| 58 | 61 | <div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified ~{{.}}</span>{{end}}</div><div class="muted small">{{template "who" .}} · {{ago .Time}}</div></div>{{end}}</div> |
templates/pulls.html modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | <div class="pullhead"> |
| 4 | 4 | <nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=merged"{{if eq .Data.State "merged"}} class="active"{{end}}>Merged</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav> |
| 5 | -{{if $p.User}}<a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}} | |
| 5 | +<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> | |
| 6 | 6 | </div> |
| 7 | 7 | {{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}} |
| 8 | 8 | {{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}} |
templates/refs.html modified
| @@ -3,5 +3,5 @@ | ||
| 3 | 3 | <div class="panel"><h3>Branches</h3> |
| 4 | 4 | <table class="refs">{{range .Data.Branches}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table></div> |
| 5 | 5 | <div class="panel"><h3>Tags</h3> |
| 6 | -{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td><td><a href="/{{$p.Repo.Path}}/archive/{{.Name}}.tar.gz">tar.gz</a> · <a href="/{{$p.Repo.Path}}/archive/{{.Name}}.zip">zip</a></td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}</div> | |
| 6 | +{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td><td><a href="/{{$p.Repo.Path}}/archive/{{.Name}}.tar.gz">tar.gz</a> <a class="muted small" href="/{{$p.Repo.Path}}/archive/{{.Name}}.tar.gz.sha256" title="this archive is byte for byte reproducible">sha256</a> · <a href="/{{$p.Repo.Path}}/archive/{{.Name}}.zip">zip</a></td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}</div> | |
| 7 | 7 | {{end}} |
templates/repo.html modified
| @@ -21,7 +21,7 @@ git push -u origin {{$p.Ref}}</pre> | ||
| 21 | 21 | {{if $p.Owner}}<strong>read/write</strong> |
| 22 | 22 | <div>{{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}}</div>{{end}} |
| 23 | 23 | <strong>download</strong> |
| 24 | -<div><a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.tar.gz">{{$p.Ref}}.tar.gz</a> · <a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.zip">{{$p.Ref}}.zip</a></div> | |
| 24 | +<div><a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.tar.gz">{{$p.Ref}}.tar.gz</a> <a class="muted small" href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.tar.gz.sha256" title="byte for byte reproducible for a given commit">sha256</a> · <a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.zip">{{$p.Ref}}.zip</a></div> | |
| 25 | 25 | <div><a href="/{{$p.Repo.Path}}/feed">atom feed</a> · <a href="/{{$p.Repo.Path}}/compare">compare</a></div> |
| 26 | 26 | </div> |
| 27 | 27 | </div> |
templates/user.html modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$own := and .User (eq .User.ID .Data.Owner.ID)}} |
| 4 | 4 | <div class="userhead"> |
| 5 | 5 | <h2>~{{.Data.Owner.Name}}</h2> |
| 6 | -<nav class="tabs small"><a class="active" href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a></nav> | |
| 6 | +<nav class="tabs small"><a class="active" href="/~{{.Data.Owner.Name}}">about</a><a href="/~{{.Data.Owner.Name}}/stars">stars{{if .Data.Count}} <span class="count">{{.Data.Count}}</span>{{end}}</a><a href="/~{{.Data.Owner.Name}}/feed">feed</a></nav> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns user"> |
| 9 | 9 | <aside class="profile"> |
web.go modified
| @@ -82,6 +82,8 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 82 | 82 | "inc": func(i int) int { return i + 1 }, |
| 83 | 83 | "perm": perm, |
| 84 | 84 | "signer": s.signer, |
| 85 | + "add": func(a, b int) int { return a + b }, | |
| 86 | + "sub": func(a, b int) int { return a - b }, | |
| 85 | 87 | "person": func(email string) string { |
| 86 | 88 | if u, err := s.store.UserByEmail(email); err == nil { |
| 87 | 89 | return u.Name |
| @@ -765,6 +767,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 765 | 767 | s.serveAvatar(w, r, owner) |
| 766 | 768 | return |
| 767 | 769 | } |
| 770 | + if name == "feed" && len(parts) == 2 && r.Method == "GET" { | |
| 771 | + s.userFeed(w, r, owner) | |
| 772 | + return | |
| 773 | + } | |
| 768 | 774 | if name == "stars" && len(parts) == 2 && r.Method == "GET" { |
| 769 | 775 | starred, _ := s.store.StarredRepos(owner.ID) |
| 770 | 776 | s.render(w, r, "stars", page{Title: "~" + owner.Name + " stars", User: user, Data: map[string]any{"Owner": owner, "Starred": starred, "Count": len(starred)}}) |
| @@ -824,6 +830,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 824 | 830 | s.download(w, r, repo, strings.TrimPrefix(rest, "archive/")) |
| 825 | 831 | case rest == "feed" && r.Method == "GET": |
| 826 | 832 | s.feed(w, r, repo) |
| 833 | + case rest == "issues/feed" && r.Method == "GET": | |
| 834 | + s.issueFeed(w, r, repo) | |
| 835 | + case rest == "pulls/feed" && r.Method == "GET": | |
| 836 | + s.pullFeed(w, r, repo) | |
| 827 | 837 | case rest == "issues" && r.Method == "GET": |
| 828 | 838 | s.issues(w, r, repo) |
| 829 | 839 | case rest == "issues/new" && r.Method == "GET": |
| @@ -1148,7 +1158,17 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref stri | ||
| 1148 | 1158 | s.fail(w, r, 404, "No such path") |
| 1149 | 1159 | return |
| 1150 | 1160 | } |
| 1151 | - data := map[string]any{"Page": rp, "Path": filePath} | |
| 1161 | + query, mode := r.URL.Query().Get("q"), r.URL.Query().Get("mode") | |
| 1162 | + var pickaxe []string | |
| 1163 | + if query != "" { | |
| 1164 | + if mode == "regex" { | |
| 1165 | + pickaxe = []string{"-G" + query} | |
| 1166 | + } else { | |
| 1167 | + mode = "text" | |
| 1168 | + pickaxe = []string{"-S" + query} | |
| 1169 | + } | |
| 1170 | + } | |
| 1171 | + data := map[string]any{"Page": rp, "Path": filePath, "Query": query, "Mode": mode} | |
| 1152 | 1172 | if !rp.Empty { |
| 1153 | 1173 | start := rp.Commit |
| 1154 | 1174 | if from := r.URL.Query().Get("from"); from != "" { |
| @@ -1158,8 +1178,10 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref stri | ||
| 1158 | 1178 | } |
| 1159 | 1179 | } |
| 1160 | 1180 | const per = 50 |
| 1161 | - commits, err := g.Log(start, per+1, filePath) | |
| 1162 | - if err != nil { | |
| 1181 | + commits, err := g.Log(start, per+1, filePath, pickaxe...) | |
| 1182 | + if err != nil && query != "" { | |
| 1183 | + data["Error"] = "That search could not run, check the pattern" | |
| 1184 | + } else if err != nil { | |
| 1163 | 1185 | s.fail(w, r, 500, err.Error()) |
| 1164 | 1186 | return |
| 1165 | 1187 | } |