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}