summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-10-31 20:13:32 +0200
committerPaul Buetow <paul@buetow.org>2025-10-31 20:13:32 +0200
commit11eea6a82cbfdde40ec1457c6ea080da4da6b7dc (patch)
tree8026068f6a3beb3ee02c45f06f4487f4b89caaf1 /internal
parent5c3e0b5cf99d028c4f06be7a825388b296e37a22 (diff)
feat: implement amp AI tool support and replace Taskfile with Magev0.10.0
- Add amp as default AI tool for release notes and showcase generation - Fallback chain: amp → hexai → claude → aichat - Replace Taskfile.yaml with magefile.go for build automation - Update all documentation (README.md, AGENTS.md, doc/development.md) - Update version to 0.10.0 Amp-Thread-ID: https://ampcode.com/threads/T-735ba1e2-0255-4b43-8ed1-6c0d2f78301b Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/description_cache.go51
-rw-r--r--internal/cli/description_sync.go185
-rw-r--r--internal/cli/flags.go72
-rw-r--r--internal/cli/handlers.go46
-rw-r--r--internal/cli/release.go270
-rw-r--r--internal/cli/showcase_handler.go10
-rw-r--r--internal/cli/showcase_only_handler.go152
-rw-r--r--internal/cli/sync_handlers.go256
-rw-r--r--internal/cmd/list.go4
-rw-r--r--internal/cmd/manage.go30
-rw-r--r--internal/cmd/release.go26
-rw-r--r--internal/cmd/root.go21
-rw-r--r--internal/cmd/showcase.go65
-rw-r--r--internal/cmd/sync.go58
-rw-r--r--internal/cmd/test.go12
-rw-r--r--internal/codeberg/codeberg.go124
-rw-r--r--internal/config/config.go44
-rw-r--r--internal/github/github.go128
-rw-r--r--internal/release/release.go411
-rw-r--r--internal/showcase/ai_context.go338
-rw-r--r--internal/showcase/code_extractor.go228
-rw-r--r--internal/showcase/images.go78
-rw-r--r--internal/showcase/language_detector.go214
-rw-r--r--internal/showcase/metadata.go33
-rw-r--r--internal/showcase/showcase.go311
-rw-r--r--internal/state/state.go2
-rw-r--r--internal/sync/branch_analyzer.go94
-rw-r--r--internal/sync/branch_filter.go8
-rw-r--r--internal/sync/branch_sync.go12
-rw-r--r--internal/sync/git_operations.go22
-rw-r--r--internal/sync/repository_setup.go14
-rw-r--r--internal/sync/sync.go40
-rw-r--r--internal/version/version.go2
33 files changed, 1727 insertions, 1634 deletions
diff --git a/internal/cli/description_cache.go b/internal/cli/description_cache.go
index 1cfc951..a2ce9ab 100644
--- a/internal/cli/description_cache.go
+++ b/internal/cli/description_cache.go
@@ -1,38 +1,37 @@
package cli
import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
)
// loadDescriptionCache loads the per-repo canonical description cache
func loadDescriptionCache(workDir string) map[string]string {
- cache := make(map[string]string)
- cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json")
- data, err := os.ReadFile(cacheFile)
- if err != nil {
- return cache
- }
- if err := json.Unmarshal(data, &cache); err != nil {
- fmt.Printf("Warning: Failed to parse descriptions cache: %v\n", err)
- return make(map[string]string)
- }
- fmt.Printf("Loaded descriptions cache with %d entries\n", len(cache))
- return cache
+ cache := make(map[string]string)
+ cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json")
+ data, err := os.ReadFile(cacheFile)
+ if err != nil {
+ return cache
+ }
+ if err := json.Unmarshal(data, &cache); err != nil {
+ fmt.Printf("Warning: Failed to parse descriptions cache: %v\n", err)
+ return make(map[string]string)
+ }
+ fmt.Printf("Loaded descriptions cache with %d entries\n", len(cache))
+ return cache
}
// saveDescriptionCache saves the per-repo canonical description cache
func saveDescriptionCache(workDir string, cache map[string]string) error {
- cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json")
- data, err := json.MarshalIndent(cache, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal descriptions cache: %w", err)
- }
- if err := os.WriteFile(cacheFile, data, 0644); err != nil {
- return fmt.Errorf("failed to write descriptions cache: %w", err)
- }
- return nil
+ cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json")
+ data, err := json.MarshalIndent(cache, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal descriptions cache: %w", err)
+ }
+ if err := os.WriteFile(cacheFile, data, 0644); err != nil {
+ return fmt.Errorf("failed to write descriptions cache: %w", err)
+ }
+ return nil
}
-
diff --git a/internal/cli/description_sync.go b/internal/cli/description_sync.go
index ae275ce..4904b56 100644
--- a/internal/cli/description_sync.go
+++ b/internal/cli/description_sync.go
@@ -1,113 +1,112 @@
package cli
import (
- "fmt"
- "strings"
+ "fmt"
+ "strings"
- "codeberg.org/snonux/gitsyncer/internal/codeberg"
- "codeberg.org/snonux/gitsyncer/internal/config"
- "codeberg.org/snonux/gitsyncer/internal/github"
+ "codeberg.org/snonux/gitsyncer/internal/codeberg"
+ "codeberg.org/snonux/gitsyncer/internal/config"
+ "codeberg.org/snonux/gitsyncer/internal/github"
)
// syncRepoDescriptions ensures both platforms have the canonical description
// Precedence: Codeberg > GitHub; if Codeberg empty and GitHub has one, use GitHub.
// knownCBDesc and knownGHDesc can be empty; the function fetches as needed.
func syncRepoDescriptions(cfg *config.Config, dryRun bool, repoName, knownCBDesc, knownGHDesc string, cache map[string]string) {
- // Load orgs
- ghOrg := cfg.FindGitHubOrg()
- cbOrg := cfg.FindCodebergOrg()
+ // Load orgs
+ ghOrg := cfg.FindGitHubOrg()
+ cbOrg := cfg.FindCodebergOrg()
- var ghClient *github.Client
- var cbClient *codeberg.Client
- if ghOrg != nil {
- c := github.NewClient(ghOrg.GitHubToken, ghOrg.Name)
- ghClient = &c
- }
- if cbOrg != nil {
- c := codeberg.NewClient(cbOrg.Name, cbOrg.CodebergToken)
- cbClient = &c
- }
+ var ghClient *github.Client
+ var cbClient *codeberg.Client
+ if ghOrg != nil {
+ c := github.NewClient(ghOrg.GitHubToken, ghOrg.Name)
+ ghClient = &c
+ }
+ if cbOrg != nil {
+ c := codeberg.NewClient(cbOrg.Name, cbOrg.CodebergToken)
+ cbClient = &c
+ }
- // Get current descriptions (use known if provided)
- cbDesc := strings.TrimSpace(knownCBDesc)
- ghDesc := strings.TrimSpace(knownGHDesc)
- var cbExists, ghExists bool
+ // Get current descriptions (use known if provided)
+ cbDesc := strings.TrimSpace(knownCBDesc)
+ ghDesc := strings.TrimSpace(knownGHDesc)
+ var cbExists, ghExists bool
- if cbDesc == "" && cbClient != nil {
- if repo, exists, err := cbClient.GetRepo(repoName); err == nil {
- cbExists = exists
- if exists {
- cbDesc = strings.TrimSpace(repo.Description)
- }
- } else {
- fmt.Printf(" Warning: Codeberg repo lookup failed: %v\n", err)
- }
- } else if cbClient != nil {
- cbExists = true
- }
+ if cbDesc == "" && cbClient != nil {
+ if repo, exists, err := cbClient.GetRepo(repoName); err == nil {
+ cbExists = exists
+ if exists {
+ cbDesc = strings.TrimSpace(repo.Description)
+ }
+ } else {
+ fmt.Printf(" Warning: Codeberg repo lookup failed: %v\n", err)
+ }
+ } else if cbClient != nil {
+ cbExists = true
+ }
- if ghClient != nil {
- if ghDesc == "" || !ghExists {
- if repo, exists, err := ghClient.GetRepo(repoName); err == nil {
- ghExists = exists
- if exists {
- ghDesc = strings.TrimSpace(repo.Description)
- }
- } else {
- fmt.Printf(" Warning: GitHub repo lookup failed: %v\n", err)
- }
- }
- }
+ if ghClient != nil {
+ if ghDesc == "" || !ghExists {
+ if repo, exists, err := ghClient.GetRepo(repoName); err == nil {
+ ghExists = exists
+ if exists {
+ ghDesc = strings.TrimSpace(repo.Description)
+ }
+ } else {
+ fmt.Printf(" Warning: GitHub repo lookup failed: %v\n", err)
+ }
+ }
+ }
- // Determine canonical description
- canonical := cbDesc
- if canonical == "" {
- canonical = ghDesc
- }
- canonical = strings.TrimSpace(canonical)
+ // Determine canonical description
+ canonical := cbDesc
+ if canonical == "" {
+ canonical = ghDesc
+ }
+ canonical = strings.TrimSpace(canonical)
- // If nothing to sync, bail
- if canonical == "" {
- return
- }
+ // If nothing to sync, bail
+ if canonical == "" {
+ return
+ }
- // Update Codeberg if needed
- if cbClient != nil && cbExists {
- if cbDesc != canonical {
- if dryRun {
- fmt.Printf(" [DRY RUN] Would update Codeberg description for %s -> %q\n", repoName, canonical)
- } else if cbClient.HasToken() {
- if err := cbClient.UpdateRepoDescription(repoName, canonical); err != nil {
- fmt.Printf(" Warning: Failed to update Codeberg description: %v\n", err)
- } else {
- fmt.Printf(" Updated Codeberg description for %s\n", repoName)
- }
- } else {
- fmt.Println(" Warning: No Codeberg token; cannot update description")
- }
- }
- }
+ // Update Codeberg if needed
+ if cbClient != nil && cbExists {
+ if cbDesc != canonical {
+ if dryRun {
+ fmt.Printf(" [DRY RUN] Would update Codeberg description for %s -> %q\n", repoName, canonical)
+ } else if cbClient.HasToken() {
+ if err := cbClient.UpdateRepoDescription(repoName, canonical); err != nil {
+ fmt.Printf(" Warning: Failed to update Codeberg description: %v\n", err)
+ } else {
+ fmt.Printf(" Updated Codeberg description for %s\n", repoName)
+ }
+ } else {
+ fmt.Println(" Warning: No Codeberg token; cannot update description")
+ }
+ }
+ }
- // Update GitHub if needed
- if ghClient != nil && ghExists {
- if ghDesc != canonical {
- if dryRun {
- fmt.Printf(" [DRY RUN] Would update GitHub description for %s -> %q\n", repoName, canonical)
- } else if ghClient.HasToken() {
- if err := ghClient.UpdateRepoDescription(repoName, canonical); err != nil {
- fmt.Printf(" Warning: Failed to update GitHub description: %v\n", err)
- } else {
- fmt.Printf(" Updated GitHub description for %s\n", repoName)
- }
- } else {
- fmt.Println(" Warning: No GitHub token; cannot update description")
- }
- }
- }
+ // Update GitHub if needed
+ if ghClient != nil && ghExists {
+ if ghDesc != canonical {
+ if dryRun {
+ fmt.Printf(" [DRY RUN] Would update GitHub description for %s -> %q\n", repoName, canonical)
+ } else if ghClient.HasToken() {
+ if err := ghClient.UpdateRepoDescription(repoName, canonical); err != nil {
+ fmt.Printf(" Warning: Failed to update GitHub description: %v\n", err)
+ } else {
+ fmt.Printf(" Updated GitHub description for %s\n", repoName)
+ }
+ } else {
+ fmt.Println(" Warning: No GitHub token; cannot update description")
+ }
+ }
+ }
- // Update cache
- if cache != nil {
- cache[repoName] = canonical
- }
+ // Update cache
+ if cache != nil {
+ cache[repoName] = canonical
+ }
}
-
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 43d9fa3..5c6914c 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -4,39 +4,39 @@ import (
"flag"
"os"
"path/filepath"
-
+
"codeberg.org/snonux/gitsyncer/internal/state"
)
// Flags holds all command-line flag values
type Flags struct {
- VersionFlag bool
- ConfigPath string
- ListOrgs bool
- ListRepos bool
- SyncRepo string
- SyncAll bool
- SyncCodebergPublic bool
- SyncGitHubPublic bool
- FullSync bool
- CreateGitHubRepos bool
+ VersionFlag bool
+ ConfigPath string
+ ListOrgs bool
+ ListRepos bool
+ SyncRepo string
+ SyncAll bool
+ SyncCodebergPublic bool
+ SyncGitHubPublic bool
+ FullSync bool
+ CreateGitHubRepos bool
CreateCodebergRepos bool
- DryRun bool
- WorkDir string
- TestGitHubToken bool
- Clean bool
- DeleteRepo string
- Backup bool
- Showcase bool
- Force bool
- BatchRun bool
- CheckReleases bool
- NoCheckReleases bool
- AutoCreateReleases bool
- AIReleaseNotes bool
- UpdateReleases bool
- AITool string
-
+ DryRun bool
+ WorkDir string
+ TestGitHubToken bool
+ Clean bool
+ DeleteRepo string
+ Backup bool
+ Showcase bool
+ Force bool
+ BatchRun bool
+ CheckReleases bool
+ NoCheckReleases bool
+ AutoCreateReleases bool
+ AIReleaseNotes bool
+ UpdateReleases bool
+ AITool string
+
// Internal fields for batch run state management (not set by flags)
BatchRunStateManager *state.Manager
BatchRunState *state.State
@@ -45,7 +45,7 @@ type Flags struct {
// ParseFlags parses command-line flags and returns the flags struct
func ParseFlags() *Flags {
f := &Flags{}
-
+
flag.BoolVar(&f.VersionFlag, "version", false, "print version information")
flag.BoolVar(&f.VersionFlag, "v", false, "print version information (short)")
flag.StringVar(&f.ConfigPath, "config", "", "path to configuration file")
@@ -65,17 +65,17 @@ 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.Showcase, "showcase", false, "generate project showcase using AI (amp by default) after syncing")
flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data")
flag.BoolVar(&f.BatchRun, "batch-run", false, "enable --full and --showcase (runs only once per week)")
flag.BoolVar(&f.CheckReleases, "check-releases", false, "manually check for version tags without releases and create them (with confirmation)")
flag.BoolVar(&f.NoCheckReleases, "no-check-releases", false, "disable automatic release checking after sync operations")
flag.BoolVar(&f.AutoCreateReleases, "auto-create-releases", false, "automatically create releases without confirmation prompts")
- flag.BoolVar(&f.AIReleaseNotes, "ai-release-notes", false, "generate release notes using Claude AI based on git diff")
+ flag.BoolVar(&f.AIReleaseNotes, "ai-release-notes", false, "generate release notes using AI (amp by default) based on git diff")
flag.BoolVar(&f.UpdateReleases, "update-releases", false, "update existing releases with new AI-generated notes")
-
+
flag.Parse()
-
+
// Set default WorkDir if not provided
if f.WorkDir == "" {
home, err := os.UserHomeDir()
@@ -86,7 +86,7 @@ func ParseFlags() *Flags {
f.WorkDir = ".gitsyncer-work"
}
}
-
+
// Handle --full flag by enabling all sync operations
if f.FullSync {
f.SyncCodebergPublic = true
@@ -94,7 +94,7 @@ func ParseFlags() *Flags {
f.CreateGitHubRepos = true
f.CreateCodebergRepos = true
}
-
+
// Handle --batch-run flag by enabling --full and --showcase
if f.BatchRun {
f.FullSync = true
@@ -105,6 +105,6 @@ func ParseFlags() *Flags {
f.CreateGitHubRepos = true
f.CreateCodebergRepos = true
}
-
+
return f
-} \ No newline at end of file
+}
diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go
index 02df7ae..aa43a86 100644
--- a/internal/cli/handlers.go
+++ b/internal/cli/handlers.go
@@ -28,7 +28,7 @@ func HandleTestGitHubToken() int {
fmt.Println("Please set GITHUB_TOKEN environment variable or create ~/.gitsyncer_github_token file")
return 1
}
-
+
// Test the token by checking a known repo
exists, err := client.RepoExists("gitsyncer")
if err != nil {
@@ -41,7 +41,7 @@ func HandleTestGitHubToken() int {
}
return 1
}
-
+
fmt.Printf("SUCCESS: Token is valid! Repository check returned: %v\n", exists)
return 0
}
@@ -54,7 +54,7 @@ func LoadConfig(configPath string) (*config.Config, error) {
return nil, fmt.Errorf("no configuration file found")
}
}
-
+
fmt.Printf("Loaded configuration from: %s\n", configPath)
return config.Load(configPath)
}
@@ -78,14 +78,14 @@ func findDefaultConfigPath() string {
return loc
}
}
-
+
return ""
}
// ShowConfigHelp displays help for creating a configuration file
func ShowConfigHelp() {
home, _ := os.UserHomeDir()
-
+
fmt.Println("No configuration file found. Please create one of:")
fmt.Printf(" - ./gitsyncer.json\n")
fmt.Printf(" - %s/.config/gitsyncer/config.json\n", home)
@@ -135,7 +135,7 @@ func HandleListRepos(cfg *config.Config) int {
// ShowUsage displays the usage information
func ShowUsage(cfg *config.Config) {
fmt.Println("\ngitsyncer - Git repository synchronization tool")
- fmt.Printf("Configured with %d organization(s) and %d repository(ies)\n",
+ fmt.Printf("Configured with %d organization(s) and %d repository(ies)\n",
len(cfg.Organizations), len(cfg.Repositories))
fmt.Println("\nUsage:")
fmt.Println(" gitsyncer --sync <repo-name> Sync a specific repository")
@@ -166,18 +166,18 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int {
}
fmt.Printf("\n⚠️ WARNING: This will permanently delete the repository '%s' from all configured organizations!\n\n", repoName)
-
+
// Find organizations where the repo exists
var orgsWithRepo []struct {
org config.Organization
exists bool
err error
}
-
+
for _, org := range cfg.Organizations {
var exists bool
var err error
-
+
switch org.Host {
case "git@github.com":
client := github.NewClient(org.GitHubToken, org.Name)
@@ -189,14 +189,14 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int {
fmt.Printf("Skipping unsupported host: %s\n", org.Host)
continue
}
-
+
orgsWithRepo = append(orgsWithRepo, struct {
org config.Organization
exists bool
err error
}{org, exists, err})
}
-
+
// Show summary of where the repo exists
fmt.Println("Repository status:")
foundAny := false
@@ -210,36 +210,36 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int {
fmt.Printf(" ⬜ %s: Not found\n", info.org.GetGitURL())
}
}
-
+
if !foundAny {
fmt.Printf("\nRepository '%s' not found in any configured organization.\n", repoName)
return 0
}
-
+
// Confirm deletion
fmt.Printf("\nAre you sure you want to delete '%s' from the above organizations? This action cannot be undone!\n", repoName)
fmt.Print("Type 'yes' to confirm: ")
-
+
reader := bufio.NewReader(os.Stdin)
confirmation, _ := reader.ReadString('\n')
confirmation = strings.TrimSpace(confirmation)
-
+
if confirmation != "yes" {
fmt.Println("Deletion cancelled.")
return 0
}
-
+
// Perform deletions
fmt.Println("\nDeleting repositories...")
hasError := false
-
+
for _, info := range orgsWithRepo {
if !info.exists || info.err != nil {
continue
}
-
+
fmt.Printf(" Deleting from %s... ", info.org.GetGitURL())
-
+
var deleteErr error
switch info.org.Host {
case "git@github.com":
@@ -249,7 +249,7 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int {
client := codeberg.NewClient(info.org.Name, info.org.CodebergToken)
deleteErr = client.DeleteRepo(repoName)
}
-
+
if deleteErr != nil {
fmt.Printf("FAILED: %v\n", deleteErr)
hasError = true
@@ -257,12 +257,12 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int {
fmt.Println("SUCCESS")
}
}
-
+
if hasError {
fmt.Println("\n⚠️ Some deletions failed. Check the errors above.")
return 1
}
-
+
fmt.Printf("\n✅ Repository '%s' has been successfully deleted from all organizations.\n", repoName)
return 0
-} \ No newline at end of file
+}
diff --git a/internal/cli/release.go b/internal/cli/release.go
index 86cc5f9..e6dd057 100644
--- a/internal/cli/release.go
+++ b/internal/cli/release.go
@@ -29,7 +29,7 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int {
fmt.Printf("Error reading work directory %s: %v\n", flags.WorkDir, err)
return 1
}
-
+
var repositories []string
for _, entry := range entries {
if entry.IsDir() {
@@ -40,12 +40,12 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int {
}
}
}
-
+
if len(repositories) == 0 {
fmt.Println("No repositories found in work directory")
return 1
}
-
+
fmt.Printf("Found %d repositories in work directory\n", len(repositories))
return HandleCheckReleasesForRepos(cfg, flags, repositories)
}
@@ -60,23 +60,23 @@ func HandleCheckReleasesForRepo(cfg *config.Config, flags *Flags, repoName strin
func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories []string) int {
releaseManager := release.NewManager(flags.WorkDir)
releaseManager.SetAITool(flags.AITool)
-
+
// Load persistent AI release notes cache
cacheFile := filepath.Join(flags.WorkDir, ".gitsyncer-ai-release-notes-cache.json")
aiReleaseNotesCache := loadAIReleaseNotesCache(cacheFile)
initialCacheSize := len(aiReleaseNotesCache)
-
+
// Track failed AI generations
failedAIGenerations := []string{}
-
+
// Print summary at the end
defer func() {
if len(aiReleaseNotesCache) > initialCacheSize {
- fmt.Printf("\nAI release notes cache updated: %d new entries added (total: %d entries)\n",
+ fmt.Printf("\nAI release notes cache updated: %d new entries added (total: %d entries)\n",
len(aiReleaseNotesCache)-initialCacheSize, len(aiReleaseNotesCache))
fmt.Printf("Cache file: %s\n", cacheFile)
}
-
+
if len(failedAIGenerations) > 0 {
fmt.Printf("\n⚠️ AI release notes generation failed for %d releases:\n", len(failedAIGenerations))
for _, failed := range failedAIGenerations {
@@ -86,12 +86,12 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
fmt.Println("Run again to retry generation for these releases.")
}
}()
-
+
// Set tokens from config with fallback to environment variables and files
githubOrg := cfg.FindGitHubOrg()
if githubOrg != nil {
fmt.Printf("Found GitHub org: %s\n", githubOrg.Name)
-
+
// Try config token first, then fallback to env var and file
token := githubOrg.GitHubToken
if token == "" {
@@ -109,7 +109,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
}
}
}
-
+
if token != "" {
releaseManager.SetGitHubToken(token)
} else {
@@ -118,11 +118,11 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
fmt.Println("No GitHub organization found in config")
}
-
+
codebergOrg := cfg.FindCodebergOrg()
if codebergOrg != nil {
fmt.Printf("Found Codeberg org: %s\n", codebergOrg.Name)
-
+
// Try config token first, then fallback to env var and file
token := codebergOrg.CodebergToken
if token == "" {
@@ -140,7 +140,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
}
}
}
-
+
if token != "" {
releaseManager.SetCodebergToken(token)
fmt.Printf(" Codeberg token loaded (length: %d)\n", len(token))
@@ -150,114 +150,114 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
fmt.Println("No Codeberg organization found in config")
}
-
- // Process the specified repositories
- for _, repoName := range repositories {
- fmt.Printf("\nChecking releases for repository: %s\n", repoName)
-
+
+ // Process the specified repositories
+ for _, repoName := range repositories {
+ fmt.Printf("\nChecking releases for repository: %s\n", repoName)
+
// Check if the repository is cloned locally
repoPath := filepath.Join(flags.WorkDir, repoName)
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
fmt.Printf(" Repository not found locally at %s, skipping...\n", repoPath)
continue
}
-
+
// Get local tags
localTags, err := releaseManager.GetLocalTags(repoPath)
if err != nil {
fmt.Printf(" Error getting local tags: %v\n", err)
continue
}
-
+
if len(localTags) == 0 {
fmt.Println(" No version tags found")
continue
}
-
- fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", "))
- // Log configured skip rules for this repo, if any
- if cfg.SkipReleases != nil {
- if skipTags, ok := cfg.SkipReleases[repoName]; ok && len(skipTags) > 0 {
- fmt.Printf(" Config skip_releases for %s: %s\n", repoName, strings.Join(skipTags, ", "))
- }
- }
-
+
+ fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", "))
+ // Log configured skip rules for this repo, if any
+ if cfg.SkipReleases != nil {
+ if skipTags, ok := cfg.SkipReleases[repoName]; ok && len(skipTags) > 0 {
+ fmt.Printf(" Config skip_releases for %s: %s\n", repoName, strings.Join(skipTags, ", "))
+ }
+ }
+
// Check GitHub releases if GitHub is configured
var missingGitHub []string
githubOrg := cfg.FindGitHubOrg()
- if githubOrg != nil && githubOrg.Name != "" {
- githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName)
- if err != nil {
- fmt.Printf(" Error checking GitHub releases: %v\n", err)
- } else {
- missingGitHub = releaseManager.FindMissingReleases(localTags, githubReleases)
- // Filter out tags that should be skipped per config
- if len(missingGitHub) > 0 {
- var filtered []string
- var skipped []string
- for _, t := range missingGitHub {
- if cfg.ShouldSkipRelease(repoName, t) {
- skipped = append(skipped, t)
- } else {
- filtered = append(filtered, t)
- }
- }
- if len(skipped) > 0 {
- fmt.Printf(" Skipping GitHub releases per config for tags: %s\n", strings.Join(skipped, ", "))
- }
- missingGitHub = filtered
- if len(missingGitHub) > 0 {
- fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", "))
- }
- }
- }
- }
-
+ if githubOrg != nil && githubOrg.Name != "" {
+ githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName)
+ if err != nil {
+ fmt.Printf(" Error checking GitHub releases: %v\n", err)
+ } else {
+ missingGitHub = releaseManager.FindMissingReleases(localTags, githubReleases)
+ // Filter out tags that should be skipped per config
+ if len(missingGitHub) > 0 {
+ var filtered []string
+ var skipped []string
+ for _, t := range missingGitHub {
+ if cfg.ShouldSkipRelease(repoName, t) {
+ skipped = append(skipped, t)
+ } else {
+ filtered = append(filtered, t)
+ }
+ }
+ if len(skipped) > 0 {
+ fmt.Printf(" Skipping GitHub releases per config for tags: %s\n", strings.Join(skipped, ", "))
+ }
+ missingGitHub = filtered
+ if len(missingGitHub) > 0 {
+ fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", "))
+ }
+ }
+ }
+ }
+
// Check Codeberg releases if Codeberg is configured
var missingCodeberg []string
codebergOrg := cfg.FindCodebergOrg()
- if codebergOrg != nil && codebergOrg.Name != "" {
- codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName)
- if err != nil {
- fmt.Printf(" Error checking Codeberg releases: %v\n", err)
- } else {
- missingCodeberg = releaseManager.FindMissingReleases(localTags, codebergReleases)
- // Filter out tags that should be skipped per config
- if len(missingCodeberg) > 0 {
- var filtered []string
- var skipped []string
- for _, t := range missingCodeberg {
- if cfg.ShouldSkipRelease(repoName, t) {
- skipped = append(skipped, t)
- } else {
- filtered = append(filtered, t)
- }
- }
- if len(skipped) > 0 {
- fmt.Printf(" Skipping Codeberg releases per config for tags: %s\n", strings.Join(skipped, ", "))
- }
- missingCodeberg = filtered
- if len(missingCodeberg) > 0 {
- fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", "))
- }
- }
- }
- }
-
+ if codebergOrg != nil && codebergOrg.Name != "" {
+ codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName)
+ if err != nil {
+ fmt.Printf(" Error checking Codeberg releases: %v\n", err)
+ } else {
+ missingCodeberg = releaseManager.FindMissingReleases(localTags, codebergReleases)
+ // Filter out tags that should be skipped per config
+ if len(missingCodeberg) > 0 {
+ var filtered []string
+ var skipped []string
+ for _, t := range missingCodeberg {
+ if cfg.ShouldSkipRelease(repoName, t) {
+ skipped = append(skipped, t)
+ } else {
+ filtered = append(filtered, t)
+ }
+ }
+ if len(skipped) > 0 {
+ fmt.Printf(" Skipping Codeberg releases per config for tags: %s\n", strings.Join(skipped, ", "))
+ }
+ missingCodeberg = filtered
+ if len(missingCodeberg) > 0 {
+ fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", "))
+ }
+ }
+ }
+ }
+
// Create missing releases with confirmation
- if len(missingGitHub) > 0 && githubOrg != nil {
- for _, tag := range missingGitHub {
- // Skip if configured to skip this repo/tag
- if cfg.ShouldSkipRelease(repoName, tag) {
- fmt.Printf(" Skipping GitHub release for %s:%s per config skip_releases\n", repoName, tag)
- continue
- }
+ if len(missingGitHub) > 0 && githubOrg != nil {
+ for _, tag := range missingGitHub {
+ // Skip if configured to skip this repo/tag
+ if cfg.ShouldSkipRelease(repoName, tag) {
+ fmt.Printf(" Skipping GitHub release for %s:%s per config skip_releases\n", repoName, tag)
+ continue
+ }
// Get commits for this tag
commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag)
if err != nil {
commits = []string{}
}
-
+
// Generate release notes
var releaseNotes string
if flags.AIReleaseNotes {
@@ -295,16 +295,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags)
}
-
+
// Print release notes to stdout
fmt.Printf("\n%s\n", strings.Repeat("=", 70))
fmt.Printf("Release Notes for %s/%s tag %s:\n", githubOrg.Name, repoName, tag)
fmt.Printf("%s\n", strings.Repeat("-", 70))
fmt.Println(releaseNotes)
fmt.Printf("%s\n\n", strings.Repeat("=", 70))
-
+
msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag)
-
+
// Check if auto-create is enabled
createRelease := false
if flags.AutoCreateReleases {
@@ -313,7 +313,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
createRelease = release.PromptConfirmation(msg)
}
-
+
if createRelease {
if err := releaseManager.CreateGitHubRelease(githubOrg.Name, repoName, tag, releaseNotes); err != nil {
fmt.Printf(" Error creating GitHub release: %v\n", err)
@@ -323,24 +323,24 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
}
}
}
-
- if len(missingCodeberg) > 0 && codebergOrg != nil {
- // Ensure Releases feature is enabled on Codeberg before creating releases
- if err := releaseManager.EnsureCodebergReleasesEnabled(codebergOrg.Name, repoName); err != nil {
- fmt.Printf(" Warning: Could not ensure Codeberg releases are enabled: %v\n", err)
- }
- for _, tag := range missingCodeberg {
- // Skip if configured to skip this repo/tag
- if cfg.ShouldSkipRelease(repoName, tag) {
- fmt.Printf(" Skipping Codeberg release for %s:%s per config skip_releases\n", repoName, tag)
- continue
- }
+
+ if len(missingCodeberg) > 0 && codebergOrg != nil {
+ // Ensure Releases feature is enabled on Codeberg before creating releases
+ if err := releaseManager.EnsureCodebergReleasesEnabled(codebergOrg.Name, repoName); err != nil {
+ fmt.Printf(" Warning: Could not ensure Codeberg releases are enabled: %v\n", err)
+ }
+ for _, tag := range missingCodeberg {
+ // Skip if configured to skip this repo/tag
+ if cfg.ShouldSkipRelease(repoName, tag) {
+ fmt.Printf(" Skipping Codeberg release for %s:%s per config skip_releases\n", repoName, tag)
+ continue
+ }
// Get commits for this tag
commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag)
if err != nil {
commits = []string{}
}
-
+
// Generate release notes
var releaseNotes string
if flags.AIReleaseNotes {
@@ -378,16 +378,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags)
}
-
+
// Print release notes to stdout
fmt.Printf("\n%s\n", strings.Repeat("=", 70))
fmt.Printf("Release Notes for %s/%s tag %s:\n", codebergOrg.Name, repoName, tag)
fmt.Printf("%s\n", strings.Repeat("-", 70))
fmt.Println(releaseNotes)
fmt.Printf("%s\n\n", strings.Repeat("=", 70))
-
+
msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag)
-
+
// Check if auto-create is enabled
createRelease := false
if flags.AutoCreateReleases {
@@ -396,7 +396,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
createRelease = release.PromptConfirmation(msg)
}
-
+
if createRelease {
if err := releaseManager.CreateCodebergRelease(codebergOrg.Name, repoName, tag, releaseNotes); err != nil {
fmt.Printf(" Error creating Codeberg release: %v\n", err)
@@ -406,7 +406,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
}
}
}
-
+
// Update existing releases if requested
if flags.UpdateReleases {
// Update GitHub releases
@@ -419,13 +419,13 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
if !isVersionTag(tag) {
continue
}
-
+
// Get commits for this tag
commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag)
if err != nil {
commits = []string{}
}
-
+
// Generate AI release notes
if flags.AIReleaseNotes {
// Check cache first (unless --force is used)
@@ -464,16 +464,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
fmt.Printf(" Warning: Failed to save cache: %v\n", err)
}
}
-
+
// Print release notes to stdout
fmt.Printf("\n%s\n", strings.Repeat("=", 70))
fmt.Printf("Updated Release Notes for %s/%s tag %s:\n", githubOrg.Name, repoName, tag)
fmt.Printf("%s\n", strings.Repeat("-", 70))
fmt.Println(aiNotes)
fmt.Printf("%s\n\n", strings.Repeat("=", 70))
-
+
msg := fmt.Sprintf("Update GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag)
-
+
updateRelease := false
if flags.AutoCreateReleases {
fmt.Printf(" Auto-updating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag)
@@ -481,7 +481,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
updateRelease = release.PromptConfirmation(msg)
}
-
+
if updateRelease {
if err := releaseManager.UpdateGitHubRelease(githubOrg.Name, repoName, tag, aiNotes); err != nil {
fmt.Printf(" Error updating GitHub release: %v\n", err)
@@ -493,7 +493,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
}
}
}
-
+
// Update Codeberg releases
if codebergOrg != nil && codebergOrg.Name != "" {
codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName)
@@ -504,13 +504,13 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
if !isVersionTag(tag) {
continue
}
-
+
// Get commits for this tag
commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag)
if err != nil {
commits = []string{}
}
-
+
// Generate AI release notes
if flags.AIReleaseNotes {
// Check cache first (unless --force is used)
@@ -549,16 +549,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
fmt.Printf(" Warning: Failed to save cache: %v\n", err)
}
}
-
+
// Print release notes to stdout
fmt.Printf("\n%s\n", strings.Repeat("=", 70))
fmt.Printf("Updated Release Notes for %s/%s tag %s:\n", codebergOrg.Name, repoName, tag)
fmt.Printf("%s\n", strings.Repeat("-", 70))
fmt.Println(aiNotes)
fmt.Printf("%s\n\n", strings.Repeat("=", 70))
-
+
msg := fmt.Sprintf("Update Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag)
-
+
updateRelease := false
if flags.AutoCreateReleases {
fmt.Printf(" Auto-updating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag)
@@ -566,7 +566,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
} else {
updateRelease = release.PromptConfirmation(msg)
}
-
+
if updateRelease {
if err := releaseManager.UpdateCodebergRelease(codebergOrg.Name, repoName, tag, aiNotes); err != nil {
fmt.Printf(" Error updating Codeberg release: %v\n", err)
@@ -580,25 +580,25 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories
}
}
}
-
+
return 0
}
// loadAIReleaseNotesCache loads the AI release notes cache from disk
func loadAIReleaseNotesCache(cacheFile string) map[string]string {
cache := make(map[string]string)
-
+
data, err := os.ReadFile(cacheFile)
if err != nil {
// Cache file doesn't exist yet, return empty cache
return cache
}
-
+
if err := json.Unmarshal(data, &cache); err != nil {
fmt.Printf("Warning: Failed to parse AI release notes cache: %v\n", err)
return make(map[string]string)
}
-
+
fmt.Printf("Loaded AI release notes cache with %d entries\n", len(cache))
return cache
}
@@ -609,11 +609,11 @@ func saveAIReleaseNotesCache(cacheFile string, cache map[string]string) error {
if err != nil {
return fmt.Errorf("failed to marshal cache: %w", err)
}
-
+
if err := os.WriteFile(cacheFile, data, 0644); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}
-
+
// Don't print on every save since we save after each generation
return nil
}
diff --git a/internal/cli/showcase_handler.go b/internal/cli/showcase_handler.go
index 929ce95..642313f 100644
--- a/internal/cli/showcase_handler.go
+++ b/internal/cli/showcase_handler.go
@@ -20,21 +20,21 @@ func HandleShowcase(cfg *config.Config, flags *Flags) int {
// 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)
-
+
// Set AI tool if specified
if flags.AITool != "" {
generator.SetAITool(flags.AITool)
}
-
+
// 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
index 2055612..1a2a835 100644
--- a/internal/cli/showcase_only_handler.go
+++ b/internal/cli/showcase_only_handler.go
@@ -14,90 +14,90 @@ import (
// 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 {
- // If a specific repo is requested, only generate for that repo
- if flags.SyncRepo != "" {
- repo := flags.SyncRepo
-
- // Ensure the repository is cloned
- syncer := sync.New(cfg, flags.WorkDir)
- syncer.SetBackupEnabled(false)
- if err := syncer.EnsureRepositoryCloned(repo); err != nil {
- fmt.Printf("ERROR: Failed to clone %s: %v\n", repo, err)
- return 1
- }
-
- // Generate showcase for just this repository
- fmt.Printf("\nGenerating showcase for repository: %s...\n", repo)
- generator := showcase.New(cfg, flags.WorkDir)
- if flags.AITool != "" {
- generator.SetAITool(flags.AITool)
- }
- if err := generator.GenerateShowcase([]string{repo}, flags.Force); err != nil {
- log.Printf("ERROR: Failed to generate showcase for %s: %v\n", repo, err)
- return 1
- }
- fmt.Println("Showcase generation completed!")
- return 0
- }
-
- // Otherwise, process all repositories
- 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)
-
- // Set AI tool if specified
- if flags.AITool != "" {
- generator.SetAITool(flags.AITool)
- }
-
- // 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
+ // If a specific repo is requested, only generate for that repo
+ if flags.SyncRepo != "" {
+ repo := flags.SyncRepo
+
+ // Ensure the repository is cloned
+ syncer := sync.New(cfg, flags.WorkDir)
+ syncer.SetBackupEnabled(false)
+ if err := syncer.EnsureRepositoryCloned(repo); err != nil {
+ fmt.Printf("ERROR: Failed to clone %s: %v\n", repo, err)
+ return 1
+ }
+
+ // Generate showcase for just this repository
+ fmt.Printf("\nGenerating showcase for repository: %s...\n", repo)
+ generator := showcase.New(cfg, flags.WorkDir)
+ if flags.AITool != "" {
+ generator.SetAITool(flags.AITool)
+ }
+ if err := generator.GenerateShowcase([]string{repo}, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase for %s: %v\n", repo, err)
+ return 1
+ }
+ fmt.Println("Showcase generation completed!")
+ return 0
+ }
+
+ // Otherwise, process all repositories
+ 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)
+
+ // Set AI tool if specified
+ if flags.AITool != "" {
+ generator.SetAITool(flags.AITool)
+ }
+
+ // 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
@@ -106,17 +106,17 @@ func getAllRepositories(cfg *config.Config) ([]string, error) {
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 {
@@ -130,12 +130,12 @@ func getAllRepositories(cfg *config.Config) ([]string, error) {
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
}
diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go
index 09b993a..5c0c9bf 100644
--- a/internal/cli/sync_handlers.go
+++ b/internal/cli/sync_handlers.go
@@ -20,7 +20,7 @@ func HandleSync(cfg *config.Config, flags *Flags) int {
return 1
}
}
-
+
// If create-codeberg-repos is enabled, create the repo if needed
if flags.CreateCodebergRepos {
if err := createCodebergRepoIfNeeded(cfg, flags.SyncRepo); err != nil {
@@ -28,20 +28,20 @@ func HandleSync(cfg *config.Config, flags *Flags) int {
return 1
}
}
-
+
syncer := sync.New(cfg, flags.WorkDir)
syncer.SetBackupEnabled(flags.Backup)
- if err := syncer.SyncRepository(flags.SyncRepo); err != nil {
- log.Fatal("Sync failed:", err)
- return 1
- }
- // Also sync descriptions for this single repository
- descCache := loadDescriptionCache(flags.WorkDir)
- syncRepoDescriptions(cfg, flags.DryRun, flags.SyncRepo, "", "", descCache)
- if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
- fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
- }
- return 0
+ if err := syncer.SyncRepository(flags.SyncRepo); err != nil {
+ log.Fatal("Sync failed:", err)
+ return 1
+ }
+ // Also sync descriptions for this single repository
+ descCache := loadDescriptionCache(flags.WorkDir)
+ syncRepoDescriptions(cfg, flags.DryRun, flags.SyncRepo, "", "", descCache)
+ if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
+ fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
+ }
+ return 0
}
// HandleSyncAll handles syncing all configured repositories
@@ -71,15 +71,15 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int {
}
}
- syncer := sync.New(cfg, flags.WorkDir)
- syncer.SetBackupEnabled(flags.Backup)
- successCount := 0
- // Load descriptions cache
- descCache := loadDescriptionCache(flags.WorkDir)
-
+ syncer := sync.New(cfg, flags.WorkDir)
+ syncer.SetBackupEnabled(flags.Backup)
+ successCount := 0
+ // Load descriptions cache
+ descCache := loadDescriptionCache(flags.WorkDir)
+
for i, repo := range cfg.Repositories {
fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(cfg.Repositories), repo)
-
+
// Create GitHub repo if needed
if hasGithubClient {
if err := createRepoWithClient(&githubClient, repo, fmt.Sprintf("Mirror of %s", repo)); err != nil {
@@ -88,7 +88,7 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int {
return 1
}
}
-
+
// Create Codeberg repo if needed
if hasCodebergClient {
fmt.Printf("Checking/creating Codeberg repository %s...\n", repo)
@@ -96,28 +96,28 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int {
fmt.Printf("Warning: Failed to create Codeberg repo %s: %v\n", repo, err)
}
}
-
- if err := syncer.SyncRepository(repo); err != nil {
- fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err)
- fmt.Printf("Stopping sync due to error.\n")
- return 1
- }
- successCount++
- // Sync descriptions after repo sync
- syncRepoDescriptions(cfg, flags.DryRun, repo, "", "", descCache)
- }
- // Save descriptions cache
- if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
- fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
- }
+
+ if err := syncer.SyncRepository(repo); err != nil {
+ fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err)
+ fmt.Printf("Stopping sync due to error.\n")
+ return 1
+ }
+ successCount++
+ // Sync descriptions after repo sync
+ syncRepoDescriptions(cfg, flags.DryRun, repo, "", "", descCache)
+ }
+ // Save descriptions cache
+ if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
+ fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
+ }
fmt.Printf("\nSuccessfully synced all %d repositories!\n", successCount)
-
+
// Print abandoned branches summary
if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" {
fmt.Print(summary)
}
-
+
// Generate script for abandoned branches
if scriptPath, err := syncer.GenerateDeleteScript(); err != nil {
fmt.Printf("\n⚠️ Failed to generate script: %v\n", err)
@@ -144,7 +144,7 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int {
fmt.Printf(strings.Repeat("=", 70))
fmt.Printf("\n")
}
-
+
return 0
}
@@ -157,9 +157,9 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int {
}
fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name)
-
+
client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken)
-
+
// Try fetching as organization first, then as user
repos, err := client.ListPublicRepos()
if err != nil {
@@ -172,15 +172,15 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int {
repoNames := codeberg.GetRepoNames(repos)
fmt.Printf("Found %d public repositories on Codeberg\n", len(repoNames))
-
+
if len(repoNames) == 0 {
fmt.Println("No public repositories found")
return 0
}
- // Show the repositories that will be synced
- showReposToSync(repoNames)
-
+ // Show the repositories that will be synced
+ showReposToSync(repoNames)
+
if flags.DryRun {
fmt.Printf("\n[DRY RUN] Would sync %d repositories from Codeberg to GitHub\n", len(repoNames))
if flags.CreateGitHubRepos {
@@ -190,11 +190,11 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int {
return 0
}
}
-
- if !flags.DryRun {
- return syncCodebergRepos(cfg, flags, repos, repoNames)
- }
-
+
+ if !flags.DryRun {
+ return syncCodebergRepos(cfg, flags, repos, repoNames)
+ }
+
return 0
}
@@ -207,14 +207,14 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int {
}
fmt.Printf("Fetching public repositories from GitHub user/org: %s...\n", githubOrg.Name)
-
+
client := github.NewClient(githubOrg.GitHubToken, githubOrg.Name)
if !client.HasToken() {
fmt.Println("ERROR: GitHub token required to list repositories")
fmt.Println("Set GITHUB_TOKEN env var or create ~/.gitsyncer_github_token file")
return 1
}
-
+
repos, err := client.ListPublicRepos()
if err != nil {
log.Fatal("Failed to fetch repositories:", err)
@@ -222,15 +222,15 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int {
repoNames := github.GetRepoNames(repos)
fmt.Printf("Found %d public repositories on GitHub\n", len(repoNames))
-
+
if len(repoNames) == 0 {
fmt.Println("No public repositories found")
return 0
}
- // Show the repositories that will be synced
- showReposToSync(repoNames)
-
+ // Show the repositories that will be synced
+ showReposToSync(repoNames)
+
if flags.DryRun {
fmt.Printf("\n[DRY RUN] Would sync %d repositories from GitHub to Codeberg\n", len(repoNames))
if flags.CreateCodebergRepos {
@@ -238,11 +238,11 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int {
}
return 0
}
-
- if !flags.DryRun {
- return syncGitHubRepos(cfg, flags, repos, repoNames)
- }
-
+
+ if !flags.DryRun {
+ return syncGitHubRepos(cfg, flags, repos, repoNames)
+ }
+
return 0
}
@@ -253,14 +253,14 @@ func createGitHubRepoIfNeeded(cfg *config.Config, repoName string) error {
if githubOrg == nil {
return nil
}
-
+
fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name)
githubClient := github.NewClient(githubOrg.GitHubToken, githubOrg.Name)
if !githubClient.HasToken() {
fmt.Println("Warning: No GitHub token found. Cannot create repository.")
return nil
}
-
+
fmt.Println("Checking/creating GitHub repository...")
return githubClient.CreateRepo(repoName, fmt.Sprintf("Mirror of %s", repoName), false)
}
@@ -270,14 +270,14 @@ func createCodebergRepoIfNeeded(cfg *config.Config, repoName string) error {
if codebergOrg == nil {
return nil
}
-
+
fmt.Printf("Initializing Codeberg client for organization: %s\n", codebergOrg.Name)
codebergClient := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken)
if !codebergClient.HasToken() {
fmt.Println("Warning: No Codeberg token found. Cannot create repository.")
return nil
}
-
+
fmt.Println("Checking/creating Codeberg repository...")
return codebergClient.CreateRepo(repoName, fmt.Sprintf("Mirror of %s", repoName), false)
}
@@ -288,14 +288,14 @@ func initGitHubClient(cfg *config.Config) *github.Client {
fmt.Println("Warning: --create-github-repos specified but no GitHub organization found in config")
return nil
}
-
+
fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name)
githubClient := github.NewClient(githubOrg.GitHubToken, githubOrg.Name)
if !githubClient.HasToken() {
fmt.Println("Warning: No GitHub token found. Cannot create repositories.")
return nil
}
-
+
fmt.Println("GitHub client initialized successfully with token")
return &githubClient
}
@@ -346,25 +346,25 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi
hasGithubClient = true
}
}
-
- fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames))
- // Load descriptions cache
- descCache := loadDescriptionCache(flags.WorkDir)
-
+ fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames))
+
+ // Load descriptions cache
+ descCache := loadDescriptionCache(flags.WorkDir)
+
syncer := sync.New(cfg, flags.WorkDir)
syncer.SetBackupEnabled(flags.Backup)
successCount := 0
-
+
// Create map for descriptions
repoMap := make(map[string]codeberg.Repository)
for _, repo := range repos {
repoMap[repo.Name] = repo
}
-
- for i, repoName := range repoNames {
- fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName)
-
+
+ for i, repoName := range repoNames {
+ fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName)
+
// Create GitHub repo if needed
if hasGithubClient && flags.CreateGitHubRepos {
codebergRepo := repoMap[repoName]
@@ -372,42 +372,42 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi
if description == "" {
description = fmt.Sprintf("Mirror of %s from Codeberg", repoName)
}
-
+
fmt.Printf("Checking/creating GitHub repository %s...\n", repoName)
err := githubClient.CreateRepo(repoName, description, false)
if err != nil {
fmt.Printf("Warning: Failed to create GitHub repo %s: %v\n", repoName, err)
}
}
-
- if err := syncer.SyncRepository(repoName); err != nil {
- fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err)
- fmt.Printf("Stopping sync due to error.\n")
- return 1
- }
- successCount++
-
- // After syncing, sync descriptions according to precedence
- if cbRepo, ok := repoMap[repoName]; ok {
- syncRepoDescriptions(cfg, flags.DryRun, repoName, cbRepo.Description, "", descCache)
- } else {
- syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache)
- }
- }
-
- // Save descriptions cache
- if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
- fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
- }
-
- fmt.Printf("\n=== Summary ===\n")
+
+ if err := syncer.SyncRepository(repoName); err != nil {
+ fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err)
+ fmt.Printf("Stopping sync due to error.\n")
+ return 1
+ }
+ successCount++
+
+ // After syncing, sync descriptions according to precedence
+ if cbRepo, ok := repoMap[repoName]; ok {
+ syncRepoDescriptions(cfg, flags.DryRun, repoName, cbRepo.Description, "", descCache)
+ } else {
+ syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache)
+ }
+ }
+
+ // Save descriptions cache
+ if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
+ fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
+ }
+
+ fmt.Printf("\n=== Summary ===\n")
fmt.Printf("Successfully synced: %d repositories\n", successCount)
-
+
// Print abandoned branches summary
if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" {
fmt.Print(summary)
}
-
+
// Generate script for abandoned branches
if scriptPath, err := syncer.GenerateDeleteScript(); err != nil {
fmt.Printf("\n⚠️ Failed to generate script: %v\n", err)
@@ -434,11 +434,11 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi
fmt.Printf(strings.Repeat("=", 70))
fmt.Printf("\n")
}
-
+
if !flags.SyncGitHubPublic {
return 0
}
-
+
// Print separator for full sync
printFullSyncSeparator()
return 0
@@ -455,10 +455,10 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository
}
}
- fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames))
+ fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames))
- // Load descriptions cache
- descCache := loadDescriptionCache(flags.WorkDir)
+ // Load descriptions cache
+ descCache := loadDescriptionCache(flags.WorkDir)
syncer := sync.New(cfg, flags.WorkDir)
syncer.SetBackupEnabled(flags.Backup)
@@ -470,8 +470,8 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository
repoMap[repo.Name] = repo
}
- for i, repoName := range repoNames {
- fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName)
+ for i, repoName := range repoNames {
+ fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName)
// Create Codeberg repo if needed
if hasCodebergClient && flags.CreateCodebergRepos {
@@ -488,25 +488,25 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository
}
}
- if err := syncer.SyncRepository(repoName); err != nil {
- fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err)
- fmt.Printf("Stopping sync due to error.\n")
- return 1
- }
- successCount++
-
- // After syncing, sync descriptions according to precedence
- if ghRepo, ok := repoMap[repoName]; ok {
- syncRepoDescriptions(cfg, flags.DryRun, repoName, "", ghRepo.Description, descCache)
- } else {
- syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache)
- }
- }
-
- // Save descriptions cache
- if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
- fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
- }
+ if err := syncer.SyncRepository(repoName); err != nil {
+ fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err)
+ fmt.Printf("Stopping sync due to error.\n")
+ return 1
+ }
+ successCount++
+
+ // After syncing, sync descriptions according to precedence
+ if ghRepo, ok := repoMap[repoName]; ok {
+ syncRepoDescriptions(cfg, flags.DryRun, repoName, "", ghRepo.Description, descCache)
+ } else {
+ syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache)
+ }
+ }
+
+ // Save descriptions cache
+ if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil {
+ fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err)
+ }
fmt.Printf("\n=== Summary ===\n")
fmt.Printf("Successfully synced: %d repositories\n", successCount)
@@ -515,7 +515,7 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository
if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" {
fmt.Print(summary)
}
-
+
// Generate script for abandoned branches
if scriptPath, err := syncer.GenerateDeleteScript(); err != nil {
fmt.Printf("\n⚠️ Failed to generate script: %v\n", err)
diff --git a/internal/cmd/list.go b/internal/cmd/list.go
index 90d0eb8..31a39f5 100644
--- a/internal/cmd/list.go
+++ b/internal/cmd/list.go
@@ -3,8 +3,8 @@ package cmd
import (
"os"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/cli"
+ "github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
@@ -37,4 +37,4 @@ func init() {
rootCmd.AddCommand(listCmd)
listCmd.AddCommand(listOrgsCmd)
listCmd.AddCommand(listReposCmd)
-} \ No newline at end of file
+}
diff --git a/internal/cmd/manage.go b/internal/cmd/manage.go
index 437bd96..6e2ffee 100644
--- a/internal/cmd/manage.go
+++ b/internal/cmd/manage.go
@@ -5,9 +5,9 @@ import (
"os"
"path/filepath"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/cli"
"codeberg.org/snonux/gitsyncer/internal/state"
+ "github.com/spf13/cobra"
)
var force bool
@@ -43,7 +43,7 @@ var cleanCmd = &cobra.Command{
flags := buildFlags()
flags.Clean = true
flags.Force = force
-
+
// TODO: Implement clean handler
fmt.Println("Clean command not yet implemented")
os.Exit(1)
@@ -64,7 +64,7 @@ This is designed for automated weekly synchronization from cron jobs or shell sc
flags := buildFlags()
flags.BatchRun = true
flags.Force = force
-
+
// Check state unless forced
if !force {
stateManager := state.NewManager(workDir)
@@ -72,23 +72,23 @@ This is designed for automated weekly synchronization from cron jobs or shell sc
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to load state: %v\n", err)
}
-
+
if s.HasRunWithinWeek() {
- fmt.Printf("Batch run was already executed within the past week (last run: %s).\n",
+ fmt.Printf("Batch run was already executed within the past week (last run: %s).\n",
s.LastBatchRun.Format("2006-01-02 15:04:05"))
stateFile := filepath.Join(workDir, ".gitsyncer-state.json")
fmt.Printf("State file location: %s\n", stateFile)
fmt.Println("Skipping batch run. Use --force to override.")
os.Exit(0)
}
-
+
// Store state manager for later
flags.BatchRunStateManager = stateManager
flags.BatchRunState = s
}
-
+
fmt.Println("Starting weekly batch run (full sync + showcase)...")
-
+
// Enable full sync and showcase
flags.FullSync = true
flags.Showcase = true
@@ -96,24 +96,24 @@ This is designed for automated weekly synchronization from cron jobs or shell sc
flags.SyncGitHubPublic = true
flags.CreateGitHubRepos = true
flags.CreateCodebergRepos = true
-
+
// Run sync operations
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode != 0 {
os.Exit(exitCode)
}
-
+
exitCode = cli.HandleSyncGitHubPublic(cfg, flags)
if exitCode != 0 {
os.Exit(exitCode)
}
-
+
// Run showcase
showcaseCode := cli.HandleShowcase(cfg, flags)
if showcaseCode != 0 {
os.Exit(showcaseCode)
}
-
+
// Save batch run state
if flags.BatchRunStateManager != nil && flags.BatchRunState != nil {
flags.BatchRunState.UpdateBatchRunTime()
@@ -125,7 +125,7 @@ This is designed for automated weekly synchronization from cron jobs or shell sc
fmt.Println("Next batch run allowed after one week.")
}
}
-
+
os.Exit(0)
},
}
@@ -135,8 +135,8 @@ func init() {
manageCmd.AddCommand(deleteRepoCmd)
manageCmd.AddCommand(cleanCmd)
manageCmd.AddCommand(batchRunCmd)
-
+
// Manage-specific flags
cleanCmd.Flags().BoolVarP(&force, "force", "f", false, "force operation without confirmation")
batchRunCmd.Flags().BoolVarP(&force, "force", "f", false, "force run even if already run this week")
-} \ No newline at end of file
+}
diff --git a/internal/cmd/release.go b/internal/cmd/release.go
index 9f4e713..656f4a6 100644
--- a/internal/cmd/release.go
+++ b/internal/cmd/release.go
@@ -3,8 +3,8 @@ package cmd
import (
"os"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/cli"
+ "github.com/spf13/cobra"
)
var (
@@ -16,11 +16,11 @@ var (
)
var releaseCmd = &cobra.Command{
- Use: "release",
- Short: "Manage releases across platforms",
- Long: `Check for version tags without releases and create them across
-GitHub and Codeberg. Supports AI-generated release notes via hexai (stdin pipeline),
-with fallback to Claude or aichat.`,
+ Use: "release",
+ Short: "Manage releases across platforms",
+ Long: `Check for version tags without releases and create them across
+GitHub and Codeberg. Supports AI-generated release notes via amp (stdin pipeline),
+with fallback to hexai, Claude, or aichat.`,
}
var releaseCheckCmd = &cobra.Command{
@@ -40,7 +40,7 @@ If no repository is specified, checks all configured repositories.`,
Run: func(cmd *cobra.Command, args []string) {
flags := buildFlags()
flags.CheckReleases = true
-
+
if len(args) > 0 {
// Check specific repo
exitCode := cli.HandleCheckReleasesForRepo(cfg, flags, args[0])
@@ -74,8 +74,8 @@ If no repository is specified, processes all configured repositories.`,
# Create for specific repository without AI
gitsyncer release create myproject --no-ai-notes
- # Use aichat instead of claude for AI release notes
- gitsyncer release create --ai-tool aichat`,
+ # Use amp for AI release notes
+gitsyncer release create --ai-tool amp`,
Run: func(cmd *cobra.Command, args []string) {
flags := buildFlags()
flags.CheckReleases = true
@@ -83,7 +83,7 @@ If no repository is specified, processes all configured repositories.`,
flags.AIReleaseNotes = !noAINotes
flags.UpdateReleases = updateExisting
flags.AITool = aiTool
-
+
if len(args) > 0 {
// Create releases for specific repo
exitCode := cli.HandleCheckReleasesForRepo(cfg, flags, args[0])
@@ -100,14 +100,14 @@ func init() {
rootCmd.AddCommand(releaseCmd)
releaseCmd.AddCommand(releaseCheckCmd)
releaseCmd.AddCommand(releaseCreateCmd)
-
+
// Release flags
releaseCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "preview what releases would be created")
-
+
// Create-specific flags
releaseCreateCmd.Flags().BoolVar(&autoRelease, "auto", false, "skip confirmation prompts")
releaseCreateCmd.Flags().BoolVar(&noAINotes, "no-ai-notes", false, "disable AI-generated release notes (AI notes are enabled by default)")
releaseCreateCmd.Flags().BoolVar(&updateExisting, "update-existing", false, "update existing releases with new AI-generated notes")
releaseCreateCmd.Flags().StringVar(&templatePath, "template", "", "custom template for release notes")
- releaseCreateCmd.Flags().StringVar(&aiTool, "ai-tool", "claude", "AI tool to use for release notes (claude or aichat; hexai is tried first if available)")
+ releaseCreateCmd.Flags().StringVar(&aiTool, "ai-tool", "amp", "AI tool to use for release notes (amp, claude, aichat, or hexai; amp is tried first if available)")
}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index bf4f64a..caf89f1 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -5,15 +5,15 @@ import (
"os"
"path/filepath"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/config"
"codeberg.org/snonux/gitsyncer/internal/version"
+ "github.com/spf13/cobra"
)
var (
- cfgFile string
- workDir string
- cfg *config.Config
+ cfgFile string
+ workDir string
+ cfg *config.Config
rootCmd = &cobra.Command{
Use: "gitsyncer",
Short: "Synchronize git repositories across multiple platforms",
@@ -25,7 +25,7 @@ keeps all branches in sync across different git hosting platforms.`,
if cmd.Use == "version" {
return
}
-
+
// Load configuration
var err error
cfg, err = config.Load(cfgFile)
@@ -35,7 +35,7 @@ keeps all branches in sync across different git hosting platforms.`,
fmt.Fprintf(os.Stderr, "See 'gitsyncer help' for more information.\n")
os.Exit(1)
}
-
+
// Use config WorkDir if no flag was explicitly provided
if !cmd.Flags().Changed("work-dir") && cfg.WorkDir != "" {
workDir = cfg.WorkDir
@@ -54,16 +54,16 @@ func Execute() {
func init() {
// Global flags
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "configuration file (default: ~/.config/gitsyncer/config.json)")
-
+
// Set default work directory
home, err := os.UserHomeDir()
defaultWorkDir := ".gitsyncer-work"
if err == nil {
defaultWorkDir = filepath.Join(home, "git", "gitsyncer-workdir")
}
-
+
rootCmd.PersistentFlags().StringVarP(&workDir, "work-dir", "w", defaultWorkDir, "working directory for operations")
-
+
// Version command
rootCmd.AddCommand(&cobra.Command{
Use: "version",
@@ -72,6 +72,5 @@ func init() {
fmt.Println(version.GetVersion())
},
})
-
-}
+}
diff --git a/internal/cmd/showcase.go b/internal/cmd/showcase.go
index 6f9f984..a590756 100644
--- a/internal/cmd/showcase.go
+++ b/internal/cmd/showcase.go
@@ -4,26 +4,25 @@ import (
"fmt"
"os"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/cli"
+ "github.com/spf13/cobra"
)
var (
- forceRegenerate bool
- outputPath string
- outputFormat string
- excludePattern string
- showcaseAITool string
- showcaseRepo string
+ forceRegenerate bool
+ outputPath string
+ outputFormat string
+ excludePattern string
+ showcaseAITool string
+ showcaseRepo string
)
var showcaseCmd = &cobra.Command{
Use: "showcase",
Short: "Generate AI-powered project showcase",
- Long: `Generate a comprehensive showcase of all your projects using AI.
+ Long: `Generate a comprehensive showcase of all your projects using AI.
This feature creates a formatted document with project summaries, statistics,
-and code snippets. By default uses Claude, but will try hexai first if available,
-and can also use aichat.`,
+and code snippets. By default uses amp, with fallback to hexai, claude, and aichat.`,
Example: ` # Generate showcase with cached summaries
gitsyncer showcase
@@ -40,30 +39,30 @@ and can also use aichat.`,
gitsyncer showcase --exclude "test-.*"
# Use a specific AI tool
- gitsyncer showcase --ai-tool hexai`,
- Run: func(cmd *cobra.Command, args []string) {
- flags := buildFlags()
- flags.Showcase = true
- flags.Force = forceRegenerate
- flags.AITool = showcaseAITool
- if showcaseRepo != "" {
- flags.SyncRepo = showcaseRepo
- }
-
- fmt.Println("Running showcase generation for all repositories...")
- exitCode := cli.HandleShowcaseOnly(cfg, flags)
- os.Exit(exitCode)
- },
+ gitsyncer showcase --ai-tool amp`,
+ Run: func(cmd *cobra.Command, args []string) {
+ flags := buildFlags()
+ flags.Showcase = true
+ flags.Force = forceRegenerate
+ flags.AITool = showcaseAITool
+ if showcaseRepo != "" {
+ flags.SyncRepo = showcaseRepo
+ }
+
+ fmt.Println("Running showcase generation for all repositories...")
+ exitCode := cli.HandleShowcaseOnly(cfg, flags)
+ os.Exit(exitCode)
+ },
}
func init() {
- rootCmd.AddCommand(showcaseCmd)
-
- // Showcase flags
- showcaseCmd.Flags().BoolVarP(&forceRegenerate, "force", "f", false, "force regeneration of cached summaries")
- showcaseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "custom output path (default: ~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl)")
- showcaseCmd.Flags().StringVar(&outputFormat, "format", "gemtext", "output format: gemtext, markdown, html")
- showcaseCmd.Flags().StringVar(&excludePattern, "exclude", "", "exclude repos matching pattern")
- showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "claude", "AI tool for summaries: hexai, claude, claude-code, or aichat (default tries hexai→claude→aichat)")
- showcaseCmd.Flags().StringVar(&showcaseRepo, "repo", "", "only generate showcase for a single repository")
+ rootCmd.AddCommand(showcaseCmd)
+
+ // Showcase flags
+ showcaseCmd.Flags().BoolVarP(&forceRegenerate, "force", "f", false, "force regeneration of cached summaries")
+ showcaseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "custom output path (default: ~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl)")
+ showcaseCmd.Flags().StringVar(&outputFormat, "format", "gemtext", "output format: gemtext, markdown, html")
+ showcaseCmd.Flags().StringVar(&excludePattern, "exclude", "", "exclude repos matching pattern")
+ showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "amp", "AI tool for summaries: amp, hexai, claude, claude-code, or aichat (default tries amp→hexai→claude→aichat)")
+ showcaseCmd.Flags().StringVar(&showcaseRepo, "repo", "", "only generate showcase for a single repository")
}
diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go
index 86505d5..a28f50d 100644
--- a/internal/cmd/sync.go
+++ b/internal/cmd/sync.go
@@ -3,18 +3,18 @@ package cmd
import (
"os"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/cli"
+ "github.com/spf13/cobra"
)
var (
- dryRun bool
- backup bool
- createRepos bool
- noReleases bool
- autoCreate bool
+ dryRun bool
+ backup bool
+ createRepos bool
+ noReleases bool
+ autoCreate bool
noAIReleaseNotes bool
- syncAITool string
+ syncAITool string
)
var syncCmd = &cobra.Command{
@@ -42,12 +42,12 @@ var syncRepoCmd = &cobra.Command{
# Sync without AI-generated release notes
gitsyncer sync repo myproject --no-ai-release-notes
- # Auto-create releases using aichat for AI notes
- gitsyncer sync repo myproject --auto-create-releases --ai-tool aichat`,
+ # Auto-create releases using amp for AI notes
+gitsyncer sync repo myproject --auto-create-releases --ai-tool amp`,
Run: func(cmd *cobra.Command, args []string) {
flags := buildFlags()
flags.SyncRepo = args[0]
-
+
exitCode := cli.HandleSync(cfg, flags)
if exitCode == 0 && !noReleases {
cli.HandleCheckReleasesForRepo(cfg, flags, args[0])
@@ -71,7 +71,7 @@ var syncAllCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
flags := buildFlags()
flags.SyncAll = true
-
+
exitCode := cli.HandleSyncAll(cfg, flags)
if exitCode == 0 && !noReleases {
cli.HandleCheckReleases(cfg, flags)
@@ -95,11 +95,11 @@ var syncCodebergToGitHubCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
flags := buildFlags()
flags.SyncCodebergPublic = true
-
+
if createRepos || autoCreate {
flags.CreateGitHubRepos = true
}
-
+
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode == 0 && !noReleases {
cli.HandleCheckReleases(cfg, flags)
@@ -123,11 +123,11 @@ var syncGitHubToCodebergCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
flags := buildFlags()
flags.SyncGitHubPublic = true
-
+
if createRepos || autoCreate {
flags.CreateCodebergRepos = true
}
-
+
exitCode := cli.HandleSyncGitHubPublic(cfg, flags)
if exitCode == 0 && !noReleases {
cli.HandleCheckReleases(cfg, flags)
@@ -156,13 +156,13 @@ repositories between GitHub and Codeberg. This is equivalent to the old --full f
flags.SyncGitHubPublic = true
flags.CreateGitHubRepos = true
flags.CreateCodebergRepos = true
-
+
// First sync Codeberg to GitHub
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode != 0 {
os.Exit(exitCode)
}
-
+
// Then sync GitHub to Codeberg
exitCode = cli.HandleSyncGitHubPublic(cfg, flags)
if exitCode == 0 && !noReleases {
@@ -174,14 +174,14 @@ repositories between GitHub and Codeberg. This is equivalent to the old --full f
func init() {
rootCmd.AddCommand(syncCmd)
-
+
// Add subcommands
syncCmd.AddCommand(syncRepoCmd)
syncCmd.AddCommand(syncAllCmd)
syncCmd.AddCommand(syncCodebergToGitHubCmd)
syncCmd.AddCommand(syncGitHubToCodebergCmd)
syncCmd.AddCommand(syncBidirectionalCmd)
-
+
// Sync flags (available for all sync subcommands)
syncCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "preview what would be synced")
syncCmd.PersistentFlags().BoolVar(&backup, "backup", false, "include backup locations")
@@ -189,20 +189,20 @@ func init() {
syncCmd.PersistentFlags().BoolVar(&noReleases, "no-releases", false, "skip release checking after sync")
syncCmd.PersistentFlags().BoolVar(&autoCreate, "auto-create-releases", false, "automatically create releases without confirmation")
syncCmd.PersistentFlags().BoolVar(&noAIReleaseNotes, "no-ai-release-notes", false, "disable AI-generated release notes (AI notes are enabled by default)")
- syncCmd.PersistentFlags().StringVar(&syncAITool, "ai-tool", "claude", "AI tool to use for release notes when auto-creating (claude or aichat; hexai is tried first if available)")
+ syncCmd.PersistentFlags().StringVar(&syncAITool, "ai-tool", "amp", "AI tool to use for release notes when auto-creating (amp, claude, aichat, or hexai; amp is tried first if available)")
}
func buildFlags() *cli.Flags {
return &cli.Flags{
- ConfigPath: cfgFile,
- WorkDir: workDir,
- DryRun: dryRun,
- Backup: backup,
- NoCheckReleases: noReleases,
- AutoCreateReleases: autoCreate,
- AIReleaseNotes: !noAIReleaseNotes,
- AITool: syncAITool,
- CreateGitHubRepos: createRepos,
+ ConfigPath: cfgFile,
+ WorkDir: workDir,
+ DryRun: dryRun,
+ Backup: backup,
+ NoCheckReleases: noReleases,
+ AutoCreateReleases: autoCreate,
+ AIReleaseNotes: !noAIReleaseNotes,
+ AITool: syncAITool,
+ CreateGitHubRepos: createRepos,
CreateCodebergRepos: createRepos,
}
}
diff --git a/internal/cmd/test.go b/internal/cmd/test.go
index 2c50112..ebee3db 100644
--- a/internal/cmd/test.go
+++ b/internal/cmd/test.go
@@ -4,9 +4,9 @@ import (
"fmt"
"os"
- "github.com/spf13/cobra"
"codeberg.org/snonux/gitsyncer/internal/cli"
"codeberg.org/snonux/gitsyncer/internal/config"
+ "github.com/spf13/cobra"
)
var testCmd = &cobra.Command{
@@ -52,11 +52,11 @@ var testConfigCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Configuration validation failed: %v\n", err)
os.Exit(1)
}
-
+
fmt.Println("Configuration validation successful!")
fmt.Printf(" Organizations: %d\n", len(cfg.Organizations))
fmt.Printf(" Repositories: %d\n", len(cfg.Repositories))
-
+
// Check for common issues
hasGitHub := false
hasCodeberg := false
@@ -74,11 +74,11 @@ var testConfigCmd = &cobra.Command{
}
}
}
-
+
if !hasGitHub && !hasCodeberg {
fmt.Println(" ⚠️ Warning: No GitHub or Codeberg organizations configured")
}
-
+
os.Exit(0)
},
}
@@ -88,4 +88,4 @@ func init() {
testCmd.AddCommand(testGitHubCmd)
testCmd.AddCommand(testCodebergCmd)
testCmd.AddCommand(testConfigCmd)
-} \ No newline at end of file
+}
diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go
index 356a14b..7ef583d 100644
--- a/internal/codeberg/codeberg.go
+++ b/internal/codeberg/codeberg.go
@@ -70,74 +70,74 @@ func (c *Client) loadToken(tokenFromConfig string) {
// HasToken returns true if a token is loaded
func (c *Client) HasToken() bool {
- return c.token != ""
+ return c.token != ""
}
// GetRepo fetches a repository by name
func (c *Client) GetRepo(repoName string) (Repository, bool, error) {
- var repo Repository
- url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName)
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return repo, false, err
- }
- if c.HasToken() {
- req.Header.Set("Authorization", "token "+c.token)
- }
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return repo, false, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode == 404 {
- return repo, false, nil
- }
- if resp.StatusCode != 200 {
- body, _ := io.ReadAll(resp.Body)
- return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body))
- }
-
- if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
- return repo, false, fmt.Errorf("failed to parse response: %w", err)
- }
- return repo, true, nil
+ var repo Repository
+ url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return repo, false, err
+ }
+ if c.HasToken() {
+ req.Header.Set("Authorization", "token "+c.token)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return repo, false, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ return repo, false, nil
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body))
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
+ return repo, false, fmt.Errorf("failed to parse response: %w", err)
+ }
+ return repo, true, nil
}
// UpdateRepoDescription updates a repository description on Codeberg
func (c *Client) UpdateRepoDescription(repoName, description string) error {
- if !c.HasToken() {
- return fmt.Errorf("Codeberg token required to update repository")
- }
-
- url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName)
- payload := map[string]interface{}{
- "description": description,
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return err
- }
-
- req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body))
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "token "+c.token)
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- b, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("failed to update Codeberg description: %s - %s", resp.Status, string(b))
- }
- return nil
+ if !c.HasToken() {
+ return fmt.Errorf("Codeberg token required to update repository")
+ }
+
+ url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName)
+ payload := map[string]interface{}{
+ "description": description,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "token "+c.token)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to update Codeberg description: %s - %s", resp.Status, string(b))
+ }
+ return nil
}
// ListPublicRepos lists all public repositories for an organization
@@ -299,7 +299,7 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error {
if err != nil {
return fmt.Errorf("failed to create repository: status code %d (could not read response)", resp.StatusCode)
}
-
+
// Try to parse as JSON error response
var errorResp map[string]interface{}
if err := json.Unmarshal(body, &errorResp); err == nil {
@@ -308,7 +308,7 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error {
return fmt.Errorf("failed to create repository: %s (status code %d)", msg, resp.StatusCode)
}
}
-
+
// If we can't parse JSON, return the raw response
return fmt.Errorf("failed to create repository: %s (status code %d)", string(body), resp.StatusCode)
}
diff --git a/internal/config/config.go b/internal/config/config.go
index dce5526..48e6d5f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,14 +19,14 @@ 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
- ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase
- // SkipReleases maps a repository name to a list of tag names for which
- // releases should NOT be created on any platform (GitHub/Codeberg)
- SkipReleases map[string][]string `json:"skip_releases,omitempty"`
+ 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
+ // SkipReleases maps a repository name to a list of tag names for which
+ // releases should NOT be created on any platform (GitHub/Codeberg)
+ SkipReleases map[string][]string `json:"skip_releases,omitempty"`
}
// Load reads and parses the configuration file
@@ -102,25 +102,25 @@ func (c *Config) Validate() error {
}
}
- return nil
+ return nil
}
// ShouldSkipRelease returns true if the configuration specifies that
// the given repo/tag combination should not have a release created.
func (c *Config) ShouldSkipRelease(repo, tag string) bool {
- if c == nil || c.SkipReleases == nil {
- return false
- }
- tags, ok := c.SkipReleases[repo]
- if !ok {
- return false
- }
- for _, t := range tags {
- if t == tag {
- return true
- }
- }
- return false
+ if c == nil || c.SkipReleases == nil {
+ return false
+ }
+ tags, ok := c.SkipReleases[repo]
+ if !ok {
+ return false
+ }
+ for _, t := range tags {
+ if t == tag {
+ return true
+ }
+ }
+ return false
}
// GetGitURL returns the git URL for an organization
diff --git a/internal/github/github.go b/internal/github/github.go
index 5bcc4f1..238b486 100644
--- a/internal/github/github.go
+++ b/internal/github/github.go
@@ -196,79 +196,79 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error {
// HasToken returns whether a token is configured
func (c *Client) HasToken() bool {
- return c.token != ""
+ return c.token != ""
}
// GetRepo fetches a single repository by name
// Returns the repository, a boolean indicating existence, and an error
func (c *Client) GetRepo(repoName string) (Repository, bool, error) {
- var repo Repository
- if c.token == "" {
- return repo, false, fmt.Errorf("GitHub token required")
- }
-
- url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName)
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return repo, false, err
- }
- req.Header.Set("Authorization", "Bearer "+c.token)
- req.Header.Set("Accept", "application/vnd.github.v3+json")
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return repo, false, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode == 404 {
- return repo, false, nil
- }
- if resp.StatusCode != 200 {
- body, _ := io.ReadAll(resp.Body)
- return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body))
- }
-
- if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
- return repo, false, fmt.Errorf("failed to decode repo: %w", err)
- }
- return repo, true, nil
+ var repo Repository
+ if c.token == "" {
+ return repo, false, fmt.Errorf("GitHub token required")
+ }
+
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return repo, false, err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return repo, false, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ return repo, false, nil
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body))
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
+ return repo, false, fmt.Errorf("failed to decode repo: %w", err)
+ }
+ return repo, true, nil
}
// UpdateRepoDescription updates the repository description
func (c *Client) UpdateRepoDescription(repoName, description string) error {
- if c.token == "" {
- return fmt.Errorf("GitHub token required to update repository")
- }
-
- url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName)
- payload := map[string]interface{}{
- "description": description,
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return err
- }
-
- req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body))
- if err != nil {
- return err
- }
- req.Header.Set("Authorization", "Bearer "+c.token)
- req.Header.Set("Accept", "application/vnd.github.v3+json")
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- b, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("failed to update GitHub description: %s - %s", resp.Status, string(b))
- }
- return nil
+ if c.token == "" {
+ return fmt.Errorf("GitHub token required to update repository")
+ }
+
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName)
+ payload := map[string]interface{}{
+ "description": description,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ b, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to update GitHub description: %s - %s", resp.Status, string(b))
+ }
+ return nil
}
// Repository represents a GitHub repository
diff --git a/internal/release/release.go b/internal/release/release.go
index 2746389..2acd621 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -52,65 +52,65 @@ func (m *Manager) SetCodebergToken(token string) {
// SetAITool sets the AI tool to use for release notes generation
func (m *Manager) SetAITool(tool string) {
- m.aiTool = tool
+ m.aiTool = tool
}
// EnsureCodebergReleasesEnabled ensures that the Codeberg repository has the
// Releases feature enabled. If it's disabled, attempts to enable it via API.
func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error {
- if m.codebergToken == "" {
- return fmt.Errorf("Codeberg token is required to manage repository settings")
- }
-
- // Fetch repository metadata
- infoURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo)
- getReq, err := http.NewRequest("GET", infoURL, nil)
- if err != nil {
- return err
- }
- getReq.Header.Set("Authorization", "token "+m.codebergToken)
- resp, err := (&http.Client{}).Do(getReq)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != 200 {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body))
- }
-
- var repoInfo struct{
- HasReleases bool `json:"has_releases"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&repoInfo); err != nil {
- return fmt.Errorf("failed to parse repo info: %w", err)
- }
- if repoInfo.HasReleases {
- return nil
- }
-
- // Enable releases via PATCH
- payload := map[string]any{"has_releases": true}
- body, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- patchReq, err := http.NewRequest("PATCH", infoURL, bytes.NewBuffer(body))
- if err != nil {
- return err
- }
- patchReq.Header.Set("Authorization", "token "+m.codebergToken)
- patchReq.Header.Set("Content-Type", "application/json")
- patchResp, err := (&http.Client{}).Do(patchReq)
- if err != nil {
- return err
- }
- defer patchResp.Body.Close()
- if patchResp.StatusCode != 200 {
- pbody, _ := io.ReadAll(patchResp.Body)
- return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody))
- }
- return nil
+ if m.codebergToken == "" {
+ return fmt.Errorf("Codeberg token is required to manage repository settings")
+ }
+
+ // Fetch repository metadata
+ infoURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo)
+ getReq, err := http.NewRequest("GET", infoURL, nil)
+ if err != nil {
+ return err
+ }
+ getReq.Header.Set("Authorization", "token "+m.codebergToken)
+ resp, err := (&http.Client{}).Do(getReq)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body))
+ }
+
+ var repoInfo struct {
+ HasReleases bool `json:"has_releases"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&repoInfo); err != nil {
+ return fmt.Errorf("failed to parse repo info: %w", err)
+ }
+ if repoInfo.HasReleases {
+ return nil
+ }
+
+ // Enable releases via PATCH
+ payload := map[string]any{"has_releases": true}
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ patchReq, err := http.NewRequest("PATCH", infoURL, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ patchReq.Header.Set("Authorization", "token "+m.codebergToken)
+ patchReq.Header.Set("Content-Type", "application/json")
+ patchResp, err := (&http.Client{}).Do(patchReq)
+ if err != nil {
+ return err
+ }
+ defer patchResp.Body.Close()
+ if patchResp.StatusCode != 200 {
+ pbody, _ := io.ReadAll(patchResp.Body)
+ return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody))
+ }
+ return nil
}
// isVersionTag checks if a tag name is a version tag
@@ -388,67 +388,88 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags
return "", fmt.Errorf("failed to get diff: %w", err)
}
- // Prepare prompt/instructions and input payload
- var instr strings.Builder
- instr.WriteString(fmt.Sprintf("Generate professional release notes for %s version %s.\n", repoName, tag))
- if prevTag != "" {
- instr.WriteString(fmt.Sprintf("Previous version: %s\n", prevTag))
- }
- instr.WriteString("\nBased on the provided commits and code changes, write professional release notes that:\n")
- instr.WriteString("1. Start with a brief overview of what this release accomplishes\n")
- instr.WriteString("2. Group changes into logical sections (Features, Improvements, Bug Fixes, etc.)\n")
- instr.WriteString("3. Explain WHY each change is useful to users, not just what changed\n")
- instr.WriteString("4. Use clear, non-technical language where possible\n")
- instr.WriteString("5. Highlight any breaking changes or migration steps\n")
- instr.WriteString("6. Keep it concise but informative\n")
- instr.WriteString("7. Format using Markdown\n")
- instr.WriteString("\nDo not include the version number in the title as it will be added automatically.")
-
- var input strings.Builder
- input.WriteString("Commit messages:\n")
- for _, commit := range commits {
- input.WriteString(fmt.Sprintf("- %s\n", commit))
- }
- input.WriteString("\nCode changes:\n")
- input.WriteString(diff)
-
- fmt.Printf(" Prompt: Generate release notes for %s %s\n", repoName, tag)
- fmt.Printf(" Prompt includes: %d commits, %.1fKB of code changes\n", len(commits), float64(len(diff))/1024)
- fmt.Printf(" Total prompt length: %d characters\n", len(instr.String())+len(input.String()))
-
- // Determine which AI tool to use (default to claude if not set)
- aiTool := m.aiTool
- if aiTool == "" {
- aiTool = "claude"
- }
-
- // Build a full prompt string for tools that read a single argument
- fullPrompt := instr.String() + "\n\n" + input.String()
-
- var releaseNotes string
-
- // 1) Try hexai first: echo input to stdin and pass instructions as argument
- // Note: print stderr to console, but only use stdout for notes
- if _, err := exec.LookPath("hexai"); err == nil {
- fmt.Println(" Running hexai CLI command (stdin payload)...")
- cmd := exec.Command("hexai", instr.String())
- cmd.Stdin = strings.NewReader(input.String())
- cmd.Stderr = os.Stderr
- out, err := cmd.Output()
- if err != nil {
- fmt.Printf(" hexai CLI failed: %v\n", err)
- } else {
- notes := strings.TrimSpace(string(out))
- if notes == "" {
- fmt.Println(" hexai returned empty output; will try fallbacks...")
- } else {
- releaseNotes = notes
- }
- }
- }
-
- if releaseNotes == "" && aiTool == "claude" {
- fmt.Println(" Running claude CLI command...")
+ // Prepare prompt/instructions and input payload
+ var instr strings.Builder
+ instr.WriteString(fmt.Sprintf("Generate professional release notes for %s version %s.\n", repoName, tag))
+ if prevTag != "" {
+ instr.WriteString(fmt.Sprintf("Previous version: %s\n", prevTag))
+ }
+ instr.WriteString("\nBased on the provided commits and code changes, write professional release notes that:\n")
+ instr.WriteString("1. Start with a brief overview of what this release accomplishes\n")
+ instr.WriteString("2. Group changes into logical sections (Features, Improvements, Bug Fixes, etc.)\n")
+ instr.WriteString("3. Explain WHY each change is useful to users, not just what changed\n")
+ instr.WriteString("4. Use clear, non-technical language where possible\n")
+ instr.WriteString("5. Highlight any breaking changes or migration steps\n")
+ instr.WriteString("6. Keep it concise but informative\n")
+ instr.WriteString("7. Format using Markdown\n")
+ instr.WriteString("\nDo not include the version number in the title as it will be added automatically.")
+
+ var input strings.Builder
+ input.WriteString("Commit messages:\n")
+ for _, commit := range commits {
+ input.WriteString(fmt.Sprintf("- %s\n", commit))
+ }
+ input.WriteString("\nCode changes:\n")
+ input.WriteString(diff)
+
+ fmt.Printf(" Prompt: Generate release notes for %s %s\n", repoName, tag)
+ fmt.Printf(" Prompt includes: %d commits, %.1fKB of code changes\n", len(commits), float64(len(diff))/1024)
+ fmt.Printf(" Total prompt length: %d characters\n", len(instr.String())+len(input.String()))
+
+ // Determine which AI tool to use (default to amp if not set)
+ aiTool := m.aiTool
+ if aiTool == "" {
+ aiTool = "amp"
+ }
+
+ // Build a full prompt string for tools that read a single argument
+ fullPrompt := instr.String() + "\n\n" + input.String()
+
+ var releaseNotes string
+
+ // 1) Try amp first: echo input to stdin and pass instructions as argument
+ // Note: print stderr to console, but only use stdout for notes
+ if _, err := exec.LookPath("amp"); err == nil {
+ fmt.Println(" Running amp CLI command (stdin payload)...")
+ cmd := exec.Command("amp", "--execute", instr.String())
+ cmd.Stdin = strings.NewReader(input.String())
+ cmd.Stderr = os.Stderr
+ out, err := cmd.Output()
+ if err != nil {
+ fmt.Printf(" amp CLI failed: %v\n", err)
+ } else {
+ notes := strings.TrimSpace(string(out))
+ if notes == "" {
+ fmt.Println(" amp returned empty output; will try fallbacks...")
+ } else {
+ releaseNotes = notes
+ }
+ }
+ }
+
+ // 2) Try hexai as fallback
+ if releaseNotes == "" {
+ if _, err := exec.LookPath("hexai"); err == nil {
+ fmt.Println(" Running hexai CLI command (stdin payload)...")
+ cmd := exec.Command("hexai", instr.String())
+ cmd.Stdin = strings.NewReader(input.String())
+ cmd.Stderr = os.Stderr
+ out, err := cmd.Output()
+ if err != nil {
+ fmt.Printf(" hexai CLI failed: %v\n", err)
+ } else {
+ notes := strings.TrimSpace(string(out))
+ if notes == "" {
+ fmt.Println(" hexai returned empty output; will try fallbacks...")
+ } else {
+ releaseNotes = notes
+ }
+ }
+ }
+ }
+
+ if releaseNotes == "" && aiTool == "claude" {
+ fmt.Println(" Running claude CLI command...")
if _, err := exec.LookPath("claude"); err != nil {
fmt.Println(" claude CLI not found, falling back to aichat...")
aiTool = "aichat"
@@ -467,10 +488,10 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags
}
}
- if releaseNotes == "" && aiTool == "aichat" {
+ if releaseNotes == "" && aiTool == "aichat" {
fmt.Println(" Running aichat CLI command...")
if _, err := exec.LookPath("aichat"); err != nil {
- return "", fmt.Errorf("aichat CLI not found in PATH and claude fallback failed")
+ return "", fmt.Errorf("aichat CLI not found in PATH and fallbacks failed")
}
cmd := exec.Command("aichat", fullPrompt)
@@ -481,6 +502,10 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags
releaseNotes = notes
}
+ if releaseNotes == "" && aiTool == "amp" {
+ return "", fmt.Errorf("amp CLI not found in PATH and fallbacks failed")
+ }
+
if releaseNotes == "" {
return "", fmt.Errorf("all AI tools failed to generate release notes")
}
@@ -693,81 +718,83 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e
}
defer resp.Body.Close()
- if resp.StatusCode != 201 {
- body, _ := io.ReadAll(resp.Body)
-
- // Provide a more actionable hint when the repository is missing or owner/repo is wrong
- if resp.StatusCode == 404 {
- // Probe repository details to distinguish scenarios
- probeURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo)
- probeReq, perr := http.NewRequest("GET", probeURL, nil)
- if perr == nil {
- // Prefer probing with the same token
- if m.codebergToken != "" {
- probeReq.Header.Set("Authorization", "token "+m.codebergToken)
- }
- if probeResp, perr2 := (&http.Client{}).Do(probeReq); perr2 == nil {
- defer probeResp.Body.Close()
- if probeResp.StatusCode == 200 {
- // Try to detect if releases are disabled
- var repoInfo struct{ HasReleases bool `json:"has_releases"` }
- if data, rerr := io.ReadAll(probeResp.Body); rerr == nil {
- _ = json.Unmarshal(data, &repoInfo)
- if !repoInfo.HasReleases {
- // Try to enable releases automatically and retry creation
- if err := m.EnsureCodebergReleasesEnabled(owner, repo); err != nil {
- return fmt.Errorf(
- "failed to create Codeberg release: releases are disabled for %s/%s and enabling via API failed: %v. Raw response: %s",
- owner, repo, err, string(body),
- )
- }
- // Retry POST after enabling
- retryReq, rerr := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
- if rerr != nil {
- return rerr
- }
- retryReq.Header.Set("Authorization", "token "+m.codebergToken)
- retryReq.Header.Set("Content-Type", "application/json")
- retryReq.Header.Set("Accept", "application/json")
- retryResp, rerr := (&http.Client{}).Do(retryReq)
- if rerr != nil {
- return rerr
- }
- defer retryResp.Body.Close()
- if retryResp.StatusCode != 201 {
- rbody, _ := io.ReadAll(retryResp.Body)
- return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody))
- }
- // Success after enabling
- return nil
- }
- }
- // Repo exists and has releases; likely permission/scope issue
- return fmt.Errorf(
- "failed to create Codeberg release: repo %s/%s exists but returned 404 on release creation. This usually indicates the token lacks write permissions to this repository or owner. Ensure the token belongs to '%s' (or a collaborator/maintainer) and has repository write access. Raw response: %s",
- owner, repo, owner, string(body),
- )
- }
- }
- }
- return fmt.Errorf(
- "failed to create Codeberg release: repository %s/%s not found (404). Verify your Codeberg owner ('organizations[].name') matches the actual owner for this repo and that the repository exists. If needed, create it first, e.g.: gitsyncer sync repo %s --create-codeberg-repos. Raw response: %s",
- owner, repo, repo, string(body),
- )
- }
-
- // Special handling for known Gitea issue
- if resp.StatusCode == 409 && strings.Contains(string(body), "Release is has no Tag") {
- // This is a known Gitea bug - the tag exists but Gitea can't create a release for it
- // Check if it's one of the problematic old tags
- fmt.Printf("\nWARNING: Codeberg/Gitea returned 'Release is has no Tag' error for tag %s\n", tag)
- fmt.Printf("This is a known issue with some old tags. The tag exists but cannot have a release created via API.\n")
- fmt.Printf("You may need to create this release manually through the Codeberg web interface.\n\n")
- return fmt.Errorf("cannot create release for tag %s due to Gitea API limitation", tag)
- }
-
- return fmt.Errorf("failed to create Codeberg release: %s - %s", resp.Status, string(body))
- }
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+
+ // Provide a more actionable hint when the repository is missing or owner/repo is wrong
+ if resp.StatusCode == 404 {
+ // Probe repository details to distinguish scenarios
+ probeURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo)
+ probeReq, perr := http.NewRequest("GET", probeURL, nil)
+ if perr == nil {
+ // Prefer probing with the same token
+ if m.codebergToken != "" {
+ probeReq.Header.Set("Authorization", "token "+m.codebergToken)
+ }
+ if probeResp, perr2 := (&http.Client{}).Do(probeReq); perr2 == nil {
+ defer probeResp.Body.Close()
+ if probeResp.StatusCode == 200 {
+ // Try to detect if releases are disabled
+ var repoInfo struct {
+ HasReleases bool `json:"has_releases"`
+ }
+ if data, rerr := io.ReadAll(probeResp.Body); rerr == nil {
+ _ = json.Unmarshal(data, &repoInfo)
+ if !repoInfo.HasReleases {
+ // Try to enable releases automatically and retry creation
+ if err := m.EnsureCodebergReleasesEnabled(owner, repo); err != nil {
+ return fmt.Errorf(
+ "failed to create Codeberg release: releases are disabled for %s/%s and enabling via API failed: %v. Raw response: %s",
+ owner, repo, err, string(body),
+ )
+ }
+ // Retry POST after enabling
+ retryReq, rerr := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if rerr != nil {
+ return rerr
+ }
+ retryReq.Header.Set("Authorization", "token "+m.codebergToken)
+ retryReq.Header.Set("Content-Type", "application/json")
+ retryReq.Header.Set("Accept", "application/json")
+ retryResp, rerr := (&http.Client{}).Do(retryReq)
+ if rerr != nil {
+ return rerr
+ }
+ defer retryResp.Body.Close()
+ if retryResp.StatusCode != 201 {
+ rbody, _ := io.ReadAll(retryResp.Body)
+ return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody))
+ }
+ // Success after enabling
+ return nil
+ }
+ }
+ // Repo exists and has releases; likely permission/scope issue
+ return fmt.Errorf(
+ "failed to create Codeberg release: repo %s/%s exists but returned 404 on release creation. This usually indicates the token lacks write permissions to this repository or owner. Ensure the token belongs to '%s' (or a collaborator/maintainer) and has repository write access. Raw response: %s",
+ owner, repo, owner, string(body),
+ )
+ }
+ }
+ }
+ return fmt.Errorf(
+ "failed to create Codeberg release: repository %s/%s not found (404). Verify your Codeberg owner ('organizations[].name') matches the actual owner for this repo and that the repository exists. If needed, create it first, e.g.: gitsyncer sync repo %s --create-codeberg-repos. Raw response: %s",
+ owner, repo, repo, string(body),
+ )
+ }
+
+ // Special handling for known Gitea issue
+ if resp.StatusCode == 409 && strings.Contains(string(body), "Release is has no Tag") {
+ // This is a known Gitea bug - the tag exists but Gitea can't create a release for it
+ // Check if it's one of the problematic old tags
+ fmt.Printf("\nWARNING: Codeberg/Gitea returned 'Release is has no Tag' error for tag %s\n", tag)
+ fmt.Printf("This is a known issue with some old tags. The tag exists but cannot have a release created via API.\n")
+ fmt.Printf("You may need to create this release manually through the Codeberg web interface.\n\n")
+ return fmt.Errorf("cannot create release for tag %s due to Gitea API limitation", tag)
+ }
+
+ return fmt.Errorf("failed to create Codeberg release: %s - %s", resp.Status, string(body))
+ }
return nil
}
diff --git a/internal/showcase/ai_context.go b/internal/showcase/ai_context.go
index f418894..1c812f8 100644
--- a/internal/showcase/ai_context.go
+++ b/internal/showcase/ai_context.go
@@ -1,184 +1,214 @@
package showcase
import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
)
// buildAIInputContext prepares a textual context for AI tools when no README exists.
// It returns a string to be piped to the AI tool's stdin and a boolean indicating
// whether this was sourced from an actual README (true) or synthesized (false).
func buildAIInputContext(repoPath string) (string, bool) {
- // 1) Try to load a README first
- readmeFiles := []string{
- "README.md", "readme.md", "Readme.md",
- "README.MD", "README.txt", "readme.txt",
- "README", "readme",
- }
- for _, f := range readmeFiles {
- p := filepath.Join(repoPath, f)
- if b, err := os.ReadFile(p); err == nil {
- return string(b), true
- }
- }
+ // 1) Try to load a README first
+ readmeFiles := []string{
+ "README.md", "readme.md", "Readme.md",
+ "README.MD", "README.txt", "readme.txt",
+ "README", "readme",
+ }
+ for _, f := range readmeFiles {
+ p := filepath.Join(repoPath, f)
+ if b, err := os.ReadFile(p); err == nil {
+ return string(b), true
+ }
+ }
- // 2) No README: synthesize compact context
- var sb strings.Builder
+ // 2) No README: synthesize compact context
+ var sb strings.Builder
- // File tree (depth-limited)
- sb.WriteString("[CONTEXT]\n")
- sb.WriteString("Repository does not contain a README.\n")
- sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n")
+ // File tree (depth-limited)
+ sb.WriteString("[CONTEXT]\n")
+ sb.WriteString("Repository does not contain a README.\n")
+ sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n")
- sb.WriteString("FILE TREE (depth 2):\n")
- tree := listFileTree(repoPath, 2, 200)
- for _, line := range tree {
- sb.WriteString("- ")
- sb.WriteString(line)
- sb.WriteString("\n")
- }
- sb.WriteString("\n")
+ sb.WriteString("FILE TREE (depth 2):\n")
+ tree := listFileTree(repoPath, 2, 200)
+ for _, line := range tree {
+ sb.WriteString("- ")
+ sb.WriteString(line)
+ sb.WriteString("\n")
+ }
+ sb.WriteString("\n")
- // Key manifests we often care about
- manifests := []string{
- "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock",
- "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile",
- "build.gradle", "pom.xml", "composer.json",
- }
- wroteHeader := false
- for _, m := range manifests {
- p := filepath.Join(repoPath, m)
- if b, err := os.ReadFile(p); err == nil {
- if !wroteHeader {
- sb.WriteString("KEY MANIFESTS:\n")
- wroteHeader = true
- }
- sb.WriteString(fmt.Sprintf("--- %s ---\n", m))
- sb.WriteString(trimTo(string(b), 2000))
- sb.WriteString("\n\n")
- }
- }
+ // Key manifests we often care about
+ manifests := []string{
+ "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock",
+ "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile",
+ "build.gradle", "pom.xml", "composer.json",
+ }
+ wroteHeader := false
+ for _, m := range manifests {
+ p := filepath.Join(repoPath, m)
+ if b, err := os.ReadFile(p); err == nil {
+ if !wroteHeader {
+ sb.WriteString("KEY MANIFESTS:\n")
+ wroteHeader = true
+ }
+ sb.WriteString(fmt.Sprintf("--- %s ---\n", m))
+ sb.WriteString(trimTo(string(b), 2000))
+ sb.WriteString("\n\n")
+ }
+ }
- // Source hints: capture first main-ish entry file snippets
- // Priority: Go main, Rust main, Node entry, Python main
- candidates := []string{
- "cmd", // Go convention
- "main.go",
- "cmd/main.go",
- "src/main.rs",
- "index.js",
- "src/index.js",
- "main.py",
- "src/main.py",
- }
- wroteSrc := false
- for _, c := range candidates {
- p := filepath.Join(repoPath, c)
- info, err := os.Stat(p)
- if err != nil {
- continue
- }
- if info.IsDir() {
- // collect a few go files under cmd/*/main.go
- if c == "cmd" {
- _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error {
- if err != nil { return nil }
- if d.IsDir() { return nil }
- base := filepath.Base(path)
- if base == "main.go" {
- if b, e := os.ReadFile(path); e == nil {
- if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true }
- rel, _ := filepath.Rel(repoPath, path)
- sb.WriteString(fmt.Sprintf("--- %s ---\n", rel))
- sb.WriteString(trimTo(string(b), 2000))
- sb.WriteString("\n\n")
- }
- }
- return nil
- })
- }
- continue
- }
- if b, e := os.ReadFile(p); e == nil {
- if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true }
- rel, _ := filepath.Rel(repoPath, p)
- sb.WriteString(fmt.Sprintf("--- %s ---\n", rel))
- sb.WriteString(trimTo(string(b), 2000))
- sb.WriteString("\n\n")
- }
- }
+ // Source hints: capture first main-ish entry file snippets
+ // Priority: Go main, Rust main, Node entry, Python main
+ candidates := []string{
+ "cmd", // Go convention
+ "main.go",
+ "cmd/main.go",
+ "src/main.rs",
+ "index.js",
+ "src/index.js",
+ "main.py",
+ "src/main.py",
+ }
+ wroteSrc := false
+ for _, c := range candidates {
+ p := filepath.Join(repoPath, c)
+ info, err := os.Stat(p)
+ if err != nil {
+ continue
+ }
+ if info.IsDir() {
+ // collect a few go files under cmd/*/main.go
+ if c == "cmd" {
+ _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ return nil
+ }
+ base := filepath.Base(path)
+ if base == "main.go" {
+ if b, e := os.ReadFile(path); e == nil {
+ if !wroteSrc {
+ sb.WriteString("PRIMARY SOURCE SNIPPETS:\n")
+ wroteSrc = true
+ }
+ rel, _ := filepath.Rel(repoPath, path)
+ sb.WriteString(fmt.Sprintf("--- %s ---\n", rel))
+ sb.WriteString(trimTo(string(b), 2000))
+ sb.WriteString("\n\n")
+ }
+ }
+ return nil
+ })
+ }
+ continue
+ }
+ if b, e := os.ReadFile(p); e == nil {
+ if !wroteSrc {
+ sb.WriteString("PRIMARY SOURCE SNIPPETS:\n")
+ wroteSrc = true
+ }
+ rel, _ := filepath.Rel(repoPath, p)
+ sb.WriteString(fmt.Sprintf("--- %s ---\n", rel))
+ sb.WriteString(trimTo(string(b), 2000))
+ sb.WriteString("\n\n")
+ }
+ }
- // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing
- if !wroteSrc {
- topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5)
- for _, f := range topFiles {
- if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil {
- if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true }
- sb.WriteString(fmt.Sprintf("--- %s ---\n", f))
- sb.WriteString(trimTo(string(b), 2000))
- sb.WriteString("\n\n")
- }
- }
- }
+ // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing
+ if !wroteSrc {
+ topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5)
+ for _, f := range topFiles {
+ if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil {
+ if !wroteSrc {
+ sb.WriteString("PRIMARY SOURCE SNIPPETS:\n")
+ wroteSrc = true
+ }
+ sb.WriteString(fmt.Sprintf("--- %s ---\n", f))
+ sb.WriteString(trimTo(string(b), 2000))
+ sb.WriteString("\n\n")
+ }
+ }
+ }
- // Instruction to the model
- sb.WriteString("[TASK]\n")
- sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n")
+ // Instruction to the model
+ sb.WriteString("[TASK]\n")
+ sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n")
- return sb.String(), false
+ return sb.String(), false
}
// listFileTree returns a sorted list of relative paths up to a given depth and limit.
func listFileTree(root string, maxDepth int, maxEntries int) []string {
- var entries []string
- var count int
- _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
- if err != nil { return nil }
- if path == root { return nil }
- rel, e := filepath.Rel(root, path)
- if e != nil { return nil }
- // depth check
- depth := 1 + strings.Count(rel, string(os.PathSeparator))
- if depth > maxDepth { return fs.SkipDir }
- entries = append(entries, rel)
- count++
- if count >= maxEntries { return fs.SkipDir }
- return nil
- })
- sort.Strings(entries)
- if len(entries) > maxEntries {
- entries = entries[:maxEntries]
- }
- return entries
+ var entries []string
+ var count int
+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if path == root {
+ return nil
+ }
+ rel, e := filepath.Rel(root, path)
+ if e != nil {
+ return nil
+ }
+ // depth check
+ depth := 1 + strings.Count(rel, string(os.PathSeparator))
+ if depth > maxDepth {
+ return fs.SkipDir
+ }
+ entries = append(entries, rel)
+ count++
+ if count >= maxEntries {
+ return fs.SkipDir
+ }
+ return nil
+ })
+ sort.Strings(entries)
+ if len(entries) > maxEntries {
+ entries = entries[:maxEntries]
+ }
+ return entries
}
// listTopFiles lists top-level files with certain extensions up to a limit.
func listTopFiles(root string, exts []string, limit int) []string {
- dir, err := os.ReadDir(root)
- if err != nil { return nil }
- var out []string
- for _, e := range dir {
- if e.IsDir() { continue }
- name := e.Name()
- for _, x := range exts {
- if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) {
- out = append(out, name)
- break
- }
- }
- if len(out) >= limit { break }
- }
- sort.Strings(out)
- return out
+ dir, err := os.ReadDir(root)
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range dir {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ for _, x := range exts {
+ if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) {
+ out = append(out, name)
+ break
+ }
+ }
+ if len(out) >= limit {
+ break
+ }
+ }
+ sort.Strings(out)
+ return out
}
// trimTo soft-limits content length for inclusion in AI context.
func trimTo(s string, max int) string {
- if len(s) <= max { return s }
- return s[:max] + "\n... [truncated]"
+ if len(s) <= max {
+ return s
+ }
+ return s[:max] + "\n... [truncated]"
}
-
diff --git a/internal/showcase/code_extractor.go b/internal/showcase/code_extractor.go
index 91a0a78..fbf17f6 100644
--- a/internal/showcase/code_extractor.go
+++ b/internal/showcase/code_extractor.go
@@ -22,34 +22,34 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
// Get the primary language (highest percentage)
primaryLang := languages[0].Name
-
+
// Define file extensions for each language
langExtensions := map[string][]string{
- "Go": {".go"},
- "Python": {".py"},
- "JavaScript": {".js"},
- "TypeScript": {".ts"},
- "Java": {".java"},
- "C": {".c", ".h"},
- "C++": {".cpp", ".cc", ".cxx", ".hpp"},
- "C/C++": {".h"},
- "C#": {".cs"},
- "Ruby": {".rb"},
- "PHP": {".php"},
- "Swift": {".swift"},
- "Kotlin": {".kt"},
- "Rust": {".rs"},
- "Shell": {".sh", ".bash"},
- "Perl": {".pl", ".pm"},
- "Raku": {".raku", ".rakumod", ".p6", ".pm6"},
- "Haskell": {".hs"},
- "Lua": {".lua"},
- "HTML": {".html", ".htm"},
- "CSS": {".css"},
- "SQL": {".sql"},
- "Make": {"Makefile", "makefile", "GNUmakefile"},
- "HCL": {".tf", ".tfvars", ".hcl"},
- "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts
+ "Go": {".go"},
+ "Python": {".py"},
+ "JavaScript": {".js"},
+ "TypeScript": {".ts"},
+ "Java": {".java"},
+ "C": {".c", ".h"},
+ "C++": {".cpp", ".cc", ".cxx", ".hpp"},
+ "C/C++": {".h"},
+ "C#": {".cs"},
+ "Ruby": {".rb"},
+ "PHP": {".php"},
+ "Swift": {".swift"},
+ "Kotlin": {".kt"},
+ "Rust": {".rs"},
+ "Shell": {".sh", ".bash"},
+ "Perl": {".pl", ".pm"},
+ "Raku": {".raku", ".rakumod", ".p6", ".pm6"},
+ "Haskell": {".hs"},
+ "Lua": {".lua"},
+ "HTML": {".html", ".htm"},
+ "CSS": {".css"},
+ "SQL": {".sql"},
+ "Make": {"Makefile", "makefile", "GNUmakefile"},
+ "HCL": {".tf", ".tfvars", ".hcl"},
+ "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts
}
// Get file extensions for the primary language
@@ -79,13 +79,13 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
if info.IsDir() {
name := info.Name()
// Skip hidden directories and common non-code directories
- if strings.HasPrefix(name, ".") && name != "." ||
- name == "node_modules" ||
- name == "vendor" ||
- name == "target" ||
- name == "dist" ||
- name == "build" ||
- name == "__pycache__" {
+ if strings.HasPrefix(name, ".") && name != "." ||
+ name == "node_modules" ||
+ name == "vendor" ||
+ name == "target" ||
+ name == "dist" ||
+ name == "build" ||
+ name == "__pycache__" {
return filepath.SkipDir
}
return nil
@@ -99,7 +99,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
// Check if file matches extensions
basename := filepath.Base(path)
ext := filepath.Ext(path)
-
+
matched := false
for _, validExt := range extensions {
if validExt == basename || (strings.HasPrefix(validExt, ".") && ext == validExt) {
@@ -107,7 +107,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
break
}
}
-
+
// For executable files, also check shebang if primary language is AWK and file has .cgi extension
if !matched && primaryLang == "AWK" && ext == ".cgi" && info.Mode()&0111 != 0 {
if file, err := os.Open(path); err == nil {
@@ -121,14 +121,14 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
file.Close()
}
}
-
+
if matched {
// Skip test files and generated files
- if !strings.Contains(basename, "_test") &&
- !strings.Contains(basename, ".test.") &&
- !strings.Contains(basename, ".min.") &&
- !strings.Contains(path, "/test/") &&
- !strings.Contains(path, "/tests/") {
+ if !strings.Contains(basename, "_test") &&
+ !strings.Contains(basename, ".test.") &&
+ !strings.Contains(basename, ".min.") &&
+ !strings.Contains(path, "/test/") &&
+ !strings.Contains(path, "/tests/") {
codeFiles = append(codeFiles, path)
}
}
@@ -148,10 +148,10 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
rand.Shuffle(len(codeFiles), func(i, j int) {
codeFiles[i], codeFiles[j] = codeFiles[j], codeFiles[i]
})
-
+
var snippet string
var selectedFile string
-
+
// Try up to 5 files to find a good snippet
for i := 0; i < len(codeFiles) && i < 5; i++ {
candidateFile := codeFiles[i]
@@ -159,28 +159,28 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
if err != nil {
continue
}
-
+
// Check if this snippet has acceptable line lengths
if hasAcceptableLineLength(candidateSnippet, 80) {
snippet = candidateSnippet
selectedFile = candidateFile
break
}
-
+
// Keep the first valid snippet as fallback
if snippet == "" {
snippet = candidateSnippet
selectedFile = candidateFile
}
}
-
+
if snippet == "" {
return "", "", fmt.Errorf("no valid code snippets found")
}
// Get relative path for display
relPath, _ := filepath.Rel(repoPath, selectedFile)
-
+
return snippet, fmt.Sprintf("%s from `%s`", primaryLang, relPath), nil
}
@@ -236,9 +236,9 @@ func extractSnippetFromFile(filePath string, minLines, maxLines int) (string, er
skipLines := 0
for i, line := range lines {
trimmed := strings.TrimSpace(line)
- if trimmed != "" && !strings.HasPrefix(trimmed, "import") &&
- !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") &&
- !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") {
+ if trimmed != "" && !strings.HasPrefix(trimmed, "import") &&
+ !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") &&
+ !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") {
skipLines = i
break
}
@@ -260,19 +260,19 @@ func findSmallestCompleteFunction(lines []string) string {
end int
size int
}
-
+
var functions []functionInfo
-
+
// Keywords that typically start functions/methods
functionKeywords := []string{
"func ", "function ", "def ", "public ", "private ", "protected ",
"static ", "async ", "procedure ", "sub ", "method ",
}
-
+
// Find all complete functions
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
-
+
// Check if this line starts a function
isFunction := false
for _, keyword := range functionKeywords {
@@ -281,11 +281,11 @@ func findSmallestCompleteFunction(lines []string) string {
break
}
}
-
+
if !isFunction {
continue
}
-
+
// Try to find the end of this function
functionEnd := findFunctionEnd(lines, i)
if functionEnd > i {
@@ -300,7 +300,7 @@ func findSmallestCompleteFunction(lines []string) string {
}
}
}
-
+
// Find the smallest function with acceptable line lengths
if len(functions) > 0 {
// First try to find a function with all lines <= 80 chars
@@ -310,7 +310,7 @@ func findSmallestCompleteFunction(lines []string) string {
return snippet
}
}
-
+
// If none found, return the smallest function (will be broken later)
smallest := functions[0]
for _, f := range functions[1:] {
@@ -320,7 +320,7 @@ func findSmallestCompleteFunction(lines []string) string {
}
return strings.Join(lines[smallest.start:smallest.end+1], "\n")
}
-
+
return ""
}
@@ -329,11 +329,11 @@ func findFunctionEnd(lines []string, start int) int {
if start >= len(lines) {
return -1
}
-
+
// For brace-based languages
braceCount := 0
inFunction := false
-
+
// For Python - track initial indentation
isPython := strings.Contains(lines[start], "def ") || strings.Contains(lines[start], "class ")
var initialIndent int
@@ -346,11 +346,11 @@ func findFunctionEnd(lines []string, start int) int {
}
}
}
-
+
for i := start; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
-
+
// Handle Python indentation
if isPython && i > start {
if trimmed == "" {
@@ -361,7 +361,7 @@ func findFunctionEnd(lines []string, start int) int {
return i - 1
}
}
-
+
// Handle brace-based languages
for _, ch := range line {
if ch == '{' {
@@ -375,12 +375,12 @@ func findFunctionEnd(lines []string, start int) int {
}
}
}
-
+
// If we're in Python and reached the end, return the last line
if isPython {
return len(lines) - 1
}
-
+
return -1
}
@@ -391,11 +391,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int,
"func ", "function ", "def ", "public ", "private ", "protected ",
"static ", "async ", "procedure ", "sub ", "method ",
}
-
+
// Try to find a function that fits within our size constraints
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
-
+
// Check if this line starts a function
isFunction := false
for _, keyword := range functionKeywords {
@@ -404,11 +404,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int,
break
}
}
-
+
if !isFunction {
continue
}
-
+
// Try to find the end of this function
functionEnd := findFunctionEnd(lines, i)
if functionEnd > i {
@@ -418,7 +418,7 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int,
}
}
}
-
+
return -1, -1
}
@@ -435,7 +435,7 @@ func findInterestingStart(lines []string, snippetSize int) int {
line := strings.TrimSpace(lines[i])
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") ||
- strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
+ strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
continue
}
@@ -457,10 +457,10 @@ func stripComments(code string) string {
lines := strings.Split(code, "\n")
var result []string
inMultilineComment := false
-
+
for _, line := range lines {
trimmed := strings.TrimSpace(line)
-
+
// Handle multi-line comments for C-style languages
if strings.Contains(line, "/*") {
inMultilineComment = true
@@ -475,19 +475,19 @@ func stripComments(code string) string {
continue
}
}
-
+
if inMultilineComment {
if strings.Contains(line, "*/") {
inMultilineComment = false
}
continue
}
-
+
// Skip single-line comments
if trimmed == "" {
// Keep empty lines for readability
result = append(result, line)
- } else if strings.HasPrefix(trimmed, "//") ||
+ } else if strings.HasPrefix(trimmed, "//") ||
strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "#define") ||
strings.HasPrefix(trimmed, "<!--") ||
strings.HasPrefix(trimmed, "*") && len(trimmed) > 1 && trimmed[1] == ' ' {
@@ -509,7 +509,7 @@ func stripComments(code string) string {
result = append(result, line)
}
}
-
+
// Remove leading and trailing empty lines
for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
result = result[1:]
@@ -517,13 +517,13 @@ func stripComments(code string) string {
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
result = result[:len(result)-1]
}
-
+
// Remove unnecessary indentation
result = removeCommonIndentation(result)
-
+
// Break long lines
result = breakLongLines(result, 80)
-
+
return strings.Join(result, "\n")
}
@@ -532,11 +532,11 @@ func removeCommonIndentation(lines []string) []string {
if len(lines) == 0 {
return lines
}
-
+
// Find the common prefix of whitespace
var commonPrefix string
firstNonEmpty := -1
-
+
// Find first non-empty line to use as reference
for i, line := range lines {
if strings.TrimSpace(line) != "" {
@@ -544,11 +544,11 @@ func removeCommonIndentation(lines []string) []string {
break
}
}
-
+
if firstNonEmpty == -1 {
return lines
}
-
+
// Get the whitespace prefix of the first non-empty line
firstLine := lines[firstNonEmpty]
for i, ch := range firstLine {
@@ -557,18 +557,18 @@ func removeCommonIndentation(lines []string) []string {
break
}
}
-
+
// If the first line has no indentation, return as-is
if commonPrefix == "" {
return lines
}
-
+
// Find the actual common prefix among all non-empty lines
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
-
+
// Reduce commonPrefix to what this line shares
for i := 0; i < len(commonPrefix); i++ {
if i >= len(line) || line[i] != commonPrefix[i] {
@@ -576,17 +576,17 @@ func removeCommonIndentation(lines []string) []string {
break
}
}
-
+
if commonPrefix == "" {
break
}
}
-
+
// If no common prefix found, return as-is
if commonPrefix == "" {
return lines
}
-
+
// Remove common prefix from all lines
result := make([]string, len(lines))
prefixLen := len(commonPrefix)
@@ -599,7 +599,7 @@ func removeCommonIndentation(lines []string) []string {
result[i] = line
}
}
-
+
return result
}
@@ -617,18 +617,18 @@ func hasAcceptableLineLength(snippet string, maxLength int) bool {
// breakLongLines breaks lines that exceed maxLength at appropriate points
func breakLongLines(lines []string, maxLength int) []string {
var result []string
-
+
for _, line := range lines {
if len(line) <= maxLength {
result = append(result, line)
continue
}
-
+
// Try to break the line intelligently
broken := breakLine(line, maxLength)
result = append(result, broken...)
}
-
+
return result
}
@@ -638,7 +638,7 @@ func breakLine(line string, maxLength int) []string {
if len(line) <= maxLength {
return []string{line}
}
-
+
// Get the indentation of the original line
indent := ""
for _, ch := range line {
@@ -648,43 +648,43 @@ func breakLine(line string, maxLength int) []string {
break
}
}
-
+
// Common break points in order of preference
breakPoints := []string{
- ", ", // After comma
- " && ", // Before logical operators
+ ", ", // After comma
+ " && ", // Before logical operators
" || ",
- " + ", // Before arithmetic operators
+ " + ", // Before arithmetic operators
" - ",
" * ",
" / ",
- " = ", // Before assignment
+ " = ", // Before assignment
" := ",
- " == ", // Before comparison
+ " == ", // Before comparison
" != ",
" < ",
" > ",
" <= ",
" >= ",
- "(", // After opening parenthesis
- " ", // Any space
+ "(", // After opening parenthesis
+ " ", // Any space
}
-
+
var result []string
remaining := line
isFirstLine := true
-
+
for len(remaining) > maxLength {
// Find the best break point
bestBreak := -1
-
+
for _, breakPoint := range breakPoints {
// Look for break point before maxLength
searchIn := remaining
if len(searchIn) > maxLength {
searchIn = remaining[:maxLength]
}
-
+
idx := strings.LastIndex(searchIn, breakPoint)
if idx > 0 && idx < maxLength {
// For some break points, we want to break after them
@@ -696,12 +696,12 @@ func breakLine(line string, maxLength int) []string {
}
}
}
-
+
// If no good break point found, break at maxLength
if bestBreak == -1 {
bestBreak = maxLength
}
-
+
// Add the line
lineToAdd := remaining[:bestBreak]
if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(lineToAdd), "//") {
@@ -709,7 +709,7 @@ func breakLine(line string, maxLength int) []string {
lineToAdd = indent + " " + strings.TrimLeft(lineToAdd, " \t")
}
result = append(result, strings.TrimRight(lineToAdd, " "))
-
+
// Update remaining
remaining = remaining[bestBreak:]
if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(remaining), "//") {
@@ -717,7 +717,7 @@ func breakLine(line string, maxLength int) []string {
}
isFirstLine = false
}
-
+
// Add the last part
if len(remaining) > 0 {
if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(remaining), "//") {
@@ -725,6 +725,6 @@ func breakLine(line string, maxLength int) []string {
}
result = append(result, remaining)
}
-
+
return result
-} \ No newline at end of file
+}
diff --git a/internal/showcase/images.go b/internal/showcase/images.go
index 66372cb..b6fe6d7 100644
--- a/internal/showcase/images.go
+++ b/internal/showcase/images.go
@@ -15,7 +15,7 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er
// 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 {
@@ -23,30 +23,30 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er
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 {
@@ -54,19 +54,19 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er
} 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
@@ -78,7 +78,7 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er
}
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
@@ -89,31 +89,31 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er
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
}
@@ -121,24 +121,24 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er
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"> with quotes
- `<img[^>]+src=([^\s>]+)[^>]*>`, // <img src=url> without quotes
- `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref]
- `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title"
+ `!\[([^\]]*)\]\(([^)]+)\)`, // ![alt](url)
+ `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url"> with quotes
+ `<img[^>]+src=([^\s>]+)[^>]*>`, // <img src=url> without quotes
+ `!\[([^\]]*)\]\[([^\]]+)\]`, // ![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[:3] { // First three 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] {
@@ -146,18 +146,18 @@ func extractImageReferences(content string) []string {
} else {
url = match[1] // For <img src="url"> (both with and without quotes)
}
-
+
// Clean and validate URL
url = strings.TrimSpace(url)
-
+
// Handle markdown image titles - remove anything after a space or quote
if idx := strings.IndexAny(url, " \"'"); idx != -1 {
url = url[:idx]
}
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] {
@@ -181,7 +181,7 @@ func extractImageReferences(content string) []string {
}
}
}
-
+
// Handle reference-style images
refPattern := regexp.MustCompile(patterns[4])
refMatches := refPattern.FindAllStringSubmatch(content, -1)
@@ -189,7 +189,7 @@ func extractImageReferences(content string) []string {
for _, match := range refMatches {
refs[match[1]] = strings.TrimSpace(match[2])
}
-
+
// Find reference-style image uses
refUsePattern := regexp.MustCompile(patterns[3])
refUseMatches := refUsePattern.FindAllStringSubmatch(content, -1)
@@ -202,7 +202,7 @@ func extractImageReferences(content string) []string {
}
}
}
-
+
return images
}
@@ -220,7 +220,7 @@ func isImageFile(url string) bool {
// isGitHostedImage checks if URL is from GitHub/Codeberg
func isGitHostedImage(url string) bool {
- return strings.Contains(url, "github.com") ||
+ return strings.Contains(url, "github.com") ||
strings.Contains(url, "githubusercontent.com") ||
strings.Contains(url, "codeberg.org") ||
strings.Contains(url, "codeberg.page")
@@ -233,18 +233,18 @@ func copyFile(src, dst string) error {
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()
}
@@ -256,11 +256,11 @@ func downloadImage(url, dst string) error {
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/language_detector.go b/internal/showcase/language_detector.go
index 0f356b8..692f048 100644
--- a/internal/showcase/language_detector.go
+++ b/internal/showcase/language_detector.go
@@ -14,111 +14,111 @@ import (
func detectLanguages(repoPath string) (languages []LanguageStats, documentation []LanguageStats, err error) {
languageLines := make(map[string]int)
documentationLines := make(map[string]int)
-
+
// Define common language extensions
langExtensions := map[string]string{
- ".go": "Go",
- ".py": "Python",
- ".js": "JavaScript",
- ".ts": "TypeScript",
- ".java": "Java",
- ".c": "C",
- ".cpp": "C++",
- ".cc": "C++",
- ".cxx": "C++",
- ".h": "C/C++",
- ".hpp": "C++",
- ".hxx": "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": "Shell",
- ".zsh": "Shell",
- ".fish": "Shell",
- ".pl": "Perl",
- ".pm": "Perl",
- ".raku": "Raku",
- ".rakumod": "Raku",
- ".rakudoc": "Raku",
+ ".go": "Go",
+ ".py": "Python",
+ ".js": "JavaScript",
+ ".ts": "TypeScript",
+ ".java": "Java",
+ ".c": "C",
+ ".cpp": "C++",
+ ".cc": "C++",
+ ".cxx": "C++",
+ ".h": "C/C++",
+ ".hpp": "C++",
+ ".hxx": "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": "Shell",
+ ".zsh": "Shell",
+ ".fish": "Shell",
+ ".pl": "Perl",
+ ".pm": "Perl",
+ ".raku": "Raku",
+ ".rakumod": "Raku",
+ ".rakudoc": "Raku",
".rakutest": "Raku",
- ".p6": "Raku",
- ".pm6": "Raku",
- ".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",
- ".html": "HTML",
- ".htm": "HTML",
- ".css": "CSS",
- ".scss": "SCSS",
- ".sass": "Sass",
- ".less": "Less",
- ".xml": "XML",
- ".json": "JSON",
- ".yaml": "YAML",
- ".yml": "YAML",
- ".toml": "TOML",
- ".ini": "INI",
- ".cfg": "Config",
- ".conf": "Config",
- ".sql": "SQL",
- ".tf": "HCL",
- ".tfvars": "HCL",
- ".hcl": "HCL",
- ".awk": "AWK",
+ ".p6": "Raku",
+ ".pm6": "Raku",
+ ".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",
+ ".html": "HTML",
+ ".htm": "HTML",
+ ".css": "CSS",
+ ".scss": "SCSS",
+ ".sass": "Sass",
+ ".less": "Less",
+ ".xml": "XML",
+ ".json": "JSON",
+ ".yaml": "YAML",
+ ".yml": "YAML",
+ ".toml": "TOML",
+ ".ini": "INI",
+ ".cfg": "Config",
+ ".conf": "Config",
+ ".sql": "SQL",
+ ".tf": "HCL",
+ ".tfvars": "HCL",
+ ".hcl": "HCL",
+ ".awk": "AWK",
}
-
+
// Define documentation/text extensions
docExtensions := map[string]string{
- ".md": "Markdown",
- ".rst": "reStructuredText",
- ".tex": "LaTeX",
- ".txt": "Text",
- ".adoc": "AsciiDoc",
- ".org": "Org",
+ ".md": "Markdown",
+ ".rst": "reStructuredText",
+ ".tex": "LaTeX",
+ ".txt": "Text",
+ ".adoc": "AsciiDoc",
+ ".org": "Org",
}
// Special files that indicate specific languages
specialFiles := map[string]string{
- "makefile": "Make",
- "gnumakefile": "Make",
- "dockerfile": "Docker",
- "dockerfile.*": "Docker",
- "cmakelists.txt": "CMake",
- "rakefile": "Ruby",
- "gemfile": "Ruby",
- "package.json": "JavaScript",
- "cargo.toml": "Rust",
- "go.mod": "Go",
- "go.sum": "Go",
- "pom.xml": "Java",
- "build.gradle": "Gradle",
- "build.gradle.kts": "Kotlin",
- "requirements.txt": "Python",
- "setup.py": "Python",
- "pyproject.toml": "Python",
- "composer.json": "PHP",
- "*.dockerfile": "Docker",
- "containerfile": "Docker",
- "jenkinsfile": "Groovy",
- "vagrantfile": "Ruby",
+ "makefile": "Make",
+ "gnumakefile": "Make",
+ "dockerfile": "Docker",
+ "dockerfile.*": "Docker",
+ "cmakelists.txt": "CMake",
+ "rakefile": "Ruby",
+ "gemfile": "Ruby",
+ "package.json": "JavaScript",
+ "cargo.toml": "Rust",
+ "go.mod": "Go",
+ "go.sum": "Go",
+ "pom.xml": "Java",
+ "build.gradle": "Gradle",
+ "build.gradle.kts": "Kotlin",
+ "requirements.txt": "Python",
+ "setup.py": "Python",
+ "pyproject.toml": "Python",
+ "composer.json": "PHP",
+ "*.dockerfile": "Docker",
+ "containerfile": "Docker",
+ "jenkinsfile": "Groovy",
+ "vagrantfile": "Ruby",
}
// Count lines for each language
@@ -131,15 +131,15 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation
if info.IsDir() {
name := info.Name()
// Skip hidden directories and common non-code directories
- if strings.HasPrefix(name, ".") && name != "." ||
- name == "node_modules" ||
- name == "vendor" ||
- name == "target" ||
- name == "dist" ||
- name == "build" ||
- name == "out" ||
- name == "__pycache__" ||
- name == "coverage" {
+ if strings.HasPrefix(name, ".") && name != "." ||
+ name == "node_modules" ||
+ name == "vendor" ||
+ name == "target" ||
+ name == "dist" ||
+ name == "build" ||
+ name == "out" ||
+ name == "__pycache__" ||
+ name == "coverage" {
return filepath.SkipDir
}
return nil
@@ -157,7 +157,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation
// Determine the language or documentation type
var language string
var isDoc bool
-
+
// Check special files first
if lang, ok := specialFiles[basename]; ok {
language = lang
@@ -204,7 +204,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation
file.Close()
}
}
-
+
// If we identified a language, count its lines
if language != "" {
lines, err := countFileLines(path)
@@ -317,4 +317,4 @@ func FormatLanguagesWithPercentages(languages []LanguageStats) string {
}
return strings.Join(parts, ", ")
-} \ No newline at end of file
+}
diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go
index ca8af05..147e714 100644
--- a/internal/showcase/metadata.go
+++ b/internal/showcase/metadata.go
@@ -22,8 +22,8 @@ type RepoMetadata struct {
Languages []LanguageStats // Programming languages with usage statistics
Documentation []LanguageStats // Documentation/text files with usage statistics
CommitCount int
- LinesOfCode int // Lines of code (excluding documentation)
- LinesOfDocs int // Lines of documentation
+ LinesOfCode int // Lines of code (excluding documentation)
+ LinesOfDocs int // Lines of documentation
FirstCommitDate string
LastCommitDate string
License string
@@ -58,7 +58,7 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) {
loc += lang.Lines
}
metadata.LinesOfCode = loc
-
+
locDocs := 0
for _, doc := range metadata.Documentation {
locDocs += doc.Lines
@@ -101,7 +101,6 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) {
return metadata, nil
}
-
// getCommitCount returns the total number of commits
func getCommitCount(repoPath string) (int, error) {
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count")
@@ -126,7 +125,7 @@ func countLinesOfCode(repoPath string) (int, error) {
`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
@@ -264,12 +263,12 @@ func getAverageCommitAge(repoPath string, commitCount int) (float64, error) {
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++
@@ -310,12 +309,12 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
break
}
}
-
+
if latestTag == "" {
// No version-like tags found
return "", "", false, nil
}
-
+
// Get the date of the latest tag
cmd = exec.Command("git", "-C", repoPath, "log", "-1", "--format=%ai", latestTag)
dateOutput, err := cmd.Output()
@@ -323,7 +322,7 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
// Tag exists but couldn't get date
return latestTag, "", true, nil
}
-
+
// Extract just the date part (YYYY-MM-DD)
parts := strings.Fields(string(dateOutput))
tagDate := ""
@@ -339,24 +338,24 @@ func getLatestTag(repoPath string) (string, string, bool, error) {
func isVersionTag(tag string) bool {
// Remove 'v' prefix if present
versionStr := strings.TrimPrefix(tag, "v")
-
+
// Check if the remaining string contains at least one digit and one dot
hasDigit := false
hasDot := false
-
+
for _, ch := range versionStr {
if ch >= '0' && ch <= '9' {
hasDigit = true
} else if ch == '.' {
hasDot = true
- } else if ch != '-' && ch != '+' && ch != '_' &&
- (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') {
+ } else if ch != '-' && ch != '+' && ch != '_' &&
+ (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') {
// Allow alphanumeric characters and common separators
// but anything else makes it not a version
return false
}
}
-
+
// Must have at least one digit, and either:
// - have a dot (e.g., 1.0, 0.1.2)
// - be just digits (e.g., 2, 2024)
@@ -367,6 +366,6 @@ func isVersionTag(tag string) bool {
return true
}
}
-
+
return hasDigit && hasDot
-} \ No newline at end of file
+}
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index 01e0e2b..1c057f3 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -1,8 +1,8 @@
package showcase
import (
- "context"
- "encoding/json"
+ "context"
+ "encoding/json"
"fmt"
"os"
"os/exec"
@@ -51,7 +51,7 @@ func New(cfg *config.Config, workDir string) *Generator {
return &Generator{
config: cfg,
workDir: workDir,
- aiTool: "claude", // default to claude
+ aiTool: "amp", // default to amp
}
}
@@ -157,25 +157,25 @@ 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)
- defer cancel()
- cmd := exec.CommandContext(ctx, name, args...)
- out, err := cmd.CombinedOutput()
- if ctx.Err() == context.DeadlineExceeded {
- return "", fmt.Errorf("command timed out")
- }
- if err != nil {
- // include a snippet of output for debugging
- msg := strings.TrimSpace(string(out))
- if len(msg) > 300 {
- msg = msg[:300] + "..."
- }
- if msg != "" {
- return "", fmt.Errorf("%v: %s", err, msg)
- }
- return "", err
- }
- return string(out), nil
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, name, args...)
+ out, err := cmd.CombinedOutput()
+ if ctx.Err() == context.DeadlineExceeded {
+ return "", fmt.Errorf("command timed out")
+ }
+ if err != nil {
+ // include a snippet of output for debugging
+ msg := strings.TrimSpace(string(out))
+ if len(msg) > 300 {
+ msg = msg[:300] + "..."
+ }
+ if msg != "" {
+ return "", fmt.Errorf("%v: %s", err, msg)
+ }
+ return "", err
+ }
+ return string(out), nil
}
// getRepositories returns a list of repository directories in the work directory
@@ -222,35 +222,48 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
}
}
- // Determine which AI tool to use (only if we need to run it)
- // Prefer hexai if available when default tool is "claude" (aligns with release flow)
- selectedTool := g.aiTool
- if !haveCachedSummary {
- switch g.aiTool {
- case "claude", "claude-code", "":
- // Try hexai -> claude -> aichat
- if _, err := exec.LookPath("hexai"); err == nil {
- selectedTool = "hexai"
- } else if _, err := exec.LookPath("claude"); err == nil {
- selectedTool = "claude"
- } else if _, err := exec.LookPath("aichat"); err == nil {
- selectedTool = "aichat"
- } else {
- // No AI tool available; fall back to README-based summary later
- selectedTool = ""
- }
- case "hexai", "aichat":
- if _, err := exec.LookPath(g.aiTool); err != nil {
- // Requested tool missing; fall back to README-based summary later
- selectedTool = ""
- } else {
- selectedTool = g.aiTool
- }
- default:
- // Unsupported tool configured; fall back to README-based summary later
- selectedTool = ""
- }
- }
+ // Determine which AI tool to use (only if we need to run it)
+ // Prefer amp if available when default tool is "" (aligns with release flow)
+ selectedTool := g.aiTool
+ if !haveCachedSummary {
+ switch g.aiTool {
+ case "amp", "":
+ // Try amp -> hexai -> claude -> aichat
+ if _, err := exec.LookPath("amp"); err == nil {
+ selectedTool = "amp"
+ } else if _, err := exec.LookPath("hexai"); err == nil {
+ selectedTool = "hexai"
+ } else if _, err := exec.LookPath("claude"); err == nil {
+ selectedTool = "claude"
+ } else if _, err := exec.LookPath("aichat"); err == nil {
+ selectedTool = "aichat"
+ } else {
+ // No AI tool available; fall back to README-based summary later
+ selectedTool = ""
+ }
+ case "claude", "claude-code":
+ // Try claude -> hexai -> aichat
+ if _, err := exec.LookPath("claude"); err == nil {
+ selectedTool = "claude"
+ } else if _, err := exec.LookPath("hexai"); err == nil {
+ selectedTool = "hexai"
+ } else if _, err := exec.LookPath("aichat"); err == nil {
+ selectedTool = "aichat"
+ } else {
+ selectedTool = ""
+ }
+ case "hexai", "aichat":
+ if _, err := exec.LookPath(g.aiTool); err != nil {
+ // Requested tool missing; fall back to README-based summary later
+ selectedTool = ""
+ } else {
+ selectedTool = g.aiTool
+ }
+ default:
+ // Unsupported tool configured; fall back to README-based summary later
+ selectedTool = ""
+ }
+ }
// Change to repository directory
originalDir, err := os.Getwd()
@@ -273,50 +286,78 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
// Get the summary - either from cache or by running AI tool
var summary string
- if haveCachedSummary {
- summary = cachedSummary
- fmt.Printf("Using cached AI summary\n")
- } else {
- prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative."
-
- var cmd *exec.Cmd
-
- switch selectedTool {
- case "claude":
- fmt.Printf("Running Claude command:\n")
- fmt.Printf(" claude --model sonnet \"%s\"\n", prompt)
- cmd = exec.Command("claude", "--model", "sonnet", prompt)
- case "hexai":
- // Use README content as stdin and pass the prompt as argument
- fmt.Printf("Running hexai command (stdin payload)\n")
- // Find README file
- readmeFiles := []string{
- "README.md", "readme.md", "Readme.md",
- "README.MD", "README.txt", "readme.txt",
- "README", "readme",
- }
- var readmeContent []byte
- var readmeFound bool
- for _, readmeFile := range readmeFiles {
- content, err := os.ReadFile(readmeFile)
- if err == nil {
- readmeContent = content
- readmeFound = true
- fmt.Printf(" Using %s as input\n", readmeFile)
- break
- }
- }
- if readmeFound {
- fmt.Printf(" echo <README content> | hexai \"%s\"\n", prompt)
- cmd = exec.Command("hexai", prompt)
- cmd.Stdin = strings.NewReader(string(readmeContent))
- } else {
- // Will fall back below
- cmd = nil
- }
- case "aichat":
- // For aichat, we need to read README.md and pipe it to aichat
- fmt.Printf("Running aichat command:\n")
+ if haveCachedSummary {
+ summary = cachedSummary
+ fmt.Printf("Using cached AI summary\n")
+ } else {
+ prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative."
+
+ var cmd *exec.Cmd
+
+ switch selectedTool {
+ case "amp":
+ // Use README content as stdin and pass the prompt as --execute argument
+ fmt.Printf("Running amp command (stdin payload)\n")
+ // Find README file
+ readmeFiles := []string{
+ "README.md", "readme.md", "Readme.md",
+ "README.MD", "README.txt", "readme.txt",
+ "README", "readme",
+ }
+ var readmeContent []byte
+ var readmeFound bool
+ for _, readmeFile := range readmeFiles {
+ content, err := os.ReadFile(readmeFile)
+ if err == nil {
+ readmeContent = content
+ readmeFound = true
+ fmt.Printf(" Using %s as input\n", readmeFile)
+ break
+ }
+ }
+ if readmeFound {
+ fmt.Printf(" echo <README content> | amp --execute \"%s\"\n", prompt)
+ cmd = exec.Command("amp", "--execute", prompt)
+ cmd.Stdin = strings.NewReader(string(readmeContent))
+ } else {
+ // Will fall back below
+ cmd = nil
+ }
+ case "claude":
+ fmt.Printf("Running Claude command:\n")
+ fmt.Printf(" claude --model sonnet \"%s\"\n", prompt)
+ cmd = exec.Command("claude", "--model", "sonnet", prompt)
+ case "hexai":
+ // Use README content as stdin and pass the prompt as argument
+ fmt.Printf("Running hexai command (stdin payload)\n")
+ // Find README file
+ readmeFiles := []string{
+ "README.md", "readme.md", "Readme.md",
+ "README.MD", "README.txt", "readme.txt",
+ "README", "readme",
+ }
+ var readmeContent []byte
+ var readmeFound bool
+ for _, readmeFile := range readmeFiles {
+ content, err := os.ReadFile(readmeFile)
+ if err == nil {
+ readmeContent = content
+ readmeFound = true
+ fmt.Printf(" Using %s as input\n", readmeFile)
+ break
+ }
+ }
+ if readmeFound {
+ fmt.Printf(" echo <README content> | hexai \"%s\"\n", prompt)
+ cmd = exec.Command("hexai", prompt)
+ cmd.Stdin = strings.NewReader(string(readmeContent))
+ } else {
+ // Will fall back below
+ cmd = nil
+ }
+ case "aichat":
+ // For aichat, we need to read README.md and pipe it to aichat
+ fmt.Printf("Running aichat command:\n")
// Find README file
readmeFiles := []string{
@@ -337,46 +378,46 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
}
}
- if readmeFound {
- fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt)
- cmd = exec.Command("aichat", prompt)
- cmd.Stdin = strings.NewReader(string(readmeContent))
- } else {
- // Will fall back below
- cmd = nil
- }
- default:
- // No/unsupported tool; will fall back below
- cmd = nil
- }
-
- if cmd != nil {
- if output, err := cmd.Output(); err == nil {
- summary = strings.TrimSpace(string(output))
- }
- }
-
- // Fallback: create a minimal summary from README if AI unavailable/failed
- if summary == "" {
- readmeFiles := []string{
- "README.md", "readme.md", "Readme.md",
- "README.MD", "README.txt", "readme.txt",
- "README", "readme",
- }
- for _, readmeFile := range readmeFiles {
- if content, err := os.ReadFile(readmeFile); err == nil {
- parts := strings.Split(strings.TrimSpace(string(content)), "\n\n")
- if len(parts) > 0 {
- summary = strings.TrimSpace(parts[0])
- break
- }
- }
- }
- if summary == "" {
- summary = fmt.Sprintf("%s: source code repository.", repoName)
- }
- }
- }
+ if readmeFound {
+ fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt)
+ cmd = exec.Command("aichat", prompt)
+ cmd.Stdin = strings.NewReader(string(readmeContent))
+ } else {
+ // Will fall back below
+ cmd = nil
+ }
+ default:
+ // No/unsupported tool; will fall back below
+ cmd = nil
+ }
+
+ if cmd != nil {
+ if output, err := cmd.Output(); err == nil {
+ summary = strings.TrimSpace(string(output))
+ }
+ }
+
+ // Fallback: create a minimal summary from README if AI unavailable/failed
+ if summary == "" {
+ readmeFiles := []string{
+ "README.md", "readme.md", "Readme.md",
+ "README.MD", "README.txt", "readme.txt",
+ "README", "readme",
+ }
+ for _, readmeFile := range readmeFiles {
+ if content, err := os.ReadFile(readmeFile); err == nil {
+ parts := strings.Split(strings.TrimSpace(string(content)), "\n\n")
+ if len(parts) > 0 {
+ summary = strings.TrimSpace(parts[0])
+ break
+ }
+ }
+ }
+ if summary == "" {
+ summary = fmt.Sprintf("%s: source code repository.", repoName)
+ }
+ }
+ }
// Build URLs
codebergURL := ""
diff --git a/internal/state/state.go b/internal/state/state.go
index af90879..c2a62ed 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -76,4 +76,4 @@ func (s *State) HasRunWithinWeek() bool {
// UpdateBatchRunTime updates the last batch run timestamp to now
func (s *State) UpdateBatchRunTime() {
s.LastBatchRun = time.Now()
-} \ No newline at end of file
+}
diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go
index 3038fcc..7499996 100644
--- a/internal/sync/branch_analyzer.go
+++ b/internal/sync/branch_analyzer.go
@@ -12,28 +12,28 @@ import (
// BranchInfo holds information about a branch
type BranchInfo struct {
- Name string
- LastCommit time.Time
- Remote string
- IsAbandoned bool
- AbandonReason string
+ Name string
+ LastCommit time.Time
+ Remote string
+ IsAbandoned bool
+ AbandonReason string
RemotesWithBranch []string // List of remotes that have this branch
}
// AbandonedBranchReport holds the analysis results
type AbandonedBranchReport struct {
- MainBranchUpdated bool
- MainBranchLastCommit time.Time
- AbandonedBranches []BranchInfo
+ MainBranchUpdated bool
+ MainBranchLastCommit time.Time
+ AbandonedBranches []BranchInfo
AbandonedIgnoredBranches []BranchInfo // Abandoned branches that match exclusion patterns
- TotalBranches int
- TotalIgnoredBranches int
+ TotalBranches int
+ TotalIgnoredBranches int
}
// analyzeAbandonedBranches analyzes branches to find abandoned ones
func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
report := &AbandonedBranchReport{
- AbandonedBranches: []BranchInfo{},
+ AbandonedBranches: []BranchInfo{},
AbandonedIgnoredBranches: []BranchInfo{},
}
@@ -42,11 +42,11 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
if err != nil {
return nil, fmt.Errorf("failed to get branches: %w", err)
}
-
+
// Filter branches based on exclusion patterns
branches := s.branchFilter.FilterBranches(allBranches)
report.TotalBranches = len(branches)
-
+
// Get excluded branches for separate analysis
excludedBranches := s.branchFilter.GetExcludedBranches(allBranches)
report.TotalIgnoredBranches = len(excludedBranches)
@@ -69,7 +69,7 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
// Analyze each branch
sixMonthsAgo := time.Now().AddDate(0, -6, 0)
-
+
for _, branch := range branches {
// Skip main/master branches
if branch == "main" || branch == "master" {
@@ -89,7 +89,7 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
report.AbandonedBranches = append(report.AbandonedBranches, *branchInfo)
}
}
-
+
// Also analyze ignored branches for abandonment
for _, branch := range excludedBranches {
// Skip main/master branches even if they match exclusion patterns
@@ -127,7 +127,7 @@ func (s *Syncer) findMainBranch(branches []string) string {
// getBranchInfo gets information about a specific branch
func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) {
info := &BranchInfo{
- Name: branch,
+ Name: branch,
RemotesWithBranch: []string{},
}
@@ -137,18 +137,18 @@ func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) {
for i := range s.config.Organizations {
org := &s.config.Organizations[i]
-
+
// Skip backup locations if backup is not enabled
if org.BackupLocation && !s.backupEnabled {
continue
}
-
+
remoteName := s.getRemoteName(org)
if s.remoteBranchExists(remoteName, branch) {
// Add this remote to the list
info.RemotesWithBranch = append(info.RemotesWithBranch, remoteName)
-
+
// Get last commit date for this branch on this remote
commitTime, err := s.getLastCommitTime(remoteName, branch)
if err == nil && (latestCommit.IsZero() || commitTime.After(latestCommit)) {
@@ -211,22 +211,22 @@ func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n🔍 Abandoned branches in %s:\n", repoName))
sb.WriteString(fmt.Sprintf(" Main branch last updated: %s\n", report.MainBranchLastCommit.Format("2006-01-02")))
-
+
if len(report.AbandonedBranches) > 0 {
sb.WriteString(fmt.Sprintf(" Found %d abandoned branches (no commits for 6+ months):\n\n", len(report.AbandonedBranches)))
for _, branch := range report.AbandonedBranches {
- sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n",
- branch.Name,
+ sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n",
+ branch.Name,
branch.LastCommit.Format("2006-01-02"),
branch.AbandonReason))
}
}
-
+
if len(report.AbandonedIgnoredBranches) > 0 {
sb.WriteString(fmt.Sprintf("\n Found %d abandoned IGNORED branches (no commits for 6+ months):\n\n", len(report.AbandonedIgnoredBranches)))
for _, branch := range report.AbandonedIgnoredBranches {
- sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n",
- branch.Name,
+ sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n",
+ branch.Name,
branch.LastCommit.Format("2006-01-02"),
branch.AbandonReason))
}
@@ -244,7 +244,7 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string {
totalAbandoned := 0
totalAbandonedIgnored := 0
reposWithAbandoned := 0
-
+
for _, report := range s.abandonedReports {
if len(report.AbandonedBranches) > 0 || len(report.AbandonedIgnoredBranches) > 0 {
totalAbandoned += len(report.AbandonedBranches)
@@ -274,30 +274,30 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string {
if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 {
continue
}
-
+
totalBranches := len(report.AbandonedBranches) + len(report.AbandonedIgnoredBranches)
sb.WriteString(fmt.Sprintf("📁 %s (%d branches):\n", repoName, totalBranches))
-
+
// Regular abandoned branches
if len(report.AbandonedBranches) > 0 {
sb.WriteString(" Regular branches:\n")
for _, branch := range report.AbandonedBranches {
- sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n",
- branch.Name,
+ sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n",
+ branch.Name,
branch.LastCommit.Format("2006-01-02")))
}
}
-
+
// Ignored abandoned branches
if len(report.AbandonedIgnoredBranches) > 0 {
sb.WriteString(" Ignored branches:\n")
for _, branch := range report.AbandonedIgnoredBranches {
- sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n",
- branch.Name,
+ sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n",
+ branch.Name,
branch.LastCommit.Format("2006-01-02")))
}
}
-
+
sb.WriteString("\n")
}
@@ -324,7 +324,7 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName
sb.WriteString("# === REGULAR BRANCHES ===\n")
for _, branch := range report.AbandonedBranches {
sb.WriteString(fmt.Sprintf("# Branch: %s (last commit: %s)\n", branch.Name, branch.LastCommit.Format("2006-01-02")))
-
+
// Delete from all remotes that have this branch
if len(branch.RemotesWithBranch) > 0 {
sb.WriteString("# Delete from remotes:\n")
@@ -332,19 +332,19 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName
sb.WriteString(fmt.Sprintf("git push %s --delete %s\n", remote, branch.Name))
}
}
-
+
// Delete local branch
sb.WriteString("# Delete local branch:\n")
sb.WriteString(fmt.Sprintf("git branch -D %s\n\n", branch.Name))
}
}
-
+
// Process ignored abandoned branches
if len(report.AbandonedIgnoredBranches) > 0 {
sb.WriteString("# === IGNORED BRANCHES ===\n")
for _, branch := range report.AbandonedIgnoredBranches {
sb.WriteString(fmt.Sprintf("# Branch: %s (last commit: %s) [IGNORED]\n", branch.Name, branch.LastCommit.Format("2006-01-02")))
-
+
// Delete from all remotes that have this branch
if len(branch.RemotesWithBranch) > 0 {
sb.WriteString("# Delete from remotes:\n")
@@ -352,7 +352,7 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName
sb.WriteString(fmt.Sprintf("git push %s --delete %s\n", remote, branch.Name))
}
}
-
+
// Delete local branch
sb.WriteString("# Delete local branch:\n")
sb.WriteString(fmt.Sprintf("git branch -D %s\n\n", branch.Name))
@@ -375,7 +375,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
totalAbandoned += len(report.AbandonedBranches)
totalIgnored += len(report.AbandonedIgnoredBranches)
}
-
+
if totalAbandoned == 0 && totalIgnored == 0 {
return "", nil
}
@@ -540,7 +540,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
fmt.Fprintf(file, " fi\n")
fmt.Fprintf(file, "else\n")
fmt.Fprintf(file, " echo \" 🔸 Deleting branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02"))
-
+
// Check if we're on the branch to be deleted, and switch to main/master if so
fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n")
fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n")
@@ -558,12 +558,12 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name)
fmt.Fprintf(file, " continue\n")
fmt.Fprintf(file, " fi\n")
-
+
// Delete from remotes
for _, remote := range branch.RemotesWithBranch {
fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name)
}
-
+
// Delete local branch
fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name)
fmt.Fprintf(file, "fi\n\n")
@@ -580,7 +580,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
fmt.Fprintf(file, " fi\n")
fmt.Fprintf(file, "else\n")
fmt.Fprintf(file, " echo \" 🔹 Deleting ignored branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02"))
-
+
// Check if we're on the branch to be deleted, and switch to main/master if so
fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n")
fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n")
@@ -598,12 +598,12 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name)
fmt.Fprintf(file, " continue\n")
fmt.Fprintf(file, " fi\n")
-
+
// Delete from remotes
for _, remote := range branch.RemotesWithBranch {
fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name)
}
-
+
// Delete local branch
fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name)
fmt.Fprintf(file, "fi\n\n")
@@ -634,4 +634,4 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
}
return scriptPath, nil
-} \ No newline at end of file
+}
diff --git a/internal/sync/branch_filter.go b/internal/sync/branch_filter.go
index 3a4fd40..33f7895 100644
--- a/internal/sync/branch_filter.go
+++ b/internal/sync/branch_filter.go
@@ -77,7 +77,7 @@ func FormatExclusionReport(excludedBranches []string, patterns []string) string
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n🚫 Excluded %d branches based on patterns:\n", len(excludedBranches)))
-
+
// Show patterns
sb.WriteString(" Patterns: ")
for i, pattern := range patterns {
@@ -87,12 +87,12 @@ func FormatExclusionReport(excludedBranches []string, patterns []string) string
sb.WriteString(fmt.Sprintf("'%s'", pattern))
}
sb.WriteString("\n")
-
+
// Show excluded branches
sb.WriteString(" Excluded branches:\n")
for _, branch := range excludedBranches {
sb.WriteString(fmt.Sprintf(" - %s\n", branch))
}
-
+
return sb.String()
-} \ No newline at end of file
+}
diff --git a/internal/sync/branch_sync.go b/internal/sync/branch_sync.go
index 02f8964..eafb551 100644
--- a/internal/sync/branch_sync.go
+++ b/internal/sync/branch_sync.go
@@ -9,7 +9,7 @@ import (
// trackRemotesWithBranch finds which remotes have a specific branch
func (s *Syncer) trackRemotesWithBranch(branch string, remotes map[string]*config.Organization) map[string]bool {
remotesWithBranch := make(map[string]bool)
-
+
for remoteName, org := range remotes {
// Skip checking backup locations as we don't sync from them
if org.BackupLocation {
@@ -19,7 +19,7 @@ func (s *Syncer) trackRemotesWithBranch(branch string, remotes map[string]*confi
remotesWithBranch[remoteName] = true
}
}
-
+
return remotesWithBranch
}
@@ -29,14 +29,14 @@ func mergeFromRemotes(branch string, remotesWithBranch map[string]bool) error {
fmt.Printf(" Branch %s is local only, will push to all remotes\n", branch)
return nil
}
-
+
// Merge changes from all remotes that have this branch
for remoteName := range remotesWithBranch {
if err := mergeBranch(remoteName, branch); err != nil {
return err
}
}
-
+
return nil
}
@@ -56,7 +56,7 @@ func pushToAllRemotes(branch string, remotes map[string]*config.Organization, re
return err
}
}
-
+
return nil
}
@@ -69,4 +69,4 @@ func (s *Syncer) syncAllBranches(branches []string, remotes map[string]*config.O
}
}
return nil
-} \ No newline at end of file
+}
diff --git a/internal/sync/git_operations.go b/internal/sync/git_operations.go
index efa27f5..0bd698e 100644
--- a/internal/sync/git_operations.go
+++ b/internal/sync/git_operations.go
@@ -242,24 +242,24 @@ func createSSHBareRepository(sshHost, repoPath string) error {
if len(parts) != 2 {
return fmt.Errorf("invalid SSH host format: %s", sshHost)
}
-
+
userHost := parts[0]
basePath := parts[1]
-
+
// Full path to the repository
fullRepoPath := fmt.Sprintf("%s/%s.git", basePath, repoPath)
-
+
fmt.Printf("Creating bare repository at %s:%s\n", userHost, fullRepoPath)
-
+
// Create the repository directory and initialize as bare
commands := fmt.Sprintf("mkdir -p %s && cd %s && git init --bare", fullRepoPath, fullRepoPath)
cmd := exec.Command("ssh", userHost, commands)
output, err := cmd.CombinedOutput()
-
+
if err != nil {
return fmt.Errorf("failed to create bare repository: %w\n%s", err, string(output))
}
-
+
fmt.Printf("Successfully created bare repository at %s:%s\n", userHost, fullRepoPath)
return nil
}
@@ -280,18 +280,18 @@ func pushBranchWithBackupSupport(remoteName, branch string, remoteHasBranch bool
if err != nil {
return fmt.Errorf("failed to get remote URL: %w", err)
}
-
+
// Extract repo name from URL
repoName := extractRepoName(remoteURL)
if repoName == "" {
return fmt.Errorf("failed to extract repository name from URL: %s", remoteURL)
}
-
+
// Create the bare repository
if err := createSSHBareRepository(org.Host, repoName); err != nil {
return fmt.Errorf("failed to create SSH repository: %w", err)
}
-
+
// Try pushing again
cmd = exec.Command("git", "push", remoteName, branch, "--tags")
if err := cmd.Run(); err != nil {
@@ -300,7 +300,7 @@ func pushBranchWithBackupSupport(remoteName, branch string, remoteHasBranch bool
fmt.Printf(" Successfully pushed to newly created backup repository\n")
return nil
}
-
+
fmt.Printf(" Note: Remote repository %s does not exist - must be created manually\n", remoteName)
fmt.Printf(" Skipping push to %s\n", remoteName)
return nil // Not an error, just skip
@@ -341,7 +341,7 @@ func getRemoteURL(remoteName string) (string, error) {
func extractRepoName(url string) string {
// Remove .git suffix if present
url = strings.TrimSuffix(url, ".git")
-
+
// Extract the last component of the path
parts := strings.Split(url, "/")
if len(parts) > 0 {
diff --git a/internal/sync/repository_setup.go b/internal/sync/repository_setup.go
index 3ebafbd..7e2c40e 100644
--- a/internal/sync/repository_setup.go
+++ b/internal/sync/repository_setup.go
@@ -54,12 +54,12 @@ func (s *Syncer) setupNewRepository(repoPath string) error {
continue // Skip the first org we already cloned from
}
org := &s.config.Organizations[i]
-
+
// Skip backup locations if backup is not enabled
if org.BackupLocation && !s.backupEnabled {
continue
}
-
+
if err := s.addRemote(repoPath, org); err != nil {
return fmt.Errorf("failed to add remote %s: %w", s.getRemoteName(org), err)
}
@@ -75,12 +75,12 @@ func (s *Syncer) setupExistingRepository(repoPath string) error {
// Check and add any missing remotes
for i := range s.config.Organizations {
org := &s.config.Organizations[i]
-
+
// Skip backup locations if backup is not enabled
if org.BackupLocation && !s.backupEnabled {
continue
}
-
+
remoteName := s.getRemoteName(org)
// Check if remote exists
@@ -115,14 +115,14 @@ func (s *Syncer) getRemotesMap() map[string]*config.Organization {
remotes := make(map[string]*config.Organization)
for i := range s.config.Organizations {
org := &s.config.Organizations[i]
-
+
// Skip backup locations if backup is not enabled
if org.BackupLocation && !s.backupEnabled {
continue
}
-
+
remoteName := s.getRemoteName(org)
remotes[remoteName] = org
}
return remotes
-} \ No newline at end of file
+}
diff --git a/internal/sync/sync.go b/internal/sync/sync.go
index 0f51689..0f2f479 100644
--- a/internal/sync/sync.go
+++ b/internal/sync/sync.go
@@ -12,9 +12,9 @@ import (
// Syncer handles repository synchronization between organizations
type Syncer struct {
- config *config.Config
- workDir string
- repoName string
+ config *config.Config
+ workDir string
+ repoName string
abandonedReports map[string]*AbandonedBranchReport // Collects reports across repos
branchFilter *BranchFilter // Filter for excluding branches
backupEnabled bool // Whether to sync to backup locations
@@ -32,8 +32,8 @@ func New(cfg *config.Config, workDir string) *Syncer {
}
return &Syncer{
- config: cfg,
- workDir: workDir,
+ config: cfg,
+ workDir: workDir,
abandonedReports: make(map[string]*AbandonedBranchReport),
branchFilter: branchFilter,
backupEnabled: false, // Default to false, will be set via SetBackupEnabled
@@ -82,7 +82,7 @@ func (s *Syncer) SyncRepository(repoName string) error {
// Filter branches based on exclusion patterns
branches := s.branchFilter.FilterBranches(allBranches)
excludedBranches := s.branchFilter.GetExcludedBranches(allBranches)
-
+
// Report excluded branches if any
if exclusionReport := FormatExclusionReport(excludedBranches, s.config.ExcludeBranches); exclusionReport != "" {
fmt.Print(exclusionReport)
@@ -118,12 +118,12 @@ func (s *Syncer) SyncRepository(repoName string) error {
// This is used for showcase-only mode
func (s *Syncer) EnsureRepositoryCloned(repoName string) error {
s.repoName = repoName
-
+
// Create work directory if it doesn't exist
if err := os.MkdirAll(s.workDir, 0755); err != nil {
return fmt.Errorf("failed to create work directory: %w", err)
}
-
+
// Check if repository already exists
repoPath := filepath.Join(s.workDir, repoName)
if _, err := os.Stat(repoPath); err == nil {
@@ -131,10 +131,10 @@ func (s *Syncer) EnsureRepositoryCloned(repoName string) error {
fmt.Printf(" Repository %s already exists locally\n", repoName)
return nil
}
-
+
// Repository doesn't exist, clone it
fmt.Printf(" Cloning %s...\n", repoName)
-
+
// Find first non-backup organization to clone from
var sourceOrg *config.Organization
for i := range s.config.Organizations {
@@ -143,16 +143,16 @@ func (s *Syncer) EnsureRepositoryCloned(repoName string) error {
break
}
}
-
+
if sourceOrg == nil {
return fmt.Errorf("no non-backup organizations configured to clone from")
}
-
+
// Clone the repository
if err := s.cloneRepository(sourceOrg, repoPath); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
-
+
fmt.Printf(" Successfully cloned %s\n", repoName)
return nil
}
@@ -282,7 +282,7 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati
if stashed {
defer popStash()
}
-
+
// Create or checkout the branch
if err := s.checkoutBranch(branch); err != nil {
return fmt.Errorf("failed to checkout branch %s: %w", branch, err)
@@ -307,7 +307,7 @@ func (s *Syncer) handleWorkingDirectoryState() (bool, error) {
if err != nil || statusStr == "" {
return false, nil
}
-
+
if hasConflicts {
// Get absolute path for clarity
absPath, err := filepath.Abs(s.workDir)
@@ -316,7 +316,7 @@ func (s *Syncer) handleWorkingDirectoryState() (bool, error) {
}
return false, fmt.Errorf("repository has unresolved merge conflicts\nPlease resolve conflicts in: %s\nOr delete the directory to start fresh: rm -rf %s", absPath, absPath)
}
-
+
// If we have uncommitted changes but no conflicts, try to stash them
if err := stashChanges(); err != nil {
return false, fmt.Errorf("failed to stash changes: %w", err)
@@ -380,13 +380,13 @@ func (s *Syncer) getRemoteName(org *config.Organization) string {
func (s *Syncer) filterBackupBranches(output []byte) []byte {
lines := strings.Split(string(output), "\n")
var filtered []string
-
+
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
-
+
// Check if this branch is from a backup remote
isBackup := false
for i := range s.config.Organizations {
@@ -399,11 +399,11 @@ func (s *Syncer) filterBackupBranches(output []byte) []byte {
}
}
}
-
+
if !isBackup {
filtered = append(filtered, line)
}
}
-
+
return []byte(strings.Join(filtered, "\n"))
}
diff --git a/internal/version/version.go b/internal/version/version.go
index 7872b56..dda0d9a 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.9.2"
+ Version = "0.10.0"
// GitCommit is the git commit hash at build time
GitCommit = "unknown"