Commit fee1e1f177

fee1e1f1777795a692a4f4d3a0130855cb38fb16

parent: d2563ecad0

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-14 05:47 UTC

repo definitions: where the names a file uses are defined

For each name in the file that the symbol index defines: the first
definition and how many there are, the lookup the blob page links names
with. For a client that draws the file itself. Names are every
identifier in the file, since a command has no highlighter; keywords
fall out because nothing defines them.

Closes #333

Layout: unified · split

.gitbay/wiki/Parity.org +1 −1
@@ -189,7 +189,7 @@ there shows the app's rendering rather than the one the web page shows.
189| blame | yes | yes | yes | 189| blame | yes | yes | yes |
190| search file contents | yes | yes | yes | 190| search file contents | yes | yes | yes |
191| find a definition (symbols) | yes | yes | no | 191| find a definition (symbols) | yes | yes | no |
192| jump to definition from a file | n/a | yes | no | 192| jump to definition from a file | yes | yes | no |
193| compare two refs | yes | yes | yes | 193| compare two refs | yes | yes | yes |
194| branches and tags | yes | yes | yes | 194| branches and tags | yes | yes | yes |
195| wiki (read) | yes | yes | yes | 195| wiki (read) | yes | yes | yes |
.gitbay/wiki/Users.org +5
@@ -261,6 +261,11 @@ line; =--limit= and =--cursor= page it, and without them it stops at
261ref only when its tree is the indexed one. The index follows read 261ref only when its tree is the indexed one. The index follows read
262access: a repository you cannot read has no symbols either. 262access: a repository you cannot read has no symbols either.
263 263
264=repo definitions <owner/name> <path>= goes the other way: for each
265name the file uses that the index defines, the first definition and how
266many there are. It is what the blob page links names from, for a client
267that draws the file itself.
268
264What is indexed: 269What is indexed:
265 270
266| language | kinds | 271| language | kinds |
cmd/gitbay/main.go +1
@@ -567,6 +567,7 @@ func repoCmd() *cobra.Command {
567 pass("search", passOpts{server: []string{"repo", "search"}}), 567 pass("search", passOpts{server: []string{"repo", "search"}}),
568 pass("grep", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), 568 pass("grep", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
569 pass("symbols", passOpts{server: []string{"repo", "symbols"}, needsRepo: true}), 569 pass("symbols", passOpts{server: []string{"repo", "symbols"}, needsRepo: true}),
570 pass("definitions", passOpts{server: []string{"repo", "definitions"}, needsRepo: true}),
570 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}), 571 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
571 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), 572 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
572 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), 573 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +1
@@ -160,6 +160,7 @@ var summaries = map[string]string{
160 "repo commit": "show one commit with its patch", 160 "repo commit": "show one commit with its patch",
161 "repo commit-file": "write a file and commit it", 161 "repo commit-file": "write a file and commit it",
162 "repo create": "create a repository", 162 "repo create": "create a repository",
163 "repo definitions": "where each name a file uses is defined, from the symbol index",
163 "repo delete": "delete a repository", 164 "repo delete": "delete a repository",
164 "repo deploy-key add": "bind a read-only (or --rw) key to one repository", 165 "repo deploy-key add": "bind a read-only (or --rw) key to one repository",
165 "repo deploy-key list": "list deploy keys", 166 "repo deploy-key list": "list deploy keys",
e2e/readonly_test.go +1
@@ -132,6 +132,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
132 "repo blame": {"alice/app", "f.go"}, 132 "repo blame": {"alice/app", "f.go"},
133 "repo grep": {"alice/app", "hello"}, 133 "repo grep": {"alice/app", "hello"},
134 "repo symbols": {"alice/app", "app"}, 134 "repo symbols": {"alice/app", "app"},
135 "repo definitions": {"alice/app", "f.go"},
135 "repo diff": {"alice/app", "main", "feat"}, 136 "repo diff": {"alice/app", "main", "feat"},
136 "repo commit": {"alice/app", sha}, 137 "repo commit": {"alice/app", sha},
137 "repo download": {"alice/app"}, 138 "repo download": {"alice/app"},
internal/control/definitions_test.go added +112
@@ -0,0 +1,112 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// definitionsFixture is a repository whose default branch holds use.go,
16// which calls names defined in lib.go, and a symbol index of that tree.
17func definitionsFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
18 t.Helper()
19 st, repo, uid := newQueueTestRepo(t)
20 root := t.TempDir()
21 git := gitRunner(t)
22 src := filepath.Join(root, "src")
23 git(root, "init", "-q", "-b", "main", "src")
24 os.WriteFile(filepath.Join(src, "lib.go"), []byte("package app\n\nfunc Parse() {}\nfunc Run() {}\nfunc Run2() {}\n"), 0o644)
25 os.WriteFile(filepath.Join(src, "use.go"), []byte("package app\n\nfunc main() {\n\tParse()\n\tRun()\n\tunknown()\n}\n"), 0o644)
26 git(src, "add", ".")
27 git(src, "commit", "-q", "-m", "base")
28 dir := RepoDir(root, repo.OwnerName, repo.Name)
29 os.MkdirAll(filepath.Dir(dir), 0o755)
30 git(root, "clone", "-q", "--bare", src, dir)
31 head := strings.TrimSpace(git(dir, "rev-parse", "main"))
32 tree := strings.TrimSpace(git(dir, "rev-parse", "main^{tree}"))
33 rows := []store.SymbolRow{
34 {Name: "Parse", Key: "Parse", Kind: "function", Path: "lib.go", Line: 3},
35 {Name: "Run", Key: "Run", Kind: "function", Path: "lib.go", Line: 4},
36 {Name: "Server.Run", Key: "Run", Kind: "method", Path: "srv.go", Line: 9},
37 {Name: "main", Key: "main", Kind: "function", Path: "use.go", Line: 3},
38 }
39 if _, err := st.ReplaceSymbolIndex(store.SymbolIndex{RepoID: repo.ID, Commit: head, Tree: tree, State: "ok"}, rows); err != nil {
40 t.Fatal(err)
41 }
42 return st, repo, store.User{ID: uid, Username: "alice"}, root
43}
44
45func runDefinitions(t *testing.T, st *store.Store, u store.User, root string, argv ...string) (int, string) {
46 t.Helper()
47 var out, errOut bytes.Buffer
48 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: &out, Stderr: &errOut, JSON: true}
49 c.Cfg.Server.Root = root
50 c.Cfg.Limits.MaxBlobBytes = 1 << 20
51 code := Dispatch(c, argv)
52 return code, out.String() + errOut.String()
53}
54
55// repo definitions says where each name a file uses is defined: the
56// first definition and how many there are, so a client links a name
57// defined once to its line and one defined more often to a search, the
58// way the blob page does. Names the index does not hold are left out.
59func TestRepoDefinitionsListsTheNamesAFileUses(t *testing.T) {
60 st, repo, alice, root := definitionsFixture(t)
61
62 code, out := runDefinitions(t, st, alice, root, "repo", "definitions", repo.Path(), "use.go")
63 if code != protocol.ExitOK {
64 t.Fatalf("exit %d: %s", code, out)
65 }
66 var env struct {
67 Data []definitionOut `json:"data"`
68 }
69 if err := json.Unmarshal([]byte(out), &env); err != nil {
70 t.Fatal(err)
71 }
72 want := []definitionOut{
73 {Name: "Parse", Count: 1, Path: "lib.go", Line: 3},
74 {Name: "Run", Count: 2, Path: "lib.go", Line: 4},
75 {Name: "main", Count: 1, Path: "use.go", Line: 3},
76 }
77 if len(env.Data) != len(want) {
78 t.Fatalf("got %+v", env.Data)
79 }
80 for i := range want {
81 if env.Data[i] != want[i] {
82 t.Errorf("row %d: got %+v, want %+v", i, env.Data[i], want[i])
83 }
84 }
85}
86
87func TestRepoDefinitionsRefusals(t *testing.T) {
88 st, repo, alice, root := definitionsFixture(t)
89 for _, tc := range []struct {
90 argv []string
91 code int
92 want string
93 }{
94 {[]string{"repo", "definitions", repo.Path(), "nope.go"}, protocol.ExitNotFound, "no such file"},
95 {[]string{"repo", "definitions", repo.Path(), "../x"}, protocol.ExitUsage, "inside the repository"},
96 {[]string{"repo", "definitions", repo.Path(), "use.go", "--ref", "nope"}, protocol.ExitNotFound, "not at the indexed tree"},
97 } {
98 code, out := runDefinitions(t, st, alice, root, tc.argv...)
99 if code != tc.code || !strings.Contains(out, tc.want) {
100 t.Errorf("%v: exit %d, %s", tc.argv, code, out)
101 }
102 }
103}
104
105func TestRepoDefinitionsScreen(t *testing.T) {
106 st, repo, alice, root := definitionsFixture(t)
107 out := atTerminalIn(t, st, alice, root, "repo", "definitions", repo.Path(), "use.go")
108 if !strings.Contains(out, "Parse") || !strings.Contains(out, "lib.go:4") {
109 t.Fatalf("screen:\n%s", out)
110 }
111 checkLegend(t, out)
112}
internal/control/symbols.go +105
@@ -4,6 +4,8 @@ import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "regexp"
8 "sort"
7 "strconv" 9 "strconv"
8 "strings" 10 "strings"
9 11
@@ -29,6 +31,14 @@ func init() {
29 "repo symbols krz/gitbay --kind method Ctx.", 31 "repo symbols krz/gitbay --kind method Ctx.",
30 }, 32 },
31 ReadOnly: true, Run: runRepoSymbols}) 33 ReadOnly: true, Run: runRepoSymbols})
34 register(Command{Path: []string{"repo", "definitions"},
35 Summary: "where each name a file uses is defined, from the symbol index",
36 Usage: "repo definitions <owner/name> <path> [--ref <ref>]",
37 Flags: []Flag{
38 {"--ref", "<ref>", "a ref whose tree is the indexed one; only the default branch is indexed", "the default branch"},
39 },
40 Examples: []string{"repo definitions krz/gitbay internal/control/control.go"},
41 ReadOnly: true, Run: runRepoDefinitions})
32 register(Command{Path: []string{"admin", "symbols", "reindex"}, 42 register(Command{Path: []string{"admin", "symbols", "reindex"},
33 Summary: "rebuild a repository's symbol index, even when its tree is already indexed (instance admins)", 43 Summary: "rebuild a repository's symbol index, even when its tree is already indexed (instance admins)",
34 Usage: "admin symbols reindex <owner/name>", 44 Usage: "admin symbols reindex <owner/name>",
@@ -204,3 +214,98 @@ func runAdminSymbolsReindex(c *Ctx, args []string) int {
204 fmt.Fprintf(w, "queued a rebuild of the symbol index of %s\n", repo.Path()) 214 fmt.Fprintf(w, "queued a rebuild of the symbol index of %s\n", repo.Path())
205 }) 215 })
206} 216}
217
218// definitionOut is one name a file uses and where it is defined: the
219// first definition, and Count of them. A client links a name defined once
220// to its line and one defined more often to repo symbols, as the blob page
221// does.
222type definitionOut struct {
223 Name string `json:"name"`
224 Count int `json:"count"`
225 Path string `json:"path"`
226 Line int `json:"line"`
227}
228
229// identifier is a name as the index keys it. The blob page takes names
230// from the highlighter's spans; a command has no highlighter, so it takes
231// every identifier, and keywords fall out because nothing defines them.
232var identifier = regexp.MustCompile(`[\p{L}_$][\p{L}\p{N}_$]*`)
233
234// maxDefinitionNames bounds the lookup for one file.
235const maxDefinitionNames = 5000
236
237func runRepoDefinitions(c *Ctx, args []string) int {
238 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: c.Cmd.Usage})
239 if err != nil {
240 return c.fail(protocol.ExitUsage, "%v", err)
241 }
242 path, file, ref := f.pos(0), f.pos(1), f.Value("--ref")
243 if path == "" || file == "" {
244 return c.usage()
245 }
246 filePath, ok := cleanRepoPath(file)
247 if !ok || filePath == "" {
248 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
249 }
250 repo, code := resolveRepo(c, path, policy.CanRead)
251 if code >= 0 {
252 return code
253 }
254 idx, err := c.Store.SymbolIndexFor(repo.ID)
255 if errors.Is(err, store.ErrNotFound) {
256 return c.fail(protocol.ExitNotFound, "%s has no symbol index yet; one is built after a push to %s", repo.Path(), repo.DefaultBranch)
257 } else if err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 if ref == "" {
261 ref = repo.DefaultBranch
262 }
263 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
264 sha, err := gitutil.ResolveRef(dir, ref)
265 if err != nil || !IndexedTree(dir, sha, idx) {
266 return c.fail(protocol.ExitNotFound, "only the default branch, %s, is indexed; %s is not at the indexed tree", repo.DefaultBranch, ref)
267 }
268 data, err := gitutil.ReadBlob(dir, sha, filePath, c.Cfg.Limits.MaxBlobBytes)
269 if err != nil {
270 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
271 }
272 var keys []string
273 if !gitutil.IsBinary(data) {
274 seen := map[string]bool{}
275 for _, name := range identifier.FindAllString(string(data), -1) {
276 if !seen[name] && len(keys) < maxDefinitionNames {
277 seen[name] = true
278 keys = append(keys, name)
279 }
280 }
281 }
282 targets, err := c.Store.SymbolTargets(idx.ID, keys)
283 if err != nil {
284 return c.fail(protocol.ExitFailure, "%v", err)
285 }
286 ds := make([]definitionOut, 0, len(targets))
287 for name, t := range targets {
288 ds = append(ds, definitionOut{name, t.Count, t.Path, t.Line})
289 }
290 sort.Slice(ds, func(i, j int) bool { return ds[i].Name < ds[j].Name })
291 rowsOf := func() []row {
292 rows := make([]row, len(ds))
293 for i, d := range ds {
294 rows[i] = rowOf(cRef(d.Name), cText(strconv.Itoa(d.Count)), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
295 }
296 return rows
297 }
298 return c.emitView(ds, func(w io.Writer) {
299 tb := c.table(w, "NAME", "DEFINED", "FIRST")
300 for _, d := range ds {
301 tb.row(cRef(d.Name), cText(strconv.Itoa(d.Count)), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
302 }
303 tb.flush()
304 }, func() screen {
305 s := listScreen("Definitions used in "+filePath, rowsOf())
306 if len(ds) > 0 {
307 s.actions = []action{{"Find", []string{"repo", "symbols", repo.Path(), ds[0].Name}}}
308 }
309 return s
310 })
311}