A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 40dab3e11b

40dab3e11b472386aff31e5f6903a5a63a60b43d

parent: 4de15d1806

Verified · cmc ci/build: success

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

control: repo tree and repo cat

Reading repository contents was not reachable outside a browser session:
no command returned file contents, and the web's raw and tree routes
authenticate through the session cookie, so a bearer token got nothing.

Both are read-only commands on the shared registry, so the CLI, the JSON
API and the web gain them together. Tree entries carry the object id, so
a client can cache by sha rather than by path and ref. Files come back as
text or base64, never as broken UTF-8, and report truncation against
limits.max_blob_bytes. Paths are confined to the repository.

Closes #36
cmd/gitbay/main.go +2
@@ -239,6 +239,8 @@ func repoCmd() *cobra.Command {
239239 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
240240 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
241241 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
242 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
243 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
242244 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
243245 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
244246 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
e2e/apiread_test.go added +152
@@ -0,0 +1,152 @@
1package e2e
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// TestRepoTreeAndCat covers reading repository contents over the control
13// plane — the capability a native client needs and could not reach at all
14// before: no command returned file contents, and the web's raw route
15// authenticates by session cookie, not bearer token.
16func TestRepoTreeAndCat(t *testing.T) {
17 inst := startInstance(t)
18 aliceKey := inst.newKey(t, "alice")
19 bobKey := inst.newKey(t, "bob")
20 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
21 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
22 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app", "--private"); code != 0 {
23 t.Fatalf("repo create: %s", errOut)
24 }
25
26 work := t.TempDir()
27 env := inst.gitEnv(aliceKey)
28 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
29 dir := filepath.Join(work, "w")
30 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n\nhello\n"), 0o644)
31 os.MkdirAll(filepath.Join(dir, "src"), 0o755)
32 os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n"), 0o644)
33 os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0x00, 0x01, 0x02, 0xff, 0x00}, 0o644)
34 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
35 mustGit(t, dir, env, "add", ".")
36 mustGit(t, dir, env, "commit", "-q", "-m", "base")
37 mustGit(t, dir, env, "push", "-q", "origin", "main")
38
39 // Tree at the root: directories and files, with sizes and object ids.
40 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--json")
41 if code != 0 {
42 t.Fatalf("repo tree: %s", errOut)
43 }
44 var tree struct {
45 Data struct {
46 Ref string `json:"ref"`
47 Dir string `json:"dir"`
48 Entries []struct {
49 Name string `json:"name"`
50 Type string `json:"type"`
51 SHA string `json:"sha"`
52 Size int64 `json:"size"`
53 } `json:"entries"`
54 } `json:"data"`
55 }
56 if err := json.Unmarshal([]byte(out), &tree); err != nil {
57 t.Fatalf("tree JSON: %v\n%s", err, out)
58 }
59 if tree.Data.Ref != "main" {
60 t.Errorf("ref = %q, want main", tree.Data.Ref)
61 }
62 byName := map[string]string{}
63 for _, e := range tree.Data.Entries {
64 byName[e.Name] = e.Type
65 if e.SHA == "" {
66 t.Errorf("%s has no object id, so a client cannot cache it", e.Name)
67 }
68 if e.Type == "blob" && e.Size == 0 {
69 t.Errorf("%s reports no size", e.Name)
70 }
71 }
72 if byName["src"] != "tree" || byName["README.md"] != "blob" {
73 t.Fatalf("root listing wrong: %v", byName)
74 }
75
76 // A subdirectory, and a file's contents.
77 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "src", "--json")
78 if !strings.Contains(out, `"main.go"`) {
79 t.Errorf("subdirectory listing: %s", out)
80 }
81 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "README.md", "--json")
82 if code != 0 {
83 t.Fatalf("repo cat: %s", errOut)
84 }
85 var file struct {
86 Data struct {
87 File string `json:"file"`
88 Content string `json:"content"`
89 Base64 string `json:"base64"`
90 Binary bool `json:"binary"`
91 Truncated bool `json:"truncated"`
92 } `json:"data"`
93 }
94 if err := json.Unmarshal([]byte(out), &file); err != nil {
95 t.Fatalf("cat JSON: %v\n%q", err, out)
96 }
97 if file.Data.Content != "# app\n\nhello\n" || file.Data.Binary {
98 t.Fatalf("cat returned %+v", file.Data)
99 }
100
101 // Binary content comes back base64, never as broken UTF-8 in "content".
102 file.Data = struct {
103 File string `json:"file"`
104 Content string `json:"content"`
105 Base64 string `json:"base64"`
106 Binary bool `json:"binary"`
107 Truncated bool `json:"truncated"`
108 }{}
109 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "logo.bin", "--json")
110 if code != 0 {
111 t.Fatalf("cat binary: exit %d %s", code, errOut)
112 }
113 if err := json.Unmarshal([]byte(out), &file); err != nil {
114 t.Fatalf("binary cat JSON: %v\n%q", err, out)
115 }
116 if !file.Data.Binary || file.Data.Content != "" || file.Data.Base64 == "" {
117 t.Fatalf("binary file not base64: %+v", file.Data)
118 }
119 if raw, err := base64.StdEncoding.DecodeString(file.Data.Base64); err != nil ||
120 len(raw) != 5 || raw[3] != 0xff {
121 t.Fatalf("base64 does not round-trip: %v %v", raw, err)
122 }
123
124 // Plain output is the file itself, so `repo cat` pipes like cat does.
125 out, _, code = inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "README.md")
126 if code != 0 || out != "# app\n\nhello\n" {
127 t.Fatalf("plain cat = %q", out)
128 }
129
130 // Reads honour repository visibility: this repo is private.
131 if _, _, code := inst.ssh(t, bobKey, "", "repo", "tree", "alice/app"); code == 0 {
132 t.Error("a stranger listed a private repository's tree")
133 }
134 if _, _, code := inst.ssh(t, bobKey, "", "repo", "cat", "alice/app", "README.md"); code == 0 {
135 t.Error("a stranger read a private repository's file")
136 }
137
138 // Paths cannot climb out of the repository.
139 for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "src/../../.."} {
140 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", bad); code == 0 {
141 t.Errorf("path %q was accepted", bad)
142 }
143 }
144
145 // Missing things are not found rather than server errors.
146 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "nope.txt"); code != 3 {
147 t.Errorf("missing file exit = %d, want 3", code)
148 }
149 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--ref", "nosuch"); code != 3 {
150 t.Errorf("missing ref exit = %d, want 3", code)
151 }
152}
internal/control/read.go added +211
@@ -0,0 +1,211 @@
1package control
2
3import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "path"
8 "strings"
9
10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
13)
14
15func init() {
16 register(Command{
17 Path: []string{"repo", "tree"},
18 Summary: "list a directory: repo tree <owner/name> [<path>] [--ref <ref>]",
19 ReadOnly: true,
20 Run: runRepoTree,
21 })
22 register(Command{
23 Path: []string{"repo", "cat"},
24 Summary: "read a file: repo cat <owner/name> <path> [--ref <ref>]",
25 ReadOnly: true,
26 Run: runRepoCat,
27 })
28}
29
30// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
31// off argv. Positionals are returned in order so each command can name them
32// in its own usage message.
33func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
34 for i := 0; i < len(args); i++ {
35 switch args[i] {
36 case "--ref":
37 if i+1 >= len(args) {
38 return nil, "", c.fail(protocol.ExitUsage, "--ref requires a value")
39 }
40 ref = args[i+1]
41 i++
42 default:
43 if strings.HasPrefix(args[i], "--") {
44 return nil, "", c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
45 }
46 if len(pos) >= maxPos {
47 return nil, "", c.fail(protocol.ExitUsage, "usage: %s", usage)
48 }
49 pos = append(pos, args[i])
50 }
51 }
52 return pos, ref, -1
53}
54
55// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
56// "..", no leading slash. git would resolve those against the work tree.
57func cleanRepoPath(p string) (string, bool) {
58 p = strings.Trim(p, "/")
59 if p == "" {
60 return "", true
61 }
62 cleaned := path.Clean(p)
63 if cleaned == "." || cleaned == ".." ||
64 strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
65 return "", false
66 }
67 return cleaned, true
68}
69
70// entryOut is one tree entry. The sha lets a client cache by object id
71// rather than by path and ref, which is what makes an offline client
72// tractable.
73type entryOut struct {
74 Name string `json:"name"`
75 Type string `json:"type"` // blob | tree
76 Mode string `json:"mode"`
77 SHA string `json:"sha"`
78 Size int64 `json:"size,omitempty"` // absent for trees
79}
80
81func runRepoTree(c *Ctx, args []string) int {
82 const usage = "repo tree <owner/name> [<path>] [--ref <ref>]"
83 pos, ref, code := readArgs(c, args, usage, 2)
84 if code >= 0 {
85 return code
86 }
87 if len(pos) == 0 {
88 return c.fail(protocol.ExitUsage, "usage: %s", usage)
89 }
90 repo, code := resolveRepo(c, pos[0], policy.CanRead)
91 if code >= 0 {
92 return code
93 }
94 dirPath := ""
95 if len(pos) == 2 {
96 var ok bool
97 if dirPath, ok = cleanRepoPath(pos[1]); !ok {
98 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
99 }
100 }
101 if ref == "" {
102 ref = repo.DefaultBranch
103 }
104 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
105 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
106 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
107 }
108 entries, err := gitutil.ListTree(dir, ref, dirPath)
109 if err != nil {
110 return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
111 }
112
113 type out struct {
114 Path string `json:"path"`
115 Ref string `json:"ref"`
116 Dir string `json:"dir"`
117 Entries []entryOut `json:"entries"`
118 }
119 d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
120 for _, e := range entries {
121 eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
122 if e.Type != "tree" && e.Size >= 0 {
123 eo.Size = e.Size
124 }
125 d.Entries = append(d.Entries, eo)
126 }
127 return c.emit(d, func(w io.Writer) {
128 for _, e := range d.Entries {
129 name := e.Name
130 if e.Type == "tree" {
131 name += "/"
132 }
133 fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name)
134 }
135 })
136}
137
138func sizeCol(e entryOut) string {
139 if e.Type == "tree" {
140 return "-"
141 }
142 return fmt.Sprintf("%d", e.Size)
143}
144
145func runRepoCat(c *Ctx, args []string) int {
146 const usage = "repo cat <owner/name> <path> [--ref <ref>]"
147 pos, ref, code := readArgs(c, args, usage, 2)
148 if code >= 0 {
149 return code
150 }
151 if len(pos) != 2 {
152 return c.fail(protocol.ExitUsage, "usage: %s", usage)
153 }
154 repo, code := resolveRepo(c, pos[0], policy.CanRead)
155 if code >= 0 {
156 return code
157 }
158 filePath, ok := cleanRepoPath(pos[1])
159 if !ok || filePath == "" {
160 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
161 }
162 if ref == "" {
163 ref = repo.DefaultBranch
164 }
165 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
166 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
167 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
168 }
169 // One byte over the cap distinguishes "exactly at the limit" from
170 // "truncated", so a client is told which it got.
171 limit := c.Cfg.Limits.MaxBlobBytes
172 data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
173 if err != nil {
174 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
175 }
176 truncated := int64(len(data)) > limit
177 if truncated {
178 data = data[:limit]
179 }
180 binary := gitutil.IsBinary(data)
181
182 type out struct {
183 Path string `json:"path"`
184 Ref string `json:"ref"`
185 File string `json:"file"`
186 Size int `json:"size"`
187 Binary bool `json:"binary"`
188 Truncated bool `json:"truncated,omitempty"`
189 // Exactly one of these is set: text for UTF-8-safe content,
190 // base64 for anything else, so a client never has to guess.
191 Content string `json:"content,omitempty"`
192 Base64 string `json:"base64,omitempty"`
193 }
194 d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
195 Binary: binary, Truncated: truncated}
196 if binary {
197 d.Base64 = base64.StdEncoding.EncodeToString(data)
198 } else {
199 d.Content = string(data)
200 }
201 return c.emit(d, func(w io.Writer) {
202 if binary {
203 fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
204 return
205 }
206 w.Write(data)
207 if len(data) > 0 && data[len(data)-1] != '\n' {
208 fmt.Fprintln(w)
209 }
210 })
211}