diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-28 09:12:03 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-28 09:12:03 +0200 |
| commit | 1615abaacccdbb5002404a77270fd333ce8ad718 (patch) | |
| tree | c84cde36805a8717d21504cf954cffc72a2f1181 /internal/showcase | |
| parent | a694dd751eb16d47b0956facd5594ef575c9dce4 (diff) | |
feat(showcase): support per-repo stats branchesv0.16.0
Diffstat (limited to 'internal/showcase')
| -rw-r--r-- | internal/showcase/metadata.go | 17 | ||||
| -rw-r--r-- | internal/showcase/metadata_test.go | 63 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 102 | ||||
| -rw-r--r-- | internal/showcase/showcase_test.go | 125 |
4 files changed, 296 insertions, 11 deletions
diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go index 798b8fe..038dc08 100644 --- a/internal/showcase/metadata.go +++ b/internal/showcase/metadata.go @@ -131,9 +131,9 @@ func calculateRepoScore(linesOfCode int, avgCommitAge float64, tagCount int, has return score } -// getCommitCount returns the total number of commits +// getCommitCount returns the total number of commits reachable from the current HEAD. func getCommitCount(repoPath string) (int, error) { - cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count") + cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD") output, err := cmd.Output() if err != nil { return 0, err @@ -179,7 +179,7 @@ func countLinesOfCode(repoPath string) (int, error) { // getFirstCommitDate returns the date of the first commit func getFirstCommitDate(repoPath string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--pretty=format:%ai", "--date=short") + cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--pretty=format:%ai", "--date=short", "HEAD") output, err := cmd.Output() if err != nil { return "", err @@ -199,7 +199,7 @@ func getFirstCommitDate(repoPath string) (string, error) { // getLastCommitDate returns the date of the last commit func getLastCommitDate(repoPath string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--pretty=format:%ai", "--date=short") + cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--pretty=format:%ai", "--date=short", "HEAD") output, err := cmd.Output() if err != nil { return "", err @@ -273,7 +273,7 @@ func detectLicense(repoPath string) string { // getAverageCommitAge calculates the average age of the last N commits in days func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { // Get the last N commit dates - cmd := exec.Command("git", "-C", repoPath, "log", fmt.Sprintf("-%d", commitCount), "--pretty=format:%at") + cmd := exec.Command("git", "-C", repoPath, "log", fmt.Sprintf("-%d", commitCount), "--pretty=format:%at", "HEAD") output, err := cmd.Output() if err != nil { return 0, err @@ -311,14 +311,15 @@ func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { return totalAge / float64(validCommits), nil } -// getLatestTag returns the latest version-like tag, its date, whether the repo has releases, and total tag count. +// getLatestTag returns the latest version-like tag merged into HEAD, its date, +// whether the repo has releases, and total merged 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") + cmd := exec.Command("git", "-C", repoPath, "tag", "-l", "--merged", "HEAD", "--sort=-version:refname") output, err := cmd.Output() if err != nil { // Fallback to describe - cmd = exec.Command("git", "-C", repoPath, "describe", "--tags", "--abbrev=0") + cmd = exec.Command("git", "-C", repoPath, "describe", "--tags", "--abbrev=0", "HEAD") output, err = cmd.Output() if err != nil { // No tags at all diff --git a/internal/showcase/metadata_test.go b/internal/showcase/metadata_test.go index e8777ce..abc4664 100644 --- a/internal/showcase/metadata_test.go +++ b/internal/showcase/metadata_test.go @@ -79,6 +79,69 @@ func TestGetLatestTag_ReturnsTotalTagCount(t *testing.T) { } } +func TestExtractRepoMetadata_UsesCurrentBranchState(t *testing.T) { + t.Parallel() + + repoPath := t.TempDir() + runGit(t, repoPath, "init", "--initial-branch=main") + 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("main.go", "package main\n\nfunc main() {\n}\n", "main branch") + runGit(t, repoPath, "tag", "v1.0.0") + + runGit(t, repoPath, "checkout", "-b", "content-gemtext") + writeAndCommit("content.go", "package main\n\nfunc render() string {\n\treturn \"gemtext\"\n}\n", "content branch") + runGit(t, repoPath, "tag", "v2.0.0") + + runGit(t, repoPath, "checkout", "main") + + mainMetadata, err := extractRepoMetadata(repoPath) + if err != nil { + t.Fatalf("extractRepoMetadata(main) error = %v", err) + } + if mainMetadata.CommitCount != 1 { + t.Fatalf("main branch CommitCount = %d, want %d", mainMetadata.CommitCount, 1) + } + if mainMetadata.LinesOfCode != 4 { + t.Fatalf("main branch LinesOfCode = %d, want %d", mainMetadata.LinesOfCode, 4) + } + if mainMetadata.LatestTag != "v1.0.0" { + t.Fatalf("main branch LatestTag = %q, want %q", mainMetadata.LatestTag, "v1.0.0") + } + if mainMetadata.TagCount != 1 { + t.Fatalf("main branch TagCount = %d, want %d", mainMetadata.TagCount, 1) + } + + runGit(t, repoPath, "checkout", "content-gemtext") + + contentMetadata, err := extractRepoMetadata(repoPath) + if err != nil { + t.Fatalf("extractRepoMetadata(content-gemtext) error = %v", err) + } + if contentMetadata.CommitCount != 2 { + t.Fatalf("content-gemtext CommitCount = %d, want %d", contentMetadata.CommitCount, 2) + } + if contentMetadata.LinesOfCode != 9 { + t.Fatalf("content-gemtext LinesOfCode = %d, want %d", contentMetadata.LinesOfCode, 9) + } + if contentMetadata.LatestTag != "v2.0.0" { + t.Fatalf("content-gemtext LatestTag = %q, want %q", contentMetadata.LatestTag, "v2.0.0") + } + if contentMetadata.TagCount != 2 { + t.Fatalf("content-gemtext TagCount = %d, want %d", contentMetadata.TagCount, 2) + } +} + func runGit(t *testing.T, repoPath string, args ...string) string { t.Helper() diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 25f28f9..9cf43a6 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -174,7 +174,11 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) // runCommandWithTimeout runs a command with a short timeout and returns trimmed stdout. // Stderr is included in the error message for easier debugging when GITSYNCER_DEBUG=1. func runCommandWithTimeout(name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + return runCommandWithCustomTimeout(8*time.Second, name, args...) +} + +func runCommandWithCustomTimeout(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) out, err := cmd.CombinedOutput() @@ -680,6 +684,88 @@ func (g *Generator) buildProjectLinks(repoName string) (string, string) { return codebergURL, githubURL } +func (g *Generator) prepareStatsRepoPath(repoName, repoPath string) (string, func() error, error) { + if g.config == nil { + return repoPath, func() error { return nil }, nil + } + + branch := strings.TrimSpace(g.config.ShowcaseStatsBranches[repoName]) + if branch == "" { + return repoPath, func() error { return nil }, nil + } + + resolvedRef, err := resolveShowcaseStatsRef(repoPath, branch) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve showcase stats branch for %s: %w", repoName, err) + } + + tempPrefix := strings.ReplaceAll(repoName, string(os.PathSeparator), "-") + tempRoot, err := os.MkdirTemp("", "gitsyncer-showcase-"+tempPrefix+"-") + if err != nil { + return "", nil, fmt.Errorf("failed to create temporary worktree root for %s: %w", repoName, err) + } + + worktreePath := filepath.Join(tempRoot, "repo") + if _, err := runCommandWithCustomTimeout(45*time.Second, "git", "-C", repoPath, "worktree", "add", "--detach", worktreePath, resolvedRef); err != nil { + _ = os.RemoveAll(tempRoot) + return "", nil, fmt.Errorf("failed to create showcase stats worktree for %s on branch %q: %w", repoName, branch, err) + } + + cleanup := func() error { + defer os.RemoveAll(tempRoot) + + if _, err := runCommandWithCustomTimeout(45*time.Second, "git", "-C", repoPath, "worktree", "remove", "--force", worktreePath); err != nil { + return fmt.Errorf("failed to remove temporary worktree for %s: %w", repoName, err) + } + + return nil + } + + if resolvedRef == branch { + fmt.Printf("Using showcase stats branch %q for %s\n", branch, repoName) + } else { + fmt.Printf("Using showcase stats branch %q for %s (resolved to %s)\n", branch, repoName, resolvedRef) + } + + return worktreePath, cleanup, nil +} + +func resolveShowcaseStatsRef(repoPath, branch string) (string, error) { + localRef := "refs/heads/" + branch + if _, err := runCommandWithTimeout("git", "-C", repoPath, "show-ref", "--verify", "--quiet", localRef); err == nil { + return branch, nil + } + + output, err := runCommandWithTimeout("git", "-C", repoPath, "for-each-ref", "--format=%(refname)", "refs/remotes") + if err != nil { + return "", fmt.Errorf("failed to inspect remote refs for branch %q: %w", branch, err) + } + + var candidates []string + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + ref := strings.TrimSpace(line) + if ref == "" || strings.HasSuffix(ref, "/HEAD") { + continue + } + if strings.HasSuffix(ref, "/"+branch) { + candidates = append(candidates, ref) + } + } + + if len(candidates) == 0 { + return "", fmt.Errorf("branch %q not found locally or on any remote", branch) + } + + sort.Strings(candidates) + for _, ref := range candidates { + if strings.HasPrefix(ref, "refs/remotes/origin/") { + return ref, nil + } + } + + return candidates[0], nil +} + // generateProjectSummary generates a summary for a single project func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool) (*ProjectSummary, error) { repoPath := filepath.Join(g.workDir, repoName) @@ -708,9 +794,19 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool readmeContent, readmeFile, readmeFound := findReadmeContent(repoPath) + statsRepoPath, cleanupStatsRepoPath, err := g.prepareStatsRepoPath(repoName, repoPath) + if err != nil { + return nil, err + } + defer func() { + if err := cleanupStatsRepoPath(); err != nil { + fmt.Printf("Warning: %v\n", err) + } + }() + // Always extract metadata (not cached) fmt.Printf("Extracting repository metadata...\n") - metadata, err := extractRepoMetadata(repoPath) + metadata, err := extractRepoMetadata(statsRepoPath) if err != nil { fmt.Printf("Warning: Failed to extract some metadata: %v\n", err) // Continue anyway with partial metadata @@ -755,7 +851,7 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool // Extract code snippet for all projects var codeSnippet, codeLanguage string if metadata != nil && len(metadata.Languages) > 0 { - snippet, lang, err := extractCodeSnippet(repoPath, metadata.Languages) + snippet, lang, err := extractCodeSnippet(statsRepoPath, metadata.Languages) if err != nil { fmt.Printf("Warning: Failed to extract code snippet: %v\n", err) } else { diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go index 8bcad88..fa18799 100644 --- a/internal/showcase/showcase_test.go +++ b/internal/showcase/showcase_test.go @@ -2,6 +2,7 @@ package showcase import ( "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -229,3 +230,127 @@ func TestExtractUsefulSummary_SkipsFencedCodeBlocks(t *testing.T) { t.Fatalf("extractUsefulSummary() = %q, want %q", got, want) } } + +func TestPrepareStatsRepoPath_UsesConfiguredBranchWithoutChangingMainCheckout(t *testing.T) { + t.Parallel() + + repoPath := t.TempDir() + runGit(t, repoPath, "init", "--initial-branch=main") + runGit(t, repoPath, "config", "user.name", "Test User") + runGit(t, repoPath, "config", "user.email", "test@example.com") + + mainFile := filepath.Join(repoPath, "README.md") + if err := os.WriteFile(mainFile, []byte("main branch"), 0644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, repoPath, "add", "README.md") + runGit(t, repoPath, "commit", "-m", "main") + + runGit(t, repoPath, "checkout", "-b", "content-gemtext") + branchOnlyFile := filepath.Join(repoPath, "branch-only.txt") + if err := os.WriteFile(branchOnlyFile, []byte("content branch"), 0644); err != nil { + t.Fatalf("write branch-only.txt: %v", err) + } + runGit(t, repoPath, "add", "branch-only.txt") + runGit(t, repoPath, "commit", "-m", "content branch") + runGit(t, repoPath, "checkout", "main") + + g := &Generator{ + config: &config.Config{ + ShowcaseStatsBranches: map[string]string{ + "foo.zone": "content-gemtext", + }, + }, + } + + statsRepoPath, cleanup, err := g.prepareStatsRepoPath("foo.zone", repoPath) + if err != nil { + t.Fatalf("prepareStatsRepoPath() error = %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + }() + + if statsRepoPath == repoPath { + t.Fatal("expected a detached worktree path for configured stats branch") + } + if _, err := os.Stat(filepath.Join(statsRepoPath, "branch-only.txt")); err != nil { + t.Fatalf("expected branch-only file in detached worktree: %v", err) + } + if _, err := os.Stat(filepath.Join(repoPath, "branch-only.txt")); !os.IsNotExist(err) { + t.Fatalf("expected branch-only file to stay absent from main checkout, stat err = %v", err) + } + + currentBranch := strings.TrimSpace(runGit(t, repoPath, "branch", "--show-current")) + if currentBranch != "main" { + t.Fatalf("current branch = %q, want %q", currentBranch, "main") + } +} + +func TestPrepareStatsRepoPath_UsesRemoteTrackingBranchWhenLocalBranchMissing(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + seedRepoPath := filepath.Join(rootDir, "seed") + runGit(t, rootDir, "init", "--initial-branch=main", seedRepoPath) + runGit(t, seedRepoPath, "config", "user.name", "Test User") + runGit(t, seedRepoPath, "config", "user.email", "test@example.com") + + if err := os.WriteFile(filepath.Join(seedRepoPath, "README.md"), []byte("main branch"), 0644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, seedRepoPath, "add", "README.md") + runGit(t, seedRepoPath, "commit", "-m", "main") + + runGit(t, seedRepoPath, "checkout", "-b", "content-gemtext") + if err := os.WriteFile(filepath.Join(seedRepoPath, "branch-only.txt"), []byte("content branch"), 0644); err != nil { + t.Fatalf("write branch-only.txt: %v", err) + } + runGit(t, seedRepoPath, "add", "branch-only.txt") + runGit(t, seedRepoPath, "commit", "-m", "content branch") + runGit(t, seedRepoPath, "checkout", "main") + + remoteRepoPath := filepath.Join(rootDir, "remote.git") + cloneCmd := exec.Command("git", "clone", "--bare", seedRepoPath, remoteRepoPath) + if output, err := cloneCmd.CombinedOutput(); err != nil { + t.Fatalf("git clone --bare failed: %v\n%s", err, string(output)) + } + + cloneRepoPath := filepath.Join(rootDir, "clone") + workingCloneCmd := exec.Command("git", "clone", remoteRepoPath, cloneRepoPath) + if output, err := workingCloneCmd.CombinedOutput(); err != nil { + t.Fatalf("git clone failed: %v\n%s", err, string(output)) + } + + g := &Generator{ + config: &config.Config{ + ShowcaseStatsBranches: map[string]string{ + "foo.zone": "content-gemtext", + }, + }, + } + + statsRepoPath, cleanup, err := g.prepareStatsRepoPath("foo.zone", cloneRepoPath) + if err != nil { + t.Fatalf("prepareStatsRepoPath() error = %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + }() + + if _, err := os.Stat(filepath.Join(statsRepoPath, "branch-only.txt")); err != nil { + t.Fatalf("expected branch-only file in detached worktree from remote branch: %v", err) + } + if _, err := os.Stat(filepath.Join(cloneRepoPath, "branch-only.txt")); !os.IsNotExist(err) { + t.Fatalf("expected branch-only file to stay absent from main checkout, stat err = %v", err) + } + + currentBranch := strings.TrimSpace(runGit(t, cloneRepoPath, "branch", "--show-current")) + if currentBranch != "main" { + t.Fatalf("current branch = %q, want %q", currentBranch, "main") + } +} |
