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 12a07a9..f8f3ebb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,11 +28,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 noColor bool ) @@ -61,7 +62,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. @@ -94,6 +95,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.Flags().BoolVar(&noColor, "no-color", false, "Disable colored output") @@ -292,21 +294,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\n", 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++ { @@ -317,7 +329,7 @@ func runRootCommand(ctx context.Context) error { }() } - for _, repo := range nonArchivedRepos { + for _, repo := range repos { jobs <- repo } close(jobs) @@ -327,7 +339,7 @@ func runRootCommand(ctx context.Context) error { close(results) }() - errorCount, _ := collectAndDisplay(results, nonArchivedCount, verbose, startTime) + errorCount, _ := collectAndDisplay(results, repoCount, verbose, startTime) if errorCount > 0 { return fmt.Errorf("failed to process %d repositories", errorCount)