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