master
1package main
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "regexp"
9 "runtime/pprof"
10 "sort"
11 "strings"
12 "time"
13
14 "github.com/antonmedv/gitmal/pkg/git"
15
16 flag "github.com/spf13/pflag"
17)
18
19var (
20 flagName string
21 flagOutput string
22 flagBranches string
23 flagDefaultBranch string
24 flagTheme string
25 flagThemeLight string
26 flagThemeDark string
27 flagConfig string
28 flagPreviewThemes bool
29 flagMinify bool
30 flagGzip bool
31 flagGit bool
32 flagInlineStyles bool
33 flagSort string
34)
35
36type Params struct {
37 Name string
38 SiteName string
39 RepoDir string
40 Ref git.Ref
41 OutputDir string
42 Style string
43 StyleLight string
44 StyleDark string
45 Dark bool
46 DefaultRef git.Ref
47 RootPrefix string
48 InlineStyles bool
49}
50
51func main() {
52 if _, ok := os.LookupEnv("GITMAL_PPROF"); ok {
53 f, err := os.Create("cpu.prof")
54 if err != nil {
55 panic(err)
56 }
57 err = pprof.StartCPUProfile(f)
58 if err != nil {
59 panic(err)
60 }
61 defer f.Close()
62 defer pprof.StopCPUProfile()
63 memProf, err := os.Create("mem.prof")
64 if err != nil {
65 panic(err)
66 }
67 defer memProf.Close()
68 defer pprof.WriteHeapProfile(memProf)
69 }
70
71 _, noFiles := os.LookupEnv("NO_FILES")
72 _, noCommitsList := os.LookupEnv("NO_COMMITS_LIST")
73
74 flag.StringVar(&flagName, "name", "", "Project name")
75 flag.StringVar(&flagOutput, "output", "output", "Output directory for generated HTML files")
76 flag.StringVar(&flagBranches, "branches", "", "Regex for branches to include")
77 flag.StringVar(&flagDefaultBranch, "default-branch", "", "Default branch to use (autodetect master or main)")
78 flag.StringVar(&flagTheme, "theme", "github", "Style theme")
79 flag.StringVar(&flagThemeLight, "theme-light", "", "Light theme for code highlighting (overrides --theme)")
80 flag.StringVar(&flagThemeDark, "theme-dark", "", "Dark theme for code highlighting (overrides --theme)")
81 flag.StringVar(&flagConfig, "config", "", "Path to TOML config file for multi-repo support")
82 flag.BoolVar(&flagPreviewThemes, "preview-themes", false, "Preview available themes")
83 flag.BoolVar(&flagMinify, "minify", false, "Minify all generated HTML files")
84 flag.BoolVar(&flagGzip, "gzip", false, "Compress all generated HTML files")
85 flag.BoolVar(&flagGit, "git", false, "Generate static files for Git dumb HTTP protocol")
86 flag.BoolVar(&flagInlineStyles, "inline-styles", false, "Keep all CSS inline in HTML instead of external files")
87 flag.StringVar(&flagSort, "sort", "", "Multi-repo sort order: config, name[-asc|-desc], time[-asc|-desc]")
88 flag.Usage = usage
89 flag.Parse()
90
91 if flagPreviewThemes {
92 previewThemes()
93 os.Exit(0)
94 }
95
96 outputDir, err := filepath.Abs(flagOutput)
97 if err != nil {
98 panic(err)
99 }
100
101 styleLight, styleDark, err := resolveTheme(flagTheme, flagThemeLight, flagThemeDark)
102 if err != nil {
103 panic(err)
104 }
105
106 baseParams := Params{
107 Style: flagTheme,
108 StyleLight: styleLight,
109 StyleDark: styleDark,
110 Dark: themeStyles[flagTheme] == "dark",
111 InlineStyles: flagInlineStyles,
112 OutputDir: outputDir,
113 }
114
115 var siteName string
116 isMulti := false
117 var generatables []repoGeneration
118
119 if flagConfig != "" {
120 cfg, err := parseConfig(flagConfig)
121 if err != nil {
122 panic(err)
123 }
124 siteName = cfg.SiteName
125
126 if len(cfg.Repos) > 0 {
127 isMulti = true
128 for _, repo := range cfg.Repos {
129 if err := validateRepoPath(repo.Path); err != nil {
130 panic(fmt.Errorf("repos %q: %w", repo.Slug, err))
131 }
132 generatables = append(generatables, repoGeneration{
133 RepoDir: repo.Path,
134 Name: repo.Name,
135 Slug: repo.Slug,
136 Description: repo.Description,
137 DefaultBranch: repo.DefaultBranch,
138 })
139 }
140 sortMode := flagSort
141 if !flag.CommandLine.Changed("sort") && cfg.Sort != "" {
142 sortMode = cfg.Sort
143 }
144 if sortMode != "" && sortMode != "config" {
145 sortGeneratables(generatables, sortMode)
146 }
147 } else if cfg.Repo != nil {
148 repoPath := cfg.Repo.Path
149 if len(flag.Args()) > 0 {
150 absPath, err := filepath.Abs(flag.Args()[0])
151 if err != nil {
152 panic(err)
153 }
154 repoPath = absPath
155 }
156 repoName := flagName
157 if repoName == "" {
158 repoName = cfg.Repo.Name
159 }
160 defaultBranch := flagDefaultBranch
161 if defaultBranch == "" {
162 defaultBranch = cfg.Repo.DefaultBranch
163 }
164 if err := validateRepoPath(repoPath); err != nil {
165 panic(err)
166 }
167 generatables = append(generatables, repoGeneration{
168 RepoDir: repoPath,
169 Name: repoName,
170 Description: cfg.Repo.Description,
171 DefaultBranch: defaultBranch,
172 })
173 }
174
175 // === Merge global config values with CLI flags (CLI takes priority) ===
176 if !flag.CommandLine.Changed("theme") && cfg.Theme != "" {
177 flagTheme = cfg.Theme
178 baseParams.Style = flagTheme
179 baseParams.Dark = themeStyles[flagTheme] == "dark"
180 }
181 if !flag.CommandLine.Changed("theme-light") && cfg.ThemeLight != "" {
182 flagThemeLight = cfg.ThemeLight
183 }
184 if !flag.CommandLine.Changed("theme-dark") && cfg.ThemeDark != "" {
185 flagThemeDark = cfg.ThemeDark
186 }
187 if (!flag.CommandLine.Changed("theme") && cfg.Theme != "") ||
188 (!flag.CommandLine.Changed("theme-light") && cfg.ThemeLight != "") ||
189 (!flag.CommandLine.Changed("theme-dark") && cfg.ThemeDark != "") {
190 styleLight, styleDark, err = resolveTheme(flagTheme, flagThemeLight, flagThemeDark)
191 if err != nil {
192 panic(err)
193 }
194 baseParams.StyleLight = styleLight
195 baseParams.StyleDark = styleDark
196 }
197 if !flag.CommandLine.Changed("branches") && cfg.Branches != "" {
198 flagBranches = cfg.Branches
199 }
200 if !flag.CommandLine.Changed("output") && cfg.Output != "" {
201 flagOutput = cfg.Output
202 outputDir, err = filepath.Abs(flagOutput)
203 if err != nil {
204 panic(err)
205 }
206 baseParams.OutputDir = outputDir
207 }
208 if !flag.CommandLine.Changed("inline-styles") {
209 flagInlineStyles = cfg.InlineStyles
210 baseParams.InlineStyles = flagInlineStyles
211 }
212 if !flag.CommandLine.Changed("minify") {
213 flagMinify = cfg.Minify
214 }
215 if !flag.CommandLine.Changed("gzip") {
216 flagGzip = cfg.Gzip
217 }
218 if !flag.CommandLine.Changed("git") {
219 flagGit = cfg.Git
220 }
221 }
222
223 if len(generatables) == 0 {
224 inputPath, err := repoInputPath()
225 if err != nil {
226 panic(err)
227 }
228 repoName := flagName
229 if repoName == "" {
230 repoName = filepath.Base(inputPath)
231 repoName = strings.TrimSuffix(repoName, ".git")
232 }
233 if err := validateRepoPath(inputPath); err != nil {
234 panic(err)
235 }
236 generatables = append(generatables, repoGeneration{
237 RepoDir: inputPath,
238 Name: repoName,
239 DefaultBranch: flagDefaultBranch,
240 })
241 }
242
243 baseParams.SiteName = siteName
244
245 if !baseParams.InlineStyles {
246 if err := generateCSSFiles(baseParams); err != nil {
247 panic(err)
248 }
249 }
250
251 for _, g := range generatables {
252 params := baseParams
253 params.RepoDir = g.RepoDir
254 params.Name = g.Name
255 params.SiteName = siteName
256 params.RootPrefix = ""
257
258 if isMulti {
259 params.OutputDir = filepath.Join(outputDir, g.Slug)
260 params.RootPrefix = "../"
261 }
262
263 if err := generateRepo(g, params, noFiles, noCommitsList); err != nil {
264 panic(err)
265 }
266 }
267
268 if isMulti {
269 if err := generateMultiRepoIndex(cfgReposToEntries(generatables), siteName, baseParams); err != nil {
270 panic(err)
271 }
272 }
273
274 if flagMinify || flagGzip {
275 echo("> post-processing HTML...")
276 if err := postProcessHTML(outputDir, flagMinify, flagGzip); err != nil {
277 panic(err)
278 }
279 }
280}
281
282type repoGeneration struct {
283 RepoDir string
284 Name string
285 Slug string
286 Description string
287 DefaultBranch string
288}
289
290func generateRepo(g repoGeneration, params Params, noFiles, noCommitsList bool) error {
291 branchesFilter, err := regexp.Compile(flagBranches)
292 if err != nil {
293 return err
294 }
295
296 branches, err := git.Branches(params.RepoDir, branchesFilter, g.DefaultBranch)
297 if err != nil {
298 return err
299 }
300
301 tags, err := git.Tags(params.RepoDir)
302 if err != nil {
303 return err
304 }
305
306 defaultBranch := g.DefaultBranch
307 if defaultBranch == "" {
308 if containsBranch(branches, "master") {
309 defaultBranch = "master"
310 } else if containsBranch(branches, "main") {
311 defaultBranch = "main"
312 } else {
313 return fmt.Errorf("no default branch found in %s", params.RepoDir)
314 }
315 }
316
317 if !containsBranch(branches, defaultBranch) {
318 return fmt.Errorf("default branch %q not found in %s", defaultBranch, params.RepoDir)
319 }
320
321 if yes, a, b := hasConflictingBranchNames(branches); yes {
322 return fmt.Errorf("conflicting branch names %q and %q, both want to use %q dir name", a, b, a.DirName())
323 }
324
325 params.DefaultRef = git.NewRef(defaultBranch)
326
327 commits := make(map[string]git.Commit)
328 commitsFor := make(map[git.Ref][]git.Commit, len(branches))
329
330 for _, branch := range branches {
331 commitsFor[branch], err = git.Commits(branch, params.RepoDir)
332 if err != nil {
333 return err
334 }
335
336 for _, commit := range commitsFor[branch] {
337 if alreadyExisting, ok := commits[commit.Hash]; ok && alreadyExisting.Branch == params.DefaultRef {
338 continue
339 }
340 commit.Branch = branch
341 commits[commit.Hash] = commit
342 }
343 }
344
345 for _, tag := range tags {
346 commitsForTag, err := git.Commits(git.NewRef(tag.Name), params.RepoDir)
347 if err != nil {
348 return err
349 }
350 for _, commit := range commitsForTag {
351 if alreadyExisting, ok := commits[commit.Hash]; ok && !alreadyExisting.Branch.IsEmpty() {
352 continue
353 }
354 commits[commit.Hash] = commit
355 }
356 }
357
358 echo(fmt.Sprintf("> %s: %d branches, %d tags, %d commits", params.Name, len(branches), len(tags), len(commits)))
359
360 if err := generateBranches(branches, defaultBranch, params); err != nil {
361 return err
362 }
363
364 var defaultBranchFiles []git.Blob
365
366 for i, branch := range branches {
367 echo(fmt.Sprintf("> [%d/%d] %s@%s", i+1, len(branches), params.Name, branch))
368 params.Ref = branch
369
370 if !noFiles {
371 files, err := git.Files(params.Ref, params.RepoDir)
372 if err != nil {
373 return err
374 }
375
376 if branch.String() == defaultBranch {
377 defaultBranchFiles = files
378 }
379
380 err = generateBlobs(files, params)
381 if err != nil {
382 return err
383 }
384
385 err = generateLists(files, params)
386 if err != nil {
387 return err
388 }
389 }
390
391 if !noCommitsList {
392 err = generateLogForBranch(commitsFor[branch], params)
393 if err != nil {
394 return err
395 }
396 }
397 }
398
399 params.Ref = git.NewRef(defaultBranch)
400
401 echo("> generating commits...")
402 err = generateCommits(commits, params)
403 if err != nil {
404 return err
405 }
406
407 if err := generateTags(tags, params); err != nil {
408 return err
409 }
410
411 if flagGit {
412 echo("> generating dumb protocol files...")
413 if err := generateDumbProtocol(params, branches, defaultBranch, tags, commitsFor, commits); err != nil {
414 return err
415 }
416 }
417
418 if !noFiles {
419 if len(defaultBranchFiles) == 0 {
420 return fmt.Errorf("no files found for default branch in %s", params.RepoDir)
421 }
422 err = generateIndex(defaultBranchFiles, params)
423 if err != nil {
424 return err
425 }
426 }
427
428 return nil
429}
430
431func repoInputPath() (string, error) {
432 args := flag.Args()
433 if len(args) == 0 {
434 abs, err := filepath.Abs(".")
435 if err != nil {
436 return "", err
437 }
438 return abs, nil
439 }
440 if len(args) > 1 {
441 return "", fmt.Errorf("multiple positional args not supported with --config; use [[repos]] in config instead")
442 }
443 abs, err := filepath.Abs(args[0])
444 if err != nil {
445 return "", err
446 }
447 return abs, nil
448}
449
450func validateRepoPath(path string) error {
451 info, err := os.Stat(path)
452 if err != nil {
453 return fmt.Errorf("repo path %q: %w", path, err)
454 }
455 if !info.IsDir() {
456 return fmt.Errorf("repo path %q is not a directory", path)
457 }
458 cmd := exec.Command("git", "rev-parse", "--git-dir")
459 cmd.Dir = path
460 if _, err := cmd.Output(); err != nil {
461 return fmt.Errorf("repo path %q is not a git repository", path)
462 }
463 return nil
464}
465
466func sortGeneratables(gs []repoGeneration, mode string) {
467 switch mode {
468 case "time-asc", "time", "time-desc":
469 case "name-asc", "name", "name-desc":
470 default:
471 fmt.Fprintf(os.Stderr, "warning: unknown sort mode %q, ignoring\n", mode)
472 return
473 }
474
475 if strings.HasPrefix(mode, "time") {
476 times := make(map[string]time.Time, len(gs))
477 for _, g := range gs {
478 times[g.RepoDir] = lastCommitTime(g.RepoDir, g.DefaultBranch)
479 }
480 asc := mode == "time-asc" || mode == "time"
481 sort.Slice(gs, func(i, j int) bool {
482 if asc {
483 return times[gs[i].RepoDir].Before(times[gs[j].RepoDir])
484 }
485 return times[gs[i].RepoDir].After(times[gs[j].RepoDir])
486 })
487 return
488 }
489 asc := mode == "name-asc" || mode == "name"
490 sort.Slice(gs, func(i, j int) bool {
491 if asc {
492 return strings.ToLower(gs[i].Name) < strings.ToLower(gs[j].Name)
493 }
494 return strings.ToLower(gs[i].Name) > strings.ToLower(gs[j].Name)
495 })
496}
497
498func cfgReposToEntries(gs []repoGeneration) []RepoEntry {
499 entries := make([]RepoEntry, len(gs))
500 for i, g := range gs {
501 entries[i] = RepoEntry{
502 Name: g.Name,
503 Slug: g.Slug,
504 Path: g.RepoDir,
505 Description: g.Description,
506 DefaultBranch: g.DefaultBranch,
507 }
508 }
509 return entries
510}
511
512func usage() {
513 fmt.Fprintf(os.Stderr, "Usage: gitmal [options] [path ...]\n")
514 flag.PrintDefaults()
515}