// Package gitread turns git output into template values, and only ever reads. package gitread import ( "context" "fmt" "strconv" "strings" "time" "github.com/barerepo/server/internal/gitx" ) type Commit struct { SHA string Short string Author string When time.Time Subject string Body string Files []FileDiff Add int Del int // Signed is read from the commit object, so a server with no gpg still knows a signature is there. Signed bool // SigState is what git says verification found, and only the commit page asks for it. SigState string Signer string // SigKey is the fingerprint, which is the part of a signature a reader can actually compare. SigKey string } type FileDiff struct { Path string OldPath string // set on a rename Add int Del int Binary bool // Gone marks a deletion, so a link to the file at this commit is not offered where it would 404. Gone bool Hunks []Hunk } type Hunk struct { Header string Lines []Line } // Line.Kind is ' ', '+' or '-', and New is the new-side number chapter 35.3 comments on. type Line struct { Kind byte Text string New int } // A commit message holds anything but NUL, so NUL separates records and \x1e separates fields. const logFormat = "%x00%H\x1e%h\x1e%an\x1e%at\x1e%s\x1e%b\x1e" // Log reads n commits and what each touched, since the log page draws no diff. Chapter 24. func Log(ctx context.Context, dir, ref, path string, n int) ([]Commit, error) { if !gitx.ValidRev(ref) { return nil, nil } args := append([]string{"log", "--max-count=" + strconv.Itoa(n), "--format=" + logFormat, "--numstat", "--no-renames", ref, "--"}, only(path)...) // The metadata walk alone, because a warm cache answers the rest without a tree diff. meta, err := gitx.Run(ctx, dir, append(append([]string{}, args[:3]...), append([]string{ref, "--"}, only(path)...)...)...) if err != nil { return nil, err } commits := parseLog(meta, func(string) []FileDiff { return nil }) if path == "" && fromCache(commits) { return commits, nil } out, err := gitx.Run(ctx, dir, args...) if err != nil { return nil, err } commits = parseLog(out, parseNumstat) markSigned(ctx, dir, commits) if path == "" { toCache(commits) } return commits, nil } // only is the pathspec, kept in one place because two commands take it. func only(path string) []string { if path == "" { return nil } return []string{path} } // fromCache fills every commit's stats and signature, or reports that one of them was missing. func fromCache(commits []Commit) bool { if Cache == nil { return false } for i := range commits { body, ok := Cache.Get("stat", commits[i].SHA) if !ok { return false } signed, rest, _ := strings.Cut(string(body), "\n") commits[i].Signed = signed == "1" commits[i].Files = parseNumstat(rest) for _, f := range commits[i].Files { commits[i].Add += f.Add commits[i].Del += f.Del } } return true } // toCache keeps what a log row needs, which is a commit's counts and whether it carries a signature. func toCache(commits []Commit) { if Cache == nil { return } for _, c := range commits { var b strings.Builder if c.Signed { b.WriteString("1\n") } else { b.WriteString("0\n") } for _, f := range c.Files { fmt.Fprintf(&b, "%d\t%d\t%s\n", f.Add, f.Del, f.Path) } Cache.Put("stat", c.SHA, []byte(b.String())) } } // markSigned reads the commit objects in one process, because a signature is a header and not a check. func markSigned(ctx context.Context, dir string, commits []Commit) { specs := make([]string, 0, len(commits)) for _, c := range commits { specs = append(specs, c.SHA) } objs, err := gitx.Batch(ctx, dir, specs) if err != nil { return } for i := range commits { if obj := objs[commits[i].SHA]; obj != nil { commits[i].Signed = hasSignature(obj.Body) } } } // hasSignature looks for the header git writes for both gpg and ssh signatures. func hasSignature(body string) bool { for _, line := range strings.Split(body, "\n") { if line == "" { // The headers end at the first blank line, and the message can say anything it likes. return false } if strings.HasPrefix(line, "gpgsig") { return true } } return false } // parseNumstat reads the counts git prints per file, which is all a log row says about a commit. func parseNumstat(body string) []FileDiff { var files []FileDiff for _, line := range strings.Split(body, "\n") { parts := strings.SplitN(strings.TrimSpace(line), "\t", 3) if len(parts) != 3 || parts[2] == "" { continue } f := FileDiff{Path: parts[2]} // git writes a dash for each count of a binary file, which has no lines to count. f.Binary = parts[0] == "-" || parts[1] == "-" f.Add, _ = strconv.Atoi(parts[0]) f.Del, _ = strconv.Atoi(parts[1]) // A file with only removals is one this commit deleted, and it has no page at this ref. f.Gone = !f.Binary && f.Add == 0 && f.Del > 0 files = append(files, f) } return files } func parseLog(out string, files func(string) []FileDiff) []Commit { var commits []Commit for _, record := range strings.Split(out, "\x00") { if strings.TrimSpace(record) == "" { continue } fields := strings.SplitN(record, "\x1e", 7) if len(fields) < 7 { continue } c := Commit{ SHA: fields[0], Short: fields[1], Author: fields[2], Subject: fields[4], Body: strings.TrimSpace(fields[5]), } if secs, err := strconv.ParseInt(fields[3], 10, 64); err == nil { c.When = time.Unix(secs, 0) } c.Files = files(fields[6]) for _, f := range c.Files { c.Add += f.Add c.Del += f.Del } commits = append(commits, c) } return commits } // parsePatch reads unified diff output into files and hunks. func parsePatch(patch string) []FileDiff { var files []FileDiff var cur *FileDiff var hunk *Hunk newLine := 0 flushHunk := func() { if cur != nil && hunk != nil { cur.Hunks = append(cur.Hunks, *hunk) hunk = nil } } flushFile := func() { flushHunk() if cur != nil { files = append(files, *cur) cur = nil } } for _, line := range strings.Split(patch, "\n") { switch { case strings.HasPrefix(line, "diff --git "): flushFile() cur = &FileDiff{Path: pathFromDiffLine(line)} case cur == nil: // The blank lines and indented subject git prints before the first file. case strings.HasPrefix(line, "rename from "): cur.OldPath = strings.TrimPrefix(line, "rename from ") case strings.HasPrefix(line, "rename to "): cur.Path = strings.TrimPrefix(line, "rename to ") case strings.HasPrefix(line, "Binary files "): cur.Binary = true case strings.HasPrefix(line, "deleted file mode "): cur.Gone = true case hunk == nil && strings.HasPrefix(line, "+++ b/"): // Only before the first hunk, because an added line reading "++ b/x" arrives as this one, and a name holding a space ends at the tab. name := strings.TrimPrefix(line, "+++ b/") cur.Path, _, _ = strings.Cut(name, "\t") case strings.HasPrefix(line, "@@"): flushHunk() hunk = &Hunk{Header: line} newLine = newStartOf(line) case hunk == nil: // index, mode and --- lines, between the header and the hunks. case strings.HasPrefix(line, "+"): cur.Add++ hunk.Lines = append(hunk.Lines, Line{Kind: '+', Text: line[1:], New: newLine}) newLine++ case strings.HasPrefix(line, "-"): cur.Del++ hunk.Lines = append(hunk.Lines, Line{Kind: '-', Text: line[1:]}) case strings.HasPrefix(line, " "): hunk.Lines = append(hunk.Lines, Line{Kind: ' ', Text: line[1:], New: newLine}) newLine++ case line == `\ No newline at end of file`: // Real, and not worth a row in the diff. } } flushFile() return files } // pathFromDiffLine takes the b-side, falling back to the tail when a path holds a space. func pathFromDiffLine(line string) string { rest := strings.TrimPrefix(line, "diff --git ") if i := strings.Index(rest, " b/"); i >= 0 { return rest[i+3:] } return rest } // newStartOf reads the new-side start line out of an "@@ -a,b +c,d @@" header. func newStartOf(header string) int { _, rest, ok := strings.Cut(header, "+") if !ok { return 0 } end := strings.IndexAny(rest, ", ") if end < 0 { end = len(rest) } n, err := strconv.Atoi(rest[:end]) if err != nil { return 0 } return n }