A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 4f3bd4893b

4f3bd4893bc9923a02b86ae2cbae1fb17e019d2c

parent: 069bd1e107

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T18:22:04Z

Add repository and code search (#7)

repo search <query> matches name, description, and topics across
repositories visible to the caller; repo grep <owner/name> <query>
runs a literal case-insensitive git grep (5s timeout, 200-match cap,
binary files skipped) at the default branch or --ref. The web index
gets a ?q= filter box with the same matching, and repo pages a search
tab at /{owner}/{repo}/search with <mark>ed matches linking to blob
line anchors (chroma linkable line numbers enabled). Queries are 2-200
chars; private repos stay invisible in every path.
cmd/gitbay/main.go +2
@@ -216,6 +216,8 @@ func repoCmd() *cobra.Command {
216216 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
217217 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
218218 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
219 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
220 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
219221 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
220222 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
221223 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
docs/users.org +2
@@ -96,6 +96,8 @@ gitbay repo settings protect you/project main # no force-push, no delete
9696 gitbay repo settings require-signed you/project on # every commit must verify
9797 gitbay repo settings git-daemon you/project on # expose over git://
9898 gitbay repo topics add you/project cli forge # free-form tags, shown on the web
99gitbay repo search forge # find repos by name/description/topic
100gitbay repo grep you/project "some string" # literal git grep over the default branch
99101 gitbay repo archive you/project # read-only: pushes and issue/MR
100102 gitbay repo unarchive you/project # writes refused, browsing intact
101103 #+end_src
e2e/search_test.go added +106
@@ -0,0 +1,106 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestSearch(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
17
18 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/webapp",
19 "--description", "'a small web application'"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/webapp", "golang"); code != 0 {
23 t.Fatal("topics add failed")
24 }
25 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private",
26 "--description", "'hidden things'"); code != 0 {
27 t.Fatal("private repo create failed")
28 }
29
30 work := t.TempDir()
31 env := inst.gitEnv(aliceKey)
32 mustGit(t, work, env, "clone", inst.sshURL("alice/webapp"), "w")
33 dir := filepath.Join(work, "w")
34 os.WriteFile(filepath.Join(dir, "main.go"),
35 []byte("package main\n\nfunc Greet() string {\n\treturn \"hello, forge\"\n}\n"), 0o644)
36 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
37 mustGit(t, dir, env, "add", ".")
38 mustGit(t, dir, env, "commit", "-q", "-m", "base")
39 mustGit(t, dir, env, "push", "-q", "origin", "main")
40
41 // repo search over SSH: by name, description, and topic; visibility
42 // respected — bob never sees the private repo, alice does.
43 out, _, code := inst.ssh(t, aliceKey, "", "repo", "search", "webapp")
44 if code != 0 || !strings.Contains(out, "alice/webapp") {
45 t.Fatalf("search by name: %s", out)
46 }
47 if out, _, _ = inst.ssh(t, bobKey, "", "repo", "search", "application"); !strings.Contains(out, "alice/webapp") {
48 t.Fatalf("search by description: %s", out)
49 }
50 if out, _, _ = inst.ssh(t, bobKey, "", "repo", "search", "golang"); !strings.Contains(out, "alice/webapp") {
51 t.Fatalf("search by topic: %s", out)
52 }
53 if out, _, _ = inst.ssh(t, bobKey, "", "repo", "search", "secret"); strings.Contains(out, "alice/secret") {
54 t.Fatal("private repo leaked to bob's search")
55 }
56 if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "search", "hidden"); !strings.Contains(out, "alice/secret") {
57 t.Fatalf("owner's search missed private repo: %s", out)
58 }
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "search", "x"); code != 2 || !strings.Contains(errOut, "2 to 200") {
60 t.Fatalf("short query: exit %d, %s", code, errOut)
61 }
62
63 // repo grep over SSH: literal, case-insensitive, 404-parity on private.
64 out, _, code = inst.ssh(t, aliceKey, "", "repo", "grep", "alice/webapp", "HELLO", "--json")
65 if code != 0 || !strings.Contains(out, `"path":"main.go"`) || !strings.Contains(out, `"line":4`) {
66 t.Fatalf("grep: %s", out)
67 }
68 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "grep", "alice/secret", "anything"); code != 3 || !strings.Contains(errOut, "not found") {
69 t.Fatalf("private grep parity: exit %d, %s", code, errOut)
70 }
71
72 // Web: index filter respects the query and visibility.
73 status, body := inst.get(t, "/?q=webapp")
74 if status != 200 || !strings.Contains(body, "alice/webapp") {
75 t.Fatalf("index filter: %d", status)
76 }
77 _, body = inst.get(t, "/?q=nomatchhere")
78 if strings.Contains(body, "alice/webapp") {
79 t.Fatal("index filter did not filter")
80 }
81 _, body = inst.get(t, "/?q=hidden")
82 if strings.Contains(body, "secret") {
83 t.Fatal("private repo leaked on index search")
84 }
85
86 // Web: repo code search with mark and blob line anchor.
87 status, body = inst.get(t, "/alice/webapp/search?q=hello")
88 if status != 200 || !strings.Contains(body, `<mark>hello</mark>`) ||
89 !strings.Contains(body, `/alice/webapp/blob/main/main.go#L4">main.go:4</a>`) {
90 t.Fatalf("repo search page: %d\n%s", status, body)
91 }
92 _, body = inst.get(t, "/alice/webapp/search?q=zzznothing")
93 if !strings.Contains(body, "no matches") {
94 t.Fatal("empty state missing")
95 }
96 _, body = inst.get(t, "/alice/webapp/search?q=x")
97 if !strings.Contains(body, "2 to 200") {
98 t.Fatal("short query error missing")
99 }
100
101 // Blob line numbers are linkable anchors for the search links.
102 _, body = inst.get(t, "/alice/webapp/blob/main/main.go")
103 if !strings.Contains(body, `id="L4"`) {
104 t.Fatal("blob line anchors missing")
105 }
106}
internal/control/repo.go +133
@@ -60,6 +60,23 @@ func init() {
6060 Summary: "add topics: repo topics add <owner/name> <topic>...", Run: runTopicsAdd})
6161 register(Command{Path: []string{"repo", "topics", "remove"},
6262 Summary: "remove topics: repo topics remove <owner/name> <topic>...", Run: runTopicsRemove})
63 register(Command{Path: []string{"repo", "search"},
64 Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch})
65 register(Command{Path: []string{"repo", "grep"},
66 Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep})
67}
68
69const (
70 minQueryLen = 2
71 maxQueryLen = 200
72 maxGrepMatches = 200
73)
74
75func validQuery(q string) error {
76 if len(q) < minQueryLen || len(q) > maxQueryLen {
77 return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
78 }
79 return nil
6380 }
6481
6582 // refuseArchived blocks content writes (pushes are refused in the transport
@@ -557,6 +574,122 @@ func editTopics(c *Ctx, args []string, add bool) int {
557574 })
558575 }
559576
577// runRepoSearch matches the query against name, owner/name, description,
578// and topics of every repository the caller can see.
579func runRepoSearch(c *Ctx, args []string) int {
580 if len(args) != 1 {
581 return c.fail(protocol.ExitUsage, "usage: repo search <query>")
582 }
583 if err := validQuery(args[0]); err != nil {
584 return c.fail(protocol.ExitUsage, "%v", err)
585 }
586 q := strings.ToLower(args[0])
587
588 public, err := c.Store.ListPublicRepos()
589 if err != nil {
590 return c.fail(protocol.ExitFailure, "%v", err)
591 }
592 own, err := c.Store.ListReposForUser(c.User.ID)
593 if err != nil {
594 return c.fail(protocol.ExitFailure, "%v", err)
595 }
596 seen := map[int64]bool{}
597 type out struct {
598 Path string `json:"path"`
599 Visibility string `json:"visibility"`
600 Description string `json:"description,omitempty"`
601 Topics []string `json:"topics,omitempty"`
602 }
603 var ds []out
604 for _, r := range append(public, own...) {
605 if seen[r.ID] {
606 continue
607 }
608 seen[r.ID] = true
609 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
610 topics, _ := c.Store.ListTopics(r.ID)
611 if !matchesRepo(q, r, desc, topics) {
612 continue
613 }
614 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
615 }
616 return c.emit(ds, func(w io.Writer) {
617 for _, d := range ds {
618 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
619 }
620 })
621}
622
623func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
624 if strings.Contains(strings.ToLower(r.Path()), q) ||
625 strings.Contains(strings.ToLower(desc), q) {
626 return true
627 }
628 for _, t := range topics {
629 if strings.Contains(t, q) {
630 return true
631 }
632 }
633 return false
634}
635
636func runRepoGrep(c *Ctx, args []string) int {
637 var path, query, ref string
638 for i := 0; i < len(args); i++ {
639 switch args[i] {
640 case "--ref":
641 if i+1 >= len(args) {
642 return c.fail(protocol.ExitUsage, "--ref requires a value")
643 }
644 ref = args[i+1]
645 i++
646 default:
647 if path == "" {
648 path = args[i]
649 } else if query == "" {
650 query = args[i]
651 } else {
652 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
653 }
654 }
655 }
656 if path == "" || query == "" {
657 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
658 }
659 if err := validQuery(query); err != nil {
660 return c.fail(protocol.ExitUsage, "%v", err)
661 }
662 repo, code := resolveRepo(c, path, policy.CanRead)
663 if code >= 0 {
664 return code
665 }
666 if ref == "" {
667 ref = repo.DefaultBranch
668 }
669 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
670 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
671 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
672 }
673 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
674 if err != nil {
675 return c.fail(protocol.ExitFailure, "%v", err)
676 }
677 type out struct {
678 Path string `json:"path"`
679 Line int `json:"line"`
680 Text string `json:"text"`
681 }
682 var ds []out
683 for _, m := range matches {
684 ds = append(ds, out{m.Path, m.Line, m.Text})
685 }
686 return c.emit(ds, func(w io.Writer) {
687 for _, d := range ds {
688 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
689 }
690 })
691}
692
560693 func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
561694 func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
562695
internal/gitutil/grep.go added +57
@@ -0,0 +1,57 @@
1package gitutil
2
3import (
4 "context"
5 "fmt"
6 "os/exec"
7 "strconv"
8 "strings"
9 "time"
10)
11
12type GrepMatch struct {
13 Path string
14 Line int
15 Text string
16}
17
18// Grep runs a literal, case-insensitive git grep over the tree at ref,
19// skipping binary files. Matches are capped at max; "no matches" is an
20// empty result, not an error.
21func Grep(dir, ref, query string, max int) ([]GrepMatch, error) {
22 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
23 defer cancel()
24 // -z: NUL after the path and the line number, so paths containing
25 // ':' parse unambiguously (format: "ref:path\0line\0text\n").
26 cmd := exec.CommandContext(ctx, "git", "-C", dir, "grep", "-nIiF", "-z", "-e", query, ref)
27 out, err := cmd.Output()
28 if err != nil {
29 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
30 return nil, nil
31 }
32 return nil, fmt.Errorf("git grep at %s: %w", ref, err)
33 }
34 var matches []GrepMatch
35 for _, line := range strings.Split(string(out), "\n") {
36 if line == "" {
37 continue
38 }
39 parts := strings.SplitN(line, "\x00", 3)
40 if len(parts) != 3 {
41 continue
42 }
43 n, err := strconv.Atoi(parts[1])
44 if err != nil {
45 continue
46 }
47 matches = append(matches, GrepMatch{
48 Path: strings.TrimPrefix(parts[0], ref+":"),
49 Line: n,
50 Text: parts[2],
51 })
52 if len(matches) >= max {
53 break
54 }
55 }
56 return matches, nil
57}
internal/httpd/routes.go +1
@@ -34,6 +34,7 @@ func (s *Server) Routes() []Route {
3434 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
3535 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
3636 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
37 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
3738 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
3839 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
3940 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
internal/httpd/web.go +91 −2
@@ -103,12 +103,40 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
103103 }
104104 }
105105 }
106 q := strings.TrimSpace(r.URL.Query().Get("q"))
106107 s.render(w, "index.html", struct {
107108 Site string
108109 Viewer string
110 Query string
109111 Repos []describedRepo
110112 Mine []describedRepo
111 }{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)})
113 }{s.siteName(), viewer.Username, q,
114 s.filterRepos(q, s.describeAll(repos)), s.filterRepos(q, s.describeAll(mine))})
115}
116
117// filterRepos keeps repos whose path, description, or topics contain the
118// query, case-insensitively. An empty query keeps everything.
119func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
120 if q == "" {
121 return repos
122 }
123 q = strings.ToLower(q)
124 var out []describedRepo
125 for _, d := range repos {
126 if strings.Contains(strings.ToLower(d.Path()), q) ||
127 strings.Contains(strings.ToLower(d.Desc), q) {
128 out = append(out, d)
129 continue
130 }
131 topics, _ := s.st.ListTopics(d.ID)
132 for _, t := range topics {
133 if strings.Contains(t, q) {
134 out = append(out, d)
135 break
136 }
137 }
138 }
139 return out
112140 }
113141
114142 // repoPage is the shared context for repo-scoped pages.
@@ -329,6 +357,66 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
329357 }{p, cs, base, filePath, binary, len(data), codeHTML})
330358 }
331359
360// search runs a bounded literal git grep over the repo's default branch.
361func (s *Server) search(w http.ResponseWriter, r *http.Request) {
362 p, ok := s.repoFor(w, r, "")
363 if !ok {
364 return
365 }
366 p.Tab = "search"
367 q := strings.TrimSpace(r.URL.Query().Get("q"))
368 type matchView struct {
369 Path string
370 Line int
371 TextHTML template.HTML
372 }
373 var matches []matchView
374 var queryErr string
375 if q != "" {
376 if len(q) < 2 || len(q) > 200 {
377 queryErr = "query must be 2 to 200 characters"
378 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
379 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
380 if err != nil {
381 http.Error(w, "internal error", http.StatusInternalServerError)
382 return
383 }
384 for _, m := range raw {
385 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
386 }
387 }
388 }
389 s.render(w, "search.html", struct {
390 repoPage
391 Query string
392 QueryErr string
393 Matches []matchView
394 Capped bool
395 }{p, q, queryErr, matches, len(matches) == 200})
396}
397
398// markMatch escapes a matched line and wraps case-insensitive occurrences
399// of the query in <mark>.
400func markMatch(text, q string) template.HTML {
401 lower, lq := strings.ToLower(text), strings.ToLower(q)
402 var b strings.Builder
403 pos := 0
404 for {
405 i := strings.Index(lower[pos:], lq)
406 if i < 0 {
407 break
408 }
409 i += pos
410 b.WriteString(template.HTMLEscapeString(text[pos:i]))
411 b.WriteString("<mark>")
412 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
413 b.WriteString("</mark>")
414 pos = i + len(q)
415 }
416 b.WriteString(template.HTMLEscapeString(text[pos:]))
417 return template.HTML(b.String())
418}
419
332420 // blamePageSize caps how many lines one blame page renders; blame is a
333421 // per-line subprocess cost, so large files paginate.
334422 const blamePageSize = 1000
@@ -417,7 +505,8 @@ func highlight(filePath string, data []byte) template.HTML {
417505 lexer = lexers.Fallback
418506 }
419507 style := styles.Get("friendly")
420 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
508 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false),
509 html.WithLinkableLineNumbers(true, "L"))
421510 iterator, err := lexer.Tokenise(nil, string(data))
422511 if err != nil {
423512 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
internal/web/static/style.css +30
@@ -578,6 +578,36 @@ footer p { margin: 0; }
578578 footer a { color: var(--muted); text-decoration: underline; }
579579 footer a:hover { color: var(--accent); }
580580
581/* search */
582form.searchform {
583 display: flex;
584 gap: var(--sp-2);
585 margin: var(--sp-3) 0 var(--sp-4);
586}
587form.searchform input[type="search"] { flex: 1; max-width: 32rem; }
588form.searchform.compact { margin: 0; }
589form.searchform.compact input[type="search"] {
590 padding: var(--sp-1) var(--sp-3);
591 font-size: var(--fs-2);
592 min-width: 18rem;
593}
594ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
595ul.matchlist li { margin-bottom: var(--sp-3); }
596ul.matchlist .matchpath { font-family: var(--mono); font-size: var(--fs-1); }
597pre.matchline {
598 background: var(--code-bg);
599 border: 1px solid var(--faint);
600 border-radius: var(--r-md);
601 padding: var(--sp-1) var(--sp-3);
602 margin: var(--sp-1) 0 0;
603 overflow-x: auto;
604}
605pre.matchline mark {
606 background: color-mix(in srgb, var(--warn) 25%, transparent);
607 color: inherit;
608 border-radius: 2px;
609}
610
581611 /* blame */
582612 .blame {
583613 border: 1px solid var(--line);
internal/web/templates/index.html +3
@@ -2,6 +2,9 @@
22 {{define "content"}}
33 <div class="headrow">
44 <h1>repositories</h1>
5<form method="get" action="/" class="searchform compact">
6 <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic">
7</form>
58 <span class="spacer"></span>
69 {{if .Viewer}}<p class="toolbar">logged in as {{.Viewer}} · <a href="/new">new repository</a> ·
710 <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}}
internal/web/templates/layout.html +1
@@ -31,6 +31,7 @@
3131 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
3232 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
3333 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
34 <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a>
3435 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
3536 </nav>
3637 </div>
internal/web/templates/search.html added +21
@@ -0,0 +1,21 @@
1{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform">
5 <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus>
6 <button type="submit">search</button>
7</form>
8{{if .QueryErr}}<p class="error">{{.QueryErr}}</p>
9{{else if .Query}}
10{{if .Matches}}
11<p class="meta">{{len .Matches}} match{{if ne (len .Matches) 1}}es{{end}}{{if .Capped}} (capped — refine the query){{end}} on <code>{{.Ref}}</code></p>
12<ul class="matchlist">
13{{range .Matches}}<li>
14 <a class="matchpath" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}#L{{.Line}}">{{.Path}}:{{.Line}}</a>
15 <pre class="matchline">{{.TextHTML}}</pre>
16</li>
17{{end}}
18</ul>
19{{else}}<p class="empty-note">no matches for “{{.Query}}” on <code>{{.Ref}}</code></p>{{end}}
20{{end}}
21{{end}}