Commit e88e46e41d

e88e46e41d53cb8abbe7bbd63311082dd7a4f8f5

parent: f1cc68cb41

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 15:17 UTC

control, web, wiki: fork a repository from the browser

The repository header gets a fork control dispatching repo fork, so the
contributor path — fork, edit a file, open the merge request — runs in a
browser. The repo home page gains the flash slot the refusal needs.

Closes #174

Layout: unified · split

.gitbay/wiki/Parity.org +4 −2
@@ -79,8 +79,9 @@ a thread hears nothing until they do (krz/gitbay#145).
79 79
80Creating from a fork works anywhere the source can be typed as 80Creating from a fork works anywhere the source can be typed as
81=owner/name:branch=. The web's source picker offers the branches of 81=owner/name:branch=. The web's source picker offers the branches of
82every fork the viewer can push to in that form; forking itself is still 82every fork the viewer can push to in that form, and the repository
83a CLI operation. Retargeting moves an open 83header has the fork control, so the whole path — fork, edit a file,
84propose — runs in a browser. Retargeting moves an open
84merge request onto another branch of the same repository and stales the 85merge request onto another branch of the same repository and stales the
85reviews, since an approval was of the diff against the old branch. 86reviews, since an approval was of the diff against the old branch.
86 87
@@ -133,6 +134,7 @@ format column and are always markdown.
133| download an archive | yes | yes | n/a | 134| download an archive | yes | yes | n/a |
134| edit a file | yes | yes | yes | 135| edit a file | yes | yes | yes |
135| create | yes | yes | yes | 136| create | yes | yes | yes |
137| fork | yes | yes | no |
136| pin | yes | yes | yes | 138| pin | yes | yes | yes |
137| watch, mute | yes | yes | no | 139| watch, mute | yes | yes | no |
138| settings, protection | yes | yes | yes | 140| settings, protection | yes | yes | yes |
e2e/forkweb_test.go added +78
@@ -0,0 +1,78 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Forking from the browser completes the contributor path: fork, edit a
12// file in the fork, open the merge request against the parent, without a
13// terminal at any step (#174).
14func TestForkWeb(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
19 "--email", "alice@example.test", "--verified")
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub",
21 "--email", "bob@example.test", "--verified")
22
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26 env := inst.gitEnv(aliceKey)
27 work := t.TempDir()
28 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
29 dir := filepath.Join(work, "w")
30 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "base")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35
36 bob := inst.login(t, bobKey)
37 base := inst.base() + "/alice/app"
38
39 // The control is on the repository header for a signed-in visitor.
40 if _, page := browserGet(t, bob, base); !strings.Contains(page, `/alice/app/fork"`) {
41 t.Fatalf("no fork control on the repository header:\n%s", page)
42 }
43
44 // Forking lands on the fork, which carries the parent's history.
45 if status, body := browserPost(t, bob, base+"/fork", url.Values{}); status != 200 ||
46 !strings.Contains(body, "bob/app") {
47 t.Fatalf("fork did not land on the new repository: %d\n%s", status, body)
48 }
49 out, _, _ := inst.ssh(t, bobKey, "", "repo", "list", "--json")
50 if !strings.Contains(out, `"path":"bob/app"`) {
51 t.Fatalf("fork not created:\n%s", out)
52 }
53
54 // Editing a file in the fork from the browser — the editor commits to
55 // the ref in the URL — then proposing it to the parent through the
56 // source picker.
57 if status, _ := browserPost(t, bob, inst.base()+"/bob/app/edit/main/f.txt", url.Values{
58 "content": {"y\n"}, "message": {"change f"}}); status != 200 {
59 t.Fatal("web edit in the fork failed")
60 }
61 if _, page := browserGet(t, bob, base+"/mrs/new"); !strings.Contains(page, `value="bob/app:main"`) {
62 t.Fatalf("the fork's branch is not offered:\n%s", page)
63 }
64 if status, _ := browserPost(t, bob, base+"/mrs/new", url.Values{
65 "source": {"bob/app:main"}, "target": {"main"}, "title": {"change"}}); status != 200 {
66 t.Fatal("mr create from the fork failed")
67 }
68 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); !strings.Contains(out, `"source":"bob/app:main"`) {
69 t.Fatalf("merge request not opened from the fork:\n%s", out)
70 }
71
72 // Forking twice collides on the name, and says so on the page rather
73 // than failing silently.
74 _, body := browserPost(t, bob, base+"/fork", url.Values{})
75 if !strings.Contains(body, `class="error"`) {
76 t.Errorf("a second fork reported nothing:\n%s", body)
77 }
78}
internal/control/mr.go +8 −1
@@ -87,6 +87,13 @@ func init() {
87 Usage: "mr close <owner/name> <n>", Run: runMRClose}) 87 Usage: "mr close <owner/name> <n>", Run: runMRClose})
88} 88}
89 89
90// ForkOut is what `repo fork` emits: where the fork landed, and what it
91// came from. Named so the web can send a person to the new repository.
92type ForkOut struct {
93 Path string `json:"path"`
94 ForkOf string `json:"fork_of"`
95}
96
90func runRepoFork(c *Ctx, args []string) int { 97func runRepoFork(c *Ctx, args []string) int {
91 f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"}) 98 f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"})
92 if err != nil { 99 if err != nil {
@@ -133,7 +140,7 @@ func runRepoFork(c *Ctx, args []string) int {
133 } 140 }
134 } 141 }
135 forkPath := c.User.Username + "/" + name 142 forkPath := c.User.Username + "/" + name
136 return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) { 143 return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) {
137 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) 144 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
138 }) 145 })
139} 146}
internal/control/output_test.go +4 −2
@@ -16,7 +16,8 @@ func TestNamedPayloadsRoundTrip(t *testing.T) {
16 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, 16 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, 17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, 18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, 19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{},
20 &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
20 } 21 }
21 for _, p := range payloads { 22 for _, p := range payloads {
22 name := reflect.TypeOf(p).Elem().Name() 23 name := reflect.TypeOf(p).Elem().Name()
@@ -39,7 +40,8 @@ func TestPayloadFieldsAreTagged(t *testing.T) {
39 types := []any{ 40 types := []any{
40 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, 41 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
41 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, 42 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
42 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{}, 43 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{},
44 SearchResult{}, ReviewOut{},
43 CheckOut{}, CommitOut{}, ServerOut{}, 45 CheckOut{}, CommitOut{}, ServerOut{},
44 } 46 }
45 for _, v := range types { 47 for _, v := range types {
internal/httpd/accounts.go +18
@@ -221,6 +221,24 @@ func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User)
221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
222} 222}
223 223
224// forkSubmit forks the repository under the viewer's account and sends
225// them to it. The command decides everything that matters — read access,
226// quota, name collisions — so a refusal comes back as its own message on
227// the page the button was pressed from (#174).
228func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
229 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
230 if !ok {
231 return
232 }
233 var fork control.ForkOut
234 if msg, ok := s.runControlInto(u, []string{"repo", "fork", repo.Path()}, &fork); !ok {
235 s.setFlash(w, msg)
236 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
237 return
238 }
239 http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
240}
241
224// repoForUser is repoFor with a write/read permission requirement for a 242// repoForUser is repoFor with a write/read permission requirement for a
225// logged-in user. 243// logged-in user.
226func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User, 244func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
internal/httpd/routes.go +2
@@ -152,6 +152,8 @@ func (s *Server) Routes() []Route {
152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, 152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, 153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
154 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, 154 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
155 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
156 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))},
155 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, 157 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
156 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, 158 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
157 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true, 159 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true,
internal/httpd/web.go +3 −2
@@ -483,12 +483,13 @@ type treePage struct {
483 LastCommits map[string]namedCommit 483 LastCommits map[string]namedCommit
484 Tip namedCommit 484 Tip namedCommit
485 Facts repoFacts 485 Facts repoFacts
486 Notice string
486} 487}
487 488
488func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { 489func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
489 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { 490 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
490 // Empty repo: render the page with no entries rather than 404. 491 // Empty repo: render the page with no entries rather than 404.
491 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"}) 492 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)})
492 return 493 return
493 } 494 }
494 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) 495 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
@@ -529,7 +530,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
529 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, 530 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
530 readmeName, readmeHTML, 531 readmeName, readmeHTML,
531 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), 532 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
532 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts}) 533 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)})
533} 534}
534 535
535func (s *Server) blob(w http.ResponseWriter, r *http.Request) { 536func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
internal/web/templates/layout.html +2 −1
@@ -64,7 +64,8 @@
64 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} 64 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
65 <span class="grow"></span> 65 <span class="grow"></span>
66 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> 66 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
67 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>{{end}} 67 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
68 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
68 </div> 69 </div>
69 {{$top := topTab (str $ "Tab")}} 70 {{$top := topTab (str $ "Tab")}}
70 {{/* The header is the same on every tab: it sits above the tab bar, 71 {{/* The header is the same on every tab: it sits above the tab bar,
internal/web/templates/tree.html +1
@@ -1,5 +1,6 @@
1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
3{{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}} 4{{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}}
4<div class="pathbar"> 5<div class="pathbar">
5 {{template "refmenu" .}} 6 {{template "refmenu" .}}