Skip to main content

vibe_workspace/git/
bulk_clone.rs

1use anyhow::{Context, Result};
2use console::style;
3use inquire::Confirm;
4use std::path::PathBuf;
5use std::time::{Duration, Instant};
6use tracing::{info, warn};
7
8use crate::git::provider::github_cli::GitHubCliProvider;
9use crate::git::{GitConfig, Repository};
10use crate::workspace::install::RepositoryInstaller;
11use crate::workspace::manager::WorkspaceManager;
12
13/// Options for bulk cloning operations
14#[derive(Debug, Clone)]
15pub struct BulkCloneOptions {
16    pub exclude_patterns: Vec<String>,
17    pub include_patterns: Vec<String>,
18    pub skip_existing: bool,
19    pub custom_path: Option<PathBuf>,
20    pub force: bool, // Skip confirmation prompts
21}
22
23impl Default for BulkCloneOptions {
24    fn default() -> Self {
25        Self {
26            exclude_patterns: Vec::new(),
27            include_patterns: Vec::new(),
28            skip_existing: true,
29            custom_path: None,
30            force: false,
31        }
32    }
33}
34
35/// Target type for bulk cloning
36#[derive(Debug, Clone, PartialEq)]
37#[allow(dead_code)]
38pub enum TargetType {
39    User,
40    Organization,
41    Unknown,
42}
43
44/// Result of filtering repositories before cloning
45#[derive(Debug, Clone)]
46pub struct FilterResult {
47    pub to_clone: Vec<Repository>,
48    pub skipped: Vec<SkippedRepository>,
49}
50
51/// Result of a bulk clone operation
52#[derive(Debug, Clone)]
53pub struct BulkCloneResult {
54    pub total_discovered: usize,
55    pub total_cloned: usize,
56    pub skipped: Vec<SkippedRepository>,
57    pub failed: Vec<FailedRepository>,
58    pub successful: Vec<String>,
59    pub duration: Duration,
60}
61
62/// Repository that was skipped during bulk cloning
63#[derive(Debug, Clone)]
64pub struct SkippedRepository {
65    pub name: String,
66    pub reason: SkipReason,
67}
68
69/// Reason why a repository was skipped
70#[derive(Debug, Clone)]
71#[allow(dead_code)]
72pub enum SkipReason {
73    #[allow(dead_code)]
74    AlreadyExists(PathBuf),
75    #[allow(dead_code)]
76    ExcludedByPattern(String),
77    NotIncludedByPattern,
78    Fork,
79    Archived,
80}
81
82/// Repository that failed to clone
83#[derive(Debug, Clone)]
84pub struct FailedRepository {
85    pub name: String,
86    pub error: String,
87    pub url: String,
88}
89
90/// Progress information for bulk clone operations
91#[derive(Debug)]
92pub struct BulkCloneProgress {
93    pub current: usize,
94    pub total: usize,
95    pub current_repo: String,
96    pub status: CloneStatus,
97}
98
99/// Status of the current clone operation
100#[derive(Debug)]
101pub enum CloneStatus {
102    Discovering,
103    Confirming,
104    Cloning,
105    AddingToWorkspace,
106    Complete,
107}
108
109/// Rate limiter for API calls
110pub struct RateLimiter {
111    last_request: Instant,
112    min_interval: Duration,
113}
114
115impl RateLimiter {
116    pub fn new(requests_per_second: f64) -> Self {
117        Self {
118            last_request: Instant::now(),
119            min_interval: Duration::from_secs_f64(1.0 / requests_per_second),
120        }
121    }
122
123    pub async fn wait(&mut self) {
124        let elapsed = self.last_request.elapsed();
125        if elapsed < self.min_interval {
126            let wait_time = self.min_interval - elapsed;
127            tokio::time::sleep(wait_time).await;
128        }
129        self.last_request = Instant::now();
130    }
131}
132
133/// Main bulk clone command implementation
134pub struct BulkCloneCommand;
135
136impl BulkCloneCommand {
137    /// Execute bulk cloning for a user or organization
138    pub async fn execute(
139        target: String,
140        options: BulkCloneOptions,
141        workspace_manager: &mut WorkspaceManager,
142        git_config: &GitConfig,
143    ) -> Result<BulkCloneResult> {
144        let github_cli =
145            GitHubCliProvider::new().context("Failed to initialize GitHub CLI provider")?;
146
147        // Step 1: Discover repositories
148        Self::report_progress(BulkCloneProgress {
149            current: 0,
150            total: 0,
151            current_repo: "Discovering repositories...".to_string(),
152            status: CloneStatus::Discovering,
153        });
154
155        let repositories = Self::discover_repositories(&github_cli, &target).await?;
156
157        if repositories.is_empty() {
158            anyhow::bail!("No repositories found for '{}'", target);
159        }
160
161        // Step 2: Filter repositories
162        let filter_result =
163            Self::filter_repositories(&repositories, &options, workspace_manager, git_config)?;
164
165        if filter_result.to_clone.is_empty() {
166            anyhow::bail!("No repositories remaining after filtering");
167        }
168
169        // Step 3: Show confirmation unless forced
170        if !options.force {
171            Self::show_confirmation(&filter_result, &target, repositories.len())?;
172        }
173
174        // Step 4: Clone repositories in serial
175        let result = Self::clone_repositories_serial(
176            filter_result.to_clone,
177            filter_result.skipped,
178            options,
179            workspace_manager,
180            git_config,
181        )
182        .await?;
183
184        // Step 5: Display summary
185        Self::display_summary(&result);
186
187        Ok(result)
188    }
189
190    /// Discover all repositories for a target (user or organization)
191    async fn discover_repositories(
192        github_cli: &GitHubCliProvider,
193        target: &str,
194    ) -> Result<Vec<Repository>> {
195        // Try as organization first, then as user
196        match github_cli.get_organization_repositories(target).await {
197            Ok(repos) => {
198                info!(
199                    "Found {} repositories for organization '{}'",
200                    repos.len(),
201                    target
202                );
203                Ok(repos)
204            }
205            Err(_) => {
206                // Try as user
207                match github_cli.get_user_repositories(target).await {
208                    Ok(repos) => {
209                        info!("Found {} repositories for user '{}'", repos.len(), target);
210                        Ok(repos)
211                    }
212                    Err(e) => {
213                        anyhow::bail!("Failed to find repositories for '{}': {}", target, e);
214                    }
215                }
216            }
217        }
218    }
219
220    /// Filter repositories based on patterns and existing state
221    fn filter_repositories(
222        repositories: &[Repository],
223        options: &BulkCloneOptions,
224        workspace_manager: &WorkspaceManager,
225        git_config: &GitConfig,
226    ) -> Result<FilterResult> {
227        let mut to_clone = Vec::new();
228        let mut skipped = Vec::new();
229        let workspace_root = workspace_manager.get_workspace_root();
230
231        for repo in repositories {
232            // Check if already exists locally
233            if options.skip_existing {
234                // Parse the repository URL to get org and repo name
235                let repo_path = match Self::parse_git_url(&repo.url) {
236                    Ok((org, repo_name)) => {
237                        Self::calculate_install_path(workspace_root, git_config, &org, &repo_name)
238                    }
239                    Err(_) => {
240                        // Fallback to just using the repo name if URL parsing fails
241                        workspace_root.join(&repo.name)
242                    }
243                };
244
245                if repo_path.exists() {
246                    skipped.push(SkippedRepository {
247                        name: repo.full_name.clone(),
248                        reason: SkipReason::AlreadyExists(repo_path),
249                    });
250                    continue;
251                }
252            }
253
254            // Apply exclude patterns
255            if !options.exclude_patterns.is_empty() {
256                let should_exclude = options.exclude_patterns.iter().any(|pattern| {
257                    glob::Pattern::new(pattern)
258                        .map(|p| p.matches(&repo.name))
259                        .unwrap_or(false)
260                });
261                if should_exclude {
262                    if let Some(pattern) = options.exclude_patterns.iter().find(|pattern| {
263                        glob::Pattern::new(pattern)
264                            .map(|p| p.matches(&repo.name))
265                            .unwrap_or(false)
266                    }) {
267                        skipped.push(SkippedRepository {
268                            name: repo.full_name.clone(),
269                            reason: SkipReason::ExcludedByPattern(pattern.clone()),
270                        });
271                    }
272                    continue;
273                }
274            }
275
276            // Apply include patterns (if any)
277            if !options.include_patterns.is_empty() {
278                let should_include = options.include_patterns.iter().any(|pattern| {
279                    glob::Pattern::new(pattern)
280                        .map(|p| p.matches(&repo.name))
281                        .unwrap_or(false)
282                });
283                if !should_include {
284                    skipped.push(SkippedRepository {
285                        name: repo.full_name.clone(),
286                        reason: SkipReason::NotIncludedByPattern,
287                    });
288                    continue;
289                }
290            }
291
292            to_clone.push(repo.clone());
293        }
294
295        Ok(FilterResult { to_clone, skipped })
296    }
297
298    /// Show confirmation dialog for bulk clone operation
299    fn show_confirmation(
300        filter_result: &FilterResult,
301        target: &str,
302        total_discovered: usize,
303    ) -> Result<()> {
304        let repositories = &filter_result.to_clone;
305        let skipped = &filter_result.skipped;
306
307        println!(
308            "\n{} {} {}",
309            style("šŸ“‹").blue(),
310            style("Bulk Clone Summary").cyan().bold(),
311            style(format!("- GitHub target '{}'", target)).dim()
312        );
313
314        println!(
315            "Total repositories discovered: {}",
316            style(total_discovered).blue().bold()
317        );
318
319        // Show skipped repositories if any
320        if !skipped.is_empty() {
321            let existing_count = skipped
322                .iter()
323                .filter(|s| matches!(s.reason, SkipReason::AlreadyExists(_)))
324                .count();
325
326            if existing_count > 0 {
327                println!(
328                    "{} Already exist locally: {}",
329                    style("āœ…").green(),
330                    style(existing_count).green().bold()
331                );
332            }
333
334            let other_skipped = skipped.len() - existing_count;
335            if other_skipped > 0 {
336                println!(
337                    "{} Skipped (patterns/filters): {}",
338                    style("ā­ļø").yellow(),
339                    style(other_skipped).yellow().bold()
340                );
341            }
342        }
343
344        if repositories.is_empty() {
345            anyhow::bail!("No repositories to clone after filtering");
346        }
347
348        println!(
349            "{} {} {}",
350            style("šŸ“¦").blue(),
351            style("To confirm clone in bulk:").cyan(),
352            style(repositories.len()).green().bold()
353        );
354
355        // Show sample repositories that will be cloned
356        println!(
357            "\n{} Sample repositories to clone (showing first 8):",
358            style("šŸ”½").blue()
359        );
360        for (i, repo) in repositories.iter().take(8).enumerate() {
361            let lang = repo.language.as_deref().unwrap_or("unknown");
362            let stars = if repo.stars > 0 {
363                format!(" {}", style(format!("⭐ {}", repo.stars)).dim())
364            } else {
365                String::new()
366            };
367
368            println!(
369                "  {}. {}{} [{}]",
370                i + 1,
371                style(&repo.full_name).cyan(),
372                stars,
373                style(lang).dim()
374            );
375        }
376
377        if repositories.len() > 8 {
378            println!("  ... and {} more repositories", repositories.len() - 8);
379        }
380
381        // Calculate estimated size (rough approximation)
382        let estimated_size_mb = repositories.len() * 15; // Rough estimate of 15MB per repo
383        let estimated_time_min = (repositories.len() as f64 * 0.5).ceil() as usize; // ~30s per repo
384
385        println!("\nšŸ’¾ Estimated size: ~{} MB", estimated_size_mb);
386        println!("ā±ļø  Estimated time: {} minutes", estimated_time_min);
387
388        println!(
389            "\n{} {}",
390            style("āš ļø").yellow(),
391            "This will clone ONLY the filtered repositories. Apps will NOT be configured automatically."
392        );
393
394        let proceed = Confirm::new(&format!(
395            "Proceed with bulk cloning {} repositories?",
396            repositories.len()
397        ))
398        .with_default(false)
399        .with_help_message("This operation cannot be easily undone")
400        .prompt()?;
401
402        if !proceed {
403            anyhow::bail!("User cancelled bulk clone operation");
404        }
405
406        Ok(())
407    }
408
409    /// Clone repositories in serial with progress reporting
410    async fn clone_repositories_serial(
411        repositories: Vec<Repository>,
412        skipped_from_filter: Vec<SkippedRepository>,
413        options: BulkCloneOptions,
414        workspace_manager: &mut WorkspaceManager,
415        git_config: &GitConfig,
416    ) -> Result<BulkCloneResult> {
417        let total = repositories.len();
418        let mut successful = Vec::new();
419        let mut failed = Vec::new();
420        let skipped = skipped_from_filter;
421
422        let start_time = Instant::now();
423
424        // Conservative rate limiting: 1 clone every 2 seconds
425        let mut rate_limiter = RateLimiter::new(0.5);
426
427        for (index, repo) in repositories.iter().enumerate() {
428            // Progress reporting
429            Self::report_progress(BulkCloneProgress {
430                current: index + 1,
431                total,
432                current_repo: repo.full_name.clone(),
433                status: CloneStatus::Cloning,
434            });
435
436            // Rate limiting
437            if index > 0 {
438                rate_limiter.wait().await;
439            }
440
441            // Attempt clone with error isolation
442            match Self::clone_single_repository(repo, &options, workspace_manager, git_config).await
443            {
444                Ok(_) => {
445                    successful.push(repo.full_name.clone());
446                    info!("Successfully cloned {}", repo.full_name);
447                }
448                Err(e) => {
449                    warn!("Failed to clone {}: {}", repo.full_name, e);
450                    failed.push(FailedRepository {
451                        name: repo.full_name.clone(),
452                        error: e.to_string(),
453                        url: repo.url.clone(),
454                    });
455                }
456            }
457        }
458
459        let duration = start_time.elapsed();
460
461        // Final progress update
462        Self::report_progress(BulkCloneProgress {
463            current: total,
464            total,
465            current_repo: "Complete!".to_string(),
466            status: CloneStatus::Complete,
467        });
468
469        Ok(BulkCloneResult {
470            total_discovered: total + skipped.len(), // Include all discovered repos
471            total_cloned: successful.len(),
472            skipped,
473            failed,
474            successful,
475            duration,
476        })
477    }
478
479    /// Clone a single repository without post-install workflow
480    async fn clone_single_repository(
481        repo: &Repository,
482        _options: &BulkCloneOptions,
483        workspace_manager: &mut WorkspaceManager,
484        git_config: &GitConfig,
485    ) -> Result<()> {
486        // Create installer but skip post-install actions for bulk operations
487        let installer = RepositoryInstaller::new(
488            workspace_manager.get_workspace_root().clone(),
489            git_config.clone(),
490        );
491
492        // Clone without opening or running install commands (fast bulk mode)
493        let installed = installer
494            .install_from_url_with_options(
495                &repo.url, None,  // Use default path
496                false, // don't open
497                false, // don't run install commands
498            )
499            .await
500            .context("Failed to clone repository")?;
501
502        // Add to workspace configuration (but skip app configuration)
503        workspace_manager
504            .add_repository(installed.repository)
505            .await
506            .context("Failed to add repository to workspace")?;
507
508        Ok(())
509    }
510
511    /// Report progress during bulk clone operation
512    fn report_progress(progress: BulkCloneProgress) {
513        if progress.total == 0 {
514            print!("\ršŸ” {}", progress.current_repo);
515        } else {
516            let percent = (progress.current as f64 / progress.total as f64 * 100.0) as usize;
517            let bar_length = 20;
518            let filled = (progress.current * bar_length) / progress.total.max(1);
519            let empty = bar_length - filled;
520
521            let status_icon = match progress.status {
522                CloneStatus::Discovering => "šŸ”",
523                CloneStatus::Confirming => "ā“",
524                CloneStatus::Cloning => "šŸ“¦",
525                CloneStatus::AddingToWorkspace => "āž•",
526                CloneStatus::Complete => "āœ…",
527            };
528
529            print!(
530                "\r{} [{:>3}%] [{}{}] ({}/{}) {}",
531                status_icon,
532                percent,
533                "ā–ˆ".repeat(filled),
534                "ā–‘".repeat(empty),
535                progress.current,
536                progress.total,
537                if progress.current_repo.len() > 40 {
538                    format!("{}...", &progress.current_repo[..37])
539                } else {
540                    progress.current_repo
541                }
542            );
543        }
544
545        use std::io::{self, Write};
546        io::stdout().flush().unwrap();
547
548        if matches!(progress.status, CloneStatus::Complete) {
549            println!(); // New line after completion
550        }
551    }
552
553    /// Parse Git URL to extract org and repo name (mirrors RepositoryInstaller::parse_git_url)
554    fn parse_git_url(url: &str) -> Result<(String, String)> {
555        let url = url.trim();
556
557        // SSH format: git@github.com:org/repo.git
558        if url.starts_with("git@") {
559            let parts: Vec<&str> = url.split(':').collect();
560            if parts.len() != 2 {
561                anyhow::bail!("Invalid SSH URL format: {}", url);
562            }
563
564            let path_parts: Vec<&str> = parts[1].trim_end_matches(".git").split('/').collect();
565            if path_parts.len() != 2 {
566                anyhow::bail!("Invalid SSH URL path format: {}", url);
567            }
568
569            return Ok((path_parts[0].to_string(), path_parts[1].to_string()));
570        }
571
572        // HTTPS format: https://github.com/org/repo.git
573        if url.starts_with("https://") || url.starts_with("http://") {
574            let url_without_scheme = if url.starts_with("https://") {
575                &url[8..]
576            } else {
577                &url[7..]
578            };
579
580            let parts: Vec<&str> = url_without_scheme.split('/').collect();
581            if parts.len() >= 3 && parts[0].contains("github.com") {
582                let org = parts[1];
583                let repo = parts[2].trim_end_matches(".git");
584                return Ok((org.to_string(), repo.to_string()));
585            }
586        }
587
588        anyhow::bail!("Unsupported URL format: {}", url);
589    }
590
591    /// Calculate install path (mirrors RepositoryInstaller::calculate_install_path)
592    fn calculate_install_path(
593        workspace_root: &std::path::Path,
594        git_config: &GitConfig,
595        org: &str,
596        repo: &str,
597    ) -> std::path::PathBuf {
598        if git_config.standardize_paths {
599            workspace_root.join(org).join(repo)
600        } else {
601            workspace_root.join(repo)
602        }
603    }
604
605    /// Display summary of bulk clone operation
606    fn display_summary(result: &BulkCloneResult) {
607        println!("\n{} Bulk Clone Complete!", style("šŸŽ‰").green().bold());
608
609        println!(
610            "šŸ“Š Total repositories discovered: {}",
611            style(result.total_discovered).blue().bold()
612        );
613
614        println!(
615            "āœ… Successfully cloned: {}",
616            style(result.total_cloned).green().bold()
617        );
618
619        if !result.skipped.is_empty() {
620            let existing_count = result
621                .skipped
622                .iter()
623                .filter(|s| matches!(s.reason, SkipReason::AlreadyExists(_)))
624                .count();
625            let pattern_count = result.skipped.len() - existing_count;
626
627            if existing_count > 0 {
628                println!(
629                    "āœ… Already existed locally: {}",
630                    style(existing_count).green().bold()
631                );
632            }
633            if pattern_count > 0 {
634                println!(
635                    "ā­ļø  Skipped by filters: {}",
636                    style(pattern_count).yellow().bold()
637                );
638            }
639        }
640
641        if !result.failed.is_empty() {
642            println!(
643                "āŒ Failed: {} repositories",
644                style(result.failed.len()).red().bold()
645            );
646
647            for failed in &result.failed {
648                println!(
649                    "  • {} - {}",
650                    style(&failed.name).red(),
651                    style(&failed.error).dim()
652                );
653            }
654        }
655
656        let minutes = result.duration.as_secs() / 60;
657        let seconds = result.duration.as_secs() % 60;
658        println!("ā±ļø  Total time: {}m {}s", minutes, seconds);
659
660        if result.total_cloned > 0 {
661            println!("\n{} Next steps:", style("šŸ’”").yellow());
662            println!(
663                "• Configure apps: {}",
664                style("vibe apps configure <repo>").cyan()
665            );
666            println!("• Explore repos: {}", style("vibe launch").cyan());
667            println!("• Check status: {}", style("vibe git status").cyan());
668        }
669    }
670}