From 8effe3bb62cac47bc3e9a8e2610c7a9d3cd4dfaf Mon Sep 17 00:00:00 2001 From: Bear Date: Mon, 9 Mar 2026 14:56:50 -0600 Subject: [PATCH 1/2] feat: add --include-archived flag and prune-archived subcommand Add --include-archived flag to include archived repos during clone/fetch. Add prune-archived subcommand to scan local directories and remove any that correspond to archived repositories on GitHub (dry-run by default). --- cmd/prune_archived.go | 119 ++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 46 ++++++++++------ 2 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 cmd/prune_archived.go diff --git a/cmd/prune_archived.go b/cmd/prune_archived.go new file mode 100644 index 0000000..2fd591e --- /dev/null +++ b/cmd/prune_archived.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/google/go-github/v84/github" + "github.com/spf13/cobra" +) + +var ( + pruneOrg string + prunePath string + dryRun bool +) + +var pruneArchivedCmd = &cobra.Command{ + Use: "prune-archived", + Short: "Remove local directories for repositories that are archived on GitHub", + Long: `Scans directories in the target path and checks each one against the GitHub API. +Any directory that corresponds to an archived repository in the specified organization +will be removed. + +By default runs in dry-run mode (--dry-run) so you can preview what would be deleted. +Pass --dry-run=false to actually remove directories.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPruneArchived(cmd.Context()) + }, + SilenceUsage: true, +} + +func init() { + pruneArchivedCmd.Flags().StringVarP(&pruneOrg, "org", "o", "", "GitHub organization to check repositories against (required)") + pruneArchivedCmd.Flags().StringVarP(&prunePath, "path", "p", "", "Local path containing cloned repositories (required)") + pruneArchivedCmd.Flags().BoolVar(&dryRun, "dry-run", true, "Preview what would be removed without deleting anything") + + _ = pruneArchivedCmd.MarkFlagRequired("org") + _ = pruneArchivedCmd.MarkFlagRequired("path") + + rootCmd.AddCommand(pruneArchivedCmd) +} + +func runPruneArchived(ctx context.Context) error { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return fmt.Errorf("GITHUB_TOKEN not set: set it with: export GITHUB_TOKEN=\"your-personal-access-token\"") + } + + absPath, err := filepath.Abs(prunePath) + if err != nil { + return fmt.Errorf("resolving target path: %w", err) + } + + entries, err := os.ReadDir(absPath) + if err != nil { + return fmt.Errorf("reading target directory: %w", err) + } + + client := github.NewClient(nil).WithAuthToken(token) + + allRepos, err := fetchOrgRepos(ctx, client, pruneOrg) + if err != nil { + return err + } + + archivedSet := make(map[string]bool) + for _, repo := range allRepos { + if repo.GetArchived() { + archivedSet[repo.GetName()] = true + } + } + + removedCount := 0 + skippedCount := 0 + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + if !archivedSet[name] { + continue + } + + dirPath := filepath.Join(absPath, name) + + if dryRun { + fmt.Printf("[dry-run] would remove: %s\n", dirPath) + removedCount++ + continue + } + + fmt.Printf("Removing archived repository: %s\n", dirPath) + if err := os.RemoveAll(dirPath); err != nil { + fmt.Printf("Error removing %s: %v\n", dirPath, err) + skippedCount++ + continue + } + removedCount++ + } + + if dryRun { + fmt.Printf("\nDry-run complete: %d archived repositories would be removed\n", removedCount) + if removedCount > 0 { + fmt.Println("Run with --dry-run=false to actually remove them") + } + } else { + fmt.Printf("\nSummary: Removed %d archived repositories", removedCount) + if skippedCount > 0 { + fmt.Printf(" (%d failed)", skippedCount) + } + fmt.Println() + } + + return nil +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 400047b..1670540 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,11 +26,12 @@ var version = func() string { }() var ( - organization string - targetPath string - skipUpdate bool - verbose bool - parallelLimit int + organization string + targetPath string + skipUpdate bool + verbose bool + parallelLimit int + includeArchived bool ) const maxParallelLimit = 50 @@ -63,7 +64,7 @@ var rootCmd = &cobra.Command{ Short: "Clone non-archived repositories for a GitHub organization", Long: `This tool fetches all non-archived repositories for a specified GitHub organization and either clones them (if they don't exist locally) or fetches remote updates (if they do exist) -to a specified local path. +to a specified local path. Use --include-archived to also process archived repositories. The tool will never modify local branches - it only fetches remote tracking information for existing repositories. @@ -96,6 +97,7 @@ func init() { rootCmd.Flags().BoolVarP(&skipUpdate, "skip-update", "s", false, "Skip updating existing repositories") rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") rootCmd.Flags().IntVarP(¶llelLimit, "parallel", "j", 5, "Number of repositories to process in parallel") + rootCmd.Flags().BoolVar(&includeArchived, "include-archived", false, "Include archived repositories") _ = rootCmd.MarkFlagRequired("org") _ = rootCmd.MarkFlagRequired("path") @@ -291,21 +293,31 @@ func runRootCommand(ctx context.Context) error { return err } - nonArchivedRepos := filterNonArchived(allRepos) - nonArchivedCount := len(nonArchivedRepos) + var repos []*github.Repository + if includeArchived { + repos = allRepos + } else { + repos = filterNonArchived(allRepos) + } + repoCount := len(repos) + nonArchivedCount := len(filterNonArchived(allRepos)) fmt.Printf("Found %d repositories in organization %s (%d non-archived)\n", len(allRepos), organization, nonArchivedCount) - if nonArchivedCount == 0 { - fmt.Println("No non-archived repositories found. Nothing to process.") + if includeArchived { + fmt.Printf("Including archived repositories (--include-archived)\n") + } + + if repoCount == 0 { + fmt.Println("No repositories found. Nothing to process.") return nil } - fmt.Printf("Processing repositories with %d parallel workers\n", parallelLimit) + fmt.Printf("Processing %d repositories with %d parallel workers\n", repoCount, parallelLimit) - jobs := make(chan *github.Repository, nonArchivedCount) - results := make(chan repoResult, nonArchivedCount) + jobs := make(chan *github.Repository, repoCount) + results := make(chan repoResult, repoCount) var wg sync.WaitGroup for w := 0; w < parallelLimit; w++ { @@ -316,7 +328,7 @@ func runRootCommand(ctx context.Context) error { }() } - for _, repo := range nonArchivedRepos { + for _, repo := range repos { jobs <- repo } close(jobs) @@ -333,7 +345,7 @@ func runRootCommand(ctx context.Context) error { for result := range results { processedCount++ - fmt.Printf("[%d/%d] %s\n", processedCount, nonArchivedCount, result.message) + fmt.Printf("[%d/%d] %s\n", processedCount, repoCount, result.message) if !result.success { errorCount++ @@ -343,8 +355,8 @@ func runRootCommand(ctx context.Context) error { } } - fmt.Printf("\nSummary: Processed %d/%d non-archived repositories from organization %s\n", - processedCount, nonArchivedCount, organization) + fmt.Printf("\nSummary: Processed %d/%d repositories from organization %s\n", + processedCount, repoCount, organization) if errorCount > 0 { fmt.Printf("Encountered %d errors during processing\n", errorCount) From 49ea017c1ee94dec864d8ba6305d20a567da03ae Mon Sep 17 00:00:00 2001 From: Bear Date: Mon, 9 Mar 2026 15:00:45 -0600 Subject: [PATCH 2/2] fix: resolve merge conflict in result processing loop Remove duplicate old result-processing code that was kept alongside the new collectAndDisplay call during merge. Pass repoCount to respect --include-archived flag. --- cmd/root.go | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 401f964..f8f3ebb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -339,42 +339,7 @@ func runRootCommand(ctx context.Context) error { close(results) }() - processedCount := 0 - errorCount := 0 - var authErrors bool - - for result := range results { - processedCount++ - - fmt.Printf("[%d/%d] %s\n", processedCount, repoCount, result.message) - - if !result.success { - errorCount++ - if strings.Contains(result.message, "Authentication error detected") { - authErrors = true - } - } - } - - fmt.Printf("\nSummary: Processed %d/%d repositories from organization %s\n", - processedCount, repoCount, organization) - - if errorCount > 0 { - fmt.Printf("Encountered %d errors during processing\n", errorCount) - - if authErrors { - fmt.Printf("\nSome authentication errors were detected. Please verify your setup:\n") - fmt.Printf("1. SSH setup guide: https://docs.github.com/en/authentication/connecting-to-github-with-ssh\n") - fmt.Printf("2. Personal access token guide: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token\n") - } - } else { - fmt.Printf("All repositories processed successfully\n") - } - - fmt.Printf("\nNote: For existing repositories, only 'git fetch --all' was performed.\n") - fmt.Printf("Local branches were not modified. Use 'git merge' or 'git rebase' manually to update local branches.\n") - - errorCount, _ := collectAndDisplay(results, nonArchivedCount, verbose, startTime) + errorCount, _ := collectAndDisplay(results, repoCount, verbose, startTime) if errorCount > 0 { return fmt.Errorf("failed to process %d repositories", errorCount)