summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-07 23:25:10 +0300
committerPaul Buetow <paul@buetow.org>2025-07-07 23:25:10 +0300
commit4526c8a171dbe40762c116e5b8a404f20131d2b1 (patch)
treeea3d544cbad995dabb616f4b6136e6e24a097524
parent64095a2c8d5a3a72c55d7bd0737c5542a5aeee09 (diff)
feat: add comprehensive showcase generation with metadata and images
- Add --showcase flag to generate project showcases using Claude - Extract repository metadata (languages, commits, LOC, dates, license) - Support image extraction from README files (local and remote) - Add caching with --force flag to regenerate - Add exclude_from_showcase config option - Add standalone showcase mode (--showcase without sync) - Sort projects by recent activity (avg age of last 100 commits) - Output in Gemini Gemtext template format (.gmi.tpl) - Fix backup location fetching when --backup flag not set 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--cmd/gitsyncer/main.go43
-rw-r--r--internal/cli/flags.go4
-rw-r--r--internal/cli/showcase_handler.go35
-rw-r--r--internal/cli/showcase_only_handler.go112
-rw-r--r--internal/config/config.go9
-rw-r--r--internal/showcase/images.go258
-rw-r--r--internal/showcase/metadata.go365
-rw-r--r--internal/showcase/showcase.go560
-rw-r--r--internal/sync/sync.go50
9 files changed, 1428 insertions, 8 deletions
diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go
index ddf00e3..9828776 100644
--- a/cmd/gitsyncer/main.go
+++ b/cmd/gitsyncer/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "fmt"
"os"
"path/filepath"
@@ -59,25 +60,61 @@ func main() {
// Handle sync operation
if flags.SyncRepo != "" {
- os.Exit(cli.HandleSync(cfg, flags))
+ exitCode := cli.HandleSync(cfg, flags)
+ if exitCode == 0 && flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+ os.Exit(exitCode)
}
// Handle sync all operation
if flags.SyncAll {
- os.Exit(cli.HandleSyncAll(cfg, flags))
+ exitCode := cli.HandleSyncAll(cfg, flags)
+ if exitCode == 0 && flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+ os.Exit(exitCode)
}
// Handle sync Codeberg public repos
if flags.SyncCodebergPublic {
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode != 0 || !flags.SyncGitHubPublic {
+ if exitCode == 0 && flags.Showcase && !flags.SyncGitHubPublic {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
os.Exit(exitCode)
}
}
// Handle sync GitHub public repos
if flags.SyncGitHubPublic {
- os.Exit(cli.HandleSyncGitHubPublic(cfg, flags))
+ exitCode := cli.HandleSyncGitHubPublic(cfg, flags)
+
+ // Run showcase generation if requested and sync was successful
+ if exitCode == 0 && flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+
+ os.Exit(exitCode)
+ }
+
+ // Handle standalone showcase mode (no sync operations specified)
+ if flags.Showcase {
+ fmt.Println("Running showcase generation for all repositories (clone-only mode)...")
+ os.Exit(cli.HandleShowcaseOnly(cfg, flags))
}
// Default: show usage
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 322ad5a..7640398 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -25,6 +25,8 @@ type Flags struct {
Clean bool
DeleteRepo string
Backup bool
+ Showcase bool
+ Force bool
}
// ParseFlags parses command-line flags and returns the flags struct
@@ -50,6 +52,8 @@ func ParseFlags() *Flags {
flag.BoolVar(&f.Clean, "clean", false, "delete all repositories in work directory (with confirmation)")
flag.StringVar(&f.DeleteRepo, "delete-repo", "", "delete specified repository from all configured organizations (with confirmation)")
flag.BoolVar(&f.Backup, "backup", false, "enable syncing to backup locations")
+ flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using Claude after syncing")
+ flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data")
flag.Parse()
diff --git a/internal/cli/showcase_handler.go b/internal/cli/showcase_handler.go
new file mode 100644
index 0000000..a3dfd26
--- /dev/null
+++ b/internal/cli/showcase_handler.go
@@ -0,0 +1,35 @@
+package cli
+
+import (
+ "fmt"
+ "log"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
+ "codeberg.org/snonux/gitsyncer/internal/showcase"
+)
+
+// HandleShowcase handles the showcase generation after syncing
+func HandleShowcase(cfg *config.Config, flags *Flags) int {
+ // Determine which repositories to process
+ var repoFilter []string
+ if flags.SyncRepo != "" {
+ // Only process the specific repository that was synced
+ repoFilter = []string{flags.SyncRepo}
+ fmt.Printf("\nGenerating showcase for %s...\n", flags.SyncRepo)
+ } else {
+ // Process all repositories for --sync-all or public sync operations
+ fmt.Println("\nGenerating project showcase for all repositories...")
+ }
+
+ // Create showcase generator
+ generator := showcase.New(cfg, flags.WorkDir)
+
+ // Generate showcase with optional filter
+ if err := generator.GenerateShowcase(repoFilter, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase: %v\n", err)
+ return 1
+ }
+
+ fmt.Println("Showcase generated successfully!")
+ return 0
+} \ No newline at end of file
diff --git a/internal/cli/showcase_only_handler.go b/internal/cli/showcase_only_handler.go
new file mode 100644
index 0000000..ff2a82a
--- /dev/null
+++ b/internal/cli/showcase_only_handler.go
@@ -0,0 +1,112 @@
+package cli
+
+import (
+ "fmt"
+ "log"
+
+ "codeberg.org/snonux/gitsyncer/internal/codeberg"
+ "codeberg.org/snonux/gitsyncer/internal/config"
+ "codeberg.org/snonux/gitsyncer/internal/github"
+ "codeberg.org/snonux/gitsyncer/internal/showcase"
+ "codeberg.org/snonux/gitsyncer/internal/sync"
+)
+
+// HandleShowcaseOnly handles showcase generation without syncing
+// It will clone repositories if they don't exist locally, but won't sync changes
+func HandleShowcaseOnly(cfg *config.Config, flags *Flags) int {
+ // Get all repositories from all sources
+ allRepos, err := getAllRepositories(cfg)
+ if err != nil {
+ log.Printf("ERROR: Failed to get repositories: %v\n", err)
+ return 1
+ }
+
+ if len(allRepos) == 0 {
+ fmt.Println("No repositories found")
+ return 1
+ }
+
+ fmt.Printf("Found %d repositories total\n", len(allRepos))
+
+ // Create a minimal syncer just for cloning
+ syncer := sync.New(cfg, flags.WorkDir)
+ syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode
+
+ // Ensure repositories are cloned (but not synced)
+ fmt.Println("\nEnsuring repositories are cloned locally...")
+ for _, repo := range allRepos {
+ if err := syncer.EnsureRepositoryCloned(repo); err != nil {
+ fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err)
+ // Continue with other repos
+ }
+ }
+
+ // Generate showcase for all repositories
+ fmt.Println("\nGenerating showcase for all repositories...")
+ generator := showcase.New(cfg, flags.WorkDir)
+
+ // Pass empty filter to process all repos
+ if err := generator.GenerateShowcase(nil, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase: %v\n", err)
+ return 1
+ }
+
+ fmt.Println("Showcase generation completed!")
+ return 0
+}
+
+// getAllRepositories collects all unique repository names from all sources
+func getAllRepositories(cfg *config.Config) ([]string, error) {
+ repoMap := make(map[string]bool)
+
+ // Add configured repositories
+ for _, repo := range cfg.Repositories {
+ repoMap[repo] = true
+ }
+
+ // Add Codeberg public repos if configured
+ if codebergOrg := cfg.FindCodebergOrg(); codebergOrg != nil {
+ fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name)
+ client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken)
+
+ repos, err := client.ListPublicRepos()
+ if err != nil {
+ // Try as user
+ repos, err = client.ListUserPublicRepos()
+ if err != nil {
+ fmt.Printf("Warning: Failed to fetch Codeberg repos: %v\n", err)
+ }
+ }
+
+ for _, repo := range repos {
+ repoMap[repo.Name] = true
+ }
+ }
+
+ // Add GitHub public repos if configured
+ if githubOrg := cfg.FindGitHubOrg(); githubOrg != nil {
+ fmt.Printf("Fetching public repositories from GitHub user/org: %s...\n", githubOrg.Name)
+ client := github.NewClient(githubOrg.GitHubToken, githubOrg.Name)
+
+ if client.HasToken() {
+ repos, err := client.ListPublicRepos()
+ if err != nil {
+ fmt.Printf("Warning: Failed to fetch GitHub repos: %v\n", err)
+ } else {
+ for _, repo := range repos {
+ repoMap[repo.Name] = true
+ }
+ }
+ } else {
+ fmt.Println("Warning: No GitHub token found, skipping GitHub repos")
+ }
+ }
+
+ // Convert map to slice
+ var allRepos []string
+ for repo := range repoMap {
+ allRepos = append(allRepos, repo)
+ }
+
+ return allRepos, nil
+} \ No newline at end of file
diff --git a/internal/config/config.go b/internal/config/config.go
index 7dedee7..cb27058 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,10 +19,11 @@ type Organization struct {
// Config holds the application configuration
type Config struct {
- Organizations []Organization `json:"organizations"`
- Repositories []string `json:"repositories,omitempty"`
- ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude
- WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories
+ Organizations []Organization `json:"organizations"`
+ Repositories []string `json:"repositories,omitempty"`
+ ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude
+ WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories
+ ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase
}
// Load reads and parses the configuration file
diff --git a/internal/showcase/images.go b/internal/showcase/images.go
new file mode 100644
index 0000000..fe5be72
--- /dev/null
+++ b/internal/showcase/images.go
@@ -0,0 +1,258 @@
+package showcase
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// extractImagesFromRepo extracts up to 2 images from README.md and copies them to showcase directory
+func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, error) {
+ // Look for README files
+ readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README.MD"}
+ var readmePath string
+
+ for _, filename := range readmeFiles {
+ path := filepath.Join(repoPath, filename)
+ if _, err := os.Stat(path); err == nil {
+ readmePath = path
+ break
+ }
+ }
+
+ if readmePath == "" {
+ return nil, nil // No README found, not an error
+ }
+
+ // Read README content
+ content, err := os.ReadFile(readmePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read README: %w", err)
+ }
+
+ fmt.Printf("Found README at: %s\n", readmePath)
+
+ // Extract image references
+ images := extractImageReferences(string(content))
+ fmt.Printf("Found %d images in README\n", len(images))
+ for i, img := range images {
+ fmt.Printf(" Image %d: %s\n", i+1, img)
+ }
+
+ if len(images) == 0 {
+ return nil, nil
+ }
+
+ // Limit to first and last image (max 2)
+ var selectedImages []string
+ if len(images) == 1 {
+ selectedImages = images
+ } else {
+ selectedImages = []string{images[0], images[len(images)-1]}
+ }
+
+ // Create showcase subdirectory for this repo
+ repoShowcaseDir := filepath.Join(showcaseDir, "showcase", repoName)
+ if err := os.MkdirAll(repoShowcaseDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create showcase directory: %w", err)
+ }
+
+ // Copy images and collect relative paths
+ var copiedImages []string
+ for i, imgPath := range selectedImages {
+ var destFilename string
+ var err error
+
+ if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") {
+ // Handle URL - download the image
+ // Extract extension from URL, handling query parameters
+ urlParts := strings.Split(imgPath, "?")
+ basePath := urlParts[0]
+ ext := filepath.Ext(basePath)
+ if ext == "" || len(ext) > 5 { // Likely not a real extension
+ ext = ".png" // Default extension
+ }
+ destFilename = fmt.Sprintf("image-%d%s", i+1, ext)
+ destPath := filepath.Join(repoShowcaseDir, destFilename)
+
+ if err = downloadImage(imgPath, destPath); err != nil {
+ fmt.Printf("Warning: Failed to download image %s: %v\n", imgPath, err)
+ continue
+ }
+ } else {
+ // Handle local file
+ srcPath := imgPath
+ if !filepath.IsAbs(imgPath) {
+ srcPath = filepath.Join(repoPath, imgPath)
+ }
+
+ // Check if image exists
+ if _, err := os.Stat(srcPath); err != nil {
+ fmt.Printf("Warning: Image not found: %s\n", srcPath)
+ continue
+ }
+
+ // Generate destination filename
+ ext := filepath.Ext(srcPath)
+ destFilename = fmt.Sprintf("image-%d%s", i+1, ext)
+ destPath := filepath.Join(repoShowcaseDir, destFilename)
+
+ // Copy image
+ if err := copyFile(srcPath, destPath); err != nil {
+ fmt.Printf("Warning: Failed to copy image %s: %v\n", srcPath, err)
+ continue
+ }
+ }
+
+ // Store relative path from showcase directory
+ relativePath := filepath.Join("showcase", repoName, destFilename)
+ copiedImages = append(copiedImages, relativePath)
+ fmt.Printf("Copied/Downloaded image: %s -> %s\n", imgPath, relativePath)
+ }
+
+ return copiedImages, nil
+}
+
+// extractImageReferences extracts image references from markdown content
+func extractImageReferences(content string) []string {
+ var images []string
+ seen := make(map[string]bool)
+
+ // Regex patterns for markdown images
+ patterns := []string{
+ `!\[([^\]]*)\]\(([^)]+)\)`, // ![alt](url)
+ `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url">
+ `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref]
+ `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title"
+ }
+
+ fmt.Printf("DEBUG: Content length: %d bytes\n", len(content))
+
+ // Extract from markdown image syntax
+ for i, pattern := range patterns[:2] { // First two patterns have URLs in different positions
+ re := regexp.MustCompile(pattern)
+ matches := re.FindAllStringSubmatch(content, -1)
+ fmt.Printf("DEBUG: Pattern %d (%s) found %d matches\n", i, pattern, len(matches))
+
+ for _, match := range matches {
+ var url string
+ if pattern == patterns[0] {
+ url = match[2] // For ![alt](url)
+ } else {
+ url = match[1] // For <img src="url">
+ }
+
+ // Clean and validate URL
+ url = strings.TrimSpace(url)
+ fmt.Printf("DEBUG: Found potential image URL: %s\n", url)
+
+ if isImageFile(url) {
+ fmt.Printf("DEBUG: URL is image file\n")
+ if !seen[url] {
+ // Handle different types of URLs
+ if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+ // Local file
+ fmt.Printf("DEBUG: Adding local image: %s\n", url)
+ images = append(images, url)
+ seen[url] = true
+ } else if isGitHostedImage(url) {
+ // GitHub/Codeberg hosted images - we can download these
+ fmt.Printf("DEBUG: Found git-hosted image: %s\n", url)
+ images = append(images, url)
+ seen[url] = true
+ } else {
+ fmt.Printf("DEBUG: Skipping external URL: %s\n", url)
+ }
+ }
+ } else {
+ fmt.Printf("DEBUG: Not recognized as image file: %s\n", url)
+ }
+ }
+ }
+
+ // Handle reference-style images
+ refPattern := regexp.MustCompile(patterns[3])
+ refMatches := refPattern.FindAllStringSubmatch(content, -1)
+ refs := make(map[string]string)
+ for _, match := range refMatches {
+ refs[match[1]] = strings.TrimSpace(match[2])
+ }
+
+ // Find reference-style image uses
+ refUsePattern := regexp.MustCompile(patterns[2])
+ refUseMatches := refUsePattern.FindAllStringSubmatch(content, -1)
+ for _, match := range refUseMatches {
+ ref := match[2]
+ if url, ok := refs[ref]; ok && isImageFile(url) && !seen[url] {
+ if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+ images = append(images, url)
+ seen[url] = true
+ }
+ }
+ }
+
+ return images
+}
+
+// isImageFile checks if a URL points to an image file
+func isImageFile(url string) bool {
+ url = strings.ToLower(url)
+ extensions := []string{".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
+ for _, ext := range extensions {
+ if strings.HasSuffix(url, ext) {
+ return true
+ }
+ }
+ return false
+}
+
+// isGitHostedImage checks if URL is from GitHub/Codeberg
+func isGitHostedImage(url string) bool {
+ return strings.Contains(url, "github.com") ||
+ strings.Contains(url, "githubusercontent.com") ||
+ strings.Contains(url, "codeberg.org") ||
+ strings.Contains(url, "codeberg.page")
+}
+
+// copyFile copies a file from src to dst
+func copyFile(src, dst string) error {
+ sourceFile, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, sourceFile)
+ if err != nil {
+ return err
+ }
+
+ return destFile.Sync()
+}
+
+// downloadImage downloads an image from URL to dst
+func downloadImage(url, dst string) error {
+ // Use curl to download the image
+ cmd := exec.Command("curl", "-L", "-o", dst, url)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("curl failed: %v, output: %s", err, string(output))
+ }
+
+ // Verify the file was created
+ if _, err := os.Stat(dst); err != nil {
+ return fmt.Errorf("downloaded file not found: %v", err)
+ }
+
+ return nil
+} \ No newline at end of file
diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go
new file mode 100644
index 0000000..209c7b6
--- /dev/null
+++ b/internal/showcase/metadata.go
@@ -0,0 +1,365 @@
+package showcase
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// RepoMetadata holds metadata about a repository
+type RepoMetadata struct {
+ Languages []string
+ CommitCount int
+ LinesOfCode int
+ FirstCommitDate string
+ LastCommitDate string
+ License string
+ AvgCommitAge float64 // Average age of last 100 commits in days
+}
+
+// extractRepoMetadata extracts metadata from a repository
+func extractRepoMetadata(repoPath string) (*RepoMetadata, error) {
+ metadata := &RepoMetadata{}
+
+ // Get programming languages by analyzing file extensions
+ languages, err := detectLanguages(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to detect languages: %v\n", err)
+ }
+ metadata.Languages = languages
+
+ // Get commit count
+ commitCount, err := getCommitCount(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get commit count: %v\n", err)
+ }
+ metadata.CommitCount = commitCount
+
+ // Get lines of code
+ loc, err := countLinesOfCode(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to count lines of code: %v\n", err)
+ }
+ metadata.LinesOfCode = loc
+
+ // Get first and last commit dates
+ firstDate, err := getFirstCommitDate(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get first commit date: %v\n", err)
+ }
+ metadata.FirstCommitDate = firstDate
+
+ lastDate, err := getLastCommitDate(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get last commit date: %v\n", err)
+ }
+ metadata.LastCommitDate = lastDate
+
+ // Check for license file
+ license := detectLicense(repoPath)
+ metadata.License = license
+
+ // Get average age of last 100 commits
+ avgAge, err := getAverageCommitAge(repoPath, 100)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get average commit age: %v\n", err)
+ }
+ metadata.AvgCommitAge = avgAge
+
+ return metadata, nil
+}
+
+// detectLanguages detects programming languages used in the repository
+func detectLanguages(repoPath string) ([]string, error) {
+ languageMap := make(map[string]bool)
+
+ // Define common language extensions
+ langExtensions := map[string]string{
+ ".go": "Go",
+ ".py": "Python",
+ ".js": "JavaScript",
+ ".ts": "TypeScript",
+ ".java": "Java",
+ ".c": "C",
+ ".cpp": "C++",
+ ".cc": "C++",
+ ".h": "C/C++",
+ ".hpp": "C++",
+ ".cs": "C#",
+ ".rb": "Ruby",
+ ".php": "PHP",
+ ".swift": "Swift",
+ ".kt": "Kotlin",
+ ".rs": "Rust",
+ ".scala": "Scala",
+ ".r": "R",
+ ".m": "Objective-C",
+ ".mm": "Objective-C++",
+ ".sh": "Shell",
+ ".bash": "Bash",
+ ".zsh": "Zsh",
+ ".pl": "Perl",
+ ".lua": "Lua",
+ ".vim": "Vim Script",
+ ".el": "Emacs Lisp",
+ ".clj": "Clojure",
+ ".hs": "Haskell",
+ ".ml": "OCaml",
+ ".ex": "Elixir",
+ ".exs": "Elixir",
+ ".dart": "Dart",
+ ".jl": "Julia",
+ ".nim": "Nim",
+ ".v": "V",
+ ".zig": "Zig",
+ }
+
+ // Walk through the repository
+ err := filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // Skip errors
+ }
+
+ // Skip hidden directories and common non-code directories
+ if info.IsDir() {
+ name := info.Name()
+ if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" || name == "target" || name == "dist" || name == "build" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ // Check file extension
+ ext := strings.ToLower(filepath.Ext(path))
+ if lang, ok := langExtensions[ext]; ok {
+ languageMap[lang] = true
+ }
+
+ // Check for special files
+ basename := filepath.Base(path)
+ switch strings.ToLower(basename) {
+ case "makefile", "gnumakefile":
+ languageMap["Make"] = true
+ case "dockerfile":
+ languageMap["Docker"] = true
+ case "cmakelists.txt":
+ languageMap["CMake"] = true
+ case "rakefile":
+ languageMap["Ruby"] = true
+ case "gemfile":
+ languageMap["Ruby"] = true
+ case "package.json":
+ languageMap["JavaScript/Node.js"] = true
+ case "cargo.toml":
+ languageMap["Rust"] = true
+ case "go.mod":
+ languageMap["Go"] = true
+ case "pom.xml":
+ languageMap["Java/Maven"] = true
+ case "build.gradle", "build.gradle.kts":
+ languageMap["Java/Gradle"] = true
+ case "requirements.txt", "setup.py", "pyproject.toml":
+ languageMap["Python"] = true
+ case "composer.json":
+ languageMap["PHP"] = true
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert map to slice
+ var languages []string
+ for lang := range languageMap {
+ languages = append(languages, lang)
+ }
+
+ return languages, nil
+}
+
+// getCommitCount returns the total number of commits
+func getCommitCount(repoPath string) (int, error) {
+ cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count")
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ count, err := strconv.Atoi(strings.TrimSpace(string(output)))
+ if err != nil {
+ return 0, err
+ }
+
+ return count, nil
+}
+
+// countLinesOfCode counts lines of code (excluding binary files and common non-code files)
+func countLinesOfCode(repoPath string) (int, error) {
+ // Use git ls-files to get tracked files, then count lines
+ // Exclude binary files and common non-code files
+ cmd := exec.Command("bash", "-c", fmt.Sprintf(
+ `cd "%s" && git ls-files | grep -E '\.(go|py|js|ts|java|c|cpp|h|hpp|cs|rb|php|swift|kt|rs|scala|r|sh|bash|zsh|pl|lua|vim|el|clj|hs|ml|ex|exs|dart|jl|nim|v|zig|html|css|scss|sass|json|xml|yaml|yml|toml|ini|conf|cfg)$' | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`,
+ repoPath,
+ ))
+
+ output, err := cmd.Output()
+ if err != nil {
+ // Fallback: try a simpler approach
+ cmd = exec.Command("bash", "-c", fmt.Sprintf(
+ `find "%s" -type f -name "*.go" -o -name "*.py" -o -name "*.js" -o -name "*.java" -o -name "*.c" -o -name "*.cpp" -o -name "*.rs" | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`,
+ repoPath,
+ ))
+ output, err = cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ loc, err := strconv.Atoi(strings.TrimSpace(string(output)))
+ if err != nil {
+ return 0, err
+ }
+
+ return loc, nil
+}
+
+// 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")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+
+ lines := strings.Split(string(output), "\n")
+ if len(lines) > 0 && lines[0] != "" {
+ // Extract just the date part (YYYY-MM-DD)
+ parts := strings.Fields(lines[0])
+ if len(parts) > 0 {
+ return parts[0], nil
+ }
+ }
+
+ return "", fmt.Errorf("no commits found")
+}
+
+// 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")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+
+ // Extract just the date part (YYYY-MM-DD)
+ parts := strings.Fields(string(output))
+ if len(parts) > 0 {
+ return parts[0], nil
+ }
+
+ return "", fmt.Errorf("no commits found")
+}
+
+// detectLicense checks for common license files
+func detectLicense(repoPath string) string {
+ licenseFiles := []string{
+ "LICENSE",
+ "LICENSE.txt",
+ "LICENSE.md",
+ "license",
+ "license.txt",
+ "license.md",
+ "COPYING",
+ "COPYING.txt",
+ "COPYRIGHT",
+ "COPYRIGHT.txt",
+ }
+
+ for _, filename := range licenseFiles {
+ path := filepath.Join(repoPath, filename)
+ if info, err := os.Stat(path); err == nil && !info.IsDir() {
+ // Try to detect license type by reading the file
+ content, err := os.ReadFile(path)
+ if err == nil {
+ contentStr := string(content)
+ switch {
+ case strings.Contains(contentStr, "MIT License"):
+ return "MIT"
+ case strings.Contains(contentStr, "Apache License") && strings.Contains(contentStr, "Version 2.0"):
+ return "Apache-2.0"
+ case strings.Contains(contentStr, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(contentStr, "Version 3"):
+ return "GPL-3.0"
+ case strings.Contains(contentStr, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(contentStr, "Version 2"):
+ return "GPL-2.0"
+ case strings.Contains(contentStr, "BSD 3-Clause License"):
+ return "BSD-3-Clause"
+ case strings.Contains(contentStr, "BSD 2-Clause License"):
+ return "BSD-2-Clause"
+ case strings.Contains(contentStr, "Mozilla Public License Version 2.0"):
+ return "MPL-2.0"
+ case strings.Contains(contentStr, "ISC License"):
+ return "ISC"
+ case strings.Contains(contentStr, "GNU LESSER GENERAL PUBLIC LICENSE"):
+ return "LGPL"
+ case strings.Contains(contentStr, "The Unlicense"):
+ return "Unlicense"
+ case strings.Contains(contentStr, "CC0"):
+ return "CC0"
+ default:
+ return "Custom License"
+ }
+ }
+ return "License file found"
+ }
+ }
+
+ return "No license found"
+}
+
+// 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")
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ if len(lines) == 0 || lines[0] == "" {
+ return 0, fmt.Errorf("no commits found")
+ }
+
+ // Calculate average age
+ now := float64(time.Now().Unix())
+ var totalAge float64
+ validCommits := 0
+
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+
+ timestamp, err := strconv.ParseInt(line, 10, 64)
+ if err != nil {
+ continue
+ }
+
+ age := (now - float64(timestamp)) / 86400 // Convert to days
+ totalAge += age
+ validCommits++
+ }
+
+ if validCommits == 0 {
+ return 0, fmt.Errorf("no valid commits found")
+ }
+
+ return totalAge / float64(validCommits), nil
+} \ No newline at end of file
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
new file mode 100644
index 0000000..9c5afa6
--- /dev/null
+++ b/internal/showcase/showcase.go
@@ -0,0 +1,560 @@
+package showcase
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
+)
+
+// Generator handles showcase generation for repositories
+type Generator struct {
+ config *config.Config
+ workDir string
+}
+
+// ProjectSummary holds the summary information for a project
+type ProjectSummary struct {
+ Name string
+ Summary string
+ CodebergURL string
+ GitHubURL string
+ Metadata *RepoMetadata
+ Images []string // Relative paths to images in showcase directory
+}
+
+// New creates a new showcase generator
+func New(cfg *config.Config, workDir string) *Generator {
+ return &Generator{
+ config: cfg,
+ workDir: workDir,
+ }
+}
+
+// GenerateShowcase generates a showcase for repositories
+// If repoFilter is provided, only those repositories are processed
+// If repoFilter is empty/nil, all repositories in work directory are processed
+func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) error {
+ var repos []string
+ var err error
+
+ if len(repoFilter) > 0 {
+ // Use the provided filter
+ repos = repoFilter
+ } else {
+ // Get all repositories in work directory
+ repos, err = g.getRepositories()
+ if err != nil {
+ return fmt.Errorf("failed to get repositories: %w", err)
+ }
+ }
+
+ if len(repos) == 0 {
+ return fmt.Errorf("no repositories found")
+ }
+
+ // Filter out excluded repositories
+ filteredRepos := g.filterExcludedRepos(repos)
+
+ fmt.Printf("Found %d repositories to process (after filtering %d excluded)\n",
+ len(filteredRepos), len(repos)-len(filteredRepos))
+
+ // Generate summaries for each repository
+ summaries := make([]ProjectSummary, 0, len(filteredRepos))
+ successCount := 0
+
+ for i, repo := range filteredRepos {
+ fmt.Printf("\n[%d/%d] Processing %s...\n", i+1, len(filteredRepos), repo)
+
+ summary, err := g.generateProjectSummary(repo, forceRegenerate)
+ if err != nil {
+ fmt.Printf("WARNING: Failed to generate summary for %s: %v\n", repo, err)
+ continue
+ }
+
+ // Print the generated summary to stdout
+ fmt.Printf("\n--- Generated summary for %s ---\n", repo)
+ fmt.Println(summary.Summary)
+ if summary.Metadata != nil {
+ fmt.Printf("Languages: %s\n", strings.Join(summary.Metadata.Languages, ", "))
+ fmt.Printf("Commits: %d\n", summary.Metadata.CommitCount)
+ fmt.Printf("Lines of Code: %d\n", summary.Metadata.LinesOfCode)
+ 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("Avg. age of last 100 commits: %.1f days\n", summary.Metadata.AvgCommitAge)
+ }
+ fmt.Println("--- End of summary ---")
+
+ summaries = append(summaries, *summary)
+ successCount++
+ }
+
+ if successCount == 0 {
+ return fmt.Errorf("failed to generate any summaries")
+ }
+
+ fmt.Printf("\nSuccessfully generated %d/%d summaries\n", successCount, len(repos))
+
+ // Sort summaries by average commit age (newest first)
+ sort.Slice(summaries, func(i, j int) bool {
+ // If metadata is missing, put at the end
+ if summaries[i].Metadata == nil {
+ return false
+ }
+ if summaries[j].Metadata == nil {
+ return true
+ }
+ // Lower average age means more recent activity
+ return summaries[i].Metadata.AvgCommitAge < summaries[j].Metadata.AvgCommitAge
+ })
+
+ // When filtering (single repo), we need to update existing showcase
+ if len(repoFilter) > 0 {
+ if err := g.updateShowcaseFile(summaries); err != nil {
+ return fmt.Errorf("failed to update showcase file: %w", err)
+ }
+ } else {
+ // Full regeneration - format as Gemtext and write
+ content := g.formatGemtext(summaries)
+ if err := g.writeShowcaseFile(content); err != nil {
+ return fmt.Errorf("failed to write showcase file: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// getRepositories returns a list of repository directories in the work directory
+func (g *Generator) getRepositories() ([]string, error) {
+ entries, err := os.ReadDir(g.workDir)
+ if err != nil {
+ return nil, err