~gitpocket/gitpocket

feat(web): pull request range-diff, pickaxe history search, reproducible tarballs and atom feeds

commit
0f25c94d29a72b8167e1a39139b56db56f24119f browse tree
parent
05eddd69545cc1928195a442fa2a196c77e38bae
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 09:43 UTC

15 files changed, +296 −20

README.md modified

@@ -15,7 +15,10 @@ Small and lightweight git server for self hosting. Repositories are served over
1515 - 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`
1616 - Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip
1717 - 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
1922 - Rename, transfer to another user and archive from the repository settings. An archived repository refuses pushes, pull requests and comments but stays readable
2023 - 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
2124 - 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 @@
11 package main
22
33 import (
4+ "compress/gzip"
45 "context"
6+ "crypto/sha256"
57 "encoding/xml"
68 "fmt"
79 "html/template"
10+ "io"
811 "net/http"
912 "os"
1013 "os/exec"
1114 "path/filepath"
15+ "slices"
1216 "strconv"
1317 "strings"
1418 "time"
@@ -231,6 +235,8 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request, repo Repo, spec
231235
232236 func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
233237 g := Git{Dir: s.repoDir(repo)}
238+ digest := strings.HasSuffix(spec, ".sha256")
239+ spec = strings.TrimSuffix(spec, ".sha256")
234240 format := ""
235241 switch {
236242 case strings.HasSuffix(spec, ".tar.gz"):
@@ -248,14 +254,50 @@ func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spe
248254 }
249255 safe := strings.NewReplacer("/", "-", "\\", "-").Replace(spec)
250256 name := repo.Name + "-" + safe
257+ file := name + "." + format
251258 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
252259 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+ }
255278 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()
259301 }
260302
261303 type atomFeed struct {
@@ -298,7 +340,91 @@ func (s *Server) feed(w http.ResponseWriter, r *http.Request, repo Repo) {
298340 feed.Updated = commits[0].Time.UTC().Format(time.RFC3339)
299341 }
300342 }
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+ }
301351 w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
302352 w.Write([]byte(xml.Header))
303353 xml.NewEncoder(w).Encode(feed)
304354 }
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 {
332332 return commits
333333 }
334334
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)
337338 if filePath != "" {
338339 args = append(args, "--", filePath)
339340 }

pull.go modified

@@ -525,6 +525,7 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
525525 retry("Could not fetch the source branch: " + err.Error())
526526 return
527527 }
528+ g.RecordRevision(pull)
528529 url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number)
529530 if repo.Owner.ID != user.ID {
530531 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
634635 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
635636 return
636637 }
638+ g.RecordRevision(pull)
637639 s.store.TouchPull(pull.ID)
638640 }
639641 case "merge":
@@ -689,7 +691,12 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
689691 s.fail(w, r, 500, "Pull request head is missing")
690692 return
691693 }
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,
693700 "Body": s.renderMarkdown([]byte(pull.Body)), "Labels": s.store.ItemLabels("pull", pull.ID), "AllLabels": nil, "Assignees": s.store.Assignees("pull", pull.ID)}
694701 if isOwner {
695702 data["AllLabels"], _ = s.store.Labels(repo.ID)
@@ -829,3 +836,92 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) {
829836 installHook(s.root, dir)
830837 http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther)
831838 }
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}
310310 @media (max-width:700px){.featuredgrid{grid-template-columns:1fr}.landing .more .btn{min-width:0;width:100%}}
311311 .readme video,.readme img{max-width:100%;height:auto}
312312 .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) {
527527 }
528528
529529 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" {
531531 return Repo{}, errors.New("Repository names are lowercase letters, digits, dot, dash or underscore")
532532 }
533533 tags, err := parseTags(rawTags)
@@ -915,7 +915,7 @@ func (s *Store) DeleteUser(name string) error {
915915 }
916916
917917 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" {
919919 return errors.New("Repository names are lowercase letters, digits, dot, dash or underscore")
920920 }
921921 _, 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>&gt;</code> are new and <code>&lt;</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 @@
22 {{$p := .Data.Page}}
33 <div class="pullhead">
44 <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>
66 </div>
77 {{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}}
88 {{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 @@
22 {{$p := .Data.Page}}
33 {{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}}
44 {{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}}
57 <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}}
79 {{end}}
810 {{end}}
911 {{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 @@
5353 {{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)}}
5454 </aside>
5555 </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}}
5659 {{if .Data.Commits}}
5760 <h3>Commits</h3>
5861 <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 @@
22 {{$p := .Data.Page}}
33 <div class="pullhead">
44 <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>
66 </div>
77 {{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}}
88 {{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 @@
33 <div class="panel"><h3>Branches</h3>
44 <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>
55 <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>
77 {{end}}

templates/repo.html modified

@@ -21,7 +21,7 @@ git push -u origin {{$p.Ref}}</pre>
2121 {{if $p.Owner}}<strong>read/write</strong>
2222 <div>{{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}}</div>{{end}}
2323 <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>
2525 <div><a href="/{{$p.Repo.Path}}/feed">atom feed</a> · <a href="/{{$p.Repo.Path}}/compare">compare</a></div>
2626 </div>
2727 </div>

templates/user.html modified

@@ -3,7 +3,7 @@
33 {{$own := and .User (eq .User.ID .Data.Owner.ID)}}
44 <div class="userhead">
55 <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>
77 </div>
88 <div class="columns user">
99 <aside class="profile">

web.go modified

@@ -82,6 +82,8 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv
8282 "inc": func(i int) int { return i + 1 },
8383 "perm": perm,
8484 "signer": s.signer,
85+ "add": func(a, b int) int { return a + b },
86+ "sub": func(a, b int) int { return a - b },
8587 "person": func(email string) string {
8688 if u, err := s.store.UserByEmail(email); err == nil {
8789 return u.Name
@@ -765,6 +767,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
765767 s.serveAvatar(w, r, owner)
766768 return
767769 }
770+ if name == "feed" && len(parts) == 2 && r.Method == "GET" {
771+ s.userFeed(w, r, owner)
772+ return
773+ }
768774 if name == "stars" && len(parts) == 2 && r.Method == "GET" {
769775 starred, _ := s.store.StarredRepos(owner.ID)
770776 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) {
824830 s.download(w, r, repo, strings.TrimPrefix(rest, "archive/"))
825831 case rest == "feed" && r.Method == "GET":
826832 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)
827837 case rest == "issues" && r.Method == "GET":
828838 s.issues(w, r, repo)
829839 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
11481158 s.fail(w, r, 404, "No such path")
11491159 return
11501160 }
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}
11521172 if !rp.Empty {
11531173 start := rp.Commit
11541174 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
11581178 }
11591179 }
11601180 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 {
11631185 s.fail(w, r, 500, err.Error())
11641186 return
11651187 }