Wikis live in the repository !263

merged merged by cmc on 2026-09-05 06:19 UTC · krz/gitbay:wiki-in-repo into main

13 files changed, +144 −163

Layout: unified · split

.gitbay/ci.yml +6
@@ -15,6 +15,8 @@ jobs:
1515 steps:
1616 - missing=""; for t in git git-lfs gpg; do command -v "$t" >/dev/null || missing="$missing $t"; done; test -x /usr/sbin/sshd || missing="$missing sshd"; test -z "$missing" || { echo "runner is missing:$missing"; exit 1; }
1717 - go test ./... -count=1 -timeout 20m
18 paths-ignore:
19 - .gitbay/wiki/**
1820 # Vulnerability scanning, separate from test so a newly published advisory
1921 # against unchanged code does not mask a real test failure. It fails the
2022 # build on purpose: an advisory that only lands in a report nobody reads is
@@ -23,6 +25,8 @@ jobs:
2325 vuln:
2426 steps:
2527 - go run golang.org/x/vuln/cmd/govulncheck@latest ./...
28 paths-ignore:
29 - .gitbay/wiki/**
2630 # SonarCloud static analysis. Report-only: unlike vuln this does not
2731 # gate, so a first scan of an existing codebase does not turn every
2832 # build red before anyone has read what it says. Flip the trailing
@@ -66,3 +70,5 @@ jobs:
6670 fi
6771 SONAR_HOST_URL=https://sonarcloud.io \
6872 "$SCANNER/bin/sonar-scanner" -Dsonar.scm.revision="${GITBAY_SHA:-}" $BRANCH_ARG || true
73 paths-ignore:
74 - .gitbay/wiki/**
README.org +1 −2
@@ -104,8 +104,7 @@ via =gitbay man=, completions via =gitbay completion <shell>=.
104104
105105* Documentation
106106
107Docs live in [[https://gitbay.org/krz/gitbay/wiki][the wiki]] — itself a git repository
108(=git clone ssh://git@gitbay.org/krz/gitbay.wiki.git=), dogfooding the
107Docs live in [[https://gitbay.org/krz/gitbay/wiki][the wiki]], dogfooding the
109108wiki feature:
110109
111110- [[https://gitbay.org/krz/gitbay/wiki/Users][user guide]] — accounts, keys, verified commits, repos, issues, MRs, scripting
cmd/gitbayd/backup.go +1 −1
@@ -267,7 +267,7 @@ func verifyBackup(path string) error {
267267 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
268268 }
269269 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)
271271 }
272272 return nil
273273}
e2e/readonly_test.go +1 −1
@@ -152,7 +152,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
152152 "wiki show": {"alice/app"},
153153 }
154154 // 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}
156156
157157 dbPath := filepath.Join(inst.root, "gitbay.db")
158158 before := dbFingerprint(t, dbPath)
e2e/wiki_test.go +63 −28
@@ -11,7 +11,8 @@ func TestWikis(t *testing.T) {
1111 inst := startInstance(t)
1212 aliceKey := inst.newKey(t, "alice")
1313 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")
1516 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
1617 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
1718 t.Fatal("repo create failed")
@@ -20,8 +21,8 @@ func TestWikis(t *testing.T) {
2021 t.Fatal("private repo create failed")
2122 }
2223
23 // No wiki yet: the tab is absent, the page shows the push hint, and
24 // cloning the companion says so.
24 // No wiki yet: the tab is absent, the page shows the missing hint, and
25 // listing reports no wiki rather than erroring.
2526 _, body := inst.get(t, "/alice/app")
2627 if strings.Contains(body, ">Wiki<") {
2728 t.Fatal("wiki tab shown with no wiki")
@@ -30,26 +31,30 @@ func TestWikis(t *testing.T) {
3031 if !strings.Contains(body, "no wiki yet") {
3132 t.Fatal("missing-wiki hint absent")
3233 }
33 env := inst.gitEnv(aliceKey)
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("clone of absent wiki: %d\n%s", code, out)
34 if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json"); code != 0 {
35 t.Fatalf("wiki list on a repo without one: %s", errOut)
36 } else if !strings.Contains(out, `"pages":[]`) {
37 t.Errorf("wiki list on a repo without one returned pages: %s", out)
3638 }
3739
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)
3942 work := t.TempDir()
4043 mustGit(t, work, env, "init", "-q", "-b", "main", "w")
4144 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(
4347 "# 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)
45 os.WriteFile(filepath.Join(dir, "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644)
48 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644)
49 os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644)
50 os.WriteFile(filepath.Join(dir, "top.txt"), []byte("not part of the wiki\n"), 0o644)
4651 mustGit(t, dir, env, "add", ".")
4752 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start")
48 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app.wiki"), "main")
53 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
4954
5055 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") {
52 t.Fatalf("reader pushed the wiki: %d\n%s", code, out)
56 if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app"), "main"); code == 0 && !strings.Contains(out, "denied") {
57 t.Fatalf("reader pushed the repository: %d\n%s", code, out)
5358 }
5459
5560 // Rendering: home resolves, tab appears, links rewrite to wiki pages
@@ -76,6 +81,13 @@ func TestWikis(t *testing.T) {
7681 if status != 200 || !strings.HasPrefix(raw, "\x89PNG") {
7782 t.Fatalf("wiki raw: %d", status)
7883 }
84 // The raw route cannot climb out of .gitbay/wiki. A literal ".." is
85 // caught by the mux's own path cleaning, which would make this pass
86 // vacuously; percent-encoding it reaches the handler with real ".."
87 // segments in PathValue, which is what the guard has to refuse.
88 if status, body := inst.get(t, "/alice/app/wiki/_raw/%2e%2e/%2e%2e/top.txt"); status == 200 {
89 t.Fatalf("wiki raw escaped .gitbay/wiki: %d\n%s", status, body)
90 }
7991
8092 // A wiki is readable from every surface, not just a browser: the
8193 // commands are what the web dispatches, and what the CLI and the
@@ -115,10 +127,12 @@ func TestWikis(t *testing.T) {
115127 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 {
116128 t.Error("wiki show escaped the repository")
117129 }
118 // A repository with no wiki says so rather than failing oddly.
119 if _, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive"); code == 0 ||
120 !strings.Contains(errOut, "no wiki") {
121 t.Errorf("wiki list on a repo without one: %d %s", code, errOut)
130 // A repository with no wiki says so rather than failing oddly, even
131 // for its owner.
132 if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive", "--json"); code != 0 {
133 t.Fatalf("wiki list on a repo without one: %s", errOut)
134 } else if !strings.Contains(out, `"pages":[]`) {
135 t.Errorf("wiki list on a repo without one returned pages: %s", out)
122136 }
123137 // Wiki access derives from the parent: a stranger gets nothing.
124138 if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 {
@@ -126,24 +140,45 @@ func TestWikis(t *testing.T) {
126140 }
127141
128142 // 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")
130143 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
131144 t.Fatalf("private wiki page: %d", status)
132145 }
133 if out, code := gitRun(t, t.TempDir(), benv, "clone", inst.sshURL("alice/secretive.wiki"), "x"); code == 0 || !strings.Contains(out, "not found") {
134 t.Fatalf("private wiki clone by outsider: %d\n%s", code, out)
146
147 // Pushing to <name>.wiki.git is refused now that the companion route
148 // is gone; there is no such repository.
149 if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "x"); code == 0 {
150 t.Fatalf("cloned a nonexistent companion: %s", out)
151 } else if !strings.Contains(out, "not found") {
152 t.Fatalf("clone of alice/app.wiki: %s", out)
135153 }
136154
137 // Repo names ending .wiki are refused (companion namespace).
138 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 2 || !strings.Contains(errOut, "reserved") {
139 t.Fatalf(".wiki name allowed: %d %s", code, errOut)
155 // A repository may now be named something.wiki: the suffix is no
156 // longer reserved.
157 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 0 {
158 t.Fatalf("something.wiki repo name refused: %s", errOut)
140159 }
141160
142 // Deleting the repo removes the wiki companion.
143 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "delete", "alice/secretive", "--yes"); code != 0 {
144 t.Fatal("repo delete failed")
161 // repo commit-file writes a page on a repository that permits
162 // server-authored commits: it is the command behind the web editor,
163 // and there is no wiki-specific write command.
164 if _, errOut, code := inst.ssh(t, aliceKey, "written by commit-file\n",
165 "repo", "commit-file", "alice/app", ".gitbay/wiki/Extra.md",
166 "--ref", "main", "--message", "'add a page'", "--file", "-"); code != 0 {
167 t.Fatalf("repo commit-file: %s", errOut)
168 }
169 out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Extra", "--json")
170 if code != 0 || !strings.Contains(out, "written by commit-file") {
171 t.Errorf("wiki show Extra: %s", out)
145172 }
146 if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "secretive.wiki.git")); err == nil {
147 t.Fatal("wiki survived repo delete")
173
174 // A repository requiring verified signatures refuses repo commit-file,
175 // since the server cannot sign on the user's behalf.
176 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/app", "on"); code != 0 {
177 t.Fatal("require-signed failed")
178 }
179 if _, errOut, code := inst.ssh(t, aliceKey, "blocked\n",
180 "repo", "commit-file", "alice/app", ".gitbay/wiki/Blocked.md",
181 "--ref", "main", "--file", "-"); code == 0 || !strings.Contains(errOut, "requires signed commits") {
182 t.Errorf("repo commit-file not refused on a signed-commits repo: %d %s", code, errOut)
148183 }
149184}
internal/control/repo.go +1 −7
@@ -392,11 +392,6 @@ func runRepoTransfer(c *Ctx, args []string) int {
392392 }
393393 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
394394 }
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 }
400395 newPath := newOwner + "/" + repo.Name
401396 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
402397 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 {
429424}
430425
431426// 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.
433428//
434429// There is deliberately no repo.deleted event. events.repo_id and
435430// webhooks.repo_id both cascade from repos, so recording one would delete
@@ -449,7 +444,6 @@ func deleteRepo(c *Ctx, repo store.Repo) int {
449444 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
450445 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
451446 }
452 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
453447 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
454448 fmt.Fprintf(w, "deleted %s\n", repo.Path())
455449 })
internal/control/wiki.go +24 −28
@@ -4,7 +4,6 @@ import (
44 "encoding/base64"
55 "fmt"
66 "io"
7 "os"
87 "path"
98 "strings"
109
@@ -31,39 +30,36 @@ func init() {
3130 })
3231}
3332
34// A wiki lives in a companion bare repo beside its parent, so prose
35// edits stay out of the code repository's history, its protected
36// branches and its builds. The companion has no store row of its own —
37// access derives from the parent, exactly as it does for git over SSH —
38// which is why it needs commands rather than being addressable as a
39// repository.
33// Wiki pages are files under .gitbay/wiki on the repository's default
34// branch, alongside ci.yml and CODEOWNERS. They have no store row of
35// their own — access derives from the parent, exactly as it does for any
36// other path in the repository.
4037//
41// Editing stays a push to <repo>.wiki.git. That is the whole write
42// interface, on every surface, and there is nothing for a command to
43// add.
38// Writing is a push, or repo commit-file, like any other file in the
39// repository. There is no wiki-specific write command.
4440
4541// wikiExts are the page formats the web renders, in resolution order.
4642var wikiExts = []string{".md", ".org", ".markdown"}
4743
48// wikiDir resolves the parent, checks read access, and returns the
49// companion's path. A parent you cannot read has no wiki you can read.
50func wikiDir(c *Ctx, spec string) (repo store.Repo, dir string, code int) {
44// wikiTreePath is where wiki pages live in a repository's tree.
45const wikiTreePath = ".gitbay/wiki"
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) {
5151 parent, code := resolveRepo(c, spec, policy.CanRead)
5252 if code >= 0 {
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())
53 return store.Repo{}, "", "", code
5854 }
59 return parent, d, -1
55 return parent, RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name), parent.DefaultBranch, -1
6056}
6157
62// wikiPages lists the page names in the companion, without extensions.
63func wikiPages(dir string) []string {
64 entries, err := gitutil.ListTree(dir, "main", "")
58// wikiPages lists the page names under .gitbay/wiki, without extensions.
59func wikiPages(dir, branch string) []string {
60 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
6561 if err != nil {
66 return nil // the companion exists but has no commits yet
62 return nil // no .gitbay/wiki tree on this branch
6763 }
6864 var pages []string
6965 for _, e := range entries {
@@ -85,11 +81,11 @@ func runWikiList(c *Ctx, args []string) int {
8581 if len(args) != 1 {
8682 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>")
8783 }
88 repo, dir, code := wikiDir(c, args[0])
84 repo, dir, branch, code := wikiDir(c, args[0])
8985 if code >= 0 {
9086 return code
9187 }
92 pages := wikiPages(dir)
88 pages := wikiPages(dir, branch)
9389 type out struct {
9490 Path string `json:"path"`
9591 Home string `json:"home,omitempty"`
@@ -126,11 +122,11 @@ func runWikiShow(c *Ctx, args []string) int {
126122 if len(args) < 1 || len(args) > 2 {
127123 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]")
128124 }
129 repo, dir, code := wikiDir(c, args[0])
125 repo, dir, branch, code := wikiDir(c, args[0])
130126 if code >= 0 {
131127 return code
132128 }
133 pages := wikiPages(dir)
129 pages := wikiPages(dir, branch)
134130 page := wikiHome(pages)
135131 if len(args) == 2 {
136132 page = strings.TrimSuffix(args[1], path.Ext(args[1]))
@@ -144,7 +140,7 @@ func runWikiShow(c *Ctx, args []string) int {
144140 }
145141
146142 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)
148144 if err != nil {
149145 continue
150146 }
internal/gitutil/gitutil.go +3 −7
@@ -25,13 +25,9 @@ func InitBare(path, defaultBranch, hooksPath string) error {
2525 if out, err := cmd.CombinedOutput(); err != nil {
2626 return fmt.Errorf("git init: %v\n%s", err, out)
2727 }
28 // An empty hooksPath leaves the bare repo with no hooks — used for
29 // companion repos (wikis) that carry no ref policy.
30 if hooksPath != "" {
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 }
28 cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
29 if out, err := cmd.CombinedOutput(); err != nil {
30 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
3531 }
3632 return nil
3733}
internal/httpd/web.go +1 −1
@@ -333,7 +333,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
333333 Mirrors: mirrors,
334334 Pinned: pinned,
335335 Watch: watch,
336 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
336 HasWiki: s.hasWiki(repo),
337337 Host: s.cfg.SiteHost(),
338338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
339339 Repo: repo,
internal/httpd/wiki.go +40 −22
@@ -3,7 +3,6 @@ package httpd
33import (
44 "html/template"
55 "net/http"
6 "os"
76 "path"
87 "strings"
98
@@ -15,16 +14,45 @@ import (
1514 "gitbay.org/gitbay/internal/store"
1615)
1716
18// wikiDir returns the companion repo path, or "" when the repo has none.
19func (s *Server) wikiDir(owner, name string) string {
20 dir := control.RepoDir(s.cfg.Server.Root, owner, name+".wiki")
21 if _, err := os.Stat(dir); err != nil {
22 return ""
17// wikiTreePath is where wiki pages live in a repository's tree.
18const wikiTreePath = ".gitbay/wiki"
19
20// wikiExts are the page formats that count as wiki content.
21var wikiExts = []string{".md", ".org", ".markdown"}
22
23// wikiDir returns repo's directory and default branch, where wiki pages
24// are resolved from .gitbay/wiki.
25func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
26 return control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), repo.DefaultBranch
27}
28
29// hasWiki reports whether repo's default branch holds a wiki page.
30func (s *Server) hasWiki(repo store.Repo) bool {
31 dir, branch := s.wikiDir(repo)
32 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
33 if err != nil {
34 return false
35 }
36 for _, e := range entries {
37 if e.Type == "blob" && wikiExtMatch(e.Name) {
38 return true
39 }
40 }
41 return false
42}
43
44// wikiExtMatch reports whether name has one of the wiki page extensions.
45func wikiExtMatch(name string) bool {
46 ext := strings.ToLower(path.Ext(name))
47 for _, want := range wikiExts {
48 if ext == want {
49 return true
50 }
2351 }
24 return dir
52 return false
2553}
2654
27// wiki renders a page from the repo's wiki companion. The home page is
55// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
2856// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and
2957// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs.
3058func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
@@ -33,17 +61,6 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
3361 return
3462 }
3563 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 }
4764 var listing struct {
4865 Home string `json:"home"`
4966 Pages []string `json:"pages"`
@@ -97,12 +114,13 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
97114 if !ok {
98115 return
99116 }
100 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name)
101 if dir == "" {
117 dir, branch := s.wikiDir(p.Repo)
118 rel := path.Join(wikiTreePath, strings.Trim(r.PathValue("path"), "/"))
119 if !strings.HasPrefix(rel, wikiTreePath+"/") {
102120 s.notFound(w, r)
103121 return
104122 }
105 data, err := gitutil.ReadBlob(dir, "main", strings.Trim(r.PathValue("path"), "/"), s.cfg.Limits.MaxBlobBytes)
123 data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes)
106124 if err != nil {
107125 s.notFound(w, r)
108126 return
internal/policy/names.go −3
@@ -59,9 +59,6 @@ func ValidateName(name string) error {
5959 if len(name) > 4 && name[len(name)-4:] == ".git" {
6060 return fmt.Errorf("invalid name %q: must not end in .git", name)
6161 }
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 }
6562 return nil
6663}
6764
internal/sshd/sshd.go −60
@@ -16,7 +16,6 @@ import (
1616 "os"
1717 "path/filepath"
1818 "strconv"
19 "strings"
2019 "sync"
2120 "sync/atomic"
2221 "time"
@@ -356,12 +355,6 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
356355 }
357356 write := service == "git-receive-pack"
358357
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
365358 repo, err := st.RepoByPath(argv[1])
366359 if err != nil {
367360 fmt.Fprintln(stderr, "repository not found")
@@ -433,56 +426,3 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
433426 }
434427 return protocol.ExitOK
435428}
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 @@
11{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
33<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>
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>
4{{if .Missing}}<p class="empty-note">no wiki yet — add pages under <code>.gitbay/wiki/</code> on the default branch:<br>
5push <code>.gitbay/wiki/Home.md</code> (or .org).</p>
66{{else}}
77<div class="wikilayout">
88<div class="wikipage">
@@ -14,7 +14,7 @@
1414<nav class="wikinav">
1515<p class="meta">pages</p>
1616<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> by push.</p>
1818</nav>
1919</div>
2020{{end}}