package site import ( "context" "fmt" "html/template" "path" "regexp" "strconv" "strings" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" ) const ( maxCommitPatchBytes = 8 * 1024 * 1024 maxCommitDiffRows = 4000 maxFileDiffRows = 2000 ) var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) type CommitParent struct { OID string ShortOID string URL string } type CommitView struct { Commit Commit Parents []CommitParent Files []DiffFile FileCount int Binaries int Additions int Deletions int Truncated bool } type DiffFile struct { Anchor string OldPath string NewPath string DisplayPath string TreePath string Status string Rows []DiffRow Additions int Deletions int Binary bool Truncated bool } type DiffRow struct { Hunk bool Header string OldNumber int NewNumber int OldText string NewText string OldCode template.HTML NewCode template.HTML OldClass string NewClass string OldPresent bool NewPresent bool } type numberedLine struct { number int text string } func (g *Generator) commitPages(ctx context.Context, branch, oldOID, newOID string, summary branchSummary) ([]Page, error) { wanted := make(map[string]struct{}) // A site-version change is also our migration signal: the first push after // installing commit views backfills historical pages once. Ordinary pushes // with current assets remain proportional to newly reachable commits. if oldOID == "" || !g.AssetsCurrent { for _, group := range summary.Groups { for _, commit := range group.Commits { wanted[commit.OID] = struct{}{} } } } else { out, err := g.Git.Run(ctx, "rev-list", newOID, "^"+oldOID) if err != nil { g.progress("could not enumerate new commits from %s; generating reachable commit pages", short(oldOID)) for _, group := range summary.Groups { for _, commit := range group.Commits { wanted[commit.OID] = struct{}{} } } } else { for _, oid := range strings.Fields(string(out)) { wanted[oid] = struct{}{} } } } var pages []Page for _, group := range summary.Groups { for _, commit := range group.Commits { if _, ok := wanted[commit.OID]; !ok || g.commitGenerated(commit.OID) { continue } page, err := g.commitPage(ctx, branch, commit, summary) if err != nil { return nil, err } pages = append(pages, page) } } if len(pages) > 0 { g.progress("generated %d commit diff page(s)", len(pages)) } return pages, nil } func (g *Generator) commitGenerated(oid string) bool { g.commitMu.Lock() defer g.commitMu.Unlock() _, ok := g.generated[oid] return ok } func (g *Generator) markCommitGenerated(oid string) { g.commitMu.Lock() g.generated[oid] = struct{}{} g.commitMu.Unlock() } func (g *Generator) commitPage(ctx context.Context, branch string, commit Commit, summary branchSummary) (Page, error) { view := CommitView{Commit: commit} for _, oid := range commit.Parents { view.Parents = append(view.Parents, CommitParent{ OID: oid, ShortOID: short(oid), URL: routeURL(g.BaseURL, path.Join("commit", oid)), }) } args := []string{"diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=3"} if len(commit.Parents) > 0 { args = append(args, commit.Parents[0], commit.OID, "--") } else { args = []string{"diff-tree", "--root", "--no-commit-id", "-r", "-p", "--no-ext-diff", "--no-color", "--find-renames", "--unified=3", commit.OID, "--"} } patch, truncated, err := g.Git.RunLimited(ctx, maxCommitPatchBytes, args...) if err != nil { return Page{}, err } view.Truncated = truncated if !truncated { parsed := parsePatch(patch) view.FileCount = len(parsed) for _, file := range parsed { if file.Binary { view.Binaries++ continue } highlightDiffFile(&file) view.Files = append(view.Files, file) view.Additions += file.Additions view.Deletions += file.Deletions } } data := g.pageData("commit", commit.Message+" · "+g.RepoName, branch, summary) data.ViewedCommit = view body, err := g.render(data) if err != nil { return Page{}, err } return Page{ Route: path.Join("commit", commit.OID), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache, }, nil } func highlightDiffFile(file *DiffFile) { filename := file.NewPath if filename == "" { filename = file.OldPath } lexer := lexers.Match(filename) if lexer == nil { setPlainDiffCode(file) return } lexer = chroma.Coalesce(lexer) for start := 0; start < len(file.Rows); { if file.Rows[start].Hunk { start++ continue } end := start for end < len(file.Rows) && !file.Rows[end].Hunk { end++ } highlightDiffSide(file.Rows[start:end], lexer, true) highlightDiffSide(file.Rows[start:end], lexer, false) start = end } } func setPlainDiffCode(file *DiffFile) { for index := range file.Rows { if file.Rows[index].OldPresent { file.Rows[index].OldCode = template.HTML(template.HTMLEscapeString(file.Rows[index].OldText)) } if file.Rows[index].NewPresent { file.Rows[index].NewCode = template.HTML(template.HTMLEscapeString(file.Rows[index].NewText)) } } } func highlightDiffSide(rows []DiffRow, lexer chroma.Lexer, old bool) { var indexes []int var source strings.Builder for index, row := range rows { present, value := row.NewPresent, row.NewText if old { present, value = row.OldPresent, row.OldText } if !present { continue } indexes = append(indexes, index) source.WriteString(value) source.WriteByte('\n') } if len(indexes) == 0 { return } iterator, err := lexer.Tokenise(nil, source.String()) if err != nil { setPlainDiffSide(rows, indexes, old) return } lines := chroma.SplitTokensIntoLines(iterator.Tokens()) if len(lines) != len(indexes) { setPlainDiffSide(rows, indexes, old) return } for offset, tokens := range lines { formatted := formatDiffTokens(tokens) if old { rows[indexes[offset]].OldCode = formatted } else { rows[indexes[offset]].NewCode = formatted } } } func setPlainDiffSide(rows []DiffRow, indexes []int, old bool) { for _, index := range indexes { value := rows[index].NewText if old { value = rows[index].OldText } formatted := template.HTML(template.HTMLEscapeString(value)) if old { rows[index].OldCode = formatted } else { rows[index].NewCode = formatted } } } func formatDiffTokens(tokens []chroma.Token) template.HTML { var result strings.Builder for _, token := range tokens { value := strings.TrimSuffix(token.Value, "\n") if value == "" { continue } escaped := template.HTMLEscapeString(value) class := diffTokenClass(token.Type) if class == "" { result.WriteString(escaped) continue } result.WriteString(``) result.WriteString(escaped) result.WriteString(``) } return template.HTML(result.String()) } func diffTokenClass(tokenType chroma.TokenType) string { for tokenType != 0 { if class, ok := chroma.StandardTypes[tokenType]; ok { return class } tokenType = tokenType.Parent() } return chroma.StandardTypes[tokenType] } func parsePatch(patch []byte) []DiffFile { var files []DiffFile var current *DiffFile var oldLine, newLine int var oldBlock, newBlock []numberedLine var blockTruncated bool renderedRows := 0 fileRows := 0 inHunk := false rowAllowance := func() int { return min(maxCommitDiffRows-renderedRows, maxFileDiffRows-fileRows) } appendRow := func(row DiffRow) { if current == nil { return } if rowAllowance() <= 0 { current.Truncated = true return } current.Rows = append(current.Rows, row) renderedRows++ fileRows++ } flushBlock := func() { if current == nil || len(oldBlock)+len(newBlock) == 0 { if current != nil { current.Truncated = current.Truncated || blockTruncated } blockTruncated = false return } count := max(len(oldBlock), len(newBlock)) for index := range count { row := DiffRow{} if index < len(oldBlock) { row.OldNumber = oldBlock[index].number row.OldText = oldBlock[index].text row.OldClass = "deletion" row.OldPresent = true } if index < len(newBlock) { row.NewNumber = newBlock[index].number row.NewText = newBlock[index].text row.NewClass = "addition" row.NewPresent = true } appendRow(row) } current.Truncated = current.Truncated || blockTruncated oldBlock = oldBlock[:0] newBlock = newBlock[:0] blockTruncated = false } finishFile := func() { flushBlock() if current == nil { return } if current.NewPath == "" { current.NewPath = current.OldPath } if current.OldPath == "" { current.OldPath = current.NewPath } switch { case current.Status == "Renamed": current.DisplayPath = current.OldPath + " → " + current.NewPath current.TreePath = current.NewPath case current.Status == "Deleted": current.DisplayPath = current.OldPath current.TreePath = current.OldPath default: current.DisplayPath = current.NewPath current.TreePath = current.NewPath } if current.Status == "" { current.Status = "Modified" } current.Anchor = fmt.Sprintf("diff-%d", len(files)+1) files = append(files, *current) current = nil inHunk = false fileRows = 0 } for _, line := range strings.Split(string(patch), "\n") { if strings.HasPrefix(line, "diff --git ") { finishFile() oldPath, newPath := diffHeaderPaths(strings.TrimPrefix(line, "diff --git ")) current = &DiffFile{OldPath: oldPath, NewPath: newPath} continue } if current == nil { continue } if matches := hunkHeaderPattern.FindStringSubmatch(line); matches != nil { flushBlock() oldLine, _ = strconv.Atoi(matches[1]) newLine, _ = strconv.Atoi(matches[2]) appendRow(DiffRow{Hunk: true, Header: line}) inHunk = true continue } if inHunk { if line == `\ No newline at end of file` { continue } if line == "" { continue } switch line[0] { case ' ': flushBlock() appendRow(DiffRow{ OldNumber: oldLine, NewNumber: newLine, OldText: line[1:], NewText: line[1:], OldPresent: true, NewPresent: true, }) oldLine++ newLine++ case '-': if len(oldBlock) < rowAllowance() { oldBlock = append(oldBlock, numberedLine{number: oldLine, text: line[1:]}) } else { blockTruncated = true } current.Deletions++ oldLine++ case '+': if len(newBlock) < rowAllowance() { newBlock = append(newBlock, numberedLine{number: newLine, text: line[1:]}) } else { blockTruncated = true } current.Additions++ newLine++ } continue } switch { case strings.HasPrefix(line, "new file mode "): current.Status = "Added" case strings.HasPrefix(line, "deleted file mode "): current.Status = "Deleted" case strings.HasPrefix(line, "rename from "): current.Status = "Renamed" current.OldPath = decodeGitPath(strings.TrimPrefix(line, "rename from ")) case strings.HasPrefix(line, "rename to "): current.Status = "Renamed" current.NewPath = decodeGitPath(strings.TrimPrefix(line, "rename to ")) case strings.HasPrefix(line, "Binary files ") || line == "GIT binary patch": current.Binary = true case strings.HasPrefix(line, "--- "): value := patchPath(strings.TrimPrefix(line, "--- ")) if value != "" { current.OldPath = value } case strings.HasPrefix(line, "+++ "): value := patchPath(strings.TrimPrefix(line, "+++ ")) if value != "" { current.NewPath = value } } } finishFile() return files } func diffHeaderPaths(value string) (string, string) { if strings.HasPrefix(value, `"`) { oldPath, rest := quotedGitField(value) newPath, _ := quotedGitField(strings.TrimSpace(rest)) return stripDiffPrefix(oldPath), stripDiffPrefix(newPath) } marker := strings.Index(value, " b/") if marker < 0 { return stripDiffPrefix(value), stripDiffPrefix(value) } return stripDiffPrefix(value[:marker]), stripDiffPrefix(value[marker+1:]) } func quotedGitField(value string) (string, string) { if !strings.HasPrefix(value, `"`) { field, rest, _ := strings.Cut(value, " ") return decodeGitPath(field), rest } escaped := false for index := 1; index < len(value); index++ { switch { case escaped: escaped = false case value[index] == '\\': escaped = true case value[index] == '"': return decodeGitPath(value[:index+1]), value[index+1:] } } return decodeGitPath(value), "" } func patchPath(value string) string { value = strings.TrimSuffix(value, "\t") value = decodeGitPath(value) if value == "/dev/null" { return "" } return stripDiffPrefix(value) } func stripDiffPrefix(value string) string { value = decodeGitPath(value) value = strings.TrimPrefix(value, "a/") value = strings.TrimPrefix(value, "b/") return value } func decodeGitPath(value string) string { if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { if decoded, err := strconv.Unquote(value); err == nil { return decoded } } return value }