A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 59a6e6bf08

59a6e6bf0838b3024ed8f0696cec971fd790b05d

parent: ec82c4bca9

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28T04:14:34Z

control: wiki list and wiki show

The last capability only a browser could reach. The web read the
companion repository's tree and blobs directly, so the CLI could not
print a page and no native client could show one.

The companion itself is right, and stays: prose in the code repository
would put every doc typo in git log and blame, drag wiki history into
every clone, subject a typo fix to protected branches and required
reviews, and fire CI. What was wrong is that the companion has no store
row, so resolveRepo cannot find it and no command could address it.
Hence commands of its own rather than making it a repository.

wiki list gives the page names and which one is the landing page, by
the same rule the web used. wiki show prints a page, defaulting to the
landing page, accepting a name with or without its extension. Access
derives from the parent, exactly as it does for git over SSH: a wiki
you cannot read belongs to a repository you cannot read.

Editing is still a push to <repo>.wiki.git. That is the whole write
interface on every surface, so there is nothing for a command to add.

The web dispatches both now and keeps only its rendering. TestWikis
passes unchanged — same tab, same link rewriting, same 404 parity on a
private repository's wiki.

Closes #48
e2e/wiki_test.go +48
@@ -77,6 +77,54 @@ func TestWikis(t *testing.T) {
7777 t.Fatalf("wiki raw: %d", status)
7878 }
7979
80 // A wiki is readable from every surface, not just a browser: the
81 // commands are what the web dispatches, and what the CLI and the
82 // JSON API reach.
83 out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json")
84 if code != 0 {
85 t.Fatalf("wiki list: %s", errOut)
86 }
87 if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) {
88 t.Errorf("wiki list pages: %s", out)
89 }
90 if !strings.Contains(out, `"home":"Home"`) {
91 t.Errorf("wiki list did not name the landing page: %s", out)
92 }
93 // shot.png is not a page.
94 if strings.Contains(out, "shot") {
95 t.Errorf("wiki list included a non-page file: %s", out)
96 }
97
98 // Named page, and the landing page when none is named.
99 out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Setup", "--json")
100 if code != 0 || !strings.Contains(out, "steps here") {
101 t.Errorf("wiki show Setup: %s", out)
102 }
103 out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "--json")
104 if code != 0 || !strings.Contains(out, "welcome") {
105 t.Errorf("wiki show default page: %s", out)
106 }
107 // An extension is accepted and ignored, as the web's routes do.
108 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Setup.org"); code != 0 {
109 t.Error("wiki show rejected a page named with its extension")
110 }
111 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Nope"); code == 0 {
112 t.Error("a missing wiki page resolved")
113 }
114 // A page name cannot climb out of the wiki.
115 if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 {
116 t.Error("wiki show escaped the repository")
117 }
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)
122 }
123 // Wiki access derives from the parent: a stranger gets nothing.
124 if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 {
125 t.Error("a stranger listed a private repository's wiki")
126 }
127
80128 // 404-parity: a private repo's wiki is invisible, over web and git.
81129 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main")
82130 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
internal/control/wiki.go added +171
@@ -0,0 +1,171 @@
1package control
2
3import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "os"
8 "path"
9 "strings"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17func init() {
18 register(Command{
19 Path: []string{"wiki", "list"},
20 Summary: "list a repository's wiki pages: wiki list <owner/name>",
21 ReadOnly: true,
22 Run: runWikiList,
23 })
24 register(Command{
25 Path: []string{"wiki", "show"},
26 Summary: "print a wiki page: wiki show <owner/name> [<page>]",
27 ReadOnly: true,
28 Run: runWikiShow,
29 })
30}
31
32// A wiki lives in a companion bare repo beside its parent, so prose
33// edits stay out of the code repository's history, its protected
34// branches and its builds. The companion has no store row of its own —
35// access derives from the parent, exactly as it does for git over SSH —
36// which is why it needs commands rather than being addressable as a
37// repository.
38//
39// Editing stays a push to <repo>.wiki.git. That is the whole write
40// interface, on every surface, and there is nothing for a command to
41// add.
42
43// wikiExts are the page formats the web renders, in resolution order.
44var wikiExts = []string{".md", ".org", ".markdown"}
45
46// wikiDir resolves the parent, checks read access, and returns the
47// companion's path. A parent you cannot read has no wiki you can read.
48func wikiDir(c *Ctx, spec string) (repo store.Repo, dir string, code int) {
49 parent, code := resolveRepo(c, spec, policy.CanRead)
50 if code >= 0 {
51 return store.Repo{}, "", code
52 }
53 d := RepoDir(c.Cfg.Server.Root, parent.OwnerName, parent.Name+".wiki")
54 if _, err := os.Stat(d); err != nil {
55 return store.Repo{}, "", c.fail(protocol.ExitNotFound, "%s has no wiki", parent.Path())
56 }
57 return parent, d, -1
58}
59
60// wikiPages lists the page names in the companion, without extensions.
61func wikiPages(dir string) []string {
62 entries, err := gitutil.ListTree(dir, "main", "")
63 if err != nil {
64 return nil // the companion exists but has no commits yet
65 }
66 var pages []string
67 for _, e := range entries {
68 if e.Type != "blob" {
69 continue
70 }
71 ext := strings.ToLower(path.Ext(e.Name))
72 for _, want := range wikiExts {
73 if ext == want {
74 pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name)))
75 break
76 }
77 }
78 }
79 return pages
80}
81
82func runWikiList(c *Ctx, args []string) int {
83 if len(args) != 1 {
84 return c.fail(protocol.ExitUsage, "usage: wiki list <owner/name>")
85 }
86 repo, dir, code := wikiDir(c, args[0])
87 if code >= 0 {
88 return code
89 }
90 pages := wikiPages(dir)
91 type out struct {
92 Path string `json:"path"`
93 Home string `json:"home,omitempty"`
94 Pages []string `json:"pages"`
95 }
96 d := out{Path: repo.Path(), Home: wikiHome(pages), Pages: pages}
97 if d.Pages == nil {
98 d.Pages = []string{}
99 }
100 return c.emit(d, func(w io.Writer) {
101 for _, p := range d.Pages {
102 fmt.Fprintln(w, p)
103 }
104 })
105}
106
107// wikiHome picks the landing page the way the web does: a conventional
108// name if one exists, otherwise the first page.
109func wikiHome(pages []string) string {
110 for _, home := range []string{"Home", "home", "README", "index"} {
111 for _, p := range pages {
112 if p == home {
113 return home
114 }
115 }
116 }
117 if len(pages) > 0 {
118 return pages[0]
119 }
120 return ""
121}
122
123func runWikiShow(c *Ctx, args []string) int {
124 if len(args) < 1 || len(args) > 2 {
125 return c.fail(protocol.ExitUsage, "usage: wiki show <owner/name> [<page>]")
126 }
127 repo, dir, code := wikiDir(c, args[0])
128 if code >= 0 {
129 return code
130 }
131 pages := wikiPages(dir)
132 page := wikiHome(pages)
133 if len(args) == 2 {
134 page = strings.TrimSuffix(args[1], path.Ext(args[1]))
135 }
136 if page == "" {
137 return c.fail(protocol.ExitNotFound, "%s has no wiki pages", repo.Path())
138 }
139 // A page name is a file name, so it must not climb out of the repo.
140 if cleaned, ok := cleanRepoPath(page); !ok || cleaned != page {
141 return c.fail(protocol.ExitUsage, "page must be a name inside the wiki")
142 }
143
144 for _, ext := range wikiExts {
145 raw, err := gitutil.ReadBlob(dir, "main", page+ext, c.Cfg.Limits.MaxBlobBytes)
146 if err != nil {
147 continue
148 }
149 binary := gitutil.IsBinary(raw)
150 type out struct {
151 Path string `json:"path"`
152 Page string `json:"page"`
153 File string `json:"file"`
154 Size int `json:"size"`
155 Binary bool `json:"binary,omitempty"`
156 Content string `json:"content,omitempty"`
157 Base64 string `json:"base64,omitempty"`
158 }
159 d := out{Path: repo.Path(), Page: page, File: page + ext,
160 Size: len(raw), Binary: binary}
161 if binary {
162 d.Base64 = base64.StdEncoding.EncodeToString(raw)
163 } else {
164 d.Content = string(raw)
165 }
166 return c.emit(d, func(w io.Writer) {
167 fmt.Fprint(w, d.Content)
168 })
169 }
170 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())
171}
internal/httpd/wiki.go +19 −33
@@ -12,6 +12,7 @@ import (
1212
1313 "gitbay.org/gitbay/internal/control"
1414 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/store"
1516 )
1617
1718 // wikiDir returns the companion repo path, or "" when the repo has none.
@@ -43,8 +44,16 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
4344 }{repoPage: p, Missing: true})
4445 return
4546 }
46 entries, err := gitutil.ListTree(dir, "main", "")
47 if err != nil { // wiki repo exists but has no commits yet
47 var listing struct {
48 Home string `json:"home"`
49 Pages []string `json:"pages"`
50 }
51 var viewer store.User
52 if s.cfg.Web.Mode == "accounts" {
53 viewer = s.viewer(r)
54 }
55 _, listed := s.runControlInto(viewer, []string{"wiki", "list", p.Repo.Path()}, &listing)
56 if !listed || len(listing.Pages) == 0 { // no wiki, or no commits yet
4857 s.render(w, "wiki.html", struct {
4958 repoPage
5059 Page string
@@ -54,47 +63,24 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
5463 }{repoPage: p, Missing: true})
5564 return
5665 }
57 var pages []string
58 for _, e := range entries {
59 if e.Type != "blob" {
60 continue
61 }
62 ext := strings.ToLower(path.Ext(e.Name))
63 if ext == ".md" || ext == ".org" || ext == ".markdown" {
64 pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name)))
65 }
66 }
66 pages := listing.Pages
6767
6868 page := strings.Trim(r.PathValue("page"), "/")
6969 if page == "" {
70 for _, home := range []string{"Home", "home", "README", "index"} {
71 for _, pg := range pages {
72 if pg == home {
73 page = home
74 }
75 }
76 if page != "" {
77 break
78 }
79 }
80 if page == "" && len(pages) > 0 {
81 page = pages[0]
82 }
70 page = listing.Home
8371 }
8472 var pageHTML template.HTML
8573 if page != "" {
86 fileName, raw := "", []byte(nil)
87 for _, ext := range []string{".md", ".org", ".markdown"} {
88 if b, err := gitutil.ReadBlob(dir, "main", page+ext, maxRenderBytes); err == nil {
89 fileName, raw = page+ext, b
90 break
91 }
74 var shown struct {
75 File string `json:"file"`
76 Content string `json:"content"`
9277 }
93 if fileName == "" {
78 if _, ok := s.runControlInto(viewer,
79 []string{"wiki", "show", p.Repo.Path(), page}, &shown); !ok {
9480 s.notFound(w, r)
9581 return
9682 }
97 pageHTML = rewriteWikiLinks(renderReadme(fileName, raw), p)
83 pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p)
9884 }
9985 s.render(w, "wiki.html", struct {
10086 repoPage