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(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 = if stack.entries.is_empty() {
629 None
631 } else {
632 stack.entries.first().map(|entry| &entry.branch)
634 };
635
636 let current_branch = repo.get_current_branch().ok();
638
639 if let Some(target) = target_branch {
641 if current_branch.as_ref() != Some(target) {
642 println!("🔄 Switching to stack branch: {target}");
643
644 if repo.branch_exists(target) {
646 match repo.checkout_branch(target) {
647 Ok(_) => {
648 println!("✅ Checked out branch: {target}");
649 }
650 Err(e) => {
651 println!("⚠️ Failed to checkout '{target}': {e}");
652 println!(" Stack activated but stayed on current branch");
653 println!(" You can manually checkout with: git checkout {target}");
654 }
655 }
656 } else {
657 println!("⚠️ Stack branch '{target}' doesn't exist locally");
658 println!(" Stack activated but stayed on current branch");
659 println!(" You may need to create the branch or fetch from remote");
660 }
661 } else {
662 println!("✅ Already on stack branch: {target}");
663 }
664 } else {
665 println!("ℹ️ Empty stack - staying on current branch");
666 }
667
668 manager.set_active_stack_by_name(&name)?;
670 info!("✅ Switched to stack '{}'", name);
671
672 Ok(())
673}
674
675async fn deactivate_stack(force: bool) -> Result<()> {
676 let current_dir = env::current_dir()
677 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
678
679 let repo_root = find_repository_root(¤t_dir)
680 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
681
682 let mut manager = StackManager::new(&repo_root)?;
683
684 let active_stack = manager.get_active_stack();
685
686 if active_stack.is_none() {
687 println!("ℹ️ No active stack to deactivate");
688 return Ok(());
689 }
690
691 let stack_name = active_stack.unwrap().name.clone();
692
693 if !force {
694 println!("⚠️ This will deactivate stack '{stack_name}' and return to normal Git workflow");
695 println!(" You can reactivate it later with 'ca stacks switch {stack_name}'");
696 print!(" Continue? (y/N): ");
697
698 use std::io::{self, Write};
699 io::stdout().flush().unwrap();
700
701 let mut input = String::new();
702 io::stdin().read_line(&mut input).unwrap();
703
704 if !input.trim().to_lowercase().starts_with('y') {
705 println!("Cancelled deactivation");
706 return Ok(());
707 }
708 }
709
710 manager.set_active_stack(None)?;
712
713 println!("✅ Deactivated stack '{stack_name}'");
714 println!(" Stack management is now OFF - you can use normal Git workflow");
715 println!(" To reactivate: ca stacks switch {stack_name}");
716
717 Ok(())
718}
719
720async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
721 let current_dir = env::current_dir()
722 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
723
724 let repo_root = find_repository_root(¤t_dir)
725 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
726
727 let stack_manager = StackManager::new(&repo_root)?;
728
729 let (stack_id, stack_name, stack_base, stack_entries) = {
731 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
732 CascadeError::config(
733 "No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
734 .to_string(),
735 )
736 })?;
737
738 (
739 active_stack.id,
740 active_stack.name.clone(),
741 active_stack.base_branch.clone(),
742 active_stack.entries.clone(),
743 )
744 };
745
746 println!("📊 Stack: {stack_name}");
747 println!(" Base branch: {stack_base}");
748 println!(" Total entries: {}", stack_entries.len());
749
750 if stack_entries.is_empty() {
751 println!(" No entries in this stack yet");
752 println!(" Use 'ca push' to add commits to this stack");
753 return Ok(());
754 }
755
756 println!("\n📚 Stack Entries:");
758 for (i, entry) in stack_entries.iter().enumerate() {
759 let entry_num = i + 1;
760 let short_hash = entry.short_hash();
761 let short_msg = entry.short_message(50);
762
763 let metadata = stack_manager.get_repository_metadata();
765 let source_branch_info = if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash)
766 {
767 if commit_meta.source_branch != commit_meta.branch
768 && !commit_meta.source_branch.is_empty()
769 {
770 format!(" (from {})", commit_meta.source_branch)
771 } else {
772 String::new()
773 }
774 } else {
775 String::new()
776 };
777
778 println!(
779 " {entry_num}. {} {} {}{}",
780 short_hash,
781 if entry.is_submitted { "📤" } else { "📝" },
782 short_msg,
783 source_branch_info
784 );
785
786 if verbose {
787 println!(" Branch: {}", entry.branch);
788 println!(
789 " Created: {}",
790 entry.created_at.format("%Y-%m-%d %H:%M")
791 );
792 if let Some(pr_id) = &entry.pull_request_id {
793 println!(" PR: #{pr_id}");
794 }
795 }
796 }
797
798 if show_mergeable {
800 println!("\n🔍 Mergability Status:");
801
802 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
804 let config_path = config_dir.join("config.json");
805 let settings = crate::config::Settings::load_from_file(&config_path)?;
806
807 let cascade_config = crate::config::CascadeConfig {
808 bitbucket: Some(settings.bitbucket.clone()),
809 git: settings.git.clone(),
810 auth: crate::config::AuthConfig::default(),
811 cascade: settings.cascade.clone(),
812 };
813
814 let integration =
815 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
816
817 match integration.check_enhanced_stack_status(&stack_id).await {
818 Ok(status) => {
819 println!(" Total entries: {}", status.total_entries);
820 println!(" Submitted: {}", status.submitted_entries);
821 println!(" Open PRs: {}", status.open_prs);
822 println!(" Merged PRs: {}", status.merged_prs);
823 println!(" Declined PRs: {}", status.declined_prs);
824 println!(" Completion: {:.1}%", status.completion_percentage());
825
826 if !status.enhanced_statuses.is_empty() {
827 println!("\n📋 Pull Request Status:");
828 let mut ready_to_land = 0;
829
830 for enhanced in &status.enhanced_statuses {
831 let status_display = enhanced.get_display_status();
832 let ready_icon = if enhanced.is_ready_to_land() {
833 ready_to_land += 1;
834 "🚀"
835 } else {
836 "⏳"
837 };
838
839 println!(
840 " {} PR #{}: {} ({})",
841 ready_icon, enhanced.pr.id, enhanced.pr.title, status_display
842 );
843
844 if verbose {
845 println!(
846 " {} -> {}",
847 enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
848 );
849
850 if !enhanced.is_ready_to_land() {
852 let blocking = enhanced.get_blocking_reasons();
853 if !blocking.is_empty() {
854 println!(" Blocking: {}", blocking.join(", "));
855 }
856 }
857
858 println!(
860 " Reviews: {}/{} approvals",
861 enhanced.review_status.current_approvals,
862 enhanced.review_status.required_approvals
863 );
864
865 if enhanced.review_status.needs_work_count > 0 {
866 println!(
867 " {} reviewers requested changes",
868 enhanced.review_status.needs_work_count
869 );
870 }
871
872 if let Some(build) = &enhanced.build_status {
874 let build_icon = match build.state {
875 crate::bitbucket::pull_request::BuildState::Successful => "✅",
876 crate::bitbucket::pull_request::BuildState::Failed => "❌",
877 crate::bitbucket::pull_request::BuildState::InProgress => "🔄",
878 _ => "⚪",
879 };
880 println!(" Build: {} {:?}", build_icon, build.state);
881 }
882
883 if let Some(url) = enhanced.pr.web_url() {
884 println!(" URL: {url}");
885 }
886 println!();
887 }
888 }
889
890 if ready_to_land > 0 {
891 println!(
892 "\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
893 ready_to_land,
894 if ready_to_land == 1 { " is" } else { "s are" }
895 );
896 }
897 }
898 }
899 Err(e) => {
900 warn!("Failed to get enhanced stack status: {}", e);
901 println!(" ⚠️ Could not fetch mergability status");
902 println!(" Use 'ca stack show --verbose' for basic PR information");
903 }
904 }
905 } else {
906 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
908 let config_path = config_dir.join("config.json");
909 let settings = crate::config::Settings::load_from_file(&config_path)?;
910
911 let cascade_config = crate::config::CascadeConfig {
912 bitbucket: Some(settings.bitbucket.clone()),
913 git: settings.git.clone(),
914 auth: crate::config::AuthConfig::default(),
915 cascade: settings.cascade.clone(),
916 };
917
918 let integration =
919 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
920
921 match integration.check_stack_status(&stack_id).await {
922 Ok(status) => {
923 println!("\n📊 Pull Request Status:");
924 println!(" Total entries: {}", status.total_entries);
925 println!(" Submitted: {}", status.submitted_entries);
926 println!(" Open PRs: {}", status.open_prs);
927 println!(" Merged PRs: {}", status.merged_prs);
928 println!(" Declined PRs: {}", status.declined_prs);
929 println!(" Completion: {:.1}%", status.completion_percentage());
930
931 if !status.pull_requests.is_empty() {
932 println!("\n📋 Pull Requests:");
933 for pr in &status.pull_requests {
934 let state_icon = match pr.state {
935 crate::bitbucket::PullRequestState::Open => "🔄",
936 crate::bitbucket::PullRequestState::Merged => "✅",
937 crate::bitbucket::PullRequestState::Declined => "❌",
938 };
939 println!(
940 " {} PR #{}: {} ({} -> {})",
941 state_icon,
942 pr.id,
943 pr.title,
944 pr.from_ref.display_id,
945 pr.to_ref.display_id
946 );
947 if let Some(url) = pr.web_url() {
948 println!(" URL: {url}");
949 }
950 }
951 }
952
953 println!("\n💡 Use 'ca stack --mergeable' to see detailed status including build and review information");
954 }
955 Err(e) => {
956 warn!("Failed to check stack status: {}", e);
957 }
958 }
959 }
960
961 Ok(())
962}
963
964#[allow(clippy::too_many_arguments)]
965async fn push_to_stack(
966 branch: Option<String>,
967 message: Option<String>,
968 commit: Option<String>,
969 since: Option<String>,
970 commits: Option<String>,
971 squash: Option<usize>,
972 squash_since: Option<String>,
973 auto_branch: bool,
974 allow_base_branch: bool,
975) -> Result<()> {
976 let current_dir = env::current_dir()
977 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
978
979 let repo_root = find_repository_root(¤t_dir)
980 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
981
982 let mut manager = StackManager::new(&repo_root)?;
983 let repo = GitRepository::open(&repo_root)?;
984
985 if !manager.check_for_branch_change()? {
987 return Ok(()); }
989
990 let active_stack = manager.get_active_stack().ok_or_else(|| {
992 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
993 })?;
994
995 let current_branch = repo.get_current_branch()?;
997 let base_branch = &active_stack.base_branch;
998
999 if current_branch == *base_branch {
1000 println!("🚨 WARNING: You're currently on the base branch '{base_branch}'");
1001 println!(" Making commits directly on the base branch is not recommended.");
1002 println!(" This can pollute the base branch with work-in-progress commits.");
1003
1004 if allow_base_branch {
1006 println!(" ⚠️ Proceeding anyway due to --allow-base-branch flag");
1007 } else {
1008 let has_changes = repo.is_dirty()?;
1010
1011 if has_changes {
1012 if auto_branch {
1013 let feature_branch = format!("feature/{}-work", active_stack.name);
1015 println!("🚀 Auto-creating feature branch '{feature_branch}'...");
1016
1017 repo.create_branch(&feature_branch, None)?;
1018 repo.checkout_branch(&feature_branch)?;
1019
1020 println!("✅ Created and switched to '{feature_branch}'");
1021 println!(" You can now commit and push your changes safely");
1022
1023 } else {
1025 println!("\n💡 You have uncommitted changes. Here are your options:");
1026 println!(" 1. Create a feature branch first:");
1027 println!(" git checkout -b feature/my-work");
1028 println!(" git commit -am \"your work\"");
1029 println!(" ca push");
1030 println!("\n 2. Auto-create a branch (recommended):");
1031 println!(" ca push --auto-branch");
1032 println!("\n 3. Force push to base branch (dangerous):");
1033 println!(" ca push --allow-base-branch");
1034
1035 return Err(CascadeError::config(
1036 "Refusing to push uncommitted changes from base branch. Use one of the options above."
1037 ));
1038 }
1039 } else {
1040 let commits_to_check = if let Some(commits_str) = &commits {
1042 commits_str
1043 .split(',')
1044 .map(|s| s.trim().to_string())
1045 .collect::<Vec<String>>()
1046 } else if let Some(since_ref) = &since {
1047 let since_commit = repo.resolve_reference(since_ref)?;
1048 let head_commit = repo.get_head_commit()?;
1049 let commits = repo.get_commits_between(
1050 &since_commit.id().to_string(),
1051 &head_commit.id().to_string(),
1052 )?;
1053 commits.into_iter().map(|c| c.id().to_string()).collect()
1054 } else if commit.is_none() {
1055 let mut unpushed = Vec::new();
1056 let head_commit = repo.get_head_commit()?;
1057 let mut current_commit = head_commit;
1058
1059 loop {
1060 let commit_hash = current_commit.id().to_string();
1061 let already_in_stack = active_stack
1062 .entries
1063 .iter()
1064 .any(|entry| entry.commit_hash == commit_hash);
1065
1066 if already_in_stack {
1067 break;
1068 }
1069
1070 unpushed.push(commit_hash);
1071
1072 if let Some(parent) = current_commit.parents().next() {
1073 current_commit = parent;
1074 } else {
1075 break;
1076 }
1077 }
1078
1079 unpushed.reverse();
1080 unpushed
1081 } else {
1082 vec![repo.get_head_commit()?.id().to_string()]
1083 };
1084
1085 if !commits_to_check.is_empty() {
1086 if auto_branch {
1087 let feature_branch = format!("feature/{}-work", active_stack.name);
1089 println!("🚀 Auto-creating feature branch '{feature_branch}'...");
1090
1091 repo.create_branch(&feature_branch, Some(base_branch))?;
1092 repo.checkout_branch(&feature_branch)?;
1093
1094 println!(
1096 "🍒 Cherry-picking {} commit(s) to new branch...",
1097 commits_to_check.len()
1098 );
1099 for commit_hash in &commits_to_check {
1100 match repo.cherry_pick(commit_hash) {
1101 Ok(_) => println!(" ✅ Cherry-picked {}", &commit_hash[..8]),
1102 Err(e) => {
1103 println!(
1104 " ❌ Failed to cherry-pick {}: {}",
1105 &commit_hash[..8],
1106 e
1107 );
1108 println!(" 💡 You may need to resolve conflicts manually");
1109 return Err(CascadeError::branch(format!(
1110 "Failed to cherry-pick commit {commit_hash}: {e}"
1111 )));
1112 }
1113 }
1114 }
1115
1116 println!(
1117 "✅ Successfully moved {} commit(s) to '{feature_branch}'",
1118 commits_to_check.len()
1119 );
1120 println!(
1121 " You're now on the feature branch and can continue with 'ca push'"
1122 );
1123
1124 } else {
1126 println!(
1127 "\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
1128 commits_to_check.len()
1129 );
1130 println!(" These commits are currently ON the base branch, which may not be intended.");
1131 println!("\n Options:");
1132 println!(" 1. Auto-create feature branch and cherry-pick commits:");
1133 println!(" ca push --auto-branch");
1134 println!("\n 2. Manually create branch and move commits:");
1135 println!(" git checkout -b feature/my-work");
1136 println!(" ca push");
1137 println!("\n 3. Force push from base branch (not recommended):");
1138 println!(" ca push --allow-base-branch");
1139
1140 return Err(CascadeError::config(
1141 "Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
1142 ));
1143 }
1144 }
1145 }
1146 }
1147 }
1148
1149 if let Some(squash_count) = squash {
1151 if squash_count == 0 {
1152 let active_stack = manager.get_active_stack().ok_or_else(|| {
1154 CascadeError::config(
1155 "No active stack. Create a stack first with 'ca stacks create'",
1156 )
1157 })?;
1158
1159 let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
1160
1161 if unpushed_count == 0 {
1162 println!("ℹ️ No unpushed commits to squash");
1163 } else if unpushed_count == 1 {
1164 println!("ℹ️ Only 1 unpushed commit, no squashing needed");
1165 } else {
1166 println!("🔄 Auto-detected {unpushed_count} unpushed commits, squashing...");
1167 squash_commits(&repo, unpushed_count, None).await?;
1168 println!("✅ Squashed {unpushed_count} unpushed commits into one");
1169 }
1170 } else {
1171 println!("🔄 Squashing last {squash_count} commits...");
1172 squash_commits(&repo, squash_count, None).await?;
1173 println!("✅ Squashed {squash_count} commits into one");
1174 }
1175 } else if let Some(since_ref) = squash_since {
1176 println!("🔄 Squashing commits since {since_ref}...");
1177 let since_commit = repo.resolve_reference(&since_ref)?;
1178 let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
1179 squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
1180 println!("✅ Squashed {commits_count} commits since {since_ref} into one");
1181 }
1182
1183 let commits_to_push = if let Some(commits_str) = commits {
1185 commits_str
1187 .split(',')
1188 .map(|s| s.trim().to_string())
1189 .collect::<Vec<String>>()
1190 } else if let Some(since_ref) = since {
1191 let since_commit = repo.resolve_reference(&since_ref)?;
1193 let head_commit = repo.get_head_commit()?;
1194
1195 let commits = repo.get_commits_between(
1197 &since_commit.id().to_string(),
1198 &head_commit.id().to_string(),
1199 )?;
1200 commits.into_iter().map(|c| c.id().to_string()).collect()
1201 } else if let Some(hash) = commit {
1202 vec![hash]
1204 } else {
1205 let active_stack = manager.get_active_stack().ok_or_else(|| {
1207 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1208 })?;
1209
1210 let base_branch = &active_stack.base_branch;
1212 let current_branch = repo.get_current_branch()?;
1213
1214 if current_branch == *base_branch {
1216 let mut unpushed = Vec::new();
1217 let head_commit = repo.get_head_commit()?;
1218 let mut current_commit = head_commit;
1219
1220 loop {
1222 let commit_hash = current_commit.id().to_string();
1223 let already_in_stack = active_stack
1224 .entries
1225 .iter()
1226 .any(|entry| entry.commit_hash == commit_hash);
1227
1228 if already_in_stack {
1229 break;
1230 }
1231
1232 unpushed.push(commit_hash);
1233
1234 if let Some(parent) = current_commit.parents().next() {
1236 current_commit = parent;
1237 } else {
1238 break;
1239 }
1240 }
1241
1242 unpushed.reverse(); unpushed
1244 } else {
1245 match repo.get_commits_between(base_branch, ¤t_branch) {
1247 Ok(commits) => {
1248 let mut unpushed: Vec<String> =
1249 commits.into_iter().map(|c| c.id().to_string()).collect();
1250
1251 unpushed.retain(|commit_hash| {
1253 !active_stack
1254 .entries
1255 .iter()
1256 .any(|entry| entry.commit_hash == *commit_hash)
1257 });
1258
1259 unpushed.reverse(); unpushed
1261 }
1262 Err(e) => {
1263 return Err(CascadeError::branch(format!(
1264 "Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
1265 This usually means the branches have diverged or don't share common history."
1266 )));
1267 }
1268 }
1269 }
1270 };
1271
1272 if commits_to_push.is_empty() {
1273 println!("ℹ️ No commits to push to stack");
1274 return Ok(());
1275 }
1276
1277 let mut pushed_count = 0;
1279 let mut source_branches = std::collections::HashSet::new();
1280
1281 for (i, commit_hash) in commits_to_push.iter().enumerate() {
1282 let commit_obj = repo.get_commit(commit_hash)?;
1283 let commit_msg = commit_obj.message().unwrap_or("").to_string();
1284
1285 let commit_source_branch = repo
1287 .find_branch_containing_commit(commit_hash)
1288 .unwrap_or_else(|_| current_branch.clone());
1289 source_branches.insert(commit_source_branch.clone());
1290
1291 let branch_name = if i == 0 && branch.is_some() {
1293 branch.clone().unwrap()
1294 } else {
1295 let temp_repo = GitRepository::open(&repo_root)?;
1297 let branch_mgr = crate::git::BranchManager::new(temp_repo);
1298 branch_mgr.generate_branch_name(&commit_msg)
1299 };
1300
1301 let final_message = if i == 0 && message.is_some() {
1303 message.clone().unwrap()
1304 } else {
1305 commit_msg.clone()
1306 };
1307
1308 let entry_id = manager.push_to_stack(
1309 branch_name.clone(),
1310 commit_hash.clone(),
1311 final_message.clone(),
1312 commit_source_branch.clone(),
1313 )?;
1314 pushed_count += 1;
1315
1316 println!(
1317 "✅ Pushed commit {}/{} to stack",
1318 i + 1,
1319 commits_to_push.len()
1320 );
1321 println!(
1322 " Commit: {} ({})",
1323 &commit_hash[..8],
1324 commit_msg.split('\n').next().unwrap_or("")
1325 );
1326 println!(" Branch: {branch_name}");
1327 println!(" Source: {commit_source_branch}");
1328 println!(" Entry ID: {entry_id}");
1329 println!();
1330 }
1331
1332 if source_branches.len() > 1 {
1334 println!("⚠️ WARNING: Scattered Commit Detection");
1335 println!(
1336 " You've pushed commits from {} different Git branches:",
1337 source_branches.len()
1338 );
1339 for branch in &source_branches {
1340 println!(" • {branch}");
1341 }
1342 println!();
1343 println!(" This can lead to confusion because:");
1344 println!(" • Stack appears sequential but commits are scattered across branches");
1345 println!(" • Team members won't know which branch contains which work");
1346 println!(" • Branch cleanup becomes unclear after merge");
1347 println!(" • Rebase operations become more complex");
1348 println!();
1349 println!(" 💡 Consider consolidating work to a single feature branch:");
1350 println!(" 1. Create a new feature branch: git checkout -b feature/consolidated-work");
1351 println!(" 2. Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
1352 println!(" 3. Delete old scattered branches");
1353 println!(" 4. Push the consolidated branch to your stack");
1354 println!();
1355 }
1356
1357 println!(
1358 "🎉 Successfully pushed {} commit{} to stack",
1359 pushed_count,
1360 if pushed_count == 1 { "" } else { "s" }
1361 );
1362
1363 Ok(())
1364}
1365
1366async fn pop_from_stack(keep_branch: bool) -> Result<()> {
1367 let current_dir = env::current_dir()
1368 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1369
1370 let repo_root = find_repository_root(¤t_dir)
1371 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1372
1373 let mut manager = StackManager::new(&repo_root)?;
1374 let repo = GitRepository::open(&repo_root)?;
1375
1376 let entry = manager.pop_from_stack()?;
1377
1378 info!("✅ Popped commit from stack");
1379 info!(
1380 " Commit: {} ({})",
1381 entry.short_hash(),
1382 entry.short_message(50)
1383 );
1384 info!(" Branch: {}", entry.branch);
1385
1386 if !keep_branch && entry.branch != repo.get_current_branch()? {
1388 match repo.delete_branch(&entry.branch) {
1389 Ok(_) => info!(" Deleted branch: {}", entry.branch),
1390 Err(e) => warn!(" Could not delete branch {}: {}", entry.branch, e),
1391 }
1392 }
1393
1394 Ok(())
1395}
1396
1397async fn submit_entry(
1398 entry: Option<usize>,
1399 title: Option<String>,
1400 description: Option<String>,
1401 range: Option<String>,
1402 draft: bool,
1403) -> Result<()> {
1404 let current_dir = env::current_dir()
1405 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1406
1407 let repo_root = find_repository_root(¤t_dir)
1408 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1409
1410 let mut stack_manager = StackManager::new(&repo_root)?;
1411
1412 if !stack_manager.check_for_branch_change()? {
1414 return Ok(()); }
1416
1417 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1419 let config_path = config_dir.join("config.json");
1420 let settings = crate::config::Settings::load_from_file(&config_path)?;
1421
1422 let cascade_config = crate::config::CascadeConfig {
1424 bitbucket: Some(settings.bitbucket.clone()),
1425 git: settings.git.clone(),
1426 auth: crate::config::AuthConfig::default(),
1427 cascade: settings.cascade.clone(),
1428 };
1429
1430 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1432 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1433 })?;
1434 let stack_id = active_stack.id;
1435
1436 let entries_to_submit = if let Some(range_str) = range {
1438 let mut entries = Vec::new();
1440
1441 if range_str.contains('-') {
1442 let parts: Vec<&str> = range_str.split('-').collect();
1444 if parts.len() != 2 {
1445 return Err(CascadeError::config(
1446 "Invalid range format. Use 'start-end' (e.g., '1-3')",
1447 ));
1448 }
1449
1450 let start: usize = parts[0]
1451 .parse()
1452 .map_err(|_| CascadeError::config("Invalid start number in range"))?;
1453 let end: usize = parts[1]
1454 .parse()
1455 .map_err(|_| CascadeError::config("Invalid end number in range"))?;
1456
1457 if start == 0
1458 || end == 0
1459 || start > active_stack.entries.len()
1460 || end > active_stack.entries.len()
1461 {
1462 return Err(CascadeError::config(format!(
1463 "Range out of bounds. Stack has {} entries",
1464 active_stack.entries.len()
1465 )));
1466 }
1467
1468 for i in start..=end {
1469 entries.push((i, active_stack.entries[i - 1].clone()));
1470 }
1471 } else {
1472 for entry_str in range_str.split(',') {
1474 let entry_num: usize = entry_str.trim().parse().map_err(|_| {
1475 CascadeError::config(format!("Invalid entry number: {entry_str}"))
1476 })?;
1477
1478 if entry_num == 0 || entry_num > active_stack.entries.len() {
1479 return Err(CascadeError::config(format!(
1480 "Entry {} out of bounds. Stack has {} entries",
1481 entry_num,
1482 active_stack.entries.len()
1483 )));
1484 }
1485
1486 entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
1487 }
1488 }
1489
1490 entries
1491 } else if let Some(entry_num) = entry {
1492 if entry_num == 0 || entry_num > active_stack.entries.len() {
1494 return Err(CascadeError::config(format!(
1495 "Invalid entry number: {}. Stack has {} entries",
1496 entry_num,
1497 active_stack.entries.len()
1498 )));
1499 }
1500 vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
1501 } else {
1502 active_stack
1504 .entries
1505 .iter()
1506 .enumerate()
1507 .filter(|(_, entry)| !entry.is_submitted)
1508 .map(|(i, entry)| (i + 1, entry.clone())) .collect::<Vec<(usize, _)>>()
1510 };
1511
1512 if entries_to_submit.is_empty() {
1513 println!("ℹ️ No entries to submit");
1514 return Ok(());
1515 }
1516
1517 let total_operations = entries_to_submit.len() + 2; let pb = ProgressBar::new(total_operations as u64);
1520 pb.set_style(
1521 ProgressStyle::default_bar()
1522 .template("📤 {msg} [{bar:40.cyan/blue}] {pos}/{len}")
1523 .map_err(|e| CascadeError::config(format!("Progress bar template error: {e}")))?,
1524 );
1525
1526 pb.set_message("Connecting to Bitbucket");
1527 pb.inc(1);
1528
1529 let integration_stack_manager = StackManager::new(&repo_root)?;
1531 let mut integration =
1532 BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
1533
1534 pb.set_message("Starting batch submission");
1535 pb.inc(1);
1536
1537 let mut submitted_count = 0;
1539 let mut failed_entries = Vec::new();
1540 let total_entries = entries_to_submit.len();
1541
1542 for (entry_num, entry_to_submit) in &entries_to_submit {
1543 pb.set_message("Submitting entries...");
1544
1545 let entry_title = if total_entries == 1 {
1547 title.clone()
1548 } else {
1549 None
1550 };
1551 let entry_description = if total_entries == 1 {
1552 description.clone()
1553 } else {
1554 None
1555 };
1556
1557 match integration
1558 .submit_entry(
1559 &stack_id,
1560 &entry_to_submit.id,
1561 entry_title,
1562 entry_description,
1563 draft,
1564 )
1565 .await
1566 {
1567 Ok(pr) => {
1568 submitted_count += 1;
1569 println!("✅ Entry {} - PR #{}: {}", entry_num, pr.id, pr.title);
1570 if let Some(url) = pr.web_url() {
1571 println!(" URL: {url}");
1572 }
1573 println!(
1574 " From: {} -> {}",
1575 pr.from_ref.display_id, pr.to_ref.display_id
1576 );
1577 println!();
1578 }
1579 Err(e) => {
1580 failed_entries.push((*entry_num, e.to_string()));
1581 println!("❌ Entry {entry_num} failed: {e}");
1582 }
1583 }
1584
1585 pb.inc(1);
1586 }
1587
1588 if failed_entries.is_empty() {
1589 pb.finish_with_message("✅ All pull requests created successfully");
1590 println!(
1591 "🎉 Successfully submitted {} entr{}",
1592 submitted_count,
1593 if submitted_count == 1 { "y" } else { "ies" }
1594 );
1595 } else {
1596 pb.abandon_with_message("⚠️ Some submissions failed");
1597 println!("📊 Submission Summary:");
1598 println!(" ✅ Successful: {submitted_count}");
1599 println!(" ❌ Failed: {}", failed_entries.len());
1600 println!();
1601 println!("💡 Failed entries:");
1602 for (entry_num, error) in failed_entries {
1603 println!(" - Entry {entry_num}: {error}");
1604 }
1605 println!();
1606 println!("💡 You can retry failed entries individually:");
1607 println!(" ca stack submit <ENTRY_NUMBER>");
1608 }
1609
1610 Ok(())
1611}
1612
1613async fn check_stack_status(name: Option<String>) -> Result<()> {
1614 let current_dir = env::current_dir()
1615 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1616
1617 let repo_root = find_repository_root(¤t_dir)
1618 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1619
1620 let stack_manager = StackManager::new(&repo_root)?;
1621
1622 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1624 let config_path = config_dir.join("config.json");
1625 let settings = crate::config::Settings::load_from_file(&config_path)?;
1626
1627 let cascade_config = crate::config::CascadeConfig {
1629 bitbucket: Some(settings.bitbucket.clone()),
1630 git: settings.git.clone(),
1631 auth: crate::config::AuthConfig::default(),
1632 cascade: settings.cascade.clone(),
1633 };
1634
1635 let stack = if let Some(name) = name {
1637 stack_manager
1638 .get_stack_by_name(&name)
1639 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
1640 } else {
1641 stack_manager.get_active_stack().ok_or_else(|| {
1642 CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
1643 })?
1644 };
1645 let stack_id = stack.id;
1646
1647 println!("📋 Stack: {}", stack.name);
1648 println!(" ID: {}", stack.id);
1649 println!(" Base: {}", stack.base_branch);
1650
1651 if let Some(description) = &stack.description {
1652 println!(" Description: {description}");
1653 }
1654
1655 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1657
1658 match integration.check_stack_status(&stack_id).await {
1660 Ok(status) => {
1661 println!("\n📊 Pull Request Status:");
1662 println!(" Total entries: {}", status.total_entries);
1663 println!(" Submitted: {}", status.submitted_entries);
1664 println!(" Open PRs: {}", status.open_prs);
1665 println!(" Merged PRs: {}", status.merged_prs);
1666 println!(" Declined PRs: {}", status.declined_prs);
1667 println!(" Completion: {:.1}%", status.completion_percentage());
1668
1669 if !status.pull_requests.is_empty() {
1670 println!("\n📋 Pull Requests:");
1671 for pr in &status.pull_requests {
1672 let state_icon = match pr.state {
1673 crate::bitbucket::PullRequestState::Open => "🔄",
1674 crate::bitbucket::PullRequestState::Merged => "✅",
1675 crate::bitbucket::PullRequestState::Declined => "❌",
1676 };
1677 println!(
1678 " {} PR #{}: {} ({} -> {})",
1679 state_icon, pr.id, pr.title, pr.from_ref.display_id, pr.to_ref.display_id
1680 );
1681 if let Some(url) = pr.web_url() {
1682 println!(" URL: {url}");
1683 }
1684 }
1685 }
1686 }
1687 Err(e) => {
1688 warn!("Failed to check stack status: {}", e);
1689 return Err(e);
1690 }
1691 }
1692
1693 Ok(())
1694}
1695
1696async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
1697 let current_dir = env::current_dir()
1698 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1699
1700 let repo_root = find_repository_root(¤t_dir)
1701 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1702
1703 let stack_manager = StackManager::new(&repo_root)?;
1704
1705 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1707 let config_path = config_dir.join("config.json");
1708 let settings = crate::config::Settings::load_from_file(&config_path)?;
1709
1710 let cascade_config = crate::config::CascadeConfig {
1712 bitbucket: Some(settings.bitbucket.clone()),
1713 git: settings.git.clone(),
1714 auth: crate::config::AuthConfig::default(),
1715 cascade: settings.cascade.clone(),
1716 };
1717
1718 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1720
1721 let pr_state = if let Some(state_str) = state {
1723 match state_str.to_lowercase().as_str() {
1724 "open" => Some(crate::bitbucket::PullRequestState::Open),
1725 "merged" => Some(crate::bitbucket::PullRequestState::Merged),
1726 "declined" => Some(crate::bitbucket::PullRequestState::Declined),
1727 _ => {
1728 return Err(CascadeError::config(format!(
1729 "Invalid state '{state_str}'. Use: open, merged, declined"
1730 )))
1731 }
1732 }
1733 } else {
1734 None
1735 };
1736
1737 match integration.list_pull_requests(pr_state).await {
1739 Ok(pr_page) => {
1740 if pr_page.values.is_empty() {
1741 info!("No pull requests found.");
1742 return Ok(());
1743 }
1744
1745 println!("📋 Pull Requests ({} total):", pr_page.values.len());
1746 for pr in &pr_page.values {
1747 let state_icon = match pr.state {
1748 crate::bitbucket::PullRequestState::Open => "🔄",
1749 crate::bitbucket::PullRequestState::Merged => "✅",
1750 crate::bitbucket::PullRequestState::Declined => "❌",
1751 };
1752 println!(" {} PR #{}: {}", state_icon, pr.id, pr.title);
1753 if verbose {
1754 println!(
1755 " From: {} -> {}",
1756 pr.from_ref.display_id, pr.to_ref.display_id
1757 );
1758 println!(" Author: {}", pr.author.user.display_name);
1759 if let Some(url) = pr.web_url() {
1760 println!(" URL: {url}");
1761 }
1762 if let Some(desc) = &pr.description {
1763 if !desc.is_empty() {
1764 println!(" Description: {desc}");
1765 }
1766 }
1767 println!();
1768 }
1769 }
1770
1771 if !verbose {
1772 println!("\nUse --verbose for more details");
1773 }
1774 }
1775 Err(e) => {
1776 warn!("Failed to list pull requests: {}", e);
1777 return Err(e);
1778 }
1779 }
1780
1781 Ok(())
1782}
1783
1784async fn check_stack(_force: bool) -> Result<()> {
1785 let current_dir = env::current_dir()
1786 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1787
1788 let repo_root = find_repository_root(¤t_dir)
1789 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1790
1791 let mut manager = StackManager::new(&repo_root)?;
1792
1793 let active_stack = manager
1794 .get_active_stack()
1795 .ok_or_else(|| CascadeError::config("No active stack"))?;
1796 let stack_id = active_stack.id;
1797
1798 manager.sync_stack(&stack_id)?;
1799
1800 info!("✅ Stack check completed successfully");
1801
1802 Ok(())
1803}
1804
1805async fn sync_stack(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
1806 let current_dir = env::current_dir()
1807 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1808
1809 let repo_root = find_repository_root(¤t_dir)
1810 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1811
1812 let stack_manager = StackManager::new(&repo_root)?;
1813 let git_repo = GitRepository::open(&repo_root)?;
1814
1815 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1817 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1818 })?;
1819
1820 let base_branch = active_stack.base_branch.clone();
1821 let stack_name = active_stack.name.clone();
1822
1823 println!("🔄 Syncing stack '{stack_name}' with remote...");
1824
1825 println!("📥 Pulling latest changes from '{base_branch}'...");
1827
1828 match git_repo.checkout_branch(&base_branch) {
1830 Ok(_) => {
1831 println!(" ✅ Switched to '{base_branch}'");
1832
1833 match git_repo.pull(&base_branch) {
1835 Ok(_) => {
1836 println!(" ✅ Successfully pulled latest changes");
1837 }
1838 Err(e) => {
1839 if force {
1840 println!(" ⚠️ Failed to pull: {e} (continuing due to --force)");
1841 } else {
1842 return Err(CascadeError::branch(format!(
1843 "Failed to pull latest changes from '{base_branch}': {e}. Use --force to continue anyway."
1844 )));
1845 }
1846 }
1847 }
1848 }
1849 Err(e) => {
1850 if force {
1851 println!(
1852 " ⚠️ Failed to checkout '{base_branch}': {e} (continuing due to --force)"
1853 );
1854 } else {
1855 return Err(CascadeError::branch(format!(
1856 "Failed to checkout base branch '{base_branch}': {e}. Use --force to continue anyway."
1857 )));
1858 }
1859 }
1860 }
1861
1862 println!("🔍 Checking if stack needs rebase...");
1864
1865 let mut updated_stack_manager = StackManager::new(&repo_root)?;
1866 let stack_id = active_stack.id;
1867
1868 match updated_stack_manager.sync_stack(&stack_id) {
1869 Ok(_) => {
1870 if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
1872 match &updated_stack.status {
1873 crate::stack::StackStatus::NeedsSync => {
1874 println!(" 🔄 Stack needs rebase due to new commits on '{base_branch}'");
1875
1876 println!("🔀 Rebasing stack onto updated '{base_branch}'...");
1878
1879 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1881 let config_path = config_dir.join("config.json");
1882 let settings = crate::config::Settings::load_from_file(&config_path)?;
1883
1884 let cascade_config = crate::config::CascadeConfig {
1885 bitbucket: Some(settings.bitbucket.clone()),
1886 git: settings.git.clone(),
1887 auth: crate::config::AuthConfig::default(),
1888 cascade: settings.cascade.clone(),
1889 };
1890
1891 let options = crate::stack::RebaseOptions {
1893 strategy: crate::stack::RebaseStrategy::BranchVersioning,
1894 interactive,
1895 target_base: Some(base_branch.clone()),
1896 preserve_merges: true,
1897 auto_resolve: !interactive,
1898 max_retries: 3,
1899 skip_pull: Some(true), };
1901
1902 let mut rebase_manager = crate::stack::RebaseManager::new(
1903 updated_stack_manager,
1904 git_repo,
1905 options,
1906 );
1907
1908 match rebase_manager.rebase_stack(&stack_id) {
1909 Ok(result) => {
1910 println!(" ✅ Rebase completed successfully!");
1911
1912 if !result.branch_mapping.is_empty() {
1913 println!(" 📋 Updated branches:");
1914 for (old, new) in &result.branch_mapping {
1915 println!(" {old} → {new}");
1916 }
1917
1918 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
1920 println!(" 🔄 Updating pull requests...");
1921
1922 let integration_stack_manager =
1923 StackManager::new(&repo_root)?;
1924 let mut integration =
1925 crate::bitbucket::BitbucketIntegration::new(
1926 integration_stack_manager,
1927 cascade_config,
1928 )?;
1929
1930 match integration
1931 .update_prs_after_rebase(
1932 &stack_id,
1933 &result.branch_mapping,
1934 )
1935 .await
1936 {
1937 Ok(updated_prs) => {
1938 if !updated_prs.is_empty() {
1939 println!(
1940 " ✅ Updated {} pull requests",
1941 updated_prs.len()
1942 );
1943 }
1944 }
1945 Err(e) => {
1946 println!(
1947 " ⚠️ Failed to update pull requests: {e}"
1948 );
1949 }
1950 }
1951 }
1952 }
1953 }
1954 Err(e) => {
1955 println!(" ❌ Rebase failed: {e}");
1956 println!(" 💡 To resolve conflicts:");
1957 println!(" 1. Fix conflicts in the affected files");
1958 println!(" 2. Stage resolved files: git add <files>");
1959 println!(" 3. Continue: ca stack continue-rebase");
1960 return Err(e);
1961 }
1962 }
1963 }
1964 crate::stack::StackStatus::Clean => {
1965 println!(" ✅ Stack is already up to date");
1966 }
1967 other => {
1968 println!(" ℹ️ Stack status: {other:?}");
1969 }
1970 }
1971 }
1972 }
1973 Err(e) => {
1974 if force {
1975 println!(" ⚠️ Failed to check stack status: {e} (continuing due to --force)");
1976 } else {
1977 return Err(e);
1978 }
1979 }
1980 }
1981
1982 if !skip_cleanup {
1984 println!("🧹 Checking for merged branches to clean up...");
1985 println!(" ℹ️ Branch cleanup not yet implemented");
1991 } else {
1992 println!("⏭️ Skipping branch cleanup");
1993 }
1994
1995 println!("🎉 Sync completed successfully!");
1996 println!(" Base branch: {base_branch}");
1997 println!(" 💡 Next steps:");
1998 println!(" • Review your updated stack: ca stack show");
1999 println!(" • Check PR status: ca stack status");
2000
2001 Ok(())
2002}
2003
2004async fn rebase_stack(
2005 interactive: bool,
2006 onto: Option<String>,
2007 strategy: Option<RebaseStrategyArg>,
2008) -> Result<()> {
2009 let current_dir = env::current_dir()
2010 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2011
2012 let repo_root = find_repository_root(¤t_dir)
2013 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2014
2015 let stack_manager = StackManager::new(&repo_root)?;
2016 let git_repo = GitRepository::open(&repo_root)?;
2017
2018 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2020 let config_path = config_dir.join("config.json");
2021 let settings = crate::config::Settings::load_from_file(&config_path)?;
2022
2023 let cascade_config = crate::config::CascadeConfig {
2025 bitbucket: Some(settings.bitbucket.clone()),
2026 git: settings.git.clone(),
2027 auth: crate::config::AuthConfig::default(),
2028 cascade: settings.cascade.clone(),
2029 };
2030
2031 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2033 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2034 })?;
2035 let stack_id = active_stack.id;
2036
2037 let active_stack = stack_manager
2038 .get_stack(&stack_id)
2039 .ok_or_else(|| CascadeError::config("Active stack not found"))?
2040 .clone();
2041
2042 if active_stack.entries.is_empty() {
2043 println!("ℹ️ Stack is empty. Nothing to rebase.");
2044 return Ok(());
2045 }
2046
2047 println!("🔄 Rebasing stack: {}", active_stack.name);
2048 println!(" Base: {}", active_stack.base_branch);
2049
2050 let rebase_strategy = if let Some(cli_strategy) = strategy {
2052 match cli_strategy {
2053 RebaseStrategyArg::BranchVersioning => crate::stack::RebaseStrategy::BranchVersioning,
2054 RebaseStrategyArg::CherryPick => crate::stack::RebaseStrategy::CherryPick,
2055 RebaseStrategyArg::ThreeWayMerge => crate::stack::RebaseStrategy::ThreeWayMerge,
2056 RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
2057 }
2058 } else {
2059 match settings.cascade.default_sync_strategy.as_str() {
2061 "branch-versioning" => crate::stack::RebaseStrategy::BranchVersioning,
2062 "cherry-pick" => crate::stack::RebaseStrategy::CherryPick,
2063 "three-way-merge" => crate::stack::RebaseStrategy::ThreeWayMerge,
2064 "rebase" => crate::stack::RebaseStrategy::Interactive,
2065 _ => crate::stack::RebaseStrategy::BranchVersioning, }
2067 };
2068
2069 let options = crate::stack::RebaseOptions {
2071 strategy: rebase_strategy.clone(),
2072 interactive,
2073 target_base: onto,
2074 preserve_merges: true,
2075 auto_resolve: !interactive, max_retries: 3,
2077 skip_pull: None, };
2079
2080 info!(" Strategy: {:?}", rebase_strategy);
2081 info!(" Interactive: {}", interactive);
2082 info!(" Target base: {:?}", options.target_base);
2083 info!(" Entries: {}", active_stack.entries.len());
2084
2085 let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2087
2088 if rebase_manager.is_rebase_in_progress() {
2089 println!("⚠️ Rebase already in progress!");
2090 println!(" Use 'git status' to check the current state");
2091 println!(" Use 'ca stack continue-rebase' to continue");
2092 println!(" Use 'ca stack abort-rebase' to abort");
2093 return Ok(());
2094 }
2095
2096 match rebase_manager.rebase_stack(&stack_id) {
2098 Ok(result) => {
2099 println!("🎉 Rebase completed!");
2100 println!(" {}", result.get_summary());
2101
2102 if result.has_conflicts() {
2103 println!(" ⚠️ {} conflicts were resolved", result.conflicts.len());
2104 for conflict in &result.conflicts {
2105 println!(" - {}", &conflict[..8.min(conflict.len())]);
2106 }
2107 }
2108
2109 if !result.branch_mapping.is_empty() {
2110 println!(" 📋 Branch mapping:");
2111 for (old, new) in &result.branch_mapping {
2112 println!(" {old} -> {new}");
2113 }
2114
2115 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2117 let integration_stack_manager = StackManager::new(&repo_root)?;
2119 let mut integration = BitbucketIntegration::new(
2120 integration_stack_manager,
2121 cascade_config.clone(),
2122 )?;
2123
2124 match integration
2125 .update_prs_after_rebase(&stack_id, &result.branch_mapping)
2126 .await
2127 {
2128 Ok(updated_prs) => {
2129 if !updated_prs.is_empty() {
2130 println!(" 🔄 Preserved pull request history:");
2131 for pr_update in updated_prs {
2132 println!(" ✅ {pr_update}");
2133 }
2134 }
2135 }
2136 Err(e) => {
2137 eprintln!(" ⚠️ Failed to update pull requests: {e}");
2138 eprintln!(" You may need to manually update PRs in Bitbucket");
2139 }
2140 }
2141 }
2142 }
2143
2144 println!(
2145 " ✅ {} commits successfully rebased",
2146 result.success_count()
2147 );
2148
2149 if matches!(
2151 rebase_strategy,
2152 crate::stack::RebaseStrategy::BranchVersioning
2153 ) {
2154 println!("\n📝 Next steps:");
2155 if !result.branch_mapping.is_empty() {
2156 println!(" 1. ✅ New versioned branches have been created");
2157 println!(" 2. ✅ Pull requests have been updated automatically");
2158 println!(" 3. 🔍 Review the updated PRs in Bitbucket");
2159 println!(" 4. 🧪 Test your changes on the new branches");
2160 println!(
2161 " 5. 🗑️ Old branches are preserved for safety (can be deleted later)"
2162 );
2163 } else {
2164 println!(" 1. Review the rebased stack");
2165 println!(" 2. Test your changes");
2166 println!(" 3. Submit new pull requests with 'ca stack submit'");
2167 }
2168 }
2169 }
2170 Err(e) => {
2171 warn!("❌ Rebase failed: {}", e);
2172 println!("💡 Tips for resolving rebase issues:");
2173 println!(" - Check for uncommitted changes with 'git status'");
2174 println!(" - Ensure base branch is up to date");
2175 println!(" - Try interactive mode: 'ca stack rebase --interactive'");
2176 return Err(e);
2177 }
2178 }
2179
2180 Ok(())
2181}
2182
2183async fn continue_rebase() -> Result<()> {
2184 let current_dir = env::current_dir()
2185 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2186
2187 let repo_root = find_repository_root(¤t_dir)
2188 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2189
2190 let stack_manager = StackManager::new(&repo_root)?;
2191 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2192 let options = crate::stack::RebaseOptions::default();
2193 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2194
2195 if !rebase_manager.is_rebase_in_progress() {
2196 println!("ℹ️ No rebase in progress");
2197 return Ok(());
2198 }
2199
2200 println!("🔄 Continuing rebase...");
2201 match rebase_manager.continue_rebase() {
2202 Ok(_) => {
2203 println!("✅ Rebase continued successfully");
2204 println!(" Check 'ca stack rebase-status' for current state");
2205 }
2206 Err(e) => {
2207 warn!("❌ Failed to continue rebase: {}", e);
2208 println!("💡 You may need to resolve conflicts first:");
2209 println!(" 1. Edit conflicted files");
2210 println!(" 2. Stage resolved files with 'git add'");
2211 println!(" 3. Run 'ca stack continue-rebase' again");
2212 }
2213 }
2214
2215 Ok(())
2216}
2217
2218async fn abort_rebase() -> Result<()> {
2219 let current_dir = env::current_dir()
2220 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2221
2222 let repo_root = find_repository_root(¤t_dir)
2223 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2224
2225 let stack_manager = StackManager::new(&repo_root)?;
2226 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2227 let options = crate::stack::RebaseOptions::default();
2228 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2229
2230 if !rebase_manager.is_rebase_in_progress() {
2231 println!("ℹ️ No rebase in progress");
2232 return Ok(());
2233 }
2234
2235 println!("⚠️ Aborting rebase...");
2236 match rebase_manager.abort_rebase() {
2237 Ok(_) => {
2238 println!("✅ Rebase aborted successfully");
2239 println!(" Repository restored to pre-rebase state");
2240 }
2241 Err(e) => {
2242 warn!("❌ Failed to abort rebase: {}", e);
2243 println!("⚠️ You may need to manually clean up the repository state");
2244 }
2245 }
2246
2247 Ok(())
2248}
2249
2250async fn rebase_status() -> Result<()> {
2251 let current_dir = env::current_dir()
2252 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2253
2254 let repo_root = find_repository_root(¤t_dir)
2255 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2256
2257 let stack_manager = StackManager::new(&repo_root)?;
2258 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2259
2260 println!("📊 Rebase Status");
2261
2262 let git_dir = current_dir.join(".git");
2264 let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
2265 || git_dir.join("rebase-merge").exists()
2266 || git_dir.join("rebase-apply").exists();
2267
2268 if rebase_in_progress {
2269 println!(" Status: 🔄 Rebase in progress");
2270 println!(
2271 "
2272📝 Actions available:"
2273 );
2274 println!(" - 'ca stack continue-rebase' to continue");
2275 println!(" - 'ca stack abort-rebase' to abort");
2276 println!(" - 'git status' to see conflicted files");
2277
2278 match git_repo.get_status() {
2280 Ok(statuses) => {
2281 let mut conflicts = Vec::new();
2282 for status in statuses.iter() {
2283 if status.status().contains(git2::Status::CONFLICTED) {
2284 if let Some(path) = status.path() {
2285 conflicts.push(path.to_string());
2286 }
2287 }
2288 }
2289
2290 if !conflicts.is_empty() {
2291 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
2292 for conflict in conflicts {
2293 println!(" - {conflict}");
2294 }
2295 println!(
2296 "
2297💡 To resolve conflicts:"
2298 );
2299 println!(" 1. Edit the conflicted files");
2300 println!(" 2. Stage resolved files: git add <file>");
2301 println!(" 3. Continue: ca stack continue-rebase");
2302 }
2303 }
2304 Err(e) => {
2305 warn!("Failed to get git status: {}", e);
2306 }
2307 }
2308 } else {
2309 println!(" Status: ✅ No rebase in progress");
2310
2311 if let Some(active_stack) = stack_manager.get_active_stack() {
2313 println!(" Active stack: {}", active_stack.name);
2314 println!(" Entries: {}", active_stack.entries.len());
2315 println!(" Base branch: {}", active_stack.base_branch);
2316 }
2317 }
2318
2319 Ok(())
2320}
2321
2322async fn delete_stack(name: String, force: bool) -> Result<()> {
2323 let current_dir = env::current_dir()
2324 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2325
2326 let repo_root = find_repository_root(¤t_dir)
2327 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2328
2329 let mut manager = StackManager::new(&repo_root)?;
2330
2331 let stack = manager
2332 .get_stack_by_name(&name)
2333 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2334 let stack_id = stack.id;
2335
2336 if !force && !stack.entries.is_empty() {
2337 return Err(CascadeError::config(format!(
2338 "Stack '{}' has {} entries. Use --force to delete anyway",
2339 name,
2340 stack.entries.len()
2341 )));
2342 }
2343
2344 let deleted = manager.delete_stack(&stack_id)?;
2345
2346 info!("✅ Deleted stack '{}'", deleted.name);
2347 if !deleted.entries.is_empty() {
2348 warn!(" {} entries were removed", deleted.entries.len());
2349 }
2350
2351 Ok(())
2352}
2353
2354async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
2355 let current_dir = env::current_dir()
2356 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2357
2358 let repo_root = find_repository_root(¤t_dir)
2359 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2360
2361 let mut manager = StackManager::new(&repo_root)?;
2362
2363 if let Some(name) = name {
2364 let stack = manager
2366 .get_stack_by_name(&name)
2367 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2368
2369 let stack_id = stack.id;
2370
2371 match stack.validate() {
2373 Ok(message) => {
2374 println!("✅ Stack '{name}' structure validation: {message}");
2375 }
2376 Err(e) => {
2377 println!("❌ Stack '{name}' structure validation failed: {e}");
2378 return Err(CascadeError::config(e));
2379 }
2380 }
2381
2382 manager.handle_branch_modifications(&stack_id, fix_mode)?;
2384
2385 println!("🎉 Stack '{name}' validation completed");
2386 Ok(())
2387 } else {
2388 println!("🔍 Validating all stacks...");
2390
2391 let all_stacks = manager.get_all_stacks();
2393 let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
2394
2395 if stack_ids.is_empty() {
2396 println!("📭 No stacks found");
2397 return Ok(());
2398 }
2399
2400 let mut all_valid = true;
2401 for stack_id in stack_ids {
2402 let stack = manager.get_stack(&stack_id).unwrap();
2403 let stack_name = &stack.name;
2404
2405 println!("\n📋 Checking stack '{stack_name}':");
2406
2407 match stack.validate() {
2409 Ok(message) => {
2410 println!(" ✅ Structure: {message}");
2411 }
2412 Err(e) => {
2413 println!(" ❌ Structure: {e}");
2414 all_valid = false;
2415 continue;
2416 }
2417 }
2418
2419 match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
2421 Ok(_) => {
2422 println!(" ✅ Git integrity: OK");
2423 }
2424 Err(e) => {
2425 println!(" ❌ Git integrity: {e}");
2426 all_valid = false;
2427 }
2428 }
2429 }
2430
2431 if all_valid {
2432 println!("\n🎉 All stacks passed validation");
2433 } else {
2434 println!("\n⚠️ Some stacks have validation issues");
2435 return Err(CascadeError::config("Stack validation failed".to_string()));
2436 }
2437
2438 Ok(())
2439 }
2440}
2441
2442#[allow(dead_code)]
2444fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
2445 let mut unpushed = Vec::new();
2446 let head_commit = repo.get_head_commit()?;
2447 let mut current_commit = head_commit;
2448
2449 loop {
2451 let commit_hash = current_commit.id().to_string();
2452 let already_in_stack = stack
2453 .entries
2454 .iter()
2455 .any(|entry| entry.commit_hash == commit_hash);
2456
2457 if already_in_stack {
2458 break;
2459 }
2460
2461 unpushed.push(commit_hash);
2462
2463 if let Some(parent) = current_commit.parents().next() {
2465 current_commit = parent;
2466 } else {
2467 break;
2468 }
2469 }
2470
2471 unpushed.reverse(); Ok(unpushed)
2473}
2474
2475pub async fn squash_commits(
2477 repo: &GitRepository,
2478 count: usize,
2479 since_ref: Option<String>,
2480) -> Result<()> {
2481 if count <= 1 {
2482 return Ok(()); }
2484
2485 let _current_branch = repo.get_current_branch()?;
2487
2488 let rebase_range = if let Some(ref since) = since_ref {
2490 since.clone()
2491 } else {
2492 format!("HEAD~{count}")
2493 };
2494
2495 println!(" Analyzing {count} commits to create smart squash message...");
2496
2497 let head_commit = repo.get_head_commit()?;
2499 let mut commits_to_squash = Vec::new();
2500 let mut current = head_commit;
2501
2502 for _ in 0..count {
2504 commits_to_squash.push(current.clone());
2505 if current.parent_count() > 0 {
2506 current = current.parent(0).map_err(CascadeError::Git)?;
2507 } else {
2508 break;
2509 }
2510 }
2511
2512 let smart_message = generate_squash_message(&commits_to_squash)?;
2514 println!(
2515 " Smart message: {}",
2516 smart_message.lines().next().unwrap_or("")
2517 );
2518
2519 let reset_target = if since_ref.is_some() {
2521 format!("{rebase_range}~1")
2523 } else {
2524 format!("HEAD~{count}")
2526 };
2527
2528 repo.reset_soft(&reset_target)?;
2530
2531 repo.stage_all()?;
2533
2534 let new_commit_hash = repo.commit(&smart_message)?;
2536
2537 println!(
2538 " Created squashed commit: {} ({})",
2539 &new_commit_hash[..8],
2540 smart_message.lines().next().unwrap_or("")
2541 );
2542 println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
2543
2544 Ok(())
2545}
2546
2547pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
2549 if commits.is_empty() {
2550 return Ok("Squashed commits".to_string());
2551 }
2552
2553 let messages: Vec<String> = commits
2555 .iter()
2556 .map(|c| c.message().unwrap_or("").trim().to_string())
2557 .filter(|m| !m.is_empty())
2558 .collect();
2559
2560 if messages.is_empty() {
2561 return Ok("Squashed commits".to_string());
2562 }
2563
2564 if let Some(last_msg) = messages.first() {
2566 if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
2568 return Ok(last_msg
2569 .trim_start_matches("Final:")
2570 .trim_start_matches("final:")
2571 .trim()
2572 .to_string());
2573 }
2574 }
2575
2576 let wip_count = messages
2578 .iter()
2579 .filter(|m| {
2580 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
2581 })
2582 .count();
2583
2584 if wip_count > messages.len() / 2 {
2585 let non_wip: Vec<&String> = messages
2587 .iter()
2588 .filter(|m| {
2589 !m.to_lowercase().starts_with("wip")
2590 && !m.to_lowercase().contains("work in progress")
2591 })
2592 .collect();
2593
2594 if let Some(best_msg) = non_wip.first() {
2595 return Ok(best_msg.to_string());
2596 }
2597
2598 let feature = extract_feature_from_wip(&messages);
2600 return Ok(feature);
2601 }
2602
2603 Ok(messages.first().unwrap().clone())
2605}
2606
2607pub fn extract_feature_from_wip(messages: &[String]) -> String {
2609 for msg in messages {
2611 if msg.to_lowercase().starts_with("wip:") {
2613 if let Some(rest) = msg
2614 .strip_prefix("WIP:")
2615 .or_else(|| msg.strip_prefix("wip:"))
2616 {
2617 let feature = rest.trim();
2618 if !feature.is_empty() && feature.len() > 3 {
2619 let mut chars: Vec<char> = feature.chars().collect();
2621 if let Some(first) = chars.first_mut() {
2622 *first = first.to_uppercase().next().unwrap_or(*first);
2623 }
2624 return chars.into_iter().collect();
2625 }
2626 }
2627 }
2628 }
2629
2630 if let Some(first) = messages.first() {
2632 let cleaned = first
2633 .trim_start_matches("WIP:")
2634 .trim_start_matches("wip:")
2635 .trim_start_matches("WIP")
2636 .trim_start_matches("wip")
2637 .trim();
2638
2639 if !cleaned.is_empty() {
2640 return format!("Implement {cleaned}");
2641 }
2642 }
2643
2644 format!("Squashed {} commits", messages.len())
2645}
2646
2647pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
2649 let head_commit = repo.get_head_commit()?;
2650 let since_commit = repo.get_commit(since_commit_hash)?;
2651
2652 let mut count = 0;
2653 let mut current = head_commit;
2654
2655 loop {
2657 if current.id() == since_commit.id() {
2658 break;
2659 }
2660
2661 count += 1;
2662
2663 if current.parent_count() == 0 {
2665 break; }
2667
2668 current = current.parent(0).map_err(CascadeError::Git)?;
2669 }
2670
2671 Ok(count)
2672}
2673
2674async fn land_stack(
2676 entry: Option<usize>,
2677 force: bool,
2678 dry_run: bool,
2679 auto: bool,
2680 wait_for_builds: bool,
2681 strategy: Option<MergeStrategyArg>,
2682 build_timeout: u64,
2683) -> Result<()> {
2684 let current_dir = env::current_dir()
2685 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2686
2687 let repo_root = find_repository_root(¤t_dir)
2688 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2689
2690 let stack_manager = StackManager::new(&repo_root)?;
2691
2692 let stack_id = stack_manager
2694 .get_active_stack()
2695 .map(|s| s.id)
2696 .ok_or_else(|| {
2697 CascadeError::config(
2698 "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
2699 .to_string(),
2700 )
2701 })?;
2702
2703 let active_stack = stack_manager
2704 .get_active_stack()
2705 .cloned()
2706 .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
2707
2708 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2710 let config_path = config_dir.join("config.json");
2711 let settings = crate::config::Settings::load_from_file(&config_path)?;
2712
2713 let cascade_config = crate::config::CascadeConfig {
2714 bitbucket: Some(settings.bitbucket.clone()),
2715 git: settings.git.clone(),
2716 auth: crate::config::AuthConfig::default(),
2717 cascade: settings.cascade.clone(),
2718 };
2719
2720 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2721
2722 let status = integration.check_enhanced_stack_status(&stack_id).await?;
2724
2725 if status.enhanced_statuses.is_empty() {
2726 println!("❌ No pull requests found to land");
2727 return Ok(());
2728 }
2729
2730 let ready_prs: Vec<_> = status
2732 .enhanced_statuses
2733 .iter()
2734 .filter(|pr_status| {
2735 if let Some(entry_num) = entry {
2737 if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
2739 if pr_status.pr.from_ref.display_id != stack_entry.branch {
2741 return false;
2742 }
2743 } else {
2744 return false; }
2746 }
2747
2748 if force {
2749 pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
2751 } else {
2752 pr_status.is_ready_to_land()
2753 }
2754 })
2755 .collect();
2756
2757 if ready_prs.is_empty() {
2758 if let Some(entry_num) = entry {
2759 println!("❌ Entry {entry_num} is not ready to land or doesn't exist");
2760 } else {
2761 println!("❌ No pull requests are ready to land");
2762 }
2763
2764 println!("\n🚫 Blocking Issues:");
2766 for pr_status in &status.enhanced_statuses {
2767 if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
2768 let blocking = pr_status.get_blocking_reasons();
2769 if !blocking.is_empty() {
2770 println!(" PR #{}: {}", pr_status.pr.id, blocking.join(", "));
2771 }
2772 }
2773 }
2774
2775 if !force {
2776 println!("\n💡 Use --force to land PRs with blocking issues (dangerous!)");
2777 }
2778 return Ok(());
2779 }
2780
2781 if dry_run {
2782 if let Some(entry_num) = entry {
2783 println!("🏃 Dry Run - Entry {entry_num} that would be landed:");
2784 } else {
2785 println!("🏃 Dry Run - PRs that would be landed:");
2786 }
2787 for pr_status in &ready_prs {
2788 println!(" ✅ PR #{}: {}", pr_status.pr.id, pr_status.pr.title);
2789 if !pr_status.is_ready_to_land() && force {
2790 let blocking = pr_status.get_blocking_reasons();
2791 println!(
2792 " ⚠️ Would force land despite: {}",
2793 blocking.join(", ")
2794 );
2795 }
2796 }
2797 return Ok(());
2798 }
2799
2800 if entry.is_some() && ready_prs.len() > 1 {
2803 println!(
2804 "🎯 {} PRs are ready to land, but landing only entry #{}",
2805 ready_prs.len(),
2806 entry.unwrap()
2807 );
2808 }
2809
2810 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
2812 strategy.unwrap_or(MergeStrategyArg::Squash).into();
2813 let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
2814 merge_strategy: merge_strategy.clone(),
2815 wait_for_builds,
2816 build_timeout: std::time::Duration::from_secs(build_timeout),
2817 allowed_authors: None, };
2819
2820 println!(
2822 "🚀 Landing {} PR{}...",
2823 ready_prs.len(),
2824 if ready_prs.len() == 1 { "" } else { "s" }
2825 );
2826
2827 let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
2828 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
2829 );
2830
2831 let mut landed_count = 0;
2833 let mut failed_count = 0;
2834 let total_ready_prs = ready_prs.len();
2835
2836 for pr_status in ready_prs {
2837 let pr_id = pr_status.pr.id;
2838
2839 print!("🚀 Landing PR #{}: {}", pr_id, pr_status.pr.title);
2840
2841 let land_result = if auto {
2842 pr_manager
2844 .auto_merge_if_ready(pr_id, &auto_merge_conditions)
2845 .await
2846 } else {
2847 pr_manager
2849 .merge_pull_request(pr_id, merge_strategy.clone())
2850 .await
2851 .map(
2852 |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
2853 pr: Box::new(pr),
2854 merge_strategy: merge_strategy.clone(),
2855 },
2856 )
2857 };
2858
2859 match land_result {
2860 Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
2861 println!(" ✅");
2862 landed_count += 1;
2863
2864 if landed_count < total_ready_prs {
2866 println!("🔄 Retargeting remaining PRs to latest base...");
2867
2868 let base_branch = active_stack.base_branch.clone();
2870 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2871
2872 println!(" 📥 Updating base branch: {base_branch}");
2873 match git_repo.pull(&base_branch) {
2874 Ok(_) => println!(" ✅ Base branch updated successfully"),
2875 Err(e) => {
2876 println!(" ⚠️ Warning: Failed to update base branch: {e}");
2877 println!(
2878 " 💡 You may want to manually run: git pull origin {base_branch}"
2879 );
2880 }
2881 }
2882
2883 let mut rebase_manager = crate::stack::RebaseManager::new(
2885 StackManager::new(&repo_root)?,
2886 git_repo,
2887 crate::stack::RebaseOptions {
2888 strategy: crate::stack::RebaseStrategy::BranchVersioning,
2889 target_base: Some(base_branch.clone()),
2890 ..Default::default()
2891 },
2892 );
2893
2894 match rebase_manager.rebase_stack(&stack_id) {
2895 Ok(rebase_result) => {
2896 if !rebase_result.branch_mapping.is_empty() {
2897 let retarget_config = crate::config::CascadeConfig {
2899 bitbucket: Some(settings.bitbucket.clone()),
2900 git: settings.git.clone(),
2901 auth: crate::config::AuthConfig::default(),
2902 cascade: settings.cascade.clone(),
2903 };
2904 let mut retarget_integration = BitbucketIntegration::new(
2905 StackManager::new(&repo_root)?,
2906 retarget_config,
2907 )?;
2908
2909 match retarget_integration
2910 .update_prs_after_rebase(
2911 &stack_id,
2912 &rebase_result.branch_mapping,
2913 )
2914 .await
2915 {
2916 Ok(updated_prs) => {
2917 if !updated_prs.is_empty() {
2918 println!(
2919 " ✅ Updated {} PRs with new targets",
2920 updated_prs.len()
2921 );
2922 }
2923 }
2924 Err(e) => {
2925 println!(" ⚠️ Failed to update remaining PRs: {e}");
2926 println!(
2927 " 💡 You may need to run: ca stack rebase --onto {base_branch}"
2928 );
2929 }
2930 }
2931 }
2932 }
2933 Err(e) => {
2934 println!(" ❌ Auto-retargeting conflicts detected!");
2936 println!(" 📝 To resolve conflicts and continue landing:");
2937 println!(" 1. Resolve conflicts in the affected files");
2938 println!(" 2. Stage resolved files: git add <files>");
2939 println!(" 3. Continue the process: ca stack continue-land");
2940 println!(" 4. Or abort the operation: ca stack abort-land");
2941 println!();
2942 println!(" 💡 Check current status: ca stack land-status");
2943 println!(" ⚠️ Error details: {e}");
2944
2945 break;
2947 }
2948 }
2949 }
2950 }
2951 Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
2952 println!(" ❌ Not ready: {}", blocking_reasons.join(", "));
2953 failed_count += 1;
2954 if !force {
2955 break;
2956 }
2957 }
2958 Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
2959 println!(" ❌ Failed: {error}");
2960 failed_count += 1;
2961 if !force {
2962 break;
2963 }
2964 }
2965 Err(e) => {
2966 println!(" ❌");
2967 eprintln!("Failed to land PR #{pr_id}: {e}");
2968 failed_count += 1;
2969
2970 if !force {
2971 break;
2972 }
2973 }
2974 }
2975 }
2976
2977 println!("\n🎯 Landing Summary:");
2979 println!(" ✅ Successfully landed: {landed_count}");
2980 if failed_count > 0 {
2981 println!(" ❌ Failed to land: {failed_count}");
2982 }
2983
2984 if landed_count > 0 {
2985 println!("✅ Landing operation completed!");
2986 } else {
2987 println!("❌ No PRs were successfully landed");
2988 }
2989
2990 Ok(())
2991}
2992
2993async fn auto_land_stack(
2995 force: bool,
2996 dry_run: bool,
2997 wait_for_builds: bool,
2998 strategy: Option<MergeStrategyArg>,
2999 build_timeout: u64,
3000) -> Result<()> {
3001 land_stack(
3003 None,
3004 force,
3005 dry_run,
3006 true, wait_for_builds,
3008 strategy,
3009 build_timeout,
3010 )
3011 .await
3012}
3013
3014async fn continue_land() -> Result<()> {
3015 let current_dir = env::current_dir()
3016 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3017
3018 let repo_root = find_repository_root(¤t_dir)
3019 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3020
3021 let stack_manager = StackManager::new(&repo_root)?;
3022 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3023 let options = crate::stack::RebaseOptions::default();
3024 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3025
3026 if !rebase_manager.is_rebase_in_progress() {
3027 println!("ℹ️ No rebase in progress");
3028 return Ok(());
3029 }
3030
3031 println!("🔄 Continuing land operation...");
3032 match rebase_manager.continue_rebase() {
3033 Ok(_) => {
3034 println!("✅ Land operation continued successfully");
3035 println!(" Check 'ca stack land-status' for current state");
3036 }
3037 Err(e) => {
3038 warn!("❌ Failed to continue land operation: {}", e);
3039 println!("💡 You may need to resolve conflicts first:");
3040 println!(" 1. Edit conflicted files");
3041 println!(" 2. Stage resolved files with 'git add'");
3042 println!(" 3. Run 'ca stack continue-land' again");
3043 }
3044 }
3045
3046 Ok(())
3047}
3048
3049async fn abort_land() -> Result<()> {
3050 let current_dir = env::current_dir()
3051 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3052
3053 let repo_root = find_repository_root(¤t_dir)
3054 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3055
3056 let stack_manager = StackManager::new(&repo_root)?;
3057 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3058 let options = crate::stack::RebaseOptions::default();
3059 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3060
3061 if !rebase_manager.is_rebase_in_progress() {
3062 println!("ℹ️ No rebase in progress");
3063 return Ok(());
3064 }
3065
3066 println!("⚠️ Aborting land operation...");
3067 match rebase_manager.abort_rebase() {
3068 Ok(_) => {
3069 println!("✅ Land operation aborted successfully");
3070 println!(" Repository restored to pre-land state");
3071 }
3072 Err(e) => {
3073 warn!("❌ Failed to abort land operation: {}", e);
3074 println!("⚠️ You may need to manually clean up the repository state");
3075 }
3076 }
3077
3078 Ok(())
3079}
3080
3081async fn land_status() -> Result<()> {
3082 let current_dir = env::current_dir()
3083 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3084
3085 let repo_root = find_repository_root(¤t_dir)
3086 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3087
3088 let stack_manager = StackManager::new(&repo_root)?;
3089 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3090
3091 println!("📊 Land Status");
3092
3093 let git_dir = repo_root.join(".git");
3095 let land_in_progress = git_dir.join("REBASE_HEAD").exists()
3096 || git_dir.join("rebase-merge").exists()
3097 || git_dir.join("rebase-apply").exists();
3098
3099 if land_in_progress {
3100 println!(" Status: 🔄 Land operation in progress");
3101 println!(
3102 "
3103📝 Actions available:"
3104 );
3105 println!(" - 'ca stack continue-land' to continue");
3106 println!(" - 'ca stack abort-land' to abort");
3107 println!(" - 'git status' to see conflicted files");
3108
3109 match git_repo.get_status() {
3111 Ok(statuses) => {
3112 let mut conflicts = Vec::new();
3113 for status in statuses.iter() {
3114 if status.status().contains(git2::Status::CONFLICTED) {
3115 if let Some(path) = status.path() {
3116 conflicts.push(path.to_string());
3117 }
3118 }
3119 }
3120
3121 if !conflicts.is_empty() {
3122 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
3123 for conflict in conflicts {
3124 println!(" - {conflict}");
3125 }
3126 println!(
3127 "
3128💡 To resolve conflicts:"
3129 );
3130 println!(" 1. Edit the conflicted files");
3131 println!(" 2. Stage resolved files: git add <file>");
3132 println!(" 3. Continue: ca stack continue-land");
3133 }
3134 }
3135 Err(e) => {
3136 warn!("Failed to get git status: {}", e);
3137 }
3138 }
3139 } else {
3140 println!(" Status: ✅ No land operation in progress");
3141
3142 if let Some(active_stack) = stack_manager.get_active_stack() {
3144 println!(" Active stack: {}", active_stack.name);
3145 println!(" Entries: {}", active_stack.entries.len());
3146 println!(" Base branch: {}", active_stack.base_branch);
3147 }
3148 }
3149
3150 Ok(())
3151}
3152
3153async fn repair_stack_data() -> Result<()> {
3154 let current_dir = env::current_dir()
3155 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3156
3157 let repo_root = find_repository_root(¤t_dir)
3158 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3159
3160 let mut stack_manager = StackManager::new(&repo_root)?;
3161
3162 println!("🔧 Repairing stack data consistency...");
3163
3164 stack_manager.repair_all_stacks()?;
3165
3166 println!("✅ Stack data consistency repaired successfully!");
3167 println!("💡 Run 'ca stack --mergeable' to see updated status");
3168
3169 Ok(())
3170}
3171
3172#[cfg(test)]
3173mod tests {
3174 use super::*;
3175 use std::process::Command;
3176 use tempfile::TempDir;
3177
3178 fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
3179 let temp_dir = TempDir::new()
3180 .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
3181 let repo_path = temp_dir.path().to_path_buf();
3182
3183 let output = Command::new("git")
3185 .args(["init"])
3186 .current_dir(&repo_path)
3187 .output()
3188 .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
3189 if !output.status.success() {
3190 return Err(CascadeError::config("Git init failed".to_string()));
3191 }
3192
3193 let output = Command::new("git")
3194 .args(["config", "user.name", "Test User"])
3195 .current_dir(&repo_path)
3196 .output()
3197 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3198 if !output.status.success() {
3199 return Err(CascadeError::config(
3200 "Git config user.name failed".to_string(),
3201 ));
3202 }
3203
3204 let output = Command::new("git")
3205 .args(["config", "user.email", "test@example.com"])
3206 .current_dir(&repo_path)
3207 .output()
3208 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3209 if !output.status.success() {
3210 return Err(CascadeError::config(
3211 "Git config user.email failed".to_string(),
3212 ));
3213 }
3214
3215 std::fs::write(repo_path.join("README.md"), "# Test")
3217 .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
3218 let output = Command::new("git")
3219 .args(["add", "."])
3220 .current_dir(&repo_path)
3221 .output()
3222 .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
3223 if !output.status.success() {
3224 return Err(CascadeError::config("Git add failed".to_string()));
3225 }
3226
3227 let output = Command::new("git")
3228 .args(["commit", "-m", "Initial commit"])
3229 .current_dir(&repo_path)
3230 .output()
3231 .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
3232 if !output.status.success() {
3233 return Err(CascadeError::config("Git commit failed".to_string()));
3234 }
3235
3236 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
3238
3239 Ok((temp_dir, repo_path))
3240 }
3241
3242 #[tokio::test]
3243 async fn test_create_stack() {
3244 let (temp_dir, repo_path) = match create_test_repo() {
3245 Ok(repo) => repo,
3246 Err(_) => {
3247 println!("Skipping test due to git environment setup failure");
3248 return;
3249 }
3250 };
3251 let _ = &temp_dir;
3253
3254 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3258 match env::set_current_dir(&repo_path) {
3259 Ok(_) => {
3260 let result = create_stack(
3261 "test-stack".to_string(),
3262 None, Some("Test description".to_string()),
3264 )
3265 .await;
3266
3267 if let Ok(orig) = original_dir {
3269 let _ = env::set_current_dir(orig);
3270 }
3271
3272 assert!(
3273 result.is_ok(),
3274 "Stack creation should succeed in initialized repository"
3275 );
3276 }
3277 Err(_) => {
3278 println!("Skipping test due to directory access restrictions");
3280 }
3281 }
3282 }
3283
3284 #[tokio::test]
3285 async fn test_list_empty_stacks() {
3286 let (temp_dir, repo_path) = match create_test_repo() {
3287 Ok(repo) => repo,
3288 Err(_) => {
3289 println!("Skipping test due to git environment setup failure");
3290 return;
3291 }
3292 };
3293 let _ = &temp_dir;
3295
3296 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3300 match env::set_current_dir(&repo_path) {
3301 Ok(_) => {
3302 let result = list_stacks(false, false, None).await;
3303
3304 if let Ok(orig) = original_dir {
3306 let _ = env::set_current_dir(orig);
3307 }
3308
3309 assert!(
3310 result.is_ok(),
3311 "Listing stacks should succeed in initialized repository"
3312 );
3313 }
3314 Err(_) => {
3315 println!("Skipping test due to directory access restrictions");
3317 }
3318 }
3319 }
3320
3321 #[test]
3324 fn test_extract_feature_from_wip_basic() {
3325 let messages = vec![
3326 "WIP: add authentication".to_string(),
3327 "WIP: implement login flow".to_string(),
3328 ];
3329
3330 let result = extract_feature_from_wip(&messages);
3331 assert_eq!(result, "Add authentication");
3332 }
3333
3334 #[test]
3335 fn test_extract_feature_from_wip_capitalize() {
3336 let messages = vec!["WIP: fix user validation bug".to_string()];
3337
3338 let result = extract_feature_from_wip(&messages);
3339 assert_eq!(result, "Fix user validation bug");
3340 }
3341
3342 #[test]
3343 fn test_extract_feature_from_wip_fallback() {
3344 let messages = vec![
3345 "WIP user interface changes".to_string(),
3346 "wip: css styling".to_string(),
3347 ];
3348
3349 let result = extract_feature_from_wip(&messages);
3350 assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
3352 }
3353
3354 #[test]
3355 fn test_extract_feature_from_wip_empty() {
3356 let messages = vec![];
3357
3358 let result = extract_feature_from_wip(&messages);
3359 assert_eq!(result, "Squashed 0 commits");
3360 }
3361
3362 #[test]
3363 fn test_extract_feature_from_wip_short_message() {
3364 let messages = vec!["WIP: x".to_string()]; let result = extract_feature_from_wip(&messages);
3367 assert!(result.starts_with("Implement") || result.contains("Squashed"));
3368 }
3369
3370 #[test]
3373 fn test_squash_message_final_strategy() {
3374 let messages = [
3378 "Final: implement user authentication system".to_string(),
3379 "WIP: add tests".to_string(),
3380 "WIP: fix validation".to_string(),
3381 ];
3382
3383 assert!(messages[0].starts_with("Final:"));
3385
3386 let extracted = messages[0].trim_start_matches("Final:").trim();
3388 assert_eq!(extracted, "implement user authentication system");
3389 }
3390
3391 #[test]
3392 fn test_squash_message_wip_detection() {
3393 let messages = [
3394 "WIP: start feature".to_string(),
3395 "WIP: continue work".to_string(),
3396 "WIP: almost done".to_string(),
3397 "Regular commit message".to_string(),
3398 ];
3399
3400 let wip_count = messages
3401 .iter()
3402 .filter(|m| {
3403 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3404 })
3405 .count();
3406
3407 assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2); let non_wip: Vec<&String> = messages
3412 .iter()
3413 .filter(|m| {
3414 !m.to_lowercase().starts_with("wip")
3415 && !m.to_lowercase().contains("work in progress")
3416 })
3417 .collect();
3418
3419 assert_eq!(non_wip.len(), 1);
3420 assert_eq!(non_wip[0], "Regular commit message");
3421 }
3422
3423 #[test]
3424 fn test_squash_message_all_wip() {
3425 let messages = vec![
3426 "WIP: add feature A".to_string(),
3427 "WIP: add feature B".to_string(),
3428 "WIP: finish implementation".to_string(),
3429 ];
3430
3431 let result = extract_feature_from_wip(&messages);
3432 assert_eq!(result, "Add feature A");
3434 }
3435
3436 #[test]
3437 fn test_squash_message_edge_cases() {
3438 let empty_messages: Vec<String> = vec![];
3440 let result = extract_feature_from_wip(&empty_messages);
3441 assert_eq!(result, "Squashed 0 commits");
3442
3443 let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
3445 let result = extract_feature_from_wip(&whitespace_messages);
3446 assert!(result.contains("Squashed") || result.contains("Implement"));
3447
3448 let mixed_case = vec!["wip: Add Feature".to_string()];
3450 let result = extract_feature_from_wip(&mixed_case);
3451 assert_eq!(result, "Add Feature");
3452 }
3453
3454 #[tokio::test]
3457 async fn test_auto_land_wrapper() {
3458 let (temp_dir, repo_path) = match create_test_repo() {
3460 Ok(repo) => repo,
3461 Err(_) => {
3462 println!("Skipping test due to git environment setup failure");
3463 return;
3464 }
3465 };
3466 let _ = &temp_dir;
3468
3469 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
3471 .expect("Failed to initialize Cascade in test repo");
3472
3473 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3474 match env::set_current_dir(&repo_path) {
3475 Ok(_) => {
3476 let result = create_stack(
3478 "test-stack".to_string(),
3479 None,
3480 Some("Test stack for auto-land".to_string()),
3481 )
3482 .await;
3483
3484 if let Ok(orig) = original_dir {
3485 let _ = env::set_current_dir(orig);
3486 }
3487
3488 assert!(
3491 result.is_ok(),
3492 "Stack creation should succeed in initialized repository"
3493 );
3494 }
3495 Err(_) => {
3496 println!("Skipping test due to directory access restrictions");
3497 }
3498 }
3499 }
3500
3501 #[test]
3502 fn test_auto_land_action_enum() {
3503 use crate::cli::commands::stack::StackAction;
3505
3506 let _action = StackAction::AutoLand {
3508 force: false,
3509 dry_run: true,
3510 wait_for_builds: true,
3511 strategy: Some(MergeStrategyArg::Squash),
3512 build_timeout: 1800,
3513 };
3514
3515 }
3517
3518 #[test]
3519 fn test_merge_strategy_conversion() {
3520 let squash_strategy = MergeStrategyArg::Squash;
3522 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
3523
3524 match merge_strategy {
3525 crate::bitbucket::pull_request::MergeStrategy::Squash => {
3526 }
3528 _ => panic!("Expected Squash strategy"),
3529 }
3530
3531 let merge_strategy = MergeStrategyArg::Merge;
3532 let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
3533
3534 match converted {
3535 crate::bitbucket::pull_request::MergeStrategy::Merge => {
3536 }
3538 _ => panic!("Expected Merge strategy"),
3539 }
3540 }
3541
3542 #[test]
3543 fn test_auto_merge_conditions_structure() {
3544 use std::time::Duration;
3546
3547 let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
3548 merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
3549 wait_for_builds: true,
3550 build_timeout: Duration::from_secs(1800),
3551 allowed_authors: None,
3552 };
3553
3554 assert!(conditions.wait_for_builds);
3556 assert_eq!(conditions.build_timeout.as_secs(), 1800);
3557 assert!(conditions.allowed_authors.is_none());
3558 assert!(matches!(
3559 conditions.merge_strategy,
3560 crate::bitbucket::pull_request::MergeStrategy::Squash
3561 ));
3562 }
3563
3564 #[test]
3565 fn test_polling_constants() {
3566 use std::time::Duration;
3568
3569 let expected_polling_interval = Duration::from_secs(30);
3571
3572 assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
3577
3578 #[test]
3579 fn test_build_timeout_defaults() {
3580 const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
3583 let timeout_value = 1800u64;
3585 assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
3588
3589 #[test]
3590 fn test_scattered_commit_detection() {
3591 use std::collections::HashSet;
3592
3593 let mut source_branches = HashSet::new();
3595 source_branches.insert("feature-branch-1".to_string());
3596 source_branches.insert("feature-branch-2".to_string());
3597 source_branches.insert("feature-branch-3".to_string());
3598
3599 let single_branch = HashSet::from(["main".to_string()]);
3601 assert_eq!(single_branch.len(), 1);
3602
3603 assert!(source_branches.len() > 1);
3605 assert_eq!(source_branches.len(), 3);
3606
3607 assert!(source_branches.contains("feature-branch-1"));
3609 assert!(source_branches.contains("feature-branch-2"));
3610 assert!(source_branches.contains("feature-branch-3"));
3611 }
3612
3613 #[test]
3614 fn test_source_branch_tracking() {
3615 let branch_a = "feature-work";
3619 let branch_b = "feature-work";
3620 assert_eq!(branch_a, branch_b);
3621
3622 let branch_1 = "feature-ui";
3624 let branch_2 = "feature-api";
3625 assert_ne!(branch_1, branch_2);
3626
3627 assert!(branch_1.starts_with("feature-"));
3629 assert!(branch_2.starts_with("feature-"));
3630 }
3631
3632 #[tokio::test]
3635 async fn test_push_default_behavior() {
3636 let (temp_dir, repo_path) = match create_test_repo() {
3638 Ok(repo) => repo,
3639 Err(_) => {
3640 println!("Skipping test due to git environment setup failure");
3641 return;
3642 }
3643 };
3644 let _ = &temp_dir;
3646
3647 if !repo_path.exists() {
3649 println!("Skipping test due to temporary directory creation issue");
3650 return;
3651 }
3652
3653 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3655
3656 match env::set_current_dir(&repo_path) {
3657 Ok(_) => {
3658 let result = push_to_stack(
3660 None, None, None, None, None, None, None, false, false, )
3670 .await;
3671
3672 if let Ok(orig) = original_dir {
3674 let _ = env::set_current_dir(orig);
3675 }
3676
3677 match &result {
3679 Err(e) => {
3680 let error_msg = e.to_string();
3681 assert!(
3683 error_msg.contains("No active stack")
3684 || error_msg.contains("config")
3685 || error_msg.contains("current directory")
3686 || error_msg.contains("Not a git repository")
3687 || error_msg.contains("could not find repository"),
3688 "Expected 'No active stack' or repository error, got: {error_msg}"
3689 );
3690 }
3691 Ok(_) => {
3692 println!(
3694 "Push succeeded unexpectedly - test environment may have active stack"
3695 );
3696 }
3697 }
3698 }
3699 Err(_) => {
3700 println!("Skipping test due to directory access restrictions");
3702 }
3703 }
3704
3705 let push_action = StackAction::Push {
3707 branch: None,
3708 message: None,
3709 commit: None,
3710 since: None,
3711 commits: None,
3712 squash: None,
3713 squash_since: None,
3714 auto_branch: false,
3715 allow_base_branch: false,
3716 };
3717
3718 assert!(matches!(
3719 push_action,
3720 StackAction::Push {
3721 branch: None,
3722 message: None,
3723 commit: None,
3724 since: None,
3725 commits: None,
3726 squash: None,
3727 squash_since: None,
3728 auto_branch: false,
3729 allow_base_branch: false
3730 }
3731 ));
3732 }
3733
3734 #[tokio::test]
3735 async fn test_submit_default_behavior() {
3736 let (temp_dir, repo_path) = match create_test_repo() {
3738 Ok(repo) => repo,
3739 Err(_) => {
3740 println!("Skipping test due to git environment setup failure");
3741 return;
3742 }
3743 };
3744 let _ = &temp_dir;
3746
3747 if !repo_path.exists() {
3749 println!("Skipping test due to temporary directory creation issue");
3750 return;
3751 }
3752
3753 let original_dir = match env::current_dir() {
3755 Ok(dir) => dir,
3756 Err(_) => {
3757 println!("Skipping test due to current directory access restrictions");
3758 return;
3759 }
3760 };
3761
3762 match env::set_current_dir(&repo_path) {
3763 Ok(_) => {
3764 let result = submit_entry(
3766 None, None, None, None, false, )
3772 .await;
3773
3774 let _ = env::set_current_dir(original_dir);
3776
3777 match &result {
3779 Err(e) => {
3780 let error_msg = e.to_string();
3781 assert!(
3783 error_msg.contains("No active stack")
3784 || error_msg.contains("config")
3785 || error_msg.contains("current directory")
3786 || error_msg.contains("Not a git repository")
3787 || error_msg.contains("could not find repository"),
3788 "Expected 'No active stack' or repository error, got: {error_msg}"
3789 );
3790 }
3791 Ok(_) => {
3792 println!("Submit succeeded unexpectedly - test environment may have active stack");
3794 }
3795 }
3796 }
3797 Err(_) => {
3798 println!("Skipping test due to directory access restrictions");
3800 }
3801 }
3802
3803 let submit_action = StackAction::Submit {
3805 entry: None,
3806 title: None,
3807 description: None,
3808 range: None,
3809 draft: false,
3810 };
3811
3812 assert!(matches!(
3813 submit_action,
3814 StackAction::Submit {
3815 entry: None,
3816 title: None,
3817 description: None,
3818 range: None,
3819 draft: false
3820 }
3821 ));
3822 }
3823
3824 #[test]
3825 fn test_targeting_options_still_work() {
3826 let commits = "abc123,def456,ghi789";
3830 let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
3831 assert_eq!(parsed.len(), 3);
3832 assert_eq!(parsed[0], "abc123");
3833 assert_eq!(parsed[1], "def456");
3834 assert_eq!(parsed[2], "ghi789");
3835
3836 let range = "1-3";
3838 assert!(range.contains('-'));
3839 let parts: Vec<&str> = range.split('-').collect();
3840 assert_eq!(parts.len(), 2);
3841
3842 let since_ref = "HEAD~3";
3844 assert!(since_ref.starts_with("HEAD"));
3845 assert!(since_ref.contains('~'));
3846 }
3847
3848 #[test]
3849 fn test_command_flow_logic() {
3850 assert!(matches!(
3852 StackAction::Push {
3853 branch: None,
3854 message: None,
3855 commit: None,
3856 since: None,
3857 commits: None,
3858 squash: None,
3859 squash_since: None,
3860 auto_branch: false,
3861 allow_base_branch: false
3862 },
3863 StackAction::Push { .. }
3864 ));
3865
3866 assert!(matches!(
3867 StackAction::Submit {
3868 entry: None,
3869 title: None,
3870 description: None,
3871 range: None,
3872 draft: false
3873 },
3874 StackAction::Submit { .. }
3875 ));
3876 }
3877
3878 #[tokio::test]
3879 async fn test_deactivate_command_structure() {
3880 let deactivate_action = StackAction::Deactivate { force: false };
3882
3883 assert!(matches!(
3885 deactivate_action,
3886 StackAction::Deactivate { force: false }
3887 ));
3888
3889 let force_deactivate = StackAction::Deactivate { force: true };
3891 assert!(matches!(
3892 force_deactivate,
3893 StackAction::Deactivate { force: true }
3894 ));
3895 }
3896}