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).
7979
8080Creating from a fork works anywhere the source can be typed as
8181=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
83a CLI operation. Retargeting moves an open
82every fork the viewer can push to in that form, and the repository
83header has the fork control, so the whole path — fork, edit a file,
84propose — runs in a browser. Retargeting moves an open
8485merge request onto another branch of the same repository and stales the
8586reviews, since an approval was of the diff against the old branch.
8687
@@ -133,6 +134,7 @@ format column and are always markdown.
133134| download an archive | yes | yes | n/a |
134135| edit a file | yes | yes | yes |
135136| create | yes | yes | yes |
137| fork | yes | yes | no |
136138| pin | yes | yes | yes |
137139| watch, mute | yes | yes | no |
138140| 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() {
8787 Usage: "mr close <owner/name> <n>", Run: runMRClose})
8888}
8989
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
9097func runRepoFork(c *Ctx, args []string) int {
9198 f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"})
9299 if err != nil {
@@ -133,7 +140,7 @@ func runRepoFork(c *Ctx, args []string) int {
133140 }
134141 }
135142 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) {
137144 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
138145 })
139146}
internal/control/output_test.go +4 −2
@@ -16,7 +16,8 @@ func TestNamedPayloadsRoundTrip(t *testing.T) {
1616 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
1717 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
1818 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{},
20 &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
2021 }
2122 for _, p := range payloads {
2223 name := reflect.TypeOf(p).Elem().Name()
@@ -39,7 +40,8 @@ func TestPayloadFieldsAreTagged(t *testing.T) {
3940 types := []any{
4041 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
4142 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
42 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{},
43 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{},
44 SearchResult{}, ReviewOut{},
4345 CheckOut{}, CommitOut{}, ServerOut{},
4446 }
4547 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)
221221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
222222}
223223
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
224242// repoForUser is repoFor with a write/read permission requirement for a
225243// logged-in user.
226244func (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 {
152152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
153153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
154154 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))},
155157 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
156158 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
157159 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true,
internal/httpd/web.go +3 −2
@@ -483,12 +483,13 @@ type treePage struct {
483483 LastCommits map[string]namedCommit
484484 Tip namedCommit
485485 Facts repoFacts
486 Notice string
486487}
487488
488489func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
489490 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
490491 // 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)})
492493 return
493494 }
494495 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,
529530 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
530531 readmeName, readmeHTML,
531532 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)})
533534}
534535
535536func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
internal/web/templates/layout.html +2 −1
@@ -64,7 +64,8 @@
6464 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
6565 <span class="grow"></span>
6666 {{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}}
6869 </div>
6970 {{$top := topTab (str $ "Tab")}}
7071 {{/* The header is the same on every tab: it sits above the tab bar,
internal/web/templates/tree.html +1
@@ -1,5 +1,6 @@
11{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
34{{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}}
45<div class="pathbar">
56 {{template "refmenu" .}}