package gitread import ( "context" "strconv" "strings" "time" "github.com/barerepo/server/internal/cache" "github.com/barerepo/server/internal/gitx" ) // File carries blame already, because "what is this line" and "why" are one question. 24. type File struct { Path string Blob string Size int64 Binary bool // TooBig is chapter 42.4's cap, rather than sending a browser what it will choke on. TooBig bool Lines []FileLine } type FileLine struct { Number int Text string SHA string // the commit that last touched this line Short string Author string When time.Time } // renderCap is the largest file that is rendered whole. Chapter 42.4. const renderCap = 1 << 20 // binarySniff is how many bytes are checked for a null byte. Chapter 42.4. const binarySniff = 8000 // Open reads a file at a revision. func Open(ctx context.Context, dir, rev, path string) (*File, error) { if !gitx.ValidRev(rev) || !gitx.ValidPath(path) { return nil, nil } // One process for hash, size and content, because three is what chapter 25 cannot afford. obj, err := gitx.CatFile(ctx, dir, rev+":"+path) if err != nil || obj.Type != "blob" { return nil, nil // no such file at that revision } f := &File{Path: path, Size: obj.Size, Blob: obj.SHA} body := obj.Body head := body if len(head) > binarySniff { head = head[:binarySniff] } if strings.IndexByte(head, 0) >= 0 { f.Binary = true return f, nil } if obj.Size > renderCap { f.TooBig = true return f, nil } text := strings.Split(strings.TrimSuffix(body, "\n"), "\n") blame, err := blameAt(ctx, dir, rev, path) if err != nil { blame = nil // a file with no history still renders } f.Lines = make([]FileLine, len(text)) for i, line := range text { f.Lines[i] = FileLine{Number: i + 1, Text: line} if i < len(blame) { b := blame[i] f.Lines[i].SHA = b.SHA f.Lines[i].Short = b.Short f.Lines[i].Author = b.Author f.Lines[i].When = b.When } } return f, nil } // BlameLine is who last touched one line. type BlameLine struct { SHA string Short string Author string When time.Time } // Cache holds answers that cannot change. Nil means compute every time. var Cache *cache.Disk // Blame reads porcelain output, cached by commit and path, because that is the question it answers. func Blame(ctx context.Context, dir, rev, path string) ([]BlameLine, error) { if !gitx.ValidRev(rev) || !gitx.ValidPath(path) { return nil, nil } return blameAt(ctx, dir, rev, path) } // blameAt keys on commit and path, not blob: two paths can share a blob and a revert reuses one, and either serves the wrong author and commit on every line. func blameAt(ctx context.Context, dir, rev, path string) ([]BlameLine, error) { key := "" if sha := revObject(dir, rev); sha != "" { key = sha + ":" + path } if key != "" { if body, ok := Cache.Get("blame", key); ok { return parseBlame(string(body)), nil } } out, err := gitx.Run(ctx, dir, "blame", "--porcelain", rev, "--", path) if err != nil { return nil, err } if key != "" { Cache.Put("blame", key, []byte(out)) } return parseBlame(out), nil } // parseBlame remembers each commit's details as they pass, since the format states them once. func parseBlame(out string) []BlameLine { type meta struct { author string when time.Time } seen := map[string]meta{} var lines []BlameLine var cur BlameLine var curMeta meta inEntry := false for _, line := range strings.Split(out, "\n") { switch { case strings.HasPrefix(line, "\t"): // The content line ends an entry. if inEntry { if curMeta.author == "" { curMeta = seen[cur.SHA] } else { seen[cur.SHA] = curMeta } cur.Author = curMeta.author cur.When = curMeta.when lines = append(lines, cur) inEntry = false curMeta = meta{} } case strings.HasPrefix(line, "author "): curMeta.author = strings.TrimPrefix(line, "author ") case strings.HasPrefix(line, "author-time "): if secs, err := strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64); err == nil { curMeta.when = time.Unix(secs, 0) } case len(line) >= 40 && isHex(line[:40]): cur = BlameLine{SHA: line[:40], Short: line[:7]} inEntry = true } } return lines } func isHex(s string) bool { for i := 0; i < len(s); i++ { c := s[i] if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { return false } } return true }