~gitpocket/gitpocket

1

feat(basin): pull requests, forks and visibility

commit
dbac2a9979ba48e06975c2946cd997277d8e06f6 browse tree
parent
b20cc8808e9e966472e64e307861e32bce91a250
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
none
date
2026-09-26 14:32 UTC

15 files changed, +906 −35

README.md modified

@@ -31,6 +31,10 @@ Anonymous clients may fetch public repositories. Private repositories and all pu
3131
3232 Each user has a bio, location, workplace, website and an avatar. Avatar uploads are decoded on the server, cropped to the square the settings page chose with a small canvas script, resampled to 256 pixels and stored as PNG, so the served image never contains the original file's metadata or dimensions. Without JavaScript the crop falls back to the centred square.
3333
34+## Pull requests
35+
36+Any account can fork a public repository, which runs a local bare clone, and open a pull request from a branch of the target repository or of their own fork. Creating the request fetches the source branch into `refs/pull/N/head` of the target, so the diff, commit list and mergeability are computed inside one repository with `merge-base`, `diff` and `merge-tree --write-tree`. Merging builds the merge commit with `commit-tree` and advances the branch with a compare and swap `update-ref`, so no working tree is ever checked out and a concurrent push makes the merge fail cleanly rather than clobber. Discussion is a flat list of Markdown comments. Repositories also carry a visibility of public, unlisted or private and up to three tags, which the `/projects` page searches and sorts by last push time taken from ref mtimes.
37+
3438 ## Limitations
3539
36-There is no search, no collaborators beyond the owner and no disk quota, so a pushed repository is bounded only by the nginx body limit and free space. Login, registration and https basic auth share a per address failure counter of ten misses per five minutes, which nginx must feed through `X-Forwarded-For`. Blobs over 1 MiB are not rendered and over 64 MiB are not served raw. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet.
40+There is no search, no collaborators beyond the owner and no disk quota, so a pushed repository is bounded only by the nginx body limit and free space. Login, registration and https basic auth share a per address failure counter of ten misses per five minutes, which nginx must feed through `X-Forwarded-For`. Blobs over 1 MiB are not rendered and over 64 MiB are not served raw. Pull requests have no inline review comments, no required reviews and no rebase or squash strategies, only a merge commit.

git.go modified

