Commit 1bbe5e4

mo khan <mo@mokhan.ca>
2026-01-31 06:42:28
refactor: return err instead of panic and use errgroup
1 parent bbcd6c6
Changed files (8)
cmd/gitmal/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -15,6 +16,13 @@ import (
 var flagOutput string
 
 func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, "error:", err)
+		os.Exit(1)
+	}
+}
+
+func run() error {
 	flag.StringVar(&flagOutput, "output", "output", "Output directory")
 	flag.Usage = usage
 	flag.Parse()
@@ -25,17 +33,17 @@ func main() {
 		input = args[0]
 	}
 	if len(args) > 1 {
-		panic("Multiple repos not supported yet")
+		return errors.New("multiple repos not supported yet")
 	}
 
 	outputDir, err := filepath.Abs(flagOutput)
 	if err != nil {
-		panic(err)
+		return err
 	}
 
 	absInput, err := filepath.Abs(input)
 	if err != nil {
-		panic(err)
+		return err
 	}
 	input = absInput
 
@@ -44,12 +52,12 @@ func main() {
 
 	branches, err := git.Branches(input)
 	if err != nil {
-		panic(err)
+		return err
 	}
 
 	tags, err := git.Tags(input)
 	if err != nil {
-		panic(err)
+		return err
 	}
 
 	var defaultBranch string
@@ -58,13 +66,11 @@ func main() {
 	} else if generator.ContainsBranch(branches, "main") {
 		defaultBranch = "main"
 	} else {
-		generator.Echo("No default branch found (expected 'main' or 'master').")
-		os.Exit(1)
+		return errors.New("no default branch found (expected 'main' or 'master')")
 	}
 
 	if yes, a, b := generator.HasConflictingBranchNames(branches); yes {
-		generator.Echo(fmt.Sprintf("Conflicting branchs %q and %q, both want to use %q dir name.", a, b, a.DirName()))
-		os.Exit(1)
+		return fmt.Errorf("conflicting branches %q and %q, both want to use %q dir name", a, b, a.DirName())
 	}
 
 	params := generator.Params{
@@ -80,7 +86,7 @@ func main() {
 	for _, branch := range branches {
 		commitsFor[branch], err = git.Commits(branch, params.RepoDir)
 		if err != nil {
-			panic(err)
+			return err
 		}
 
 		for _, commit := range commitsFor[branch] {
@@ -95,7 +101,7 @@ func main() {
 	for _, tag := range tags {
 		commitsForTag, err := git.Commits(git.NewRef(tag.Name), params.RepoDir)
 		if err != nil {
-			panic(err)
+			return err
 		}
 		for _, commit := range commitsForTag {
 			if alreadyExisting, ok := commits[commit.Hash]; ok && !alreadyExisting.Branch.IsEmpty() {
@@ -108,15 +114,15 @@ func main() {
 	generator.Echo(fmt.Sprintf("> %s: %d branches, %d tags, %d commits", params.Name, len(branches), len(tags), len(commits)))
 
 	if err := generator.GenerateCSS(params); err != nil {
-		panic(err)
+		return err
 	}
 
 	if err := generator.GenerateBranches(branches, defaultBranch, params); err != nil {
-		panic(err)
+		return err
 	}
 
 	if err := generator.GenerateBranchesJSON(branches, commitsFor, params); err != nil {
-		panic(err)
+		return err
 	}
 
 	var defaultBranchFiles []git.Blob
@@ -127,7 +133,7 @@ func main() {
 
 		files, err := git.Files(params.Ref, params.RepoDir)
 		if err != nil {
-			panic(err)
+			return err
 		}
 
 		if branch.String() == defaultBranch {
@@ -135,23 +141,23 @@ func main() {
 		}
 
 		if err := generator.GenerateBlobs(files, params); err != nil {
-			panic(err)
+			return err
 		}
 
 		if err := generator.GenerateLists(files, params); err != nil {
-			panic(err)
+			return err
 		}
 
 		if err := generator.GenerateLogForBranch(commitsFor[branch], params); err != nil {
-			panic(err)
+			return err
 		}
 
 		if err := generator.GenerateCommitsJSON(commitsFor[branch], params); err != nil {
-			panic(err)
+			return err
 		}
 
 		if err := generator.GenerateCommitsAtom(commitsFor[branch], params); err != nil {
-			panic(err)
+			return err
 		}
 	}
 
@@ -159,28 +165,29 @@ func main() {
 
 	generator.Echo("> generating commits...")
 	if err := generator.GenerateCommits(commits, params); err != nil {
-		panic(err)
+		return err
 	}
 
 	if err := generator.GenerateTags(tags, params); err != nil {
-		panic(err)
+		return err
 	}
 
 	if err := generator.GenerateTagsAtom(tags, params); err != nil {
-		panic(err)
+		return err
 	}
 
 	if err := generator.GenerateReleasesAtom(tags, params); err != nil {
-		panic(err)
+		return err
 	}
 
 	if len(defaultBranchFiles) == 0 {
-		panic("No files found for default branch")
+		return errors.New("no files found for default branch")
 	}
 	if err := generator.GenerateIndex(defaultBranchFiles, params); err != nil {
-		panic(err)
+		return err
 	}
 
+	return nil
 }
 
 func usage() {
internal/generator/blob.go
@@ -8,11 +8,9 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/alecthomas/chroma/v2"
 	"github.com/alecthomas/chroma/v2/formatters/html"
 	"github.com/alecthomas/chroma/v2/lexers"
 	"github.com/alecthomas/chroma/v2/styles"
-	"github.com/yuin/goldmark"
 
 	"mokhan.ca/antonmedv/gitmal/internal/git"
 	"mokhan.ca/antonmedv/gitmal/internal/links"
@@ -20,12 +18,6 @@ import (
 	"mokhan.ca/antonmedv/gitmal/internal/templates"
 )
 
-type blobWorker struct {
-	md        goldmark.Markdown
-	formatter *html.Formatter
-	style     *chroma.Style
-}
-
 func GenerateBlobs(files []git.Blob, params Params) error {
 	formatterOptions := []html.Option{
 		html.WithLineNumbers(true),
@@ -34,134 +26,129 @@ func GenerateBlobs(files []git.Blob, params Params) error {
 		html.WithCSSComments(false),
 	}
 
+	md := createMarkdown()
+	formatter := html.New(formatterOptions...)
+	style := styles.Get("github")
+
 	dirsSet := links.BuildDirSet(files)
 	filesSet := links.BuildFileSet(files)
 
-	return pool.RunWithInit(files,
-		func() *blobWorker {
-			return &blobWorker{
-				md:        createMarkdown(),
-				formatter: html.New(formatterOptions...),
-				style:     styles.Get("github"),
-			}
-		},
-		func(w *blobWorker, blob git.Blob) error {
-			var content string
-			data, isBin, err := git.BlobContent(params.Ref, blob.Path, params.RepoDir)
-			if err != nil {
+	return pool.Run(files, func(blob git.Blob) error {
+		var content string
+		data, isBin, err := git.BlobContent(params.Ref, blob.Path, params.RepoDir)
+		if err != nil {
+			return err
+		}
+
+		isImg := isImage(blob.Path)
+		if !isBin {
+			content = string(data)
+		}
+
+		outPath := filepath.Join(params.OutputDir, "blob", params.Ref.DirName(), blob.Path) + ".html"
+		if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+			return err
+		}
+
+		f, err := os.Create(outPath)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+
+		depth := 0
+		if strings.Contains(blob.Path, "/") {
+			depth = len(strings.Split(blob.Path, "/")) - 1
+		}
+		rootHref := strings.Repeat("../", depth+2)
+
+		if isMarkdown(blob.Path) {
+			var b bytes.Buffer
+			if err := md.Convert([]byte(content), &b); err != nil {
 				return err
 			}
 
-			isImg := isImage(blob.Path)
-			if !isBin {
-				content = string(data)
-			}
+			contentHTML := links.Resolve(
+				b.String(),
+				blob.Path,
+				rootHref,
+				params.Ref.DirName(),
+				dirsSet,
+				filesSet,
+			)
 
-			outPath := filepath.Join(params.OutputDir, "blob", params.Ref.DirName(), blob.Path) + ".html"
-			if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+			return templates.MarkdownTemplate.ExecuteTemplate(f, "layout.gohtml", templates.MarkdownParams{
+				LayoutParams: templates.LayoutParams{
+					Title:            fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
+					Name:             params.Name,
+					RootHref:         rootHref,
+					CurrentRefDir:    params.Ref.DirName(),
+					Selected:         "code",
+					NeedsMarkdownCSS: true,
+					NeedsSyntaxCSS:   true,
+				},
+				HeaderParams: templates.HeaderParams{
+					Ref:         params.Ref,
+					Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
+				},
+				Blob:    blob,
+				Content: template.HTML(contentHTML),
+			})
+		}
+
+		var contentHTML template.HTML
+		if !isBin {
+			var b bytes.Buffer
+			lx := lexers.Match(blob.Path)
+			if lx == nil {
+				lx = lexers.Fallback
+			}
+			iterator, _ := lx.Tokenise(nil, content)
+			if err := formatter.Format(&b, style, iterator); err != nil {
+				return err
+			}
+			contentHTML = template.HTML(b.String())
+		} else if isImg {
+			rawPath := filepath.Join(params.OutputDir, "raw", params.Ref.DirName(), blob.Path)
+			if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); err != nil {
 				return err
 			}
 
-			f, err := os.Create(outPath)
+			rf, err := os.Create(rawPath)
 			if err != nil {
 				return err
 			}
-			defer f.Close()
 
-			depth := 0
-			if strings.Contains(blob.Path, "/") {
-				depth = len(strings.Split(blob.Path, "/")) - 1
-			}
-			rootHref := strings.Repeat("../", depth+2)
-
-			if isMarkdown(blob.Path) {
-				var b bytes.Buffer
-				if err := w.md.Convert([]byte(content), &b); err != nil {
-					return err
-				}
-
-				contentHTML := links.Resolve(
-					b.String(),
-					blob.Path,
-					rootHref,
-					params.Ref.DirName(),
-					dirsSet,
-					filesSet,
-				)
-
-				return templates.MarkdownTemplate.ExecuteTemplate(f, "layout.gohtml", templates.MarkdownParams{
-					LayoutParams: templates.LayoutParams{
-						Title:            fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
-						Name:             params.Name,
-						RootHref:         rootHref,
-						CurrentRefDir:    params.Ref.DirName(),
-						Selected:         "code",
-						NeedsMarkdownCSS: true,
-						NeedsSyntaxCSS:   true,
-					},
-					HeaderParams: templates.HeaderParams{
-						Ref:         params.Ref,
-						Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
-					},
-					Blob:    blob,
-					Content: template.HTML(contentHTML),
-				})
+			if _, err := rf.Write(data); err != nil {
+				rf.Close()
+				return err
 			}
-
-			var contentHTML template.HTML
-			if !isBin {
-				var b bytes.Buffer
-				lx := lexers.Match(blob.Path)
-				if lx == nil {
-					lx = lexers.Fallback
-				}
-				iterator, _ := lx.Tokenise(nil, content)
-				if err := w.formatter.Format(&b, w.style, iterator); err != nil {
-					return err
-				}
-				contentHTML = template.HTML(b.String())
-			} else if isImg {
-				rawPath := filepath.Join(params.OutputDir, "raw", params.Ref.DirName(), blob.Path)
-				if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); err != nil {
-					return err
-				}
-
-				rf, err := os.Create(rawPath)
-				if err != nil {
-					return err
-				}
-
-				if _, err := rf.Write(data); err != nil {
-					rf.Close()
-					return err
-				}
-				if err := rf.Close(); err != nil {
-					return err
-				}
-
-				relativeRawPath := filepath.Join(rootHref, "raw", params.Ref.DirName(), blob.Path)
-				contentHTML = template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" />`, relativeRawPath, blob.FileName))
+			if err := rf.Close(); err != nil {
+				return err
 			}
 
-			return templates.BlobTemplate.ExecuteTemplate(f, "layout.gohtml", templates.BlobParams{
-				LayoutParams: templates.LayoutParams{
-					Title:          fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
-					Name:           params.Name,
-					RootHref:       rootHref,
-					CurrentRefDir:  params.Ref.DirName(),
-					Selected:       "code",
-					NeedsSyntaxCSS: !isBin,
-				},
-				HeaderParams: templates.HeaderParams{
-					Ref:         params.Ref,
-					Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
-				},
-				Blob:     blob,
-				IsBinary: isBin,
-				IsImage:  isImg,
-				Content:  contentHTML,
-			})
-		},
-	)
+			relativeRawPath := filepath.Join(rootHref, "raw", params.Ref.DirName(), blob.Path)
+			contentHTML = template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" />`, relativeRawPath, blob.FileName))
+		}
+
+		return templates.BlobTemplate.ExecuteTemplate(f, "layout.gohtml", templates.BlobParams{
+			LayoutParams: templates.LayoutParams{
+				Title:          fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
+				Name:           params.Name,
+				RootHref:       rootHref,
+				CurrentRefDir:  params.Ref.DirName(),
+				Selected:       "code",
+				NeedsSyntaxCSS: !isBin,
+			},
+			HeaderParams: templates.HeaderParams{
+				Ref:         params.Ref,
+				Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
+			},
+			Blob:     blob,
+			IsBinary: isBin,
+			IsImage:  isImg,
+			Content:  contentHTML,
+		})
+	})
 }
 
internal/generator/list.go
@@ -19,34 +19,28 @@ type dirInfo struct {
 }
 
 func buildDirTree(files []git.Blob) map[string]*dirInfo {
-	dirs := map[string]*dirInfo{}
-
-	ensureDir := func(p string) *dirInfo {
-		if di, ok := dirs[p]; ok {
-			return di
-		}
-		di := &dirInfo{subdirs: map[string]struct{}{}, files: []git.Blob{}}
-		dirs[p] = di
-		return di
-	}
+	dirs := make(map[string]*dirInfo)
+	dirs[""] = &dirInfo{subdirs: make(map[string]struct{})}
 
 	for _, b := range files {
-		p := b.Path
-		parts := strings.Split(p, "/")
+		parts := strings.Split(b.Path, "/")
 		cur := ""
 		for i := 0; i < len(parts)-1; i++ {
-			child := parts[i]
-			ensureDir(cur).subdirs[child] = struct{}{}
+			if dirs[cur] == nil {
+				dirs[cur] = &dirInfo{subdirs: make(map[string]struct{})}
+			}
+			dirs[cur].subdirs[parts[i]] = struct{}{}
 			if cur == "" {
-				cur = child
+				cur = parts[i]
 			} else {
-				cur = cur + "/" + child
+				cur += "/" + parts[i]
 			}
-			ensureDir(cur)
 		}
-		ensureDir(cur).files = append(ensureDir(cur).files, b)
+		if dirs[cur] == nil {
+			dirs[cur] = &dirInfo{subdirs: make(map[string]struct{})}
+		}
+		dirs[cur].files = append(dirs[cur].files, b)
 	}
-
 	return dirs
 }
 
internal/git/git.go
@@ -11,14 +11,25 @@ import (
 	"time"
 )
 
-func Branches(repoDir string) ([]Ref, error) {
-	cmd := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/heads/")
+func gitCmd(repoDir string, args ...string) ([]byte, error) {
+	cmd := exec.Command("git", args...)
 	if repoDir != "" {
 		cmd.Dir = repoDir
 	}
 	out, err := cmd.Output()
 	if err != nil {
-		return nil, fmt.Errorf("failed to list branches: %w", err)
+		if ee, ok := err.(*exec.ExitError); ok {
+			return nil, fmt.Errorf("git %s: %w: %s", args[0], err, ee.Stderr)
+		}
+		return nil, err
+	}
+	return out, nil
+}
+
+func Branches(repoDir string) ([]Ref, error) {
+	out, err := gitCmd(repoDir, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
+	if err != nil {
+		return nil, err
 	}
 	lines := strings.Split(string(out), "\n")
 	branches := make([]Ref, 0, len(lines))
@@ -33,24 +44,19 @@ func Branches(repoDir string) ([]Ref, error) {
 
 func Tags(repoDir string) ([]Tag, error) {
 	format := []string{
-		"%(refname:short)",    // tag name
-		"%(creatordate:unix)", // creation date
-		"%(objectname)",       // commit hash for lightweight tags
-		"%(*objectname)",      // peeled object => commit hash
+		"%(refname:short)",
+		"%(creatordate:unix)",
+		"%(objectname)",
+		"%(*objectname)",
 	}
-	args := []string{
+	out, err := gitCmd(repoDir,
 		"for-each-ref",
 		"--sort=-creatordate",
-		"--format=" + strings.Join(format, "%00"),
+		"--format="+strings.Join(format, "%00"),
 		"refs/tags",
-	}
-	cmd := exec.Command("git", args...)
-	if repoDir != "" {
-		cmd.Dir = repoDir
-	}
-	out, err := cmd.Output()
+	)
 	if err != nil {
-		return nil, fmt.Errorf("failed to list tags: %w", err)
+		return nil, err
 	}
 
 	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
@@ -194,52 +200,22 @@ func BlobContent(ref Ref, path string, repoDir string) ([]byte, bool, error) {
 	if ref.IsEmpty() {
 		ref = NewRef("HEAD")
 	}
-	// Use `git show ref:path` to get the blob content at that ref
-	cmd := exec.Command("git", "show", ref.String()+":"+path)
-	if repoDir != "" {
-		cmd.Dir = repoDir
-	}
-	out, err := cmd.Output()
+	out, err := gitCmd(repoDir, "show", ref.String()+":"+path)
 	if err != nil {
-		// include stderr if available
-		if ee, ok := err.(*exec.ExitError); ok {
-			return nil, false, fmt.Errorf("git show failed: %v: %s", err, string(ee.Stderr))
-		}
-		return nil, false, fmt.Errorf("git show failed: %w", err)
+		return nil, false, err
 	}
 	return out, IsBinary(out), nil
 }
 
 func Commits(ref Ref, repoDir string) ([]Commit, error) {
-	format := []string{
-		"%H",  // commit hash
-		"%h",  // abbreviated commit hash
-		"%s",  // subject
-		"%b",  // body
-		"%an", // author name
-		"%ae", // author email
-		"%ad", // author date
-		"%cn", // committer name
-		"%ce", // committer email
-		"%cd", // committer date
-		"%P",  // parent hashes
-		"%D",  // ref names without the "(", ")" wrapping.
-	}
-
-	args := []string{
+	format := []string{"%H", "%h", "%s", "%b", "%an", "%ae", "%ad", "%cn", "%ce", "%cd", "%P", "%D"}
+	out, err := gitCmd(repoDir,
 		"log",
 		"--date=unix",
-		"--pretty=format:" + strings.Join(format, "\x1F"),
-		"-z", // Separate the commits with NULs instead of newlines
+		"--pretty=format:"+strings.Join(format, "\x1F"),
+		"-z",
 		ref.String(),
-	}
-
-	cmd := exec.Command("git", args...)
-	if repoDir != "" {
-		cmd.Dir = repoDir
-	}
-
-	out, err := cmd.Output()
+	)
 	if err != nil {
 		return nil, err
 	}
@@ -347,12 +323,7 @@ func parseRefNames(refNames string) []RefName {
 }
 
 func CommitDiff(hash, repoDir string) (string, error) {
-	// unified diff without a commit header
-	cmd := exec.Command("git", "show", "--pretty=format:", "--patch", hash)
-	if repoDir != "" {
-		cmd.Dir = repoDir
-	}
-	out, err := cmd.Output()
+	out, err := gitCmd(repoDir, "show", "--pretty=format:", "--patch", hash)
 	if err != nil {
 		return "", err
 	}
internal/pool/pool.go
@@ -1,83 +1,19 @@
 package pool
 
 import (
-	"context"
 	"runtime"
-	"sync"
+
+	"golang.org/x/sync/errgroup"
 )
 
 func Run[T any](items []T, fn func(T) error) error {
-	return RunWithInit(items, func() struct{} { return struct{}{} }, func(_ struct{}, item T) error {
-		return fn(item)
-	})
-}
-
-func RunWithInit[T, W any](items []T, init func() W, fn func(W, T) error) error {
 	if len(items) == 0 {
 		return nil
 	}
-
-	workers := runtime.NumCPU()
-	if workers < 1 {
-		workers = 1
+	g := &errgroup.Group{}
+	g.SetLimit(runtime.NumCPU())
+	for _, item := range items {
+		g.Go(func() error { return fn(item) })
 	}
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	jobs := make(chan T)
-	errCh := make(chan error, 1)
-	var wg sync.WaitGroup
-
-	wg.Add(workers)
-	for i := 0; i < workers; i++ {
-		go func() {
-			defer wg.Done()
-			w := init()
-			for {
-				select {
-				case <-ctx.Done():
-					return
-				case item, ok := <-jobs:
-					if !ok {
-						return
-					}
-					if err := fn(w, item); err != nil {
-						select {
-						case errCh <- err:
-							cancel()
-						default:
-						}
-						return
-					}
-				}
-			}
-		}()
-	}
-
-	go func() {
-		defer close(jobs)
-		for _, item := range items {
-			select {
-			case <-ctx.Done():
-				return
-			case jobs <- item:
-			}
-		}
-	}()
-
-	done := make(chan struct{})
-	go func() {
-		wg.Wait()
-		close(done)
-	}()
-
-	var err error
-	select {
-	case err = <-errCh:
-		<-done
-	case <-done:
-	}
-
-	return err
+	return g.Wait()
 }
go.mod
@@ -11,4 +11,7 @@ require (
 	golang.org/x/net v0.47.0
 )
 
-require github.com/dlclark/regexp2 v1.11.5 // indirect
+require (
+	github.com/dlclark/regexp2 v1.11.5 // indirect
+	golang.org/x/sync v0.19.0 // indirect
+)
go.sum
@@ -28,5 +28,7 @@ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+
 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
 golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
 golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
README.md
@@ -53,12 +53,6 @@ Generated paths follow GitHub repository conventions:
 | `/releases.atom` | Releases Atom feed |
 | `/branches.json` | Branches JSON |
 
-## Building
-
-```bash
-go build ./cmd/gitmal
-```
-
 ## License
 
 MIT