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