feat/multi-repo
1package main
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "html/template"
8 "os"
9 "path/filepath"
10 "runtime"
11 "sort"
12 "strings"
13 "sync"
14
15 "github.com/alecthomas/chroma/v2"
16 "github.com/alecthomas/chroma/v2/formatters/html"
17 "github.com/alecthomas/chroma/v2/lexers"
18 "github.com/alecthomas/chroma/v2/styles"
19
20 "github.com/antonmedv/gitmal/pkg/git"
21 "github.com/antonmedv/gitmal/pkg/gitdiff"
22 "github.com/antonmedv/gitmal/pkg/progress_bar"
23 "github.com/antonmedv/gitmal/pkg/templates"
24)
25
26func generateCommits(commits map[string]git.Commit, params Params) error {
27 outDir := filepath.Join(params.OutputDir, "commit")
28 if err := os.MkdirAll(outDir, 0o755); err != nil {
29 return err
30 }
31
32 list := make([]git.Commit, 0, len(commits))
33 for _, c := range commits {
34 list = append(list, c)
35 }
36
37 workers := runtime.NumCPU()
38 if workers < 1 {
39 workers = 1
40 }
41
42 ctx, cancel := context.WithCancel(context.Background())
43 defer cancel()
44
45 jobs := make(chan git.Commit)
46 errCh := make(chan error, 1)
47 var wg sync.WaitGroup
48
49 p := progress_bar.NewProgressBar("commits", len(list))
50
51 workerFn := func() {
52 defer wg.Done()
53 for {
54 select {
55 case <-ctx.Done():
56 return
57 case c, ok := <-jobs:
58 if !ok {
59 return
60 }
61 if err := generateCommitPage(c, params); err != nil {
62 select {
63 case errCh <- err:
64 cancel()
65 default:
66 }
67 return
68 }
69 p.Inc()
70 }
71 }
72 }
73
74 wg.Add(workers)
75 for i := 0; i < workers; i++ {
76 go workerFn()
77 }
78
79 go func() {
80 defer close(jobs)
81 for _, c := range list {
82 select {
83 case <-ctx.Done():
84 return
85 case jobs <- c:
86 }
87 }
88 }()
89
90 done := make(chan struct{})
91 go func() {
92 wg.Wait()
93 close(done)
94 }()
95
96 var err error
97 select {
98 case err = <-errCh:
99 cancel()
100 <-done
101 case <-done:
102 }
103
104 p.Done()
105 return err
106}
107
108func generateCommitPage(commit git.Commit, params Params) error {
109 diff, err := git.CommitDiff(commit.Hash, params.RepoDir)
110 if err != nil {
111 return err
112 }
113
114 files, _, err := gitdiff.Parse(strings.NewReader(diff))
115 if err != nil {
116 return err
117 }
118
119 style := styles.Get(params.Style)
120 if style == nil {
121 return fmt.Errorf("unknown style: %s", params.Style)
122 }
123
124 formatter := html.New(
125 html.WithClasses(true),
126 html.WithCSSComments(false),
127 html.WithCustomCSS(map[chroma.TokenType]string{
128 chroma.GenericInserted: "display: block;",
129 chroma.GenericDeleted: "display: block;",
130 }),
131 )
132
133 var cssBuf bytes.Buffer
134 if err := formatter.WriteCSS(&cssBuf, style); err != nil {
135 return err
136 }
137
138 lexer := lexers.Get("diff")
139 if lexer == nil {
140 return fmt.Errorf("failed to get lexer for diff")
141 }
142
143 outPath := filepath.Join(params.OutputDir, "commit", commit.Hash+".html")
144
145 f, err := os.Create(outPath)
146 if err != nil {
147 return err
148 }
149 rootHref := filepath.ToSlash("../")
150
151 fileTree := buildFileTree(files)
152
153 // Create a stable order for files that matches the file tree traversal
154 // so that the per-file views appear in the same order as the sidebar tree.
155 fileOrder := make(map[string]int)
156 {
157 // Preorder traversal (dirs first, then files), respecting sortNode ordering
158 var idx int
159 var walk func(nodes []*templates.FileTree)
160 walk = func(nodes []*templates.FileTree) {
161 for _, n := range nodes {
162 if n.IsDir {
163 // Children are already sorted by sortNode
164 walk(n.Children)
165 continue
166 }
167 if n.Path == "" {
168 continue
169 }
170 if _, ok := fileOrder[n.Path]; !ok {
171 fileOrder[n.Path] = idx
172 idx++
173 }
174 }
175 }
176 walk(fileTree)
177 }
178
179 // Prepare per-file views
180 var filesViews []templates.FileView
181 for _, f := range files {
182 path := f.NewName
183 if f.IsDelete {
184 path = f.OldName
185 }
186 if path == "" {
187 continue
188 }
189
190 var fileDiff strings.Builder
191 for _, frag := range f.TextFragments {
192 fileDiff.WriteString(frag.String())
193 }
194
195 it, err := lexer.Tokenise(nil, fileDiff.String())
196 if err != nil {
197 return err
198 }
199 var buf bytes.Buffer
200 if err := formatter.Format(&buf, style, it); err != nil {
201 return err
202 }
203
204 filesViews = append(filesViews, templates.FileView{
205 Path: path,
206 OldName: f.OldName,
207 NewName: f.NewName,
208 IsNew: f.IsNew,
209 IsDelete: f.IsDelete,
210 IsRename: f.IsRename,
211 IsBinary: f.IsBinary,
212 HasChanges: f.TextFragments != nil,
213 HTML: template.HTML(buf.String()),
214 })
215 }
216
217 // Sort file views to match the file tree order. If for some reason a path
218 // is missing in the order map (shouldn't happen), fall back to case-insensitive
219 // alphabetical order by full path.
220 sort.Slice(filesViews, func(i, j int) bool {
221 oi, iok := fileOrder[filesViews[i].Path]
222 oj, jok := fileOrder[filesViews[j].Path]
223 if iok && jok {
224 return oi < oj
225 }
226 if iok != jok {
227 return iok // known order first
228 }
229 return filesViews[i].Path < filesViews[j].Path
230 })
231
232 currentRef := params.DefaultRef
233 if !commit.Branch.IsEmpty() {
234 currentRef = commit.Branch
235 }
236
237 err = templates.CommitTemplate.ExecuteTemplate(f, "layout.gohtml", templates.CommitParams{
238 LayoutParams: templates.LayoutParams{
239 Title: fmt.Sprintf("%s %s %s@%s", commit.Subject, dot, params.Name, commit.ShortHash),
240 Name: params.Name,
241 SiteName: params.SiteName,
242 Dark: params.Dark,
243 RootHref: rootHref + params.RootPrefix,
244 RepoHref: rootHref,
245 CurrentRefDir: currentRef.DirName(),
246 Selected: "commits",
247 InlineStyles: params.InlineStyles,
248 },
249 Commit: commit,
250 DiffCSS: template.CSS(cssBuf.String()),
251 FileTree: fileTree,
252 FileViews: filesViews,
253 })
254 if err != nil {
255 _ = f.Close()
256 return err
257 }
258 if err := f.Close(); err != nil {
259 return err
260 }
261 return nil
262}
263
264func buildFileTree(files []*gitdiff.File) []*templates.FileTree {
265 // Use a synthetic root (not rendered), collect top-level nodes in a map first.
266 root := &templates.FileTree{IsDir: true, Name: "", Path: "", Children: nil}
267
268 for _, f := range files {
269 path := f.NewName
270 if f.IsDelete {
271 path = f.OldName
272 }
273
274 path = filepath.ToSlash(strings.TrimPrefix(path, "./"))
275 if path == "" {
276 continue
277 }
278 parts := strings.Split(path, "/")
279
280 parent := root
281 accum := ""
282 if len(parts) > 1 {
283 for i := 0; i < len(parts)-1; i++ {
284 if accum == "" {
285 accum = parts[i]
286 } else {
287 accum = accum + "/" + parts[i]
288 }
289 parent = findOrCreateDir(parent, parts[i], accum)
290 }
291 }
292
293 fileName := parts[len(parts)-1]
294 node := &templates.FileTree{
295 Name: fileName,
296 Path: path,
297 IsDir: false,
298 IsNew: f.IsNew,
299 IsDelete: f.IsDelete,
300 IsRename: f.IsRename,
301 OldName: f.OldName,
302 NewName: f.NewName,
303 }
304 parent.Children = append(parent.Children, node)
305 }
306
307 sortNode(root)
308 return root.Children
309}
310
311func findOrCreateDir(parent *templates.FileTree, name, path string) *templates.FileTree {
312 for _, ch := range parent.Children {
313 if ch.IsDir && ch.Name == name {
314 return ch
315 }
316 }
317 node := &templates.FileTree{IsDir: true, Name: name, Path: path}
318 parent.Children = append(parent.Children, node)
319 return node
320}
321
322func sortNode(n *templates.FileTree) {
323 if len(n.Children) == 0 {
324 return
325 }
326 sort.Slice(n.Children, func(i, j int) bool {
327 a, b := n.Children[i], n.Children[j]
328 if a.IsDir != b.IsDir {
329 return a.IsDir && !b.IsDir // dirs first
330 }
331 return strings.ToLower(a.Name) < strings.ToLower(b.Name)
332 })
333 for _, ch := range n.Children {
334 if ch.IsDir {
335 sortNode(ch)
336 }
337 }
338}