~gitpocket/gitpocket

1

feat(repos): branch switcher and push notices

commit
05701a4801f8776fd20963cf4e3b4d41ae24ce82 browse tree
parent
82c196820173610e1d9a14e48d6a14f5dd31f95b
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-10-31 17:48 UTC

19 files changed, +455 −78

README.md modified

@@ -19,6 +19,7 @@ Small and lightweight git server for self hosting. Repositories are served over
1919 - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title
2020 - A year of activity on each profile, drawn server side as a grid with no script, with a tab for the past twelve months and one for each calendar year that has commits. Hovering or focusing a day shows its count in a CSS tooltip. It counts commits on every branch of every public repository whose author email matches the account's verified address, so work on other people's projects counts and nothing is attributed by name alone
2121 - 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
22+- A branch switcher on every tree and file view, and a notice on the summary and pull request pages whenever a branch with commits the default branch lacks was pushed in the last three days, with a button that opens a pull request from it
2223 - 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
2324 - 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
2425 - 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. Each page names its feeds in a `<link rel="alternate">`, so a reader finds them from the ordinary page URL
@@ -32,7 +33,7 @@ Small and lightweight git server for self hosting. Repositories are served over
3233
3334 ## Design
3435
35-Every read of git data is a call out to the plumbing with a thirty second timeout, which in practice means `ls-tree -l` for trees and `log` with a NUL separated format for history and `diff-tree -p --root -M` for commit pages and `cat-file` for blobs. Nothing is parsed out of packfiles in process, so a large repository costs exactly what git says it costs. A buffered call that tries to return more than 16 MiB gets killed. Only twice the core count may run together, or four on a single core box, and raw files and archives stream two at a time. On gitpocket.io the server idles at about 35 MB resident, so most of a 512 MB VPS is left for git.
36+Every read of git data is a call out to the plumbing with a thirty second timeout, which in practice means `ls-tree -l` for trees and `log` with a NUL separated format for history and `diff-tree -p --root -M` for commit pages and `cat-file` for blobs. Nothing is parsed out of packfiles in process, so a large repository costs exactly what git says it costs. A buffered call that tries to return more than 16 MiB gets killed. Only twice the core count may run together, or four on a single core box, and raw files and archives stream two at a time. In production the server idles at about 35 MB resident, so most of a 512 MB VPS is left for git.
3637
3738 A little is remembered between requests, though deleting all of it loses nothing. Language bars and the history behind the activity graph are stored against the mtime of the refs, so after a push one `stat` notices and before one nothing is read at all. That history holds sixteen bytes a commit (a hash prefix, the day and which author address it was), and the list of people that search walks is refreshed at most twice a minute.
3839
@@ -76,7 +77,7 @@ sudo -u git gitpocket -root /srv/gitpocket admin you
7677 sudo -u git gitpocket -root /srv/gitpocket registration invite
7778 ```
7879
79-Registration starts open. `gitpocket registration` prints or sets the mode (open, invite or closed), `gitpocket invite` prints a single use code, and `gitpocket user <name>` creates an account from the command line with the password read from stdin. `-ssh-user` changes the account name that appears in clone URLs if you did not call yours `git`. The header shows `-name`, which defaults to gitpocket, while `-host` stays the hostname used in clone URLs and feeds.
80+Registration starts open. `gitpocket registration` prints or sets the mode (open, invite or closed), `gitpocket invite` prints a single use code, and `gitpocket user <name>` creates an account from the command line with the password read from stdin. `-ssh-user` changes the account name that appears in clone URLs if you did not call yours `git`. The header shows `-name`, which defaults to gitpocket, while `-host` stays the hostname used in clone URLs and feeds. A `logo.png` placed in the root directory replaces the header wordmark and an `icon.png` there replaces the favicon, both read once at startup.
8081
8182 Mail is optional and nothing needs it to sign up. Without it password resets go through `gitpocket passwd`. It is configured in `smtp.json` under the root directory and read once at startup. Plain SMTP takes `host`, `port`, `user`, `password`, `from` and `starttls`, but many VPS providers block every outbound submission port, so `api` and `key` post each message as JSON to a Resend compatible HTTPS endpoint instead.
8283

admin.go modified

@@ -26,16 +26,34 @@ create table if not exists redirects (
2626
2727 var registrationModes = []string{"open", "invite", "closed"}
2828
29+type settingEntry struct {
30+ at time.Time
31+ value string
32+ ok bool
33+}
34+
2935 func (s *Store) Setting(key, fallback string) string {
30- var v string
31- if err := s.db.QueryRow(`select value from settings where key = ?`, key).Scan(&v); err != nil {
36+ s.settingsMu.Lock()
37+ e, hit := s.settings[key]
38+ s.settingsMu.Unlock()
39+ if !hit || time.Since(e.at) > 5*time.Second {
40+ e = settingEntry{at: time.Now()}
41+ e.ok = s.db.QueryRow(`select value from settings where key = ?`, key).Scan(&e.value) == nil
42+ s.settingsMu.Lock()
43+ s.settings[key] = e
44+ s.settingsMu.Unlock()
45+ }
46+ if !e.ok {
3247 return fallback
3348 }
34- return v
49+ return e.value
3550 }
3651
3752 func (s *Store) SetSetting(key, value string) error {
3853 _, err := s.db.Exec(`insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value`, key, value)
54+ s.settingsMu.Lock()
55+ delete(s.settings, key)
56+ s.settingsMu.Unlock()
3957 return err
4058 }
4159

deploy/nginx.conf modified

@@ -17,6 +17,11 @@ server {
1717 ssl_protocols TLSv1.2 TLSv1.3;
1818 client_max_body_size 512m;
1919 add_header Strict-Transport-Security "max-age=31536000" always;
20+ gzip on;
21+ gzip_proxied any;
22+ gzip_vary on;
23+ gzip_comp_level 5;
24+ gzip_types text/css text/plain application/javascript image/svg+xml application/atom+xml application/json;
2025 location / {
2126 limit_req zone=gitpocket burst=60 nodelay;
2227 limit_req_status 429;
@@ -32,6 +37,5 @@ server {
3237 location /static/ {
3338 proxy_pass http://127.0.0.1:3200;
3439 proxy_set_header Host $host;
35- expires 1d;
3640 }
3741 }

extra.go modified

@@ -499,7 +499,34 @@ func (s *Server) history(repo Repo) []historyCommit {
499499 return commits
500500 }
501501
502+var activityCache = struct {
503+ sync.Mutex
504+ m map[int64]activityEntry
505+}{m: map[int64]activityEntry{}}
506+
507+type activityEntry struct {
508+ at time.Time
509+ counts map[string]int
510+}
511+
502512 func (s *Server) activityCounts(owner User) map[string]int {
513+ activityCache.Lock()
514+ e, ok := activityCache.m[owner.ID]
515+ activityCache.Unlock()
516+ if ok && time.Since(e.at) < 30*time.Second {
517+ return e.counts
518+ }
519+ counts := s.countActivity(owner)
520+ activityCache.Lock()
521+ if len(activityCache.m) > 4096 {
522+ clear(activityCache.m)
523+ }
524+ activityCache.m[owner.ID] = activityEntry{time.Now(), counts}
525+ activityCache.Unlock()
526+ return counts
527+}
528+
529+func (s *Server) countActivity(owner User) map[string]int {
503530 counts := map[string]int{}
504531 org := s.store.IsOrg(owner.ID)
505532 email := strings.ToLower(s.store.VerifiedEmail(owner.ID))

flow_test.go modified

@@ -324,3 +324,45 @@ func TestProfileReadme(t *testing.T) {
324324 t.Error("private profile README shown")
325325 }
326326 }
327+
328+func TestBranchPushAndPullMerge(t *testing.T) {
329+ if _, err := exec.LookPath("git"); err != nil {
330+ t.Skip("git not installed")
331+ }
332+ s, h := testServer(t)
333+ alice, _ := s.store.CreateUser("alice", "correct horse")
334+ repo, _ := s.store.CreateRepo(alice, "proj", "", Public, "")
335+ g := Git{Dir: s.repoDir(repo)}
336+ if err := initRepo(g.Dir); err != nil {
337+ t.Fatal(err)
338+ }
339+ base := commitFile(t, g, g.Head(), "", "a.txt", "one\n")
340+ feature := commitFile(t, g, "feature", base, "b.txt", "two\n")
341+ own, _ := s.store.CreateSession(alice.ID)
342+ if body := get(h, "/~alice/proj", own).Body.String(); !strings.Contains(body, `class="pushnote"`) || !strings.Contains(body, "<strong>feature</strong>") {
343+ t.Fatal("recent push notice missing for the owner")
344+ }
345+ if strings.Contains(get(h, "/~alice/proj", "").Body.String(), `class="pushnote"`) {
346+ t.Error("recent push notice shown to a visitor")
347+ }
348+ if body := get(h, "/~alice/proj/tree/feature/", own).Body.String(); !strings.Contains(body, `class="branchpick"`) || !strings.Contains(body, `href="/~alice/proj/tree/main/"`) {
349+ t.Error("branch switcher missing")
350+ }
351+ w := post(h, "/~alice/proj/pulls/new", url.Values{"title": {"Add b"}, "source": {"~alice/proj:feature"}, "target": {g.Head()}}, own)
352+ if w.Code != http.StatusSeeOther {
353+ t.Fatalf("opening the pull request failed: %d %s", w.Code, w.Body.String())
354+ }
355+ if strings.Contains(get(h, "/~alice/proj", own).Body.String(), `class="pushnote"`) {
356+ t.Error("notice still shown after a pull request was opened")
357+ }
358+ if w := post(h, "/~alice/proj/pulls/1/merge", url.Values{"strategy": {"merge"}, "title": {"Merge feature"}}, own); w.Code != http.StatusSeeOther {
359+ t.Fatalf("merge failed: %d", w.Code)
360+ }
361+ head, _ := g.Resolve(g.Head())
362+ if out, err := g.run(nil, "merge-base", "--is-ancestor", feature, head); err != nil {
363+ t.Errorf("main does not contain the feature branch after merging: %s", out)
364+ }
365+ if body := get(h, "/~alice/proj/pulls/1", own).Body.String(); !strings.Contains(strings.ToLower(body), "merged") {
366+ t.Error("pull request not shown as merged")
367+ }
368+}

git.go modified

@@ -8,6 +8,7 @@ import (
88 "os"
99 "os/exec"
1010 "path"
11+ "path/filepath"
1112 "runtime"
1213 "slices"
1314 "strconv"
@@ -51,6 +52,7 @@ type Ref struct {
5152 Name string
5253 Hash string
5354 Time time.Time
55+ Tag bool
5456 }
5557
5658 type Line struct {
@@ -322,11 +324,11 @@ func (g Git) Empty() bool {
322324 }
323325
324326 func (g Git) Head() string {
325- out, err := g.run(nil, "symbolic-ref", "--short", "HEAD")
326- if err != nil {
327- return "main"
327+ b, err := os.ReadFile(filepath.Join(g.Dir, "HEAD"))
328+ if branch, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "ref: refs/heads/"); err == nil && ok && branch != "" {
329+ return branch
328330 }
329- return strings.TrimSpace(string(out))
331+ return "main"
330332 }
331333
332334 func (g Git) Resolve(ref string) (string, error) {
@@ -549,19 +551,26 @@ func (g Git) Kind(commit, filePath string) string {
549551 return strings.TrimSpace(string(out))
550552 }
551553
552-func (g Git) Refs(prefix string) ([]Ref, error) {
553- out, err := g.run(nil, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname)%00%(creatordate:unix)", prefix)
554+func (g Git) Refs(prefixes ...string) ([]Ref, error) {
555+ out, err := g.run(nil, append([]string{"for-each-ref", "--sort=-creatordate", "--format=%(refname)%00%(objectname)%00%(creatordate:unix)%00%(*objectname)"}, prefixes...)...)
554556 if err != nil {
555557 return nil, err
556558 }
557559 var refs []Ref
558560 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
559561 f := strings.Split(line, "\x00")
560- if len(f) != 3 {
562+ if len(f) != 4 {
561563 continue
562564 }
563565 t, _ := strconv.ParseInt(f[2], 10, 64)
564- refs = append(refs, Ref{Name: f[0], Hash: f[1], Time: time.Unix(t, 0)})
566+ if f[3] != "" {
567+ f[1] = f[3]
568+ }
569+ name, tag := strings.CutPrefix(f[0], "refs/tags/")
570+ if !tag {
571+ name = strings.TrimPrefix(f[0], "refs/heads/")
572+ }
573+ refs = append(refs, Ref{Name: name, Hash: f[1], Time: time.Unix(t, 0), Tag: tag})
565574 }
566575 return refs, nil
567576 }

pull.go modified

@@ -379,7 +379,7 @@ func (s *Server) pullAccess(w http.ResponseWriter, r *http.Request, repo Repo) (
379379 }
380380
381381 func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) {
382- rp, _, _, ok := s.pullAccess(w, r, repo)
382+ rp, g, _, ok := s.pullAccess(w, r, repo)
383383 if !ok {
384384 return
385385 }
@@ -411,7 +411,7 @@ func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) {
411411 for _, p := range pulls {
412412 itemLabels[p.ID] = s.store.ItemLabels("pull", p.ID)
413413 }
414- s.render(w, r, "pulls", page{Title: repo.Path() + " pull requests", Data: map[string]any{"Page": rp, "Pulls": pulls, "State": r.URL.Query().Get("state"), "Labels": labels, "Filter": filter, "ItemLabels": itemLabels}})
414+ s.render(w, r, "pulls", page{Title: repo.Path() + " pull requests", Data: map[string]any{"Page": rp, "Pulls": pulls, "Pushed": s.recentPushes(repo, g, rp), "State": r.URL.Query().Get("state"), "Labels": labels, "Filter": filter, "ItemLabels": itemLabels}})
415415 }
416416
417417 type sourceOption struct {
@@ -965,3 +965,9 @@ func (g Git) FastForward(target, head, baseRef string) (string, error) {
965965 }
966966 return headHash, nil
967967 }
968+
969+func (s *Store) HasOpenPull(repo int64, branch string) bool {
970+ var n int
971+ s.db.QueryRow(`select count(*) from pulls where repo = ? and source_repo = ? and source_branch = ? and state = 'open'`, repo, repo, branch).Scan(&n)
972+ return n > 0
973+}

static/style.css modified

@@ -90,7 +90,7 @@ nav.tabs.small a{padding:.1rem .8rem}
9090 nav.tabs.small a.active{background:#ddd}
9191 .profile .avatar{display:block;width:230px;height:230px;margin-bottom:.9rem;border-radius:6px}
9292 .profile p{margin:.2rem 0}
93-.profile .name{font-weight:600;font-size:1.35rem;line-height:1.25;margin:0}
93+.profile .name{font-weight:400;font-size:1.3rem;line-height:1.25;margin:0;color:var(--fg)}
9494 .profile .btn.block{display:block;text-align:center;margin-top:.45rem}
9595 .info{background:var(--accentsoft);color:var(--fg);padding:.5rem .7rem;border:1px solid var(--focus);margin:0}
9696 .avatar{object-fit:cover;border:1px solid var(--line);background:var(--soft)}
@@ -189,8 +189,8 @@ input[type=file]{border:0;padding:0}
189189 .twocol{display:grid;grid-template-columns:1fr 1fr;gap:2rem;margin-bottom:2rem}
190190 .twocol label{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.8rem}
191191 .twocol .legend{display:block;margin-bottom:.4rem}
192-.twocol label.radio,.twocol label.check,.modes label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem}
193-.twocol label.radio small,.modes label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
192+label.radio,.twocol label.check,.modes label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem}
193+label.radio small,.modes label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
194194 .modes input[type=radio]{width:auto;margin:0}
195195 h3.plain{border:0;font-size:1.25rem}
196196 .columns.browse{grid-template-columns:minmax(0,2fr) 1fr}
@@ -252,7 +252,10 @@ mark{background:#fff3b0;padding:0}
252252 .comment.outdated{opacity:.8}
253253 .mergeform label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem}
254254 .mergeform label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
255-.btn.starred{background:#fff3cd;border-color:#ffe08a}
255+.btn.starred{background:var(--accentsoft);border-color:var(--focus);color:var(--accent)}
256+.btn.starred:hover{background:#e8eaf8}
257+.staricon{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.4;vertical-align:-2px;margin-right:.3rem}
258+.btn.starred .staricon{fill:currentColor}
256259 .btn.static{cursor:default}
257260 .archived{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.3rem .6rem;margin:.4rem 0 0}
258261 .compareform{display:flex;gap:.6rem;align-items:flex-end;margin-bottom:1rem;flex-wrap:wrap}
@@ -457,3 +460,16 @@ nav.site .me form{margin:0}
457460 .introhead{display:flex;gap:.6rem;font-size:.85rem;color:var(--muted);margin:0 0 .4rem}
458461 .introhead a{color:var(--muted)}
459462 .forkicon{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.4;vertical-align:-2px;margin-right:.3rem}
463+.pushnote{display:flex;align-items:center;gap:.6rem;background:var(--accentsoft);border:1px solid var(--focus);padding:.45rem .7rem;margin:0 0 1rem;font-size:.92rem}
464+.pushnote svg{width:15px;height:15px;fill:none;stroke:var(--accent);stroke-width:1.4;flex-shrink:0}
465+.pushnote span{flex:1}
466+.pushnote .btn{white-space:nowrap}
467+.branchpick{position:relative}
468+.branchpick summary{list-style:none;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:#fff;padding:.05rem .5rem;font-size:.88rem}
469+.branchpick summary::-webkit-details-marker{display:none}
470+.branchpick summary svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.4}
471+.branchpick>div{position:absolute;z-index:10;top:calc(100% + 4px);left:0;min-width:12rem;max-height:18rem;overflow:auto;background:#fff;border:1px solid var(--line);box-shadow:0 4px 12px rgba(0,0,0,.08);display:flex;flex-direction:column}
472+.branchpick>div a{padding:.3rem .7rem;color:var(--fg);font-size:.9rem}
473+.branchpick>div a:hover{background:var(--soft);text-decoration:none}
474+.branchpick>div a.current{font-weight:600;color:var(--accent)}
475+label.radio input{width:auto;margin:0}

store.go modified

@@ -10,6 +10,7 @@ import (
1010 "net/mail"
1111 "regexp"
1212 "strings"
13+ "sync"
1314 "time"
1415
1516 "golang.org/x/crypto/bcrypt"
@@ -257,11 +258,13 @@ type Token struct {
257258 }
258259
259260 type Store struct {
260- db *sql.DB
261+ db *sql.DB
262+ settingsMu sync.Mutex
263+ settings map[string]settingEntry
261264 }
262265
263266 func openStore(path string, readonly bool) (*Store, error) {
264- dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(wal)&_pragma=foreign_keys(1)"
267+ dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(wal)&_pragma=synchronous(normal)&_pragma=foreign_keys(1)"
265268 if readonly {
266269 dsn += "&mode=ro"
267270 }
@@ -283,8 +286,11 @@ func openStore(path string, readonly bool) (*Store, error) {
283286 return nil, err
284287 }
285288 }
289+ if _, err := db.Exec(indexes); err != nil {
290+ return nil, err
291+ }
286292 }
287- return &Store{db}, nil
293+ return &Store{db: db, settings: map[string]settingEntry{}}, nil
288294 }
289295
290296 func random(n int) string {
@@ -1050,6 +1056,12 @@ func (s *Store) StarCount(repo int64) int {
10501056 return n
10511057 }
10521058
1059+func (s *Store) StarredCount(user int64) int {
1060+ var n int
1061+ s.db.QueryRow(`select count(*) from stars st join repos r on r.id = st.repo where st.user = ? and r.private = 0`, user).Scan(&n)
1062+ return n
1063+}
1064+
10531065 func (s *Store) StarredRepos(user int64) ([]Repo, error) {
10541066 rows, err := s.db.Query(`select `+repoColumns+` join stars st on st.repo = r.id where st.user = ? and r.private = 0 order by st.created desc`, user)
10551067 if err != nil {
@@ -1116,3 +1128,20 @@ func (s *Store) Pin(owner, repo int64, on bool) error {
11161128 _, err := s.db.Exec(`update repos set pinned = (select coalesce(max(pinned), 0) + 1 from repos where owner = ?) where id = ? and owner = ? and pinned = 0`, owner, repo, owner)
11171129 return err
11181130 }
1131+
1132+const indexes = `
1133+create index if not exists stars_repo on stars(repo);
1134+create index if not exists repos_parent on repos(parent);
1135+create index if not exists members_user on members(user);
1136+create index if not exists collaborators_user on collaborators(user);
1137+create index if not exists sessions_user on sessions(user);
1138+create index if not exists keys_user on keys(user);
1139+create index if not exists tokens_user on tokens(user);
1140+create index if not exists pgp_user on pgp(user);
1141+create index if not exists mailtokens_user on mailtokens(user);
1142+create index if not exists issue_comments_issue on issue_comments(issue);
1143+create index if not exists prreviews_pull on prreviews(pull);
1144+create index if not exists reviews_pull on reviews(pull);
1145+create index if not exists comments_pull on comments(pull);
1146+create index if not exists redirects_repo on redirects(repo);
1147+`

templates/blob.html modified

@@ -12,6 +12,7 @@
1212 {{define "filebar"}}
1313 {{$p := .Page}}
1414 <div class="filebar">
15+<details class="branchpick"><summary><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="4" cy="13" r="1.6"/><circle cx="12" cy="5" r="1.6"/><path d="M4 4.6v6.8M12 6.6c0 3-4 2.6-7.4 5.2"/></svg>{{$p.Ref}}</summary><div>{{range .Branches}}<a href="/{{$p.Repo.Path}}/tree/{{.Name}}/{{$.Path}}"{{if eq .Name $p.Ref}} class="current"{{end}}>{{.Name}}</a>{{end}}</div></details>
1516 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
1617 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
1718 <a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/edit/{{$p.Ref}}/{{.Path}}">Edit</a><form method="post" action="/{{$p.Repo.Path}}/remove/{{$p.Ref}}/{{.Path}}" class="inlineform"><button class="linkbutton">Delete</button></form>{{end}}

templates/layout.html modified

@@ -1,7 +1,7 @@
11 <!doctype html>
22 <html lang="en">
33 <head>
4-<link rel="icon" type="image/png" href="/static/icon.png">
4+<link rel="icon" type="image/png" href="/static/{{if .Icon}}site/{{end}}icon.png?v={{.Version}}">
55 <meta charset="utf-8">
66 <meta name="viewport" content="width=device-width, initial-scale=1">
77 <title>{{.Title}}</title>
@@ -10,15 +10,15 @@
1010 {{with .Data}}{{with index . "Page"}}<link rel="alternate" type="application/atom+xml" title="{{.Repo.Path}} commits" href="/{{.Repo.Path}}/feed">
1111 <link rel="alternate" type="application/atom+xml" title="{{.Repo.Path}} issues" href="/{{.Repo.Path}}/issues/feed">
1212 <link rel="alternate" type="application/atom+xml" title="{{.Repo.Path}} pull requests" href="/{{.Repo.Path}}/pulls/feed">{{end}}{{if index . "Profile"}}{{with index . "Owner"}}<link rel="alternate" type="application/atom+xml" title="~{{.Name}} commits" href="/~{{.Name}}/feed">{{end}}{{end}}{{end}}
13-{{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css">
14-<script defer src="/static/katex/katex.min.js"></script>
15-<script defer src="/static/katex/auto-render.min.js"></script>
16-<script defer src="/static/math.js"></script>{{end}}
13+{{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css?v={{$.Version}}">
14+<script defer src="/static/katex/katex.min.js?v={{$.Version}}"></script>
15+<script defer src="/static/katex/auto-render.min.js?v={{$.Version}}"></script>
16+<script defer src="/static/math.js?v={{$.Version}}"></script>{{end}}
1717 </head>
1818 <body{{if .Fluid}} class="fluid"{{end}}>
1919 <header>
2020 <nav class="site">
21-<a class="brand" href="/" aria-label="{{.Name}}">{{if eq .Name "gitpocket"}}<img src="/static/wordmark.png" alt="gitpocket" width="311" height="80">{{else}}<img class="mark" src="/static/logo.png" alt="" width="35" height="36">{{.Name}}{{end}}</a>
21+<a class="brand" href="/" aria-label="{{.Name}}">{{if .Logo}}<img src="/static/site/logo.png?v={{.Version}}" alt="{{.Name}}">{{else if eq .Name "gitpocket"}}<img src="/static/wordmark.png?v={{.Version}}" alt="gitpocket" width="311" height="80">{{else}}<img class="mark" src="/static/logo.png?v={{$.Version}}" alt="" width="35" height="36">{{.Name}}{{end}}</a>
2222 <a href="/search">explore</a>
2323 {{if not (and .Data (index .Data "Facets"))}}<form class="navsearch" method="get" action="/search" role="search"><input name="q" placeholder="Search repositories, people, languages" aria-label="Search"></form>{{end}}
2424 <span class="spacer"></span>
@@ -35,10 +35,10 @@
3535 </header>
3636 {{with .Data}}{{with index . "Page"}}
3737 <div class="repohead">
38-<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}">{{if .Starred}}★ Starred{{else}}☆ Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static">☆ {{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
38+<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
3939 <div class="tabbar"><nav class="tabs inner">
4040 <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a>
41-{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}<a href="/{{.Repo.Path}}/issues"{{if eq .Tab "issues"}} class="active"{{end}}>issues{{if .OpenIssues}} <span class="count">{{.OpenIssues}}</span>{{end}}</a>
41+{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>branches</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}<a href="/{{.Repo.Path}}/issues"{{if eq .Tab "issues"}} class="active"{{end}}>issues{{if .OpenIssues}} <span class="count">{{.OpenIssues}}</span>{{end}}</a>
4242 {{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}}
4343 </nav></div>
4444 </div>

templates/meta.html modified

@@ -11,3 +11,4 @@
1111 {{define "who"}}{{with person .Email}}<a href="/~{{.}}">{{$.Author}}</a>{{else}}{{.Author}}{{end}}{{end}}
1212 {{define "repoitem"}}<div class="event repo"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}{{template "repometa" .}}</div>{{end}}
1313 {{define "repometa"}}{{$l := lang .}}<p class="repometa">{{with $l.Name}}<span><i class="dot" style="background:{{$l.Colour}}"></i>{{.}}</span>{{end}}<span title="{{.Stars}} star{{if ne .Stars 1}}s{{end}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span><span title="{{.Forks}} fork{{if ne .Forks 1}}s{{end}}"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Forks}}</span></p>{{end}}
14+{{define "pushed"}}{{$p := .Page}}{{range .Pushed}}<div class="pushnote"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="4" cy="13" r="1.6"/><circle cx="12" cy="5" r="1.6"/><path d="M4 4.6v6.8M12 6.6c0 3-4 2.6-7.4 5.2"/></svg><span><strong>{{.Name}}</strong> had recent pushes {{ago .Time}}, {{.Ahead}} commit{{if ne .Ahead 1}}s{{end}} ahead of {{$p.Ref}}</span><a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new?source={{$p.Repo.Path}}:{{.Name}}&amp;target={{$p.Ref}}">Open pull request ▸</a></div>{{end}}{{end}}

templates/orgsettings.html modified

@@ -13,7 +13,7 @@
1313 <div class="actions left"><button class="btn">Upload picture ▸</button>{{if $p.Avatar}}<button class="btn danger" formaction="/settings/avatar/delete" formnovalidate>Remove</button>{{end}}</div>
1414 </div>
1515 </form>
16-<script src="/static/crop.js" defer></script>
16+<script src="/static/crop.js?v={{$.Version}}" defer></script>
1717 <h3 class="rule">Profile</h3>
1818 <form method="post" action="/settings/profile" class="stack wide">
1919 <input type="hidden" name="org" value="{{$o.Name}}">

templates/pull.html modified

@@ -3,7 +3,7 @@
33 {{$pr := .Data.Pull}}
44 <div class="pulltitle">
55 <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}} commits 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 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>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p>
77 </div>
88 <div class="columns pullbody">
99 <div class="main">

templates/pulls.html modified

@@ -1,5 +1,6 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3+{{template "pushed" .Data}}
34 <div class="pullhead">
45 <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>
56 <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>

templates/repo.html modified

@@ -1,5 +1,6 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3+{{template "pushed" .Data}}
34 {{if $p.Empty}}
45 <p>This repository is empty. Push something to it:</p>
56 <pre>git remote add origin {{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}}

templates/settings.html modified

@@ -12,7 +12,7 @@
1212 <div class="actions left"><button class="btn">Upload avatar ▸</button>{{if $p.Avatar}}<button class="btn danger" formaction="/settings/avatar/delete" formnovalidate>Remove</button>{{end}}</div>
1313 </div>
1414 </form>
15-<script src="/static/crop.js" defer></script>
15+<script src="/static/crop.js?v={{$.Version}}" defer></script>
1616 <h3 class="rule">Email</h3>
1717 <form method="post" action="/settings/email" class="stack wide">
1818 <label>Email address{{if .Data.Account.Email}}{{if .Data.Account.Verified}} <span class="muted">(verified)</span>{{else}} <span class="muted">(not verified yet)</span>{{end}}{{end}} <input name="email" type="email" value="{{.Data.Account.Email}}" placeholder="you@example.org"></label>

templates/tree.html modified

@@ -16,6 +16,7 @@
1616 {{define "filebar"}}
1717 {{$p := .Page}}
1818 <div class="filebar">
19+<details class="branchpick"><summary><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="4" cy="13" r="1.6"/><circle cx="12" cy="5" r="1.6"/><path d="M4 4.6v6.8M12 6.6c0 3-4 2.6-7.4 5.2"/></svg>{{$p.Ref}}</summary><div>{{range .Branches}}<a href="/{{$p.Repo.Path}}/tree/{{.Name}}/{{$.Path}}"{{if eq .Name $p.Ref}} class="current"{{end}}>{{.Name}}</a>{{end}}</div></details>
1920 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
2021 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
2122 {{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/{{.Path}}">New file</a>{{end}}{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{if and $p.Owner (not $p.Repo.Archived)}}<a href="/{{$p.Repo.Path}}/new/{{$p.Ref}}/">New file</a>{{end}}{{end}}

web.go modified

@@ -53,6 +53,7 @@ type Server struct {
5353 name string
5454 sshUser string
5555 pages map[string]*template.Template
56+ brand map[string][]byte
5657 markdown goldmark.Markdown
5758 policy *bluemonday.Policy
5859 }
@@ -71,6 +72,8 @@ type page struct {
7172
7273 Registration string
7374 Admin bool
75+ Logo bool
76+ Icon bool
7477 }
7578
7679 func newServer(store *Store, root, host, name string, sshUser string) *Server {
@@ -143,9 +146,21 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server {
143146 s.writeKeyring()
144147 s.installHooks()
145148 sum := sha256.New()
146- css, _ := staticFS.ReadFile("static/style.css")
147- sum.Write(css)
149+ fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
150+ if err == nil && !d.IsDir() {
151+ b, _ := staticFS.ReadFile(path)
152+ sum.Write(b)
153+ }
154+ return nil
155+ })
148156 sum.Write(highlightCSS())
157+ s.brand = map[string][]byte{}
158+ for _, f := range []string{"logo.png", "icon.png"} {
159+ if b, err := os.ReadFile(filepath.Join(root, f)); err == nil {
160+ s.brand[f] = b
161+ sum.Write(b)
162+ }
163+ }
149164 s.version = hex.EncodeToString(sum.Sum(nil))[:12]
150165 return s
151166 }
@@ -155,6 +170,8 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p
155170 p.Name = s.name
156171 p.SSHUser = s.sshUser
157172 p.Version = s.version
173+ p.Logo = s.brand["logo.png"] != nil
174+ p.Icon = s.brand["icon.png"] != nil
158175 p.Registration = s.store.Registration()
159176 if d, ok := p.Data.(map[string]any); ok && d["File"] == true {
160177 p.Fluid = true
@@ -200,13 +217,24 @@ func (s *Server) current(r *http.Request) *User {
200217 func (s *Server) handler() http.Handler {
201218 mux := http.NewServeMux()
202219 static, _ := fs.Sub(staticFS, "static")
203- mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
220+ files := http.StripPrefix("/static/", http.FileServerFS(static))
221+ mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) {
222+ s.staticCache(w, r)
223+ files.ServeHTTP(w, r)
224+ })
204225 css := highlightCSS()
205226 mux.HandleFunc("GET /static/highlight.css", func(w http.ResponseWriter, r *http.Request) {
206227 w.Header().Set("Content-Type", "text/css; charset=utf-8")
207- w.Header().Set("Cache-Control", "public, max-age=86400")
228+ s.staticCache(w, r)
208229 w.Write(css)
209230 })
231+ for name, b := range s.brand {
232+ mux.HandleFunc("GET /static/site/"+name, func(w http.ResponseWriter, r *http.Request) {
233+ w.Header().Set("Content-Type", "image/png")
234+ s.staticCache(w, r)
235+ w.Write(b)
236+ })
237+ }
210238 mux.HandleFunc("GET /{$}", s.index)
211239 mux.HandleFunc("GET /projects", s.projects)
212240 mux.HandleFunc("GET /search", s.find)
@@ -824,12 +852,11 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
824852 if profile.Bio != "" {
825853 bio = s.renderMarkdown([]byte(profile.Bio))
826854 }
827- starred, _ := s.store.StarredRepos(owner.ID)
828855 year, _ := strconv.Atoi(r.URL.Query().Get("year"))
829856 if year < 1970 || year > time.Now().Year() {
830857 year = 0
831858 }
832- data := map[string]any{"Owner": owner, "Repos": pinnedRepos(repos, s.store.Pins(owner.ID)), "Total": len(repos), "Profile": profile, "Bio": bio, "Count": len(starred), "Activity": s.activity(owner, year), "Member": member}
859+ data := map[string]any{"Owner": owner, "Repos": pinnedRepos(repos, s.store.Pins(owner.ID)), "Total": len(repos), "Profile": profile, "Bio": bio, "Count": s.store.StarredCount(owner.ID), "Activity": s.activity(owner, year), "Member": member}
833860 if org {
834861 var people []userHit
835862 for _, m := range s.store.Members(owner.ID) {
@@ -1012,7 +1039,7 @@ type repoPage struct {
10121039 func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
10131040 g := Git{Dir: s.repoDir(repo)}
10141041 user := s.current(r)
1015- rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: s.store.Owns(repo, user), Open: s.store.OpenPullCount(repo.ID), OpenIssues: s.store.OpenIssueCount(repo.ID), User: user, Stars: s.store.StarCount(repo.ID)}
1042+ rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: s.store.Owns(repo, user), Open: s.store.OpenPullCount(repo.ID), OpenIssues: s.store.OpenIssueCount(repo.ID), User: user, Stars: repo.Stars}
10161043 if user != nil {
10171044 rp.Starred = s.store.Starred(user.ID, repo.ID)
10181045 }
@@ -1021,16 +1048,21 @@ func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git
10211048 rp.Parent = &parent
10221049 }
10231050 }
1024- if g.Empty() {
1025- rp.Empty = true
1026- rp.Ref = g.Head()
1027- return rp, g, nil
1028- }
1051+ head := g.Head()
10291052 if ref == "" {
1030- ref = g.Head()
1053+ ref = head
1054+ }
1055+ commit, known := s.refTable(g).byName[ref]
1056+ var err error
1057+ if !known {
1058+ commit, err = g.Resolve(ref)
10311059 }
1032- commit, err := g.Resolve(ref)
10331060 if err != nil {
1061+ if g.Empty() {
1062+ rp.Empty = true
1063+ rp.Ref = head
1064+ return rp, g, nil
1065+ }
10341066 return rp, g, err
10351067 }
10361068 rp.Ref = ref
@@ -1046,21 +1078,17 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
10461078 }
10471079 rp.Tab = "summary"
10481080 data := map[string]any{"Page": rp}
1081+ data["Pushed"] = s.recentPushes(repo, g, rp)
10491082 if !rp.Empty {
1050- commits, _ := g.Log(rp.Commit, 4, "")
1051- data["Commits"] = commits
1052- branches, _ := g.Refs("refs/heads")
1053- tags, _ := g.Refs("refs/tags")
1054- data["Branches"] = branches
1083+ sum := s.summaryData(repo, g, rp)
1084+ data["Commits"], data["Branches"], data["Tags"] = sum.commits, sum.branches, sum.tags
10551085 data["Languages"] = s.repoLanguages(repo)
1056- data["Tags"] = tags
1057- name, content := g.Readme(rp.Commit)
1058- if name != "" {
1059- data["ReadmeName"] = name
1060- if strings.HasSuffix(strings.ToLower(name), ".md") {
1061- data["Readme"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/", "/"+repo.Path()+"/tree/"+rp.Ref+"/")
1086+ if sum.readmeName != "" {
1087+ data["ReadmeName"] = sum.readmeName
1088+ if sum.readme != "" {
1089+ data["Readme"] = sum.readme
10621090 } else {
1063- data["ReadmeText"] = string(content)
1091+ data["ReadmeText"] = sum.readmeText
10641092 }
10651093 }
10661094 }
@@ -1115,7 +1143,16 @@ func (s *Server) splitRef(g Git, spec string) (string, string, bool) {
11151143 return "", "", false
11161144 }
11171145 parts := strings.Split(spec, "/")
1146+ known := s.refTable(g).byName
11181147 for i := len(parts); i >= 1; i-- {
1148+ if _, ok := known[strings.Join(parts[:i], "/")]; ok {
1149+ return strings.Join(parts[:i], "/"), strings.Join(parts[i:], "/"), true
1150+ }
1151+ }
1152+ if _, err := g.Resolve(parts[0]); err == nil {
1153+ return parts[0], strings.Join(parts[1:], "/"), true
1154+ }
1155+ for i := len(parts); i >= 2; i-- {
11191156 ref := strings.Join(parts[:i], "/")
11201157 if _, err := g.Resolve(ref); err == nil {
11211158 return ref, strings.Join(parts[i:], "/"), true
@@ -1146,26 +1183,22 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st
11461183 if filePath == "" {
11471184 crumbs = nil
11481185 }
1149- data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs}
1150- if last, _ := g.Log(rp.Commit, 1, filePath); len(last) > 0 {
1151- data["LastCommit"] = last[0]
1186+ data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs, "Branches": s.refTable(g).branches()}
1187+ meta := treeInfo(g, rp.Commit, filePath)
1188+ if meta.last != nil {
1189+ data["LastCommit"] = *meta.last
11521190 }
1153- if entry := g.Entry(rp.Commit, filePath); entry != nil {
1154- data["Entry"] = entry
1191+ if meta.entry != nil {
1192+ data["Entry"] = meta.entry
11551193 }
1156- switch g.Kind(rp.Commit, filePath) {
1194+ switch meta.kind {
11571195 case "tree", "commit":
1158- entries, err := g.Tree(rp.Commit, filePath)
1159- if err != nil {
1196+ if meta.entries == nil {
11601197 s.fail(w, r, 404, "No such path")
11611198 return
11621199 }
1163- data["Entries"] = entries
1164- names := make([]string, len(entries))
1165- for i, e := range entries {
1166- names[i] = e.Name
1167- }
1168- data["Last"] = g.LastCommits(rp.Commit, filePath, names)
1200+ data["Entries"] = meta.entries
1201+ data["Last"] = meta.lastByName
11691202 s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data})
11701203 case "blob":
11711204 size := g.Size(rp.Commit, filePath)
@@ -1367,8 +1400,14 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) {
13671400 return
13681401 }
13691402 rp.Tab = "refs"
1370- branches, _ := g.Refs("refs/heads")
1371- tags, _ := g.Refs("refs/tags")
1403+ var branches, tags []Ref
1404+ for _, ref := range s.refTable(g).list {
1405+ if ref.Tag {
1406+ tags = append(tags, ref)
1407+ } else {
1408+ branches = append(branches, ref)
1409+ }
1410+ }
13721411 s.render(w, r, "refs", page{Title: repo.Path() + " refs", Data: map[string]any{"Page": rp, "Branches": branches, "Tags": tags}})
13731412 }
13741413
@@ -1609,8 +1648,7 @@ func (s *Server) repos(w http.ResponseWriter, r *http.Request, owner User, user
16091648 s.fail(w, r, 500, err.Error())
16101649 return
16111650 }
1612- starred, _ := s.store.StarredRepos(owner.ID)
1613- data := map[string]any{"Owner": owner, "Repos": s.byUpdate(repos), "Member": member, "Count": len(starred), "Org": s.store.IsOrg(owner.ID)}
1651+ data := map[string]any{"Owner": owner, "Repos": s.byUpdate(repos), "Member": member, "Count": s.store.StarredCount(owner.ID), "Org": s.store.IsOrg(owner.ID)}
16141652 s.render(w, r, "repos", page{Title: "~" + owner.Name + " repos", User: user, Data: data})
16151653 }
16161654
@@ -1660,3 +1698,185 @@ var nameWords = []string{"heron", "lantern", "harbour", "kestrel", "thicket", "a
16601698 func suggestName() string {
16611699 return nameWords[rand.IntN(len(nameWords))]
16621700 }
1701+
1702+func (s *Server) staticCache(w http.ResponseWriter, r *http.Request) {
1703+ if r.URL.Query().Get("v") == s.version {
1704+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
1705+ } else {
1706+ w.Header().Set("Cache-Control", "public, max-age=3600")
1707+ }
1708+}
1709+
1710+type summaryEntry struct {
1711+ stamp time.Time
1712+ ref string
1713+ commit string
1714+ commits []Commit
1715+ branches []Ref
1716+ tags []Ref
1717+ readmeName string
1718+ readme template.HTML
1719+ readmeText string
1720+}
1721+
1722+var summaries = struct {
1723+ sync.Mutex
1724+ m map[int64]summaryEntry
1725+}{m: map[int64]summaryEntry{}}
1726+
1727+func (s *Server) summaryData(repo Repo, g Git, rp repoPage) summaryEntry {
1728+ stamp := repoUpdated(g.Dir)
1729+ summaries.Lock()
1730+ e, ok := summaries.m[repo.ID]
1731+ summaries.Unlock()
1732+ if ok && e.stamp.Equal(stamp) && e.ref == rp.Ref && e.commit == rp.Commit {
1733+ return e
1734+ }
1735+ e = summaryEntry{stamp: stamp, ref: rp.Ref, commit: rp.Commit}
1736+ e.commits, _ = g.Log(rp.Commit, 4, "")
1737+ for _, r := range s.refTable(g).list {
1738+ if r.Tag {
1739+ e.tags = append(e.tags, r)
1740+ } else {
1741+ e.branches = append(e.branches, r)
1742+ }
1743+ }
1744+ name, content := g.Readme(rp.Commit)
1745+ e.readmeName = name
1746+ if strings.HasSuffix(strings.ToLower(name), ".md") {
1747+ e.readme = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/", "/"+repo.Path()+"/tree/"+rp.Ref+"/")
1748+ } else if name != "" {
1749+ e.readmeText = string(content)
1750+ }
1751+ summaries.Lock()
1752+ if len(summaries.m) > 1024 {
1753+ clear(summaries.m)
1754+ }
1755+ summaries.m[repo.ID] = e
1756+ summaries.Unlock()
1757+ return e
1758+}
1759+
1760+type refSet struct {
1761+ stamp time.Time
1762+ list []Ref
1763+ byName map[string]string
1764+}
1765+
1766+var refCache = struct {
1767+ sync.Mutex
1768+ m map[string]refSet
1769+}{m: map[string]refSet{}}
1770+
1771+func (s *Server) refTable(g Git) refSet {
1772+ stamp := repoUpdated(g.Dir)
1773+ refCache.Lock()
1774+ rs, ok := refCache.m[g.Dir]
1775+ refCache.Unlock()
1776+ if ok && rs.stamp.Equal(stamp) {
1777+ return rs
1778+ }
1779+ list, err := g.Refs("refs/heads", "refs/tags")
1780+ rs = refSet{stamp: stamp, list: list, byName: map[string]string{}}
1781+ for _, r := range list {
1782+ if _, dup := rs.byName[r.Name]; !dup || !r.Tag {
1783+ rs.byName[r.Name] = r.Hash
1784+ }
1785+ }
1786+ if err == nil {
1787+ refCache.Lock()
1788+ if len(refCache.m) > 1024 {
1789+ clear(refCache.m)
1790+ }
1791+ refCache.m[g.Dir] = rs
1792+ refCache.Unlock()
1793+ }
1794+ return rs
1795+}
1796+
1797+func (rs refSet) branches() []Ref {
1798+ var out []Ref
1799+ for _, r := range rs.list {
1800+ if !r.Tag {
1801+ out = append(out, r)
1802+ }
1803+ }
1804+ return out
1805+}
1806+
1807+type pushedBranch struct {
1808+ Ref
1809+ Ahead int
1810+}
1811+
1812+func (s *Server) recentPushes(repo Repo, g Git, rp repoPage) []pushedBranch {
1813+ if !rp.Owner || rp.Empty {
1814+ return nil
1815+ }
1816+ head := g.Head()
1817+ var out []pushedBranch
1818+ for _, b := range s.refTable(g).branches() {
1819+ if len(out) == 3 || time.Since(b.Time) > 72*time.Hour {
1820+ break
1821+ }
1822+ if b.Name == head || s.store.HasOpenPull(repo.ID, b.Name) {
1823+ continue
1824+ }
1825+ n, err := g.run(nil, "rev-list", "--count", "--end-of-options", "refs/heads/"+head+"..refs/heads/"+b.Name)
1826+ if ahead, _ := strconv.Atoi(strings.TrimSpace(string(n))); err == nil && ahead > 0 {
1827+ out = append(out, pushedBranch{b, ahead})
1828+ }
1829+ }
1830+ return out
1831+}
1832+
1833+type treeMeta struct {
1834+ kind string
1835+ last *Commit
1836+ entry *Entry
1837+ entries []Entry
1838+ lastByName map[string]Commit
1839+}
1840+
1841+var treeCache = struct {
1842+ sync.Mutex
1843+ m map[string]treeMeta
1844+}{m: map[string]treeMeta{}}
1845+
1846+func treeInfo(g Git, commit, filePath string) treeMeta {
1847+ key := commit + "\x00" + filePath
1848+ treeCache.Lock()
1849+ m, ok := treeCache.m[key]
1850+ treeCache.Unlock()
1851+ if ok {
1852+ return m
1853+ }
1854+ if last, _ := g.Log(commit, 1, filePath); len(last) > 0 {
1855+ m.last = &last[0]
1856+ }
1857+ m.entry = g.Entry(commit, filePath)
1858+ m.kind = g.Kind(commit, filePath)
1859+ if m.kind == "tree" || m.kind == "commit" {
1860+ if entries, err := g.Tree(commit, filePath); err == nil {
1861+ m.entries = entries
1862+ if m.entries == nil {
1863+ m.entries = []Entry{}
1864+ }
1865+ names := make([]string, len(entries))
1866+ for i, e := range entries {
1867+ names[i] = e.Name
1868+ }
1869+ m.lastByName = g.LastCommits(commit, filePath, names)
1870+ }
1871+ }
1872+ if m.kind == "" || ((m.kind == "tree" || m.kind == "commit") && m.entries == nil) {
1873+ return m
1874+ }
1875+ treeCache.Lock()
1876+ if len(treeCache.m) > 2048 {
1877+ clear(treeCache.m)
1878+ }
1879+ treeCache.m[key] = m
1880+ treeCache.Unlock()
1881+ return m
1882+}