@@ -65,10 +65,14 @@ type File struct {
6565 var ErrMissing = errors.New("missing")
6666
6767 func (g Git) run(stdin []byte, args ...string) ([]byte, error) {
68+ return g.runEnv(nil, stdin, args...)
69+}
70+
71+func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) {
6872 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
6973 defer cancel()
7074 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...)
71- cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"}
75+ cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"}, env...)
7276 if stdin != nil {
7377 cmd.Stdin = bytes.NewReader(stdin)
7478 }
@@ -90,6 +94,78 @@ func initRepo(dir string) error {
9094 return err
9195 }
9296
97+func cloneRepo(src, dst string) error {
98+ _, err := Git{Dir: "/"}.run(nil, "clone", "--bare", "--quiet", "--no-hardlinks", src, dst)
99+ if err != nil {
100+ return err
101+ }
102+ Git{Dir: dst}.run(nil, "remote", "remove", "origin")
103+ return nil
104+}
105+
106+func (g Git) Fetch(srcDir, srcRef, dstRef string) error {
107+ _, err := g.run(nil, "fetch", "--quiet", "--no-tags", "--force", srcDir, srcRef+":"+dstRef)
108+ return err
109+}
110+
111+func (g Git) MergeBase(a, b string) (string, error) {
112+ out, err := g.run(nil, "merge-base", a, b)
113+ if err != nil {
114+ return "", err
115+ }
116+ return strings.TrimSpace(string(out)), nil
117+}
118+
119+func (g Git) LogRange(base, head string, n int) ([]Commit, error) {
120+ out, err := g.run(nil, "log", "--format="+logFormat, "-n", strconv.Itoa(n), base+".."+head)
121+ if err != nil {
122+ return nil, err
123+ }
124+ return parseCommits(out), nil
125+}
126+
127+func (g Git) DiffRange(base, head string) ([]File, error) {
128+ out, err := g.run(nil, "diff", "-M", "--no-color", base, head)
129+ if err != nil {
130+ return nil, err
131+ }
132+ return parseDiff(string(out)), nil
133+}
134+
135+func (g Git) Mergeable(target, head string) bool {
136+ _, err := g.run(nil, "merge-tree", "--write-tree", "--quiet", "refs/heads/"+target, head)
137+ return err == nil
138+}
139+
140+func (g Git) Merge(target, head, message, name, email string) (string, error) {
141+ targetHash, err := g.Resolve("refs/heads/" + target)
142+ if err != nil {
143+ return "", errors.New("target branch is missing")
144+ }
145+ headHash, err := g.Resolve(head)
146+ if err != nil {
147+ return "", errors.New("pull request head is missing")
148+ }
149+ if base, err := g.MergeBase(targetHash, headHash); err == nil && base == headHash {
150+ return "", errors.New("already merged")
151+ }
152+ out, err := g.run(nil, "merge-tree", "--write-tree", targetHash, headHash)
153+ if err != nil {
154+ return "", errors.New("merge conflicts, resolve them locally and update the pull request")
155+ }
156+ tree, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
157+ ident := []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email}
158+ commit, err := g.runEnv(ident, []byte(message), "commit-tree", tree, "-p", targetHash, "-p", headHash)
159+ if err != nil {
160+ return "", err
161+ }
162+ hash := strings.TrimSpace(string(commit))
163+ if _, err := g.run(nil, "update-ref", "refs/heads/"+target, hash, targetHash); err != nil {
164+ return "", errors.New("target branch moved, try again")
165+ }
166+ return hash, nil
167+}
168+
93169 func (g Git) Empty() bool {
94170 _, err := g.run(nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
95171 return err != nil

pull.go added

@@ -0,0 +1,498 @@
1+package main
2+
3+import (
4+ "database/sql"
5+ "errors"
6+ "fmt"
7+ "html/template"
8+ "net/http"
9+ "os"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+ "time"
14+)
15+
16+const pullSchema = `
17+create table if not exists pulls (
18+ id integer primary key,
19+ repo integer not null references repos(id) on delete cascade,
20+ number integer not null,
21+ author integer not null references users(id) on delete cascade,
22+ title text not null,
23+ body text not null default '',
24+ source_repo integer not null,
25+ source_branch text not null,
26+ target_branch text not null,
27+ state text not null default 'open',
28+ created integer not null,
29+ updated integer not null,
30+ merged text not null default '',
31+ unique(repo, number)
32+);
33+create table if not exists comments (
34+ id integer primary key,
35+ pull integer not null references pulls(id) on delete cascade,
36+ author integer not null references users(id) on delete cascade,
37+ body text not null,
38+ created integer not null
39+);
40+`
41+
42+type Pull struct {
43+ ID int64
44+ Number int64
45+ Repo Repo
46+ Author User
47+ Title string
48+ Body string
49+ SourceRepo Repo
50+ SourceBranch string
51+ TargetBranch string
52+ State string
53+ Created time.Time
54+ Updated time.Time
55+ Merged string
56+ Comments int
57+}
58+
59+type Comment struct {
60+ ID int64
61+ Author User
62+ Body string
63+ Created time.Time
64+}
65+
66+func (p Pull) Ref() string {
67+ return fmt.Sprintf("refs/pull/%d/head", p.Number)
68+}
69+
70+func (p Pull) SameRepo() bool {
71+ return p.SourceRepo.ID == p.Repo.ID
72+}
73+
74+func (s *Store) CreatePull(repo Repo, author User, title, body string, source Repo, sourceBranch, targetBranch string) (Pull, error) {
75+ title = strings.TrimSpace(title)
76+ if title == "" || len(title) > 200 {
77+ return Pull{}, errors.New("Title is required and at most 200 characters")
78+ }
79+ now := time.Now().Unix()
80+ var number int64
81+ tx, err := s.db.Begin()
82+ if err != nil {
83+ return Pull{}, err
84+ }
85+ defer tx.Rollback()
86+ tx.QueryRow(`select coalesce(max(number), 0) + 1 from pulls where repo = ?`, repo.ID).Scan(&number)
87+ res, err := tx.Exec(`insert into pulls(repo, number, author, title, body, source_repo, source_branch, target_branch, state, created, updated) values (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
88+ repo.ID, number, author.ID, title, body, source.ID, sourceBranch, targetBranch, now, now)
89+ if err != nil {
90+ return Pull{}, err
91+ }
92+ if err := tx.Commit(); err != nil {
93+ return Pull{}, err
94+ }
95+ id, _ := res.LastInsertId()
96+ return Pull{ID: id, Number: number, Repo: repo, Author: author, Title: title, Body: body, SourceRepo: source, SourceBranch: sourceBranch, TargetBranch: targetBranch, State: "open"}, nil
97+}
98+
99+func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) {
100+ defer rows.Close()
101+ var pulls []Pull
102+ for rows.Next() {
103+ var p Pull
104+ var created, updated, aCreated int64
105+ var sourceID int64
106+ if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &p.Author.ID, &p.Author.Name, &aCreated); err != nil {
107+ return nil, err
108+ }
109+ p.Created, p.Updated, p.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(aCreated, 0)
110+ p.SourceRepo.ID = sourceID
111+ pulls = append(pulls, p)
112+ }
113+ return pulls, rows.Err()
114+}
115+
116+const pullColumns = `p.id, p.number, p.title, p.body, p.source_repo, p.source_branch, p.target_branch, p.state, p.created, p.updated, p.merged,
117+ (select count(*) from comments c where c.pull = p.id), u.id, u.name, u.created from pulls p join users u on u.id = p.author`
118+
119+func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) {
120+ rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and (? = '' or p.state = ?) order by p.number desc`, repo.ID, state, state)
121+ if err != nil {
122+ return nil, err
123+ }
124+ pulls, err := s.scanPulls(rows)
125+ for i := range pulls {
126+ pulls[i].Repo = repo
127+ s.fillSource(&pulls[i])
128+ }
129+ return pulls, err
130+}
131+
132+func (s *Store) Pull(repo Repo, number int64) (Pull, error) {
133+ rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and p.number = ?`, repo.ID, number)
134+ if err != nil {
135+ return Pull{}, err
136+ }
137+ pulls, err := s.scanPulls(rows)
138+ if err != nil || len(pulls) == 0 {
139+ return Pull{}, ErrNotFound
140+ }
141+ p := pulls[0]
142+ p.Repo = repo
143+ s.fillSource(&p)
144+ return p, nil
145+}
146+
147+func (s *Store) fillSource(p *Pull) {
148+ if p.SourceRepo.ID == p.Repo.ID {
149+ p.SourceRepo = p.Repo
150+ return
151+ }
152+ rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, p.SourceRepo.ID)
153+ if err != nil {
154+ return
155+ }
156+ if repos, _ := scanRepos(rows); len(repos) == 1 {
157+ p.SourceRepo = repos[0]
158+ }
159+}
160+
161+func (s *Store) SetPullState(id int64, state, merged string) error {
162+ _, err := s.db.Exec(`update pulls set state = ?, merged = ?, updated = ? where id = ?`, state, merged, time.Now().Unix(), id)
163+ return err
164+}
165+
166+func (s *Store) TouchPull(id int64) {
167+ s.db.Exec(`update pulls set updated = ? where id = ?`, time.Now().Unix(), id)
168+}
169+
170+func (s *Store) AddComment(pull int64, author User, body string) error {
171+ body = strings.TrimSpace(body)
172+ if body == "" || len(body) > 20000 {
173+ return errors.New("Comment is empty or too long")
174+ }
175+ _, err := s.db.Exec(`insert into comments(pull, author, body, created) values (?, ?, ?, ?)`, pull, author.ID, body, time.Now().Unix())
176+ s.TouchPull(pull)
177+ return err
178+}
179+
180+func (s *Store) Comments(pull int64) ([]Comment, error) {
181+ rows, err := s.db.Query(`select c.id, c.body, c.created, u.id, u.name, u.created from comments c join users u on u.id = c.author where c.pull = ? order by c.id`, pull)
182+ if err != nil {
183+ return nil, err
184+ }
185+ defer rows.Close()
186+ var out []Comment
187+ for rows.Next() {
188+ var c Comment
189+ var created, uCreated int64
190+ if err := rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &uCreated); err != nil {
191+ return nil, err
192+ }
193+ c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0)
194+ out = append(out, c)
195+ }
196+ return out, rows.Err()
197+}
198+
199+func (s *Store) OpenPullCount(repo int64) int {
200+ var n int
201+ s.db.QueryRow(`select count(*) from pulls where repo = ? and state = 'open'`, repo).Scan(&n)
202+ return n
203+}
204+
205+func (s *Store) Forks(parent int64, viewer int64) ([]Repo, error) {
206+ rows, err := s.db.Query(`select `+repoColumns+` where r.parent = ? and (r.private = 0 or r.owner = ?) order by u.name`, parent, viewer)
207+ if err != nil {
208+ return nil, err
209+ }
210+ return scanRepos(rows)
211+}
212+
213+func (s *Store) ForkRepo(owner User, parent Repo) (Repo, error) {
214+ name := parent.Name
215+ repo, err := s.CreateRepo(owner, name, parent.Description, Public, strings.Join(parent.Tags, ","))
216+ if err != nil {
217+ return Repo{}, err
218+ }
219+ s.db.Exec(`update repos set parent = ? where id = ?`, parent.ID, repo.ID)
220+ repo.Parent = parent.ID
221+ return repo, nil
222+}
223+
224+func (s *Server) pullAccess(w http.ResponseWriter, r *http.Request, repo Repo) (repoPage, Git, *User, bool) {
225+ rp, g, err := s.repoPage(r, repo, "")
226+ if err != nil {
227+ s.fail(w, r, 404, "No such ref")
228+ return rp, g, nil, false
229+ }
230+ rp.Tab = "pulls"
231+ return rp, g, s.current(r), true
232+}
233+
234+func (s *Server) pulls(w http.ResponseWriter, r *http.Request, repo Repo) {
235+ rp, _, _, ok := s.pullAccess(w, r, repo)
236+ if !ok {
237+ return
238+ }
239+ state := r.URL.Query().Get("state")
240+ if state == "" {
241+ state = "open"
242+ }
243+ if state == "all" {
244+ state = ""
245+ }
246+ pulls, err := s.store.Pulls(repo, state)
247+ if err != nil {
248+ s.fail(w, r, 500, err.Error())
249+ return
250+ }
251+ 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")}})
252+}
253+
254+type sourceOption struct {
255+ Value string
256+ Label string
257+}
258+
259+func (s *Server) pullSources(repo Repo, user *User) []sourceOption {
260+ var opts []sourceOption
261+ add := func(src Repo) {
262+ g := Git{Dir: s.repoDir(src)}
263+ branches, _ := g.Refs("refs/heads")
264+ for _, b := range branches {
265+ label := b.Name
266+ if src.ID != repo.ID {
267+ label = src.Path() + ":" + b.Name
268+ }
269+ opts = append(opts, sourceOption{Value: src.Path() + ":" + b.Name, Label: label})
270+ }
271+ }
272+ add(repo)
273+ if user != nil {
274+ forks, _ := s.store.Forks(repo.ID, user.ID)
275+ for _, f := range forks {
276+ if f.Owner.ID == user.ID {
277+ add(f)
278+ }
279+ }
280+ }
281+ return opts
282+}
283+
284+func (s *Server) newPull(w http.ResponseWriter, r *http.Request, repo Repo) {
285+ rp, g, user, ok := s.pullAccess(w, r, repo)
286+ if !ok {
287+ return
288+ }
289+ if user == nil {
290+ http.Redirect(w, r, "/login", http.StatusSeeOther)
291+ return
292+ }
293+ branches, _ := g.Refs("refs/heads")
294+ s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: r.URL.Query().Get("error"),
295+ Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.URL.Query().Get("source"), "Target": r.URL.Query().Get("target")}})
296+}
297+
298+func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, string, error) {
299+ path, branch, ok := strings.Cut(value, ":")
300+ if !ok || branch == "" {
301+ return Repo{}, "", errors.New("Choose a source branch")
302+ }
303+ owner, name, ok := strings.Cut(strings.TrimPrefix(path, "~"), "/")
304+ if !ok {
305+ return Repo{}, "", errors.New("Choose a source branch")
306+ }
307+ src, err := s.store.RepoByPath(owner, name)
308+ if err != nil {
309+ return Repo{}, "", errors.New("No such source repository")
310+ }
311+ if src.ID != repo.ID && (src.Parent != repo.ID || user == nil || src.Owner.ID != user.ID) {
312+ return Repo{}, "", errors.New("Source must be this repository or your own fork of it")
313+ }
314+ if _, err := (Git{Dir: s.repoDir(src)}).Resolve("refs/heads/" + branch); err != nil {
315+ return Repo{}, "", errors.New("No such source branch")
316+ }
317+ return src, branch, nil
318+}
319+
320+func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
321+ rp, g, user, ok := s.pullAccess(w, r, repo)
322+ if !ok {
323+ return
324+ }
325+ if user == nil {
326+ http.Redirect(w, r, "/login", http.StatusSeeOther)
327+ return
328+ }
329+ retry := func(msg string) {
330+ branches, _ := g.Refs("refs/heads")
331+ w.WriteHeader(400)
332+ s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg,
333+ Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.FormValue("source"), "Target": r.FormValue("target"), "PullTitle": r.FormValue("title"), "Body": r.FormValue("body")}})
334+ }
335+ src, branch, err := s.resolveSource(repo, user, r.FormValue("source"))
336+ if err != nil {
337+ retry(err.Error())
338+ return
339+ }
340+ target := r.FormValue("target")
341+ if _, err := g.Resolve("refs/heads/" + target); err != nil {
342+ retry("No such target branch")
343+ return
344+ }
345+ if src.ID == repo.ID && branch == target {
346+ retry("Source and target are the same branch")
347+ return
348+ }
349+ pull, err := s.store.CreatePull(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")), src, branch, target)
350+ if err != nil {
351+ retry(err.Error())
352+ return
353+ }
354+ if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil {
355+ retry("Could not fetch the source branch: " + err.Error())
356+ return
357+ }
358+ http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther)
359+}
360+
361+func (s *Server) loadPull(w http.ResponseWriter, r *http.Request, repo Repo, rest string) (repoPage, Git, *User, Pull, string, bool) {
362+ rp, g, user, ok := s.pullAccess(w, r, repo)
363+ if !ok {
364+ return rp, g, user, Pull{}, "", false
365+ }
366+ numStr, action, _ := strings.Cut(rest, "/")
367+ number, err := strconv.ParseInt(numStr, 10, 64)
368+ if err != nil {
369+ s.fail(w, r, 404, "No such pull request")
370+ return rp, g, user, Pull{}, "", false
371+ }
372+ pull, err := s.store.Pull(repo, number)
373+ if err != nil {
374+ s.fail(w, r, 404, "No such pull request")
375+ return rp, g, user, Pull{}, "", false
376+ }
377+ return rp, g, user, pull, action, true
378+}
379+
380+func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest string) {
381+ rp, g, user, pull, action, ok := s.loadPull(w, r, repo, rest)
382+ if !ok {
383+ return
384+ }
385+ isOwner := user != nil && user.ID == repo.Owner.ID
386+ isAuthor := user != nil && user.ID == pull.Author.ID
387+ if r.Method == "POST" {
388+ if user == nil {
389+ http.Redirect(w, r, "/login", http.StatusSeeOther)
390+ return
391+ }
392+ back := fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number)
393+ switch action {
394+ case "comment":
395+ if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil {
396+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
397+ return
398+ }
399+ case "close":
400+ if (isOwner || isAuthor) && pull.State == "open" {
401+ s.store.SetPullState(pull.ID, "closed", "")
402+ }
403+ case "reopen":
404+ if (isOwner || isAuthor) && pull.State == "closed" {
405+ s.store.SetPullState(pull.ID, "open", "")
406+ }
407+ case "update":
408+ if (isOwner || isAuthor) && pull.State == "open" {
409+ if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil {
410+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
411+ return
412+ }
413+ s.store.TouchPull(pull.ID)
414+ }
415+ case "merge":
416+ if !isOwner || pull.State != "open" {
417+ s.fail(w, r, 403, "Only the repository owner can merge")
418+ return
419+ }
420+ hash, err := g.Merge(pull.TargetBranch, pull.Ref(), fmt.Sprintf("Merge pull request #%d from %s\n\n%s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch, pull.Title), user.Name, user.Name+"@"+s.host)
421+ if err != nil {
422+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
423+ return
424+ }
425+ s.store.SetPullState(pull.ID, "merged", hash)
426+ default:
427+ s.fail(w, r, 404, "Page not found")
428+ return
429+ }
430+ http.Redirect(w, r, back, http.StatusSeeOther)
431+ return
432+ }
433+ head, err := g.Resolve(pull.Ref())
434+ if err != nil {
435+ s.fail(w, r, 500, "Pull request head is missing")
436+ return
437+ }
438+ data := map[string]any{"Page": rp, "Pull": pull, "Head": head, "Owner": isOwner, "Author": isAuthor,
439+ "Body": s.renderMarkdown([]byte(pull.Body))}
440+ base := pull.TargetBranch
441+ if pull.State == "merged" && pull.Merged != "" {
442+ base = pull.Merged + "^1"
443+ }
444+ if mb, err := g.MergeBase(base, head); err == nil {
445+ data["Base"] = mb
446+ commits, _ := g.LogRange(mb, head, 200)
447+ data["Commits"] = commits
448+ files, _ := g.DiffRange(mb, head)
449+ data["Files"] = files
450+ added, removed := 0, 0
451+ for _, f := range files {
452+ added += f.Added
453+ removed += f.Removed
454+ }
455+ data["Added"], data["Removed"] = added, removed
456+ if pull.State == "open" {
457+ data["Mergeable"] = g.Mergeable(pull.TargetBranch, head)
458+ if cur, err := g.Resolve("refs/heads/" + pull.TargetBranch); err == nil && cur == head {
459+ data["Mergeable"] = false
460+ }
461+ }
462+ } else {
463+ data["Unrelated"] = true
464+ }
465+ comments, _ := s.store.Comments(pull.ID)
466+ rendered := make([]map[string]any, len(comments))
467+ for i, c := range comments {
468+ rendered[i] = map[string]any{"Comment": c, "Body": s.renderMarkdown([]byte(c.Body))}
469+ }
470+ data["Comments"] = rendered
471+ s.render(w, r, "pull", page{Title: fmt.Sprintf("#%d %s", pull.Number, pull.Title), Error: r.URL.Query().Get("error"), Math: hasMath(data["Body"]), Data: data})
472+}
473+
474+func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) {
475+ user := s.require(w, r)
476+ if user == nil {
477+ return
478+ }
479+ if user.ID == repo.Owner.ID {
480+ http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
481+ return
482+ }
483+ fork, err := s.store.ForkRepo(*user, repo)
484+ if err != nil {
485+ s.fail(w, r, 400, err.Error())
486+ return
487+ }
488+ dir := s.repoDir(fork)
489+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err == nil {
490+ err = cloneRepo(s.repoDir(repo), dir)
491+ }
492+ if err != nil {
493+ s.store.DeleteRepo(fork.ID)
494+ s.fail(w, r, 500, err.Error())
495+ return
496+ }
497+ http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther)
498+}

static/style.css modified

@@ -171,3 +171,36 @@ input[type=file]{border:0;padding:0}
171171 .cropper canvas{display:block;max-width:100%;border:1px solid var(--line);cursor:move;touch-action:none}
172172 .cropper input[type=range]{width:100%;padding:0}
173173 @media(max-width:800px){.cards,.cards.commits,.columns,.columns.user,.pair,.avatarform{grid-template-columns:1fr}.profile .avatar{width:120px;height:120px}}
174+.twocol{display:grid;grid-template-columns:1fr 1fr;gap:2rem;margin-bottom:2rem}
175+.twocol label{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.8rem}
176+.twocol .legend{display:block;margin-bottom:.4rem}
177+.twocol label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.6rem}
178+.twocol label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
179+h3.plain{border:0;font-size:1.25rem}
180+.columns.browse{grid-template-columns:minmax(0,2fr) 1fr}
181+.search input[name=search]{width:100%}
182+.search .sort{display:flex;justify-content:flex-end;align-items:center;gap:1rem;margin:.5rem 0 1rem}
183+.search .sort label{display:flex;align-items:center;gap:.3rem}
184+.event .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.9rem}
185+.event p.tags{margin-top:.15rem}
186+.btn.block{display:block;text-align:center}
187+.titlerow{display:flex;align-items:center;gap:1rem}
188+.titlerow form{margin:0}
189+.count{background:#e9ecef;border-radius:.6rem;padding:0 .4rem;font-size:.8rem;color:var(--fg)}
190+.pullhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem}
191+nav.filters a{margin-right:1rem;color:var(--muted)}
192+nav.filters a.active{color:var(--fg);font-weight:600}
193+.state{display:inline-block;font-size:.75rem;padding:0 .4rem;border-radius:3px;color:#fff;vertical-align:middle;text-transform:lowercase}
194+.state.open{background:#1a7f37}
195+.state.merged{background:#6f42c1}
196+.state.closed{background:#c0392b}
197+.pulltitle{margin-bottom:1rem}
198+.pulltitle p{margin:.2rem 0}
199+.columns.pullbody{grid-template-columns:minmax(0,1fr) 20rem;margin-bottom:1.5rem}
200+.comment{border:1px solid var(--line);margin-bottom:.8rem}
201+.comment .who{background:var(--soft);padding:.3rem .6rem;border-bottom:1px solid var(--line)}
202+.comment .readme{padding:.4rem .6rem}
203+.comment .readme p{margin:.3rem 0}
204+.btn.block+form{margin-top:.4rem}
205+.btn:disabled{opacity:.5;cursor:default}
206+select{font:inherit;padding:.35rem .5rem;border:1px solid #ced4da;background:#fff}

store.go modified

@@ -65,8 +65,16 @@ var migrations = []string{
6565 `alter table users add column website text not null default ''`,
6666 `alter table users add column avatar integer not null default 0`,
6767 `alter table users add column display text not null default ''`,
68+ `alter table repos add column tags text not null default ''`,
69+ `alter table repos add column parent integer not null default 0`,
6870 }
6971
72+const (
73+ Public = 0
74+ Private = 1
75+ Unlisted = 2
76+)
77+
7078 type Profile struct {
7179 Display string
7280 Bio string
@@ -92,7 +100,38 @@ type Repo struct {
92100 Name string
93101 Description string
94102 Private bool
103+ Unlisted bool
104+ Tags []string
105+ Parent int64
95106 Created time.Time
107+ Updated time.Time
108+}
109+
110+var tagRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{0,23}$`)
111+
112+func parseTags(raw string) ([]string, error) {
113+ var tags []string
114+ for _, t := range strings.Split(raw, ",") {
115+ t = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t), "#")))
116+ if t == "" {
117+ continue
118+ }
119+ if !tagRe.MatchString(t) {
120+ return nil, errors.New("Tags are lowercase letters, digits, dot, plus or dash, at most 24 characters")
121+ }
122+ tags = append(tags, t)
123+ }
124+ if len(tags) > 3 {
125+ return nil, errors.New("Up to three tags")
126+ }
127+ return tags, nil
128+}
129+
130+func splitTags(raw string) []string {
131+ if raw == "" {
132+ return nil
133+ }
134+ return strings.Split(raw, ",")
96135 }
97136
98137 type Key struct {
@@ -131,6 +170,9 @@ func openStore(path string, readonly bool) (*Store, error) {
131170 return nil, err
132171 }
133172 }
173+ if _, err := db.Exec(pullSchema); err != nil {
174+ return nil, err
175+ }
134176 }
135177 return &Store{db}, nil
136178 }
@@ -355,13 +397,17 @@ func (s *Store) UserByToken(name, token string) (User, bool) {
355397 return u, err == nil
356398 }
357399
358-func (s *Store) CreateRepo(owner User, name, description string, private bool) (Repo, error) {
400+func (s *Store) CreateRepo(owner User, name, description string, visibility int, rawTags string) (Repo, error) {
359401 if !validName(name) || name == "avatar" {
360402 return Repo{}, errors.New("Repository names are lowercase letters, digits, dot, dash or underscore")
361403 }
404+ tags, err := parseTags(rawTags)
405+ if err != nil {
406+ return Repo{}, err
407+ }
362408 now := time.Now()
363- res, err := s.db.Exec(`insert into repos(owner, name, description, private, created) values (?, ?, ?, ?, ?)`,
364- owner.ID, name, description, private, now.Unix())
409+ res, err := s.db.Exec(`insert into repos(owner, name, description, private, tags, created) values (?, ?, ?, ?, ?, ?)`,
410+ owner.ID, name, description, visibility, strings.Join(tags, ","), now.Unix())
365411 if err != nil {
366412 if strings.Contains(err.Error(), "UNIQUE") {
367413 return Repo{}, errors.New("You already have a repository with that name")
@@ -369,7 +415,7 @@ func (s *Store) CreateRepo(owner User, name, description string, private bool) (
369415 return Repo{}, err
370416 }
371417 id, _ := res.LastInsertId()
372- return Repo{ID: id, Owner: owner, Name: name, Description: description, Private: private, Created: now}, nil
418+ return Repo{ID: id, Owner: owner, Name: name, Description: description, Private: visibility == Private, Unlisted: visibility == Unlisted, Tags: tags, Created: now}, nil
373419 }
374420
375421 func scanRepos(rows *sql.Rows) ([]Repo, error) {
@@ -378,11 +424,14 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) {
378424 for rows.Next() {
379425 var r Repo
380426 var private int
427+ var tags string
381428 var created, ucreated int64
382- if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil {
429+ if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &tags, &r.Parent, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil {
383430 return nil, err
384431 }
385- r.Private = private != 0
432+ r.Private = private == Private
433+ r.Unlisted = private == Unlisted
434+ r.Tags = splitTags(tags)
386435 r.Created = time.Unix(created, 0)
387436 r.Owner.Created = time.Unix(ucreated, 0)
388437 repos = append(repos, r)
@@ -390,7 +439,19 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) {
390439 return repos, rows.Err()
391440 }
392441
393-const repoColumns = `r.id, r.name, r.description, r.private, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner`
442+const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner`
443+
444+func (s *Store) RepoByID(id int64) (Repo, error) {
445+ rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, id)
446+ if err != nil {
447+ return Repo{}, err
448+ }
449+ repos, err := scanRepos(rows)
450+ if err != nil || len(repos) == 0 {
451+ return Repo{}, ErrNotFound
452+ }
453+ return repos[0], nil
454+}
394455
395456 func (s *Store) RepoByPath(owner, name string) (Repo, error) {
396457 rows, err := s.db.Query(`select `+repoColumns+` where u.name = ? and r.name = ?`, owner, name)
@@ -423,11 +484,25 @@ func (s *Store) RecentRepos(n int) ([]Repo, error) {
423484 return scanRepos(rows)
424485 }
425486
426-func (s *Store) UpdateRepo(id int64, description string, private bool) error {
427- _, err := s.db.Exec(`update repos set description = ?, private = ? where id = ?`, description, private, id)
487+func (s *Store) UpdateRepo(id int64, description string, visibility int, rawTags string) error {
488+ tags, err := parseTags(rawTags)
489+ if err != nil {
490+ return err
491+ }
492+ _, err = s.db.Exec(`update repos set description = ?, private = ?, tags = ? where id = ?`, description, visibility, strings.Join(tags, ","), id)
428493 return err
429494 }
430495
496+func (s *Store) PublicRepos(query, tag string) ([]Repo, error) {
497+ like := "%" + strings.ReplaceAll(strings.ReplaceAll(query, "%", ""), "_", "") + "%"
498+ rows, err := s.db.Query(`select `+repoColumns+` where r.private = 0 and (? = '' or r.name like ? or r.description like ? or u.name like ? or r.tags like ?) and (? = '' or ',' || r.tags || ',' like ?) order by r.created desc limit 500`,
499+ query, like, like, like, like, tag, "%,"+tag+",%")
500+ if err != nil {
501+ return nil, err
502+ }
503+ return scanRepos(rows)
504+}
505+
431506 func (s *Store) DeleteRepo(id int64) error {
432507 _, err := s.db.Exec(`delete from repos where id = ?`, id)
433508 return err

templates/create.html modified

@@ -1,9 +1,17 @@
11 {{define "content"}}
2-<div class="panel narrow"><h3>Create a new repository</h3>
3-<form method="post" action="/create" class="stack">
4-<label>Name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label>
5-<label>Description <input name="description" maxlength="200"></label>
6-<label class="check"><input type="checkbox" name="private"> This repository is private</label>
7-<div class="actions"><button class="btn primary">Create ▸</button></div>
8-</form></div>
2+<h3>Create new repository</h3>
3+<form method="post" action="/create" class="twocol">
4+<div>
5+<label>Repository name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}" placeholder="my-cool-project" value="{{.Data.Name}}"></label>
6+<label>Description <input name="description" maxlength="200" placeholder="Short description, shown in lists" value="{{.Data.Description}}"></label>
7+<label>Tags <input name="tags" placeholder="Up to three comma-separated topics" value="{{.Data.Tags}}"></label>
8+<div class="actions"><button class="btn primary">Create repository ▸</button></div>
9+</div>
10+<div>
11+<span class="legend">Visibility</span>
12+<label class="radio"><input type="radio" name="visibility" value="public" checked> Public<small>Shown on your profile and listed in the public index</small></label>
13+<label class="radio"><input type="radio" name="visibility" value="unlisted"> Unlisted<small>Visible to anyone who knows the URL, but not shown on your profile</small></label>
14+<label class="radio"><input type="radio" name="visibility" value="private"> Private<small>Only visible to you</small></label>
15+</div>
16+</form>
917 {{end}}

templates/index.html modified

@@ -14,7 +14,7 @@
1414 </div>
1515 <div class="cards">
1616 <section class="panel"><h3>Host a repository</h3><p>Registration is by invitation. Once you have an account, push any repository and it gets a page with its history, refs and README.</p><a class="btn primary" href="/register">Register ▸</a></section>
17-<section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="#recent">Recent repositories ▸</a></section>
17+<section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="/projects">Browse public projects ▸</a></section>
1818 <section class="panel"><h3>Already a member?</h3><p>Log in to see your repositories, manage SSH keys and tokens, and edit your profile.</p><a class="btn" href="/login">Log in ▸</a></section>
1919 </div>
2020 {{end}}
@@ -25,5 +25,5 @@
2525 </section>
2626 {{end}}
2727 {{define "repolist"}}
28-{{if .}}<div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="muted">Nothing here yet.</p>{{end}}
28+{{if .}}<div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</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="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="muted">Nothing here yet.</p>{{end}}
2929 {{end}}

templates/layout.html modified

@@ -15,6 +15,7 @@
1515 <header>
1616 <nav class="site">
1717 <a class="brand" href="/">{{.Host}}</a>
18+<a href="/projects">projects</a>
1819 <span class="spacer"></span>
1920 {{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<a href="/login">Log in</a> — <a href="/register">Register</a>{{end}}
2021 </nav>
@@ -29,10 +30,10 @@
2930 </header>
3031 {{with .Data}}{{with index . "Page"}}
3132 <div class="repohead">
32-<div class="inner"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
33+<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 and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn">Fork ▸</button></form>{{end}}</div>{{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>
3334 <div class="tabbar"><nav class="tabs inner">
3435 <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a>
35-{{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>{{end}}
36+{{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}}
3637 {{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}}
3738 </nav></div>
3839 </div>

templates/newpull.html added

@@ -0,0 +1,15 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h3>New pull request</h3>
4+<form method="post" action="/{{$p.Repo.Path}}/pulls/new" class="twocol">
5+<div>
6+<label>Title <input name="title" required autofocus maxlength="200" value="{{.Data.PullTitle}}"></label>
7+<label>Description <textarea name="body" rows="8" placeholder="Markdown supported">{{.Data.Body}}</textarea></label>
8+<div class="actions"><button class="btn primary">Create pull request ▸</button></div>
9+</div>
10+<div>
11+<label>Source branch <select name="source">{{range .Data.Sources}}<option value="{{.Value}}"{{if eq .Value $.Data.Source}} selected{{end}}>{{.Label}}</option>{{end}}</select><small>Branches of this repository and of your forks. Push a branch first if it is not listed.</small></label>
12+<label>Target branch <select name="target">{{range .Data.Branches}}<option value="{{.Name}}"{{if eq .Name $.Data.Target}} selected{{else if and (not $.Data.Target) (eq .Name $p.Ref)}} selected{{end}}>{{.Name}}</option>{{end}}</select></label>
13+</div>
14+</form>
15+{{end}}

templates/projects.html added

@@ -0,0 +1,24 @@
1+{{define "content"}}
2+<div class="columns browse">
3+<div class="main">
4+<h3 class="plain">Browse public projects</h3>
5+<form method="get" action="/projects" class="search">
6+<input name="search" value="{{.Data.Query}}" placeholder="Search all public projects">
7+<div class="sort">
8+<span>Sort results by</span>
9+<label><input type="radio" name="sort" value="updated"{{if ne .Data.Sort "created"}} checked{{end}}> Recently updated</label>
10+<label><input type="radio" name="sort" value="created"{{if eq .Data.Sort "created"}} checked{{end}}> Recently created</label>
11+{{if .Data.Tag}}<input type="hidden" name="tag" value="{{.Data.Tag}}">{{end}}
12+<button class="btn">Apply ▸</button>
13+</div>
14+</form>
15+{{if .Data.Tag}}<p class="muted">Showing projects tagged <a href="/projects?tag={{.Data.Tag}}">#{{.Data.Tag}}</a> · <a href="/projects">clear</a></p>{{end}}
16+{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">No projects match.</p>{{end}}
17+</div>
18+<aside>
19+<h3 class="plain">About</h3>
20+<p>{{.Data.Public}} public projects from {{.Data.Users}} users on {{.Host}}.</p>
21+{{if .User}}<a class="btn block" href="/create">Create a new repository ▸</a>{{else}}<a class="btn block" href="/register">Register ▸</a>{{end}}
22+</aside>
23+</div>
24+{{end}}

templates/pull.html added

@@ -0,0 +1,50 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+{{$pr := .Data.Pull}}
4+<div class="pulltitle">
5+<h3 class="plain"><span class="state {{$pr.State}}">{{$pr.State}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span></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>
7+</div>
8+<div class="columns pullbody">
9+<div class="main">
10+{{if $pr.Body}}<div class="comment"><div class="who"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> <span class="muted">{{ago $pr.Created}}</span></div><div class="readme">{{.Data.Body}}</div></div>{{end}}
11+{{range .Data.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span></div><div class="readme">{{.Body}}</div></div>{{end}}
12+{{if .User}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/comment" class="stack wide">
13+<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label>
14+<div class="actions"><button class="btn">Comment ▸</button></div>
15+</form>{{else}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
16+</div>
17+<aside>
18+<div class="panel">
19+{{if eq $pr.State "open"}}
20+{{if .Data.Unrelated}}<p>The branches have no common history.</p>
21+{{else if .Data.Mergeable}}<p>This branch can be merged automatically.</p>{{else}}<p>This branch has conflicts or nothing new to merge. Resolve locally, push, then update.</p>{{end}}
22+{{if .Data.Owner}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/merge"><button class="btn primary block"{{if not .Data.Mergeable}} disabled{{end}}>Merge pull request ▸</button></form>{{end}}
23+{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/update"><button class="btn block">Update from source ▸</button></form>
24+<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/close"><button class="btn danger block">Close ▸</button></form>{{end}}
25+{{else if eq $pr.State "closed"}}<p>Closed without merging.</p>{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}}
26+{{else}}<p>Merged into <code>{{$pr.TargetBranch}}</code>.</p>{{end}}
27+<p class="muted small">Fetch locally with<br><code>git fetch https://{{.Host}}/{{$p.Repo.Path}} {{$pr.Ref}}</code></p>
28+</div>
29+</aside>
30+</div>
31+{{if .Data.Commits}}
32+<h3>Commits</h3>
33+<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div>
34+{{end}}
35+{{if .Data.Files}}
36+<h3>Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></span></h3>
37+{{range .Data.Files}}
38+<section class="diff" id="{{.New}}">
39+<h4><a href="/{{$p.Repo.Path}}/tree/{{$.Data.Head}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h4>
40+{{if .Binary}}<p class="muted">Binary file.</p>{{end}}
41+{{range .Hunks}}
42+<table class="hunk">
43+<tr class="head"><td colspan="3">{{.Header}}</td></tr>
44+{{range .Lines}}<tr class="{{.Kind}}"><td class="n">{{if .Old}}{{.Old}}{{end}}</td><td class="n">{{if .New}}{{.New}}{{end}}</td><td class="l">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else if eq .Kind "ctx"}} {{end}}{{.Text}}</td></tr>{{end}}
45+</table>
46+{{end}}
47+</section>
48+{{end}}
49+{{end}}
50+{{end}}

templates/pulls.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<div class="pullhead">
4+<nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=merged"{{if eq .Data.State "merged"}} class="active"{{end}}>Merged</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav>
5+{{if $p.User}}<a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}}
6+</div>
7+{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span></h4><p class="muted small">{{if .SameRepo}}{{.SourceBranch}}{{else}}{{.SourceRepo.Path}}:{{.SourceBranch}}{{end}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}}
8+{{end}}

templates/reposettings.html modified

@@ -1,15 +1,23 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3-<div class="panel"><h3>Repository settings</h3>
4-<form method="post" action="/{{$p.Repo.Path}}/settings" class="stack">
3+<h3>Repository settings</h3>
4+<form method="post" action="/{{$p.Repo.Path}}/settings" class="twocol">
5+<div>
56 <label>Description <input name="description" value="{{$p.Repo.Description}}" maxlength="200"></label>
6-<label class="check"><input type="checkbox" name="private"{{if $p.Repo.Private}} checked{{end}}> This repository is private</label>
7-<div class="actions left"><button class="btn primary">Save ▸</button></div>
8-</form></div>
9-<div class="panel"><h3>Delete repository</h3>
10-<p>This permanently removes the repository and its history from the server.</p>
7+<label>Tags <input name="tags" value="{{range $i, $t := $p.Repo.Tags}}{{if $i}}, {{end}}{{$t}}{{end}}" placeholder="Up to three comma-separated topics"></label>
8+<div class="actions"><button class="btn primary">Save ▸</button></div>
9+</div>
10+<div>
11+<span class="legend">Visibility</span>
12+<label class="radio"><input type="radio" name="visibility" value="public"{{if not (or $p.Repo.Private $p.Repo.Unlisted)}} checked{{end}}> Public<small>Shown on your profile and listed in the public index</small></label>
13+<label class="radio"><input type="radio" name="visibility" value="unlisted"{{if $p.Repo.Unlisted}} checked{{end}}> Unlisted<small>Visible to anyone who knows the URL, but not shown on your profile</small></label>
14+<label class="radio"><input type="radio" name="visibility" value="private"{{if $p.Repo.Private}} checked{{end}}> Private<small>Only visible to you</small></label>
15+</div>
16+</form>
17+<h3>Delete repository</h3>
1118 <form method="post" action="/{{$p.Repo.Path}}/delete" class="stack">
19+<p>This permanently removes the repository and its history from the server.</p>
1220 <label>Type <code>{{$p.Repo.Name}}</code> to confirm <input name="confirm" required></label>
1321 <div class="actions left"><button class="btn danger">Delete repository ▸</button></div>
14-</form></div>
22+</form>
1523 {{end}}

templates/user.html modified

@@ -16,7 +16,7 @@
1616 {{if $own}}<a class="btn primary block" href="/create">Create a new repository ▸</a><a class="btn block" href="/settings">Edit your profile ▸</a>{{end}}
1717 </aside>
1818 <div class="main">
19-{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}}
19+{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><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="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}}
2020 </div>
2121 </div>
2222 {{end}}

web.go modified

@@ -13,6 +13,7 @@ import (
1313 "os"
1414 "path/filepath"
1515 "regexp"
16+ "slices"
1617 "strconv"
1718 "strings"
1819 "time"
@@ -135,6 +136,7 @@ func (s *Server) handler() http.Handler {
135136 w.Write(css)
136137 })
137138 mux.HandleFunc("GET /{$}", s.index)
139+ mux.HandleFunc("GET /projects", s.projects)
138140 mux.HandleFunc("GET /login", s.loginForm)
139141 mux.HandleFunc("POST /login", s.login)
140142 mux.HandleFunc("POST /logout", s.logout)
@@ -267,7 +269,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
267269 return
268270 }
269271 name := strings.TrimSpace(r.FormValue("name"))
270- repo, err := s.store.CreateRepo(*u, name, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on")
272+ repo, err := s.store.CreateRepo(*u, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags"))
271273 if err != nil {
272274 w.WriteHeader(400)
273275 s.render(w, r, "create", page{Title: "New repository", User: u, Error: err.Error()})
@@ -285,6 +287,55 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
285287 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
286288 }
287289
290+func visibility(v string) int {
291+ switch v {
292+ case "private":
293+ return Private
294+ case "unlisted":
295+ return Unlisted
296+ }
297+ return Public
298+}
299+
300+func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
301+ query := strings.TrimSpace(r.URL.Query().Get("search"))
302+ tag := strings.TrimSpace(r.URL.Query().Get("tag"))
303+ sort := r.URL.Query().Get("sort")
304+ repos, err := s.store.PublicRepos(query, tag)
305+ if err != nil {
306+ s.fail(w, r, 500, err.Error())
307+ return
308+ }
309+ for i := range repos {
310+ repos[i].Updated = repoUpdated(s.repoDir(repos[i]))
311+ }
312+ if sort != "created" {
313+ slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) })
314+ }
315+ users, public := s.store.Stats()
316+ s.render(w, r, "projects", page{Title: "Browse public projects", Data: map[string]any{"Repos": repos, "Query": query, "Tag": tag, "Sort": sort, "Users": users, "Public": public}})
317+}
318+
319+func repoUpdated(dir string) time.Time {
320+ var latest time.Time
321+ consider := func(p string) {
322+ if info, err := os.Stat(p); err == nil && info.ModTime().After(latest) {
323+ latest = info.ModTime()
324+ }
325+ }
326+ consider(filepath.Join(dir, "packed-refs"))
327+ for _, sub := range []string{"heads", "tags"} {
328+ entries, _ := os.ReadDir(filepath.Join(dir, "refs", sub))
329+ for _, e := range entries {
330+ consider(filepath.Join(dir, "refs", sub, e.Name()))
331+ }
332+ }
333+ if latest.IsZero() {
334+ consider(dir)
335+ }
336+ return latest
337+}
338+
288339 func (s *Server) repoDir(repo Repo) string {
289340 return filepath.Join(s.root, "repos", repo.Owner.Name, repo.Name+".git")
290341 }
@@ -547,6 +598,16 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
547598 s.log(w, r, repo, "")
548599 case strings.HasPrefix(rest, "commit/") && r.Method == "GET":
549600 s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/"))
601+ case rest == "pulls" && r.Method == "GET":
602+ s.pulls(w, r, repo)
603+ case rest == "pulls/new" && r.Method == "GET":
604+ s.newPull(w, r, repo)
605+ case rest == "pulls/new" && r.Method == "POST":
606+ s.createPull(w, r, repo)
607+ case strings.HasPrefix(rest, "pulls/"):
608+ s.pull(w, r, repo, strings.TrimPrefix(rest, "pulls/"))
609+ case rest == "fork" && r.Method == "POST":
610+ s.fork(w, r, repo)
550611 default:
551612 s.fail(w, r, 404, "Page not found")
552613 }
@@ -559,12 +620,20 @@ type repoPage struct {
559620 Owner bool
560621 Empty bool
561622 Tab string
623+ Open int
624+ Parent *Repo
625+ User *User
562626 }
563627
564628 func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
565629 g := Git{Dir: s.repoDir(repo)}
566630 user := s.current(r)
567- rp := repoPage{Repo: repo, Owner: user != nil && user.ID == repo.Owner.ID}
631+ rp := repoPage{Repo: repo, Owner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user}
632+ if repo.Parent != 0 {
633+ if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private {
634+ rp.Parent = &parent
635+ }
636+ }
568637 if g.Empty() {
569638 rp.Empty = true
570639 rp.Ref = g.Head()
@@ -837,8 +906,10 @@ func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
837906 s.fail(w, r, 403, "Not your repository")
838907 return
839908 }
840- if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on"); err != nil {
841- s.fail(w, r, 500, err.Error())
909+ if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")); err != nil {
910+ rp.Tab = "settings"
911+ w.WriteHeader(400)
912+ s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: err.Error(), Data: map[string]any{"Page": rp}})
842913 return
843914 }
844915 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)