~gitpocket/gitpocket

feat(basin): search file contents with git grep

commit
9791329566342bb03ca3cb95513e01ec63a711bd browse tree
parent
4b74ef808b842cc08084616176bd12d1a1d764b4
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-12 20:44 UTC

6 files changed, +121 −3

README.md modified

@@ -5,7 +5,7 @@ Small and lightweight git server for self hosting. Repositories are served over
55 ## Features
66
77 - Repos sit under `~user/name` and can be public or unlisted or private. Each one takes up to three tags, and there is a `/projects` page that searches across all of them sorted by whoever pushed most recently
8-- Tree and blob and log and commit and refs views, with chroma doing the highlighting and goldmark rendering the Markdown before bluemonday cleans it. Anything in `$x$` or `$$x$$` gets typeset in the browser
8+- Tree and blob and log and commit and refs views, plus a case insensitive search across file contents at any ref backed by `git grep`. Chroma does the highlighting and goldmark rendering the Markdown before bluemonday cleans it. Anything in `$x$` or `$$x$$` gets typeset in the browser
99 - The last commit shown against every tree entry comes out of one `git log --name-only` walk per directory instead of a separate process per file
1010 - Profiles have a display name and a Markdown bio along with location and workplace and website, plus an avatar that you crop in the browser and the server then resamples down to a 256px PNG
1111 - Forks and pull requests from either a branch or a fork. Comments are Markdown, merges take an editable message, and a request can be closed or reopened or brought up to date from its source at any point
@@ -71,7 +71,7 @@ To send changes to something you do not own, fork it from the button beside the
7171
7272 ## Limits
7373
74-There is no search inside files and no inline review comments, no squash or rebase merges, no collaborators beyond the single owner of a repo, and no disk quota of any kind. Blobs over 1 MiB are not rendered and anything over 64 MiB is not served raw either, while highlighting gives up past 256 KiB. Signature checking is SSH only for now, so commits signed with GPG show up as unverified.
74+There are no inline review comments, no squash or rebase merges, no collaborators beyond the single owner of a repo, and no disk quota of any kind. Blobs over 1 MiB are not rendered and anything over 64 MiB is not served raw either, while highlighting gives up past 256 KiB. Signature checking is SSH only for now, so commits signed with GPG show up as unverified.
7575
7676 ## Licence
7777

git.go modified

@@ -280,6 +280,36 @@ func (g Git) Tree(commit, dir string) ([]Entry, error) {
280280 return entries, nil
281281 }
282282
283+type Match struct {
284+ Path string
285+ Line int
286+ Text string
287+}
288+
289+func (g Git) Grep(commit, query string, limit int) ([]Match, bool, error) {
290+ out, err := g.run(nil, "grep", "-n", "-i", "-I", "-F", "-z", "--no-color", "-e", query, commit)
291+ if err != nil {
292+ if strings.TrimSpace(err.Error()) == "" || strings.Contains(err.Error(), "exit status 1") {
293+ return nil, false, nil
294+ }
295+ return nil, false, err
296+ }
297+ var matches []Match
298+ prefix := commit + ":"
299+ for _, line := range strings.Split(string(out), "\n") {
300+ f := strings.Split(line, "\x00")
301+ if len(f) != 3 || !strings.HasPrefix(f[0], prefix) {
302+ continue
303+ }
304+ n, _ := strconv.Atoi(f[1])
305+ matches = append(matches, Match{Path: strings.TrimPrefix(f[0], prefix), Line: n, Text: strings.TrimLeft(f[2], " \t")})
306+ if len(matches) > limit {
307+ return matches[:limit], true, nil
308+ }
309+ }
310+ return matches, false, nil
311+}
312+
283313 func (g Git) Entry(commit, filePath string) *Entry {
284314 if filePath == "" {
285315 return nil

static/style.css modified

@@ -195,7 +195,7 @@ input[type=file]{border:0;padding:0}
195195 .twocol label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
196196 h3.plain{border:0;font-size:1.25rem}
197197 .columns.browse{grid-template-columns:minmax(0,2fr) 1fr}
198-.search input[name=search]{width:100%}
198+.search input[name=search],.search input[name=q]{width:100%}
199199 .search .sort{display:flex;justify-content:flex-end;align-items:center;gap:1rem;margin:.5rem 0 1rem}
200200 .search .sort label{display:flex;align-items:center;gap:.3rem}
201201 .event .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.9rem}
@@ -233,3 +233,8 @@ select{font:inherit;padding:.35rem .5rem;border:1px solid #ced4da;background:#ff
233233 form.stack.centred{margin:0 auto 1.2rem;max-width:34rem}
234234 form.stack.centred input{width:100%}
235235 .privacy{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.6rem .8rem}
236+.filesearch input{padding:.15rem .4rem;font-size:.9rem;width:12rem}
237+.hits{margin-bottom:1rem}
238+.hits h4{margin:0 0 .2rem}
239+.hits table.blob{border:1px solid var(--line)}
240+mark{background:#fff3b0;padding:0}

templates/search.html added

@@ -0,0 +1,18 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<form method="get" action="/{{$p.Repo.Path}}/search" class="search">
4+<input type="hidden" name="ref" value="{{$p.Ref}}">
5+<input name="q" value="{{.Data.Query}}" placeholder="Search file contents" autofocus>
6+<div class="sort"><span class="muted">Case insensitive, fixed string, at <a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Ref}}</a></span><button class="btn">Search ▸</button></div>
7+</form>
8+{{if .Data.Query}}
9+<p class="muted">{{.Data.Count}} matches{{if .Data.Truncated}}, showing the first 500{{end}}</p>
10+{{range .Data.Groups}}
11+<div class="hits">
12+<h4><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">{{.Path}}</a></h4>
13+<table class="blob">{{range .Matches}}<tr><td class="n"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}#L{{.Line}}">{{.Line}}</a></td><td class="l">{{mark .Text $.Data.Query}}</td></tr>{{end}}</table>
14+</div>
15+{{end}}
16+{{if not .Data.Groups}}<p class="info">No matches.</p>{{end}}
17+{{end}}
18+{{end}}

