Commit ff213f4

mo khan <mo@mokhan.ca>
2026-01-31 01:01:41
feat: add json and atom feeds
1 parent c60b386
pkg/git/git.go
@@ -224,6 +224,9 @@ func Commits(ref Ref, repoDir string) ([]Commit, error) {
 		"%an", // author name
 		"%ae", // author email
 		"%ad", // author date
+		"%cn", // committer name
+		"%ce", // committer email
+		"%cd", // committer date
 		"%P",  // parent hashes
 		"%D",  // ref names without the "(", ")" wrapping.
 	}
@@ -256,22 +259,31 @@ func Commits(ref Ref, repoDir string) ([]Commit, error) {
 		if len(parts) != len(format) {
 			return nil, fmt.Errorf("unexpected commit format: %s", line)
 		}
-		full, short, subject, body, author, email, date, parents, refs :=
-			parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7], parts[8]
+		full, short, subject, body, author, email, date :=
+			parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
+		committerName, committerEmail, committerDate, parents, refs :=
+			parts[7], parts[8], parts[9], parts[10], parts[11]
 		timestamp, err := strconv.Atoi(date)
 		if err != nil {
 			return nil, fmt.Errorf("failed to parse commit date: %w", err)
 		}
+		committerTimestamp, err := strconv.Atoi(committerDate)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse committer date: %w", err)
+		}
 		commits = append(commits, Commit{
-			Hash:      full,
-			ShortHash: short,
-			Subject:   subject,
-			Body:      body,
-			Author:    author,
-			Email:     email,
-			Date:      time.Unix(int64(timestamp), 0),
-			Parents:   strings.Fields(parents),
-			RefNames:  parseRefNames(refs),
+			Hash:           full,
+			ShortHash:      short,
+			Subject:        subject,
+			Body:           body,
+			Author:         author,
+			Email:          email,
+			Date:           time.Unix(int64(timestamp), 0),
+			CommitterName:  committerName,
+			CommitterEmail: committerEmail,
+			CommitterDate:  time.Unix(int64(committerTimestamp), 0),
+			Parents:        strings.Fields(parents),
+			RefNames:       parseRefNames(refs),
 		})
 	}
 	return commits, nil
pkg/git/types.go
@@ -37,17 +37,20 @@ type Blob struct {
 }
 
 type Commit struct {
-	Hash      string
-	ShortHash string
-	Subject   string
-	Body      string
-	Author    string
-	Email     string
-	Date      time.Time
-	Parents   []string
-	Branch    Ref
-	RefNames  []RefName
-	Href      string
+	Hash           string
+	ShortHash      string
+	Subject        string
+	Body           string
+	Author         string
+	Email          string
+	Date           time.Time
+	CommitterName  string
+	CommitterEmail string
+	CommitterDate  time.Time
+	Parents        []string
+	Branch         Ref
+	RefNames       []RefName
+	Href           string
 }
 
 type RefKind string
