Commit 1441fb2

Anton Medvedev <anton@medv.io>
2025-12-07 15:56:52
Refactor code by introducing `Ref` type
1 parent 20a5d07
pkg/git/git.go
@@ -31,7 +31,7 @@ func Branches(repoDir string, filter *regexp.Regexp, defaultBranch string) ([]Re
 		if filter != nil && !filter.MatchString(line) && line != defaultBranch {
 			continue
 		}
-		branches = append(branches, Ref(line))
+		branches = append(branches, NewRef(line))
 	}
 	return branches, nil
 }
@@ -88,13 +88,13 @@ func Tags(repoDir string) ([]Tag, error) {
 }
 
 func Files(ref Ref, repoDir string) ([]Blob, error) {
-	if ref == "" {
-		ref = "HEAD"
+	if ref.IsEmpty() {
+		ref = NewRef("HEAD")
 	}
 
 	// -r: recurse into subtrees
 	// -l: include blob size
-	cmd := exec.Command("git", "ls-tree", "--full-tree", "-r", "-l", string(ref))
+	cmd := exec.Command("git", "ls-tree", "--full-tree", "-r", "-l", ref.Ref())
 	if repoDir != "" {
 		cmd.Dir = repoDir
 	}
@@ -196,11 +196,11 @@ func Files(ref Ref, repoDir string) ([]Blob, error) {
 }
 
 func BlobContent(ref Ref, path string, repoDir string) ([]byte, bool, error) {
-	if ref == "" {
-		ref = "HEAD"
+	if ref.IsEmpty() {
+		ref = NewRef("HEAD")
 	}
 	// Use `git show ref:path` to get the blob content at that ref
-	cmd := exec.Command("git", "show", string(ref)+":"+path)
+	cmd := exec.Command("git", "show", ref.Ref()+":"+path)
 	if repoDir != "" {
 		cmd.Dir = repoDir
 	}
@@ -233,7 +233,7 @@ func Commits(ref Ref, repoDir string) ([]Commit, error) {
 		"--date=unix",
 		"--pretty=format:" + strings.Join(format, "\x1F"),
 		"-z", // Separate the commits with NULs instead of newlines
-		string(ref),
+		ref.Ref(),
 	}
 
 	cmd := exec.Command("git", args...)
pkg/git/types.go
@@ -4,7 +4,29 @@ import (
 	"time"
 )
 
-type Ref string
+type Ref struct {
+	ref     string
+	dirName string
+}
+
+func NewRef(ref string) Ref {
+	return Ref{
+		ref:     ref,
+		dirName: RefToFileName(ref),
+	}
+}
+
+func (r Ref) IsEmpty() bool {
+	return r.ref == ""
+}
+
+func (r Ref) Ref() string {
+	return r.ref
+}
+
+func (r Ref) DirName() string {
+	return r.dirName
+}
 
 type Blob struct {
 	Ref      Ref
pkg/git/utils.go
@@ -3,6 +3,7 @@ package git
 import (
 	"fmt"
 	"strconv"
+	"strings"
 )
 
 // ParseFileMode converts a git-style file mode (e.g. "100644")
@@ -74,3 +75,17 @@ func IsBinary(b []byte) bool {
 	// If more than 30% of sampled bytes are non-text, consider binary
 	return bad*100 > n*30
 }
+
+func RefToFileName(ref string) string {
+	var result strings.Builder
+	for _, c := range ref {
+		if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' {
+			result.WriteByte(byte(c))
+		} else if c >= 'A' && c <= 'Z' {
+			result.WriteByte(byte(c - 'A' + 'a'))
+		} else {
+			result.WriteByte('-')
+		}
+	}
+	return result.String()
+}
pkg/git/utils_test.go
@@ -54,3 +54,27 @@ func TestParseFileModeInvalid(t *testing.T) {
 		})
 	}
 }
+
+func TestRefToFileName(t *testing.T) {
+	tests := []struct {
+		in   string
+		want string
+	}{
+		{"main", "main"},
+		{"master", "master"},
+		{"release/v1.0", "release-v1.0"},
+		{"feature/add-login", "feature-add-login"},
+		{"bugfix\\windows\\path", "bugfix-windows-path"},
+		{"1.0.0", "1.0.0"},
+		{"1.x", "1.x"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.in, func(t *testing.T) {
+			got := git.RefToFileName(tt.in)
+			if got != tt.want {
+				t.Fatalf("refToFileName(%q) = %q, want %q", tt.in, got, tt.want)
+			}
+		})
+	}
+}
blob.go
@@ -57,7 +57,7 @@ func generateBlobs(files []git.Blob, params Params) error {
 	errCh := make(chan error, 1)
 	var wg sync.WaitGroup
 
-	p := progress_bar.NewProgressBar("blobs for "+string(params.Ref), len(files))
+	p := progress_bar.NewProgressBar("blobs for "+params.Ref.Ref(), len(files))
 
 	workerFn := func() {
 		defer wg.Done()
@@ -98,7 +98,7 @@ func generateBlobs(files []git.Blob, params Params) error {
 						content = string(data)
 					}
 
-					outPath := filepath.Join(params.OutputDir, "blob", string(params.Ref), blob.Path) + ".html"
+					outPath := filepath.Join(params.OutputDir, "blob", params.Ref.DirName(), blob.Path) + ".html"
 					if err := os.MkdirAll(filepath.Dir(outPath), 0o755); check(err) {
 						return
 					}
@@ -127,7 +127,7 @@ func generateBlobs(files []git.Blob, params Params) error {
 							b.String(),
 							blob.Path,
 							rootHref,
-							string(params.Ref),
+							params.Ref.DirName(),
 							dirsSet,
 							filesSet,
 						)
@@ -170,7 +170,7 @@ func generateBlobs(files []git.Blob, params Params) error {
 
 						} else if isImg {
 
-							rawPath := filepath.Join(params.OutputDir, "raw", string(params.Ref), blob.Path)
+							rawPath := filepath.Join(params.OutputDir, "raw", params.Ref.DirName(), blob.Path)
 							if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); check(err) {
 								return
 							}
@@ -187,7 +187,7 @@ func generateBlobs(files []git.Blob, params Params) error {
 								return
 							}
 
-							relativeRawPath := filepath.Join(rootHref, "raw", string(params.Ref), blob.Path)
+							relativeRawPath := filepath.Join(rootHref, "raw", params.Ref.DirName(), blob.Path)
 							contentHTML = template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" />`, relativeRawPath, blob.FileName))
 						}
 
branches.go
@@ -21,10 +21,10 @@ func generateBranches(branches []git.Ref, defaultBranch string, params Params) e
 	entries := make([]templates.BranchEntry, 0, len(branches))
 	for _, b := range branches {
 		entries = append(entries, templates.BranchEntry{
-			Name:        string(b),
-			Href:        filepath.ToSlash(filepath.Join("blob", string(b)) + "/index.html"),
-			IsDefault:   string(b) == defaultBranch,
-			CommitsHref: filepath.ToSlash(filepath.Join("commits", string(b), "index.html")),
+			Name:        b.Ref(),
+			Href:        filepath.ToSlash(filepath.Join("blob", b.DirName()) + "/index.html"),
+			IsDefault:   b.Ref() == defaultBranch,
+			CommitsHref: filepath.ToSlash(filepath.Join("commits", b.DirName(), "index.html")),
 		})
 	}
 
commit.go
@@ -230,7 +230,7 @@ func generateCommitPage(commit git.Commit, params Params) error {
 	})
 
 	currentRef := params.DefaultRef
-	if commit.Branch != "" {
+	if !commit.Branch.IsEmpty() {
 		currentRef = commit.Branch
 	}
 
commits_list.go
@@ -19,12 +19,12 @@ func generateLogForBranch(allCommits []git.Commit, params Params) error {
 
 	// RootHref from commits/<branch>/... => ../../
 	rootHref := "../../"
-	outBase := filepath.Join(params.OutputDir, "commits", string(params.Ref))
+	outBase := filepath.Join(params.OutputDir, "commits", params.Ref.DirName())
 	if err := os.MkdirAll(outBase, 0o755); err != nil {
 		return err
 	}
 
-	p := progress_bar.NewProgressBar("commits for "+string(params.Ref), totalPages)
+	p := progress_bar.NewProgressBar("commits for "+params.Ref.Ref(), totalPages)
 
 	page := 1
 	for pageCommits := range slices.Chunk(allCommits, commitsPerPage) {
index.go
@@ -72,7 +72,7 @@ func generateIndex(files []git.Blob, params Params) error {
 	for _, name := range dirNames {
 		subdirEntries = append(subdirEntries, templates.ListEntry{
 			Name:  name + "/",
-			Href:  "blob/" + string(params.Ref) + "/" + name + "/index.html",
+			Href:  "blob/" + params.Ref.DirName() + "/" + name + "/index.html",
 			IsDir: true,
 		})
 	}
@@ -81,7 +81,7 @@ func generateIndex(files []git.Blob, params Params) error {
 	for _, b := range di.files {
 		fileEntries = append(fileEntries, templates.ListEntry{
 			Name: b.FileName + "",
-			Href: "blob/" + string(params.Ref) + "/" + b.FileName + ".html",
+			Href: "blob/" + params.Ref.DirName() + "/" + b.FileName + ".html",
 			Mode: b.Mode,
 			Size: humanizeSize(b.Size),
 		})
list.go
@@ -81,7 +81,7 @@ func generateLists(files []git.Blob, params Params) error {
 	errCh := make(chan error, 1)
 	var wg sync.WaitGroup
 
-	p := progress_bar.NewProgressBar("lists for "+string(params.Ref), len(jobsSlice))
+	p := progress_bar.NewProgressBar("lists for "+params.Ref.Ref(), len(jobsSlice))
 
 	check := func(err error) bool {
 		if err != nil {
@@ -109,7 +109,7 @@ func generateLists(files []git.Blob, params Params) error {
 					dirPath := jb.dirPath
 					di := jb.di
 
-					outDir := filepath.Join(params.OutputDir, "blob", string(params.Ref))
+					outDir := filepath.Join(params.OutputDir, "blob", params.Ref.DirName())
 					if dirPath != "" {
 						// convert forward slash path into OS path
 						outDir = filepath.Join(outDir, filepath.FromSlash(dirPath))
main.go
@@ -144,7 +144,7 @@ func main() {
 		OutputDir:  outputDir,
 		Style:      flagTheme,
 		Dark:       themeColor == "dark",
-		DefaultRef: git.Ref(flagDefaultBranch),
+		DefaultRef: git.NewRef(flagDefaultBranch),
 	}
 
 	commits := make(map[string]git.Commit)
@@ -165,13 +165,15 @@ func main() {
 		}
 	}
 
+	// Add commits from tags
 	for _, tag := range tags {
-		commitsForTag, err := git.Commits(git.Ref(tag.Name), params.RepoDir)
+		commitsForTag, err := git.Commits(git.NewRef(tag.Name), params.RepoDir)
 		if err != nil {
 			panic(err)
 		}
 		for _, commit := range commitsForTag {
-			if alreadyExisting, ok := commits[commit.Hash]; ok && alreadyExisting.Branch != "" {
+			// Only add new commits
+			if alreadyExisting, ok := commits[commit.Hash]; ok && !alreadyExisting.Branch.IsEmpty() {
 				continue
 			}
 			commits[commit.Hash] = commit
@@ -196,7 +198,7 @@ func main() {
 				panic(err)
 			}
 
-			if branch == git.Ref(flagDefaultBranch) {
+			if branch.Ref() == flagDefaultBranch {
 				defaultBranchFiles = files
 			}
 
@@ -220,7 +222,7 @@ func main() {
 	}
 
 	// Back to the default branch
-	params.Ref = git.Ref(flagDefaultBranch)
+	params.Ref = git.NewRef(flagDefaultBranch)
 
 	// Commits pages generation
 	echo("> generating commits...")
readme.go
@@ -27,7 +27,14 @@ func readme(files []git.Blob, dirsSet, filesSet links.Set, params Params, rootHr
 			}
 
 			// Fix links/images relative to README location
-			htmlStr := links.Resolve(buf.String(), b.Path, rootHref, string(params.Ref), dirsSet, filesSet)
+			htmlStr := links.Resolve(
+				buf.String(),
+				b.Path,
+				rootHref,
+				params.Ref.DirName(),
+				dirsSet,
+				filesSet,
+			)
 
 			readmeHTML = template.HTML(htmlStr)
 			break
utils.go
@@ -107,23 +107,9 @@ func isImage(path string) bool {
 
 func containsBranch(branches []git.Ref, branch string) bool {
 	for _, b := range branches {
-		if string(b) == branch {
+		if b.Ref() == branch {
 			return true
 		}
 	}
 	return false
 }
-
-func refToFileName(ref git.Ref) string {
-	var result strings.Builder
-	for _, c := range string(ref) {
-		if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' {
-			result.WriteByte(byte(c))
-		} else if c >= 'A' && c <= 'Z' {
-			result.WriteByte(byte(c - 'A' + 'a'))
-		} else {
-			result.WriteByte('-')
-		}
-	}
-	return result.String()
-}
utils_test.go
@@ -1,31 +0,0 @@
-package main
-
-import (
-	"testing"
-
-	"github.com/antonmedv/gitmal/pkg/git"
-)
-
-func TestRefToFileName(t *testing.T) {
-	tests := []struct {
-		in   string
-		want string
-	}{
-		{"main", "main"},
-		{"master", "master"},
-		{"release/v1.0", "release-v1.0"},
-		{"feature/add-login", "feature-add-login"},
-		{"bugfix\\windows\\path", "bugfix-windows-path"},
-		{"1.0.0", "1.0.0"},
-		{"1.x", "1.x"},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.in, func(t *testing.T) {
-			got := refToFileName(git.Ref(tt.in))
-			if got != tt.want {
-				t.Fatalf("refToFileName(%q) = %q, want %q", tt.in, got, tt.want)
-			}
-		})
-	}
-}