package gitread import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "io/fs" "path/filepath" "sort" "strconv" "strings" "time" "github.com/barerepo/server/internal/gitx" ) // Show reads one commit whole, because the commit page is where a person went to see it. func Show(ctx context.Context, dir, rev string) (*Commit, error) { if !gitx.ValidRev(rev) { return nil, nil } out, err := gitx.Run(ctx, dir, "log", "--max-count=1", "--format="+logFormat, "--patch", "--unified=3", "--no-color", "--find-renames", rev, "--") if err != nil { return nil, err } commits := parseLog(out, parsePatch) if len(commits) == 0 { return nil, nil } c := commits[0] markSigned(ctx, dir, commits[:1]) c.Signed = commits[0].Signed if c.Signed && Keyring != "" { // One more process, and only for a commit that carries a signature to check. var buf bytes.Buffer err := gitx.Pipe(ctx, dir, nil, &buf, io.Discard, []string{"GNUPGHOME=" + Keyring}, "log", "--max-count=1", "--format=%G?\x1e%GS\x1e%GF", rev, "--") if err == nil { f := strings.SplitN(strings.TrimSpace(buf.String()), "\x1e", 3) c.SigState, c.Signer = f[0], f[1] if len(f) == 3 { c.SigKey = f[2] } } } return &c, nil } func patchOf(logOutput string) string { if i := strings.Index(logOutput, "\x1e"); i >= 0 { fields := strings.SplitN(strings.TrimPrefix(logOutput, "\x00"), "\x1e", 7) if len(fields) == 7 { return fields[6] } } return "" } // Reachable answers "is this on the default branch", which is what a link's reader wants. func Reachable(ctx context.Context, dir, rev, ref string) bool { if !gitx.ValidRev(rev) || !gitx.ValidRev(ref) { return false } _, err := gitx.Run(ctx, dir, "merge-base", "--is-ancestor", rev, ref) return err == nil } // Entry is one row of the file tree. type Entry struct { Name string Path string Dir bool Size int64 Subject string // the last commit that touched it Author string When time.Time } // Tree lists one directory, and its per-entry log is why this is not the landing page. func Tree(ctx context.Context, dir, rev, path string) ([]Entry, error) { if !gitx.ValidRev(rev) { return nil, nil } if path != "" && !gitx.ValidPath(path) { return nil, nil } // The tree comes from the object pool, because ls-tree is a process and this page is a listing. spec := rev + ":" + path objs, err := gitx.Batch(ctx, dir, []string{spec}) if err != nil { return nil, err } obj := objs[spec] if obj == nil || obj.Type != "tree" { return nil, fmt.Errorf("%s is not a directory here", spec) } var entries []Entry for _, row := range gitx.TreeRows(obj.Body) { e := Entry{Name: row.Name, Dir: row.Mode == "40000"} if path != "" { e.Path = path + "/" + row.Name } else { e.Path = row.Name } entries = append(entries, e) } // git already sorts alphabetically, so this only puts the folders on top, as the mockup shows. dirs := entries[:0:0] files := entries[:0:0] for _, e := range entries { if e.Dir { dirs = append(dirs, e) } else { files = append(files, e) } } entries = append(dirs, files...) // Keyed by this rev's commit and path, not the tree, because resolving a tree costs the process this saves and the branch tip filed old rows under the branch's name. treeHash := "" if sha := revObject(dir, rev); sha != "" { treeHash = sha + ":" + path } if treeHash != "" { if body, ok := Cache.Get("tree", treeHash); ok { if cached := parseTreeLog(string(body), entries); cached != nil { return cached, nil } } } var record strings.Builder for i := range entries { e := &entries[i] out, err := gitx.Run(ctx, dir, "log", "--max-count=1", "--format=%an\x1e%at\x1e%s", rev, "--", e.Path) if err != nil { continue } f := strings.SplitN(strings.TrimRight(out, "\n"), "\x1e", 3) if len(f) != 3 { continue } e.Author = f[0] if secs, err := strconv.ParseInt(f[1], 10, 64); err == nil { e.When = time.Unix(secs, 0) } e.Subject = f[2] fmt.Fprintf(&record, "%s\x1e%s\x1e%d\x1e%s\n", e.Path, e.Author, e.When.Unix(), e.Subject) } if treeHash != "" { Cache.Put("tree", treeHash, []byte(record.String())) } return entries, nil } // revObject is the immutable id a rev names, read from the ref files alone because chapter 25 will not spend a process on a cache key, and empty means do not cache. func revObject(dir, rev string) string { if rev == "" || rev == "HEAD" { branch, err := gitx.HeadBranch(dir) if err != nil { return "" } rev = "refs/heads/" + branch } if len(rev) == objectIDLen && isHex(rev) { return rev } // A proposal is typed in full, so refs/heads/refs/proposals/47 is a file nobody wrote, and git's own order settles a name that is both a tag and a branch. tries := []string{"refs/" + rev, "refs/tags/" + rev, "refs/heads/" + rev} if strings.HasPrefix(rev, "refs/") { tries = []string{rev} } for _, ref := range tries { if sha, err := gitx.ResolveRef(dir, ref); err == nil { return sha } } return "" } // ParentShort is the first parent's short id, or empty at a root commit. func ParentShort(ctx context.Context, dir, rev string) string { if !gitx.ValidRev(rev) { return "" } out, err := gitx.Run(ctx, dir, "rev-parse", "--short", "--verify", "--quiet", rev+"^") if err != nil { return "" } return strings.TrimSpace(out) } // RepoStat is the summary of one repository on a profile page. type RepoStat struct { Size int64 Language string Branch string Pushed time.Time } // Stat measures a repository for the profile listing. func Stat(ctx context.Context, dir string) RepoStat { var st RepoStat // A file read, because a profile lists many repositories and chapter 25 counts processes. if branch, err := gitx.HeadBranch(dir); err == nil { st.Branch = branch } if out, err := gitx.Run(ctx, dir, "for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)", "refs/heads"); err == nil { if secs, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64); err == nil { st.Pushed = time.Unix(secs, 0) } } // A walk of the object store, because a profile lists many repositories and each process costs. st.Size = objectBytes(dir) st.Language = languageOf(ctx, dir, st.Branch) return st } // objectBytes adds up what a repository keeps on disk, loose and packed, with no process at all. func objectBytes(dir string) int64 { var total int64 filepath.WalkDir(filepath.Join(dir, "objects"), func(_ string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if info, err := d.Info(); err == nil { total += info.Size() } return nil }) return total } // languageOf caches the guess under the tip it was made from, which cannot change under that name. func languageOf(ctx context.Context, dir, branch string) string { sha, err := gitx.ResolveRef(dir, "refs/heads/"+branch) if err != nil || Cache == nil { return language(ctx, dir, branch) } if body, ok := Cache.Get("lang", sha); ok { return string(body) } lang := language(ctx, dir, branch) Cache.Put("lang", sha, []byte(lang)) return lang } // language guesses from the commonest extension, and is a label on a listing, not a fact. func language(ctx context.Context, dir, branch string) string { if branch == "" { branch = "HEAD" } out, err := gitx.Run(ctx, dir, "ls-tree", "-r", "--name-only", branch) if err != nil { return "" } byExt := map[string]int{} for _, name := range strings.Split(out, "\n") { i := strings.LastIndex(name, ".") if i < 0 || i == len(name)-1 { continue } if lang, ok := languages[strings.ToLower(name[i+1:])]; ok { byExt[lang]++ } } best, bestN := "", 0 for lang, n := range byExt { if n > bestN || (n == bestN && lang < best) { best, bestN = lang, n } } return best } // languages is short on purpose, because an unlisted extension beats a wrong guess. var languages = map[string]string{ "go": "go", "c": "c", "h": "c", "cc": "c++", "cpp": "c++", "hpp": "c++", "rs": "rust", "py": "python", "rb": "ruby", "js": "javascript", "ts": "typescript", "java": "java", "kt": "kotlin", "swift": "swift", "sh": "shell", "bash": "shell", "zsh": "shell", "nix": "nix", "lua": "lua", "php": "php", "cs": "c#", "ex": "elixir", "exs": "elixir", "hs": "haskell", "ml": "ocaml", "zig": "zig", "css": "css", "html": "html", "sql": "sql", "md": "markdown", } // Release is a tag, a body and some files. Chapter 22. type Release struct { Tag string Tagger string When time.Time Subject string // the tag's own message, for an annotated tag Notes string // the body, from refs/notes/releases Object string } // ReleasesRef holds release bodies keyed by tag object, in one ref, for chapter 16's reason. const ReleasesRef = "refs/notes/releases" // Releases lists tags newest first, from the ref files and the object pool, so it starts no process. func Releases(ctx context.Context, dir string) ([]Release, error) { refs, err := gitx.ListRefs(dir, "refs/tags") if err != nil || len(refs) == 0 { return nil, err } names := make([]string, 0, len(refs)) for ref := range refs { names = append(names, ref) } sort.Strings(names) // The key is the ref state itself, so a tag pushed or deleted writes a different entry. sum := sha256.New() for _, ref := range names { io.WriteString(sum, ref+" "+refs[ref]+"\n") } key := hex.EncodeToString(sum.Sum(nil)) if body, ok := Cache.Get("tags", key); ok { return parseReleases(string(body)), nil } specs := make([]string, 0, len(names)) for _, ref := range names { specs = append(specs, refs[ref]) } objs, err := gitx.Batch(ctx, dir, specs) if err != nil { return nil, err } list := make([]Release, 0, len(names)) for _, ref := range names { obj := objs[refs[ref]] if obj == nil { continue } r := Release{Tag: strings.TrimPrefix(ref, "refs/tags/"), Object: obj.SHA} r.Tagger, r.When, r.Subject = describeTag(obj.Body) list = append(list, r) } sort.Slice(list, func(i, j int) bool { if list[i].When.Equal(list[j].When) { return list[i].Tag > list[j].Tag } return list[i].When.After(list[j].When) }) Cache.Put("tags", key, []byte(formatReleases(list))) return list, nil } // formatReleases writes the list the cache keeps, without the notes, which are read per page. func formatReleases(list []Release) string { var b strings.Builder for _, r := range list { b.WriteString(r.Tag + "\x1e" + r.Object + "\x1e" + strconv.FormatInt(r.When.Unix(), 10) + "\x1e" + r.Tagger + "\x1e" + r.Subject + "\n") } return b.String() } func parseReleases(body string) []Release { var list []Release for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { f := strings.SplitN(line, "\x1e", 5) if len(f) < 5 { continue } r := Release{Tag: f[0], Object: f[1], Tagger: f[3], Subject: f[4]} if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil { r.When = time.Unix(secs, 0) } list = append(list, r) } return list } // describeTag reads who made a tag, when, and its first line, from a tag object or from the commit. func describeTag(body string) (who string, when time.Time, subject string) { head, msg, _ := strings.Cut(body, "\n\n") for _, line := range strings.Split(head, "\n") { kind, rest, ok := strings.Cut(line, " ") if !ok || (kind != "tagger" && kind != "author") { continue } who, when = ident(rest) if kind == "tagger" { break } } subject, _, _ = strings.Cut(strings.TrimSpace(msg), "\n") return who, when, subject } // ident splits a git identity line into the name and the moment it carries. func ident(s string) (string, time.Time) { name := s if i := strings.LastIndex(s, " <"); i >= 0 { name = s[:i] } rest := s if i := strings.LastIndex(s, "> "); i >= 0 { rest = s[i+2:] } stamp, _, _ := strings.Cut(rest, " ") secs, err := strconv.ParseInt(stamp, 10, 64) if err != nil { return name, time.Time{} } return name, time.Unix(secs, 0) } // ReadNotes attaches the bodies for the releases a page is about to draw. func ReadNotes(ctx context.Context, dir string, list []Release) { want := make(map[string]int, len(list)) for i := range list { want[list[i].Object] = i } for object, body := range releaseNotes(ctx, dir) { if i, ok := want[object]; ok { list[i].Notes = strings.TrimRight(body, "\n") } } } // crumb is one tree in the notes fanout, with the object id its path has spelled so far. type crumb struct{ spec, prefix string } // releaseNotes reads every body once per notes commit, and a commit is immutable, so a repeat is free. func releaseNotes(ctx context.Context, dir string) map[string]string { refs, err := gitx.ListRefs(dir, "refs/notes") if err != nil || refs[ReleasesRef] == "" { return nil } if body, ok := Cache.Get("relnotes", refs[ReleasesRef]); ok { return parseNotes(string(body)) } all := walkNotes(ctx, dir, refs[ReleasesRef]) if all == nil { return nil } Cache.Put("relnotes", refs[ReleasesRef], []byte(formatNotes(all))) return all } // formatNotes writes the map the cache keeps, length prefixed because a body holds newlines. func formatNotes(notes map[string]string) string { var b strings.Builder for object, body := range notes { b.WriteString(object + " " + strconv.Itoa(len(body)) + "\n" + body) } return b.String() } func parseNotes(record string) map[string]string { out := map[string]string{} for len(record) > 0 { head, rest, ok := strings.Cut(record, "\n") if !ok { return out } object, size, ok := strings.Cut(head, " ") if !ok { return out } n, err := strconv.Atoi(size) if err != nil || n > len(rest) { return out } out[object] = rest[:n] record = rest[n:] } return out } // walkNotes reads the whole notes tree through the object pool, one level of fanout at a time. func walkNotes(ctx context.Context, dir, head string) map[string]string { blobOf := map[string]string{} level := []crumb{{head + "^{tree}", ""}} for len(level) > 0 { specs := make([]string, 0, len(level)) for _, c := range level { specs = append(specs, c.spec) } objs, err := gitx.Batch(ctx, dir, specs) if err != nil { return nil } var next []crumb for _, c := range level { obj := objs[c.spec] if obj == nil { continue } for name, sha := range gitx.TreeEntries(obj.Body) { id := c.prefix + name // A tree entry that is not a full object id is a fanout directory, not a note. if len(id) >= objectIDLen { blobOf[id] = sha } else { next = append(next, crumb{sha, id}) } } } level = next } if len(blobOf) == 0 { return nil } specs := make([]string, 0, len(blobOf)) for _, sha := range blobOf { specs = append(specs, sha) } blobs, err := gitx.Batch(ctx, dir, specs) if err != nil { return nil } out := make(map[string]string, len(blobOf)) for object, sha := range blobOf { if b := blobs[sha]; b != nil { out[object] = b.Body } } return out } // objectIDLen is a sha1 hex object id, which is the path a note takes once the fanout is spelled out. const objectIDLen = 40 // parseTreeLog fills entries from a cached record, or nil when the record does not match. func parseTreeLog(body string, entries []Entry) []Entry { byPath := map[string][3]string{} for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { f := strings.SplitN(line, "\x1e", 4) if len(f) != 4 { return nil } byPath[f[0]] = [3]string{f[1], f[2], f[3]} } for i := range entries { got, ok := byPath[entries[i].Path] if !ok { return nil } entries[i].Author = got[0] if secs, err := strconv.ParseInt(got[1], 10, 64); err == nil { entries[i].When = time.Unix(secs, 0) } entries[i].Subject = got[2] } return entries } // readmeNames is what the root is checked for, in order, because a repository picks one and keeps it. var readmeNames = []string{ "README.md", "README", "README.txt", "README.markdown", "README.rst", "readme.md", "readme", "readme.txt", "readme.markdown", "readme.rst", "Readme.md", "ReadMe.md", "Readme", "ReadMe", } // Readme finds a readme in the root tree, or reports none, keyed by the commit so it costs one read. func Readme(ctx context.Context, dir, commit string) string { if commit == "" { return "" } if body, ok := Cache.Get("readme", commit); ok { return string(body) } found := "" // One process for the whole root, rather than a cat-file per candidate name. if root, err := gitx.Batch(ctx, dir, []string{commit + "^{tree}"}); err == nil { if t := root[commit+"^{tree}"]; t != nil { entries := gitx.TreeEntries(t.Body) for _, want := range readmeNames { if _, ok := entries[want]; ok { found = want break } } } } Cache.Put("readme", commit, []byte(found)) return found } // Ref is one name a person can type into the compare form. Chapter 24 keeps that field free text. type Ref struct { Name string Kind string } // Refs lists what exists to compare against, in one process, because a field cannot be typed blind. func Refs(ctx context.Context, dir string) []Ref { out, err := gitx.Run(ctx, dir, "for-each-ref", "--sort=-committerdate", "--format=%(refname)", "refs/heads", "refs/tags", "refs/proposals") if err != nil { return nil } var refs []Ref for _, line := range strings.Split(strings.TrimSpace(out), "\n") { switch { case line == "": case strings.HasPrefix(line, "refs/heads/"): refs = append(refs, Ref{Name: strings.TrimPrefix(line, "refs/heads/"), Kind: "branch"}) case strings.HasPrefix(line, "refs/tags/"): refs = append(refs, Ref{Name: strings.TrimPrefix(line, "refs/tags/"), Kind: "tag"}) case strings.HasPrefix(line, "refs/proposals/"): // A proposal is typed in full, because that is what the compare page's example shows. refs = append(refs, Ref{Name: line, Kind: "proposal"}) } } return refs } // Keyring is where the keys accounts published live, and empty means no signature can be checked. var Keyring string