// Package gitread is the only package that touches go-git. It reads bare // repositories off disk and returns plain domain values; HTTP handlers ask it // for data and never open repositories themselves. package gitread import ( "database/sql" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" _ "modernc.org/sqlite" ) // ErrNotFound is returned when a repo, ref, or path does not exist. var ErrNotFound = errors.New("not found") // Store reads bare repositories from a root directory. When db is non-nil // (Soft Serve's database), only public repos are visible — private and hidden // repos are treated as if they do not exist. type Store struct { root string db *sql.DB } func New(root string) *Store { return &Store{root: root} } // WithDB attaches Soft Serve's sqlite database for visibility + metadata. The // db is opened read-concurrent (Soft Serve owns writes); only SELECTs are run. // A failure to open is returned so the caller can refuse to serve rather than // silently exposing private repos. func (s *Store) WithDB(path string) error { db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(0)") if err != nil { return err } if err := db.Ping(); err != nil { return err } s.db = db return nil } // publicMeta returns description/project for a public repo, or ok=false if the // repo is private, hidden, or unknown. Always ok=true when no db is attached. func (s *Store) publicMeta(name string) (desc, project string, ok bool) { if s.db == nil { return "", "", true } row := s.db.QueryRow( `SELECT coalesce(description,''), coalesce(project_name,'') FROM repos WHERE name = ? AND private = 0 AND hidden = 0`, name) if err := row.Scan(&desc, &project); err != nil { return "", "", false } return desc, project, true } // visible reports whether a repo may be served (public, or no db gating). func (s *Store) visible(name string) bool { _, _, ok := s.publicMeta(name) return ok } // Repo is a single repository summary for the list view. type Repo struct { Name string Description string Version string // latest semver tag (e.g. "v1.2.0"), "" if none VersionMsg string // that tag's annotation subject, "" if none/lightweight Last *Commit } // Commit is commit metadata. Subject is the first line of Message. type Commit struct { Hash string Short string Author string Email string When time.Time Message string Subject string } // Ref is a named branch or tag. Message is an annotated tag's subject ("" for // lightweight tags / branches). type Ref struct { Name string Hash string Message string } // Refs groups a repository's branches and tags. type Refs struct { Branches []Ref Tags []Ref } // Entry is a single tree entry (file or directory). type Entry struct { Name string Path string IsDir bool Mode string Size int64 } // CommitDetail is a commit plus its diff against its first parent. type CommitDetail struct { Commit Commit Parents []string Diff string } // List returns the servable repos, sorted by name. With a db attached it lists // only public repos (using db descriptions); otherwise every bare repo on disk. func (s *Store) List() ([]Repo, error) { if s.db != nil { return s.listFromDB() } entries, err := os.ReadDir(s.root) if err != nil { return nil, err } var repos []Repo for _, e := range entries { if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") { continue } name := strings.TrimSuffix(e.Name(), ".git") repos = append(repos, s.summary(name, s.description(e.Name()))) } sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name }) return repos, nil } // listFromDB lists public repos recorded in Soft Serve's db that also exist on // disk, preferring the db description (falling back to the git description file). func (s *Store) listFromDB() ([]Repo, error) { rows, err := s.db.Query( `SELECT name, coalesce(description,'') FROM repos WHERE private = 0 AND hidden = 0 ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var repos []Repo for rows.Next() { var name, desc string if err := rows.Scan(&name, &desc); err != nil { return nil, err } if info, err := os.Stat(s.dir(name)); err != nil || !info.IsDir() { continue // recorded but no bare repo on disk } if desc == "" { desc = s.description(name + ".git") } repos = append(repos, s.summary(name, desc)) } return repos, rows.Err() } // summary builds a list entry with the repo's HEAD commit + latest version. func (s *Store) summary(name, desc string) Repo { r := Repo{Name: name, Description: desc} if t, ok := s.LatestTag(name); ok { r.Version, r.VersionMsg = t.Name, t.Message } if repo, err := s.open(name); err == nil { if c, err := headCommit(repo); err == nil { r.Last = c } } return r } // LatestTag returns the highest-semver tag Ref (with its annotation message), // or ok=false if the repo has none. func (s *Store) LatestTag(name string) (Ref, bool) { refs, err := s.Refs(name) if err != nil { return Ref{}, false } var best Ref var bv [3]int have := false for _, t := range refs.Tags { v, ok := parseSemver(t.Name) if !ok { continue } if !have || semverLess(bv, v) { best, bv, have = t, v, true } } return best, have } // LatestVersion returns the name of the highest semver tag, or "". func (s *Store) LatestVersion(name string) string { t, _ := s.LatestTag(name) return t.Name } // firstLine returns the first non-empty line of s, trimmed. func firstLine(s string) string { s = strings.TrimSpace(s) if i := strings.IndexByte(s, '\n'); i >= 0 { s = strings.TrimSpace(s[:i]) } return s } func parseSemver(tag string) ([3]int, bool) { s := strings.TrimPrefix(tag, "v") if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] } parts := strings.Split(s, ".") if len(parts) != 3 { return [3]int{}, false } var v [3]int for i, p := range parts { n, err := strconv.Atoi(p) if err != nil { return [3]int{}, false } v[i] = n } return v, true } func semverLess(a, b [3]int) bool { for i := 0; i < 3; i++ { if a[i] != b[i] { return a[i] < b[i] } } return false } // Exists reports whether a repo is present and visible (public, when db-gated). func (s *Store) Exists(name string) bool { if !s.visible(name) { return false } info, err := os.Stat(s.dir(name)) return err == nil && info.IsDir() } // Refs returns the branches and tags of a repo. func (s *Store) Refs(name string) (*Refs, error) { repo, err := s.open(name) if err != nil { return nil, err } out := &Refs{} bs, err := repo.Branches() if err == nil { _ = bs.ForEach(func(r *plumbing.Reference) error { out.Branches = append(out.Branches, Ref{Name: r.Name().Short(), Hash: r.Hash().String()}) return nil }) } ts, err := repo.Tags() if err == nil { _ = ts.ForEach(func(r *plumbing.Reference) error { ref := Ref{Name: r.Name().Short(), Hash: r.Hash().String()} // Annotated tags resolve to a tag object carrying a message. if to, err := repo.TagObject(r.Hash()); err == nil { ref.Message = firstLine(to.Message) } out.Tags = append(out.Tags, ref) return nil }) } sort.Slice(out.Branches, func(i, j int) bool { return out.Branches[i].Name < out.Branches[j].Name }) // Tags newest-first: semver descending, non-semver after (reverse-lexical). sort.Slice(out.Tags, func(i, j int) bool { vi, oki := parseSemver(out.Tags[i].Name) vj, okj := parseSemver(out.Tags[j].Name) if oki && okj { return semverLess(vj, vi) } if oki != okj { return oki } return out.Tags[i].Name > out.Tags[j].Name }) return out, nil } // DefaultBranch returns the short name of HEAD's target branch. func (s *Store) DefaultBranch(name string) (string, error) { repo, err := s.open(name) if err != nil { return "", err } head, err := repo.Head() if err != nil { return "", ErrNotFound } return head.Name().Short(), nil } // Tree lists the entries at path under ref. An empty path is the root. func (s *Store) Tree(name, ref, path string) ([]Entry, error) { repo, err := s.open(name) if err != nil { return nil, err } tree, err := s.treeAt(repo, ref) if err != nil { return nil, err } path = strings.Trim(path, "/") if path != "" { tree, err = tree.Tree(path) if err != nil { return nil, ErrNotFound } } var out []Entry for _, e := range tree.Entries { isDir := e.Mode == 0o040000 entry := Entry{ Name: e.Name, Path: strings.TrimPrefix(path+"/"+e.Name, "/"), IsDir: isDir, Mode: e.Mode.String(), } if !isDir { if blob, err := repo.BlobObject(e.Hash); err == nil { entry.Size = blob.Size } } out = append(out, entry) } sort.Slice(out, func(i, j int) bool { if out[i].IsDir != out[j].IsDir { return out[i].IsDir // directories first } return out[i].Name < out[j].Name }) return out, nil } // Blob returns the raw bytes of the file at path under ref. func (s *Store) Blob(name, ref, path string) ([]byte, error) { repo, err := s.open(name) if err != nil { return nil, err } tree, err := s.treeAt(repo, ref) if err != nil { return nil, err } f, err := tree.File(strings.Trim(path, "/")) if err != nil { return nil, ErrNotFound } r, err := f.Blob.Reader() if err != nil { return nil, err } defer r.Close() buf := make([]byte, f.Size) if _, err := readFull(r, buf); err != nil { return nil, err } return buf, nil } // licenseFiles are the root license filenames tried, in order. var licenseFiles = []string{ "LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt", "COPYING", "COPYING.md", } // LicenseFile returns the path and contents of a root license file, if present. func (s *Store) LicenseFile(name, ref string) (path string, data []byte, ok bool) { for _, fn := range licenseFiles { if b, err := s.Blob(name, ref, fn); err == nil { return fn, b, true } } return "", nil, false } // Log returns up to limit commits reachable from ref, newest first. func (s *Store) Log(name, ref string, limit int) ([]Commit, error) { repo, err := s.open(name) if err != nil { return nil, err } h, err := s.resolve(repo, ref) if err != nil { return nil, err } iter, err := repo.Log(&git.LogOptions{From: *h}) if err != nil { return nil, err } defer iter.Close() var out []Commit for len(out) < limit { c, err := iter.Next() if err != nil { break } out = append(out, toCommit(c)) } return out, nil } // Commit returns a single commit with its diff against the first parent. func (s *Store) Commit(name, rev string) (*CommitDetail, error) { repo, err := s.open(name) if err != nil { return nil, err } h, err := s.resolve(repo, rev) if err != nil { return nil, err } c, err := repo.CommitObject(*h) if err != nil { return nil, ErrNotFound } d := &CommitDetail{Commit: toCommit(c)} for _, p := range c.ParentHashes { d.Parents = append(d.Parents, p.String()) } if c.NumParents() > 0 { parent, err := c.Parent(0) if err == nil { if patch, err := parent.Patch(c); err == nil { d.Diff = patch.String() } } } else { if patch, err := c.Patch(nil); err == nil { d.Diff = patch.String() } } return d, nil } // --- internals --- func (s *Store) dir(name string) string { return filepath.Join(s.root, name+".git") } func (s *Store) open(name string) (*git.Repository, error) { if !s.visible(name) { return nil, ErrNotFound // private/hidden/unknown — never serve } repo, err := git.PlainOpen(s.dir(name)) if errors.Is(err, git.ErrRepositoryNotExists) || errors.Is(err, fs.ErrNotExist) { return nil, ErrNotFound } return repo, err } // description reads the git `description` file, ignoring the default placeholder. func (s *Store) description(dirName string) string { b, err := os.ReadFile(filepath.Join(s.root, dirName, "description")) if err != nil { return "" } d := strings.TrimSpace(string(b)) if strings.HasPrefix(d, "Unnamed repository") { return "" } return d } // resolve turns a ref name, short hash, or HEAD into a commit hash. func (s *Store) resolve(repo *git.Repository, ref string) (*plumbing.Hash, error) { if ref == "" { ref = "HEAD" } h, err := repo.ResolveRevision(plumbing.Revision(ref)) if err != nil { return nil, ErrNotFound } return h, nil } func (s *Store) treeAt(repo *git.Repository, ref string) (*object.Tree, error) { h, err := s.resolve(repo, ref) if err != nil { return nil, err } c, err := repo.CommitObject(*h) if err != nil { return nil, ErrNotFound } return c.Tree() } func headCommit(repo *git.Repository) (*Commit, error) { head, err := repo.Head() if err != nil { return nil, err } c, err := repo.CommitObject(head.Hash()) if err != nil { return nil, err } out := toCommit(c) return &out, nil } func toCommit(c *object.Commit) Commit { msg := strings.TrimRight(c.Message, "\n") subject := msg if i := strings.IndexByte(subject, '\n'); i >= 0 { subject = subject[:i] } return Commit{ Hash: c.Hash.String(), Short: c.Hash.String()[:8], Author: c.Author.Name, Email: c.Author.Email, When: c.Author.When, Message: msg, Subject: subject, } } // readFull fills buf, tolerating short reads, and erroring on a size mismatch. func readFull(r interface{ Read([]byte) (int, error) }, buf []byte) (int, error) { total := 0 for total < len(buf) { n, err := r.Read(buf[total:]) total += n if err != nil { if total == len(buf) { return total, nil } return total, err } } if total != len(buf) { return total, fmt.Errorf("short read: got %d want %d", total, len(buf)) } return total, nil }