Commit 051e023

mo khan <mo@mokhan.ca>
2026-02-01 06:46:28
feat: add compare pages
1 parent 587e890
cmd/gitmal/main.go
@@ -180,6 +180,11 @@ func run() error {
 		return err
 	}
 
+	generator.Echo("> generating compare pages...")
+	if err := generator.GenerateComparePages(tags, branches, params); err != nil {
+		return err
+	}
+
 	if len(defaultBranchFiles) == 0 {
 		return errors.New("no files found for default branch")
 	}
internal/generator/compare.go
@@ -0,0 +1,184 @@
+package generator
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/alecthomas/chroma/v2/styles"
+	"github.com/bluekeyes/go-gitdiff/gitdiff"
+
+	"mokhan.ca/xlgmokha/gitmal/internal/git"
+	"mokhan.ca/xlgmokha/gitmal/internal/templates"
+)
+
+func GenerateComparePages(tags []git.Tag, branches []git.Ref, params Params) error {
+	outDir := filepath.Join(params.OutputDir, "compare")
+	if err := os.MkdirAll(outDir, 0o755); err != nil {
+		return err
+	}
+
+	if len(tags) > 1 {
+		for i := 0; i < len(tags)-1; i++ {
+			base := tags[i+1].Name
+			head := tags[i].Name
+			if err := generateComparePage(base, head, params); err != nil {
+				Echo(fmt.Sprintf("  warning: compare %s...%s failed: %v", base, head, err))
+			}
+		}
+	}
+
+	defaultBranch := params.DefaultRef.String()
+	for _, branch := range branches {
+		if branch.String() == defaultBranch {
+			continue
+		}
+		if err := generateComparePage(defaultBranch, branch.String(), params); err != nil {
+			Echo(fmt.Sprintf("  warning: compare %s...%s failed: %v", defaultBranch, branch, err))
+		}
+	}
+
+	return nil
+}
+
+func generateComparePage(base, head string, params Params) error {
+	baseRef := git.NewRef(base)
+	headRef := git.NewRef(head)
+
+	diff, err := git.CompareDiff(base, head, params.RepoDir)
+	if err != nil {
+		return err
+	}
+
+	commits, err := git.CompareCommits(baseRef, headRef, params.RepoDir)
+	if err != nil {
+		return err
+	}
+
+	files, _, err := gitdiff.Parse(strings.NewReader(diff))
+	if err != nil {
+		return err
+	}
+
+	formatter := html.New(
+		html.WithClasses(true),
+		html.WithCSSComments(false),
+	)
+	lightStyle := styles.Get("github")
+	lexer := lexers.Get("diff")
+	if lexer == nil {
+		return fmt.Errorf("failed to get lexer for diff")
+	}
+
+	fileTree := buildFileTree(files)
+	fileOrder := make(map[string]int)
+	{
+		var idx int
+		var walk func(nodes []*templates.FileTree)
+		walk = func(nodes []*templates.FileTree) {
+			for _, n := range nodes {
+				if n.IsDir {
+					walk(n.Children)
+					continue
+				}
+				if n.Path == "" {
+					continue
+				}
+				if _, ok := fileOrder[n.Path]; !ok {
+					fileOrder[n.Path] = idx
+					idx++
+				}
+			}
+		}
+		walk(fileTree)
+	}
+
+	var filesViews []templates.FileView
+	for _, f := range files {
+		path := f.NewName
+		if f.IsDelete {
+			path = f.OldName
+		}
+		if path == "" {
+			continue
+		}
+
+		var fileDiff strings.Builder
+		for _, frag := range f.TextFragments {
+			fileDiff.WriteString(frag.String())
+		}
+
+		it, err := lexer.Tokenise(nil, fileDiff.String())
+		if err != nil {
+			return err
+		}
+		var buf bytes.Buffer
+		if err := formatter.Format(&buf, lightStyle, it); err != nil {
+			return err
+		}
+
+		filesViews = append(filesViews, templates.FileView{
+			Path:       path,
+			OldName:    f.OldName,
+			NewName:    f.NewName,
+			IsNew:      f.IsNew,
+			IsDelete:   f.IsDelete,
+			IsRename:   f.IsRename,
+			IsBinary:   f.IsBinary,
+			HasChanges: f.TextFragments != nil,
+			HTML:       template.HTML(buf.String()),
+		})
+	}
+
+	sort.Slice(filesViews, func(i, j int) bool {
+		oi, iok := fileOrder[filesViews[i].Path]
+		oj, jok := fileOrder[filesViews[j].Path]
+		if iok && jok {
+			return oi < oj
+		}
+		if iok != jok {
+			return iok
+		}
+		return filesViews[i].Path < filesViews[j].Path
+	})
+
+	for i := range commits {
+		commits[i].Href = filepath.ToSlash(filepath.Join("../commit", commits[i].Hash+".html"))
+	}
+
+	fileName := baseRef.DirName() + "..." + headRef.DirName() + ".html"
+	outPath := filepath.Join(params.OutputDir, "compare", fileName)
+	f, err := os.Create(outPath)
+	if err != nil {
+		return err
+	}
+
+	rootHref := "../"
+
+	err = templates.CompareTemplate.ExecuteTemplate(f, "layout.gohtml", templates.CompareParams{
+		LayoutParams: templates.LayoutParams{
+			Title:          fmt.Sprintf("Comparing %s...%s %s %s", base, head, Dot, params.Name),
+			Name:           params.Name,
+			RootHref:       rootHref,
+			CurrentRefDir:  params.DefaultRef.DirName(),
+			Selected:       "",
+			NeedsSyntaxCSS: true,
+		},
+		Base:      base,
+		Head:      head,
+		Commits:   commits,
+		FileTree:  fileTree,
+		FileViews: filesViews,
+	})
+	if err != nil {
+		_ = f.Close()
+		return err
+	}
+	return f.Close()
+}
internal/generator/tags.go
@@ -23,6 +23,16 @@ func GenerateTags(entries []git.Tag, params Params) error {
 
 	rootHref := "./"
 
+	tagEntries := make([]templates.TagEntry, len(entries))
+	for i, tag := range entries {
+		tagEntries[i] = templates.TagEntry{Tag: tag}
+		if i < len(entries)-1 {
+			base := git.NewRef(entries[i+1].Name)
+			head := git.NewRef(tag.Name)
+			tagEntries[i].CompareHref = fmt.Sprintf("compare/%s...%s.html", base.DirName(), head.DirName())
+		}
+	}
+
 	return templates.TagsTemplate.ExecuteTemplate(f, "layout.gohtml", templates.TagsParams{
 		LayoutParams: templates.LayoutParams{
 			Title:         fmt.Sprintf("Tags %s %s", Dot, params.Name),
@@ -31,6 +41,6 @@ func GenerateTags(entries []git.Tag, params Params) error {
 			CurrentRefDir: params.DefaultRef.DirName(),
 			Selected:      "tags",
 		},
-		Tags: entries,
+		Tags: tagEntries,
 	})
 }
internal/git/compare.go
@@ -0,0 +1,70 @@
+package git
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+)
+
+func CompareDiff(base, head, repoDir string) (string, error) {
+	out, err := gitCmd(repoDir, "diff", base+"..."+head)
+	if err != nil {
+		return "", err
+	}
+	return string(out), nil
+}
+
+func CompareCommits(base, head Ref, repoDir string) ([]Commit, error) {
+	format := []string{"%H", "%h", "%s", "%b", "%an", "%ae", "%ad", "%cn", "%ce", "%cd", "%P", "%D"}
+	out, err := gitCmd(repoDir,
+		"log",
+		"--date=unix",
+		"--pretty=format:"+strings.Join(format, "\x1F"),
+		"-z",
+		base.String()+".."+head.String(),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	lines := strings.Split(string(out), "\x00")
+	commits := make([]Commit, 0, len(lines))
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Split(line, "\x1F")
+		if len(parts) != len(format) {
+			return nil, fmt.Errorf("unexpected commit format: %s", line)
+		}
+		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]
+
+		timestampInt, err := strconv.Atoi(date)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse commit date: %w", err)
+		}
+		committerTimestampInt, 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(timestampInt), 0),
+			CommitterName:  committerName,
+			CommitterEmail: committerEmail,
+			CommitterDate:  time.Unix(int64(committerTimestampInt), 0),
+			Parents:        strings.Fields(parents),
+			RefNames:       parseRefNames(refs),
+		})
+	}
+	return commits, nil
+}
internal/git/git.go
@@ -329,3 +329,4 @@ func CommitDiff(hash, repoDir string) (string, error) {
 	}
 	return string(out), nil
 }
