master
  1package main
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"regexp"
  9	"runtime/pprof"
 10	"strings"
 11
 12	"github.com/antonmedv/gitmal/pkg/git"
 13
 14	flag "github.com/spf13/pflag"
 15)
 16
 17var (
 18	flagOwner         string
 19	flagName          string
 20	flagOutput        string
 21	flagBranches      string
 22	flagDefaultBranch string
 23	flagTheme         string
 24	flagThemeLight    string
 25	flagThemeDark     string
 26	flagConfig        string
 27	flagPreviewThemes bool
 28	flagMinify        bool
 29	flagGzip          bool
 30	flagGit           bool
 31	flagInlineStyles  bool
 32)
 33
 34type Params struct {
 35	Owner        string
 36	Name         string
 37	SiteName     string
 38	RepoDir      string
 39	Ref          git.Ref
 40	OutputDir    string
 41	Style        string
 42	StyleLight   string
 43	StyleDark    string
 44	Dark         bool
 45	DefaultRef   git.Ref
 46	RootPrefix   string
 47	InlineStyles bool
 48}
 49
 50func main() {
 51	if _, ok := os.LookupEnv("GITMAL_PPROF"); ok {
 52		f, err := os.Create("cpu.prof")
 53		if err != nil {
 54			panic(err)
 55		}
 56		err = pprof.StartCPUProfile(f)
 57		if err != nil {
 58			panic(err)
 59		}
 60		defer f.Close()
 61		defer pprof.StopCPUProfile()
 62		memProf, err := os.Create("mem.prof")
 63		if err != nil {
 64			panic(err)
 65		}
 66		defer memProf.Close()
 67		defer pprof.WriteHeapProfile(memProf)
 68	}
 69
 70	_, noFiles := os.LookupEnv("NO_FILES")
 71	_, noCommitsList := os.LookupEnv("NO_COMMITS_LIST")
 72
 73	flag.StringVar(&flagOwner, "owner", "", "Project owner")
 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.Usage = usage
 88	flag.Parse()
 89
 90	if flagPreviewThemes {
 91		previewThemes()
 92		os.Exit(0)
 93	}
 94
 95	outputDir, err := filepath.Abs(flagOutput)
 96	if err != nil {
 97		panic(err)
 98	}
 99
100	styleLight, styleDark, err := resolveTheme(flagTheme, flagThemeLight, flagThemeDark)
101	if err != nil {
102		panic(err)
103	}
104
105	baseParams := Params{
106		Owner:        flagOwner,
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		} else if cfg.Repo != nil {
141			repoPath := cfg.Repo.Path
142			if len(flag.Args()) > 0 {
143				absPath, err := filepath.Abs(flag.Args()[0])
144				if err != nil {
145					panic(err)
146				}
147				repoPath = absPath
148			}
149			repoName := flagName
150			if repoName == "" {
151				repoName = cfg.Repo.Name
152			}
153			defaultBranch := flagDefaultBranch
154			if defaultBranch == "" {
155				defaultBranch = cfg.Repo.DefaultBranch
156			}
157			if err := validateRepoPath(repoPath); err != nil {
158				panic(err)
159			}
160			generatables = append(generatables, repoGeneration{
161				RepoDir:       repoPath,
162				Name:          repoName,
163				DefaultBranch: defaultBranch,
164			})
165		}
166	}
167
168	if len(generatables) == 0 {
169		inputPath, err := repoInputPath()
170		if err != nil {
171			panic(err)
172		}
173		repoName := flagName
174		if repoName == "" {
175			repoName = filepath.Base(inputPath)
176			repoName = strings.TrimSuffix(repoName, ".git")
177		}
178		if err := validateRepoPath(inputPath); err != nil {
179			panic(err)
180		}
181		generatables = append(generatables, repoGeneration{
182			RepoDir:       inputPath,
183			Name:          repoName,
184			DefaultBranch: flagDefaultBranch,
185		})
186	}
187
188	baseParams.SiteName = siteName
189
190	if !baseParams.InlineStyles {
191		if err := generateCSSFiles(baseParams); err != nil {
192			panic(err)
193		}
194	}
195
196	for _, g := range generatables {
197		params := baseParams
198		params.RepoDir = g.RepoDir
199		params.Name = g.Name
200		params.SiteName = siteName
201		params.RootPrefix = ""
202
203		if isMulti {
204			params.OutputDir = filepath.Join(outputDir, g.Slug)
205			params.RootPrefix = "../"
206		}
207
208		if err := generateRepo(g, params, noFiles, noCommitsList); err != nil {
209			panic(err)
210		}
211	}
212
213	if isMulti {
214		if err := generateMultiRepoIndex(cfgReposToEntries(generatables), siteName, baseParams); err != nil {
215			panic(err)
216		}
217	}
218
219	if flagMinify || flagGzip {
220		echo("> post-processing HTML...")
221		if err := postProcessHTML(outputDir, flagMinify, flagGzip); err != nil {
222			panic(err)
223		}
224	}
225}
226
227type repoGeneration struct {
228	RepoDir       string
229	Name          string
230	Slug          string
231	Description   string
232	DefaultBranch string
233}
234
235func generateRepo(g repoGeneration, params Params, noFiles, noCommitsList bool) error {
236	branchesFilter, err := regexp.Compile(flagBranches)
237	if err != nil {
238		return err
239	}
240
241	branches, err := git.Branches(params.RepoDir, branchesFilter, g.DefaultBranch)
242	if err != nil {
243		return err
244	}
245
246	tags, err := git.Tags(params.RepoDir)
247	if err != nil {
248		return err
249	}
250
251	defaultBranch := g.DefaultBranch
252	if defaultBranch == "" {
253		if containsBranch(branches, "master") {
254			defaultBranch = "master"
255		} else if containsBranch(branches, "main") {
256			defaultBranch = "main"
257		} else {
258			return fmt.Errorf("no default branch found in %s", params.RepoDir)
259		}
260	}
261
262	if !containsBranch(branches, defaultBranch) {
263		return fmt.Errorf("default branch %q not found in %s", defaultBranch, params.RepoDir)
264	}
265
266	if yes, a, b := hasConflictingBranchNames(branches); yes {
267		return fmt.Errorf("conflicting branch names %q and %q, both want to use %q dir name", a, b, a.DirName())
268	}
269
270	params.DefaultRef = git.NewRef(defaultBranch)
271
272	commits := make(map[string]git.Commit)
273	commitsFor := make(map[git.Ref][]git.Commit, len(branches))
274
275	for _, branch := range branches {
276		commitsFor[branch], err = git.Commits(branch, params.RepoDir)
277		if err != nil {
278			return err
279		}
280
281		for _, commit := range commitsFor[branch] {
282			if alreadyExisting, ok := commits[commit.Hash]; ok && alreadyExisting.Branch == params.DefaultRef {
283				continue
284			}
285			commit.Branch = branch
286			commits[commit.Hash] = commit
287		}
288	}
289
290	for _, tag := range tags {
291		commitsForTag, err := git.Commits(git.NewRef(tag.Name), params.RepoDir)
292		if err != nil {
293			return err
294		}
295		for _, commit := range commitsForTag {
296			if alreadyExisting, ok := commits[commit.Hash]; ok && !alreadyExisting.Branch.IsEmpty() {
297				continue
298			}
299			commits[commit.Hash] = commit
300		}
301	}
302
303	echo(fmt.Sprintf("> %s: %d branches, %d tags, %d commits", params.Name, len(branches), len(tags), len(commits)))
304
305	if err := generateBranches(branches, defaultBranch, params); err != nil {
306		return err
307	}
308
309	var defaultBranchFiles []git.Blob
310
311	for i, branch := range branches {
312		echo(fmt.Sprintf("> [%d/%d] %s@%s", i+1, len(branches), params.Name, branch))
313		params.Ref = branch
314
315		if !noFiles {
316			files, err := git.Files(params.Ref, params.RepoDir)
317			if err != nil {
318				return err
319			}
320
321			if branch.String() == defaultBranch {
322				defaultBranchFiles = files
323			}
324
325			err = generateBlobs(files, params)
326			if err != nil {
327				return err
328			}
329
330			err = generateLists(files, params)
331			if err != nil {
332				return err
333			}
334		}
335
336		if !noCommitsList {
337			err = generateLogForBranch(commitsFor[branch], params)
338			if err != nil {
339				return err
340			}
341		}
342	}
343
344	params.Ref = git.NewRef(defaultBranch)
345
346	echo("> generating commits...")
347	err = generateCommits(commits, params)
348	if err != nil {
349		return err
350	}
351
352	if err := generateTags(tags, params); err != nil {
353		return err
354	}
355
356	if flagGit {
357		echo("> generating dumb protocol files...")
358		if err := generateDumbProtocol(params, branches, defaultBranch, tags, commitsFor, commits); err != nil {
359			return err
360		}
361	}
362
363	if !noFiles {
364		if len(defaultBranchFiles) == 0 {
365			return fmt.Errorf("no files found for default branch in %s", params.RepoDir)
366		}
367		err = generateIndex(defaultBranchFiles, params)
368		if err != nil {
369			return err
370		}
371	}
372
373	return nil
374}
375
376func repoInputPath() (string, error) {
377	args := flag.Args()
378	if len(args) == 0 {
379		abs, err := filepath.Abs(".")
380		if err != nil {
381			return "", err
382		}
383		return abs, nil
384	}
385	if len(args) > 1 {
386		return "", fmt.Errorf("multiple positional args not supported with --config; use [[repos]] in config instead")
387	}
388	abs, err := filepath.Abs(args[0])
389	if err != nil {
390		return "", err
391	}
392	return abs, nil
393}
394
395func validateRepoPath(path string) error {
396	info, err := os.Stat(path)
397	if err != nil {
398		return fmt.Errorf("repo path %q: %w", path, err)
399	}
400	if !info.IsDir() {
401		return fmt.Errorf("repo path %q is not a directory", path)
402	}
403	cmd := exec.Command("git", "rev-parse", "--git-dir")
404	cmd.Dir = path
405	if _, err := cmd.Output(); err != nil {
406		return fmt.Errorf("repo path %q is not a git repository", path)
407	}
408	return nil
409}
410
411func cfgReposToEntries(gs []repoGeneration) []RepoEntry {
412	entries := make([]RepoEntry, len(gs))
413	for i, g := range gs {
414		entries[i] = RepoEntry{
415			Name:          g.Name,
416			Slug:          g.Slug,
417			Path:          g.RepoDir,
418			Description:   g.Description,
419			DefaultBranch: g.DefaultBranch,
420		}
421	}
422	return entries
423}
424
425func usage() {
426	fmt.Fprintf(os.Stderr, "Usage: gitmal [options] [path ...]\n")
427	flag.PrintDefaults()
428}