branches_json.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+
+	"mokhan.ca/antonmedv/gitmal/pkg/git"
+)
+
+type BranchJSON struct {
+	Name   string     `json:"name"`
+	Commit BranchHead `json:"commit"`
+}
+
+type BranchHead struct {
+	SHA string `json:"sha"`
+}
+
+func generateBranchesJSON(branches []git.Ref, commitsFor map[git.Ref][]git.Commit, params Params) error {
+	list := make([]BranchJSON, 0, len(branches))
+	for _, branch := range branches {
+		commits := commitsFor[branch]
+		var sha string
+		if len(commits) > 0 {
+			sha = commits[0].Hash
+		}
+		list = append(list, BranchJSON{
+			Name:   branch.String(),
+			Commit: BranchHead{SHA: sha},
+		})
+	}
+
+	outPath := filepath.Join(params.OutputDir, "branches.json")
+	f, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	encoder := json.NewEncoder(f)
+	encoder.SetIndent("", "  ")
+	return encoder.Encode(list)
+}
commits_atom.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+	"encoding/xml"
+	"os"
+	"path/filepath"
+
+	"mokhan.ca/antonmedv/gitmal/pkg/git"
+)
+
+type AtomFeed struct {
+	XMLName xml.Name    `xml:"feed"`
+	XMLNS   string      `xml:"xmlns,attr"`
+	ID      string      `xml:"id"`
+	Title   string      `xml:"title"`
+	Updated string      `xml:"updated"`
+	Link    []AtomLink  `xml:"link"`
+	Entries []AtomEntry `xml:"entry"`
+}
+
+type AtomLink struct {
+	Rel  string `xml:"rel,attr"`
+	Type string `xml:"type,attr"`
+	Href string `xml:"href,attr"`
+}
+
+type AtomEntry struct {
+	ID      string     `xml:"id"`
+	Title   string     `xml:"title"`
+	Updated string     `xml:"updated"`
+	Author  AtomAuthor `xml:"author"`
+	Content string     `xml:"content"`
+	Link    AtomLink   `xml:"link"`
+}
+
+type AtomAuthor struct {
+	Name  string `xml:"name"`
+	Email string `xml:"email,omitempty"`
+}
+
+func generateCommitsAtom(commits []git.Commit, params Params) error {
+	outDir := filepath.Join(params.OutputDir, "commits")
+	if err := os.MkdirAll(outDir, 0o755); err != nil {
+		return err
+	}
+
+	var updated string
+	if len(commits) > 0 {
+		updated = commits[0].Date.Format("2006-01-02T15:04:05Z")
+	}
+
+	entries := make([]AtomEntry, len(commits))
+	for i, c := range commits {
+		content := c.Subject
+		if c.Body != "" {
+			content = c.Subject + "\n\n" + c.Body
+		}
+		entries[i] = AtomEntry{
+			ID:      "urn:sha:" + c.Hash,
+			Title:   c.Subject,
+			Updated: c.Date.Format("2006-01-02T15:04:05Z"),
+			Author:  AtomAuthor{Name: c.Author, Email: c.Email},
+			Content: content,
+			Link: AtomLink{
+				Rel:  "alternate",
+				Type: "text/html",
+				Href: "commit/" + c.Hash + ".html",
+			},
+		}
+	}
+
+	feed := AtomFeed{
+		XMLNS:   "http://www.w3.org/2005/Atom",
+		ID:      "urn:gitmal:" + params.Name + ":" + params.Ref.String(),
+		Title:   params.Name + " commits on " + params.Ref.String(),
+		Updated: updated,
+		Link: []AtomLink{
+			{Rel: "self", Type: "application/atom+xml", Href: "commits/" + params.Ref.DirName() + ".atom"},
+		},
+		Entries: entries,
+	}
+
+	outPath := filepath.Join(outDir, params.Ref.DirName()+".atom")
+	f, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	if _, err := f.WriteString(xml.Header); err != nil {
+		return err
+	}
+	encoder := xml.NewEncoder(f)
+	encoder.Indent("", "  ")
+	return encoder.Encode(feed)
+}
commits_json.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+
+	"mokhan.ca/antonmedv/gitmal/pkg/git"
+)
+
+type CommitJSON struct {
+	SHA     string       `json:"sha"`
+	Commit  CommitDetail `json:"commit"`
+	Parents []ParentRef  `json:"parents"`
+}
+
+type CommitDetail struct {
+	Author    PersonInfo `json:"author"`
+	Committer PersonInfo `json:"committer"`
+	Message   string     `json:"message"`
+}
+
+type PersonInfo struct {
+	Name  string `json:"name"`
+	Email string `json:"email"`
+	Date  string `json:"date"`
+}
+
+type ParentRef struct {
+	SHA string `json:"sha"`
+}
+
+func toCommitJSON(c git.Commit) CommitJSON {
+	message := c.Subject
+	if c.Body != "" {
+		message = c.Subject + "\n\n" + c.Body
+	}
+	parents := make([]ParentRef, len(c.Parents))
+	for i, p := range c.Parents {
+		parents[i] = ParentRef{SHA: p}
+	}
+	return CommitJSON{
+		SHA: c.Hash,
+		Commit: CommitDetail{
+			Author:    PersonInfo{Name: c.Author, Email: c.Email, Date: c.Date.Format("2006-01-02T15:04:05Z")},
+			Committer: PersonInfo{Name: c.CommitterName, Email: c.CommitterEmail, Date: c.CommitterDate.Format("2006-01-02T15:04:05Z")},
+			Message:   message,
+		},
+		Parents: parents,
+	}
+}
+
+func generateCommitsJSON(commits []git.Commit, params Params) error {
+	outDir := filepath.Join(params.OutputDir, "commits")
+	if err := os.MkdirAll(outDir, 0o755); err != nil {
+		return err
+	}
+	list := make([]CommitJSON, len(commits))
+	for i, c := range commits {
+		list[i] = toCommitJSON(c)
+	}
+	outPath := filepath.Join(outDir, params.Ref.DirName()+".json")
+	f, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	encoder := json.NewEncoder(f)
+	encoder.SetIndent("", "  ")
+	return encoder.Encode(list)
+}
main.go
@@ -194,6 +194,10 @@ func main() {
 		panic(err)
 	}
 
