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}