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}