+	if err := generateBranchesJSON(branches, commitsFor, params); err != nil {
+		panic(err)
+	}
+
 	var defaultBranchFiles []git.Blob
 
 	for i, branch := range branches {
@@ -226,6 +230,14 @@ func main() {
 			if err != nil {
 				panic(err)
 			}
+
+			if err := generateCommitsJSON(commitsFor[branch], params); err != nil {
+				panic(err)
+			}
+
+			if err := generateCommitsAtom(commitsFor[branch], params); err != nil {
+				panic(err)
+			}
 		}
 	}
 
@@ -244,6 +256,14 @@ func main() {
 		panic(err)
 	}
 
+	if err := generateTagsAtom(tags, params); err != nil {
+		panic(err)
+	}
+
+	if err := generateReleasesAtom(tags, params); err != nil {
+		panic(err)
+	}
+
 	// Index page generation
 	if !noFiles {
 		if len(defaultBranchFiles) == 0 {
tags_atom.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+	"encoding/xml"
+	"os"
+	"path/filepath"
+
+	"mokhan.ca/antonmedv/gitmal/pkg/git"
+)
+
+func generateTagsAtom(tags []git.Tag, params Params) error {
+	if len(tags) == 0 {
+		return nil
+	}
+
+	var updated string
+	if len(tags) > 0 {
+		updated = tags[0].Date.Format("2006-01-02T15:04:05Z")
+	}
+
+	entries := make([]AtomEntry, len(tags))
+	for i, t := range tags {
+		entries[i] = AtomEntry{
+			ID:      "urn:tag:" + t.Name,
+			Title:   t.Name,
+			Updated: t.Date.Format("2006-01-02T15:04:05Z"),
+			Content: "Tag " + t.Name + " pointing to " + t.CommitHash[:7],
+			Link: AtomLink{
+				Rel:  "alternate",
+				Type: "text/html",
+				Href: "commit/" + t.CommitHash + ".html",
+			},
+		}
+	}
+
+	feed := AtomFeed{
+		XMLNS:   "http://www.w3.org/2005/Atom",
+		ID:      "urn:gitmal:" + params.Name + ":tags",
+		Title:   params.Name + " tags",
+		Updated: updated,
+		Link: []AtomLink{
+			{Rel: "self", Type: "application/atom+xml", Href: "tags.atom"},
+		},
+		Entries: entries,
+	}
+
+	outPath := filepath.Join(params.OutputDir, "tags.atom")
+	f, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	if _, err := f.WriteString(xml.Header); err != nil {
+		return err
+	}
+	encoder := xml.NewEncoder(f)
+	encoder.Indent("", "  ")
+	return encoder.Encode(feed)
+}
+
+func generateReleasesAtom(tags []git.Tag, params Params) error {
+	if len(tags) == 0 {
+		return nil
+	}
+
+	var updated string
+	if len(tags) > 0 {
+		updated = tags[0].Date.Format("2006-01-02T15:04:05Z")
+	}
+
+	entries := make([]AtomEntry, len(tags))
+	for i, t := range tags {
+		entries[i] = AtomEntry{
+			ID:      "urn:release:" + t.Name,
+			Title:   t.Name,
+			Updated: t.Date.Format("2006-01-02T15:04:05Z"),
+			Content: "Release " + t.Name,
+			Link: AtomLink{
+				Rel:  "alternate",
+				Type: "text/html",
+				Href: "commit/" + t.CommitHash + ".html",
+			},
+		}
+	}
+
+	feed := AtomFeed{
+		XMLNS:   "http://www.w3.org/2005/Atom",
+		ID:      "urn:gitmal:" + params.Name + ":releases",
+		Title:   params.Name + " releases",
+		Updated: updated,
+		Link: []AtomLink{
+			{Rel: "self", Type: "application/atom+xml", Href: "releases.atom"},
+		},
+		Entries: entries,
+	}
+
+	outPath := filepath.Join(params.OutputDir, "releases.atom")
+	f, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	if _, err := f.WriteString(xml.Header); err != nil {
+		return err
+	}
+	encoder := xml.NewEncoder(f)
+	encoder.Indent("", "  ")
+	return encoder.Encode(feed)
+}