templates/tree.html modified

@@ -20,6 +20,7 @@
2020 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
2121 {{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}}
2222 <span class="spacer"></span>
23+<form method="get" action="/{{$p.Repo.Path}}/search" class="filesearch"><input type="hidden" name="ref" value="{{$p.Ref}}"><input name="q" placeholder="Search files" value=""></form>
2324 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
2425 </div>
2526 {{end}}

web.go modified

@@ -78,6 +78,7 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv
7878 "inc": func(i int) int { return i + 1 },
7979 "perm": perm,
8080 "signer": s.signer,
81+ "mark": mark,
8182 "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
8283 "string": func(b []byte) string { return string(b) },
8384 }
@@ -646,6 +647,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
646647 s.log(w, r, repo, "")
647648 case strings.HasPrefix(rest, "commit/") && r.Method == "GET":
648649 s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/"))
650+ case rest == "search" && r.Method == "GET":
651+ s.search(w, r, repo)
649652 case rest == "pulls" && r.Method == "GET":
650653 s.pulls(w, r, repo)
651654 case rest == "pulls/new" && r.Method == "GET":
@@ -927,6 +930,44 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash
927930 s.render(w, r, "commit", page{Title: c.Subject, Data: map[string]any{"Page": rp, "Commit": c, "Files": files, "Added": added, "Removed": removed}})
928931 }
929932
933+type matchGroup struct {
934+ Path string
935+ Matches []Match
936+}
937+
938+func (s *Server) search(w http.ResponseWriter, r *http.Request, repo Repo) {
939+ ref := strings.TrimSpace(r.URL.Query().Get("ref"))
940+ rp, g, err := s.repoPage(r, repo, ref)
941+ if err != nil {
942+ s.fail(w, r, 404, "No such ref")
943+ return
944+ }
945+ rp.Tab = "tree"
946+ query := strings.TrimSpace(r.URL.Query().Get("q"))
947+ data := map[string]any{"Page": rp, "Query": query}
948+ if query != "" && !rp.Empty {
949+ if len(query) > 200 {
950+ query = query[:200]
951+ }
952+ matches, truncated, err := g.Grep(rp.Commit, query, 500)
953+ if err != nil {
954+ s.fail(w, r, 500, err.Error())
955+ return
956+ }
957+ var groups []matchGroup
958+ for _, m := range matches {
959+ if len(groups) == 0 || groups[len(groups)-1].Path != m.Path {
960+ groups = append(groups, matchGroup{Path: m.Path})
961+ }
962+ groups[len(groups)-1].Matches = append(groups[len(groups)-1].Matches, m)
963+ }
964+ data["Groups"] = groups
965+ data["Count"] = len(matches)
966+ data["Truncated"] = truncated
967+ }
968+ s.render(w, r, "search", page{Title: repo.Path() + " search", Data: data})
969+}
970+
930971 func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) {
931972 rp, g, err := s.repoPage(r, repo, "")
932973 if err != nil {
@@ -1004,6 +1045,29 @@ func ago(t time.Time) string {
10041045 }
10051046 }
10061047
1048+func mark(text, query string) template.HTML {
1049+ if query == "" {
1050+ return template.HTML(template.HTMLEscapeString(text))
1051+ }
1052+ var b strings.Builder
1053+ lower := strings.ToLower(text)
1054+ q := strings.ToLower(query)
1055+ for {
1056+ i := strings.Index(lower, q)
1057+ if i < 0 {
1058+ b.WriteString(template.HTMLEscapeString(text))
1059+ break
1060+ }
1061+ b.WriteString(template.HTMLEscapeString(text[:i]))
1062+ b.WriteString("<mark>")
1063+ b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
1064+ b.WriteString("</mark>")
1065+ text = text[i+len(q):]
1066+ lower = lower[i+len(q):]
1067+ }
1068+ return template.HTML(b.String())
1069+}
1070+
10071071 func perm(mode string) string {
10081072 bits := func(o string) string {
10091073 n, _ := strconv.ParseInt(o, 8, 16)