+
internal/templates/compare.gohtml
@@ -0,0 +1,189 @@
+{{- /*gotype: mokhan.ca/xlgmokha/gitmal/internal/templates.CompareParams*/ -}}
+{{- define "head" -}}
+<style>
+  h1 code {
+    border-radius: var(--border-radius);
+    background: var(--c-bg-alt);
+    padding: 4px 8px;
+    font-family: var(--font-family-mono), monospace;
+    font-weight: 500;
+  }
+  .compare-header {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    margin-bottom: 16px;
+  }
+  .compare-refs {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+  }
+  .ref-badge {
+    color: var(--c-text-1);
+    border: 1px solid var(--c-border);
+    border-radius: 6px;
+    padding: 6px 12px;
+    font-family: var(--font-family-mono), monospace;
+    font-size: 14px;
+    background: var(--c-bg-alt);
+  }
+  .compare-arrow { color: var(--c-text-2); }
+  .compare-stats {
+    display: flex;
+    gap: 16px;
+    color: var(--c-text-2);
+    font-size: 14px;
+  }
+  .commits-section { margin-bottom: 24px; }
+  .commits-section h2 { font-size: 16px; margin-bottom: 12px; }
+  .commits-list {
+    border: 1px solid var(--c-border);
+    border-radius: var(--border-radius);
+    overflow: hidden;
+  }
+  .commit-row {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 10px 16px;
+    border-bottom: 1px solid var(--c-border);
+  }
+  .commit-row:last-child { border-bottom: none; }
+  .commit-row:hover { background-color: var(--c-bg-alt); }
+  .commit-hash {
+    font-family: var(--font-family-mono), monospace;
+    color: var(--c-text-2);
+    font-size: 13px;
+  }
+  .commit-hash a { color: inherit; }
+  .commit-subject { flex: 1; min-width: 0; }
+  .commit-subject a { color: var(--c-text-1); }
+  .commit-subject a:hover { color: var(--c-brand-2); }
+  .commit-author { color: var(--c-text-2); font-size: 13px; }
+  .commit-date {
+    font-family: var(--font-family-mono), monospace;
+    font-size: 12px;
+    color: var(--c-text-2);
+  }
+  .compare-layout { display: grid; grid-template-columns: 1fr; gap: 16px; }
+  @media (min-width: 960px) {
+    .compare-layout { grid-template-columns: 300px 1fr; align-items: start; }
+    .files-tree { position: sticky; top: 16px; }
+    .files-tree-content { max-height: calc(100vh - var(--header-height) - 40px); overflow: auto; }
+  }
+  .files-tree { border: 1px solid var(--c-border); border-radius: var(--border-radius); }
+  .files-tree-header {
+    display: flex;
+    align-items: center;
+    padding-inline: 16px;
+    height: var(--header-height);
+    border-bottom: 1px solid var(--c-border);
+    background: var(--c-bg-alt);
+    font-size: 14px;
+    font-weight: 600;
+  }
+  .files-tree-content { padding-block: 6px; }
+  .tree .children { margin-left: 16px; border-left: 1px dashed var(--c-border); }
+  .tree .node { display: flex; align-items: center; gap: 8px; padding: 10px 16px; }
+  .tree .file-name { flex: 1; font-weight: 500; color: var(--c-text-1); cursor: pointer; }
+  .tree .file-name:hover { color: var(--c-brand-2); text-decoration: underline; }
+  .tree .dir { color: var(--c-dir); }
+  .tree .file-added { color: var(--c-green); }
+  .tree .file-deleted { color: var(--c-red); }
+  .tree .file-renamed { color: var(--c-yellow); }
+  .files { min-width: 0; }
+  .file-section + .file-section { margin-top: 16px; }
+  pre {
+    border: 1px solid var(--c-border);
+    border-top: none;
+    border-bottom-left-radius: var(--border-radius);
+    border-bottom-right-radius: var(--border-radius);
+    margin: 0;
+    padding: 8px 16px;
+    overflow-x: auto;
+    white-space: pre;
+    tab-size: 4;
+    font-family: var(--font-family-mono), monospace;
+  }
+  pre > code {
+    display: block;
+    padding: 0 16px;
+    width: fit-content;
+    min-width: 100%;
+    line-height: var(--code-line-height);
+    font-size: var(--code-font-size);
+  }
+  .binary-file, .no-changes {
+    padding: 8px 16px;
+    font-style: italic;
+    border: 1px solid var(--c-border);
+    border-top: none;
+    border-bottom-left-radius: 6px;
+    border-bottom-right-radius: 6px;
+  }
+</style>
+{{- end}}
+
+{{- define "body" -}}
+<div class="compare-header">
+  <h1>Comparing changes</h1>
+  <div class="compare-refs">
+    <span class="ref-badge">{{.Base}}</span>
+    <span class="compare-arrow">→</span>
+    <span class="ref-badge">{{.Head}}</span>
+  </div>
+  <div class="compare-stats">
+    <span>{{len .Commits}} commits</span>
+    <span>{{len .FileViews}} files changed</span>
+  </div>
+</div>
+{{- if .Commits}}
+<div class="commits-section">
+  <h2>Commits</h2>
+  <div class="commits-list">
+{{- range .Commits}}
+    <div class="commit-row">
+      <span class="commit-hash"><a href="{{.Href}}">{{.ShortHash}}</a></span>
+      <span class="commit-subject"><a href="{{.Href}}">{{.Subject}}</a></span>
+      <span class="commit-author">{{.Author}}</span>
+      <span class="commit-date">{{.Date | FormatDate}}</span>
+    </div>
+{{- end}}
+  </div>
+</div>
+{{- end}}
+<div class="compare-layout">
+  <div class="files-tree">
+    <div class="files-tree-header">Changed files ({{len .FileViews}})</div>
+    <div class="files-tree-content">
+{{- if .FileTree}}
+      <div class="tree">{{- template "file_tree" (FileTreeParams .FileTree)}}</div>
+{{- else}}
+      <div style="color: var(--c-text-3)">(no files changed)</div>
+{{- end}}
+    </div>
+  </div>
+  <div class="files">
+{{- range .FileViews}}
+    <section id="{{.Path}}" class="file-section">
+      <header class="file-header">
+{{- if .IsRename}}
+        <div class="path">{{.OldName}} → {{.NewName}}</div>
+{{- else}}
+        <div class="path">{{.Path}}</div>
+{{- end}}
+      </header>
+{{- if .IsBinary}}
+      <div class="binary-file">Binary file</div>
+{{- else if (and .IsRename (not .HasChanges))}}
+      <div class="no-changes">File renamed without changes</div>
+{{- else}}
+      <div class="file-diff">{{.HTML}}</div>
+{{- end}}
+    </section>
+{{- end}}
+  </div>
+</div>
+{{- end}}
internal/templates/tags.gohtml
@@ -1,4 +1,4 @@
-{{- /*gotype: mokhan.ca/xlgmokha/gitmal/pkg/templates.TagsParams*/ -}}
+{{- /*gotype: mokhan.ca/xlgmokha/gitmal/internal/templates.TagsParams*/ -}}
 {{- define "head" -}}
 <style>
   .tags {
@@ -42,6 +42,13 @@
     font-family: var(--font-family-mono), monospace;
     color: var(--c-text-2);
   }
