A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit ec82c4bca9

ec82c4bca9bdc770e9610244876457cec14bd774

parent: 59ae144d84

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28T03:43:00Z

control: repo log --ref, repo download, explore

The last three reads the web could do and nothing else could.

repo log takes --ref, the flag repo tree, cat and blame already take,
defaulting to the default branch as before. Browsing a ref and then
asking for its history was a move only the browser could make.

repo download writes a tar.gz of a ref to stdout, as release asset get
writes an asset. Not named repo archive: that is the read-only flag,
and renaming it would break every script that sets it.

explore lists public repositories, paginated with the same cursors as
the other listings. repo search needs a query, so there was no way to
see what an instance hosts without already knowing a name.

The web handlers still read git directly for log and archive. Making
them dispatch needs features the commands do not have — the log page
pages by start-sha rather than a count, and archive would need a
streaming dispatch — so that is left for #49 rather than half-done
here.

Closes #49
e2e/webonly_test.go added +93
@@ -0,0 +1,93 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// The three reads that were reachable only in a browser: history at a
11// ref, an archive, and the public listing. Each existed as a web route
12// whose handler went around the registry, so no other surface had them.
13func TestWebOnlyReadsAreCommands(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 work := t.TempDir()
25 env := inst.gitEnv(aliceKey)
26 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
27 dir := filepath.Join(work, "w")
28
29 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("main line\n"), 0o644)
30 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
31 mustGit(t, dir, env, "add", ".")
32 mustGit(t, dir, env, "commit", "-q", "-m", "on main")
33 mustGit(t, dir, env, "push", "-q", "origin", "main")
34
35 mustGit(t, dir, env, "checkout", "-q", "-b", "side")
36 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("side line\n"), 0o644)
37 mustGit(t, dir, env, "add", ".")
38 mustGit(t, dir, env, "commit", "-q", "-m", "only on side")
39 mustGit(t, dir, env, "push", "-q", "origin", "side")
40
41 // --- history at a ref ---
42 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--json")
43 if code != 0 {
44 t.Fatalf("repo log: %s", errOut)
45 }
46 if strings.Contains(out, "only on side") {
47 t.Error("the default branch log carried a side-branch commit")
48 }
49 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--ref", "side", "--json")
50 if code != 0 {
51 t.Fatalf("repo log --ref: %s", errOut)
52 }
53 if !strings.Contains(out, "only on side") {
54 t.Errorf("log at a ref missed its commit: %s", out)
55 }
56 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--ref", "nope"); code == 0 {
57 t.Error("an unknown ref resolved")
58 }
59
60 // --- archive ---
61 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "download", "alice/app")
62 if code != 0 {
63 t.Fatalf("repo download: %s", errOut)
64 }
65 // A gzip stream, not an error page: magic bytes 0x1f 0x8b.
66 if len(out) < 2 || out[0] != 0x1f || out[1] != 0x8b {
67 t.Errorf("repo download did not produce gzip (%d bytes)", len(out))
68 }
69 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "download", "alice/app", "--ref", "nope"); code == 0 {
70 t.Error("archived an unknown ref")
71 }
72
73 // --- the public listing ---
74 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/hidden", "--private"); code != 0 {
75 t.Fatal("private repo create")
76 }
77 out, errOut, code = inst.ssh(t, bobKey, "", "explore", "--json")
78 if code != 0 {
79 t.Fatalf("explore: %s", errOut)
80 }
81 if !strings.Contains(out, `"path":"alice/app"`) {
82 t.Errorf("explore missed a public repository: %s", out)
83 }
84 if strings.Contains(out, "alice/hidden") {
85 t.Errorf("explore leaked a private repository: %s", out)
86 }
87
88 // Paginated like the other listings.
89 out, _, code = inst.ssh(t, bobKey, "", "explore", "--limit", "1", "--json")
90 if code != 0 || !strings.Contains(out, `"items"`) {
91 t.Fatalf("explore --limit: %s", out)
92 }
93}
internal/control/explore.go added +116
@@ -0,0 +1,116 @@
1package control
2
3import (
4 "fmt"
5 "io"
6
7 "gitbay.org/gitbay/internal/gitutil"
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/protocol"
10)
11
12func init() {
13 register(Command{
14 Path: []string{"explore"},
15 Summary: "list public repositories: explore [--limit <n>] [--cursor <c>]",
16 ReadOnly: true,
17 Run: runExplore,
18 })
19 register(Command{
20 Path: []string{"repo", "download"},
21 // Not "repo archive": that name is taken by the read-only flag,
22 // and renaming it would break every script that sets it.
23 Summary: "write a tar.gz of a ref to stdout: repo download <owner/name> [--ref <r>] > repo.tar.gz",
24 ReadOnly: true,
25 Run: runRepoDownload,
26 })
27}
28
29// runExplore is the public listing the web serves at /explore. Without a
30// command there was no way to browse what an instance hosts without
31// already knowing a name to search for.
32func runExplore(c *Ctx, args []string) int {
33 rest, p, code := parsePageFlags(c, args, "explore", false)
34 if code >= 0 {
35 return code
36 }
37 if len(rest) != 0 {
38 return c.fail(protocol.ExitUsage, "usage: explore [--limit <n>] [--cursor <c>]")
39 }
40 repos, err := c.Store.ListPublicRepos()
41 if err != nil {
42 return c.fail(protocol.ExitFailure, "%v", err)
43 }
44 // Public means public, but an archived repo is still worth marking,
45 // and the cursor is the path since the listing is ordered by it.
46 type out struct {
47 Path string `json:"path"`
48 Description string `json:"description,omitempty"`
49 Archived bool `json:"archived,omitempty"`
50 Topics []string `json:"topics,omitempty"`
51 }
52 var ds []out
53 for _, repo := range repos {
54 if p.key != "" && repo.Path() <= p.key {
55 continue
56 }
57 topics, _ := c.Store.ListTopics(repo.ID)
58 ds = append(ds, out{
59 Path: repo.Path(),
60 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)),
61 Archived: repo.Settings.Archived,
62 Topics: topics,
63 })
64 if p.limit > 0 && len(ds) > p.limit {
65 break
66 }
67 }
68 ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path })
69 return c.emitPage(p, ds, next, func(w io.Writer) {
70 for _, d := range ds {
71 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Description)
72 }
73 })
74}
75
76// runRepoDownload writes a gzipped tarball of a ref to stdout, the way
77// release asset get writes an asset. The web's /archive route is the
78// same bytes with a Content-Disposition on them.
79func runRepoDownload(c *Ctx, args []string) int {
80 const usage = "repo download <owner/name> [--ref <r>] > repo.tar.gz"
81 var rest []string
82 var ref string
83 for i := 0; i < len(args); i++ {
84 switch args[i] {
85 case "--ref":
86 if i+1 >= len(args) {
87 return c.fail(protocol.ExitUsage, "--ref requires a value")
88 }
89 ref = args[i+1]
90 i++
91 default:
92 rest = append(rest, args[i])
93 }
94 }
95 if len(rest) != 1 {
96 return c.fail(protocol.ExitUsage, "usage: %s", usage)
97 }
98 repo, code := resolveRepo(c, rest[0], policy.CanRead)
99 if code >= 0 {
100 return code
101 }
102 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
103 if ref == "" {
104 ref = repo.DefaultBranch
105 }
106 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
107 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
108 }
109 // The prefix git puts on every path inside the archive, so unpacking
110 // lands in a named directory rather than the current one.
111 prefix := repo.Name + "-" + ref
112 if err := gitutil.Archive(dir, ref, prefix, c.Stdout); err != nil {
113 return c.fail(protocol.ExitFailure, "%v", err)
114 }
115 return protocol.ExitOK
116}
internal/control/sig.go +18 −6
@@ -27,7 +27,7 @@ func init() {
2727 Summary: "show one commit with its patch: repo commit <owner/name> <sha>",
2828 ReadOnly: true, Run: runRepoCommit})
2929 register(Command{Path: []string{"repo", "log"},
30 Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
30 Summary: "commit log with signature states: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
3131 }
3232
3333 func runPGPAdd(c *Ctx, args []string) int {
@@ -123,9 +123,15 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh
123123
124124 func runRepoLog(c *Ctx, args []string) int {
125125 limit := 30
126 var path, filePath string
126 var path, filePath, ref string
127127 for i := 0; i < len(args); i++ {
128128 switch args[i] {
129 case "--ref":
130 if i+1 >= len(args) {
131 return c.fail(protocol.ExitUsage, "--ref requires a value")
132 }
133 ref = args[i+1]
134 i++
129135 case "--limit":
130136 if i+1 >= len(args) {
131137 return c.fail(protocol.ExitUsage, "--limit requires a value")
@@ -144,25 +150,31 @@ func runRepoLog(c *Ctx, args []string) int {
144150 i++
145151 default:
146152 if path != "" {
147 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]")
153 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
148154 }
149155 path = args[i]
150156 }
151157 }
152158 if path == "" {
153 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]")
159 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
154160 }
155161 repo, code := resolveRepo(c, path, policy.CanRead)
156162 if code >= 0 {
157163 return code
158164 }
159165 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
166 if ref == "" {
167 ref = repo.DefaultBranch
168 }
169 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
170 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
171 }
160172 var shas []string
161173 var err error
162174 if filePath != "" {
163 shas, err = gitutil.RevListPath(dir, repo.DefaultBranch, filePath, limit)
175 shas, err = gitutil.RevListPath(dir, ref, filePath, limit)
164176 } else {
165 shas, err = gitutil.RevList(dir, repo.DefaultBranch, limit)
177 shas, err = gitutil.RevList(dir, ref, limit)
166178 }
167179 if err != nil {
168180 return c.fail(protocol.ExitFailure, "reading log: %v", err)