Commit 051e023
Changed files (9)
cmd
gitmal
internal
generator
git
templates
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/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/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