+  .compare {
+    min-width: 60px;
+  }
+  .compare a {
+    font-size: 12px;
+    color: var(--c-text-2);
+  }
 </style>
 {{- end}}
 
@@ -54,6 +61,7 @@
     <div class="cell tag-title"><a href="commit/{{.CommitHash}}.html">{{.Name}}</a></div>
     <div class="cell date">{{.Date | FormatDate}}</div>
     <div class="cell hash"><a href="commit/{{.CommitHash}}.html">{{ShortHash .CommitHash}}</a></div>
+    <div class="cell compare">{{if .CompareHref}}<a href="{{.CompareHref}}">compare</a>{{end}}</div>
   </div>
 {{- end}}
 {{- else}}
internal/templates/templates.go
@@ -140,7 +140,12 @@ type BranchEntry struct {
 
 type TagsParams struct {
 	LayoutParams
-	Tags []git.Tag
+	Tags []TagEntry
+}
+
+type TagEntry struct {
+	git.Tag
+	CompareHref string
 }
 
 type Pagination struct {
@@ -203,3 +208,16 @@ type FileView struct {
 	HasChanges bool
 	HTML       HTML // pre-rendered HTML for diff of this file
 }
+
+type CompareParams struct {
+	LayoutParams
+	Base      string
+	Head      string
+	Commits   []git.Commit
+	FileTree  []*FileTree
+	FileViews []FileView
+}
+
+//go:embed compare.gohtml
+var compareContent string
+var CompareTemplate = Must(Must(layout.Clone()).Parse(compareContent))
README.md
@@ -42,6 +42,7 @@ Generated paths follow GitHub repository conventions:
 | `/commits/<ref>/page-N.html` | Commit history (page N) |
 | `/branches.html` | Branches list |
 | `/tags.html` | Tags list |
+| `/compare/<base>...<head>.html` | Diff between refs |
 
 ### Feeds & API