1use crate::bitbucket::BitbucketIntegration;
2use crate::errors::{CascadeError, Result};
3use crate::git::{find_repository_root, GitRepository};
4use crate::stack::{StackManager, StackStatus};
5use clap::{Subcommand, ValueEnum};
6use indicatif::{ProgressBar, ProgressStyle};
7use std::env;
8use tracing::{info, warn};
9
10#[derive(ValueEnum, Clone, Debug)]
12pub enum RebaseStrategyArg {
13 BranchVersioning,
15 CherryPick,
17 ThreeWayMerge,
19 Interactive,
21}
22
23#[derive(ValueEnum, Clone, Debug)]
24pub enum MergeStrategyArg {
25 Merge,
27 Squash,
29 FastForward,
31}
32
33impl From<MergeStrategyArg> for crate::bitbucket::pull_request::MergeStrategy {
34 fn from(arg: MergeStrategyArg) -> Self {
35 match arg {
36 MergeStrategyArg::Merge => Self::Merge,
37 MergeStrategyArg::Squash => Self::Squash,
38 MergeStrategyArg::FastForward => Self::FastForward,
39 }
40 }
41}
42
43#[derive(Debug, Subcommand)]
44pub enum StackAction {
45 Create {
47 name: String,
49 #[arg(long, short)]
51 base: Option<String>,
52 #[arg(long, short)]
54 description: Option<String>,
55 },
56
57 List {
59 #[arg(long, short)]
61 verbose: bool,
62 #[arg(long)]
64 active: bool,
65 #[arg(long)]
67 format: Option<String>,
68 },
69
70 Switch {
72 name: String,
74 },
75
76 Deactivate {
78 #[arg(long)]
80 force: bool,
81 },
82
83 Show {
85 #[arg(short, long)]
87 verbose: bool,
88 #[arg(short, long)]
90 mergeable: bool,
91 },
92
93 Push {
95 #[arg(long, short)]
97 branch: Option<String>,
98 #[arg(long, short)]
100 message: Option<String>,
101 #[arg(long)]
103 commit: Option<String>,
104 #[arg(long)]
106 since: Option<String>,
107 #[arg(long)]
109 commits: Option<String>,
110 #[arg(long, num_args = 0..=1, default_missing_value = "0")]
112 squash: Option<usize>,
113 #[arg(long)]
115 squash_since: Option<String>,
116 #[arg(long)]
118 auto_branch: bool,
119 #[arg(long)]
121 allow_base_branch: bool,
122 },
123
124 Pop {
126 #[arg(long)]
128 keep_branch: bool,
129 },
130
131 Submit {
133 entry: Option<usize>,
135 #[arg(long, short)]
137 title: Option<String>,
138 #[arg(long, short)]
140 description: Option<String>,
141 #[arg(long)]
143 range: Option<String>,
144 #[arg(long)]
146 draft: bool,
147 },
148
149 Status {
151 name: Option<String>,
153 },
154
155 Prs {
157 #[arg(long)]
159 state: Option<String>,
160 #[arg(long, short)]
162 verbose: bool,
163 },
164
165 Check {
167 #[arg(long)]
169 force: bool,
170 },
171
172 Sync {
174 #[arg(long)]
176 force: bool,
177 #[arg(long)]
179 skip_cleanup: bool,
180 #[arg(long, short)]
182 interactive: bool,
183 },
184
185 Rebase {
187 #[arg(long, short)]
189 interactive: bool,
190 #[arg(long)]
192 onto: Option<String>,
193 #[arg(long, value_enum)]
195 strategy: Option<RebaseStrategyArg>,
196 },
197
198 ContinueRebase,
200
201 AbortRebase,
203
204 RebaseStatus,
206
207 Delete {
209 name: String,
211 #[arg(long)]
213 force: bool,
214 },
215
216 Validate {
228 name: Option<String>,
230 #[arg(long)]
232 fix: Option<String>,
233 },
234
235 Land {
237 entry: Option<usize>,
239 #[arg(short, long)]
241 force: bool,
242 #[arg(short, long)]
244 dry_run: bool,
245 #[arg(long)]
247 auto: bool,
248 #[arg(long)]
250 wait_for_builds: bool,
251 #[arg(long, value_enum, default_value = "squash")]
253 strategy: Option<MergeStrategyArg>,
254 #[arg(long, default_value = "1800")]
256 build_timeout: u64,
257 },
258
259 AutoLand {
261 #[arg(short, long)]
263 force: bool,
264 #[arg(short, long)]
266 dry_run: bool,
267 #[arg(long)]
269 wait_for_builds: bool,
270 #[arg(long, value_enum, default_value = "squash")]
272 strategy: Option<MergeStrategyArg>,
273 #[arg(long, default_value = "1800")]
275 build_timeout: u64,
276 },
277
278 ListPrs {
280 #[arg(short, long)]
282 state: Option<String>,
283 #[arg(short, long)]
285 verbose: bool,
286 },
287
288 ContinueLand,
290
291 AbortLand,
293
294 LandStatus,
296
297 Repair,
299}
300
301pub async fn run(action: StackAction) -> Result<()> {
302 match action {
303 StackAction::Create {
304 name,
305 base,
306 description,
307 } => create_stack(name, base, description).await,
308 StackAction::List {
309 verbose,
310 active,
311 format,
312 } => list_stacks(verbose, active, format).await,
313 StackAction::Switch { name } => switch_stack(name).await,
314 StackAction::Deactivate { force } => deactivate_stack(force).await,
315 StackAction::Show { verbose, mergeable } => show_stack(verbose, mergeable).await,
316 StackAction::Push {
317 branch,
318 message,
319 commit,
320 since,
321 commits,
322 squash,
323 squash_since,
324 auto_branch,
325 allow_base_branch,
326 } => {
327 push_to_stack(
328 branch,
329 message,
330 commit,
331 since,
332 commits,
333 squash,
334 squash_since,
335 auto_branch,
336 allow_base_branch,
337 )
338 .await
339 }
340 StackAction::Pop { keep_branch } => pop_from_stack(keep_branch).await,
341 StackAction::Submit {
342 entry,
343 title,
344 description,
345 range,
346 draft,
347 } => submit_entry(entry, title, description, range, draft).await,
348 StackAction::Status { name } => check_stack_status(name).await,
349 StackAction::Prs { state, verbose } => list_pull_requests(state, verbose).await,
350 StackAction::Check { force } => check_stack(force).await,
351 StackAction::Sync {
352 force,
353 skip_cleanup,
354 interactive,
355 } => sync_stack(force, skip_cleanup, interactive).await,
356 StackAction::Rebase {
357 interactive,
358 onto,
359 strategy,
360 } => rebase_stack(interactive, onto, strategy).await,
361 StackAction::ContinueRebase => continue_rebase().await,
362 StackAction::AbortRebase => abort_rebase().await,
363 StackAction::RebaseStatus => rebase_status().await,
364 StackAction::Delete { name, force } => delete_stack(name, force).await,
365 StackAction::Validate { name, fix } => validate_stack(name, fix).await,
366 StackAction::Land {
367 entry,
368 force,
369 dry_run,
370 auto,
371 wait_for_builds,
372 strategy,
373 build_timeout,
374 } => {
375 land_stack(
376 entry,
377 force,
378 dry_run,
379 auto,
380 wait_for_builds,
381 strategy,
382 build_timeout,
383 )
384 .await
385 }
386 StackAction::AutoLand {
387 force,
388 dry_run,
389 wait_for_builds,
390 strategy,
391 build_timeout,
392 } => auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await,
393 StackAction::ListPrs { state, verbose } => list_pull_requests(state, verbose).await,
394 StackAction::ContinueLand => continue_land().await,
395 StackAction::AbortLand => abort_land().await,
396 StackAction::LandStatus => land_status().await,
397 StackAction::Repair => repair_stack_data().await,
398 }
399}
400
401pub async fn show(verbose: bool, mergeable: bool) -> Result<()> {
403 show_stack(verbose, mergeable).await
404}
405
406#[allow(clippy::too_many_arguments)]
407pub async fn push(
408 branch: Option<String>,
409 message: Option<String>,
410 commit: Option<String>,
411 since: Option<String>,
412 commits: Option<String>,
413 squash: Option<usize>,
414 squash_since: Option<String>,
415 auto_branch: bool,
416 allow_base_branch: bool,
417) -> Result<()> {
418 push_to_stack(
419 branch,
420 message,
421 commit,
422 since,
423 commits,
424 squash,
425 squash_since,
426 auto_branch,
427 allow_base_branch,
428 )
429 .await
430}
431
432pub async fn pop(keep_branch: bool) -> Result<()> {
433 pop_from_stack(keep_branch).await
434}
435
436pub async fn land(
437 entry: Option<usize>,
438 force: bool,
439 dry_run: bool,
440 auto: bool,
441 wait_for_builds: bool,
442 strategy: Option<MergeStrategyArg>,
443 build_timeout: u64,
444) -> Result<()> {
445 land_stack(
446 entry,
447 force,
448 dry_run,
449 auto,
450 wait_for_builds,
451 strategy,
452 build_timeout,
453 )
454 .await
455}
456
457pub async fn autoland(
458 force: bool,
459 dry_run: bool,
460 wait_for_builds: bool,
461 strategy: Option<MergeStrategyArg>,
462 build_timeout: u64,
463) -> Result<()> {
464 auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await
465}
466
467pub async fn sync(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
468 sync_stack(force, skip_cleanup, interactive).await
469}
470
471pub async fn rebase(
472 interactive: bool,
473 onto: Option<String>,
474 strategy: Option<RebaseStrategyArg>,
475) -> Result<()> {
476 rebase_stack(interactive, onto, strategy).await
477}
478
479pub async fn deactivate(force: bool) -> Result<()> {
480 deactivate_stack(force).await
481}
482
483pub async fn switch(name: String) -> Result<()> {
484 switch_stack(name).await
485}
486
487async fn create_stack(
488 name: String,
489 base: Option<String>,
490 description: Option<String>,
491) -> Result<()> {
492 let current_dir = env::current_dir()
493 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
494
495 let repo_root = find_repository_root(¤t_dir)
496 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
497
498 let mut manager = StackManager::new(&repo_root)?;
499 let stack_id = manager.create_stack(name.clone(), base.clone(), description.clone())?;
500
501 info!("✅ Created stack '{}'", name);
502 if let Some(base_branch) = base {
503 info!(" Base branch: {}", base_branch);
504 }
505 if let Some(desc) = description {
506 info!(" Description: {}", desc);
507 }
508 info!(" Stack ID: {}", stack_id);
509 info!(" Stack is now active");
510
511 Ok(())
512}
513
514async fn list_stacks(verbose: bool, _active: bool, _format: Option<String>) -> Result<()> {
515 let current_dir = env::current_dir()
516 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
517
518 let repo_root = find_repository_root(¤t_dir)
519 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
520
521 let manager = StackManager::new(&repo_root)?;
522 let stacks = manager.list_stacks();
523
524 if stacks.is_empty() {
525 info!("No stacks found. Create one with: ca stack create <name>");
526 return Ok(());
527 }
528
529 println!("📚 Stacks:");
530 for (stack_id, name, status, entry_count, active_marker) in stacks {
531 let status_icon = match status {
532 StackStatus::Clean => "✅",
533 StackStatus::Dirty => "🔄",
534 StackStatus::OutOfSync => "⚠️",
535 StackStatus::Conflicted => "❌",
536 StackStatus::Rebasing => "🔀",
537 StackStatus::NeedsSync => "🔄",
538 StackStatus::Corrupted => "💥",
539 };
540
541 let active_indicator = if active_marker.is_some() {
542 " (active)"
543 } else {
544 ""
545 };
546
547 let stack = manager.get_stack(&stack_id);
549
550 if verbose {
551 println!(" {status_icon} {name} [{entry_count}]{active_indicator}");
552 println!(" ID: {stack_id}");
553 if let Some(stack_meta) = manager.get_stack_metadata(&stack_id) {
554 println!(" Base: {}", stack_meta.base_branch);
555 if let Some(desc) = &stack_meta.description {
556 println!(" Description: {desc}");
557 }
558 println!(
559 " Commits: {} total, {} submitted",
560 stack_meta.total_commits, stack_meta.submitted_commits
561 );
562 if stack_meta.has_conflicts {
563 println!(" ⚠️ Has conflicts");
564 }
565 }
566
567 if let Some(stack_obj) = stack {
569 if !stack_obj.entries.is_empty() {
570 println!(" Branches:");
571 for (i, entry) in stack_obj.entries.iter().enumerate() {
572 let entry_num = i + 1;
573 let submitted_indicator = if entry.is_submitted { "📤" } else { "📝" };
574 let branch_name = &entry.branch;
575 let short_message = if entry.message.len() > 40 {
576 format!("{}...", &entry.message[..37])
577 } else {
578 entry.message.clone()
579 };
580 println!(" {entry_num}. {submitted_indicator} {branch_name} - {short_message}");
581 }
582 }
583 }
584 println!();
585 } else {
586 let branch_info = if let Some(stack_obj) = stack {
588 if stack_obj.entries.is_empty() {
589 String::new()
590 } else if stack_obj.entries.len() == 1 {
591 format!(" → {}", stack_obj.entries[0].branch)
592 } else {
593 let first_branch = &stack_obj.entries[0].branch;
594 let last_branch = &stack_obj.entries.last().unwrap().branch;
595 format!(" → {first_branch} … {last_branch}")
596 }
597 } else {
598 String::new()
599 };
600
601 println!(" {status_icon} {name} [{entry_count}]{branch_info}{active_indicator}");
602 }
603 }
604
605 if !verbose {
606 println!("\nUse --verbose for more details");
607 }
608
609 Ok(())
610}
611
612async fn switch_stack(name: String) -> Result<()> {
613 let current_dir = env::current_dir()
614 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
615
616 let repo_root = find_repository_root(¤t_dir)
617 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
618
619 let mut manager = StackManager::new(&repo_root)?;
620 let repo = GitRepository::open(&repo_root)?;
621
622 let stack = manager
624 .get_stack_by_name(&name)
625 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
626
627 let target_branch = stack.working_branch.as_ref().unwrap_or(&stack.base_branch);
629
630 let current_branch = repo.get_current_branch().ok();
632
633 if current_branch.as_ref() != Some(target_branch) {
635 println!("🔄 Switching to stack branch: {target_branch}");
636
637 if repo.branch_exists(target_branch) {
639 match repo.checkout_branch(target_branch) {
640 Ok(_) => {
641 println!("✅ Checked out branch: {target_branch}");
642 }
643 Err(e) => {
644 println!("⚠️ Failed to checkout '{target_branch}': {e}");
645 println!(" Stack activated but stayed on current branch");
646 println!(" You can manually checkout with: git checkout {target_branch}");
647 }
648 }
649 } else {
650 println!("⚠️ Stack branch '{target_branch}' doesn't exist locally");
651 println!(" Stack activated but stayed on current branch");
652 println!(" You may need to create the branch or fetch from remote");
653 }
654 } else {
655 println!("✅ Already on stack branch: {target_branch}");
656 }
657
658 manager.set_active_stack_by_name(&name)?;
660 info!("✅ Switched to stack '{}'", name);
661
662 Ok(())
663}
664
665async fn deactivate_stack(force: bool) -> Result<()> {
666 let current_dir = env::current_dir()
667 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
668
669 let repo_root = find_repository_root(¤t_dir)
670 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
671
672 let mut manager = StackManager::new(&repo_root)?;
673
674 let active_stack = manager.get_active_stack();
675
676 if active_stack.is_none() {
677 println!("ℹ️ No active stack to deactivate");
678 return Ok(());
679 }
680
681 let stack_name = active_stack.unwrap().name.clone();
682
683 if !force {
684 println!("⚠️ This will deactivate stack '{stack_name}' and return to normal Git workflow");
685 println!(" You can reactivate it later with 'ca stacks switch {stack_name}'");
686 print!(" Continue? (y/N): ");
687
688 use std::io::{self, Write};
689 io::stdout().flush().unwrap();
690
691 let mut input = String::new();
692 io::stdin().read_line(&mut input).unwrap();
693
694 if !input.trim().to_lowercase().starts_with('y') {
695 println!("Cancelled deactivation");
696 return Ok(());
697 }
698 }
699
700 manager.set_active_stack(None)?;
702
703 println!("✅ Deactivated stack '{stack_name}'");
704 println!(" Stack management is now OFF - you can use normal Git workflow");
705 println!(" To reactivate: ca stacks switch {stack_name}");
706
707 Ok(())
708}
709
710async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
711 let current_dir = env::current_dir()
712 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
713
714 let repo_root = find_repository_root(¤t_dir)
715 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
716
717 let stack_manager = StackManager::new(&repo_root)?;
718
719 let (stack_id, stack_name, stack_base, stack_working, stack_entries) = {
721 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
722 CascadeError::config(
723 "No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
724 .to_string(),
725 )
726 })?;
727
728 (
729 active_stack.id,
730 active_stack.name.clone(),
731 active_stack.base_branch.clone(),
732 active_stack.working_branch.clone(),
733 active_stack.entries.clone(),
734 )
735 };
736
737 println!("📊 Stack: {stack_name}");
738 println!(" Base branch: {stack_base}");
739 if let Some(working) = &stack_working {
740 println!(" Working branch: {working}");
741 }
742 println!(" Total entries: {}", stack_entries.len());
743
744 if stack_entries.is_empty() {
745 println!(" No entries in this stack yet");
746 println!(" Use 'ca push' to add commits to this stack");
747 return Ok(());
748 }
749
750 println!("\n📚 Stack Entries:");
752 for (i, entry) in stack_entries.iter().enumerate() {
753 let entry_num = i + 1;
754 let short_hash = entry.short_hash();
755 let short_msg = entry.short_message(50);
756
757 let metadata = stack_manager.get_repository_metadata();
759 let source_branch_info = if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash)
760 {
761 if commit_meta.source_branch != commit_meta.branch
762 && !commit_meta.source_branch.is_empty()
763 {
764 format!(" (from {})", commit_meta.source_branch)
765 } else {
766 String::new()
767 }
768 } else {
769 String::new()
770 };
771
772 println!(
773 " {entry_num}. {} {} {}{}",
774 short_hash,
775 if entry.is_submitted { "📤" } else { "📝" },
776 short_msg,
777 source_branch_info
778 );
779
780 if verbose {
781 println!(" Branch: {}", entry.branch);
782 println!(
783 " Created: {}",
784 entry.created_at.format("%Y-%m-%d %H:%M")
785 );
786 if let Some(pr_id) = &entry.pull_request_id {
787 println!(" PR: #{pr_id}");
788 }
789 }
790 }
791
792 if show_mergeable {
794 println!("\n🔍 Mergability Status:");
795
796 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
798 let config_path = config_dir.join("config.json");
799 let settings = crate::config::Settings::load_from_file(&config_path)?;
800
801 let cascade_config = crate::config::CascadeConfig {
802 bitbucket: Some(settings.bitbucket.clone()),
803 git: settings.git.clone(),
804 auth: crate::config::AuthConfig::default(),
805 cascade: settings.cascade.clone(),
806 };
807
808 let integration =
809 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
810
811 match integration.check_enhanced_stack_status(&stack_id).await {
812 Ok(status) => {
813 println!(" Total entries: {}", status.total_entries);
814 println!(" Submitted: {}", status.submitted_entries);
815 println!(" Open PRs: {}", status.open_prs);
816 println!(" Merged PRs: {}", status.merged_prs);
817 println!(" Declined PRs: {}", status.declined_prs);
818 println!(" Completion: {:.1}%", status.completion_percentage());
819
820 if !status.enhanced_statuses.is_empty() {
821 println!("\n📋 Pull Request Status:");
822 let mut ready_to_land = 0;
823
824 for enhanced in &status.enhanced_statuses {
825 let status_display = enhanced.get_display_status();
826 let ready_icon = if enhanced.is_ready_to_land() {
827 ready_to_land += 1;
828 "🚀"
829 } else {
830 "⏳"
831 };
832
833 println!(
834 " {} PR #{}: {} ({})",
835 ready_icon, enhanced.pr.id, enhanced.pr.title, status_display
836 );
837
838 if verbose {
839 println!(
840 " {} -> {}",
841 enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
842 );
843
844 if !enhanced.is_ready_to_land() {
846 let blocking = enhanced.get_blocking_reasons();
847 if !blocking.is_empty() {
848 println!(" Blocking: {}", blocking.join(", "));
849 }
850 }
851
852 println!(
854 " Reviews: {}/{} approvals",
855 enhanced.review_status.current_approvals,
856 enhanced.review_status.required_approvals
857 );
858
859 if enhanced.review_status.needs_work_count > 0 {
860 println!(
861 " {} reviewers requested changes",
862 enhanced.review_status.needs_work_count
863 );
864 }
865
866 if let Some(build) = &enhanced.build_status {
868 let build_icon = match build.state {
869 crate::bitbucket::pull_request::BuildState::Successful => "✅",
870 crate::bitbucket::pull_request::BuildState::Failed => "❌",
871 crate::bitbucket::pull_request::BuildState::InProgress => "🔄",
872 _ => "⚪",
873 };
874 println!(" Build: {} {:?}", build_icon, build.state);
875 }
876
877 if let Some(url) = enhanced.pr.web_url() {
878 println!(" URL: {url}");
879 }
880 println!();
881 }
882 }
883
884 if ready_to_land > 0 {
885 println!(
886 "\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
887 ready_to_land,
888 if ready_to_land == 1 { " is" } else { "s are" }
889 );
890 }
891 }
892 }
893 Err(e) => {
894 warn!("Failed to get enhanced stack status: {}", e);
895 println!(" ⚠️ Could not fetch mergability status");
896 println!(" Use 'ca stack show --verbose' for basic PR information");
897 }
898 }
899 } else {
900 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
902 let config_path = config_dir.join("config.json");
903 let settings = crate::config::Settings::load_from_file(&config_path)?;
904
905 let cascade_config = crate::config::CascadeConfig {
906 bitbucket: Some(settings.bitbucket.clone()),
907 git: settings.git.clone(),
908 auth: crate::config::AuthConfig::default(),
909 cascade: settings.cascade.clone(),
910 };
911
912 let integration =
913 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
914
915 match integration.check_stack_status(&stack_id).await {
916 Ok(status) => {
917 println!("\n📊 Pull Request Status:");
918 println!(" Total entries: {}", status.total_entries);
919 println!(" Submitted: {}", status.submitted_entries);
920 println!(" Open PRs: {}", status.open_prs);
921 println!(" Merged PRs: {}", status.merged_prs);
922 println!(" Declined PRs: {}", status.declined_prs);
923 println!(" Completion: {:.1}%", status.completion_percentage());
924
925 if !status.pull_requests.is_empty() {
926 println!("\n📋 Pull Requests:");
927 for pr in &status.pull_requests {
928 let state_icon = match pr.state {
929 crate::bitbucket::PullRequestState::Open => "🔄",
930 crate::bitbucket::PullRequestState::Merged => "✅",
931 crate::bitbucket::PullRequestState::Declined => "❌",
932 };
933 println!(
934 " {} PR #{}: {} ({} -> {})",
935 state_icon,
936 pr.id,
937 pr.title,
938 pr.from_ref.display_id,
939 pr.to_ref.display_id
940 );
941 if let Some(url) = pr.web_url() {
942 println!(" URL: {url}");
943 }
944 }
945 }
946
947 println!("\n💡 Use 'ca stack --mergeable' to see detailed status including build and review information");
948 }
949 Err(e) => {
950 warn!("Failed to check stack status: {}", e);
951 }
952 }
953 }
954
955 Ok(())
956}
957
958#[allow(clippy::too_many_arguments)]
959async fn push_to_stack(
960 branch: Option<String>,
961 message: Option<String>,
962 commit: Option<String>,
963 since: Option<String>,
964 commits: Option<String>,
965 squash: Option<usize>,
966 squash_since: Option<String>,
967 auto_branch: bool,
968 allow_base_branch: bool,
969) -> Result<()> {
970 let current_dir = env::current_dir()
971 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
972
973 let repo_root = find_repository_root(¤t_dir)
974 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
975
976 let mut manager = StackManager::new(&repo_root)?;
977 let repo = GitRepository::open(&repo_root)?;
978
979 if !manager.check_for_branch_change()? {
981 return Ok(()); }
983
984 let active_stack = manager.get_active_stack().ok_or_else(|| {
986 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
987 })?;
988
989 let current_branch = repo.get_current_branch()?;
991 let base_branch = &active_stack.base_branch;
992
993 if current_branch == *base_branch {
994 println!("🚨 WARNING: You're currently on the base branch '{base_branch}'");
995 println!(" Making commits directly on the base branch is not recommended.");
996 println!(" This can pollute the base branch with work-in-progress commits.");
997
998 if allow_base_branch {
1000 println!(" ⚠️ Proceeding anyway due to --allow-base-branch flag");
1001 } else {
1002 let has_changes = repo.is_dirty()?;
1004
1005 if has_changes {
1006 if auto_branch {
1007 let feature_branch = format!("feature/{}-work", active_stack.name);
1009 println!("🚀 Auto-creating feature branch '{feature_branch}'...");
1010
1011 repo.create_branch(&feature_branch, None)?;
1012 repo.checkout_branch(&feature_branch)?;
1013
1014 println!("✅ Created and switched to '{feature_branch}'");
1015 println!(" You can now commit and push your changes safely");
1016
1017 } else {
1019 println!("\n💡 You have uncommitted changes. Here are your options:");
1020 println!(" 1. Create a feature branch first:");
1021 println!(" git checkout -b feature/my-work");
1022 println!(" git commit -am \"your work\"");
1023 println!(" ca push");
1024 println!("\n 2. Auto-create a branch (recommended):");
1025 println!(" ca push --auto-branch");
1026 println!("\n 3. Force push to base branch (dangerous):");
1027 println!(" ca push --allow-base-branch");
1028
1029 return Err(CascadeError::config(
1030 "Refusing to push uncommitted changes from base branch. Use one of the options above."
1031 ));
1032 }
1033 } else {
1034 let commits_to_check = if let Some(commits_str) = &commits {
1036 commits_str
1037 .split(',')
1038 .map(|s| s.trim().to_string())
1039 .collect::<Vec<String>>()
1040 } else if let Some(since_ref) = &since {
1041 let since_commit = repo.resolve_reference(since_ref)?;
1042 let head_commit = repo.get_head_commit()?;
1043 let commits = repo.get_commits_between(
1044 &since_commit.id().to_string(),
1045 &head_commit.id().to_string(),
1046 )?;
1047 commits.into_iter().map(|c| c.id().to_string()).collect()
1048 } else if commit.is_none() {
1049 let mut unpushed = Vec::new();
1050 let head_commit = repo.get_head_commit()?;
1051 let mut current_commit = head_commit;
1052
1053 loop {
1054 let commit_hash = current_commit.id().to_string();
1055 let already_in_stack = active_stack
1056 .entries
1057 .iter()
1058 .any(|entry| entry.commit_hash == commit_hash);
1059
1060 if already_in_stack {
1061 break;
1062 }
1063
1064 unpushed.push(commit_hash);
1065
1066 if let Some(parent) = current_commit.parents().next() {
1067 current_commit = parent;
1068 } else {
1069 break;
1070 }
1071 }
1072
1073 unpushed.reverse();
1074 unpushed
1075 } else {
1076 vec![repo.get_head_commit()?.id().to_string()]
1077 };
1078
1079 if !commits_to_check.is_empty() {
1080 if auto_branch {
1081 let feature_branch = format!("feature/{}-work", active_stack.name);
1083 println!("🚀 Auto-creating feature branch '{feature_branch}'...");
1084
1085 repo.create_branch(&feature_branch, Some(base_branch))?;
1086 repo.checkout_branch(&feature_branch)?;
1087
1088 println!(
1090 "🍒 Cherry-picking {} commit(s) to new branch...",
1091 commits_to_check.len()
1092 );
1093 for commit_hash in &commits_to_check {
1094 match repo.cherry_pick(commit_hash) {
1095 Ok(_) => println!(" ✅ Cherry-picked {}", &commit_hash[..8]),
1096 Err(e) => {
1097 println!(
1098 " ❌ Failed to cherry-pick {}: {}",
1099 &commit_hash[..8],
1100 e
1101 );
1102 println!(" 💡 You may need to resolve conflicts manually");
1103 return Err(CascadeError::branch(format!(
1104 "Failed to cherry-pick commit {commit_hash}: {e}"
1105 )));
1106 }
1107 }
1108 }
1109
1110 println!(
1111 "✅ Successfully moved {} commit(s) to '{feature_branch}'",
1112 commits_to_check.len()
1113 );
1114 println!(
1115 " You're now on the feature branch and can continue with 'ca push'"
1116 );
1117
1118 } else {
1120 println!(
1121 "\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
1122 commits_to_check.len()
1123 );
1124 println!(" These commits are currently ON the base branch, which may not be intended.");
1125 println!("\n Options:");
1126 println!(" 1. Auto-create feature branch and cherry-pick commits:");
1127 println!(" ca push --auto-branch");
1128 println!("\n 2. Manually create branch and move commits:");
1129 println!(" git checkout -b feature/my-work");
1130 println!(" ca push");
1131 println!("\n 3. Force push from base branch (not recommended):");
1132 println!(" ca push --allow-base-branch");
1133
1134 return Err(CascadeError::config(
1135 "Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
1136 ));
1137 }
1138 }
1139 }
1140 }
1141 }
1142
1143 if let Some(squash_count) = squash {
1145 if squash_count == 0 {
1146 let active_stack = manager.get_active_stack().ok_or_else(|| {
1148 CascadeError::config(
1149 "No active stack. Create a stack first with 'ca stacks create'",
1150 )
1151 })?;
1152
1153 let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
1154
1155 if unpushed_count == 0 {
1156 println!("ℹ️ No unpushed commits to squash");
1157 } else if unpushed_count == 1 {
1158 println!("ℹ️ Only 1 unpushed commit, no squashing needed");
1159 } else {
1160 println!("🔄 Auto-detected {unpushed_count} unpushed commits, squashing...");
1161 squash_commits(&repo, unpushed_count, None).await?;
1162 println!("✅ Squashed {unpushed_count} unpushed commits into one");
1163 }
1164 } else {
1165 println!("🔄 Squashing last {squash_count} commits...");
1166 squash_commits(&repo, squash_count, None).await?;
1167 println!("✅ Squashed {squash_count} commits into one");
1168 }
1169 } else if let Some(since_ref) = squash_since {
1170 println!("🔄 Squashing commits since {since_ref}...");
1171 let since_commit = repo.resolve_reference(&since_ref)?;
1172 let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
1173 squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
1174 println!("✅ Squashed {commits_count} commits since {since_ref} into one");
1175 }
1176
1177 let commits_to_push = if let Some(commits_str) = commits {
1179 commits_str
1181 .split(',')
1182 .map(|s| s.trim().to_string())
1183 .collect::<Vec<String>>()
1184 } else if let Some(since_ref) = since {
1185 let since_commit = repo.resolve_reference(&since_ref)?;
1187 let head_commit = repo.get_head_commit()?;
1188
1189 let commits = repo.get_commits_between(
1191 &since_commit.id().to_string(),
1192 &head_commit.id().to_string(),
1193 )?;
1194 commits.into_iter().map(|c| c.id().to_string()).collect()
1195 } else if let Some(hash) = commit {
1196 vec![hash]
1198 } else {
1199 let active_stack = manager.get_active_stack().ok_or_else(|| {
1201 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1202 })?;
1203
1204 let base_branch = &active_stack.base_branch;
1206 let current_branch = repo.get_current_branch()?;
1207
1208 if current_branch == *base_branch {
1210 let mut unpushed = Vec::new();
1211 let head_commit = repo.get_head_commit()?;
1212 let mut current_commit = head_commit;
1213
1214 loop {
1216 let commit_hash = current_commit.id().to_string();
1217 let already_in_stack = active_stack
1218 .entries
1219 .iter()
1220 .any(|entry| entry.commit_hash == commit_hash);
1221
1222 if already_in_stack {
1223 break;
1224 }
1225
1226 unpushed.push(commit_hash);
1227
1228 if let Some(parent) = current_commit.parents().next() {
1230 current_commit = parent;
1231 } else {
1232 break;
1233 }
1234 }
1235
1236 unpushed.reverse(); unpushed
1238 } else {
1239 match repo.get_commits_between(base_branch, ¤t_branch) {
1241 Ok(commits) => {
1242 let mut unpushed: Vec<String> =
1243 commits.into_iter().map(|c| c.id().to_string()).collect();
1244
1245 unpushed.retain(|commit_hash| {
1247 !active_stack
1248 .entries
1249 .iter()
1250 .any(|entry| entry.commit_hash == *commit_hash)
1251 });
1252
1253 unpushed.reverse(); unpushed
1255 }
1256 Err(e) => {
1257 return Err(CascadeError::branch(format!(
1258 "Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
1259 This usually means the branches have diverged or don't share common history."
1260 )));
1261 }
1262 }
1263 }
1264 };
1265
1266 if commits_to_push.is_empty() {
1267 println!("ℹ️ No commits to push to stack");
1268 return Ok(());
1269 }
1270
1271 let mut pushed_count = 0;
1273 let mut source_branches = std::collections::HashSet::new();
1274
1275 for (i, commit_hash) in commits_to_push.iter().enumerate() {
1276 let commit_obj = repo.get_commit(commit_hash)?;
1277 let commit_msg = commit_obj.message().unwrap_or("").to_string();
1278
1279 let commit_source_branch = repo
1281 .find_branch_containing_commit(commit_hash)
1282 .unwrap_or_else(|_| current_branch.clone());
1283 source_branches.insert(commit_source_branch.clone());
1284
1285 let branch_name = if i == 0 && branch.is_some() {
1287 branch.clone().unwrap()
1288 } else {
1289 let temp_repo = GitRepository::open(&repo_root)?;
1291 let branch_mgr = crate::git::BranchManager::new(temp_repo);
1292 branch_mgr.generate_branch_name(&commit_msg)
1293 };
1294
1295 let final_message = if i == 0 && message.is_some() {
1297 message.clone().unwrap()
1298 } else {
1299 commit_msg.clone()
1300 };
1301
1302 let entry_id = manager.push_to_stack(
1303 branch_name.clone(),
1304 commit_hash.clone(),
1305 final_message.clone(),
1306 commit_source_branch.clone(),
1307 )?;
1308 pushed_count += 1;
1309
1310 println!(
1311 "✅ Pushed commit {}/{} to stack",
1312 i + 1,
1313 commits_to_push.len()
1314 );
1315 println!(
1316 " Commit: {} ({})",
1317 &commit_hash[..8],
1318 commit_msg.split('\n').next().unwrap_or("")
1319 );
1320 println!(" Branch: {branch_name}");
1321 println!(" Source: {commit_source_branch}");
1322 println!(" Entry ID: {entry_id}");
1323 println!();
1324 }
1325
1326 if source_branches.len() > 1 {
1328 println!("⚠️ WARNING: Scattered Commit Detection");
1329 println!(
1330 " You've pushed commits from {} different Git branches:",
1331 source_branches.len()
1332 );
1333 for branch in &source_branches {
1334 println!(" • {branch}");
1335 }
1336 println!();
1337 println!(" This can lead to confusion because:");
1338 println!(" • Stack appears sequential but commits are scattered across branches");
1339 println!(" • Team members won't know which branch contains which work");
1340 println!(" • Branch cleanup becomes unclear after merge");
1341 println!(" • Rebase operations become more complex");
1342 println!();
1343 println!(" 💡 Consider consolidating work to a single feature branch:");
1344 println!(" 1. Create a new feature branch: git checkout -b feature/consolidated-work");
1345 println!(" 2. Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
1346 println!(" 3. Delete old scattered branches");
1347 println!(" 4. Push the consolidated branch to your stack");
1348 println!();
1349 }
1350
1351 println!(
1352 "🎉 Successfully pushed {} commit{} to stack",
1353 pushed_count,
1354 if pushed_count == 1 { "" } else { "s" }
1355 );
1356
1357 Ok(())
1358}
1359
1360async fn pop_from_stack(keep_branch: bool) -> Result<()> {
1361 let current_dir = env::current_dir()
1362 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1363
1364 let repo_root = find_repository_root(¤t_dir)
1365 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1366
1367 let mut manager = StackManager::new(&repo_root)?;
1368 let repo = GitRepository::open(&repo_root)?;
1369
1370 let entry = manager.pop_from_stack()?;
1371
1372 info!("✅ Popped commit from stack");
1373 info!(
1374 " Commit: {} ({})",
1375 entry.short_hash(),
1376 entry.short_message(50)
1377 );
1378 info!(" Branch: {}", entry.branch);
1379
1380 if !keep_branch && entry.branch != repo.get_current_branch()? {
1382 match repo.delete_branch(&entry.branch) {
1383 Ok(_) => info!(" Deleted branch: {}", entry.branch),
1384 Err(e) => warn!(" Could not delete branch {}: {}", entry.branch, e),
1385 }
1386 }
1387
1388 Ok(())
1389}
1390
1391async fn submit_entry(
1392 entry: Option<usize>,
1393 title: Option<String>,
1394 description: Option<String>,
1395 range: Option<String>,
1396 draft: bool,
1397) -> Result<()> {
1398 let current_dir = env::current_dir()
1399 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1400
1401 let repo_root = find_repository_root(¤t_dir)
1402 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1403
1404 let mut stack_manager = StackManager::new(&repo_root)?;
1405
1406 if !stack_manager.check_for_branch_change()? {
1408 return Ok(()); }
1410
1411 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1413 let config_path = config_dir.join("config.json");
1414 let settings = crate::config::Settings::load_from_file(&config_path)?;
1415
1416 let cascade_config = crate::config::CascadeConfig {
1418 bitbucket: Some(settings.bitbucket.clone()),
1419 git: settings.git.clone(),
1420 auth: crate::config::AuthConfig::default(),
1421 cascade: settings.cascade.clone(),
1422 };
1423
1424 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1426 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1427 })?;
1428 let stack_id = active_stack.id;
1429
1430 let entries_to_submit = if let Some(range_str) = range {
1432 let mut entries = Vec::new();
1434
1435 if range_str.contains('-') {
1436 let parts: Vec<&str> = range_str.split('-').collect();
1438 if parts.len() != 2 {
1439 return Err(CascadeError::config(
1440 "Invalid range format. Use 'start-end' (e.g., '1-3')",
1441 ));
1442 }
1443
1444 let start: usize = parts[0]
1445 .parse()
1446 .map_err(|_| CascadeError::config("Invalid start number in range"))?;
1447 let end: usize = parts[1]
1448 .parse()
1449 .map_err(|_| CascadeError::config("Invalid end number in range"))?;
1450
1451 if start == 0
1452 || end == 0
1453 || start > active_stack.entries.len()
1454 || end > active_stack.entries.len()
1455 {
1456 return Err(CascadeError::config(format!(
1457 "Range out of bounds. Stack has {} entries",
1458 active_stack.entries.len()
1459 )));
1460 }
1461
1462 for i in start..=end {
1463 entries.push((i, active_stack.entries[i - 1].clone()));
1464 }
1465 } else {
1466 for entry_str in range_str.split(',') {
1468 let entry_num: usize = entry_str.trim().parse().map_err(|_| {
1469 CascadeError::config(format!("Invalid entry number: {entry_str}"))
1470 })?;
1471
1472 if entry_num == 0 || entry_num > active_stack.entries.len() {
1473 return Err(CascadeError::config(format!(
1474 "Entry {} out of bounds. Stack has {} entries",
1475 entry_num,
1476 active_stack.entries.len()
1477 )));
1478 }
1479
1480 entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
1481 }
1482 }
1483
1484 entries
1485 } else if let Some(entry_num) = entry {
1486 if entry_num == 0 || entry_num > active_stack.entries.len() {
1488 return Err(CascadeError::config(format!(
1489 "Invalid entry number: {}. Stack has {} entries",
1490 entry_num,
1491 active_stack.entries.len()
1492 )));
1493 }
1494 vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
1495 } else {
1496 active_stack
1498 .entries
1499 .iter()
1500 .enumerate()
1501 .filter(|(_, entry)| !entry.is_submitted)
1502 .map(|(i, entry)| (i + 1, entry.clone())) .collect::<Vec<(usize, _)>>()
1504 };
1505
1506 if entries_to_submit.is_empty() {
1507 println!("ℹ️ No entries to submit");
1508 return Ok(());
1509 }
1510
1511 let total_operations = entries_to_submit.len() + 2; let pb = ProgressBar::new(total_operations as u64);
1514 pb.set_style(
1515 ProgressStyle::default_bar()
1516 .template("📤 {msg} [{bar:40.cyan/blue}] {pos}/{len}")
1517 .map_err(|e| CascadeError::config(format!("Progress bar template error: {e}")))?,
1518 );
1519
1520 pb.set_message("Connecting to Bitbucket");
1521 pb.inc(1);
1522
1523 let integration_stack_manager = StackManager::new(&repo_root)?;
1525 let mut integration =
1526 BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
1527
1528 pb.set_message("Starting batch submission");
1529 pb.inc(1);
1530
1531 let mut submitted_count = 0;
1533 let mut failed_entries = Vec::new();
1534 let total_entries = entries_to_submit.len();
1535
1536 for (entry_num, entry_to_submit) in &entries_to_submit {
1537 pb.set_message("Submitting entries...");
1538
1539 let entry_title = if total_entries == 1 {
1541 title.clone()
1542 } else {
1543 None
1544 };
1545 let entry_description = if total_entries == 1 {
1546 description.clone()
1547 } else {
1548 None
1549 };
1550
1551 match integration
1552 .submit_entry(
1553 &stack_id,
1554 &entry_to_submit.id,
1555 entry_title,
1556 entry_description,
1557 draft,
1558 )
1559 .await
1560 {
1561 Ok(pr) => {
1562 submitted_count += 1;
1563 println!("✅ Entry {} - PR #{}: {}", entry_num, pr.id, pr.title);
1564 if let Some(url) = pr.web_url() {
1565 println!(" URL: {url}");
1566 }
1567 println!(
1568 " From: {} -> {}",
1569 pr.from_ref.display_id, pr.to_ref.display_id
1570 );
1571 println!();
1572 }
1573 Err(e) => {
1574 failed_entries.push((*entry_num, e.to_string()));
1575 println!("❌ Entry {entry_num} failed: {e}");
1576 }
1577 }
1578
1579 pb.inc(1);
1580 }
1581
1582 if failed_entries.is_empty() {
1583 pb.finish_with_message("✅ All pull requests created successfully");
1584 println!(
1585 "🎉 Successfully submitted {} entr{}",
1586 submitted_count,
1587 if submitted_count == 1 { "y" } else { "ies" }
1588 );
1589 } else {
1590 pb.abandon_with_message("⚠️ Some submissions failed");
1591 println!("📊 Submission Summary:");
1592 println!(" ✅ Successful: {submitted_count}");
1593 println!(" ❌ Failed: {}", failed_entries.len());
1594 println!();
1595 println!("💡 Failed entries:");
1596 for (entry_num, error) in failed_entries {
1597 println!(" - Entry {entry_num}: {error}");
1598 }
1599 println!();
1600 println!("💡 You can retry failed entries individually:");
1601 println!(" ca stack submit <ENTRY_NUMBER>");
1602 }
1603
1604 Ok(())
1605}
1606
1607async fn check_stack_status(name: Option<String>) -> Result<()> {
1608 let current_dir = env::current_dir()
1609 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1610
1611 let repo_root = find_repository_root(¤t_dir)
1612 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1613
1614 let stack_manager = StackManager::new(&repo_root)?;
1615
1616 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1618 let config_path = config_dir.join("config.json");
1619 let settings = crate::config::Settings::load_from_file(&config_path)?;
1620
1621 let cascade_config = crate::config::CascadeConfig {
1623 bitbucket: Some(settings.bitbucket.clone()),
1624 git: settings.git.clone(),
1625 auth: crate::config::AuthConfig::default(),
1626 cascade: settings.cascade.clone(),
1627 };
1628
1629 let stack = if let Some(name) = name {
1631 stack_manager
1632 .get_stack_by_name(&name)
1633 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
1634 } else {
1635 stack_manager.get_active_stack().ok_or_else(|| {
1636 CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
1637 })?
1638 };
1639 let stack_id = stack.id;
1640
1641 println!("📋 Stack: {}", stack.name);
1642 println!(" ID: {}", stack.id);
1643 println!(" Base: {}", stack.base_branch);
1644
1645 if let Some(description) = &stack.description {
1646 println!(" Description: {description}");
1647 }
1648
1649 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1651
1652 match integration.check_stack_status(&stack_id).await {
1654 Ok(status) => {
1655 println!("\n📊 Pull Request Status:");
1656 println!(" Total entries: {}", status.total_entries);
1657 println!(" Submitted: {}", status.submitted_entries);
1658 println!(" Open PRs: {}", status.open_prs);
1659 println!(" Merged PRs: {}", status.merged_prs);
1660 println!(" Declined PRs: {}", status.declined_prs);
1661 println!(" Completion: {:.1}%", status.completion_percentage());
1662
1663 if !status.pull_requests.is_empty() {
1664 println!("\n📋 Pull Requests:");
1665 for pr in &status.pull_requests {
1666 let state_icon = match pr.state {
1667 crate::bitbucket::PullRequestState::Open => "🔄",
1668 crate::bitbucket::PullRequestState::Merged => "✅",
1669 crate::bitbucket::PullRequestState::Declined => "❌",
1670 };
1671 println!(
1672 " {} PR #{}: {} ({} -> {})",
1673 state_icon, pr.id, pr.title, pr.from_ref.display_id, pr.to_ref.display_id
1674 );
1675 if let Some(url) = pr.web_url() {
1676 println!(" URL: {url}");
1677 }
1678 }
1679 }
1680 }
1681 Err(e) => {
1682 warn!("Failed to check stack status: {}", e);
1683 return Err(e);
1684 }
1685 }
1686
1687 Ok(())
1688}
1689
1690async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
1691 let current_dir = env::current_dir()
1692 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1693
1694 let repo_root = find_repository_root(¤t_dir)
1695 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1696
1697 let stack_manager = StackManager::new(&repo_root)?;
1698
1699 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1701 let config_path = config_dir.join("config.json");
1702 let settings = crate::config::Settings::load_from_file(&config_path)?;
1703
1704 let cascade_config = crate::config::CascadeConfig {
1706 bitbucket: Some(settings.bitbucket.clone()),
1707 git: settings.git.clone(),
1708 auth: crate::config::AuthConfig::default(),
1709 cascade: settings.cascade.clone(),
1710 };
1711
1712 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1714
1715 let pr_state = if let Some(state_str) = state {
1717 match state_str.to_lowercase().as_str() {
1718 "open" => Some(crate::bitbucket::PullRequestState::Open),
1719 "merged" => Some(crate::bitbucket::PullRequestState::Merged),
1720 "declined" => Some(crate::bitbucket::PullRequestState::Declined),
1721 _ => {
1722 return Err(CascadeError::config(format!(
1723 "Invalid state '{state_str}'. Use: open, merged, declined"
1724 )))
1725 }
1726 }
1727 } else {
1728 None
1729 };
1730
1731 match integration.list_pull_requests(pr_state).await {
1733 Ok(pr_page) => {
1734 if pr_page.values.is_empty() {
1735 info!("No pull requests found.");
1736 return Ok(());
1737 }
1738
1739 println!("📋 Pull Requests ({} total):", pr_page.values.len());
1740 for pr in &pr_page.values {
1741 let state_icon = match pr.state {
1742 crate::bitbucket::PullRequestState::Open => "🔄",
1743 crate::bitbucket::PullRequestState::Merged => "✅",
1744 crate::bitbucket::PullRequestState::Declined => "❌",
1745 };
1746 println!(" {} PR #{}: {}", state_icon, pr.id, pr.title);
1747 if verbose {
1748 println!(
1749 " From: {} -> {}",
1750 pr.from_ref.display_id, pr.to_ref.display_id
1751 );
1752 println!(" Author: {}", pr.author.user.display_name);
1753 if let Some(url) = pr.web_url() {
1754 println!(" URL: {url}");
1755 }
1756 if let Some(desc) = &pr.description {
1757 if !desc.is_empty() {
1758 println!(" Description: {desc}");
1759 }
1760 }
1761 println!();
1762 }
1763 }
1764
1765 if !verbose {
1766 println!("\nUse --verbose for more details");
1767 }
1768 }
1769 Err(e) => {
1770 warn!("Failed to list pull requests: {}", e);
1771 return Err(e);
1772 }
1773 }
1774
1775 Ok(())
1776}
1777
1778async fn check_stack(_force: bool) -> Result<()> {
1779 let current_dir = env::current_dir()
1780 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1781
1782 let repo_root = find_repository_root(¤t_dir)
1783 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1784
1785 let mut manager = StackManager::new(&repo_root)?;
1786
1787 let active_stack = manager
1788 .get_active_stack()
1789 .ok_or_else(|| CascadeError::config("No active stack"))?;
1790 let stack_id = active_stack.id;
1791
1792 manager.sync_stack(&stack_id)?;
1793
1794 info!("✅ Stack check completed successfully");
1795
1796 Ok(())
1797}
1798
1799async fn sync_stack(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
1800 let current_dir = env::current_dir()
1801 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1802
1803 let repo_root = find_repository_root(¤t_dir)
1804 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1805
1806 let stack_manager = StackManager::new(&repo_root)?;
1807 let git_repo = GitRepository::open(&repo_root)?;
1808
1809 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1811 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1812 })?;
1813
1814 let base_branch = active_stack.base_branch.clone();
1815 let stack_name = active_stack.name.clone();
1816
1817 println!("🔄 Syncing stack '{stack_name}' with remote...");
1818
1819 println!("📥 Pulling latest changes from '{base_branch}'...");
1821
1822 match git_repo.checkout_branch(&base_branch) {
1824 Ok(_) => {
1825 println!(" ✅ Switched to '{base_branch}'");
1826
1827 match git_repo.pull(&base_branch) {
1829 Ok(_) => {
1830 println!(" ✅ Successfully pulled latest changes");
1831 }
1832 Err(e) => {
1833 if force {
1834 println!(" ⚠️ Failed to pull: {e} (continuing due to --force)");
1835 } else {
1836 return Err(CascadeError::branch(format!(
1837 "Failed to pull latest changes from '{base_branch}': {e}. Use --force to continue anyway."
1838 )));
1839 }
1840 }
1841 }
1842 }
1843 Err(e) => {
1844 if force {
1845 println!(
1846 " ⚠️ Failed to checkout '{base_branch}': {e} (continuing due to --force)"
1847 );
1848 } else {
1849 return Err(CascadeError::branch(format!(
1850 "Failed to checkout base branch '{base_branch}': {e}. Use --force to continue anyway."
1851 )));
1852 }
1853 }
1854 }
1855
1856 println!("🔍 Checking if stack needs rebase...");
1858
1859 let mut updated_stack_manager = StackManager::new(&repo_root)?;
1860 let stack_id = active_stack.id;
1861
1862 match updated_stack_manager.sync_stack(&stack_id) {
1863 Ok(_) => {
1864 if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
1866 match &updated_stack.status {
1867 crate::stack::StackStatus::NeedsSync => {
1868 println!(" 🔄 Stack needs rebase due to new commits on '{base_branch}'");
1869
1870 println!("🔀 Rebasing stack onto updated '{base_branch}'...");
1872
1873 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1875 let config_path = config_dir.join("config.json");
1876 let settings = crate::config::Settings::load_from_file(&config_path)?;
1877
1878 let cascade_config = crate::config::CascadeConfig {
1879 bitbucket: Some(settings.bitbucket.clone()),
1880 git: settings.git.clone(),
1881 auth: crate::config::AuthConfig::default(),
1882 cascade: settings.cascade.clone(),
1883 };
1884
1885 let options = crate::stack::RebaseOptions {
1887 strategy: crate::stack::RebaseStrategy::BranchVersioning,
1888 interactive,
1889 target_base: Some(base_branch.clone()),
1890 preserve_merges: true,
1891 auto_resolve: !interactive,
1892 max_retries: 3,
1893 skip_pull: Some(true), };
1895
1896 let mut rebase_manager = crate::stack::RebaseManager::new(
1897 updated_stack_manager,
1898 git_repo,
1899 options,
1900 );
1901
1902 match rebase_manager.rebase_stack(&stack_id) {
1903 Ok(result) => {
1904 println!(" ✅ Rebase completed successfully!");
1905
1906 if !result.branch_mapping.is_empty() {
1907 println!(" 📋 Updated branches:");
1908 for (old, new) in &result.branch_mapping {
1909 println!(" {old} → {new}");
1910 }
1911
1912 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
1914 println!(" 🔄 Updating pull requests...");
1915
1916 let integration_stack_manager =
1917 StackManager::new(&repo_root)?;
1918 let mut integration =
1919 crate::bitbucket::BitbucketIntegration::new(
1920 integration_stack_manager,
1921 cascade_config,
1922 )?;
1923
1924 match integration
1925 .update_prs_after_rebase(
1926 &stack_id,
1927 &result.branch_mapping,
1928 )
1929 .await
1930 {
1931 Ok(updated_prs) => {
1932 if !updated_prs.is_empty() {
1933 println!(
1934 " ✅ Updated {} pull requests",
1935 updated_prs.len()
1936 );
1937 }
1938 }
1939 Err(e) => {
1940 println!(
1941 " ⚠️ Failed to update pull requests: {e}"
1942 );
1943 }
1944 }
1945 }
1946 }
1947 }
1948 Err(e) => {
1949 println!(" ❌ Rebase failed: {e}");
1950 println!(" 💡 To resolve conflicts:");
1951 println!(" 1. Fix conflicts in the affected files");
1952 println!(" 2. Stage resolved files: git add <files>");
1953 println!(" 3. Continue: ca stack continue-rebase");
1954 return Err(e);
1955 }
1956 }
1957 }
1958 crate::stack::StackStatus::Clean => {
1959 println!(" ✅ Stack is already up to date");
1960 }
1961 other => {
1962 println!(" ℹ️ Stack status: {other:?}");
1963 }
1964 }
1965 }
1966 }
1967 Err(e) => {
1968 if force {
1969 println!(" ⚠️ Failed to check stack status: {e} (continuing due to --force)");
1970 } else {
1971 return Err(e);
1972 }
1973 }
1974 }
1975
1976 if !skip_cleanup {
1978 println!("🧹 Checking for merged branches to clean up...");
1979 println!(" ℹ️ Branch cleanup not yet implemented");
1985 } else {
1986 println!("⏭️ Skipping branch cleanup");
1987 }
1988
1989 println!("🎉 Sync completed successfully!");
1990 println!(" Base branch: {base_branch}");
1991 println!(" 💡 Next steps:");
1992 println!(" • Review your updated stack: ca stack show");
1993 println!(" • Check PR status: ca stack status");
1994
1995 Ok(())
1996}
1997
1998async fn rebase_stack(
1999 interactive: bool,
2000 onto: Option<String>,
2001 strategy: Option<RebaseStrategyArg>,
2002) -> Result<()> {
2003 let current_dir = env::current_dir()
2004 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2005
2006 let repo_root = find_repository_root(¤t_dir)
2007 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2008
2009 let stack_manager = StackManager::new(&repo_root)?;
2010 let git_repo = GitRepository::open(&repo_root)?;
2011
2012 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2014 let config_path = config_dir.join("config.json");
2015 let settings = crate::config::Settings::load_from_file(&config_path)?;
2016
2017 let cascade_config = crate::config::CascadeConfig {
2019 bitbucket: Some(settings.bitbucket.clone()),
2020 git: settings.git.clone(),
2021 auth: crate::config::AuthConfig::default(),
2022 cascade: settings.cascade.clone(),
2023 };
2024
2025 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2027 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2028 })?;
2029 let stack_id = active_stack.id;
2030
2031 let active_stack = stack_manager
2032 .get_stack(&stack_id)
2033 .ok_or_else(|| CascadeError::config("Active stack not found"))?
2034 .clone();
2035
2036 if active_stack.entries.is_empty() {
2037 println!("ℹ️ Stack is empty. Nothing to rebase.");
2038 return Ok(());
2039 }
2040
2041 println!("🔄 Rebasing stack: {}", active_stack.name);
2042 println!(" Base: {}", active_stack.base_branch);
2043
2044 let rebase_strategy = if let Some(cli_strategy) = strategy {
2046 match cli_strategy {
2047 RebaseStrategyArg::BranchVersioning => crate::stack::RebaseStrategy::BranchVersioning,
2048 RebaseStrategyArg::CherryPick => crate::stack::RebaseStrategy::CherryPick,
2049 RebaseStrategyArg::ThreeWayMerge => crate::stack::RebaseStrategy::ThreeWayMerge,
2050 RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
2051 }
2052 } else {
2053 match settings.cascade.default_sync_strategy.as_str() {
2055 "branch-versioning" => crate::stack::RebaseStrategy::BranchVersioning,
2056 "cherry-pick" => crate::stack::RebaseStrategy::CherryPick,
2057 "three-way-merge" => crate::stack::RebaseStrategy::ThreeWayMerge,
2058 "rebase" => crate::stack::RebaseStrategy::Interactive,
2059 _ => crate::stack::RebaseStrategy::BranchVersioning, }
2061 };
2062
2063 let options = crate::stack::RebaseOptions {
2065 strategy: rebase_strategy.clone(),
2066 interactive,
2067 target_base: onto,
2068 preserve_merges: true,
2069 auto_resolve: !interactive, max_retries: 3,
2071 skip_pull: None, };
2073
2074 info!(" Strategy: {:?}", rebase_strategy);
2075 info!(" Interactive: {}", interactive);
2076 info!(" Target base: {:?}", options.target_base);
2077 info!(" Entries: {}", active_stack.entries.len());
2078
2079 let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2081
2082 if rebase_manager.is_rebase_in_progress() {
2083 println!("⚠️ Rebase already in progress!");
2084 println!(" Use 'git status' to check the current state");
2085 println!(" Use 'ca stack continue-rebase' to continue");
2086 println!(" Use 'ca stack abort-rebase' to abort");
2087 return Ok(());
2088 }
2089
2090 match rebase_manager.rebase_stack(&stack_id) {
2092 Ok(result) => {
2093 println!("🎉 Rebase completed!");
2094 println!(" {}", result.get_summary());
2095
2096 if result.has_conflicts() {
2097 println!(" ⚠️ {} conflicts were resolved", result.conflicts.len());
2098 for conflict in &result.conflicts {
2099 println!(" - {}", &conflict[..8.min(conflict.len())]);
2100 }
2101 }
2102
2103 if !result.branch_mapping.is_empty() {
2104 println!(" 📋 Branch mapping:");
2105 for (old, new) in &result.branch_mapping {
2106 println!(" {old} -> {new}");
2107 }
2108
2109 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2111 let integration_stack_manager = StackManager::new(&repo_root)?;
2113 let mut integration = BitbucketIntegration::new(
2114 integration_stack_manager,
2115 cascade_config.clone(),
2116 )?;
2117
2118 match integration
2119 .update_prs_after_rebase(&stack_id, &result.branch_mapping)
2120 .await
2121 {
2122 Ok(updated_prs) => {
2123 if !updated_prs.is_empty() {
2124 println!(" 🔄 Preserved pull request history:");
2125 for pr_update in updated_prs {
2126 println!(" ✅ {pr_update}");
2127 }
2128 }
2129 }
2130 Err(e) => {
2131 eprintln!(" ⚠️ Failed to update pull requests: {e}");
2132 eprintln!(" You may need to manually update PRs in Bitbucket");
2133 }
2134 }
2135 }
2136 }
2137
2138 println!(
2139 " ✅ {} commits successfully rebased",
2140 result.success_count()
2141 );
2142
2143 if matches!(
2145 rebase_strategy,
2146 crate::stack::RebaseStrategy::BranchVersioning
2147 ) {
2148 println!("\n📝 Next steps:");
2149 if !result.branch_mapping.is_empty() {
2150 println!(" 1. ✅ New versioned branches have been created");
2151 println!(" 2. ✅ Pull requests have been updated automatically");
2152 println!(" 3. 🔍 Review the updated PRs in Bitbucket");
2153 println!(" 4. 🧪 Test your changes on the new branches");
2154 println!(
2155 " 5. 🗑️ Old branches are preserved for safety (can be deleted later)"
2156 );
2157 } else {
2158 println!(" 1. Review the rebased stack");
2159 println!(" 2. Test your changes");
2160 println!(" 3. Submit new pull requests with 'ca stack submit'");
2161 }
2162 }
2163 }
2164 Err(e) => {
2165 warn!("❌ Rebase failed: {}", e);
2166 println!("💡 Tips for resolving rebase issues:");
2167 println!(" - Check for uncommitted changes with 'git status'");
2168 println!(" - Ensure base branch is up to date");
2169 println!(" - Try interactive mode: 'ca stack rebase --interactive'");
2170 return Err(e);
2171 }
2172 }
2173
2174 Ok(())
2175}
2176
2177async fn continue_rebase() -> Result<()> {
2178 let current_dir = env::current_dir()
2179 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2180
2181 let repo_root = find_repository_root(¤t_dir)
2182 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2183
2184 let stack_manager = StackManager::new(&repo_root)?;
2185 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2186 let options = crate::stack::RebaseOptions::default();
2187 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2188
2189 if !rebase_manager.is_rebase_in_progress() {
2190 println!("ℹ️ No rebase in progress");
2191 return Ok(());
2192 }
2193
2194 println!("🔄 Continuing rebase...");
2195 match rebase_manager.continue_rebase() {
2196 Ok(_) => {
2197 println!("✅ Rebase continued successfully");
2198 println!(" Check 'ca stack rebase-status' for current state");
2199 }
2200 Err(e) => {
2201 warn!("❌ Failed to continue rebase: {}", e);
2202 println!("💡 You may need to resolve conflicts first:");
2203 println!(" 1. Edit conflicted files");
2204 println!(" 2. Stage resolved files with 'git add'");
2205 println!(" 3. Run 'ca stack continue-rebase' again");
2206 }
2207 }
2208
2209 Ok(())
2210}
2211
2212async fn abort_rebase() -> Result<()> {
2213 let current_dir = env::current_dir()
2214 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2215
2216 let repo_root = find_repository_root(¤t_dir)
2217 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2218
2219 let stack_manager = StackManager::new(&repo_root)?;
2220 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2221 let options = crate::stack::RebaseOptions::default();
2222 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2223
2224 if !rebase_manager.is_rebase_in_progress() {
2225 println!("ℹ️ No rebase in progress");
2226 return Ok(());
2227 }
2228
2229 println!("⚠️ Aborting rebase...");
2230 match rebase_manager.abort_rebase() {
2231 Ok(_) => {
2232 println!("✅ Rebase aborted successfully");
2233 println!(" Repository restored to pre-rebase state");
2234 }
2235 Err(e) => {
2236 warn!("❌ Failed to abort rebase: {}", e);
2237 println!("⚠️ You may need to manually clean up the repository state");
2238 }
2239 }
2240
2241 Ok(())
2242}
2243
2244async fn rebase_status() -> Result<()> {
2245 let current_dir = env::current_dir()
2246 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2247
2248 let repo_root = find_repository_root(¤t_dir)
2249 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2250
2251 let stack_manager = StackManager::new(&repo_root)?;
2252 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2253
2254 println!("📊 Rebase Status");
2255
2256 let git_dir = current_dir.join(".git");
2258 let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
2259 || git_dir.join("rebase-merge").exists()
2260 || git_dir.join("rebase-apply").exists();
2261
2262 if rebase_in_progress {
2263 println!(" Status: 🔄 Rebase in progress");
2264 println!(
2265 "
2266📝 Actions available:"
2267 );
2268 println!(" - 'ca stack continue-rebase' to continue");
2269 println!(" - 'ca stack abort-rebase' to abort");
2270 println!(" - 'git status' to see conflicted files");
2271
2272 match git_repo.get_status() {
2274 Ok(statuses) => {
2275 let mut conflicts = Vec::new();
2276 for status in statuses.iter() {
2277 if status.status().contains(git2::Status::CONFLICTED) {
2278 if let Some(path) = status.path() {
2279 conflicts.push(path.to_string());
2280 }
2281 }
2282 }
2283
2284 if !conflicts.is_empty() {
2285 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
2286 for conflict in conflicts {
2287 println!(" - {conflict}");
2288 }
2289 println!(
2290 "
2291💡 To resolve conflicts:"
2292 );
2293 println!(" 1. Edit the conflicted files");
2294 println!(" 2. Stage resolved files: git add <file>");
2295 println!(" 3. Continue: ca stack continue-rebase");
2296 }
2297 }
2298 Err(e) => {
2299 warn!("Failed to get git status: {}", e);
2300 }
2301 }
2302 } else {
2303 println!(" Status: ✅ No rebase in progress");
2304
2305 if let Some(active_stack) = stack_manager.get_active_stack() {
2307 println!(" Active stack: {}", active_stack.name);
2308 println!(" Entries: {}", active_stack.entries.len());
2309 println!(" Base branch: {}", active_stack.base_branch);
2310 }
2311 }
2312
2313 Ok(())
2314}
2315
2316async fn delete_stack(name: String, force: bool) -> Result<()> {
2317 let current_dir = env::current_dir()
2318 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2319
2320 let repo_root = find_repository_root(¤t_dir)
2321 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2322
2323 let mut manager = StackManager::new(&repo_root)?;
2324
2325 let stack = manager
2326 .get_stack_by_name(&name)
2327 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2328 let stack_id = stack.id;
2329
2330 if !force && !stack.entries.is_empty() {
2331 return Err(CascadeError::config(format!(
2332 "Stack '{}' has {} entries. Use --force to delete anyway",
2333 name,
2334 stack.entries.len()
2335 )));
2336 }
2337
2338 let deleted = manager.delete_stack(&stack_id)?;
2339
2340 info!("✅ Deleted stack '{}'", deleted.name);
2341 if !deleted.entries.is_empty() {
2342 warn!(" {} entries were removed", deleted.entries.len());
2343 }
2344
2345 Ok(())
2346}
2347
2348async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
2349 let current_dir = env::current_dir()
2350 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2351
2352 let repo_root = find_repository_root(¤t_dir)
2353 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2354
2355 let mut manager = StackManager::new(&repo_root)?;
2356
2357 if let Some(name) = name {
2358 let stack = manager
2360 .get_stack_by_name(&name)
2361 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2362
2363 let stack_id = stack.id;
2364
2365 match stack.validate() {
2367 Ok(message) => {
2368 println!("✅ Stack '{name}' structure validation: {message}");
2369 }
2370 Err(e) => {
2371 println!("❌ Stack '{name}' structure validation failed: {e}");
2372 return Err(CascadeError::config(e));
2373 }
2374 }
2375
2376 manager.handle_branch_modifications(&stack_id, fix_mode)?;
2378
2379 println!("🎉 Stack '{name}' validation completed");
2380 Ok(())
2381 } else {
2382 println!("🔍 Validating all stacks...");
2384
2385 let all_stacks = manager.get_all_stacks();
2387 let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
2388
2389 if stack_ids.is_empty() {
2390 println!("📭 No stacks found");
2391 return Ok(());
2392 }
2393
2394 let mut all_valid = true;
2395 for stack_id in stack_ids {
2396 let stack = manager.get_stack(&stack_id).unwrap();
2397 let stack_name = &stack.name;
2398
2399 println!("\n📋 Checking stack '{stack_name}':");
2400
2401 match stack.validate() {
2403 Ok(message) => {
2404 println!(" ✅ Structure: {message}");
2405 }
2406 Err(e) => {
2407 println!(" ❌ Structure: {e}");
2408 all_valid = false;
2409 continue;
2410 }
2411 }
2412
2413 match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
2415 Ok(_) => {
2416 println!(" ✅ Git integrity: OK");
2417 }
2418 Err(e) => {
2419 println!(" ❌ Git integrity: {e}");
2420 all_valid = false;
2421 }
2422 }
2423 }
2424
2425 if all_valid {
2426 println!("\n🎉 All stacks passed validation");
2427 } else {
2428 println!("\n⚠️ Some stacks have validation issues");
2429 return Err(CascadeError::config("Stack validation failed".to_string()));
2430 }
2431
2432 Ok(())
2433 }
2434}
2435
2436#[allow(dead_code)]
2438fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
2439 let mut unpushed = Vec::new();
2440 let head_commit = repo.get_head_commit()?;
2441 let mut current_commit = head_commit;
2442
2443 loop {
2445 let commit_hash = current_commit.id().to_string();
2446 let already_in_stack = stack
2447 .entries
2448 .iter()
2449 .any(|entry| entry.commit_hash == commit_hash);
2450
2451 if already_in_stack {
2452 break;
2453 }
2454
2455 unpushed.push(commit_hash);
2456
2457 if let Some(parent) = current_commit.parents().next() {
2459 current_commit = parent;
2460 } else {
2461 break;
2462 }
2463 }
2464
2465 unpushed.reverse(); Ok(unpushed)
2467}
2468
2469pub async fn squash_commits(
2471 repo: &GitRepository,
2472 count: usize,
2473 since_ref: Option<String>,
2474) -> Result<()> {
2475 if count <= 1 {
2476 return Ok(()); }
2478
2479 let _current_branch = repo.get_current_branch()?;
2481
2482 let rebase_range = if let Some(ref since) = since_ref {
2484 since.clone()
2485 } else {
2486 format!("HEAD~{count}")
2487 };
2488
2489 println!(" Analyzing {count} commits to create smart squash message...");
2490
2491 let head_commit = repo.get_head_commit()?;
2493 let mut commits_to_squash = Vec::new();
2494 let mut current = head_commit;
2495
2496 for _ in 0..count {
2498 commits_to_squash.push(current.clone());
2499 if current.parent_count() > 0 {
2500 current = current.parent(0).map_err(CascadeError::Git)?;
2501 } else {
2502 break;
2503 }
2504 }
2505
2506 let smart_message = generate_squash_message(&commits_to_squash)?;
2508 println!(
2509 " Smart message: {}",
2510 smart_message.lines().next().unwrap_or("")
2511 );
2512
2513 let reset_target = if since_ref.is_some() {
2515 format!("{rebase_range}~1")
2517 } else {
2518 format!("HEAD~{count}")
2520 };
2521
2522 repo.reset_soft(&reset_target)?;
2524
2525 repo.stage_all()?;
2527
2528 let new_commit_hash = repo.commit(&smart_message)?;
2530
2531 println!(
2532 " Created squashed commit: {} ({})",
2533 &new_commit_hash[..8],
2534 smart_message.lines().next().unwrap_or("")
2535 );
2536 println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
2537
2538 Ok(())
2539}
2540
2541pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
2543 if commits.is_empty() {
2544 return Ok("Squashed commits".to_string());
2545 }
2546
2547 let messages: Vec<String> = commits
2549 .iter()
2550 .map(|c| c.message().unwrap_or("").trim().to_string())
2551 .filter(|m| !m.is_empty())
2552 .collect();
2553
2554 if messages.is_empty() {
2555 return Ok("Squashed commits".to_string());
2556 }
2557
2558 if let Some(last_msg) = messages.first() {
2560 if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
2562 return Ok(last_msg
2563 .trim_start_matches("Final:")
2564 .trim_start_matches("final:")
2565 .trim()
2566 .to_string());
2567 }
2568 }
2569
2570 let wip_count = messages
2572 .iter()
2573 .filter(|m| {
2574 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
2575 })
2576 .count();
2577
2578 if wip_count > messages.len() / 2 {
2579 let non_wip: Vec<&String> = messages
2581 .iter()
2582 .filter(|m| {
2583 !m.to_lowercase().starts_with("wip")
2584 && !m.to_lowercase().contains("work in progress")
2585 })
2586 .collect();
2587
2588 if let Some(best_msg) = non_wip.first() {
2589 return Ok(best_msg.to_string());
2590 }
2591
2592 let feature = extract_feature_from_wip(&messages);
2594 return Ok(feature);
2595 }
2596
2597 Ok(messages.first().unwrap().clone())
2599}
2600
2601pub fn extract_feature_from_wip(messages: &[String]) -> String {
2603 for msg in messages {
2605 if msg.to_lowercase().starts_with("wip:") {
2607 if let Some(rest) = msg
2608 .strip_prefix("WIP:")
2609 .or_else(|| msg.strip_prefix("wip:"))
2610 {
2611 let feature = rest.trim();
2612 if !feature.is_empty() && feature.len() > 3 {
2613 let mut chars: Vec<char> = feature.chars().collect();
2615 if let Some(first) = chars.first_mut() {
2616 *first = first.to_uppercase().next().unwrap_or(*first);
2617 }
2618 return chars.into_iter().collect();
2619 }
2620 }
2621 }
2622 }
2623
2624 if let Some(first) = messages.first() {
2626 let cleaned = first
2627 .trim_start_matches("WIP:")
2628 .trim_start_matches("wip:")
2629 .trim_start_matches("WIP")
2630 .trim_start_matches("wip")
2631 .trim();
2632
2633 if !cleaned.is_empty() {
2634 return format!("Implement {cleaned}");
2635 }
2636 }
2637
2638 format!("Squashed {} commits", messages.len())
2639}
2640
2641pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
2643 let head_commit = repo.get_head_commit()?;
2644 let since_commit = repo.get_commit(since_commit_hash)?;
2645
2646 let mut count = 0;
2647 let mut current = head_commit;
2648
2649 loop {
2651 if current.id() == since_commit.id() {
2652 break;
2653 }
2654
2655 count += 1;
2656
2657 if current.parent_count() == 0 {
2659 break; }
2661
2662 current = current.parent(0).map_err(CascadeError::Git)?;
2663 }
2664
2665 Ok(count)
2666}
2667
2668async fn land_stack(
2670 entry: Option<usize>,
2671 force: bool,
2672 dry_run: bool,
2673 auto: bool,
2674 wait_for_builds: bool,
2675 strategy: Option<MergeStrategyArg>,
2676 build_timeout: u64,
2677) -> Result<()> {
2678 let current_dir = env::current_dir()
2679 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2680
2681 let repo_root = find_repository_root(¤t_dir)
2682 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2683
2684 let stack_manager = StackManager::new(&repo_root)?;
2685
2686 let stack_id = stack_manager
2688 .get_active_stack()
2689 .map(|s| s.id)
2690 .ok_or_else(|| {
2691 CascadeError::config(
2692 "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
2693 .to_string(),
2694 )
2695 })?;
2696
2697 let active_stack = stack_manager
2698 .get_active_stack()
2699 .cloned()
2700 .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
2701
2702 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2704 let config_path = config_dir.join("config.json");
2705 let settings = crate::config::Settings::load_from_file(&config_path)?;
2706
2707 let cascade_config = crate::config::CascadeConfig {
2708 bitbucket: Some(settings.bitbucket.clone()),
2709 git: settings.git.clone(),
2710 auth: crate::config::AuthConfig::default(),
2711 cascade: settings.cascade.clone(),
2712 };
2713
2714 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2715
2716 let status = integration.check_enhanced_stack_status(&stack_id).await?;
2718
2719 if status.enhanced_statuses.is_empty() {
2720 println!("❌ No pull requests found to land");
2721 return Ok(());
2722 }
2723
2724 let ready_prs: Vec<_> = status
2726 .enhanced_statuses
2727 .iter()
2728 .filter(|pr_status| {
2729 if let Some(entry_num) = entry {
2731 if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
2733 if pr_status.pr.from_ref.display_id != stack_entry.branch {
2735 return false;
2736 }
2737 } else {
2738 return false; }
2740 }
2741
2742 if force {
2743 pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
2745 } else {
2746 pr_status.is_ready_to_land()
2747 }
2748 })
2749 .collect();
2750
2751 if ready_prs.is_empty() {
2752 if let Some(entry_num) = entry {
2753 println!("❌ Entry {entry_num} is not ready to land or doesn't exist");
2754 } else {
2755 println!("❌ No pull requests are ready to land");
2756 }
2757
2758 println!("\n🚫 Blocking Issues:");
2760 for pr_status in &status.enhanced_statuses {
2761 if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
2762 let blocking = pr_status.get_blocking_reasons();
2763 if !blocking.is_empty() {
2764 println!(" PR #{}: {}", pr_status.pr.id, blocking.join(", "));
2765 }
2766 }
2767 }
2768
2769 if !force {
2770 println!("\n💡 Use --force to land PRs with blocking issues (dangerous!)");
2771 }
2772 return Ok(());
2773 }
2774
2775 if dry_run {
2776 if let Some(entry_num) = entry {
2777 println!("🏃 Dry Run - Entry {entry_num} that would be landed:");
2778 } else {
2779 println!("🏃 Dry Run - PRs that would be landed:");
2780 }
2781 for pr_status in &ready_prs {
2782 println!(" ✅ PR #{}: {}", pr_status.pr.id, pr_status.pr.title);
2783 if !pr_status.is_ready_to_land() && force {
2784 let blocking = pr_status.get_blocking_reasons();
2785 println!(
2786 " ⚠️ Would force land despite: {}",
2787 blocking.join(", ")
2788 );
2789 }
2790 }
2791 return Ok(());
2792 }
2793
2794 if entry.is_some() && ready_prs.len() > 1 {
2797 println!(
2798 "🎯 {} PRs are ready to land, but landing only entry #{}",
2799 ready_prs.len(),
2800 entry.unwrap()
2801 );
2802 }
2803
2804 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
2806 strategy.unwrap_or(MergeStrategyArg::Squash).into();
2807 let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
2808 merge_strategy: merge_strategy.clone(),
2809 wait_for_builds,
2810 build_timeout: std::time::Duration::from_secs(build_timeout),
2811 allowed_authors: None, };
2813
2814 println!(
2816 "🚀 Landing {} PR{}...",
2817 ready_prs.len(),
2818 if ready_prs.len() == 1 { "" } else { "s" }
2819 );
2820
2821 let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
2822 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
2823 );
2824
2825 let mut landed_count = 0;
2827 let mut failed_count = 0;
2828 let total_ready_prs = ready_prs.len();
2829
2830 for pr_status in ready_prs {
2831 let pr_id = pr_status.pr.id;
2832
2833 print!("🚀 Landing PR #{}: {}", pr_id, pr_status.pr.title);
2834
2835 let land_result = if auto {
2836 pr_manager
2838 .auto_merge_if_ready(pr_id, &auto_merge_conditions)
2839 .await
2840 } else {
2841 pr_manager
2843 .merge_pull_request(pr_id, merge_strategy.clone())
2844 .await
2845 .map(
2846 |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
2847 pr: Box::new(pr),
2848 merge_strategy: merge_strategy.clone(),
2849 },
2850 )
2851 };
2852
2853 match land_result {
2854 Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
2855 println!(" ✅");
2856 landed_count += 1;
2857
2858 if landed_count < total_ready_prs {
2860 println!("🔄 Retargeting remaining PRs to latest base...");
2861
2862 let base_branch = active_stack.base_branch.clone();
2864 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2865
2866 println!(" 📥 Updating base branch: {base_branch}");
2867 match git_repo.pull(&base_branch) {
2868 Ok(_) => println!(" ✅ Base branch updated successfully"),
2869 Err(e) => {
2870 println!(" ⚠️ Warning: Failed to update base branch: {e}");
2871 println!(
2872 " 💡 You may want to manually run: git pull origin {base_branch}"
2873 );
2874 }
2875 }
2876
2877 let mut rebase_manager = crate::stack::RebaseManager::new(
2879 StackManager::new(&repo_root)?,
2880 git_repo,
2881 crate::stack::RebaseOptions {
2882 strategy: crate::stack::RebaseStrategy::BranchVersioning,
2883 target_base: Some(base_branch.clone()),
2884 ..Default::default()
2885 },
2886 );
2887
2888 match rebase_manager.rebase_stack(&stack_id) {
2889 Ok(rebase_result) => {
2890 if !rebase_result.branch_mapping.is_empty() {
2891 let retarget_config = crate::config::CascadeConfig {
2893 bitbucket: Some(settings.bitbucket.clone()),
2894 git: settings.git.clone(),
2895 auth: crate::config::AuthConfig::default(),
2896 cascade: settings.cascade.clone(),
2897 };
2898 let mut retarget_integration = BitbucketIntegration::new(
2899 StackManager::new(&repo_root)?,
2900 retarget_config,
2901 )?;
2902
2903 match retarget_integration
2904 .update_prs_after_rebase(
2905 &stack_id,
2906 &rebase_result.branch_mapping,
2907 )
2908 .await
2909 {
2910 Ok(updated_prs) => {
2911 if !updated_prs.is_empty() {
2912 println!(
2913 " ✅ Updated {} PRs with new targets",
2914 updated_prs.len()
2915 );
2916 }
2917 }
2918 Err(e) => {
2919 println!(" ⚠️ Failed to update remaining PRs: {e}");
2920 println!(
2921 " 💡 You may need to run: ca stack rebase --onto {base_branch}"
2922 );
2923 }
2924 }
2925 }
2926 }
2927 Err(e) => {
2928 println!(" ❌ Auto-retargeting conflicts detected!");
2930 println!(" 📝 To resolve conflicts and continue landing:");
2931 println!(" 1. Resolve conflicts in the affected files");
2932 println!(" 2. Stage resolved files: git add <files>");
2933 println!(" 3. Continue the process: ca stack continue-land");
2934 println!(" 4. Or abort the operation: ca stack abort-land");
2935 println!();
2936 println!(" 💡 Check current status: ca stack land-status");
2937 println!(" ⚠️ Error details: {e}");
2938
2939 break;
2941 }
2942 }
2943 }
2944 }
2945 Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
2946 println!(" ❌ Not ready: {}", blocking_reasons.join(", "));
2947 failed_count += 1;
2948 if !force {
2949 break;
2950 }
2951 }
2952 Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
2953 println!(" ❌ Failed: {error}");
2954 failed_count += 1;
2955 if !force {
2956 break;
2957 }
2958 }
2959 Err(e) => {
2960 println!(" ❌");
2961 eprintln!("Failed to land PR #{pr_id}: {e}");
2962 failed_count += 1;
2963
2964 if !force {
2965 break;
2966 }
2967 }
2968 }
2969 }
2970
2971 println!("\n🎯 Landing Summary:");
2973 println!(" ✅ Successfully landed: {landed_count}");
2974 if failed_count > 0 {
2975 println!(" ❌ Failed to land: {failed_count}");
2976 }
2977
2978 if landed_count > 0 {
2979 println!("✅ Landing operation completed!");
2980 } else {
2981 println!("❌ No PRs were successfully landed");
2982 }
2983
2984 Ok(())
2985}
2986
2987async fn auto_land_stack(
2989 force: bool,
2990 dry_run: bool,
2991 wait_for_builds: bool,
2992 strategy: Option<MergeStrategyArg>,
2993 build_timeout: u64,
2994) -> Result<()> {
2995 land_stack(
2997 None,
2998 force,
2999 dry_run,
3000 true, wait_for_builds,
3002 strategy,
3003 build_timeout,
3004 )
3005 .await
3006}
3007
3008async fn continue_land() -> Result<()> {
3009 let current_dir = env::current_dir()
3010 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3011
3012 let repo_root = find_repository_root(¤t_dir)
3013 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3014
3015 let stack_manager = StackManager::new(&repo_root)?;
3016 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3017 let options = crate::stack::RebaseOptions::default();
3018 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3019
3020 if !rebase_manager.is_rebase_in_progress() {
3021 println!("ℹ️ No rebase in progress");
3022 return Ok(());
3023 }
3024
3025 println!("🔄 Continuing land operation...");
3026 match rebase_manager.continue_rebase() {
3027 Ok(_) => {
3028 println!("✅ Land operation continued successfully");
3029 println!(" Check 'ca stack land-status' for current state");
3030 }
3031 Err(e) => {
3032 warn!("❌ Failed to continue land operation: {}", e);
3033 println!("💡 You may need to resolve conflicts first:");
3034 println!(" 1. Edit conflicted files");
3035 println!(" 2. Stage resolved files with 'git add'");
3036 println!(" 3. Run 'ca stack continue-land' again");
3037 }
3038 }
3039
3040 Ok(())
3041}
3042
3043async fn abort_land() -> Result<()> {
3044 let current_dir = env::current_dir()
3045 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3046
3047 let repo_root = find_repository_root(¤t_dir)
3048 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3049
3050 let stack_manager = StackManager::new(&repo_root)?;
3051 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3052 let options = crate::stack::RebaseOptions::default();
3053 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3054
3055 if !rebase_manager.is_rebase_in_progress() {
3056 println!("ℹ️ No rebase in progress");
3057 return Ok(());
3058 }
3059
3060 println!("⚠️ Aborting land operation...");
3061 match rebase_manager.abort_rebase() {
3062 Ok(_) => {
3063 println!("✅ Land operation aborted successfully");
3064 println!(" Repository restored to pre-land state");
3065 }
3066 Err(e) => {
3067 warn!("❌ Failed to abort land operation: {}", e);
3068 println!("⚠️ You may need to manually clean up the repository state");
3069 }
3070 }
3071
3072 Ok(())
3073}
3074
3075async fn land_status() -> Result<()> {
3076 let current_dir = env::current_dir()
3077 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3078
3079 let repo_root = find_repository_root(¤t_dir)
3080 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3081
3082 let stack_manager = StackManager::new(&repo_root)?;
3083 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3084
3085 println!("📊 Land Status");
3086
3087 let git_dir = repo_root.join(".git");
3089 let land_in_progress = git_dir.join("REBASE_HEAD").exists()
3090 || git_dir.join("rebase-merge").exists()
3091 || git_dir.join("rebase-apply").exists();
3092
3093 if land_in_progress {
3094 println!(" Status: 🔄 Land operation in progress");
3095 println!(
3096 "
3097📝 Actions available:"
3098 );
3099 println!(" - 'ca stack continue-land' to continue");
3100 println!(" - 'ca stack abort-land' to abort");
3101 println!(" - 'git status' to see conflicted files");
3102
3103 match git_repo.get_status() {
3105 Ok(statuses) => {
3106 let mut conflicts = Vec::new();
3107 for status in statuses.iter() {
3108 if status.status().contains(git2::Status::CONFLICTED) {
3109 if let Some(path) = status.path() {
3110 conflicts.push(path.to_string());
3111 }
3112 }
3113 }
3114
3115 if !conflicts.is_empty() {
3116 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
3117 for conflict in conflicts {
3118 println!(" - {conflict}");
3119 }
3120 println!(
3121 "
3122💡 To resolve conflicts:"
3123 );
3124 println!(" 1. Edit the conflicted files");
3125 println!(" 2. Stage resolved files: git add <file>");
3126 println!(" 3. Continue: ca stack continue-land");
3127 }
3128 }
3129 Err(e) => {
3130 warn!("Failed to get git status: {}", e);
3131 }
3132 }
3133 } else {
3134 println!(" Status: ✅ No land operation in progress");
3135
3136 if let Some(active_stack) = stack_manager.get_active_stack() {
3138 println!(" Active stack: {}", active_stack.name);
3139 println!(" Entries: {}", active_stack.entries.len());
3140 println!(" Base branch: {}", active_stack.base_branch);
3141 }
3142 }
3143
3144 Ok(())
3145}
3146
3147async fn repair_stack_data() -> Result<()> {
3148 let current_dir = env::current_dir()
3149 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3150
3151 let repo_root = find_repository_root(¤t_dir)
3152 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3153
3154 let mut stack_manager = StackManager::new(&repo_root)?;
3155
3156 println!("🔧 Repairing stack data consistency...");
3157
3158 stack_manager.repair_all_stacks()?;
3159
3160 println!("✅ Stack data consistency repaired successfully!");
3161 println!("💡 Run 'ca stack --mergeable' to see updated status");
3162
3163 Ok(())
3164}
3165
3166#[cfg(test)]
3167mod tests {
3168 use super::*;
3169 use std::process::Command;
3170 use tempfile::TempDir;
3171
3172 fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
3173 let temp_dir = TempDir::new()
3174 .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
3175 let repo_path = temp_dir.path().to_path_buf();
3176
3177 let output = Command::new("git")
3179 .args(["init"])
3180 .current_dir(&repo_path)
3181 .output()
3182 .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
3183 if !output.status.success() {
3184 return Err(CascadeError::config("Git init failed".to_string()));
3185 }
3186
3187 let output = Command::new("git")
3188 .args(["config", "user.name", "Test User"])
3189 .current_dir(&repo_path)
3190 .output()
3191 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3192 if !output.status.success() {
3193 return Err(CascadeError::config(
3194 "Git config user.name failed".to_string(),
3195 ));
3196 }
3197
3198 let output = Command::new("git")
3199 .args(["config", "user.email", "test@example.com"])
3200 .current_dir(&repo_path)
3201 .output()
3202 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3203 if !output.status.success() {
3204 return Err(CascadeError::config(
3205 "Git config user.email failed".to_string(),
3206 ));
3207 }
3208
3209 std::fs::write(repo_path.join("README.md"), "# Test")
3211 .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
3212 let output = Command::new("git")
3213 .args(["add", "."])
3214 .current_dir(&repo_path)
3215 .output()
3216 .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
3217 if !output.status.success() {
3218 return Err(CascadeError::config("Git add failed".to_string()));
3219 }
3220
3221 let output = Command::new("git")
3222 .args(["commit", "-m", "Initial commit"])
3223 .current_dir(&repo_path)
3224 .output()
3225 .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
3226 if !output.status.success() {
3227 return Err(CascadeError::config("Git commit failed".to_string()));
3228 }
3229
3230 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
3232
3233 Ok((temp_dir, repo_path))
3234 }
3235
3236 #[tokio::test]
3237 async fn test_create_stack() {
3238 let (temp_dir, repo_path) = match create_test_repo() {
3239 Ok(repo) => repo,
3240 Err(_) => {
3241 println!("Skipping test due to git environment setup failure");
3242 return;
3243 }
3244 };
3245 let _ = &temp_dir;
3247
3248 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3252 match env::set_current_dir(&repo_path) {
3253 Ok(_) => {
3254 let result = create_stack(
3255 "test-stack".to_string(),
3256 None, Some("Test description".to_string()),
3258 )
3259 .await;
3260
3261 if let Ok(orig) = original_dir {
3263 let _ = env::set_current_dir(orig);
3264 }
3265
3266 assert!(
3267 result.is_ok(),
3268 "Stack creation should succeed in initialized repository"
3269 );
3270 }
3271 Err(_) => {
3272 println!("Skipping test due to directory access restrictions");
3274 }
3275 }
3276 }
3277
3278 #[tokio::test]
3279 async fn test_list_empty_stacks() {
3280 let (temp_dir, repo_path) = match create_test_repo() {
3281 Ok(repo) => repo,
3282 Err(_) => {
3283 println!("Skipping test due to git environment setup failure");
3284 return;
3285 }
3286 };
3287 let _ = &temp_dir;
3289
3290 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3294 match env::set_current_dir(&repo_path) {
3295 Ok(_) => {
3296 let result = list_stacks(false, false, None).await;
3297
3298 if let Ok(orig) = original_dir {
3300 let _ = env::set_current_dir(orig);
3301 }
3302
3303 assert!(
3304 result.is_ok(),
3305 "Listing stacks should succeed in initialized repository"
3306 );
3307 }
3308 Err(_) => {
3309 println!("Skipping test due to directory access restrictions");
3311 }
3312 }
3313 }
3314
3315 #[test]
3318 fn test_extract_feature_from_wip_basic() {
3319 let messages = vec![
3320 "WIP: add authentication".to_string(),
3321 "WIP: implement login flow".to_string(),
3322 ];
3323
3324 let result = extract_feature_from_wip(&messages);
3325 assert_eq!(result, "Add authentication");
3326 }
3327
3328 #[test]
3329 fn test_extract_feature_from_wip_capitalize() {
3330 let messages = vec!["WIP: fix user validation bug".to_string()];
3331
3332 let result = extract_feature_from_wip(&messages);
3333 assert_eq!(result, "Fix user validation bug");
3334 }
3335
3336 #[test]
3337 fn test_extract_feature_from_wip_fallback() {
3338 let messages = vec![
3339 "WIP user interface changes".to_string(),
3340 "wip: css styling".to_string(),
3341 ];
3342
3343 let result = extract_feature_from_wip(&messages);
3344 assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
3346 }
3347
3348 #[test]
3349 fn test_extract_feature_from_wip_empty() {
3350 let messages = vec![];
3351
3352 let result = extract_feature_from_wip(&messages);
3353 assert_eq!(result, "Squashed 0 commits");
3354 }
3355
3356 #[test]
3357 fn test_extract_feature_from_wip_short_message() {
3358 let messages = vec!["WIP: x".to_string()]; let result = extract_feature_from_wip(&messages);
3361 assert!(result.starts_with("Implement") || result.contains("Squashed"));
3362 }
3363
3364 #[test]
3367 fn test_squash_message_final_strategy() {
3368 let messages = [
3372 "Final: implement user authentication system".to_string(),
3373 "WIP: add tests".to_string(),
3374 "WIP: fix validation".to_string(),
3375 ];
3376
3377 assert!(messages[0].starts_with("Final:"));
3379
3380 let extracted = messages[0].trim_start_matches("Final:").trim();
3382 assert_eq!(extracted, "implement user authentication system");
3383 }
3384
3385 #[test]
3386 fn test_squash_message_wip_detection() {
3387 let messages = [
3388 "WIP: start feature".to_string(),
3389 "WIP: continue work".to_string(),
3390 "WIP: almost done".to_string(),
3391 "Regular commit message".to_string(),
3392 ];
3393
3394 let wip_count = messages
3395 .iter()
3396 .filter(|m| {
3397 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3398 })
3399 .count();
3400
3401 assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2); let non_wip: Vec<&String> = messages
3406 .iter()
3407 .filter(|m| {
3408 !m.to_lowercase().starts_with("wip")
3409 && !m.to_lowercase().contains("work in progress")
3410 })
3411 .collect();
3412
3413 assert_eq!(non_wip.len(), 1);
3414 assert_eq!(non_wip[0], "Regular commit message");
3415 }
3416
3417 #[test]
3418 fn test_squash_message_all_wip() {
3419 let messages = vec![
3420 "WIP: add feature A".to_string(),
3421 "WIP: add feature B".to_string(),
3422 "WIP: finish implementation".to_string(),
3423 ];
3424
3425 let result = extract_feature_from_wip(&messages);
3426 assert_eq!(result, "Add feature A");
3428 }
3429
3430 #[test]
3431 fn test_squash_message_edge_cases() {
3432 let empty_messages: Vec<String> = vec![];
3434 let result = extract_feature_from_wip(&empty_messages);
3435 assert_eq!(result, "Squashed 0 commits");
3436
3437 let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
3439 let result = extract_feature_from_wip(&whitespace_messages);
3440 assert!(result.contains("Squashed") || result.contains("Implement"));
3441
3442 let mixed_case = vec!["wip: Add Feature".to_string()];
3444 let result = extract_feature_from_wip(&mixed_case);
3445 assert_eq!(result, "Add Feature");
3446 }
3447
3448 #[tokio::test]
3451 async fn test_auto_land_wrapper() {
3452 let (temp_dir, repo_path) = match create_test_repo() {
3454 Ok(repo) => repo,
3455 Err(_) => {
3456 println!("Skipping test due to git environment setup failure");
3457 return;
3458 }
3459 };
3460 let _ = &temp_dir;
3462
3463 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
3465 .expect("Failed to initialize Cascade in test repo");
3466
3467 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3468 match env::set_current_dir(&repo_path) {
3469 Ok(_) => {
3470 let result = create_stack(
3472 "test-stack".to_string(),
3473 None,
3474 Some("Test stack for auto-land".to_string()),
3475 )
3476 .await;
3477
3478 if let Ok(orig) = original_dir {
3479 let _ = env::set_current_dir(orig);
3480 }
3481
3482 assert!(
3485 result.is_ok(),
3486 "Stack creation should succeed in initialized repository"
3487 );
3488 }
3489 Err(_) => {
3490 println!("Skipping test due to directory access restrictions");
3491 }
3492 }
3493 }
3494
3495 #[test]
3496 fn test_auto_land_action_enum() {
3497 use crate::cli::commands::stack::StackAction;
3499
3500 let _action = StackAction::AutoLand {
3502 force: false,
3503 dry_run: true,
3504 wait_for_builds: true,
3505 strategy: Some(MergeStrategyArg::Squash),
3506 build_timeout: 1800,
3507 };
3508
3509 }
3511
3512 #[test]
3513 fn test_merge_strategy_conversion() {
3514 let squash_strategy = MergeStrategyArg::Squash;
3516 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
3517
3518 match merge_strategy {
3519 crate::bitbucket::pull_request::MergeStrategy::Squash => {
3520 }
3522 _ => panic!("Expected Squash strategy"),
3523 }
3524
3525 let merge_strategy = MergeStrategyArg::Merge;
3526 let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
3527
3528 match converted {
3529 crate::bitbucket::pull_request::MergeStrategy::Merge => {
3530 }
3532 _ => panic!("Expected Merge strategy"),
3533 }
3534 }
3535
3536 #[test]
3537 fn test_auto_merge_conditions_structure() {
3538 use std::time::Duration;
3540
3541 let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
3542 merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
3543 wait_for_builds: true,
3544 build_timeout: Duration::from_secs(1800),
3545 allowed_authors: None,
3546 };
3547
3548 assert!(conditions.wait_for_builds);
3550 assert_eq!(conditions.build_timeout.as_secs(), 1800);
3551 assert!(conditions.allowed_authors.is_none());
3552 assert!(matches!(
3553 conditions.merge_strategy,
3554 crate::bitbucket::pull_request::MergeStrategy::Squash
3555 ));
3556 }
3557
3558 #[test]
3559 fn test_polling_constants() {
3560 use std::time::Duration;
3562
3563 let expected_polling_interval = Duration::from_secs(30);
3565
3566 assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
3571
3572 #[test]
3573 fn test_build_timeout_defaults() {
3574 const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
3577 let timeout_value = 1800u64;
3579 assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
3582
3583 #[test]
3584 fn test_scattered_commit_detection() {
3585 use std::collections::HashSet;
3586
3587 let mut source_branches = HashSet::new();
3589 source_branches.insert("feature-branch-1".to_string());
3590 source_branches.insert("feature-branch-2".to_string());
3591 source_branches.insert("feature-branch-3".to_string());
3592
3593 let single_branch = HashSet::from(["main".to_string()]);
3595 assert_eq!(single_branch.len(), 1);
3596
3597 assert!(source_branches.len() > 1);
3599 assert_eq!(source_branches.len(), 3);
3600
3601 assert!(source_branches.contains("feature-branch-1"));
3603 assert!(source_branches.contains("feature-branch-2"));
3604 assert!(source_branches.contains("feature-branch-3"));
3605 }
3606
3607 #[test]
3608 fn test_source_branch_tracking() {
3609 let branch_a = "feature-work";
3613 let branch_b = "feature-work";
3614 assert_eq!(branch_a, branch_b);
3615
3616 let branch_1 = "feature-ui";
3618 let branch_2 = "feature-api";
3619 assert_ne!(branch_1, branch_2);
3620
3621 assert!(branch_1.starts_with("feature-"));
3623 assert!(branch_2.starts_with("feature-"));
3624 }
3625
3626 #[tokio::test]
3629 async fn test_push_default_behavior() {
3630 let (temp_dir, repo_path) = match create_test_repo() {
3632 Ok(repo) => repo,
3633 Err(_) => {
3634 println!("Skipping test due to git environment setup failure");
3635 return;
3636 }
3637 };
3638 let _ = &temp_dir;
3640
3641 if !repo_path.exists() {
3643 println!("Skipping test due to temporary directory creation issue");
3644 return;
3645 }
3646
3647 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3649
3650 match env::set_current_dir(&repo_path) {
3651 Ok(_) => {
3652 let result = push_to_stack(
3654 None, None, None, None, None, None, None, false, false, )
3664 .await;
3665
3666 if let Ok(orig) = original_dir {
3668 let _ = env::set_current_dir(orig);
3669 }
3670
3671 match &result {
3673 Err(e) => {
3674 let error_msg = e.to_string();
3675 assert!(
3677 error_msg.contains("No active stack")
3678 || error_msg.contains("config")
3679 || error_msg.contains("current directory")
3680 || error_msg.contains("Not a git repository")
3681 || error_msg.contains("could not find repository"),
3682 "Expected 'No active stack' or repository error, got: {error_msg}"
3683 );
3684 }
3685 Ok(_) => {
3686 println!(
3688 "Push succeeded unexpectedly - test environment may have active stack"
3689 );
3690 }
3691 }
3692 }
3693 Err(_) => {
3694 println!("Skipping test due to directory access restrictions");
3696 }
3697 }
3698
3699 let push_action = StackAction::Push {
3701 branch: None,
3702 message: None,
3703 commit: None,
3704 since: None,
3705 commits: None,
3706 squash: None,
3707 squash_since: None,
3708 auto_branch: false,
3709 allow_base_branch: false,
3710 };
3711
3712 assert!(matches!(
3713 push_action,
3714 StackAction::Push {
3715 branch: None,
3716 message: None,
3717 commit: None,
3718 since: None,
3719 commits: None,
3720 squash: None,
3721 squash_since: None,
3722 auto_branch: false,
3723 allow_base_branch: false
3724 }
3725 ));
3726 }
3727
3728 #[tokio::test]
3729 async fn test_submit_default_behavior() {
3730 let (temp_dir, repo_path) = match create_test_repo() {
3732 Ok(repo) => repo,
3733 Err(_) => {
3734 println!("Skipping test due to git environment setup failure");
3735 return;
3736 }
3737 };
3738 let _ = &temp_dir;
3740
3741 if !repo_path.exists() {
3743 println!("Skipping test due to temporary directory creation issue");
3744 return;
3745 }
3746
3747 let original_dir = match env::current_dir() {
3749 Ok(dir) => dir,
3750 Err(_) => {
3751 println!("Skipping test due to current directory access restrictions");
3752 return;
3753 }
3754 };
3755
3756 match env::set_current_dir(&repo_path) {
3757 Ok(_) => {
3758 let result = submit_entry(
3760 None, None, None, None, false, )
3766 .await;
3767
3768 let _ = env::set_current_dir(original_dir);
3770
3771 match &result {
3773 Err(e) => {
3774 let error_msg = e.to_string();
3775 assert!(
3777 error_msg.contains("No active stack")
3778 || error_msg.contains("config")
3779 || error_msg.contains("current directory")
3780 || error_msg.contains("Not a git repository")
3781 || error_msg.contains("could not find repository"),
3782 "Expected 'No active stack' or repository error, got: {error_msg}"
3783 );
3784 }
3785 Ok(_) => {
3786 println!("Submit succeeded unexpectedly - test environment may have active stack");
3788 }
3789 }
3790 }
3791 Err(_) => {
3792 println!("Skipping test due to directory access restrictions");
3794 }
3795 }
3796
3797 let submit_action = StackAction::Submit {
3799 entry: None,
3800 title: None,
3801 description: None,
3802 range: None,
3803 draft: false,
3804 };
3805
3806 assert!(matches!(
3807 submit_action,
3808 StackAction::Submit {
3809 entry: None,
3810 title: None,
3811 description: None,
3812 range: None,
3813 draft: false
3814 }
3815 ));
3816 }
3817
3818 #[test]
3819 fn test_targeting_options_still_work() {
3820 let commits = "abc123,def456,ghi789";
3824 let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
3825 assert_eq!(parsed.len(), 3);
3826 assert_eq!(parsed[0], "abc123");
3827 assert_eq!(parsed[1], "def456");
3828 assert_eq!(parsed[2], "ghi789");
3829
3830 let range = "1-3";
3832 assert!(range.contains('-'));
3833 let parts: Vec<&str> = range.split('-').collect();
3834 assert_eq!(parts.len(), 2);
3835
3836 let since_ref = "HEAD~3";
3838 assert!(since_ref.starts_with("HEAD"));
3839 assert!(since_ref.contains('~'));
3840 }
3841
3842 #[test]
3843 fn test_command_flow_logic() {
3844 assert!(matches!(
3846 StackAction::Push {
3847 branch: None,
3848 message: None,
3849 commit: None,
3850 since: None,
3851 commits: None,
3852 squash: None,
3853 squash_since: None,
3854 auto_branch: false,
3855 allow_base_branch: false
3856 },
3857 StackAction::Push { .. }
3858 ));
3859
3860 assert!(matches!(
3861 StackAction::Submit {
3862 entry: None,
3863 title: None,
3864 description: None,
3865 range: None,
3866 draft: false
3867 },
3868 StackAction::Submit { .. }
3869 ));
3870 }
3871
3872 #[tokio::test]
3873 async fn test_deactivate_command_structure() {
3874 let deactivate_action = StackAction::Deactivate { force: false };
3876
3877 assert!(matches!(
3879 deactivate_action,
3880 StackAction::Deactivate { force: false }
3881 ));
3882
3883 let force_deactivate = StackAction::Deactivate { force: true };
3885 assert!(matches!(
3886 force_deactivate,
3887 StackAction::Deactivate { force: true }
3888 ));
3889 }
3890}