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#[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, }
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#[derive(Debug, Clone, PartialEq)]
37#[allow(dead_code)]
38pub enum TargetType {
39 User,
40 Organization,
41 Unknown,
42}
43
44#[derive(Debug, Clone)]
46pub struct FilterResult {
47 pub to_clone: Vec<Repository>,
48 pub skipped: Vec<SkippedRepository>,
49}
50
51#[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#[derive(Debug, Clone)]
64pub struct SkippedRepository {
65 pub name: String,
66 pub reason: SkipReason,
67}
68
69#[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#[derive(Debug, Clone)]
84pub struct FailedRepository {
85 pub name: String,
86 pub error: String,
87 pub url: String,
88}
89
90#[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#[derive(Debug)]
101pub enum CloneStatus {
102 Discovering,
103 Confirming,
104 Cloning,
105 AddingToWorkspace,
106 Complete,
107}
108
109pub 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
133pub struct BulkCloneCommand;
135
136impl BulkCloneCommand {
137 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 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 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 if !options.force {
171 Self::show_confirmation(&filter_result, &target, repositories.len())?;
172 }
173
174 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 Self::display_summary(&result);
186
187 Ok(result)
188 }
189
190 async fn discover_repositories(
192 github_cli: &GitHubCliProvider,
193 target: &str,
194 ) -> Result<Vec<Repository>> {
195 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 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 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 if options.skip_existing {
234 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 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 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 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 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 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 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 let estimated_size_mb = repositories.len() * 15; let estimated_time_min = (repositories.len() as f64 * 0.5).ceil() as usize; 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 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 let mut rate_limiter = RateLimiter::new(0.5);
426
427 for (index, repo) in repositories.iter().enumerate() {
428 Self::report_progress(BulkCloneProgress {
430 current: index + 1,
431 total,
432 current_repo: repo.full_name.clone(),
433 status: CloneStatus::Cloning,
434 });
435
436 if index > 0 {
438 rate_limiter.wait().await;
439 }
440
441 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 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(), total_cloned: successful.len(),
472 skipped,
473 failed,
474 successful,
475 duration,
476 })
477 }
478
479 async fn clone_single_repository(
481 repo: &Repository,
482 _options: &BulkCloneOptions,
483 workspace_manager: &mut WorkspaceManager,
484 git_config: &GitConfig,
485 ) -> Result<()> {
486 let installer = RepositoryInstaller::new(
488 workspace_manager.get_workspace_root().clone(),
489 git_config.clone(),
490 );
491
492 let installed = installer
494 .install_from_url_with_options(
495 &repo.url, None, false, false, )
499 .await
500 .context("Failed to clone repository")?;
501
502 workspace_manager
504 .add_repository(installed.repository)
505 .await
506 .context("Failed to add repository to workspace")?;
507
508 Ok(())
509 }
510
511 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!(); }
551 }
552
553 fn parse_git_url(url: &str) -> Result<(String, String)> {
555 let url = url.trim();
556
557 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 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 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 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}