summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/showcase/metadata.go55
-rw-r--r--internal/showcase/metadata_test.go81
-rw-r--r--internal/showcase/rank_history.go20
-rw-r--r--internal/showcase/rank_history_test.go27
-rw-r--r--internal/showcase/showcase.go6
-rw-r--r--internal/showcase/showcase_test.go10
-rw-r--r--internal/version/version.go2
7 files changed, 157 insertions, 44 deletions
diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go
index 79eaf4a..3c51812 100644
--- a/internal/showcase/metadata.go
+++ b/internal/showcase/metadata.go
@@ -29,7 +29,8 @@ type RepoMetadata struct {
LastCommitDate string
License string
AvgCommitAge float64 // Average age of last 42 commits in days
- Score float64 // Project score combining LOC and recent activity: log10(LOC) * 1000 / (avgCommitAge + 1)
+ TagCount int // Total number of git tags in the repository
+ Score float64 // Project score combining recent activity, reduced LOC weight, and tag count
LatestTag string // Latest version tag (empty if no tags)
LatestTagDate string // Date of the latest tag (empty if no tags)
HasReleases bool // Whether the project has any releases/tags
@@ -91,26 +92,37 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) {
}
metadata.AvgCommitAge = avgAge
- // Calculate score: log10(LOC) * 1000 / (avgCommitAge + 1)
- // This balances project size with recent activity
- score := 0.0
- if metadata.LinesOfCode > 0 {
- score = math.Log10(float64(metadata.LinesOfCode)) * 1000.0 / (metadata.AvgCommitAge + 1.0)
- }
- metadata.Score = score
-
- // Get latest tag and check for releases
- latestTag, latestTagDate, hasReleases, err := getLatestTag(repoPath)
+ // Get tag metadata before calculating score so tags can influence ranking.
+ latestTag, latestTagDate, hasReleases, tagCount, err := getLatestTag(repoPath)
if err != nil {
fmt.Printf("Warning: Failed to get latest tag: %v\n", err)
}
metadata.LatestTag = latestTag
metadata.LatestTagDate = latestTagDate
metadata.HasReleases = hasReleases
+ metadata.TagCount = tagCount
+
+ // Calculate score with recent activity as the strongest signal,
+ // a smaller LOC contribution than before, and a modest tag bonus.
+ metadata.Score = calculateRepoScore(metadata.LinesOfCode, metadata.AvgCommitAge, metadata.TagCount)
return metadata, nil
}
+func calculateRepoScore(linesOfCode int, avgCommitAge float64, tagCount int) float64 {
+ sizeComponent := 0.0
+ if linesOfCode > 0 {
+ sizeComponent = math.Sqrt(math.Log10(float64(linesOfCode)+1.0)) * 250.0
+ }
+
+ tagComponent := 0.0
+ if tagCount > 0 {
+ tagComponent = math.Log1p(float64(tagCount)) * 40.0
+ }
+
+ return (sizeComponent + tagComponent) / (avgCommitAge + 1.0)
+}
+
// getCommitCount returns the total number of commits
func getCommitCount(repoPath string) (int, error) {
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count")
@@ -291,8 +303,8 @@ func getAverageCommitAge(repoPath string, commitCount int) (float64, error) {
return totalAge / float64(validCommits), nil
}
-// getLatestTag returns the latest git tag, its date, and whether the repo has any releases
-func getLatestTag(repoPath string) (string, string, bool, error) {
+// getLatestTag returns the latest version-like tag, its date, whether the repo has releases, and total tag count.
+func getLatestTag(repoPath string) (string, string, bool, int, error) {
// First try to get tags sorted by version
cmd := exec.Command("git", "-C", repoPath, "tag", "-l", "--sort=-version:refname")
output, err := cmd.Output()
@@ -302,13 +314,20 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
output, err = cmd.Output()
if err != nil {
// No tags at all
- return "", "", false, nil
+ return "", "", false, 0, nil
}
}
tags := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(tags) == 0 || tags[0] == "" {
- return "", "", false, nil
+ return "", "", false, 0, nil
+ }
+
+ tagCount := 0
+ for _, tag := range tags {
+ if strings.TrimSpace(tag) != "" {
+ tagCount++
+ }
}
// Find the first tag that looks like a version number
@@ -322,7 +341,7 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
if latestTag == "" {
// No version-like tags found
- return "", "", false, nil
+ return "", "", false, tagCount, nil
}
// Get the date of the latest tag
@@ -330,7 +349,7 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
dateOutput, err := cmd.Output()
if err != nil {
// Tag exists but couldn't get date
- return latestTag, "", true, nil
+ return latestTag, "", true, tagCount, nil
}
// Extract just the date part (YYYY-MM-DD)
@@ -341,7 +360,7 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
}
// Return the latest tag and its date
- return latestTag, tagDate, true, nil
+ return latestTag, tagDate, true, tagCount, nil
}
// isVersionTag checks if a tag looks like a version number
diff --git a/internal/showcase/metadata_test.go b/internal/showcase/metadata_test.go
new file mode 100644
index 0000000..3aa49c1
--- /dev/null
+++ b/internal/showcase/metadata_test.go
@@ -0,0 +1,81 @@
+package showcase
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+func TestCalculateRepoScore_IncreasesWithTagCount(t *testing.T) {
+ t.Parallel()
+
+ withoutTags := calculateRepoScore(5000, 14, 0)
+ withTags := calculateRepoScore(5000, 14, 10)
+
+ if withTags <= withoutTags {
+ t.Fatalf("expected tags to increase score, got without=%f with=%f", withoutTags, withTags)
+ }
+}
+
+func TestCalculateRepoScore_DecreasesWithAge(t *testing.T) {
+ t.Parallel()
+
+ recent := calculateRepoScore(5000, 7, 3)
+ old := calculateRepoScore(5000, 70, 3)
+
+ if recent <= old {
+ t.Fatalf("expected newer activity to score higher, got recent=%f old=%f", recent, old)
+ }
+}
+
+func TestGetLatestTag_ReturnsTotalTagCount(t *testing.T) {
+ t.Parallel()
+
+ repoPath := t.TempDir()
+ runGit(t, repoPath, "init")
+ runGit(t, repoPath, "config", "user.name", "Test User")
+ runGit(t, repoPath, "config", "user.email", "test@example.com")
+
+ writeAndCommit := func(name, content, message string) {
+ path := filepath.Join(repoPath, name)
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatalf("write file %s: %v", name, err)
+ }
+ runGit(t, repoPath, "add", name)
+ runGit(t, repoPath, "commit", "-m", message)
+ }
+
+ writeAndCommit("README.md", "first", "first")
+ runGit(t, repoPath, "tag", "notes")
+ runGit(t, repoPath, "tag", "v1.0.0")
+
+ writeAndCommit("README.md", "second", "second")
+ runGit(t, repoPath, "tag", "v1.1.0")
+
+ latestTag, _, hasReleases, tagCount, err := getLatestTag(repoPath)
+ if err != nil {
+ t.Fatalf("getLatestTag() error = %v", err)
+ }
+ if latestTag != "v1.1.0" {
+ t.Fatalf("latestTag = %q, want %q", latestTag, "v1.1.0")
+ }
+ if !hasReleases {
+ t.Fatal("expected hasReleases to be true")
+ }
+ if tagCount != 3 {
+ t.Fatalf("tagCount = %d, want %d", tagCount, 3)
+ }
+}
+
+func runGit(t *testing.T, repoPath string, args ...string) string {
+ t.Helper()
+
+ cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
+ }
+
+ return string(output)
+}
diff --git a/internal/showcase/rank_history.go b/internal/showcase/rank_history.go
index 7f62453..559237f 100644
--- a/internal/showcase/rank_history.go
+++ b/internal/showcase/rank_history.go
@@ -163,12 +163,12 @@ func movementArrow(currentSpot, olderSpot int) string {
return "·"
}
if currentSpot == olderSpot {
- return "="
+ return "←"
}
if currentSpot < olderSpot {
- return "↑"
+ return "↖"
}
- return "↓"
+ return "↙"
}
func formatRankHistoryForHeader(history []RepoRankHistory) string {
@@ -177,24 +177,28 @@ func formatRankHistoryForHeader(history []RepoRankHistory) string {
}
tokens := make([]string, 0, len(history))
- for i, point := range history {
+ lastSpot := 0
+ for _, point := range history {
if point.Spot <= 0 {
continue
}
- spot := fmt.Sprintf("#%d", point.Spot)
- if i == 0 {
+ spot := fmt.Sprintf("%d", point.Spot)
+ if lastSpot == 0 {
tokens = append(tokens, spot)
+ lastSpot = point.Spot
continue
}
- tokens = append(tokens, point.Arrow+spot)
+
+ tokens = append(tokens, movementArrow(lastSpot, point.Spot)+spot)
+ lastSpot = point.Spot
}
if len(tokens) == 0 {
return ""
}
- return " · " + strings.Join(tokens, "")
+ return " " + strings.Join(tokens, "")
}
func anchorLabel(i int) string {
diff --git a/internal/showcase/rank_history_test.go b/internal/showcase/rank_history_test.go
index 6e010a7..042a5e6 100644
--- a/internal/showcase/rank_history_test.go
+++ b/internal/showcase/rank_history_test.go
@@ -16,9 +16,9 @@ func TestMovementArrow(t *testing.T) {
older int
want string
}{
- {name: "same spot", current: 3, older: 3, want: "="},
- {name: "improved", current: 2, older: 5, want: "↑"},
- {name: "worse", current: 6, older: 4, want: "↓"},
+ {name: "same spot", current: 3, older: 3, want: "←"},
+ {name: "improved", current: 2, older: 5, want: "↖"},
+ {name: "worse", current: 6, older: 4, want: "↙"},
{name: "missing older", current: 2, older: 0, want: "·"},
{name: "missing current", current: 0, older: 2, want: "·"},
}
@@ -135,21 +135,28 @@ func TestFormatRankHistoryForHeader(t *testing.T) {
t.Parallel()
header := formatRankHistoryForHeader([]RepoRankHistory{
- {Spot: 3, Anchor: "now"},
- {Spot: 2, Anchor: "1w", Arrow: "↓"},
- {Spot: 2, Anchor: "2w", Arrow: "="},
+ {Spot: 2, Anchor: "now"},
+ {Spot: 3, Anchor: "1w", Arrow: "↖"},
+ {Spot: 3, Anchor: "2w", Arrow: "←"},
{Spot: 0, Anchor: "3w", Arrow: "·"},
+ {Spot: 2, Anchor: "4w", Arrow: "↙"},
})
- if !strings.Contains(header, "· #3") {
+ if !strings.Contains(header, " 2") {
t.Fatalf("header missing current spot: %s", header)
}
- if !strings.Contains(header, "↓#2") {
- t.Fatalf("header missing down movement: %s", header)
+ if !strings.Contains(header, "↖3") {
+ t.Fatalf("header missing up movement: %s", header)
}
- if !strings.Contains(header, "=#2") {
+ if !strings.Contains(header, "←3") {
t.Fatalf("header missing unchanged movement marker: %s", header)
}
+ if !strings.Contains(header, "↙2") {
+ t.Fatalf("header missing down movement: %s", header)
+ }
+ if strings.Contains(header, "#") {
+ t.Fatalf("header should not include hash prefixes: %s", header)
+ }
if strings.Contains(header, "n/a") {
t.Fatalf("header should omit missing history points: %s", header)
}
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index d8b68ae..7248a77 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -110,6 +110,7 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool)
fmt.Printf("First Commit: %s\n", summary.Metadata.FirstCommitDate)
fmt.Printf("Last Commit: %s\n", summary.Metadata.LastCommitDate)
fmt.Printf("License: %s\n", summary.Metadata.License)
+ fmt.Printf("Tags: %d\n", summary.Metadata.TagCount)
fmt.Printf("Score: %.1f\n", summary.Metadata.Score)
}
fmt.Println("--- End of summary ---")
@@ -786,7 +787,7 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
builder.WriteString(fmt.Sprintf("Generated on: %s\n\n", time.Now().Format("2006-01-02")))
// Introduction paragraph
- builder.WriteString("This page showcases my side projects, providing an overview of what each project does, its technical implementation, and key metrics. Each project summary includes information about the programming languages used, development activity, and licensing. The projects are ranked by score, which combines project size and recent activity.\n\n")
+ builder.WriteString("This page showcases my side projects, providing an overview of what each project does, its technical implementation, and key metrics. Each project summary includes information about the programming languages used, development activity, releases, and licensing. The projects are ranked by score, which combines recent activity, project size, and tag history.\n\n")
// Template inline TOC
builder.WriteString("<< template::inline::toc\n\n")
@@ -906,8 +907,9 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
if summary.Metadata.LinesOfDocs > 0 {
builder.WriteString(fmt.Sprintf("* 📄 Lines of Documentation: %d\n", summary.Metadata.LinesOfDocs))
}
+ builder.WriteString(fmt.Sprintf("* 🏷️ Tags: %d\n", summary.Metadata.TagCount))
builder.WriteString(fmt.Sprintf("* 📅 Development Period: %s to %s\n", summary.Metadata.FirstCommitDate, summary.Metadata.LastCommitDate))
- builder.WriteString(fmt.Sprintf("* 🏆 Score: %.1f (combines code size and activity)\n", summary.Metadata.Score))
+ builder.WriteString(fmt.Sprintf("* 🏆 Score: %.1f (combines recent activity, code size, and tags)\n", summary.Metadata.Score))
builder.WriteString(fmt.Sprintf("* ⚖️ License: %s\n", summary.Metadata.License))
// Add release information or experimental status
diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go
index 7d095c0..8bcad88 100644
--- a/internal/showcase/showcase_test.go
+++ b/internal/showcase/showcase_test.go
@@ -111,16 +111,16 @@ func TestFormatGemtext_IncludesRankHistoryInHeader(t *testing.T) {
Name: "alpha",
Summary: "alpha summary",
RankHistory: []RepoRankHistory{
- {Spot: 1, Anchor: "now"},
- {Spot: 2, Anchor: "1w", Arrow: "↑"},
- {Spot: 2, Anchor: "2w", Arrow: "="},
+ {Spot: 2, Anchor: "now"},
+ {Spot: 3, Anchor: "1w", Arrow: "↖"},
+ {Spot: 3, Anchor: "2w", Arrow: "←"},
{Spot: 0, Anchor: "3w", Arrow: "·"},
- {Spot: 4, Anchor: "4w", Arrow: "↓"},
+ {Spot: 2, Anchor: "4w", Arrow: "↙"},
},
},
})
- if !strings.Contains(content, "### 1. alpha · #1↑#2=#2↓#4") {
+ if !strings.Contains(content, "### 1. alpha 2↖3←3↙2") {
t.Fatalf("rank history was not rendered in header: %s", content)
}
}
diff --git a/internal/version/version.go b/internal/version/version.go
index 8928856..7997fd7 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -7,7 +7,7 @@ import (
var (
// Version is the current version of gitsyncer
- Version = "0.15.2"
+ Version = "0.15.3"
// GitCommit is the git commit hash at build time
GitCommit = "unknown"