package forge import ( "context" "encoding/base64" "mime" "path" "slices" "strconv" "strings" "time" "unicode/utf8" ) type repoView struct { Path string `json:"path"` Owner string `json:"owner"` Name string `json:"name"` Description string `json:"description"` Visibility string `json:"visibility"` Tags []string `json:"tags"` Archived bool `json:"archived"` ForkOf *string `json:"fork_of"` Mirror *string `json:"mirror_of"` Stars int `json:"stars"` Forks int `json:"forks"` Branch string `json:"default_branch"` Language string `json:"language,omitempty"` Created *string `json:"created"` Pushed *string `json:"pushed"` Clone cloneSet `json:"clone"` URL string `json:"url"` API string `json:"api"` } type cloneSet struct { HTTPS string `json:"https"` SSH string `json:"ssh"` } type refView struct { Name string `json:"name"` Kind string `json:"kind"` SHA string `json:"sha"` At *string `json:"at"` Default bool `json:"default"` } type personView struct { Name string `json:"name"` Email string `json:"email"` Account *string `json:"account"` } type commitView struct { SHA string `json:"sha"` Subject string `json:"subject"` Body string `json:"body"` Author personView `json:"author"` At *string `json:"at"` Parents []string `json:"parents"` Seal *string `json:"sealed_by"` Files []fileView `json:"files,omitempty"` } type fileView struct { Path string `json:"path"` From string `json:"from,omitempty"` Status string `json:"status"` Binary bool `json:"binary"` Added int `json:"added"` Removed int `json:"removed"` Hunks []hunkView `json:"hunks,omitempty"` } type hunkView struct { Header string `json:"header"` Lines []string `json:"lines"` } type entryView struct { Name string `json:"name"` Path string `json:"path"` Kind string `json:"kind"` Mode string `json:"mode"` Size int64 `json:"size"` SHA string `json:"sha"` } type labelView struct { ID int64 `json:"id"` Name string `json:"name"` Colour string `json:"colour"` } func visibilityName(r Repo) string { switch { case r.Private: return "private" case r.Unlisted: return "unlisted" } return "public" } func (s *Server) repoView(r Repo) repoView { v := repoView{Path: r.Path(), Owner: handle(r.Owner.Name), Name: r.Name, Description: r.Description, Visibility: visibilityName(r), Tags: nonNil(r.Tags), Archived: r.Archived, Stars: r.Stars, Forks: r.Forks, Branch: Git{Dir: s.repoDir(r)}.Head(), Created: iso(r.Created), Pushed: iso(repoUpdated(s.repoDir(r))), Clone: cloneSet{HTTPS: "https://" + s.host + "/" + r.Path(), SSH: s.sshUser + "@" + s.sshHost + ":" + r.Path()}, URL: "https://" + s.host + "/" + r.Path(), API: "https://" + s.host + "/api/" + r.Path()} if r.Parent != 0 { if p, err := s.store.RepoByID(r.Parent); err == nil && !p.Private { path := p.Path() v.ForkOf = &path } } if m, ok := s.store.PullMirror(r.ID); ok { src := m.Source() v.Mirror = &src } return v } func (s *Server) commitView(c Commit) commitView { v := commitView{SHA: c.Hash, Subject: c.Subject, Body: c.Body, Author: personView{Name: c.Author, Email: c.Email}, At: iso(c.Time), Parents: nonNil(c.Parents)} if a := s.account(c); a != "" { h := handle(a) v.Author.Account = &h } if signer := s.signer(c); signer != "" { h := handle(signer) v.Seal = &h } return v } func fileViews(files []File, patch bool) []fileView { return mapped(files, func(f File) fileView { v := fileView{Path: f.New, Status: f.Status, Binary: f.Binary, Added: f.Added, Removed: f.Removed} if f.Old != f.New { v.From = f.Old } if patch { for _, h := range f.Hunks { hv := hunkView{Header: h.Header, Lines: []string{}} for _, l := range h.Lines { mark := " " switch l.Kind { case "add": mark = "+" case "del": mark = "-" case "ctx": default: mark = "\\" } hv.Lines = append(hv.Lines, mark+l.Text) } v.Hunks = append(v.Hunks, hv) } } return v }) } func entryKind(e Entry) string { switch { case e.Type == "tree": return "dir" case e.Type == "commit": return "submodule" case strings.HasPrefix(e.Mode, "120"): return "link" } return "file" } func (s *Server) repoRoutes() []apiRoute { return []apiRoute{ {"GET", "/repos", needNone, "public repositories, newest first, ?q= and ?tag= narrow the list", s.apiRepos, nil}, {"POST", "/repos", needWrite, "create a repository from {name, owner, description, visibility, tags}", s.apiCreateRepo, nil}, {"GET", "/~:owner/:repo", needNone, "a repository", s.apiRepo, nil}, {"PATCH", "/~:owner/:repo", needWrite, "change description, visibility, tags, pinned or archived, rename with name and transfer with owner", s.apiPatchRepo, nil}, {"DELETE", "/~:owner/:repo", needWrite, "delete the repository, ?confirm= must repeat its name", s.apiDeleteRepo, nil}, {"GET", "/~:owner/:repo/forks", needNone, "forks you can see", s.apiForks, nil}, {"POST", "/~:owner/:repo/forks", needWrite, "fork into your account", s.apiFork, nil}, {"PUT", "/~:owner/:repo/star", needWrite, "star", s.apiStar, nil}, {"DELETE", "/~:owner/:repo/star", needWrite, "unstar", s.apiStar, nil}, {"GET", "/~:owner/:repo/collaborators", needUser, "collaborators, visible to anyone who can push", s.apiCollaborators, nil}, {"PUT", "/~:owner/:repo/collaborators/~:name", needWrite, "grant push access", s.apiCollaborator, nil}, {"DELETE", "/~:owner/:repo/collaborators/~:name", needWrite, "revoke push access", s.apiCollaborator, nil}, {"GET", "/~:owner/:repo/labels", needNone, "labels", s.apiLabels, nil}, {"POST", "/~:owner/:repo/labels", needWrite, "add a label from {name, colour}", s.apiAddLabel, nil}, {"DELETE", "/~:owner/:repo/labels/:id", needWrite, "delete a label", s.apiDeleteLabel, nil}, {"GET", "/~:owner/:repo/protections", needNone, "protected branches", s.apiProtections, nil}, {"PUT", "/~:owner/:repo/protections/*branch", needWrite, "protect a branch with {require_pull, approvals}", s.apiProtect, nil}, {"DELETE", "/~:owner/:repo/protections/*branch", needWrite, "lift a branch protection", s.apiUnprotect, nil}, {"GET", "/~:owner/:repo/refs", needNone, "branches and tags, ?kind=branch|tag", s.apiRefs, nil}, {"GET", "/~:owner/:repo/log/*ref", needNone, "history from a ref or the default branch, ?path= follows one file", s.apiLog, nil}, {"GET", "/~:owner/:repo/commit/:sha", needNone, "a commit and the files it touched, ?patch=1 adds the hunks", s.apiCommit, nil}, {"GET", "/~:owner/:repo/compare/*spec", needNone, "commits and files between base...head, ?patch=1 adds the hunks", s.apiCompare, nil}, {"GET", "/~:owner/:repo/tree/*spec", needNone, "a directory listing or a file at ref/path, files carry their content up to 1 MiB", s.apiTree, nil}, {"PUT", "/~:owner/:repo/tree/*spec", needWrite, "commit a file to branch/path from {content, encoding, message, parent}", s.apiPutFile, nil}, {"DELETE", "/~:owner/:repo/tree/*spec", needWrite, "commit the removal of branch/path, ?message= and ?parent= are optional", s.apiDeleteFile, nil}, {"GET", "/~:owner/:repo/raw/*spec", needNone, "the bytes of a file at ref/path", s.apiRaw, nil}, {"GET", "/~:owner/:repo/blame/*spec", needNone, "the last commit to touch each line of a file", s.apiBlame, nil}, {"GET", "/~:owner/:repo/readme", needNone, "the README of a ref, raw and rendered, ?ref= picks the ref", s.apiReadme, nil}, {"GET", "/~:owner/:repo/languages", needNone, "languages by bytes on the default branch", s.apiLanguages, nil}, {"GET", "/~:owner/:repo/grep", needNone, "fixed string search through a ref, ?q= and ?ref=", s.apiGrep, nil}, {"GET", "/~:owner/:repo/archive/*spec", needNone, "a tar.gz or zip of a ref, as ref.tar.gz or ref.zip", s.apiArchive, nil}, } } func (s *Server) apiRepos(c *call) { repos, err := s.store.PublicRepos(strings.TrimSpace(c.query("q")), strings.ToLower(c.query("tag"))) if err != nil { c.err(err) return } repos = slices.DeleteFunc(repos, func(r Repo) bool { return r.Unlisted }) paged(c, s.repoViews(repos)) } func (s *Server) apiCreateRepo(c *call) { var in struct { Name string `json:"name"` Owner string `json:"owner"` Description string `json:"description"` Visibility string `json:"visibility"` Tags []string `json:"tags"` } if !c.decode(&in) { return } if in.Visibility != "" && in.Visibility != "public" && in.Visibility != "unlisted" && in.Visibility != "private" { c.fail(400, "Visibility must be public, unlisted or private") return } repo, err := s.createRepo(*c.user, strings.TrimPrefix(in.Owner, "~"), in.Name, in.Description, visibility(in.Visibility), strings.Join(in.Tags, ",")) if err != nil { c.err(err) return } c.w.Header().Set("Location", "https://"+s.host+"/api/"+repo.Path()) c.reply(201, s.repoView(repo)) } func (s *Server) apiRepo(c *call) { if repo, ok := c.repo(); ok { c.reply(200, s.repoView(repo)) } } func (s *Server) apiPatchRepo(c *call) { repo, ok := c.repo() if !ok { return } if err := s.ownRepo(c.user, repo); err != nil { c.err(err) return } var in struct { Description *string `json:"description"` Visibility *string `json:"visibility"` Tags *[]string `json:"tags"` Pinned *bool `json:"pinned"` Archived *bool `json:"archived"` Name *string `json:"name"` Owner *string `json:"owner"` } if !c.decode(&in) { return } if in.Description != nil || in.Visibility != nil || in.Tags != nil { desc, vis, tags := repo.Description, visibility(visibilityName(repo)), strings.Join(repo.Tags, ",") if in.Description != nil { desc = strings.TrimSpace(*in.Description) } if in.Visibility != nil { if *in.Visibility != "public" && *in.Visibility != "unlisted" && *in.Visibility != "private" { c.fail(400, "Visibility must be public, unlisted or private") return } vis = visibility(*in.Visibility) } if in.Tags != nil { tags = strings.Join(*in.Tags, ",") } if err := s.store.UpdateRepo(repo.ID, desc, vis, tags); err != nil { c.err(err) return } } if in.Pinned != nil && *in.Pinned != (s.store.Pins(repo.Owner.ID)[repo.ID] > 0) { if err := s.store.Pin(repo.Owner.ID, repo.ID, *in.Pinned); err != nil { c.err(err) return } } if in.Archived != nil { s.store.SetArchived(repo.ID, *in.Archived) } var err error if in.Name != nil { if repo, err = s.renameRepoTo(*c.user, repo, strings.TrimSpace(*in.Name)); err != nil { c.err(err) return } } if in.Owner != nil { if repo, err = s.transferRepoTo(*c.user, repo, *in.Owner); err != nil { c.err(err) return } } fresh, err := s.store.RepoByID(repo.ID) if err != nil { c.err(err) return } c.reply(200, s.repoView(fresh)) } func (s *Server) apiDeleteRepo(c *call) { repo, ok := c.repo() if !ok { return } if c.query("confirm") != repo.Name { c.fail(400, "Repeat the repository name as ?confirm="+repo.Name+" to delete it") return } if err := s.removeRepo(*c.user, repo); err != nil { c.err(err) return } c.done() } func (s *Server) apiForks(c *call) { repo, ok := c.repo() if !ok { return } var viewer int64 if c.user != nil { viewer = c.user.ID } forks, err := s.store.Forks(repo.ID, viewer) if err != nil { c.err(err) return } paged(c, s.repoViews(forks)) } func (s *Server) apiFork(c *call) { repo, ok := c.repo() if !ok { return } fork, err := s.forkRepo(*c.user, repo) if err != nil { c.err(err) return } c.reply(201, s.repoView(fork)) } func (s *Server) apiStar(c *call) { repo, ok := c.repo() if !ok { return } if err := s.starRepo(*c.user, repo, c.r.Method == "PUT"); err != nil { c.err(err) return } c.done() } func (s *Server) apiCollaborators(c *call) { repo, ok := c.repo() if !ok { return } if !s.store.CanWrite(repo, c.user) { c.fail(403, "Only people who can push see the collaborators") return } users, err := s.store.Collaborators(repo.ID) if err != nil { c.err(err) return } paged(c, mapped(users, s.brief)) } func (s *Server) apiCollaborator(c *call) { repo, ok := c.repo() if !ok { return } if err := s.ownRepo(c.user, repo); err != nil { c.err(err) return } target, err := s.store.UserByName(c.arg("name")) if err != nil { c.fail(404, "No such account") return } if c.r.Method == "DELETE" { s.store.RemoveCollaborator(repo.ID, target.ID) c.done() return } if target.ID == repo.Owner.ID { c.fail(409, "The owner already has access") return } if err := s.store.AddCollaborator(repo.ID, target.Name); err != nil { c.err(err) return } c.done() } func labelViews(labels []Label) []labelView { return mapped(labels, func(l Label) labelView { return labelView{l.ID, l.Name, l.Colour} }) } func (s *Server) apiLabels(c *call) { repo, ok := c.repo() if !ok { return } labels, err := s.store.Labels(repo.ID) if err != nil { c.err(err) return } paged(c, labelViews(labels)) } func (s *Server) apiAddLabel(c *call) { repo, ok := c.repo() if !ok { return } if err := s.ownRepo(c.user, repo); err != nil { c.err(err) return } var in struct { Name string `json:"name"` Colour string `json:"colour"` } if !c.decode(&in) { return } if err := s.store.AddLabel(repo.ID, in.Name, in.Colour); err != nil { c.err(err) return } labels, _ := s.store.Labels(repo.ID) for _, l := range labels { if l.Name == strings.TrimSpace(in.Name) { c.reply(201, labelView{l.ID, l.Name, l.Colour}) return } } c.reply(201, labelViews(labels)) } func (s *Server) apiDeleteLabel(c *call) { repo, ok := c.repo() if !ok { return } if err := s.ownRepo(c.user, repo); err != nil { c.err(err) return } id, ok := c.id("id") if !ok { return } s.store.DeleteLabel(repo.ID, id) c.done() } func (s *Server) apiProtections(c *call) { repo, ok := c.repo() if !ok { return } type row struct { Branch string `json:"branch"` RequirePull bool `json:"require_pull"` Approvals int `json:"approvals"` } paged(c, mapped(s.store.Protections(repo.ID), func(p Protection) row { return row{p.Branch, p.RequirePR, p.Approvals} })) } func (s *Server) apiProtect(c *call) { repo, ok := c.repo() if !ok { return } if err := s.ownRepo(c.user, repo); err != nil { c.err(err) return } var in struct { RequirePull bool `json:"require_pull"` Approvals int `json:"approvals"` } if !c.decode(&in) { return } if err := s.store.SetProtection(repo.ID, c.arg("branch"), in.RequirePull, in.Approvals); err != nil { c.err(err) return } c.done() } func (s *Server) apiUnprotect(c *call) { repo, ok := c.repo() if !ok { return } if err := s.ownRepo(c.user, repo); err != nil { c.err(err) return } s.store.DeleteProtection(repo.ID, c.arg("branch")) c.done() } func (s *Server) apiRefs(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} prefixes := []string{"refs/heads", "refs/tags"} switch c.query("kind") { case "branch": prefixes = prefixes[:1] case "tag": prefixes = prefixes[1:] } refs, err := g.Refs(prefixes...) if err != nil { c.err(err) return } head := g.Head() paged(c, mapped(refs, func(r Ref) refView { kind := "branch" if r.Tag { kind = "tag" } return refView{Name: r.Name, Kind: kind, SHA: r.Hash, At: iso(r.Time), Default: !r.Tag && r.Name == head} })) } func (c *call) commitAt(g Git, ref string) (string, bool) { if ref == "" { ref = g.Head() } sha, err := g.Resolve(ref) if err != nil { if g.Empty() { c.fail(404, "The repository is empty") } else { c.fail(404, "No such ref") } return "", false } return sha, true } func (c *call) refPath(g Git) (string, string, bool) { spec := c.arg("spec") if spec == "" { c.fail(404, "Name a ref, then a path") return "", "", false } ref, filePath, ok := c.s.splitRef(g, spec) if !ok { c.fail(404, "No such ref") return "", "", false } filePath, ok = cleanPath(filePath) if !ok { c.fail(404, "No such path") return "", "", false } return ref, filePath, true } func (s *Server) apiLog(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} sha, ok := c.commitAt(g, strings.Trim(c.arg("ref"), "/")) if !ok { return } filePath, ok := cleanPath(c.query("path")) if !ok { c.fail(404, "No such path") return } limit, err := strconv.Atoi(c.query("limit")) if err != nil || limit <= 0 { limit = 30 } limit = min(limit, 100) from, _ := strconv.Atoi(c.query("from")) from = max(from, 0) commits, err := g.Log(sha, limit+1, filePath, "--skip="+strconv.Itoa(from)) if err != nil { c.err(err) return } out := struct { Items []commitView `json:"items"` Next *string `json:"next"` }{Items: mapped(commits[:min(limit, len(commits))], s.commitView)} if len(commits) > limit { q := c.r.URL.Query() q.Set("from", strconv.Itoa(from+limit)) q.Set("limit", strconv.Itoa(limit)) next := "https://" + s.host + c.r.URL.Path + "?" + q.Encode() out.Next = &next c.w.Header().Set("Link", "<"+next+`>; rel="next"`) } c.reply(200, out) } func (s *Server) apiCommit(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} sha, ok := c.commitAt(g, c.arg("sha")) if !ok { return } commit, err := g.Commit(sha) if err != nil { c.fail(404, "No such commit") return } files, err := g.Diff(sha) if err != nil { c.err(err) return } v := s.commitView(commit) v.Files = nonNil(fileViews(files, c.query("patch") == "1")) c.reply(200, v) } func (s *Server) apiCompare(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} base, head, found := strings.Cut(c.arg("spec"), "...") if !found { c.fail(400, "Compare takes base...head") return } from, ok := c.commitAt(g, base) if !ok { return } to, ok := c.commitAt(g, head) if !ok { return } mb, err := g.MergeBase(from, to) if err != nil { c.fail(409, "The two refs share no history") return } commits, _ := g.LogRange(mb, to, 250) files, _ := g.DiffRange(mb, to) c.reply(200, map[string]any{"base": from, "head": to, "merge_base": mb, "commits": mapped(commits, s.commitView), "files": fileViews(files, c.query("patch") == "1")}) } func (s *Server) apiTree(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := c.refPath(g) if !ok { return } sha, ok := c.commitAt(g, ref) if !ok { return } switch g.Kind(sha, filePath) { case "tree", "": if filePath != "" && g.Kind(sha, filePath) == "" { c.fail(404, "No such path") return } entries, err := g.Tree(sha, filePath) if err != nil { c.fail(404, "No such path") return } c.reply(200, map[string]any{"ref": ref, "commit": sha, "path": filePath, "kind": "dir", "entries": mapped(entries, func(e Entry) entryView { return entryView{Name: e.Name, Path: path.Join(filePath, e.Name), Kind: entryKind(e), Mode: e.Mode, Size: e.Size, SHA: e.Hash} })}) default: e := g.Entry(sha, filePath) if e == nil { c.fail(404, "No such path") return } out := map[string]any{"ref": ref, "commit": sha, "path": filePath, "kind": entryKind(*e), "mode": e.Mode, "size": e.Size, "sha": e.Hash, "raw": "https://" + s.host + "/api/" + repo.Path() + "/raw/" + ref + "/" + escapePath(filePath)} if e.Size <= 1<<20 && entryKind(*e) != "submodule" { data, err := g.Blob(sha, filePath) if err == nil { if utf8.Valid(data) && !isBinary(data) { out["encoding"], out["content"] = "utf-8", string(data) } else { out["encoding"], out["content"] = "base64", base64.StdEncoding.EncodeToString(data) } } } c.reply(200, out) } } func (s *Server) apiPutFile(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} branch, filePath, ok := c.refPath(g) if !ok { return } if filePath == "" { c.fail(400, "Name a file after the branch") return } var in struct { Content string `json:"content"` Encoding string `json:"encoding"` Message string `json:"message"` Parent string `json:"parent"` } if !c.decode(&in) { return } data := []byte(in.Content) switch in.Encoding { case "", "utf-8": case "base64": var err error if data, err = base64.StdEncoding.DecodeString(in.Content); err != nil { c.fail(400, "Content is not valid base64") return } default: c.fail(400, "Encoding must be utf-8 or base64") return } sha, err := s.writeFile(*c.user, repo, branch, filePath, data, false, in.Message, in.Parent) if err != nil { c.err(err) return } commit, _ := g.Commit(sha) c.reply(201, s.commitView(commit)) } func (s *Server) apiDeleteFile(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} branch, filePath, ok := c.refPath(g) if !ok { return } sha, err := s.writeFile(*c.user, repo, branch, filePath, nil, true, c.query("message"), c.query("parent")) if err != nil { c.err(err) return } commit, _ := g.Commit(sha) c.reply(200, s.commitView(commit)) } func (s *Server) apiRaw(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := c.refPath(g) if !ok { return } sha, ok := c.commitAt(g, ref) if !ok { return } if g.Kind(sha, filePath) != "blob" { c.fail(404, "No such file") return } h := c.w.Header() h.Set("Content-Type", "application/octet-stream") h.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(filePath)})) h.Set("Content-Length", strconv.FormatInt(g.Size(sha, filePath), 10)) h.Set("Content-Security-Policy", "default-src 'none'; sandbox") g.Stream(c.w, sha, filePath) } func (s *Server) apiBlame(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := c.refPath(g) if !ok { return } sha, ok := c.commitAt(g, ref) if !ok { return } if g.Kind(sha, filePath) != "blob" { c.fail(404, "No such file") return } if g.Size(sha, filePath) > maxRender { c.fail(413, "File too large to blame") return } lines, err := g.Blame(sha, filePath) if err != nil { c.err(err) return } type row struct { Line int `json:"line"` SHA string `json:"sha"` Author string `json:"author"` At *string `json:"at"` Subject string `json:"subject"` Text string `json:"text"` } c.reply(200, map[string]any{"commit": sha, "path": filePath, "lines": mapped(lines, func(l BlameLine) row { return row{l.Line, l.Hash, l.Author, iso(l.Time), l.Subject, l.Text} })}) } func (s *Server) apiReadme(c *call) { repo, ok := c.repo() if !ok { return } g := Git{Dir: s.repoDir(repo)} sha, ok := c.commitAt(g, c.query("ref")) if !ok { return } name, data := g.Readme(sha) if name == "" { c.fail(404, "No README at this ref") return } c.reply(200, map[string]string{"path": name, "commit": sha, "text": string(data), "html": string(s.renderMarkdown(data))}) } func (s *Server) apiLanguages(c *call) { repo, ok := c.repo() if !ok { return } type row struct { Name string `json:"name"` Colour string `json:"colour"` Bytes int64 `json:"bytes"` Percent float64 `json:"percent"` } c.reply(200, mapped(s.repoLanguages(repo), func(l language) row { return row{l.Name, l.Colour, l.Bytes, l.Percent} })) } func (s *Server) apiGrep(c *call) { repo, ok := c.repo() if !ok { return } q := strings.TrimSpace(c.query("q")) if q == "" { c.fail(400, "Pass ?q=") return } g := Git{Dir: s.repoDir(repo)} sha, ok := c.commitAt(g, c.query("ref")) if !ok { return } matches, more, err := g.Grep(sha, q, 500) if err != nil { c.err(err) return } type row struct { Path string `json:"path"` Line int `json:"line"` Text string `json:"text"` } c.reply(200, map[string]any{"commit": sha, "truncated": more, "matches": mapped(matches, func(m Match) row { return row{m.Path, m.Line, m.Text} })}) } func (s *Server) apiArchive(c *call) { repo, ok := c.repo() if !ok { return } spec, format := c.arg("spec"), "" switch { case strings.HasSuffix(spec, ".tar.gz"): format, spec = "tar.gz", strings.TrimSuffix(spec, ".tar.gz") case strings.HasSuffix(spec, ".zip"): format, spec = "zip", strings.TrimSuffix(spec, ".zip") default: c.fail(404, "Ask for ref.tar.gz or ref.zip") return } g := Git{Dir: s.repoDir(repo)} sha, ok := c.commitAt(g, spec) if !ok { return } name := repo.Name + "-" + short(sha) ctx, cancel := context.WithTimeout(c.r.Context(), 10*time.Minute) defer cancel() c.w.Header().Set("Content-Type", "application/octet-stream") c.w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + "." + format})) writeArchive(ctx, c.w, g.Dir, format, name, sha) }