Detailed changes
@@ -9,7 +9,17 @@
<header>
<hgroup>
<h2 id="commit-heading">{{.Commit.Message | commitSubject}}</h2>
- <p><strong>{{.Commit.Author.Name}}</strong> created <time datetime="{{.Commit.Author.When | rfc3339}}" data-tooltip="{{.Commit.Author.When | formatDate}}">{{.Commit.Author.When | relativeTime}}</time></p>
+ <p>
+ {{ $names := .Commit | attributionNames }}
+ {{ $nlen := len $names }}
+ {{ range $i, $n := $names }}
+ {{ if gt $i 0 }}
+ {{ if eq $nlen 2 }} and {{ else if eq $i (sub $nlen 1) }}, and {{ else }}, {{ end }}
+ {{ end }}
+ <strong>{{$n}}</strong>
+ {{ end }}
+ created <time datetime="{{.Commit.Author.When | rfc3339}}" data-tooltip="{{.Commit.Author.When | formatDate}}">{{.Commit.Author.When | relativeTime}}</time>
+ </p>
</hgroup>
{{$body := .Commit.Message | commitBody}}
{{if $body}}
@@ -24,7 +24,15 @@
</details>
{{end}}
<p>
- <strong>{{.Author.Name}}</strong> created
+ {{ $names := . | attributionNames }}
+ {{ $nlen := len $names }}
+ {{ range $i, $n := $names }}
+ {{ if gt $i 0 }}
+ {{ if eq $nlen 2 }} and {{ else if eq $i (sub $nlen 1) }}, and {{ else }}, {{ end }}
+ {{ end }}
+ <strong>{{$n}}</strong>
+ {{ end }}
+ created
<time datetime="{{.Author.When | rfc3339}}" data-tooltip="{{.Author.When | formatDate}}">{{.Author.When | relativeTime}}</time>
</p>
</article>
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
+ "strings"
)
func renderStatus(code int) http.HandlerFunc {
@@ -12,3 +13,100 @@ func renderStatus(code int) http.HandlerFunc {
io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) //nolint: errcheck
}
}
+
+// ParseTrailers extracts Git commit message trailers from the given message.
+// Trailers are key-value pairs at the end of the commit message in the format
+// "Key: value". This function returns a map where keys are normalized to lowercase
+// and values are arrays to support multiple trailers with the same key.
+func ParseTrailers(message string) map[string][]string {
+ trailers := make(map[string][]string)
+
+ lines := strings.Split(message, "\n")
+
+ // Trim trailing blank lines so we don't select an empty "block" past the end
+ for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
+ lines = lines[:len(lines)-1]
+ }
+ if len(lines) == 0 {
+ return trailers
+ }
+
+ // Find the start of the last paragraph (after the last blank line)
+ start := 0
+ for i := len(lines) - 1; i >= 0; i-- {
+ if strings.TrimSpace(lines[i]) == "" {
+ start = i + 1
+ break
+ }
+ }
+
+ // Parse trailers from the trailer block, skipping non-trailer lines
+ for i := start; i < len(lines); i++ {
+ line := strings.TrimSpace(lines[i])
+ if line == "" {
+ continue
+ }
+ if key, value, ok := parseTrailerLine(line); ok {
+ key = strings.ToLower(key)
+ trailers[key] = append(trailers[key], value)
+ }
+ }
+
+ return trailers
+}
+
+// parseTrailerLine extracts the key and value from a trailer line.
+func parseTrailerLine(line string) (key, value string, ok bool) {
+ // Split on first : or #
+ sepIdx := -1
+ for i, ch := range line {
+ if ch == ':' || ch == '#' {
+ sepIdx = i
+ break
+ }
+ }
+
+ if sepIdx == -1 {
+ return "", "", false
+ }
+
+ key = strings.TrimSpace(line[:sepIdx])
+ value = strings.TrimSpace(line[sepIdx+1:])
+
+ if key == "" || value == "" {
+ return "", "", false
+ }
+
+ return key, value, true
+}
+
+// GetCoAuthors extracts co-author names from commit message trailers.
+// It looks for "Co-authored-by" or "Co-Authored-By" trailers and extracts
+// the name portion (without email address).
+func GetCoAuthors(message string) []string {
+ trailers := ParseTrailers(message)
+ coAuthors := []string{}
+
+ // Look for co-authored-by trailer (case-insensitive)
+ if authors, ok := trailers["co-authored-by"]; ok {
+ for _, author := range authors {
+ // Extract name from "Name <email>" format
+ name := extractName(author)
+ if name != "" {
+ coAuthors = append(coAuthors, name)
+ }
+ }
+ }
+
+ return coAuthors
+}
+
+// extractName extracts the name portion from "Name <email>" format.
+// If no email is present, returns the entire string trimmed.
+func extractName(author string) string {
+ // Look for "Name <email>" pattern
+ if idx := strings.Index(author, "<"); idx != -1 {
+ return strings.TrimSpace(author[:idx])
+ }
+ return strings.TrimSpace(author)
+}
@@ -15,6 +15,7 @@ import (
"time"
"github.com/charmbracelet/log/v2"
+ "github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/pkg/proto"
"github.com/dustin/go-humanize"
"github.com/gorilla/mux"
@@ -65,6 +66,9 @@ type PaginationData struct {
// parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp),
// and humanizeSize (format file size in human-readable format).
var templateFuncs = template.FuncMap{
+ "sub": func(a, b int) int {
+ return a - b
+ },
"splitPath": func(path string) []string {
if path == "." {
return []string{}
@@ -171,6 +175,59 @@ var templateFuncs = template.FuncMap{
return ""
}
},
+ "formatAttribution": func(commit interface{}) string {
+ var authorName, message string
+
+ switch v := commit.(type) {
+ case *git.Commit:
+ if v == nil {
+ return ""
+ }
+ authorName = v.Author.Name
+ message = v.Message
+ default:
+ return ""
+ }
+
+ coAuthors := GetCoAuthors(message)
+
+ if len(coAuthors) == 0 {
+ return authorName
+ }
+
+ if len(coAuthors) == 1 {
+ return authorName + " and " + coAuthors[0]
+ }
+
+ // Multiple co-authors: "Author, Co1, Co2, and Co3"
+ result := authorName
+ for i, coAuthor := range coAuthors {
+ if i == len(coAuthors)-1 {
+ result += ", and " + coAuthor
+ } else {
+ result += ", " + coAuthor
+ }
+ }
+ return result
+ },
+ "attributionNames": func(commit interface{}) []string {
+ switch v := commit.(type) {
+ case *git.Commit:
+ if v == nil {
+ return nil
+ }
+ names := []string{v.Author.Name}
+ for _, n := range GetCoAuthors(v.Message) {
+ n = strings.TrimSpace(n)
+ if n != "" {
+ names = append(names, n)
+ }
+ }
+ return names
+ default:
+ return nil
+ }
+ },
}
// renderHTML renders an HTML template with the given data.