feat(repos): branch switcher and push notices
19 files changed, +455 −78
- README.md modified +3 −2
- admin.go modified +21 −3
- deploy/nginx.conf modified +5 −1
- extra.go modified +27 −0
- flow_test.go modified +42 −0
- git.go modified +17 −8
- pull.go modified +8 −2
- static/style.css modified +20 −4
- store.go modified +32 −3
- templates/blob.html modified +1 −0
- templates/layout.html modified +8 −8
- templates/meta.html modified +1 −0
- templates/orgsettings.html modified +1 −1
- templates/pull.html modified +1 −1
- templates/pulls.html modified +1 −0
- templates/repo.html modified +1 −0
- templates/settings.html modified +1 −1
- templates/tree.html modified +1 −0
- web.go modified +264 −44
README.md modified
| @@ -19,6 +19,7 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 19 | 19 | - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title |
| 20 | 20 | - 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 |
| 21 | 21 | - 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 | |
| 22 | 23 | - 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 |
| 23 | 24 | - 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 |
| 24 | 25 | - 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 | ||
| 32 | 33 | |
| 33 | 34 | ## Design |
| 34 | 35 | |
| 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. | |
| 36 | 37 | |
| 37 | 38 | 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. |
| 38 | 39 | |
| @@ -76,7 +77,7 @@ sudo -u git gitpocket -root /srv/gitpocket admin you | ||
| 76 | 77 | sudo -u git gitpocket -root /srv/gitpocket registration invite |
| 77 | 78 | ``` |
| 78 | 79 | |
| 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. | |
| 80 | 81 | |
| 81 | 82 | 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. |
| 82 | 83 | |
admin.go modified
| @@ -26,16 +26,34 @@ create table if not exists redirects ( | ||
| 26 | 26 | |
| 27 | 27 | var registrationModes = []string{"open", "invite", "closed"} |
| 28 | 28 | |
| 29 | +type settingEntry struct { | |
| 30 | + at time.Time | |
| 31 | + value string | |
| 32 | + ok bool | |
| 33 | +} | |
| 34 | + | |
| 29 | 35 | 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 { | |
| 32 | 47 | return fallback |
| 33 | 48 | } |
| 34 | - return v | |
| 49 | + return e.value | |
| 35 | 50 | } |
| 36 | 51 | |
| 37 | 52 | func (s *Store) SetSetting(key, value string) error { |
| 38 | 53 | _, 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() | |
| 39 | 57 | return err |
| 40 | 58 | } |
| 41 | 59 | |
deploy/nginx.conf modified
| @@ -17,6 +17,11 @@ server { | ||
| 17 | 17 | ssl_protocols TLSv1.2 TLSv1.3; |
| 18 | 18 | client_max_body_size 512m; |
| 19 | 19 | 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; | |
| 20 | 25 | location / { |
| 21 | 26 | limit_req zone=gitpocket burst=60 nodelay; |
| 22 | 27 | limit_req_status 429; |
| @@ -32,6 +37,5 @@ server { | ||
| 32 | 37 | location /static/ { |
| 33 | 38 | proxy_pass http://127.0.0.1:3200; |
| 34 | 39 | proxy_set_header Host $host; |
| 35 | - expires 1d; | |
| 36 | 40 | } |
| 37 | 41 | } |
extra.go modified
| @@ -499,7 +499,34 @@ func (s *Server) history(repo Repo) []historyCommit { | ||
| 499 | 499 | return commits |
| 500 | 500 | } |
| 501 | 501 | |
| 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 | + | |
| 502 | 512 | 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 { | |
| 503 | 530 | counts := map[string]int{} |
| 504 | 531 | org := s.store.IsOrg(owner.ID) |
| 505 | 532 | email := strings.ToLower(s.store.VerifiedEmail(owner.ID)) |
flow_test.go modified
| @@ -324,3 +324,45 @@ func TestProfileReadme(t *testing.T) { | ||
| 324 | 324 | t.Error("private profile README shown") |
| 325 | 325 | } |
| 326 | 326 | } |
| 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 ( | ||
| 8 | 8 | "os" |
| 9 | 9 | "os/exec" |
| 10 | 10 | "path" |
| 11 | + "path/filepath" | |
| 11 | 12 | "runtime" |
| 12 | 13 | "slices" |
| 13 | 14 | "strconv" |
| @@ -51,6 +52,7 @@ type Ref struct { | ||
| 51 | 52 | Name string |
| 52 | 53 | Hash string |
| 53 | 54 | Time time.Time |
| 55 | + Tag bool | |
| 54 | 56 | } |
| 55 | 57 | |
| 56 | 58 | type Line struct { |
| @@ -322,11 +324,11 @@ func (g Git) Empty() bool { | ||
| 322 | 324 | } |
| 323 | 325 | |
| 324 | 326 | 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 | |
| 328 | 330 | } |
| 329 | - return strings.TrimSpace(string(out)) | |
| 331 | + return "main" | |
| 330 | 332 | } |
| 331 | 333 | |
| 332 | 334 | func (g Git) Resolve(ref string) (string, error) { |
| @@ -549,19 +551,26 @@ func (g Git) Kind(commit, filePath string) string { | ||
| 549 | 551 | return strings.TrimSpace(string(out)) |
| 550 | 552 | } |
| 551 | 553 | |
| 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...)...) | |
| 554 | 556 | if err != nil { |
| 555 | 557 | return nil, err |
| 556 | 558 | } |
| 557 | 559 | var refs []Ref |
| 558 | 560 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { |
| 559 | 561 | f := strings.Split(line, "\x00") |
| 560 | - if len(f) != 3 { | |
| 562 | + if len(f) != 4 { | |
| 561 | 563 | continue |
| 562 | 564 | } |
| 563 | 565 | 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}) | |
| 565 | 574 | } |
| 566 | 575 | return refs, nil |
| 567 | 576 | } |
pull.go modified
| @@ -379,7 +379,7 @@ func (s *Server) pullAccess(w http.ResponseWriter, r *http.Request, repo Repo) ( | ||
| 379 | 379 | } |
| 380 | 380 | |
| 381 | 381 | 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) | |
| 383 | 383 | if !ok { |
| 384 | 384 | return |
| 385 | 385 | } |
| @@ -411,7 +411,7 @@ func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 411 | 411 | for _, p := range pulls { |
| 412 | 412 | itemLabels[p.ID] = s.store.ItemLabels("pull", p.ID) |
| 413 | 413 | } |
| 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}}) | |
| 415 | 415 | } |
| 416 | 416 | |
| 417 | 417 | type sourceOption struct { |
| @@ -965,3 +965,9 @@ func (g Git) FastForward(target, head, baseRef string) (string, error) { | ||
| 965 | 965 | } |
| 966 | 966 | return headHash, nil |
| 967 | 967 | } |
| 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} | ||
| 90 | 90 | nav.tabs.small a.active{background:#ddd} |
| 91 | 91 | .profile .avatar{display:block;width:230px;height:230px;margin-bottom:.9rem;border-radius:6px} |
| 92 | 92 | .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)} | |
| 94 | 94 | .profile .btn.block{display:block;text-align:center;margin-top:.45rem} |
| 95 | 95 | .info{background:var(--accentsoft);color:var(--fg);padding:.5rem .7rem;border:1px solid var(--focus);margin:0} |
| 96 | 96 | .avatar{object-fit:cover;border:1px solid var(--line);background:var(--soft)} |
| @@ -189,8 +189,8 @@ input[type=file]{border:0;padding:0} | ||
| 189 | 189 | .twocol{display:grid;grid-template-columns:1fr 1fr;gap:2rem;margin-bottom:2rem} |
| 190 | 190 | .twocol label{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.8rem} |
| 191 | 191 | .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} | |
| 194 | 194 | .modes input[type=radio]{width:auto;margin:0} |
| 195 | 195 | h3.plain{border:0;font-size:1.25rem} |
| 196 | 196 | .columns.browse{grid-template-columns:minmax(0,2fr) 1fr} |
| @@ -252,7 +252,10 @@ mark{background:#fff3b0;padding:0} | ||
| 252 | 252 | .comment.outdated{opacity:.8} |
| 253 | 253 | .mergeform label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem} |
| 254 | 254 | .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} | |
| 256 | 259 | .btn.static{cursor:default} |
| 257 | 260 | .archived{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.3rem .6rem;margin:.4rem 0 0} |
| 258 | 261 | .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} | ||
| 457 | 460 | .introhead{display:flex;gap:.6rem;font-size:.85rem;color:var(--muted);margin:0 0 .4rem} |
| 458 | 461 | .introhead a{color:var(--muted)} |
| 459 | 462 | .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 ( | ||
| 10 | 10 | "net/mail" |
| 11 | 11 | "regexp" |
| 12 | 12 | "strings" |
| 13 | + "sync" | |
| 13 | 14 | "time" |
| 14 | 15 | |
| 15 | 16 | "golang.org/x/crypto/bcrypt" |
| @@ -257,11 +258,13 @@ type Token struct { | ||
| 257 | 258 | } |
| 258 | 259 | |
| 259 | 260 | type Store struct { |
| 260 | - db *sql.DB | |
| 261 | + db *sql.DB | |
| 262 | + settingsMu sync.Mutex | |
| 263 | + settings map[string]settingEntry | |
| 261 | 264 | } |
| 262 | 265 | |
| 263 | 266 | 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)" | |
| 265 | 268 | if readonly { |
| 266 | 269 | dsn += "&mode=ro" |
| 267 | 270 | } |
| @@ -283,8 +286,11 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 283 | 286 | return nil, err |
| 284 | 287 | } |
| 285 | 288 | } |
| 289 | + if _, err := db.Exec(indexes); err != nil { | |
| 290 | + return nil, err | |
| 291 | + } | |
| 286 | 292 | } |
| 287 | - return &Store{db}, nil | |
| 293 | + return &Store{db: db, settings: map[string]settingEntry{}}, nil | |
| 288 | 294 | } |
| 289 | 295 | |
| 290 | 296 | func random(n int) string { |
| @@ -1050,6 +1056,12 @@ func (s *Store) StarCount(repo int64) int { | ||
| 1050 | 1056 | return n |
| 1051 | 1057 | } |
| 1052 | 1058 | |
| 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 | + | |
| 1053 | 1065 | func (s *Store) StarredRepos(user int64) ([]Repo, error) { |
| 1054 | 1066 | 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) |
| 1055 | 1067 | if err != nil { |
| @@ -1116,3 +1128,20 @@ func (s *Store) Pin(owner, repo int64, on bool) error { | ||
| 1116 | 1128 | _, 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) |
| 1117 | 1129 | return err |
| 1118 | 1130 | } |
| 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 @@ | ||
| 12 | 12 | {{define "filebar"}} |
| 13 | 13 | {{$p := .Page}} |
| 14 | 14 | <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> | |
| 15 | 16 | <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> |
| 16 | 17 | {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}} |
| 17 | 18 | <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 @@ | ||
| 1 | 1 | <!doctype html> |
| 2 | 2 | <html lang="en"> |
| 3 | 3 | <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}}"> | |
| 5 | 5 | <meta charset="utf-8"> |
| 6 | 6 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 7 | 7 | <title>{{.Title}}</title> |
| @@ -10,15 +10,15 @@ | ||
| 10 | 10 | {{with .Data}}{{with index . "Page"}}<link rel="alternate" type="application/atom+xml" title="{{.Repo.Path}} commits" href="/{{.Repo.Path}}/feed"> |
| 11 | 11 | <link rel="alternate" type="application/atom+xml" title="{{.Repo.Path}} issues" href="/{{.Repo.Path}}/issues/feed"> |
| 12 | 12 | <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}} | |
| 17 | 17 | </head> |
| 18 | 18 | <body{{if .Fluid}} class="fluid"{{end}}> |
| 19 | 19 | <header> |
| 20 | 20 | <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> | |
| 22 | 22 | <a href="/search">explore</a> |
| 23 | 23 | {{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}} |
| 24 | 24 | <span class="spacer"></span> |
| @@ -35,10 +35,10 @@ | ||
| 35 | 35 | </header> |
| 36 | 36 | {{with .Data}}{{with index . "Page"}} |
| 37 | 37 | <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> | |
| 39 | 39 | <div class="tabbar"><nav class="tabs inner"> |
| 40 | 40 | <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> | |
| 42 | 42 | {{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}} |
| 43 | 43 | </nav></div> |
| 44 | 44 | </div> |
templates/meta.html modified
| @@ -11,3 +11,4 @@ | ||
| 11 | 11 | {{define "who"}}{{with person .Email}}<a href="/~{{.}}">{{$.Author}}</a>{{else}}{{.Author}}{{end}}{{end}} |
| 12 | 12 | {{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}} |
| 13 | 13 | {{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}}&target={{$p.Ref}}">Open pull request ▸</a></div>{{end}}{{end}} | |
templates/orgsettings.html modified
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | <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> |
| 14 | 14 | </div> |
| 15 | 15 | </form> |
| 16 | -<script src="/static/crop.js" defer></script> | |
| 16 | +<script src="/static/crop.js?v={{$.Version}}" defer></script> | |
| 17 | 17 | <h3 class="rule">Profile</h3> |
| 18 | 18 | <form method="post" action="/settings/profile" class="stack wide"> |
| 19 | 19 | <input type="hidden" name="org" value="{{$o.Name}}"> |
templates/pull.html modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{$pr := .Data.Pull}} |
| 4 | 4 | <div class="pulltitle"> |
| 5 | 5 | <h3 class="plain"><span class="state {{$pr.State}}{{if $pr.Draft}} draft{{end}}">{{if and $pr.Draft (eq $pr.State "open")}}draft{{else}}{{$pr.State}}{{end}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3> |
| 6 | -<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} 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> | |
| 7 | 7 | </div> |
| 8 | 8 | <div class="columns pullbody"> |
| 9 | 9 | <div class="main"> |
templates/pulls.html modified
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | +{{template "pushed" .Data}} | |
| 3 | 4 | <div class="pullhead"> |
| 4 | 5 | <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 | 6 | <span><a class="muted small feedlink" href="/{{$p.Repo.Path}}/pulls/feed">atom feed</a>{{if $p.User}} <a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}}</span> |
templates/repo.html modified
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | +{{template "pushed" .Data}} | |
| 3 | 4 | {{if $p.Empty}} |
| 4 | 5 | <p>This repository is empty. Push something to it:</p> |
| 5 | 6 | <pre>git remote add origin {{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}} |
templates/settings.html modified
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | <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> |
| 13 | 13 | </div> |
| 14 | 14 | </form> |
| 15 | -<script src="/static/crop.js" defer></script> | |
| 15 | +<script src="/static/crop.js?v={{$.Version}}" defer></script> | |
| 16 | 16 | <h3 class="rule">Email</h3> |
| 17 | 17 | <form method="post" action="/settings/email" class="stack wide"> |
| 18 | 18 | <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 @@ | ||
| 16 | 16 | {{define "filebar"}} |
| 17 | 17 | {{$p := .Page}} |
| 18 | 18 | <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> | |
| 19 | 20 | <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> |
| 20 | 21 | {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}} |
| 21 | 22 | {{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 { | ||
| 53 | 53 | name string |
| 54 | 54 | sshUser string |
| 55 | 55 | pages map[string]*template.Template |
| 56 | + brand map[string][]byte | |
| 56 | 57 | markdown goldmark.Markdown |
| 57 | 58 | policy *bluemonday.Policy |
| 58 | 59 | } |
| @@ -71,6 +72,8 @@ type page struct { | ||
| 71 | 72 | |
| 72 | 73 | Registration string |
| 73 | 74 | Admin bool |
| 75 | + Logo bool | |
| 76 | + Icon bool | |
| 74 | 77 | } |
| 75 | 78 | |
| 76 | 79 | 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 { | ||
| 143 | 146 | s.writeKeyring() |
| 144 | 147 | s.installHooks() |
| 145 | 148 | 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 | + }) | |
| 148 | 156 | 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 | + } | |
| 149 | 164 | s.version = hex.EncodeToString(sum.Sum(nil))[:12] |
| 150 | 165 | return s |
| 151 | 166 | } |
| @@ -155,6 +170,8 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p | ||
| 155 | 170 | p.Name = s.name |
| 156 | 171 | p.SSHUser = s.sshUser |
| 157 | 172 | p.Version = s.version |
| 173 | + p.Logo = s.brand["logo.png"] != nil | |
| 174 | + p.Icon = s.brand["icon.png"] != nil | |
| 158 | 175 | p.Registration = s.store.Registration() |
| 159 | 176 | if d, ok := p.Data.(map[string]any); ok && d["File"] == true { |
| 160 | 177 | p.Fluid = true |
| @@ -200,13 +217,24 @@ func (s *Server) current(r *http.Request) *User { | ||
| 200 | 217 | func (s *Server) handler() http.Handler { |
| 201 | 218 | mux := http.NewServeMux() |
| 202 | 219 | 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 | + }) | |
| 204 | 225 | css := highlightCSS() |
| 205 | 226 | mux.HandleFunc("GET /static/highlight.css", func(w http.ResponseWriter, r *http.Request) { |
| 206 | 227 | 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) | |
| 208 | 229 | w.Write(css) |
| 209 | 230 | }) |
| 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 | + } | |
| 210 | 238 | mux.HandleFunc("GET /{$}", s.index) |
| 211 | 239 | mux.HandleFunc("GET /projects", s.projects) |
| 212 | 240 | mux.HandleFunc("GET /search", s.find) |
| @@ -824,12 +852,11 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 824 | 852 | if profile.Bio != "" { |
| 825 | 853 | bio = s.renderMarkdown([]byte(profile.Bio)) |
| 826 | 854 | } |
| 827 | - starred, _ := s.store.StarredRepos(owner.ID) | |
| 828 | 855 | year, _ := strconv.Atoi(r.URL.Query().Get("year")) |
| 829 | 856 | if year < 1970 || year > time.Now().Year() { |
| 830 | 857 | year = 0 |
| 831 | 858 | } |
| 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} | |
| 833 | 860 | if org { |
| 834 | 861 | var people []userHit |
| 835 | 862 | for _, m := range s.store.Members(owner.ID) { |
| @@ -1012,7 +1039,7 @@ type repoPage struct { | ||
| 1012 | 1039 | func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) { |
| 1013 | 1040 | g := Git{Dir: s.repoDir(repo)} |
| 1014 | 1041 | 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} | |
| 1016 | 1043 | if user != nil { |
| 1017 | 1044 | rp.Starred = s.store.Starred(user.ID, repo.ID) |
| 1018 | 1045 | } |
| @@ -1021,16 +1048,21 @@ func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git | ||
| 1021 | 1048 | rp.Parent = &parent |
| 1022 | 1049 | } |
| 1023 | 1050 | } |
| 1024 | - if g.Empty() { | |
| 1025 | - rp.Empty = true | |
| 1026 | - rp.Ref = g.Head() | |
| 1027 | - return rp, g, nil | |
| 1028 | - } | |
| 1051 | + head := g.Head() | |
| 1029 | 1052 | 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) | |
| 1031 | 1059 | } |
| 1032 | - commit, err := g.Resolve(ref) | |
| 1033 | 1060 | if err != nil { |
| 1061 | + if g.Empty() { | |
| 1062 | + rp.Empty = true | |
| 1063 | + rp.Ref = head | |
| 1064 | + return rp, g, nil | |
| 1065 | + } | |
| 1034 | 1066 | return rp, g, err |
| 1035 | 1067 | } |
| 1036 | 1068 | rp.Ref = ref |
| @@ -1046,21 +1078,17 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 1046 | 1078 | } |
| 1047 | 1079 | rp.Tab = "summary" |
| 1048 | 1080 | data := map[string]any{"Page": rp} |
| 1081 | + data["Pushed"] = s.recentPushes(repo, g, rp) | |
| 1049 | 1082 | 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 | |
| 1055 | 1085 | 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 | |
| 1062 | 1090 | } else { |
| 1063 | - data["ReadmeText"] = string(content) | |
| 1091 | + data["ReadmeText"] = sum.readmeText | |
| 1064 | 1092 | } |
| 1065 | 1093 | } |
| 1066 | 1094 | } |
| @@ -1115,7 +1143,16 @@ func (s *Server) splitRef(g Git, spec string) (string, string, bool) { | ||
| 1115 | 1143 | return "", "", false |
| 1116 | 1144 | } |
| 1117 | 1145 | parts := strings.Split(spec, "/") |
| 1146 | + known := s.refTable(g).byName | |
| 1118 | 1147 | 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-- { | |
| 1119 | 1156 | ref := strings.Join(parts[:i], "/") |
| 1120 | 1157 | if _, err := g.Resolve(ref); err == nil { |
| 1121 | 1158 | 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 | ||
| 1146 | 1183 | if filePath == "" { |
| 1147 | 1184 | crumbs = nil |
| 1148 | 1185 | } |
| 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 | |
| 1152 | 1190 | } |
| 1153 | - if entry := g.Entry(rp.Commit, filePath); entry != nil { | |
| 1154 | - data["Entry"] = entry | |
| 1191 | + if meta.entry != nil { | |
| 1192 | + data["Entry"] = meta.entry | |
| 1155 | 1193 | } |
| 1156 | - switch g.Kind(rp.Commit, filePath) { | |
| 1194 | + switch meta.kind { | |
| 1157 | 1195 | case "tree", "commit": |
| 1158 | - entries, err := g.Tree(rp.Commit, filePath) | |
| 1159 | - if err != nil { | |
| 1196 | + if meta.entries == nil { | |
| 1160 | 1197 | s.fail(w, r, 404, "No such path") |
| 1161 | 1198 | return |
| 1162 | 1199 | } |
| 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 | |
| 1169 | 1202 | s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data}) |
| 1170 | 1203 | case "blob": |
| 1171 | 1204 | size := g.Size(rp.Commit, filePath) |
| @@ -1367,8 +1400,14 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 1367 | 1400 | return |
| 1368 | 1401 | } |
| 1369 | 1402 | 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 | + } | |
| 1372 | 1411 | s.render(w, r, "refs", page{Title: repo.Path() + " refs", Data: map[string]any{"Page": rp, "Branches": branches, "Tags": tags}}) |
| 1373 | 1412 | } |
| 1374 | 1413 | |
| @@ -1609,8 +1648,7 @@ func (s *Server) repos(w http.ResponseWriter, r *http.Request, owner User, user | ||
| 1609 | 1648 | s.fail(w, r, 500, err.Error()) |
| 1610 | 1649 | return |
| 1611 | 1650 | } |
| 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)} | |
| 1614 | 1652 | s.render(w, r, "repos", page{Title: "~" + owner.Name + " repos", User: user, Data: data}) |
| 1615 | 1653 | } |
| 1616 | 1654 | |
| @@ -1660,3 +1698,185 @@ var nameWords = []string{"heron", "lantern", "harbour", "kestrel", "thicket", "a | ||
| 1660 | 1698 | func suggestName() string { |
| 1661 | 1699 | return nameWords[rand.IntN(len(nameWords))] |
| 1662 | 1700 | } |
| 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 | +} | |