Commit 34a78ef570

34a78ef570fbffbfc01e61a70c85f049c100fc5f

parent: 5671f4a322

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-05 05:28 UTC

wiki: pages move into .gitbay/wiki on the default branch

The companion bare repo (<owner>/<name>.wiki.git) is gone: its SSH
route, the wikiDir helpers that resolved it, the reserved .wiki name
suffix, and its rename/delete handling. Pages now live at
.gitbay/wiki/*.{md,org,markdown} on the repository's default branch,
resolved and access-checked through the parent exactly as before.
wiki list and wiki show keep their argv, JSON fields and exit codes,
except that a repository with no .gitbay/wiki now reports an empty
list instead of erroring, since that is the common case for every
repository until the one production wiki is migrated by hand.

Writing is unchanged in kind: a push, or repo commit-file, like any
other file. No wiki-specific write command is added.

InitBare's empty-hooksPath branch was reachable only from the removed
companion init call; removed along with the comment describing it.

Closes #170

Layout: unified · split

cmd/gitbayd/backup.go +1 −1
@@ -267,7 +267,7 @@ func verifyBackup(path string) error {
267 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", ")) 267 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
268 } 268 }
269 if extra > 0 { 269 if extra > 0 {
270 fmt.Printf("%d repositories in the archive that the database does not name (deleted after the snapshot, or a wiki)\n", extra) 270 fmt.Printf("%d repositories in the archive that the database does not name (deleted after the snapshot)\n", extra)
271 } 271 }
272 return nil 272 return nil
273} 273}
e2e/readonly_test.go +1 −1
@@ -152,7 +152,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
152 "wiki show": {"alice/app"}, 152 "wiki show": {"alice/app"},
153 } 153 }
154 // Reads whose subject legitimately does not exist in this fixture. 154 // Reads whose subject legitimately does not exist in this fixture.
155 notFoundOK := map[string]bool{"wiki list": true, "wiki show": true, "repo deps status": true} 155 notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
156 156
157 dbPath := filepath.Join(inst.root, "gitbay.db") 157 dbPath := filepath.Join(inst.root, "gitbay.db")
158 before := dbFingerprint(t, dbPath) 158 before := dbFingerprint(t, dbPath)
e2e/wiki_test.go +55 −28
@@ -11,7 +11,8 @@ func TestWikis(t *testing.T) {
11 inst := startInstance(t) 11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice") 12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob") 13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") 14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
15 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") 16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
16 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { 17 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
17 t.Fatal("repo create failed") 18 t.Fatal("repo create failed")
@@ -20,8 +21,8 @@ func TestWikis(t *testing.T) {
20 t.Fatal("private repo create failed") 21 t.Fatal("private repo create failed")
21 } 22 }
22 23
23 // No wiki yet: the tab is absent, the page shows the push hint, and 24 // No wiki yet: the tab is absent, the page shows the missing hint, and
24 // cloning the companion says so. 25 // listing reports no wiki rather than erroring.
25 _, body := inst.get(t, "/alice/app") 26 _, body := inst.get(t, "/alice/app")
26 if strings.Contains(body, ">Wiki<") { 27 if strings.Contains(body, ">Wiki<") {
27 t.Fatal("wiki tab shown with no wiki") 28 t.Fatal("wiki tab shown with no wiki")
@@ -30,26 +31,29 @@ func TestWikis(t *testing.T) {
30 if !strings.Contains(body, "no wiki yet") { 31 if !strings.Contains(body, "no wiki yet") {
31 t.Fatal("missing-wiki hint absent") 32 t.Fatal("missing-wiki hint absent")
32 } 33 }
33 env := inst.gitEnv(aliceKey) 34 if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json"); code != 0 {
34 if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "w"); code == 0 || !strings.Contains(out, "no wiki yet") { 35 t.Fatalf("wiki list on a repo without one: %s", errOut)
35 t.Fatalf("clone of absent wiki: %d\n%s", code, out) 36 } else if !strings.Contains(out, `"pages":[]`) {
37 t.Errorf("wiki list on a repo without one returned pages: %s", out)
36 } 38 }
37 39
38 // First push creates the wiki. A reader without write cannot push it. 40 // Pages are ordinary files: pushing them creates the wiki.
41 env := inst.gitEnv(aliceKey)
39 work := t.TempDir() 42 work := t.TempDir()
40 mustGit(t, work, env, "init", "-q", "-b", "main", "w") 43 mustGit(t, work, env, "init", "-q", "-b", "main", "w")
41 dir := filepath.Join(work, "w") 44 dir := filepath.Join(work, "w")
42 os.WriteFile(filepath.Join(dir, "Home.md"), []byte( 45 os.MkdirAll(filepath.Join(dir, ".gitbay", "wiki"), 0o755)
46 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Home.md"), []byte(
43 "# welcome\n\nsee [Setup](Setup.md) and ![shot](shot.png)\n"), 0o644) 47 "# welcome\n\nsee [Setup](Setup.md) and ![shot](shot.png)\n"), 0o644)
44 os.WriteFile(filepath.Join(dir, "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644) 48 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644)
45 os.WriteFile(filepath.Join(dir, "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644) 49 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644)
46 mustGit(t, dir, env, "add", ".") 50 mustGit(t, dir, env, "add", ".")
47 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start") 51 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start")
48 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app.wiki"), "main") 52 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
49 53
50 benv := inst.gitEnv(bobKey) 54 benv := inst.gitEnv(bobKey)
51 if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app.wiki"), "main"); code == 0 && !strings.Contains(out, "denied") { 55 if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app"), "main"); code == 0 && !strings.Contains(out, "denied") {
52 t.Fatalf("reader pushed the wiki: %d\n%s", code, out) 56 t.Fatalf("reader pushed the repository: %d\n%s", code, out)
53 } 57 }
54 58
55 // Rendering: home resolves, tab appears, links rewrite to wiki pages 59 // Rendering: home resolves, tab appears, links rewrite to wiki pages
@@ -115,10 +119,12 @@ func TestWikis(t *testing.T) {
115 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 { 119 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 {
116 t.Error("wiki show escaped the repository") 120 t.Error("wiki show escaped the repository")
117 } 121 }
118 // A repository with no wiki says so rather than failing oddly. 122 // A repository with no wiki says so rather than failing oddly, even
119 if _, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive"); code == 0 || 123 // for its owner.
120 !strings.Contains(errOut, "no wiki") { 124 if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive", "--json"); code != 0 {
121 t.Errorf("wiki list on a repo without one: %d %s", code, errOut) 125 t.Fatalf("wiki list on a repo without one: %s", errOut)
126 } else if !strings.Contains(out, `"pages":[]`) {
127 t.Errorf("wiki list on a repo without one returned pages: %s", out)
122 } 128 }
123 // Wiki access derives from the parent: a stranger gets nothing. 129 // Wiki access derives from the parent: a stranger gets nothing.
124 if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 { 130 if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 {
@@ -126,24 +132,45 @@ func TestWikis(t *testing.T) {
126 } 132 }
127 133
128 // 404-parity: a private repo's wiki is invisible, over web and git. 134 // 404-parity: a private repo's wiki is invisible, over web and git.
129 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main")
130 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 { 135 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
131 t.Fatalf("private wiki page: %d", status) 136 t.Fatalf("private wiki page: %d", status)
132 } 137 }
133 if out, code := gitRun(t, t.TempDir(), benv, "clone", inst.sshURL("alice/secretive.wiki"), "x"); code == 0 || !strings.Contains(out, "not found") { 138
134 t.Fatalf("private wiki clone by outsider: %d\n%s", code, out) 139 // Pushing to <name>.wiki.git is refused now that the companion route
140 // is gone; there is no such repository.
141 if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "x"); code == 0 {
142 t.Fatalf("cloned a nonexistent companion: %s", out)
143 } else if !strings.Contains(out, "not found") {
144 t.Fatalf("clone of alice/app.wiki: %s", out)
135 } 145 }
136 146
137 // Repo names ending .wiki are refused (companion namespace). 147 // A repository may now be named something.wiki: the suffix is no
138 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 2 || !strings.Contains(errOut, "reserved") { 148 // longer reserved.
139 t.Fatalf(".wiki name allowed: %d %s", code, errOut) 149 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 0 {
150 t.Fatalf("something.wiki repo name refused: %s", errOut)
140 } 151 }
141 152
142 // Deleting the repo removes the wiki companion. 153 // repo commit-file writes a page on a repository that permits
143 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "delete", "alice/secretive", "--yes"); code != 0 { 154 // server-authored commits: it is the command behind the web editor,
144 t.Fatal("repo delete failed") 155 // and there is no wiki-specific write command.
156 if _, errOut, code := inst.ssh(t, aliceKey, "written by commit-file\n",
157 "repo", "commit-file", "alice/app", ".gitbay/wiki/Extra.md",
158 "--ref", "main", "--message", "'add a page'", "--file", "-"); code != 0 {
159 t.Fatalf("repo commit-file: %s", errOut)
160 }
161 out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Extra", "--json")
162 if code != 0 || !strings.Contains(out, "written by commit-file") {
163 t.Errorf("wiki show Extra: %s", out)
145 } 164 }
146 if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "secretive.wiki.git")); err == nil { 165
147 t.Fatal("wiki survived repo delete") 166 // A repository requiring verified signatures refuses repo commit-file,
167 // since the server cannot sign on the user's behalf.
168 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/app", "on"); code != 0 {
169 t.Fatal("require-signed failed")
170 }
171 if _, errOut, code := inst.ssh(t, aliceKey, "blocked\n",
172 "repo", "commit-file", "alice/app", ".gitbay/wiki/Blocked.md",
173 "--ref", "main", "--file", "-"); code == 0 || !strings.Contains(errOut, "requires signed commits") {
174 t.Errorf("repo commit-file not refused on a signed-commits repo: %d %s", code, errOut)
148 } 175 }
149} 176}
internal/control/repo.go +1 −7
@@ -392,11 +392,6 @@ func runRepoTransfer(c *Ctx, args []string) int {
392 } 392 }
393 return c.fail(protocol.ExitFailure, "moving repository: %v", err) 393 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
394 } 394 }
395 // The wiki companion follows its repo.
396 oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
397 if _, err := os.Stat(oldWiki); err == nil {
398 os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
399 }
400 newPath := newOwner + "/" + repo.Name 395 newPath := newOwner + "/" + repo.Name
401 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) { 396 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
402 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath) 397 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
@@ -429,7 +424,7 @@ func runRepoDelete(c *Ctx, args []string) int {
429} 424}
430 425
431// deleteRepo removes a repository the caller has already been cleared to 426// deleteRepo removes a repository the caller has already been cleared to
432// delete: the database row, then the directory and its wiki companion. 427// delete: the database row, then the directory.
433// 428//
434// There is deliberately no repo.deleted event. events.repo_id and 429// There is deliberately no repo.deleted event. events.repo_id and
435// webhooks.repo_id both cascade from repos, so recording one would delete 430// webhooks.repo_id both cascade from repos, so recording one would delete
@@ -449,7 +444,6 @@ func deleteRepo(c *Ctx, repo store.Repo) int {
449 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil { 444 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
450 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err) 445 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
451 } 446 }
452 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
453 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { 447 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
454 fmt.Fprintf(w, "deleted %s\n", repo.Path()) 448 fmt.Fprintf(w, "deleted %s\n", repo.Path())
455 }) 449 })
internal/control/wiki.go +24 −28
@@ -4,7 +4,6 @@ import (
4 "encoding/base64" 4 "encoding/base64"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "os"
8 "path" 7 "path"
9 "strings" 8 "strings"
10 9
@@ -31,39 +30,36 @@ func init() {
31 }) 30 })
32} 31}
33 32
34// A wiki lives in a companion bare repo beside its parent, so prose 33// Wiki pages are files under .gitbay/wiki on the repository's default
35// edits stay out of the code repository's history, its protected 34// branch, alongside ci.yml and CODEOWNERS. They have no store row of
36// branches and its builds. The companion has no store row of its own — 35// their own — access derives from the parent, exactly as it does for any
37// access derives from the parent, exactly as it does for git over SSH — 36// other path in the repository.
38// which is why it needs commands rather than being addressable as a
39// repository.
40// 37//
41// Editing stays a push to <repo>.wiki.git. That is the whole write 38// Writing is a push, or repo commit-file, like any other file in the
42// interface, on every surface, and there is nothing for a command to 39// repository. There is no wiki-specific write command.
43// add.
44 40
45// wikiExts are the page formats the web renders, in resolution order. 41// wikiExts are the page formats the web renders, in resolution order.
46var wikiExts = []string{".md", ".org", ".markdown"} 42var wikiExts = []string{".md", ".org", ".markdown"}
47 43
48// wikiDir resolves the parent, checks read access, and returns the 44// wikiTreePath is where wiki pages live in a repository's tree.
49// companion's path. A parent you cannot read has no wiki you can read. 45const wikiTreePath = ".gitbay/wiki"
50func wikiDir(c *Ctx, spec string) (repo store.Repo, dir string, code int) { 46
47// wikiDir resolves the parent, checks read access, and returns its
48// directory and default branch. A parent you cannot read has no wiki you
49// can read.
50func wikiDir(c *Ctx, spec string) (repo store.Repo, dir, branch string, code int) {
51 parent, code := resolveRepo(c, spec, policy.CanRead) 51 parent, code := resolveRepo(c, spec, policy.CanRead)
52 if code >= 0 { 52 if code >= 0 {
53 return store.Repo{}, "", code 53 return store.Repo{}, "", "", code
54 }
55 d := RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name+".wiki")
56 if _, err := os.Stat(d); err != nil {
57 return store.Repo{}, "", c.fail(protocol.ExitNotFound, "%s has no wiki", parent.Path())
58 } 54 }
59 return parent, d, -1 55 return parent, RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name), parent.DefaultBranch, -1
60} 56}
61 57
62// wikiPages lists the page names in the companion, without extensions. 58// wikiPages lists the page names under .gitbay/wiki, without extensions.
63func wikiPages(dir string) []string { 59func wikiPages(dir, branch string) []string {
64 entries, err := gitutil.ListTree(dir, "main", "") 60 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
65 if err != nil { 61 if err != nil {
66 return nil // the companion exists but has no commits yet 62 return nil // no .gitbay/wiki tree on this branch
67 } 63 }
68 var pages []string 64 var pages []string
69 for _, e := range entries { 65 for _, e := range entries {
@@ -85,11 +81,11 @@ func runWikiList(c *Ctx, args []string) int {
85 if len(args) != 1 { 81 if len(args) != 1 {
86 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>") 82 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>")
87 } 83 }
88 repo, dir, code := wikiDir(c, args[0]) 84 repo, dir, branch, code := wikiDir(c, args[0])
89 if code >= 0 { 85 if code >= 0 {
90 return code 86 return code
91 } 87 }
92 pages := wikiPages(dir) 88 pages := wikiPages(dir, branch)
93 type out struct { 89 type out struct {
94 Path string `json:"path"` 90 Path string `json:"path"`
95 Home string `json:"home,omitempty"` 91 Home string `json:"home,omitempty"`
@@ -126,11 +122,11 @@ func runWikiShow(c *Ctx, args []string) int {
126 if len(args) < 1 || len(args) > 2 { 122 if len(args) < 1 || len(args) > 2 {
127 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]") 123 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]")
128 } 124 }
129 repo, dir, code := wikiDir(c, args[0]) 125 repo, dir, branch, code := wikiDir(c, args[0])
130 if code >= 0 { 126 if code >= 0 {
131 return code 127 return code
132 } 128 }
133 pages := wikiPages(dir) 129 pages := wikiPages(dir, branch)
134 page := wikiHome(pages) 130 page := wikiHome(pages)
135 if len(args) == 2 { 131 if len(args) == 2 {
136 page = strings.TrimSuffix(args[1], path.Ext(args[1])) 132 page = strings.TrimSuffix(args[1], path.Ext(args[1]))
@@ -144,7 +140,7 @@ func runWikiShow(c *Ctx, args []string) int {
144 } 140 }
145 141
146 for _, ext := range wikiExts { 142 for _, ext := range wikiExts {
147 raw, err := gitutil.ReadBlob(dir, "main", page+ext, c.Cfg.Limits.MaxBlobBytes) 143 raw, err := gitutil.ReadBlob(dir, branch, wikiTreePath+"/"+page+ext, c.Cfg.Limits.MaxBlobBytes)
148 if err != nil { 144 if err != nil {
149 continue 145 continue
150 } 146 }
internal/gitutil/gitutil.go +3 −7
@@ -25,13 +25,9 @@ func InitBare(path, defaultBranch, hooksPath string) error {
25 if out, err := cmd.CombinedOutput(); err != nil { 25 if out, err := cmd.CombinedOutput(); err != nil {
26 return fmt.Errorf("git init: %v\n%s", err, out) 26 return fmt.Errorf("git init: %v\n%s", err, out)
27 } 27 }
28 // An empty hooksPath leaves the bare repo with no hooks — used for 28 cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
29 // companion repos (wikis) that carry no ref policy. 29 if out, err := cmd.CombinedOutput(); err != nil {
30 if hooksPath != "" { 30 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
31 cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
32 if out, err := cmd.CombinedOutput(); err != nil {
33 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
34 }
35 } 31 }
36 return nil 32 return nil
37} 33}
internal/httpd/web.go +1 −1
@@ -333,7 +333,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
333 Mirrors: mirrors, 333 Mirrors: mirrors,
334 Pinned: pinned, 334 Pinned: pinned,
335 Watch: watch, 335 Watch: watch,
336 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", 336 HasWiki: s.hasWiki(repo),
337 Host: s.cfg.SiteHost(), 337 Host: s.cfg.SiteHost(),
338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), 338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
339 Repo: repo, 339 Repo: repo,
internal/httpd/wiki.go +18 −26
@@ -3,7 +3,6 @@ package httpd
3import ( 3import (
4 "html/template" 4 "html/template"
5 "net/http" 5 "net/http"
6 "os"
7 "path" 6 "path"
8 "strings" 7 "strings"
9 8
@@ -15,16 +14,24 @@ import (
15 "gitbay.org/gitbay/internal/store" 14 "gitbay.org/gitbay/internal/store"
16) 15)
17 16
18// wikiDir returns the companion repo path, or "" when the repo has none. 17// wikiTreePath is where wiki pages live in a repository's tree.
19func (s *Server) wikiDir(owner, name string) string { 18const wikiTreePath = ".gitbay/wiki"
20 dir := control.RepoDir(s.cfg.Server.Root, owner, name+".wiki") 19
21 if _, err := os.Stat(dir); err != nil { 20// wikiDir returns repo's directory and default branch, where wiki pages
22 return "" 21// are resolved from .gitbay/wiki.
23 } 22func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
24 return dir 23 return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch
24}
25
26// hasWiki reports whether repo's default branch holds a non-empty
27// .gitbay/wiki tree.
28func (s *Server) hasWiki(repo store.Repo) bool {
29 dir, branch := s.wikiDir(repo)
30 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
31 return err == nil && len(entries) > 0
25} 32}
26 33
27// wiki renders a page from the repo's wiki companion. The home page is 34// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
28// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and 35// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and
29// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs. 36// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs.
30func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { 37func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
@@ -33,17 +40,6 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
33 return 40 return
34 } 41 }
35 p.Tab = "wiki" 42 p.Tab = "wiki"
36 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name)
37 if dir == "" {
38 s.render(w, "wiki.html", struct {
39 repoPage
40 Page string
41 PageHTML template.HTML
42 Pages []string
43 Missing bool
44 }{repoPage: p, Missing: true})
45 return
46 }
47 var listing struct { 43 var listing struct {
48 Home string `json:"home"` 44 Home string `json:"home"`
49 Pages []string `json:"pages"` 45 Pages []string `json:"pages"`
@@ -97,12 +93,8 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
97 if !ok { 93 if !ok {
98 return 94 return
99 } 95 }
100 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name) 96 dir, branch := s.wikiDir(p.Repo)
101 if dir == "" { 97 data, err := gitutil.ReadBlob(dir, branch, path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/")), s.cfg.Limits.MaxBlobBytes)
102 s.notFound(w, r)
103 return
104 }
105 data, err := gitutil.ReadBlob(dir, "main", strings.Trim(r.PathValue("path"), "/"), s.cfg.Limits.MaxBlobBytes)
106 if err != nil { 98 if err != nil {
107 s.notFound(w, r) 99 s.notFound(w, r)
108 return 100 return
internal/policy/names.go −3
@@ -59,9 +59,6 @@ func ValidateName(name string) error {
59 if len(name) > 4 && name[len(name)-4:] == ".git" { 59 if len(name) > 4 && name[len(name)-4:] == ".git" {
60 return fmt.Errorf("invalid name %q: must not end in .git", name) 60 return fmt.Errorf("invalid name %q: must not end in .git", name)
61 } 61 }
62 if len(name) > 5 && name[len(name)-5:] == ".wiki" {
63 return fmt.Errorf("invalid name %q: .wiki names are reserved for wiki companion repositories", name)
64 }
65 return nil 62 return nil
66} 63}
67 64
internal/sshd/sshd.go −60
@@ -16,7 +16,6 @@ import (
16 "os" 16 "os"
17 "path/filepath" 17 "path/filepath"
18 "strconv" 18 "strconv"
19 "strings"
20 "sync" 19 "sync"
21 "sync/atomic" 20 "sync/atomic"
22 "time" 21 "time"
@@ -356,12 +355,6 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
356 } 355 }
357 write := service == "git-receive-pack" 356 write := service == "git-receive-pack"
358 357
359 // Wiki companion repos: ssh://.../owner/name.wiki.git. Access derives
360 // from the parent repo; the bare repo is created on first push.
361 if base, ok := strings.CutSuffix(strings.TrimSuffix(argv[1], ".git"), ".wiki"); ok {
362 return runWikiGit(cfg, st, user, scope, service, base, write, stdin, stdout, stderr)
363 }
364
365 repo, err := st.RepoByPath(argv[1]) 358 repo, err := st.RepoByPath(argv[1])
366 if err != nil { 359 if err != nil {
367 fmt.Fprintln(stderr, "repository not found") 360 fmt.Fprintln(stderr, "repository not found")
@@ -433,56 +426,3 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
433 } 426 }
434 return protocol.ExitOK 427 return protocol.ExitOK
435} 428}
436
437// runWikiGit serves a repo's wiki companion. The wiki carries no ref
438// policy (no hooks, no merge requests); access is exactly the parent
439// repo's, and the bare repo is created on the first push. Deploy keys —
440// bound to the repo's own git data for CI — cannot touch the wiki.
441func runWikiGit(cfg config.Config, st *store.Store, user store.User, scope, service, basePath string,
442 write bool, stdin io.Reader, stdout, stderr io.Writer) int {
443 repo, err := st.RepoByPath(basePath)
444 if err != nil {
445 fmt.Fprintln(stderr, "repository not found")
446 return protocol.ExitNotFound
447 }
448 if policy.IsDeployScope(scope) {
449 fmt.Fprintln(stderr, "repository not found")
450 return protocol.ExitNotFound
451 }
452 grant, err := st.AccessRole(repo.ID, user.ID)
453 if err != nil {
454 fmt.Fprintln(stderr, "internal error")
455 return protocol.ExitFailure
456 }
457 if !policy.CanRead(user, repo, grant) {
458 fmt.Fprintln(stderr, "repository not found")
459 return protocol.ExitNotFound
460 }
461 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
462 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on the wiki\n", scope, service)
463 return protocol.ExitDenied
464 }
465 if write && !policy.CanWrite(user, repo, grant) {
466 fmt.Fprintf(stderr, "write access to %s wiki denied\n", repo.Path())
467 return protocol.ExitDenied
468 }
469 if write && repo.Settings.Archived {
470 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
471 return protocol.ExitDenied
472 }
473 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
474 if _, err := os.Stat(dir); err != nil {
475 if !write {
476 fmt.Fprintln(stderr, "this repository has no wiki yet")
477 return protocol.ExitNotFound
478 }
479 if err := gitutil.InitBare(dir, "main", ""); err != nil {
480 fmt.Fprintln(stderr, "initializing wiki failed")
481 return protocol.ExitFailure
482 }
483 }
484 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, nil, cfg.Limits.MaxPackBytes); err != nil {
485 return protocol.ExitFailure
486 }
487 return protocol.ExitOK
488}
internal/web/templates/wiki.html +3 −3
@@ -1,8 +1,8 @@
1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1> 3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1>
4{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br> 4{{if .Missing}}<p class="empty-note">no wiki yet — add pages under <code>.gitbay/wiki/</code> on the default branch:<br>
5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p> 5push <code>.gitbay/wiki/Home.md</code> (or .org), or use the file editor.</p>
6{{else}} 6{{else}}
7<div class="wikilayout"> 7<div class="wikilayout">
8<div class="wikipage"> 8<div class="wikipage">
@@ -14,7 +14,7 @@
14<nav class="wikinav"> 14<nav class="wikinav">
15<p class="meta">pages</p> 15<p class="meta">pages</p>
16<ul>{{range .Pages}}<li><a {{if eq . $.Page}}class="active" {{end}}href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/wiki/{{.}}">{{.}}</a></li>{{end}}</ul> 16<ul>{{range .Pages}}<li><a {{if eq . $.Page}}class="active" {{end}}href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/wiki/{{.}}">{{.}}</a></li>{{end}}</ul>
17<p class="meta">edit by push:<br><code>{{.Repo.Path}}.wiki.git</code></p> 17<p class="meta">edit under <code>.gitbay/wiki/</code> — push, or the file editor.</p>
18</nav> 18</nav>
19</div> 19</div>
20{{end}} 20{{end}}