1use crate::bitbucket::BitbucketIntegration;
2use crate::cli::output::Output;
3use crate::errors::{CascadeError, Result};
4use crate::git::{find_repository_root, GitRepository};
5use crate::stack::{CleanupManager, CleanupOptions, CleanupResult, StackManager, StackStatus};
6use clap::{Subcommand, ValueEnum};
7use dialoguer::{theme::ColorfulTheme, Confirm};
8use std::env;
10use tracing::{debug, warn};
11
12#[derive(ValueEnum, Clone, Debug)]
14pub enum RebaseStrategyArg {
15 ForcePush,
17 Interactive,
19}
20
21#[derive(ValueEnum, Clone, Debug)]
22pub enum MergeStrategyArg {
23 Merge,
25 Squash,
27 FastForward,
29}
30
31impl From<MergeStrategyArg> for crate::bitbucket::pull_request::MergeStrategy {
32 fn from(arg: MergeStrategyArg) -> Self {
33 match arg {
34 MergeStrategyArg::Merge => Self::Merge,
35 MergeStrategyArg::Squash => Self::Squash,
36 MergeStrategyArg::FastForward => Self::FastForward,
37 }
38 }
39}
40
41#[derive(Debug, Subcommand)]
42pub enum StackAction {
43 Create {
45 name: String,
47 #[arg(long, short)]
49 base: Option<String>,
50 #[arg(long, short)]
52 description: Option<String>,
53 },
54
55 List {
57 #[arg(long, short)]
59 verbose: bool,
60 #[arg(long)]
62 active: bool,
63 #[arg(long)]
65 format: Option<String>,
66 },
67
68 Switch {
70 name: String,
72 },
73
74 Deactivate {
76 #[arg(long)]
78 force: bool,
79 },
80
81 Show {
83 #[arg(short, long)]
85 verbose: bool,
86 #[arg(short, long)]
88 mergeable: bool,
89 },
90
91 Push {
93 #[arg(long, short)]
95 branch: Option<String>,
96 #[arg(long, short)]
98 message: Option<String>,
99 #[arg(long)]
101 commit: Option<String>,
102 #[arg(long)]
104 since: Option<String>,
105 #[arg(long)]
107 commits: Option<String>,
108 #[arg(long, num_args = 0..=1, default_missing_value = "0")]
110 squash: Option<usize>,
111 #[arg(long)]
113 squash_since: Option<String>,
114 #[arg(long)]
116 auto_branch: bool,
117 #[arg(long)]
119 allow_base_branch: bool,
120 #[arg(long)]
122 dry_run: 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 Cleanup {
300 #[arg(long)]
302 dry_run: bool,
303 #[arg(long)]
305 force: bool,
306 #[arg(long)]
308 include_stale: bool,
309 #[arg(long, default_value = "30")]
311 stale_days: u32,
312 #[arg(long)]
314 cleanup_remote: bool,
315 #[arg(long)]
317 include_non_stack: bool,
318 #[arg(long)]
320 verbose: bool,
321 },
322
323 Repair,
325}
326
327pub async fn run(action: StackAction) -> Result<()> {
328 match action {
329 StackAction::Create {
330 name,
331 base,
332 description,
333 } => create_stack(name, base, description).await,
334 StackAction::List {
335 verbose,
336 active,
337 format,
338 } => list_stacks(verbose, active, format).await,
339 StackAction::Switch { name } => switch_stack(name).await,
340 StackAction::Deactivate { force } => deactivate_stack(force).await,
341 StackAction::Show { verbose, mergeable } => show_stack(verbose, mergeable).await,
342 StackAction::Push {
343 branch,
344 message,
345 commit,
346 since,
347 commits,
348 squash,
349 squash_since,
350 auto_branch,
351 allow_base_branch,
352 dry_run,
353 } => {
354 push_to_stack(
355 branch,
356 message,
357 commit,
358 since,
359 commits,
360 squash,
361 squash_since,
362 auto_branch,
363 allow_base_branch,
364 dry_run,
365 )
366 .await
367 }
368 StackAction::Pop { keep_branch } => pop_from_stack(keep_branch).await,
369 StackAction::Submit {
370 entry,
371 title,
372 description,
373 range,
374 draft,
375 } => submit_entry(entry, title, description, range, draft).await,
376 StackAction::Status { name } => check_stack_status(name).await,
377 StackAction::Prs { state, verbose } => list_pull_requests(state, verbose).await,
378 StackAction::Check { force } => check_stack(force).await,
379 StackAction::Sync {
380 force,
381 skip_cleanup,
382 interactive,
383 } => sync_stack(force, skip_cleanup, interactive).await,
384 StackAction::Rebase {
385 interactive,
386 onto,
387 strategy,
388 } => rebase_stack(interactive, onto, strategy).await,
389 StackAction::ContinueRebase => continue_rebase().await,
390 StackAction::AbortRebase => abort_rebase().await,
391 StackAction::RebaseStatus => rebase_status().await,
392 StackAction::Delete { name, force } => delete_stack(name, force).await,
393 StackAction::Validate { name, fix } => validate_stack(name, fix).await,
394 StackAction::Land {
395 entry,
396 force,
397 dry_run,
398 auto,
399 wait_for_builds,
400 strategy,
401 build_timeout,
402 } => {
403 land_stack(
404 entry,
405 force,
406 dry_run,
407 auto,
408 wait_for_builds,
409 strategy,
410 build_timeout,
411 )
412 .await
413 }
414 StackAction::AutoLand {
415 force,
416 dry_run,
417 wait_for_builds,
418 strategy,
419 build_timeout,
420 } => auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await,
421 StackAction::ListPrs { state, verbose } => list_pull_requests(state, verbose).await,
422 StackAction::ContinueLand => continue_land().await,
423 StackAction::AbortLand => abort_land().await,
424 StackAction::LandStatus => land_status().await,
425 StackAction::Cleanup {
426 dry_run,
427 force,
428 include_stale,
429 stale_days,
430 cleanup_remote,
431 include_non_stack,
432 verbose,
433 } => {
434 cleanup_branches(
435 dry_run,
436 force,
437 include_stale,
438 stale_days,
439 cleanup_remote,
440 include_non_stack,
441 verbose,
442 )
443 .await
444 }
445 StackAction::Repair => repair_stack_data().await,
446 }
447}
448
449pub async fn show(verbose: bool, mergeable: bool) -> Result<()> {
451 show_stack(verbose, mergeable).await
452}
453
454#[allow(clippy::too_many_arguments)]
455pub async fn push(
456 branch: Option<String>,
457 message: Option<String>,
458 commit: Option<String>,
459 since: Option<String>,
460 commits: Option<String>,
461 squash: Option<usize>,
462 squash_since: Option<String>,
463 auto_branch: bool,
464 allow_base_branch: bool,
465 dry_run: bool,
466) -> Result<()> {
467 push_to_stack(
468 branch,
469 message,
470 commit,
471 since,
472 commits,
473 squash,
474 squash_since,
475 auto_branch,
476 allow_base_branch,
477 dry_run,
478 )
479 .await
480}
481
482pub async fn pop(keep_branch: bool) -> Result<()> {
483 pop_from_stack(keep_branch).await
484}
485
486pub async fn land(
487 entry: Option<usize>,
488 force: bool,
489 dry_run: bool,
490 auto: bool,
491 wait_for_builds: bool,
492 strategy: Option<MergeStrategyArg>,
493 build_timeout: u64,
494) -> Result<()> {
495 land_stack(
496 entry,
497 force,
498 dry_run,
499 auto,
500 wait_for_builds,
501 strategy,
502 build_timeout,
503 )
504 .await
505}
506
507pub async fn autoland(
508 force: bool,
509 dry_run: bool,
510 wait_for_builds: bool,
511 strategy: Option<MergeStrategyArg>,
512 build_timeout: u64,
513) -> Result<()> {
514 auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await
515}
516
517pub async fn sync(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
518 sync_stack(force, skip_cleanup, interactive).await
519}
520
521pub async fn rebase(
522 interactive: bool,
523 onto: Option<String>,
524 strategy: Option<RebaseStrategyArg>,
525) -> Result<()> {
526 rebase_stack(interactive, onto, strategy).await
527}
528
529pub async fn deactivate(force: bool) -> Result<()> {
530 deactivate_stack(force).await
531}
532
533pub async fn switch(name: String) -> Result<()> {
534 switch_stack(name).await
535}
536
537async fn create_stack(
538 name: String,
539 base: Option<String>,
540 description: Option<String>,
541) -> Result<()> {
542 let current_dir = env::current_dir()
543 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
544
545 let repo_root = find_repository_root(¤t_dir)
546 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
547
548 let mut manager = StackManager::new(&repo_root)?;
549 let stack_id = manager.create_stack(name.clone(), base.clone(), description.clone())?;
550
551 let stack = manager
553 .get_stack(&stack_id)
554 .ok_or_else(|| CascadeError::config("Failed to get created stack"))?;
555
556 Output::stack_info(
558 &name,
559 &stack_id.to_string(),
560 &stack.base_branch,
561 stack.working_branch.as_deref(),
562 true, );
564
565 if let Some(desc) = description {
566 Output::sub_item(format!("Description: {desc}"));
567 }
568
569 if stack.working_branch.is_none() {
571 Output::warning(format!(
572 "You're currently on the base branch '{}'",
573 stack.base_branch
574 ));
575 Output::next_steps(&[
576 &format!("Create a feature branch: git checkout -b {name}"),
577 "Make changes and commit them",
578 "Run 'ca push' to add commits to this stack",
579 ]);
580 } else {
581 Output::next_steps(&[
582 "Make changes and commit them",
583 "Run 'ca push' to add commits to this stack",
584 "Use 'ca submit' when ready to create pull requests",
585 ]);
586 }
587
588 Ok(())
589}
590
591async fn list_stacks(verbose: bool, _active: bool, _format: Option<String>) -> Result<()> {
592 let current_dir = env::current_dir()
593 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
594
595 let repo_root = find_repository_root(¤t_dir)
596 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
597
598 let manager = StackManager::new(&repo_root)?;
599 let stacks = manager.list_stacks();
600
601 if stacks.is_empty() {
602 Output::info("No stacks found. Create one with: ca stack create <name>");
603 return Ok(());
604 }
605
606 println!("📚 Stacks:");
607 for (stack_id, name, status, entry_count, active_marker) in stacks {
608 let status_icon = match status {
609 StackStatus::Clean => "✅",
610 StackStatus::Dirty => "🔄",
611 StackStatus::OutOfSync => "⚠️",
612 StackStatus::Conflicted => "❌",
613 StackStatus::Rebasing => "🔀",
614 StackStatus::NeedsSync => "🔄",
615 StackStatus::Corrupted => "💥",
616 };
617
618 let active_indicator = if active_marker.is_some() {
619 " (active)"
620 } else {
621 ""
622 };
623
624 let stack = manager.get_stack(&stack_id);
626
627 if verbose {
628 println!(" {status_icon} {name} [{entry_count}]{active_indicator}");
629 println!(" ID: {stack_id}");
630 if let Some(stack_meta) = manager.get_stack_metadata(&stack_id) {
631 println!(" Base: {}", stack_meta.base_branch);
632 if let Some(desc) = &stack_meta.description {
633 println!(" Description: {desc}");
634 }
635 println!(
636 " Commits: {} total, {} submitted",
637 stack_meta.total_commits, stack_meta.submitted_commits
638 );
639 if stack_meta.has_conflicts {
640 println!(" ⚠️ Has conflicts");
641 }
642 }
643
644 if let Some(stack_obj) = stack {
646 if !stack_obj.entries.is_empty() {
647 println!(" Branches:");
648 for (i, entry) in stack_obj.entries.iter().enumerate() {
649 let entry_num = i + 1;
650 let submitted_indicator = if entry.is_submitted { "📤" } else { "📝" };
651 let branch_name = &entry.branch;
652 let short_message = if entry.message.len() > 40 {
653 format!("{}...", &entry.message[..37])
654 } else {
655 entry.message.clone()
656 };
657 println!(" {entry_num}. {submitted_indicator} {branch_name} - {short_message}");
658 }
659 }
660 }
661 println!();
662 } else {
663 let branch_info = if let Some(stack_obj) = stack {
665 if stack_obj.entries.is_empty() {
666 String::new()
667 } else if stack_obj.entries.len() == 1 {
668 format!(" → {}", stack_obj.entries[0].branch)
669 } else {
670 let first_branch = &stack_obj.entries[0].branch;
671 let last_branch = &stack_obj.entries.last().unwrap().branch;
672 format!(" → {first_branch} … {last_branch}")
673 }
674 } else {
675 String::new()
676 };
677
678 println!(" {status_icon} {name} [{entry_count}]{branch_info}{active_indicator}");
679 }
680 }
681
682 if !verbose {
683 println!("\nUse --verbose for more details");
684 }
685
686 Ok(())
687}
688
689async fn switch_stack(name: String) -> Result<()> {
690 let current_dir = env::current_dir()
691 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
692
693 let repo_root = find_repository_root(¤t_dir)
694 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
695
696 let mut manager = StackManager::new(&repo_root)?;
697 let repo = GitRepository::open(&repo_root)?;
698
699 let stack = manager
701 .get_stack_by_name(&name)
702 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
703
704 if let Some(working_branch) = &stack.working_branch {
706 let current_branch = repo.get_current_branch().ok();
708
709 if current_branch.as_ref() != Some(working_branch) {
710 Output::progress(format!(
711 "Switching to stack working branch: {working_branch}"
712 ));
713
714 if repo.branch_exists(working_branch) {
716 match repo.checkout_branch(working_branch) {
717 Ok(_) => {
718 Output::success(format!("Checked out branch: {working_branch}"));
719 }
720 Err(e) => {
721 Output::warning(format!("Failed to checkout '{working_branch}': {e}"));
722 Output::sub_item("Stack activated but stayed on current branch");
723 Output::sub_item(format!(
724 "You can manually checkout with: git checkout {working_branch}"
725 ));
726 }
727 }
728 } else {
729 Output::warning(format!(
730 "Stack working branch '{working_branch}' doesn't exist locally"
731 ));
732 Output::sub_item("Stack activated but stayed on current branch");
733 Output::sub_item(format!(
734 "You may need to fetch from remote: git fetch origin {working_branch}"
735 ));
736 }
737 } else {
738 Output::success(format!("Already on stack working branch: {working_branch}"));
739 }
740 } else {
741 Output::warning(format!("Stack '{name}' has no working branch set"));
743 Output::sub_item(
744 "This typically happens when a stack was created while on the base branch",
745 );
746
747 Output::tip("To start working on this stack:");
748 Output::bullet(format!("Create a feature branch: git checkout -b {name}"));
749 Output::bullet("The stack will automatically track this as its working branch");
750 Output::bullet("Then use 'ca push' to add commits to the stack");
751
752 Output::sub_item(format!("Base branch: {}", stack.base_branch));
753 }
754
755 manager.set_active_stack_by_name(&name)?;
757 Output::success(format!("Switched to stack '{name}'"));
758
759 Ok(())
760}
761
762async fn deactivate_stack(force: bool) -> Result<()> {
763 let current_dir = env::current_dir()
764 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
765
766 let repo_root = find_repository_root(¤t_dir)
767 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
768
769 let mut manager = StackManager::new(&repo_root)?;
770
771 let active_stack = manager.get_active_stack();
772
773 if active_stack.is_none() {
774 Output::info("No active stack to deactivate");
775 return Ok(());
776 }
777
778 let stack_name = active_stack.unwrap().name.clone();
779
780 if !force {
781 Output::warning(format!(
782 "This will deactivate stack '{stack_name}' and return to normal Git workflow"
783 ));
784 Output::sub_item(format!(
785 "You can reactivate it later with 'ca stacks switch {stack_name}'"
786 ));
787 let should_deactivate = Confirm::with_theme(&ColorfulTheme::default())
789 .with_prompt("Continue with deactivation?")
790 .default(false)
791 .interact()
792 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
793
794 if !should_deactivate {
795 Output::info("Cancelled deactivation");
796 return Ok(());
797 }
798 }
799
800 manager.set_active_stack(None)?;
802
803 Output::success(format!("Deactivated stack '{stack_name}'"));
804 Output::sub_item("Stack management is now OFF - you can use normal Git workflow");
805 Output::sub_item(format!("To reactivate: ca stacks switch {stack_name}"));
806
807 Ok(())
808}
809
810async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
811 let current_dir = env::current_dir()
812 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
813
814 let repo_root = find_repository_root(¤t_dir)
815 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
816
817 let stack_manager = StackManager::new(&repo_root)?;
818
819 let (stack_id, stack_name, stack_base, stack_working, stack_entries) = {
821 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
822 CascadeError::config(
823 "No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
824 .to_string(),
825 )
826 })?;
827
828 (
829 active_stack.id,
830 active_stack.name.clone(),
831 active_stack.base_branch.clone(),
832 active_stack.working_branch.clone(),
833 active_stack.entries.clone(),
834 )
835 };
836
837 Output::stack_info(
839 &stack_name,
840 &stack_id.to_string(),
841 &stack_base,
842 stack_working.as_deref(),
843 true, );
845 Output::sub_item(format!("Total entries: {}", stack_entries.len()));
846
847 if stack_entries.is_empty() {
848 Output::info("No entries in this stack yet");
849 Output::tip("Use 'ca push' to add commits to this stack");
850 return Ok(());
851 }
852
853 Output::section("Stack Entries");
855 for (i, entry) in stack_entries.iter().enumerate() {
856 let entry_num = i + 1;
857 let short_hash = entry.short_hash();
858 let short_msg = entry.short_message(50);
859
860 let metadata = stack_manager.get_repository_metadata();
862 let source_branch_info = if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash)
863 {
864 if commit_meta.source_branch != commit_meta.branch
865 && !commit_meta.source_branch.is_empty()
866 {
867 format!(" (from {})", commit_meta.source_branch)
868 } else {
869 String::new()
870 }
871 } else {
872 String::new()
873 };
874
875 let status_icon = if entry.is_submitted {
876 "[submitted]"
877 } else {
878 "[pending]"
879 };
880 Output::numbered_item(
881 entry_num,
882 format!("{short_hash} {status_icon} {short_msg}{source_branch_info}"),
883 );
884
885 if verbose {
886 Output::sub_item(format!("Branch: {}", entry.branch));
887 Output::sub_item(format!(
888 "Created: {}",
889 entry.created_at.format("%Y-%m-%d %H:%M")
890 ));
891 if let Some(pr_id) = &entry.pull_request_id {
892 Output::sub_item(format!("PR: #{pr_id}"));
893 }
894
895 Output::sub_item("Commit Message:");
897 let lines: Vec<&str> = entry.message.lines().collect();
898 for line in lines {
899 Output::sub_item(format!(" {line}"));
900 }
901 }
902 }
903
904 if show_mergeable {
906 Output::section("Mergability Status");
907
908 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
910 let config_path = config_dir.join("config.json");
911 let settings = crate::config::Settings::load_from_file(&config_path)?;
912
913 let cascade_config = crate::config::CascadeConfig {
914 bitbucket: Some(settings.bitbucket.clone()),
915 git: settings.git.clone(),
916 auth: crate::config::AuthConfig::default(),
917 cascade: settings.cascade.clone(),
918 };
919
920 let integration =
921 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
922
923 match integration.check_enhanced_stack_status(&stack_id).await {
924 Ok(status) => {
925 Output::bullet(format!("Total entries: {}", status.total_entries));
926 Output::bullet(format!("Submitted: {}", status.submitted_entries));
927 Output::bullet(format!("Open PRs: {}", status.open_prs));
928 Output::bullet(format!("Merged PRs: {}", status.merged_prs));
929 Output::bullet(format!("Declined PRs: {}", status.declined_prs));
930 Output::bullet(format!(
931 "Completion: {:.1}%",
932 status.completion_percentage()
933 ));
934
935 if !status.enhanced_statuses.is_empty() {
936 Output::section("Pull Request Status");
937 let mut ready_to_land = 0;
938
939 for enhanced in &status.enhanced_statuses {
940 let status_display = enhanced.get_display_status();
941 let ready_icon = if enhanced.is_ready_to_land() {
942 ready_to_land += 1;
943 "[READY]"
944 } else {
945 "[PENDING]"
946 };
947
948 Output::bullet(format!(
949 "{} PR #{}: {} ({})",
950 ready_icon, enhanced.pr.id, enhanced.pr.title, status_display
951 ));
952
953 if verbose {
954 println!(
955 " {} -> {}",
956 enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
957 );
958
959 if !enhanced.is_ready_to_land() {
961 let blocking = enhanced.get_blocking_reasons();
962 if !blocking.is_empty() {
963 println!(" Blocking: {}", blocking.join(", "));
964 }
965 }
966
967 println!(
969 " Reviews: {} approval{}",
970 enhanced.review_status.current_approvals,
971 if enhanced.review_status.current_approvals == 1 {
972 ""
973 } else {
974 "s"
975 }
976 );
977
978 if enhanced.review_status.needs_work_count > 0 {
979 println!(
980 " {} reviewers requested changes",
981 enhanced.review_status.needs_work_count
982 );
983 }
984
985 if let Some(build) = &enhanced.build_status {
987 let build_icon = match build.state {
988 crate::bitbucket::pull_request::BuildState::Successful => "✅",
989 crate::bitbucket::pull_request::BuildState::Failed => "❌",
990 crate::bitbucket::pull_request::BuildState::InProgress => "🔄",
991 _ => "⚪",
992 };
993 println!(" Build: {} {:?}", build_icon, build.state);
994 }
995
996 if let Some(url) = enhanced.pr.web_url() {
997 println!(" URL: {url}");
998 }
999 println!();
1000 }
1001 }
1002
1003 if ready_to_land > 0 {
1004 println!(
1005 "\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
1006 ready_to_land,
1007 if ready_to_land == 1 { " is" } else { "s are" }
1008 );
1009 }
1010 }
1011 }
1012 Err(e) => {
1013 warn!("Failed to get enhanced stack status: {}", e);
1014 println!(" ⚠️ Could not fetch mergability status");
1015 println!(" Use 'ca stack show --verbose' for basic PR information");
1016 }
1017 }
1018 } else {
1019 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1021 let config_path = config_dir.join("config.json");
1022 let settings = crate::config::Settings::load_from_file(&config_path)?;
1023
1024 let cascade_config = crate::config::CascadeConfig {
1025 bitbucket: Some(settings.bitbucket.clone()),
1026 git: settings.git.clone(),
1027 auth: crate::config::AuthConfig::default(),
1028 cascade: settings.cascade.clone(),
1029 };
1030
1031 let integration =
1032 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1033
1034 match integration.check_stack_status(&stack_id).await {
1035 Ok(status) => {
1036 println!("\n📊 Pull Request Status:");
1037 println!(" Total entries: {}", status.total_entries);
1038 println!(" Submitted: {}", status.submitted_entries);
1039 println!(" Open PRs: {}", status.open_prs);
1040 println!(" Merged PRs: {}", status.merged_prs);
1041 println!(" Declined PRs: {}", status.declined_prs);
1042 println!(" Completion: {:.1}%", status.completion_percentage());
1043
1044 if !status.pull_requests.is_empty() {
1045 println!("\n📋 Pull Requests:");
1046 for pr in &status.pull_requests {
1047 let state_icon = match pr.state {
1048 crate::bitbucket::PullRequestState::Open => "🔄",
1049 crate::bitbucket::PullRequestState::Merged => "✅",
1050 crate::bitbucket::PullRequestState::Declined => "❌",
1051 };
1052 println!(
1053 " {} PR #{}: {} ({} -> {})",
1054 state_icon,
1055 pr.id,
1056 pr.title,
1057 pr.from_ref.display_id,
1058 pr.to_ref.display_id
1059 );
1060 if let Some(url) = pr.web_url() {
1061 println!(" URL: {url}");
1062 }
1063 }
1064 }
1065
1066 println!("\n💡 Use 'ca stack --mergeable' to see detailed status including build and review information");
1067 }
1068 Err(e) => {
1069 warn!("Failed to check stack status: {}", e);
1070 }
1071 }
1072 }
1073
1074 Ok(())
1075}
1076
1077#[allow(clippy::too_many_arguments)]
1078async fn push_to_stack(
1079 branch: Option<String>,
1080 message: Option<String>,
1081 commit: Option<String>,
1082 since: Option<String>,
1083 commits: Option<String>,
1084 squash: Option<usize>,
1085 squash_since: Option<String>,
1086 auto_branch: bool,
1087 allow_base_branch: bool,
1088 dry_run: bool,
1089) -> Result<()> {
1090 let current_dir = env::current_dir()
1091 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1092
1093 let repo_root = find_repository_root(¤t_dir)
1094 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1095
1096 let mut manager = StackManager::new(&repo_root)?;
1097 let repo = GitRepository::open(&repo_root)?;
1098
1099 if !manager.check_for_branch_change()? {
1101 return Ok(()); }
1103
1104 let active_stack = manager.get_active_stack().ok_or_else(|| {
1106 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1107 })?;
1108
1109 let current_branch = repo.get_current_branch()?;
1111 let base_branch = &active_stack.base_branch;
1112
1113 if current_branch == *base_branch {
1114 Output::error(format!(
1115 "You're currently on the base branch '{base_branch}'"
1116 ));
1117 Output::sub_item("Making commits directly on the base branch is not recommended.");
1118 Output::sub_item("This can pollute the base branch with work-in-progress commits.");
1119
1120 if allow_base_branch {
1122 Output::warning("Proceeding anyway due to --allow-base-branch flag");
1123 } else {
1124 let has_changes = repo.is_dirty()?;
1126
1127 if has_changes {
1128 if auto_branch {
1129 let feature_branch = format!("feature/{}-work", active_stack.name);
1131 Output::progress(format!(
1132 "Auto-creating feature branch '{feature_branch}'..."
1133 ));
1134
1135 repo.create_branch(&feature_branch, None)?;
1136 repo.checkout_branch(&feature_branch)?;
1137
1138 println!("✅ Created and switched to '{feature_branch}'");
1139 println!(" You can now commit and push your changes safely");
1140
1141 } else {
1143 println!("\n💡 You have uncommitted changes. Here are your options:");
1144 println!(" 1. Create a feature branch first:");
1145 println!(" git checkout -b feature/my-work");
1146 println!(" git commit -am \"your work\"");
1147 println!(" ca push");
1148 println!("\n 2. Auto-create a branch (recommended):");
1149 println!(" ca push --auto-branch");
1150 println!("\n 3. Force push to base branch (dangerous):");
1151 println!(" ca push --allow-base-branch");
1152
1153 return Err(CascadeError::config(
1154 "Refusing to push uncommitted changes from base branch. Use one of the options above."
1155 ));
1156 }
1157 } else {
1158 let commits_to_check = if let Some(commits_str) = &commits {
1160 commits_str
1161 .split(',')
1162 .map(|s| s.trim().to_string())
1163 .collect::<Vec<String>>()
1164 } else if let Some(since_ref) = &since {
1165 let since_commit = repo.resolve_reference(since_ref)?;
1166 let head_commit = repo.get_head_commit()?;
1167 let commits = repo.get_commits_between(
1168 &since_commit.id().to_string(),
1169 &head_commit.id().to_string(),
1170 )?;
1171 commits.into_iter().map(|c| c.id().to_string()).collect()
1172 } else if commit.is_none() {
1173 let mut unpushed = Vec::new();
1174 let head_commit = repo.get_head_commit()?;
1175 let mut current_commit = head_commit;
1176
1177 loop {
1178 let commit_hash = current_commit.id().to_string();
1179 let already_in_stack = active_stack
1180 .entries
1181 .iter()
1182 .any(|entry| entry.commit_hash == commit_hash);
1183
1184 if already_in_stack {
1185 break;
1186 }
1187
1188 unpushed.push(commit_hash);
1189
1190 if let Some(parent) = current_commit.parents().next() {
1191 current_commit = parent;
1192 } else {
1193 break;
1194 }
1195 }
1196
1197 unpushed.reverse();
1198 unpushed
1199 } else {
1200 vec![repo.get_head_commit()?.id().to_string()]
1201 };
1202
1203 if !commits_to_check.is_empty() {
1204 if auto_branch {
1205 let feature_branch = format!("feature/{}-work", active_stack.name);
1207 Output::progress(format!(
1208 "Auto-creating feature branch '{feature_branch}'..."
1209 ));
1210
1211 repo.create_branch(&feature_branch, Some(base_branch))?;
1212 repo.checkout_branch(&feature_branch)?;
1213
1214 println!(
1216 "🍒 Cherry-picking {} commit(s) to new branch...",
1217 commits_to_check.len()
1218 );
1219 for commit_hash in &commits_to_check {
1220 match repo.cherry_pick(commit_hash) {
1221 Ok(_) => println!(" ✅ Cherry-picked {}", &commit_hash[..8]),
1222 Err(e) => {
1223 println!(
1224 " ❌ Failed to cherry-pick {}: {}",
1225 &commit_hash[..8],
1226 e
1227 );
1228 println!(" 💡 You may need to resolve conflicts manually");
1229 return Err(CascadeError::branch(format!(
1230 "Failed to cherry-pick commit {commit_hash}: {e}"
1231 )));
1232 }
1233 }
1234 }
1235
1236 println!(
1237 "✅ Successfully moved {} commit(s) to '{feature_branch}'",
1238 commits_to_check.len()
1239 );
1240 println!(
1241 " You're now on the feature branch and can continue with 'ca push'"
1242 );
1243
1244 } else {
1246 println!(
1247 "\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
1248 commits_to_check.len()
1249 );
1250 println!(" These commits are currently ON the base branch, which may not be intended.");
1251 println!("\n Options:");
1252 println!(" 1. Auto-create feature branch and cherry-pick commits:");
1253 println!(" ca push --auto-branch");
1254 println!("\n 2. Manually create branch and move commits:");
1255 println!(" git checkout -b feature/my-work");
1256 println!(" ca push");
1257 println!("\n 3. Force push from base branch (not recommended):");
1258 println!(" ca push --allow-base-branch");
1259
1260 return Err(CascadeError::config(
1261 "Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
1262 ));
1263 }
1264 }
1265 }
1266 }
1267 }
1268
1269 if let Some(squash_count) = squash {
1271 if squash_count == 0 {
1272 let active_stack = manager.get_active_stack().ok_or_else(|| {
1274 CascadeError::config(
1275 "No active stack. Create a stack first with 'ca stacks create'",
1276 )
1277 })?;
1278
1279 let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
1280
1281 if unpushed_count == 0 {
1282 println!("ℹ️ No unpushed commits to squash");
1283 } else if unpushed_count == 1 {
1284 println!("ℹ️ Only 1 unpushed commit, no squashing needed");
1285 } else {
1286 println!("🔄 Auto-detected {unpushed_count} unpushed commits, squashing...");
1287 squash_commits(&repo, unpushed_count, None).await?;
1288 println!("✅ Squashed {unpushed_count} unpushed commits into one");
1289 }
1290 } else {
1291 println!("🔄 Squashing last {squash_count} commits...");
1292 squash_commits(&repo, squash_count, None).await?;
1293 println!("✅ Squashed {squash_count} commits into one");
1294 }
1295 } else if let Some(since_ref) = squash_since {
1296 println!("🔄 Squashing commits since {since_ref}...");
1297 let since_commit = repo.resolve_reference(&since_ref)?;
1298 let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
1299 squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
1300 println!("✅ Squashed {commits_count} commits since {since_ref} into one");
1301 }
1302
1303 let commits_to_push = if let Some(commits_str) = commits {
1305 commits_str
1307 .split(',')
1308 .map(|s| s.trim().to_string())
1309 .collect::<Vec<String>>()
1310 } else if let Some(since_ref) = since {
1311 let since_commit = repo.resolve_reference(&since_ref)?;
1313 let head_commit = repo.get_head_commit()?;
1314
1315 let commits = repo.get_commits_between(
1317 &since_commit.id().to_string(),
1318 &head_commit.id().to_string(),
1319 )?;
1320 commits.into_iter().map(|c| c.id().to_string()).collect()
1321 } else if let Some(hash) = commit {
1322 vec![hash]
1324 } else {
1325 let active_stack = manager.get_active_stack().ok_or_else(|| {
1327 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1328 })?;
1329
1330 let base_branch = &active_stack.base_branch;
1332 let current_branch = repo.get_current_branch()?;
1333
1334 if current_branch == *base_branch {
1336 let mut unpushed = Vec::new();
1337 let head_commit = repo.get_head_commit()?;
1338 let mut current_commit = head_commit;
1339
1340 loop {
1342 let commit_hash = current_commit.id().to_string();
1343 let already_in_stack = active_stack
1344 .entries
1345 .iter()
1346 .any(|entry| entry.commit_hash == commit_hash);
1347
1348 if already_in_stack {
1349 break;
1350 }
1351
1352 unpushed.push(commit_hash);
1353
1354 if let Some(parent) = current_commit.parents().next() {
1356 current_commit = parent;
1357 } else {
1358 break;
1359 }
1360 }
1361
1362 unpushed.reverse(); unpushed
1364 } else {
1365 match repo.get_commits_between(base_branch, ¤t_branch) {
1367 Ok(commits) => {
1368 let mut unpushed: Vec<String> =
1369 commits.into_iter().map(|c| c.id().to_string()).collect();
1370
1371 unpushed.retain(|commit_hash| {
1373 !active_stack
1374 .entries
1375 .iter()
1376 .any(|entry| entry.commit_hash == *commit_hash)
1377 });
1378
1379 unpushed.reverse(); unpushed
1381 }
1382 Err(e) => {
1383 return Err(CascadeError::branch(format!(
1384 "Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
1385 This usually means the branches have diverged or don't share common history."
1386 )));
1387 }
1388 }
1389 }
1390 };
1391
1392 if commits_to_push.is_empty() {
1393 println!("ℹ️ No commits to push to stack");
1394 return Ok(());
1395 }
1396
1397 analyze_commits_for_safeguards(&commits_to_push, &repo, dry_run).await?;
1399
1400 if dry_run {
1402 return Ok(());
1403 }
1404
1405 let mut pushed_count = 0;
1407 let mut source_branches = std::collections::HashSet::new();
1408
1409 for (i, commit_hash) in commits_to_push.iter().enumerate() {
1410 let commit_obj = repo.get_commit(commit_hash)?;
1411 let commit_msg = commit_obj.message().unwrap_or("").to_string();
1412
1413 let commit_source_branch = repo
1415 .find_branch_containing_commit(commit_hash)
1416 .unwrap_or_else(|_| current_branch.clone());
1417 source_branches.insert(commit_source_branch.clone());
1418
1419 let branch_name = if i == 0 && branch.is_some() {
1421 branch.clone().unwrap()
1422 } else {
1423 let temp_repo = GitRepository::open(&repo_root)?;
1425 let branch_mgr = crate::git::BranchManager::new(temp_repo);
1426 branch_mgr.generate_branch_name(&commit_msg)
1427 };
1428
1429 let final_message = if i == 0 && message.is_some() {
1431 message.clone().unwrap()
1432 } else {
1433 commit_msg.clone()
1434 };
1435
1436 let entry_id = manager.push_to_stack(
1437 branch_name.clone(),
1438 commit_hash.clone(),
1439 final_message.clone(),
1440 commit_source_branch.clone(),
1441 )?;
1442 pushed_count += 1;
1443
1444 Output::success(format!(
1445 "Pushed commit {}/{} to stack",
1446 i + 1,
1447 commits_to_push.len()
1448 ));
1449 Output::sub_item(format!(
1450 "Commit: {} ({})",
1451 &commit_hash[..8],
1452 commit_msg.split('\n').next().unwrap_or("")
1453 ));
1454 Output::sub_item(format!("Branch: {branch_name}"));
1455 Output::sub_item(format!("Source: {commit_source_branch}"));
1456 Output::sub_item(format!("Entry ID: {entry_id}"));
1457 println!();
1458 }
1459
1460 if source_branches.len() > 1 {
1462 Output::warning("Scattered Commit Detection");
1463 Output::sub_item(format!(
1464 "You've pushed commits from {} different Git branches:",
1465 source_branches.len()
1466 ));
1467 for branch in &source_branches {
1468 Output::bullet(branch.to_string());
1469 }
1470
1471 Output::section("This can lead to confusion because:");
1472 Output::bullet("Stack appears sequential but commits are scattered across branches");
1473 Output::bullet("Team members won't know which branch contains which work");
1474 Output::bullet("Branch cleanup becomes unclear after merge");
1475 Output::bullet("Rebase operations become more complex");
1476
1477 Output::tip("Consider consolidating work to a single feature branch:");
1478 Output::bullet("Create a new feature branch: git checkout -b feature/consolidated-work");
1479 Output::bullet("Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
1480 Output::bullet("Delete old scattered branches");
1481 Output::bullet("Push the consolidated branch to your stack");
1482 println!();
1483 }
1484
1485 Output::success(format!(
1486 "Successfully pushed {} commit{} to stack",
1487 pushed_count,
1488 if pushed_count == 1 { "" } else { "s" }
1489 ));
1490
1491 Ok(())
1492}
1493
1494async fn pop_from_stack(keep_branch: bool) -> Result<()> {
1495 let current_dir = env::current_dir()
1496 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1497
1498 let repo_root = find_repository_root(¤t_dir)
1499 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1500
1501 let mut manager = StackManager::new(&repo_root)?;
1502 let repo = GitRepository::open(&repo_root)?;
1503
1504 let entry = manager.pop_from_stack()?;
1505
1506 Output::success("Popped commit from stack");
1507 Output::sub_item(format!(
1508 "Commit: {} ({})",
1509 entry.short_hash(),
1510 entry.short_message(50)
1511 ));
1512 Output::sub_item(format!("Branch: {}", entry.branch));
1513
1514 if !keep_branch && entry.branch != repo.get_current_branch()? {
1516 match repo.delete_branch(&entry.branch) {
1517 Ok(_) => Output::sub_item(format!("Deleted branch: {}", entry.branch)),
1518 Err(e) => Output::warning(format!("Could not delete branch {}: {}", entry.branch, e)),
1519 }
1520 }
1521
1522 Ok(())
1523}
1524
1525async fn submit_entry(
1526 entry: Option<usize>,
1527 title: Option<String>,
1528 description: Option<String>,
1529 range: Option<String>,
1530 draft: bool,
1531) -> Result<()> {
1532 let current_dir = env::current_dir()
1533 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1534
1535 let repo_root = find_repository_root(¤t_dir)
1536 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1537
1538 let mut stack_manager = StackManager::new(&repo_root)?;
1539
1540 if !stack_manager.check_for_branch_change()? {
1542 return Ok(()); }
1544
1545 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1547 let config_path = config_dir.join("config.json");
1548 let settings = crate::config::Settings::load_from_file(&config_path)?;
1549
1550 let cascade_config = crate::config::CascadeConfig {
1552 bitbucket: Some(settings.bitbucket.clone()),
1553 git: settings.git.clone(),
1554 auth: crate::config::AuthConfig::default(),
1555 cascade: settings.cascade.clone(),
1556 };
1557
1558 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1560 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1561 })?;
1562 let stack_id = active_stack.id;
1563
1564 let entries_to_submit = if let Some(range_str) = range {
1566 let mut entries = Vec::new();
1568
1569 if range_str.contains('-') {
1570 let parts: Vec<&str> = range_str.split('-').collect();
1572 if parts.len() != 2 {
1573 return Err(CascadeError::config(
1574 "Invalid range format. Use 'start-end' (e.g., '1-3')",
1575 ));
1576 }
1577
1578 let start: usize = parts[0]
1579 .parse()
1580 .map_err(|_| CascadeError::config("Invalid start number in range"))?;
1581 let end: usize = parts[1]
1582 .parse()
1583 .map_err(|_| CascadeError::config("Invalid end number in range"))?;
1584
1585 if start == 0
1586 || end == 0
1587 || start > active_stack.entries.len()
1588 || end > active_stack.entries.len()
1589 {
1590 return Err(CascadeError::config(format!(
1591 "Range out of bounds. Stack has {} entries",
1592 active_stack.entries.len()
1593 )));
1594 }
1595
1596 for i in start..=end {
1597 entries.push((i, active_stack.entries[i - 1].clone()));
1598 }
1599 } else {
1600 for entry_str in range_str.split(',') {
1602 let entry_num: usize = entry_str.trim().parse().map_err(|_| {
1603 CascadeError::config(format!("Invalid entry number: {entry_str}"))
1604 })?;
1605
1606 if entry_num == 0 || entry_num > active_stack.entries.len() {
1607 return Err(CascadeError::config(format!(
1608 "Entry {} out of bounds. Stack has {} entries",
1609 entry_num,
1610 active_stack.entries.len()
1611 )));
1612 }
1613
1614 entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
1615 }
1616 }
1617
1618 entries
1619 } else if let Some(entry_num) = entry {
1620 if entry_num == 0 || entry_num > active_stack.entries.len() {
1622 return Err(CascadeError::config(format!(
1623 "Invalid entry number: {}. Stack has {} entries",
1624 entry_num,
1625 active_stack.entries.len()
1626 )));
1627 }
1628 vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
1629 } else {
1630 active_stack
1632 .entries
1633 .iter()
1634 .enumerate()
1635 .filter(|(_, entry)| !entry.is_submitted)
1636 .map(|(i, entry)| (i + 1, entry.clone())) .collect::<Vec<(usize, _)>>()
1638 };
1639
1640 if entries_to_submit.is_empty() {
1641 Output::info("No entries to submit");
1642 return Ok(());
1643 }
1644
1645 Output::section(format!(
1647 "Submitting {} {}",
1648 entries_to_submit.len(),
1649 if entries_to_submit.len() == 1 {
1650 "entry"
1651 } else {
1652 "entries"
1653 }
1654 ));
1655 println!();
1656
1657 let integration_stack_manager = StackManager::new(&repo_root)?;
1659 let mut integration =
1660 BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
1661
1662 let mut submitted_count = 0;
1664 let mut failed_entries = Vec::new();
1665 let total_entries = entries_to_submit.len();
1666
1667 for (entry_num, entry_to_submit) in &entries_to_submit {
1668 let tree_char = if entries_to_submit.len() == 1 {
1670 "→"
1671 } else if entry_num == &entries_to_submit.len() {
1672 "└─"
1673 } else {
1674 "├─"
1675 };
1676 print!(
1677 " {} Entry {}: {}... ",
1678 tree_char, entry_num, entry_to_submit.branch
1679 );
1680 std::io::Write::flush(&mut std::io::stdout()).ok();
1681
1682 let entry_title = if total_entries == 1 {
1684 title.clone()
1685 } else {
1686 None
1687 };
1688 let entry_description = if total_entries == 1 {
1689 description.clone()
1690 } else {
1691 None
1692 };
1693
1694 match integration
1695 .submit_entry(
1696 &stack_id,
1697 &entry_to_submit.id,
1698 entry_title,
1699 entry_description,
1700 draft,
1701 )
1702 .await
1703 {
1704 Ok(pr) => {
1705 submitted_count += 1;
1706 println!("✓ PR #{}", pr.id);
1707 if let Some(url) = pr.web_url() {
1708 Output::sub_item(format!(
1709 "{} → {}",
1710 pr.from_ref.display_id, pr.to_ref.display_id
1711 ));
1712 Output::sub_item(format!("URL: {url}"));
1713 }
1714 }
1715 Err(e) => {
1716 println!("✗ Failed");
1717 let clean_error = if e.to_string().contains("non-fast-forward") {
1719 "Branch has diverged (was rebased after initial submission). Update to v0.1.41+ to auto force-push.".to_string()
1720 } else if e.to_string().contains("authentication") {
1721 "Authentication failed. Check your Bitbucket credentials.".to_string()
1722 } else {
1723 e.to_string()
1725 .lines()
1726 .filter(|l| !l.trim().starts_with("hint:") && !l.trim().is_empty())
1727 .take(1)
1728 .collect::<Vec<_>>()
1729 .join(" ")
1730 .trim()
1731 .to_string()
1732 };
1733 Output::sub_item(format!("Error: {}", clean_error));
1734 failed_entries.push((*entry_num, clean_error));
1735 }
1736 }
1737 }
1738
1739 println!();
1740
1741 let has_any_prs = active_stack
1743 .entries
1744 .iter()
1745 .any(|e| e.pull_request_id.is_some());
1746 if has_any_prs && submitted_count > 0 {
1747 match integration.update_all_pr_descriptions(&stack_id).await {
1748 Ok(updated_prs) => {
1749 if !updated_prs.is_empty() {
1750 Output::sub_item(format!(
1751 "Updated {} PR description{} with stack hierarchy",
1752 updated_prs.len(),
1753 if updated_prs.len() == 1 { "" } else { "s" }
1754 ));
1755 }
1756 }
1757 Err(e) => {
1758 Output::warning(format!("Failed to update some PR descriptions: {e}"));
1759 }
1760 }
1761 }
1762
1763 if failed_entries.is_empty() {
1765 Output::success(format!(
1766 "All {} {} submitted successfully!",
1767 submitted_count,
1768 if submitted_count == 1 {
1769 "entry"
1770 } else {
1771 "entries"
1772 }
1773 ));
1774 } else {
1775 println!();
1776 Output::section("Submission Summary");
1777 println!(" ✓ Successful: {submitted_count}");
1778 println!(" ✗ Failed: {}", failed_entries.len());
1779
1780 if !failed_entries.is_empty() {
1781 println!();
1782 Output::tip("Retry failed entries:");
1783 for (entry_num, _) in &failed_entries {
1784 Output::bullet(format!("ca stack submit {entry_num}"));
1785 }
1786 }
1787 }
1788
1789 Ok(())
1790}
1791
1792async fn check_stack_status(name: Option<String>) -> Result<()> {
1793 let current_dir = env::current_dir()
1794 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1795
1796 let repo_root = find_repository_root(¤t_dir)
1797 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1798
1799 let stack_manager = StackManager::new(&repo_root)?;
1800
1801 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1803 let config_path = config_dir.join("config.json");
1804 let settings = crate::config::Settings::load_from_file(&config_path)?;
1805
1806 let cascade_config = crate::config::CascadeConfig {
1808 bitbucket: Some(settings.bitbucket.clone()),
1809 git: settings.git.clone(),
1810 auth: crate::config::AuthConfig::default(),
1811 cascade: settings.cascade.clone(),
1812 };
1813
1814 let stack = if let Some(name) = name {
1816 stack_manager
1817 .get_stack_by_name(&name)
1818 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
1819 } else {
1820 stack_manager.get_active_stack().ok_or_else(|| {
1821 CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
1822 })?
1823 };
1824 let stack_id = stack.id;
1825
1826 Output::section(format!("Stack: {}", stack.name));
1827 Output::sub_item(format!("ID: {}", stack.id));
1828 Output::sub_item(format!("Base: {}", stack.base_branch));
1829
1830 if let Some(description) = &stack.description {
1831 Output::sub_item(format!("Description: {description}"));
1832 }
1833
1834 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1836
1837 match integration.check_stack_status(&stack_id).await {
1839 Ok(status) => {
1840 Output::section("Pull Request Status");
1841 Output::sub_item(format!("Total entries: {}", status.total_entries));
1842 Output::sub_item(format!("Submitted: {}", status.submitted_entries));
1843 Output::sub_item(format!("Open PRs: {}", status.open_prs));
1844 Output::sub_item(format!("Merged PRs: {}", status.merged_prs));
1845 Output::sub_item(format!("Declined PRs: {}", status.declined_prs));
1846 Output::sub_item(format!(
1847 "Completion: {:.1}%",
1848 status.completion_percentage()
1849 ));
1850
1851 if !status.pull_requests.is_empty() {
1852 Output::section("Pull Requests");
1853 for pr in &status.pull_requests {
1854 let state_icon = match pr.state {
1855 crate::bitbucket::PullRequestState::Open => "🔄",
1856 crate::bitbucket::PullRequestState::Merged => "✅",
1857 crate::bitbucket::PullRequestState::Declined => "❌",
1858 };
1859 Output::bullet(format!(
1860 "{} PR #{}: {} ({} -> {})",
1861 state_icon, pr.id, pr.title, pr.from_ref.display_id, pr.to_ref.display_id
1862 ));
1863 if let Some(url) = pr.web_url() {
1864 Output::sub_item(format!("URL: {url}"));
1865 }
1866 }
1867 }
1868 }
1869 Err(e) => {
1870 warn!("Failed to check stack status: {}", e);
1871 return Err(e);
1872 }
1873 }
1874
1875 Ok(())
1876}
1877
1878async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
1879 let current_dir = env::current_dir()
1880 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1881
1882 let repo_root = find_repository_root(¤t_dir)
1883 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1884
1885 let stack_manager = StackManager::new(&repo_root)?;
1886
1887 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1889 let config_path = config_dir.join("config.json");
1890 let settings = crate::config::Settings::load_from_file(&config_path)?;
1891
1892 let cascade_config = crate::config::CascadeConfig {
1894 bitbucket: Some(settings.bitbucket.clone()),
1895 git: settings.git.clone(),
1896 auth: crate::config::AuthConfig::default(),
1897 cascade: settings.cascade.clone(),
1898 };
1899
1900 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1902
1903 let pr_state = if let Some(state_str) = state {
1905 match state_str.to_lowercase().as_str() {
1906 "open" => Some(crate::bitbucket::PullRequestState::Open),
1907 "merged" => Some(crate::bitbucket::PullRequestState::Merged),
1908 "declined" => Some(crate::bitbucket::PullRequestState::Declined),
1909 _ => {
1910 return Err(CascadeError::config(format!(
1911 "Invalid state '{state_str}'. Use: open, merged, declined"
1912 )))
1913 }
1914 }
1915 } else {
1916 None
1917 };
1918
1919 match integration.list_pull_requests(pr_state).await {
1921 Ok(pr_page) => {
1922 if pr_page.values.is_empty() {
1923 Output::info("No pull requests found.");
1924 return Ok(());
1925 }
1926
1927 println!("📋 Pull Requests ({} total):", pr_page.values.len());
1928 for pr in &pr_page.values {
1929 let state_icon = match pr.state {
1930 crate::bitbucket::PullRequestState::Open => "🔄",
1931 crate::bitbucket::PullRequestState::Merged => "✅",
1932 crate::bitbucket::PullRequestState::Declined => "❌",
1933 };
1934 println!(" {} PR #{}: {}", state_icon, pr.id, pr.title);
1935 if verbose {
1936 println!(
1937 " From: {} -> {}",
1938 pr.from_ref.display_id, pr.to_ref.display_id
1939 );
1940 println!(
1941 " Author: {}",
1942 pr.author
1943 .user
1944 .display_name
1945 .as_deref()
1946 .unwrap_or(&pr.author.user.name)
1947 );
1948 if let Some(url) = pr.web_url() {
1949 println!(" URL: {url}");
1950 }
1951 if let Some(desc) = &pr.description {
1952 if !desc.is_empty() {
1953 println!(" Description: {desc}");
1954 }
1955 }
1956 println!();
1957 }
1958 }
1959
1960 if !verbose {
1961 println!("\nUse --verbose for more details");
1962 }
1963 }
1964 Err(e) => {
1965 warn!("Failed to list pull requests: {}", e);
1966 return Err(e);
1967 }
1968 }
1969
1970 Ok(())
1971}
1972
1973async fn check_stack(_force: bool) -> Result<()> {
1974 let current_dir = env::current_dir()
1975 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1976
1977 let repo_root = find_repository_root(¤t_dir)
1978 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1979
1980 let mut manager = StackManager::new(&repo_root)?;
1981
1982 let active_stack = manager
1983 .get_active_stack()
1984 .ok_or_else(|| CascadeError::config("No active stack"))?;
1985 let stack_id = active_stack.id;
1986
1987 manager.sync_stack(&stack_id)?;
1988
1989 Output::success("Stack check completed successfully");
1990
1991 Ok(())
1992}
1993
1994async fn sync_stack(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
1995 let current_dir = env::current_dir()
1996 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1997
1998 let repo_root = find_repository_root(¤t_dir)
1999 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2000
2001 let stack_manager = StackManager::new(&repo_root)?;
2002 let git_repo = GitRepository::open(&repo_root)?;
2003
2004 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2006 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2007 })?;
2008
2009 let base_branch = active_stack.base_branch.clone();
2010 let stack_name = active_stack.name.clone();
2011
2012 let original_branch = git_repo.get_current_branch().ok();
2014
2015 println!("Syncing stack '{stack_name}' with remote...");
2017
2018 match git_repo.checkout_branch(&base_branch) {
2020 Ok(_) => {
2021 match git_repo.pull(&base_branch) {
2022 Ok(_) => {
2023 }
2025 Err(e) => {
2026 if force {
2027 Output::warning(format!("Pull failed: {e} (continuing due to --force)"));
2028 } else {
2029 Output::error(format!("Failed to pull latest changes: {e}"));
2030 Output::tip("Use --force to skip pull and continue with rebase");
2031 return Err(CascadeError::branch(format!(
2032 "Failed to pull latest changes from '{base_branch}': {e}. Use --force to continue anyway."
2033 )));
2034 }
2035 }
2036 }
2037 }
2038 Err(e) => {
2039 if force {
2040 Output::warning(format!(
2041 "Failed to checkout '{base_branch}': {e} (continuing due to --force)"
2042 ));
2043 } else {
2044 Output::error(format!(
2045 "Failed to checkout base branch '{base_branch}': {e}"
2046 ));
2047 Output::tip("Use --force to bypass checkout issues and continue anyway");
2048 return Err(CascadeError::branch(format!(
2049 "Failed to checkout base branch '{base_branch}': {e}. Use --force to continue anyway."
2050 )));
2051 }
2052 }
2053 }
2054
2055 let mut updated_stack_manager = StackManager::new(&repo_root)?;
2057 let stack_id = active_stack.id;
2058
2059 match updated_stack_manager.sync_stack(&stack_id) {
2060 Ok(_) => {
2061 if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
2063 match &updated_stack.status {
2064 crate::stack::StackStatus::NeedsSync => {
2065 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2067 let config_path = config_dir.join("config.json");
2068 let settings = crate::config::Settings::load_from_file(&config_path)?;
2069
2070 let cascade_config = crate::config::CascadeConfig {
2071 bitbucket: Some(settings.bitbucket.clone()),
2072 git: settings.git.clone(),
2073 auth: crate::config::AuthConfig::default(),
2074 cascade: settings.cascade.clone(),
2075 };
2076
2077 let options = crate::stack::RebaseOptions {
2080 strategy: crate::stack::RebaseStrategy::ForcePush,
2081 interactive,
2082 target_base: Some(base_branch.clone()),
2083 preserve_merges: true,
2084 auto_resolve: !interactive,
2085 max_retries: 3,
2086 skip_pull: Some(true), };
2088
2089 let mut rebase_manager = crate::stack::RebaseManager::new(
2090 updated_stack_manager,
2091 git_repo,
2092 options,
2093 );
2094
2095 match rebase_manager.rebase_stack(&stack_id) {
2096 Ok(result) => {
2097 if !result.branch_mapping.is_empty() {
2098 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2100 let integration_stack_manager =
2101 StackManager::new(&repo_root)?;
2102 let mut integration =
2103 crate::bitbucket::BitbucketIntegration::new(
2104 integration_stack_manager,
2105 cascade_config,
2106 )?;
2107
2108 match integration
2109 .update_prs_after_rebase(
2110 &stack_id,
2111 &result.branch_mapping,
2112 )
2113 .await
2114 {
2115 Ok(updated_prs) => {
2116 if !updated_prs.is_empty() {
2117 println!(
2118 "Updated {} pull requests",
2119 updated_prs.len()
2120 );
2121 }
2122 }
2123 Err(e) => {
2124 Output::warning(format!(
2125 "Failed to update pull requests: {e}"
2126 ));
2127 }
2128 }
2129 }
2130 }
2131 }
2132 Err(e) => {
2133 Output::error(format!("Rebase failed: {e}"));
2134 Output::tip("To resolve conflicts:");
2135 Output::bullet("Fix conflicts in the affected files");
2136 Output::bullet("Stage resolved files: git add <files>");
2137 Output::bullet("Continue: ca stack continue-rebase");
2138 return Err(e);
2139 }
2140 }
2141 }
2142 crate::stack::StackStatus::Clean => {
2143 }
2145 other => {
2146 Output::info(format!("Stack status: {other:?}"));
2148 }
2149 }
2150 }
2151 }
2152 Err(e) => {
2153 if force {
2154 Output::warning(format!(
2155 "Failed to check stack status: {e} (continuing due to --force)"
2156 ));
2157 } else {
2158 return Err(e);
2159 }
2160 }
2161 }
2162
2163 if !skip_cleanup {
2165 let git_repo_for_cleanup = GitRepository::open(&repo_root)?;
2166 match perform_simple_cleanup(&stack_manager, &git_repo_for_cleanup, false).await {
2167 Ok(result) => {
2168 if result.total_candidates > 0 {
2169 Output::section("Cleanup Summary");
2170 if !result.cleaned_branches.is_empty() {
2171 Output::success(format!(
2172 "Cleaned up {} merged branches",
2173 result.cleaned_branches.len()
2174 ));
2175 for branch in &result.cleaned_branches {
2176 Output::sub_item(format!("🗑️ Deleted: {branch}"));
2177 }
2178 }
2179 if !result.skipped_branches.is_empty() {
2180 Output::sub_item(format!(
2181 "Skipped {} branches",
2182 result.skipped_branches.len()
2183 ));
2184 }
2185 if !result.failed_branches.is_empty() {
2186 for (branch, error) in &result.failed_branches {
2187 Output::warning(format!("Failed to clean up {branch}: {error}"));
2188 }
2189 }
2190 }
2191 }
2192 Err(e) => {
2193 Output::warning(format!("Branch cleanup failed: {e}"));
2194 }
2195 }
2196 }
2197
2198 if let Some(orig_branch) = original_branch {
2200 if orig_branch != base_branch {
2201 if let Ok(git_repo) = GitRepository::open(&repo_root) {
2203 if let Err(e) = git_repo.checkout_branch(&orig_branch) {
2204 Output::warning(format!(
2205 "Could not return to original branch '{}': {}",
2206 orig_branch, e
2207 ));
2208 }
2209 }
2210 }
2211 }
2212
2213 Output::success("Sync completed successfully!");
2214
2215 Ok(())
2216}
2217
2218async fn rebase_stack(
2219 interactive: bool,
2220 onto: Option<String>,
2221 strategy: Option<RebaseStrategyArg>,
2222) -> Result<()> {
2223 let current_dir = env::current_dir()
2224 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2225
2226 let repo_root = find_repository_root(¤t_dir)
2227 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2228
2229 let stack_manager = StackManager::new(&repo_root)?;
2230 let git_repo = GitRepository::open(&repo_root)?;
2231
2232 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2234 let config_path = config_dir.join("config.json");
2235 let settings = crate::config::Settings::load_from_file(&config_path)?;
2236
2237 let cascade_config = crate::config::CascadeConfig {
2239 bitbucket: Some(settings.bitbucket.clone()),
2240 git: settings.git.clone(),
2241 auth: crate::config::AuthConfig::default(),
2242 cascade: settings.cascade.clone(),
2243 };
2244
2245 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2247 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2248 })?;
2249 let stack_id = active_stack.id;
2250
2251 let active_stack = stack_manager
2252 .get_stack(&stack_id)
2253 .ok_or_else(|| CascadeError::config("Active stack not found"))?
2254 .clone();
2255
2256 if active_stack.entries.is_empty() {
2257 Output::info("Stack is empty. Nothing to rebase.");
2258 return Ok(());
2259 }
2260
2261 Output::progress(format!("Rebasing stack: {}", active_stack.name));
2262 Output::sub_item(format!("Base: {}", active_stack.base_branch));
2263
2264 let rebase_strategy = if let Some(cli_strategy) = strategy {
2266 match cli_strategy {
2267 RebaseStrategyArg::ForcePush => crate::stack::RebaseStrategy::ForcePush,
2268 RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
2269 }
2270 } else {
2271 crate::stack::RebaseStrategy::ForcePush
2273 };
2274
2275 let options = crate::stack::RebaseOptions {
2277 strategy: rebase_strategy.clone(),
2278 interactive,
2279 target_base: onto,
2280 preserve_merges: true,
2281 auto_resolve: !interactive, max_retries: 3,
2283 skip_pull: None, };
2285
2286 debug!(" Strategy: {:?}", rebase_strategy);
2287 debug!(" Interactive: {}", interactive);
2288 debug!(" Target base: {:?}", options.target_base);
2289 debug!(" Entries: {}", active_stack.entries.len());
2290
2291 let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2293
2294 if rebase_manager.is_rebase_in_progress() {
2295 Output::warning("Rebase already in progress!");
2296 Output::tip("Use 'git status' to check the current state");
2297 Output::next_steps(&[
2298 "Run 'ca stack continue-rebase' to continue",
2299 "Run 'ca stack abort-rebase' to abort",
2300 ]);
2301 return Ok(());
2302 }
2303
2304 match rebase_manager.rebase_stack(&stack_id) {
2306 Ok(result) => {
2307 Output::success("Rebase completed!");
2308 Output::sub_item(result.get_summary());
2309
2310 if result.has_conflicts() {
2311 Output::warning(format!(
2312 "{} conflicts were resolved",
2313 result.conflicts.len()
2314 ));
2315 for conflict in &result.conflicts {
2316 Output::bullet(&conflict[..8.min(conflict.len())]);
2317 }
2318 }
2319
2320 if !result.branch_mapping.is_empty() {
2321 Output::section("Branch mapping");
2322 for (old, new) in &result.branch_mapping {
2323 Output::bullet(format!("{old} -> {new}"));
2324 }
2325
2326 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2328 let integration_stack_manager = StackManager::new(&repo_root)?;
2330 let mut integration = BitbucketIntegration::new(
2331 integration_stack_manager,
2332 cascade_config.clone(),
2333 )?;
2334
2335 match integration
2336 .update_prs_after_rebase(&stack_id, &result.branch_mapping)
2337 .await
2338 {
2339 Ok(updated_prs) => {
2340 if !updated_prs.is_empty() {
2341 println!(" 🔄 Preserved pull request history:");
2342 for pr_update in updated_prs {
2343 println!(" ✅ {pr_update}");
2344 }
2345 }
2346 }
2347 Err(e) => {
2348 eprintln!(" ⚠️ Failed to update pull requests: {e}");
2349 eprintln!(" You may need to manually update PRs in Bitbucket");
2350 }
2351 }
2352 }
2353 }
2354
2355 println!(
2356 " ✅ {} commits successfully rebased",
2357 result.success_count()
2358 );
2359
2360 if matches!(rebase_strategy, crate::stack::RebaseStrategy::ForcePush) {
2362 println!("\n📝 Next steps:");
2363 if !result.branch_mapping.is_empty() {
2364 println!(" 1. ✅ Branches have been rebased and force-pushed");
2365 println!(" 2. ✅ Pull requests updated automatically (history preserved)");
2366 println!(" 3. 🔍 Review the updated PRs in Bitbucket");
2367 println!(" 4. 🧪 Test your changes");
2368 } else {
2369 println!(" 1. Review the rebased stack");
2370 println!(" 2. Test your changes");
2371 println!(" 3. Submit new pull requests with 'ca stack submit'");
2372 }
2373 }
2374 }
2375 Err(e) => {
2376 warn!("❌ Rebase failed: {}", e);
2377 println!("💡 Tips for resolving rebase issues:");
2378 println!(" - Check for uncommitted changes with 'git status'");
2379 println!(" - Ensure base branch is up to date");
2380 println!(" - Try interactive mode: 'ca stack rebase --interactive'");
2381 return Err(e);
2382 }
2383 }
2384
2385 Ok(())
2386}
2387
2388async fn continue_rebase() -> Result<()> {
2389 let current_dir = env::current_dir()
2390 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2391
2392 let repo_root = find_repository_root(¤t_dir)
2393 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2394
2395 let stack_manager = StackManager::new(&repo_root)?;
2396 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2397 let options = crate::stack::RebaseOptions::default();
2398 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2399
2400 if !rebase_manager.is_rebase_in_progress() {
2401 println!("ℹ️ No rebase in progress");
2402 return Ok(());
2403 }
2404
2405 println!("🔄 Continuing rebase...");
2406 match rebase_manager.continue_rebase() {
2407 Ok(_) => {
2408 println!("✅ Rebase continued successfully");
2409 println!(" Check 'ca stack rebase-status' for current state");
2410 }
2411 Err(e) => {
2412 warn!("❌ Failed to continue rebase: {}", e);
2413 println!("💡 You may need to resolve conflicts first:");
2414 println!(" 1. Edit conflicted files");
2415 println!(" 2. Stage resolved files with 'git add'");
2416 println!(" 3. Run 'ca stack continue-rebase' again");
2417 }
2418 }
2419
2420 Ok(())
2421}
2422
2423async fn abort_rebase() -> Result<()> {
2424 let current_dir = env::current_dir()
2425 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2426
2427 let repo_root = find_repository_root(¤t_dir)
2428 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2429
2430 let stack_manager = StackManager::new(&repo_root)?;
2431 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2432 let options = crate::stack::RebaseOptions::default();
2433 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2434
2435 if !rebase_manager.is_rebase_in_progress() {
2436 println!("ℹ️ No rebase in progress");
2437 return Ok(());
2438 }
2439
2440 println!("⚠️ Aborting rebase...");
2441 match rebase_manager.abort_rebase() {
2442 Ok(_) => {
2443 println!("✅ Rebase aborted successfully");
2444 println!(" Repository restored to pre-rebase state");
2445 }
2446 Err(e) => {
2447 warn!("❌ Failed to abort rebase: {}", e);
2448 println!("⚠️ You may need to manually clean up the repository state");
2449 }
2450 }
2451
2452 Ok(())
2453}
2454
2455async fn rebase_status() -> Result<()> {
2456 let current_dir = env::current_dir()
2457 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2458
2459 let repo_root = find_repository_root(¤t_dir)
2460 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2461
2462 let stack_manager = StackManager::new(&repo_root)?;
2463 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2464
2465 println!("📊 Rebase Status");
2466
2467 let git_dir = current_dir.join(".git");
2469 let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
2470 || git_dir.join("rebase-merge").exists()
2471 || git_dir.join("rebase-apply").exists();
2472
2473 if rebase_in_progress {
2474 println!(" Status: 🔄 Rebase in progress");
2475 println!(
2476 "
2477📝 Actions available:"
2478 );
2479 println!(" - 'ca stack continue-rebase' to continue");
2480 println!(" - 'ca stack abort-rebase' to abort");
2481 println!(" - 'git status' to see conflicted files");
2482
2483 match git_repo.get_status() {
2485 Ok(statuses) => {
2486 let mut conflicts = Vec::new();
2487 for status in statuses.iter() {
2488 if status.status().contains(git2::Status::CONFLICTED) {
2489 if let Some(path) = status.path() {
2490 conflicts.push(path.to_string());
2491 }
2492 }
2493 }
2494
2495 if !conflicts.is_empty() {
2496 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
2497 for conflict in conflicts {
2498 println!(" - {conflict}");
2499 }
2500 println!(
2501 "
2502💡 To resolve conflicts:"
2503 );
2504 println!(" 1. Edit the conflicted files");
2505 println!(" 2. Stage resolved files: git add <file>");
2506 println!(" 3. Continue: ca stack continue-rebase");
2507 }
2508 }
2509 Err(e) => {
2510 warn!("Failed to get git status: {}", e);
2511 }
2512 }
2513 } else {
2514 println!(" Status: ✅ No rebase in progress");
2515
2516 if let Some(active_stack) = stack_manager.get_active_stack() {
2518 println!(" Active stack: {}", active_stack.name);
2519 println!(" Entries: {}", active_stack.entries.len());
2520 println!(" Base branch: {}", active_stack.base_branch);
2521 }
2522 }
2523
2524 Ok(())
2525}
2526
2527async fn delete_stack(name: String, force: bool) -> Result<()> {
2528 let current_dir = env::current_dir()
2529 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2530
2531 let repo_root = find_repository_root(¤t_dir)
2532 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2533
2534 let mut manager = StackManager::new(&repo_root)?;
2535
2536 let stack = manager
2537 .get_stack_by_name(&name)
2538 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2539 let stack_id = stack.id;
2540
2541 if !force && !stack.entries.is_empty() {
2542 return Err(CascadeError::config(format!(
2543 "Stack '{}' has {} entries. Use --force to delete anyway",
2544 name,
2545 stack.entries.len()
2546 )));
2547 }
2548
2549 let deleted = manager.delete_stack(&stack_id)?;
2550
2551 Output::success(format!("Deleted stack '{}'", deleted.name));
2552 if !deleted.entries.is_empty() {
2553 Output::warning(format!("{} entries were removed", deleted.entries.len()));
2554 }
2555
2556 Ok(())
2557}
2558
2559async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
2560 let current_dir = env::current_dir()
2561 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2562
2563 let repo_root = find_repository_root(¤t_dir)
2564 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2565
2566 let mut manager = StackManager::new(&repo_root)?;
2567
2568 if let Some(name) = name {
2569 let stack = manager
2571 .get_stack_by_name(&name)
2572 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2573
2574 let stack_id = stack.id;
2575
2576 match stack.validate() {
2578 Ok(message) => {
2579 println!("✅ Stack '{name}' structure validation: {message}");
2580 }
2581 Err(e) => {
2582 println!("❌ Stack '{name}' structure validation failed: {e}");
2583 return Err(CascadeError::config(e));
2584 }
2585 }
2586
2587 manager.handle_branch_modifications(&stack_id, fix_mode)?;
2589
2590 println!("🎉 Stack '{name}' validation completed");
2591 Ok(())
2592 } else {
2593 println!("🔍 Validating all stacks...");
2595
2596 let all_stacks = manager.get_all_stacks();
2598 let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
2599
2600 if stack_ids.is_empty() {
2601 println!("📭 No stacks found");
2602 return Ok(());
2603 }
2604
2605 let mut all_valid = true;
2606 for stack_id in stack_ids {
2607 let stack = manager.get_stack(&stack_id).unwrap();
2608 let stack_name = &stack.name;
2609
2610 println!("\n📋 Checking stack '{stack_name}':");
2611
2612 match stack.validate() {
2614 Ok(message) => {
2615 println!(" ✅ Structure: {message}");
2616 }
2617 Err(e) => {
2618 println!(" ❌ Structure: {e}");
2619 all_valid = false;
2620 continue;
2621 }
2622 }
2623
2624 match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
2626 Ok(_) => {
2627 println!(" ✅ Git integrity: OK");
2628 }
2629 Err(e) => {
2630 println!(" ❌ Git integrity: {e}");
2631 all_valid = false;
2632 }
2633 }
2634 }
2635
2636 if all_valid {
2637 println!("\n🎉 All stacks passed validation");
2638 } else {
2639 println!("\n⚠️ Some stacks have validation issues");
2640 return Err(CascadeError::config("Stack validation failed".to_string()));
2641 }
2642
2643 Ok(())
2644 }
2645}
2646
2647#[allow(dead_code)]
2649fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
2650 let mut unpushed = Vec::new();
2651 let head_commit = repo.get_head_commit()?;
2652 let mut current_commit = head_commit;
2653
2654 loop {
2656 let commit_hash = current_commit.id().to_string();
2657 let already_in_stack = stack
2658 .entries
2659 .iter()
2660 .any(|entry| entry.commit_hash == commit_hash);
2661
2662 if already_in_stack {
2663 break;
2664 }
2665
2666 unpushed.push(commit_hash);
2667
2668 if let Some(parent) = current_commit.parents().next() {
2670 current_commit = parent;
2671 } else {
2672 break;
2673 }
2674 }
2675
2676 unpushed.reverse(); Ok(unpushed)
2678}
2679
2680pub async fn squash_commits(
2682 repo: &GitRepository,
2683 count: usize,
2684 since_ref: Option<String>,
2685) -> Result<()> {
2686 if count <= 1 {
2687 return Ok(()); }
2689
2690 let _current_branch = repo.get_current_branch()?;
2692
2693 let rebase_range = if let Some(ref since) = since_ref {
2695 since.clone()
2696 } else {
2697 format!("HEAD~{count}")
2698 };
2699
2700 println!(" Analyzing {count} commits to create smart squash message...");
2701
2702 let head_commit = repo.get_head_commit()?;
2704 let mut commits_to_squash = Vec::new();
2705 let mut current = head_commit;
2706
2707 for _ in 0..count {
2709 commits_to_squash.push(current.clone());
2710 if current.parent_count() > 0 {
2711 current = current.parent(0).map_err(CascadeError::Git)?;
2712 } else {
2713 break;
2714 }
2715 }
2716
2717 let smart_message = generate_squash_message(&commits_to_squash)?;
2719 println!(
2720 " Smart message: {}",
2721 smart_message.lines().next().unwrap_or("")
2722 );
2723
2724 let reset_target = if since_ref.is_some() {
2726 format!("{rebase_range}~1")
2728 } else {
2729 format!("HEAD~{count}")
2731 };
2732
2733 repo.reset_soft(&reset_target)?;
2735
2736 repo.stage_all()?;
2738
2739 let new_commit_hash = repo.commit(&smart_message)?;
2741
2742 println!(
2743 " Created squashed commit: {} ({})",
2744 &new_commit_hash[..8],
2745 smart_message.lines().next().unwrap_or("")
2746 );
2747 println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
2748
2749 Ok(())
2750}
2751
2752pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
2754 if commits.is_empty() {
2755 return Ok("Squashed commits".to_string());
2756 }
2757
2758 let messages: Vec<String> = commits
2760 .iter()
2761 .map(|c| c.message().unwrap_or("").trim().to_string())
2762 .filter(|m| !m.is_empty())
2763 .collect();
2764
2765 if messages.is_empty() {
2766 return Ok("Squashed commits".to_string());
2767 }
2768
2769 if let Some(last_msg) = messages.first() {
2771 if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
2773 return Ok(last_msg
2774 .trim_start_matches("Final:")
2775 .trim_start_matches("final:")
2776 .trim()
2777 .to_string());
2778 }
2779 }
2780
2781 let wip_count = messages
2783 .iter()
2784 .filter(|m| {
2785 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
2786 })
2787 .count();
2788
2789 if wip_count > messages.len() / 2 {
2790 let non_wip: Vec<&String> = messages
2792 .iter()
2793 .filter(|m| {
2794 !m.to_lowercase().starts_with("wip")
2795 && !m.to_lowercase().contains("work in progress")
2796 })
2797 .collect();
2798
2799 if let Some(best_msg) = non_wip.first() {
2800 return Ok(best_msg.to_string());
2801 }
2802
2803 let feature = extract_feature_from_wip(&messages);
2805 return Ok(feature);
2806 }
2807
2808 Ok(messages.first().unwrap().clone())
2810}
2811
2812pub fn extract_feature_from_wip(messages: &[String]) -> String {
2814 for msg in messages {
2816 if msg.to_lowercase().starts_with("wip:") {
2818 if let Some(rest) = msg
2819 .strip_prefix("WIP:")
2820 .or_else(|| msg.strip_prefix("wip:"))
2821 {
2822 let feature = rest.trim();
2823 if !feature.is_empty() && feature.len() > 3 {
2824 let mut chars: Vec<char> = feature.chars().collect();
2826 if let Some(first) = chars.first_mut() {
2827 *first = first.to_uppercase().next().unwrap_or(*first);
2828 }
2829 return chars.into_iter().collect();
2830 }
2831 }
2832 }
2833 }
2834
2835 if let Some(first) = messages.first() {
2837 let cleaned = first
2838 .trim_start_matches("WIP:")
2839 .trim_start_matches("wip:")
2840 .trim_start_matches("WIP")
2841 .trim_start_matches("wip")
2842 .trim();
2843
2844 if !cleaned.is_empty() {
2845 return format!("Implement {cleaned}");
2846 }
2847 }
2848
2849 format!("Squashed {} commits", messages.len())
2850}
2851
2852pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
2854 let head_commit = repo.get_head_commit()?;
2855 let since_commit = repo.get_commit(since_commit_hash)?;
2856
2857 let mut count = 0;
2858 let mut current = head_commit;
2859
2860 loop {
2862 if current.id() == since_commit.id() {
2863 break;
2864 }
2865
2866 count += 1;
2867
2868 if current.parent_count() == 0 {
2870 break; }
2872
2873 current = current.parent(0).map_err(CascadeError::Git)?;
2874 }
2875
2876 Ok(count)
2877}
2878
2879async fn land_stack(
2881 entry: Option<usize>,
2882 force: bool,
2883 dry_run: bool,
2884 auto: bool,
2885 wait_for_builds: bool,
2886 strategy: Option<MergeStrategyArg>,
2887 build_timeout: u64,
2888) -> Result<()> {
2889 let current_dir = env::current_dir()
2890 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2891
2892 let repo_root = find_repository_root(¤t_dir)
2893 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2894
2895 let stack_manager = StackManager::new(&repo_root)?;
2896
2897 let stack_id = stack_manager
2899 .get_active_stack()
2900 .map(|s| s.id)
2901 .ok_or_else(|| {
2902 CascadeError::config(
2903 "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
2904 .to_string(),
2905 )
2906 })?;
2907
2908 let active_stack = stack_manager
2909 .get_active_stack()
2910 .cloned()
2911 .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
2912
2913 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2915 let config_path = config_dir.join("config.json");
2916 let settings = crate::config::Settings::load_from_file(&config_path)?;
2917
2918 let cascade_config = crate::config::CascadeConfig {
2919 bitbucket: Some(settings.bitbucket.clone()),
2920 git: settings.git.clone(),
2921 auth: crate::config::AuthConfig::default(),
2922 cascade: settings.cascade.clone(),
2923 };
2924
2925 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2926
2927 let status = integration.check_enhanced_stack_status(&stack_id).await?;
2929
2930 if status.enhanced_statuses.is_empty() {
2931 println!("❌ No pull requests found to land");
2932 return Ok(());
2933 }
2934
2935 let ready_prs: Vec<_> = status
2937 .enhanced_statuses
2938 .iter()
2939 .filter(|pr_status| {
2940 if let Some(entry_num) = entry {
2942 if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
2944 if pr_status.pr.from_ref.display_id != stack_entry.branch {
2946 return false;
2947 }
2948 } else {
2949 return false; }
2951 }
2952
2953 if force {
2954 pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
2956 } else {
2957 pr_status.is_ready_to_land()
2958 }
2959 })
2960 .collect();
2961
2962 if ready_prs.is_empty() {
2963 if let Some(entry_num) = entry {
2964 println!("❌ Entry {entry_num} is not ready to land or doesn't exist");
2965 } else {
2966 println!("❌ No pull requests are ready to land");
2967 }
2968
2969 println!("\n🚫 Blocking Issues:");
2971 for pr_status in &status.enhanced_statuses {
2972 if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
2973 let blocking = pr_status.get_blocking_reasons();
2974 if !blocking.is_empty() {
2975 println!(" PR #{}: {}", pr_status.pr.id, blocking.join(", "));
2976 }
2977 }
2978 }
2979
2980 if !force {
2981 println!("\n💡 Use --force to land PRs with blocking issues (dangerous!)");
2982 }
2983 return Ok(());
2984 }
2985
2986 if dry_run {
2987 if let Some(entry_num) = entry {
2988 println!("🏃 Dry Run - Entry {entry_num} that would be landed:");
2989 } else {
2990 println!("🏃 Dry Run - PRs that would be landed:");
2991 }
2992 for pr_status in &ready_prs {
2993 println!(" ✅ PR #{}: {}", pr_status.pr.id, pr_status.pr.title);
2994 if !pr_status.is_ready_to_land() && force {
2995 let blocking = pr_status.get_blocking_reasons();
2996 println!(
2997 " ⚠️ Would force land despite: {}",
2998 blocking.join(", ")
2999 );
3000 }
3001 }
3002 return Ok(());
3003 }
3004
3005 if entry.is_some() && ready_prs.len() > 1 {
3008 println!(
3009 "🎯 {} PRs are ready to land, but landing only entry #{}",
3010 ready_prs.len(),
3011 entry.unwrap()
3012 );
3013 }
3014
3015 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
3017 strategy.unwrap_or(MergeStrategyArg::Squash).into();
3018 let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
3019 merge_strategy: merge_strategy.clone(),
3020 wait_for_builds,
3021 build_timeout: std::time::Duration::from_secs(build_timeout),
3022 allowed_authors: None, };
3024
3025 println!(
3027 "🚀 Landing {} PR{}...",
3028 ready_prs.len(),
3029 if ready_prs.len() == 1 { "" } else { "s" }
3030 );
3031
3032 let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
3033 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
3034 );
3035
3036 let mut landed_count = 0;
3038 let mut failed_count = 0;
3039 let total_ready_prs = ready_prs.len();
3040
3041 for pr_status in ready_prs {
3042 let pr_id = pr_status.pr.id;
3043
3044 print!("🚀 Landing PR #{}: {}", pr_id, pr_status.pr.title);
3045
3046 let land_result = if auto {
3047 pr_manager
3049 .auto_merge_if_ready(pr_id, &auto_merge_conditions)
3050 .await
3051 } else {
3052 pr_manager
3054 .merge_pull_request(pr_id, merge_strategy.clone())
3055 .await
3056 .map(
3057 |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
3058 pr: Box::new(pr),
3059 merge_strategy: merge_strategy.clone(),
3060 },
3061 )
3062 };
3063
3064 match land_result {
3065 Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
3066 println!(" ✅");
3067 landed_count += 1;
3068
3069 if landed_count < total_ready_prs {
3071 println!("🔄 Retargeting remaining PRs to latest base...");
3072
3073 let base_branch = active_stack.base_branch.clone();
3075 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3076
3077 println!(" 📥 Updating base branch: {base_branch}");
3078 match git_repo.pull(&base_branch) {
3079 Ok(_) => println!(" ✅ Base branch updated successfully"),
3080 Err(e) => {
3081 println!(" ⚠️ Warning: Failed to update base branch: {e}");
3082 println!(
3083 " 💡 You may want to manually run: git pull origin {base_branch}"
3084 );
3085 }
3086 }
3087
3088 let mut rebase_manager = crate::stack::RebaseManager::new(
3090 StackManager::new(&repo_root)?,
3091 git_repo,
3092 crate::stack::RebaseOptions {
3093 strategy: crate::stack::RebaseStrategy::ForcePush,
3094 target_base: Some(base_branch.clone()),
3095 ..Default::default()
3096 },
3097 );
3098
3099 match rebase_manager.rebase_stack(&stack_id) {
3100 Ok(rebase_result) => {
3101 if !rebase_result.branch_mapping.is_empty() {
3102 let retarget_config = crate::config::CascadeConfig {
3104 bitbucket: Some(settings.bitbucket.clone()),
3105 git: settings.git.clone(),
3106 auth: crate::config::AuthConfig::default(),
3107 cascade: settings.cascade.clone(),
3108 };
3109 let mut retarget_integration = BitbucketIntegration::new(
3110 StackManager::new(&repo_root)?,
3111 retarget_config,
3112 )?;
3113
3114 match retarget_integration
3115 .update_prs_after_rebase(
3116 &stack_id,
3117 &rebase_result.branch_mapping,
3118 )
3119 .await
3120 {
3121 Ok(updated_prs) => {
3122 if !updated_prs.is_empty() {
3123 println!(
3124 " ✅ Updated {} PRs with new targets",
3125 updated_prs.len()
3126 );
3127 }
3128 }
3129 Err(e) => {
3130 println!(" ⚠️ Failed to update remaining PRs: {e}");
3131 println!(
3132 " 💡 You may need to run: ca stack rebase --onto {base_branch}"
3133 );
3134 }
3135 }
3136 }
3137 }
3138 Err(e) => {
3139 println!(" ❌ Auto-retargeting conflicts detected!");
3141 println!(" 📝 To resolve conflicts and continue landing:");
3142 println!(" 1. Resolve conflicts in the affected files");
3143 println!(" 2. Stage resolved files: git add <files>");
3144 println!(" 3. Continue the process: ca stack continue-land");
3145 println!(" 4. Or abort the operation: ca stack abort-land");
3146 println!();
3147 println!(" 💡 Check current status: ca stack land-status");
3148 println!(" ⚠️ Error details: {e}");
3149
3150 break;
3152 }
3153 }
3154 }
3155 }
3156 Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
3157 println!(" ❌ Not ready: {}", blocking_reasons.join(", "));
3158 failed_count += 1;
3159 if !force {
3160 break;
3161 }
3162 }
3163 Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
3164 println!(" ❌ Failed: {error}");
3165 failed_count += 1;
3166 if !force {
3167 break;
3168 }
3169 }
3170 Err(e) => {
3171 println!(" ❌");
3172 eprintln!("Failed to land PR #{pr_id}: {e}");
3173 failed_count += 1;
3174
3175 if !force {
3176 break;
3177 }
3178 }
3179 }
3180 }
3181
3182 println!("\n🎯 Landing Summary:");
3184 println!(" ✅ Successfully landed: {landed_count}");
3185 if failed_count > 0 {
3186 println!(" ❌ Failed to land: {failed_count}");
3187 }
3188
3189 if landed_count > 0 {
3190 println!("✅ Landing operation completed!");
3191 } else {
3192 println!("❌ No PRs were successfully landed");
3193 }
3194
3195 Ok(())
3196}
3197
3198async fn auto_land_stack(
3200 force: bool,
3201 dry_run: bool,
3202 wait_for_builds: bool,
3203 strategy: Option<MergeStrategyArg>,
3204 build_timeout: u64,
3205) -> Result<()> {
3206 land_stack(
3208 None,
3209 force,
3210 dry_run,
3211 true, wait_for_builds,
3213 strategy,
3214 build_timeout,
3215 )
3216 .await
3217}
3218
3219async fn continue_land() -> Result<()> {
3220 let current_dir = env::current_dir()
3221 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3222
3223 let repo_root = find_repository_root(¤t_dir)
3224 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3225
3226 let stack_manager = StackManager::new(&repo_root)?;
3227 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3228 let options = crate::stack::RebaseOptions::default();
3229 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3230
3231 if !rebase_manager.is_rebase_in_progress() {
3232 println!("ℹ️ No rebase in progress");
3233 return Ok(());
3234 }
3235
3236 println!("🔄 Continuing land operation...");
3237 match rebase_manager.continue_rebase() {
3238 Ok(_) => {
3239 println!("✅ Land operation continued successfully");
3240 println!(" Check 'ca stack land-status' for current state");
3241 }
3242 Err(e) => {
3243 warn!("❌ Failed to continue land operation: {}", e);
3244 println!("💡 You may need to resolve conflicts first:");
3245 println!(" 1. Edit conflicted files");
3246 println!(" 2. Stage resolved files with 'git add'");
3247 println!(" 3. Run 'ca stack continue-land' again");
3248 }
3249 }
3250
3251 Ok(())
3252}
3253
3254async fn abort_land() -> Result<()> {
3255 let current_dir = env::current_dir()
3256 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3257
3258 let repo_root = find_repository_root(¤t_dir)
3259 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3260
3261 let stack_manager = StackManager::new(&repo_root)?;
3262 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3263 let options = crate::stack::RebaseOptions::default();
3264 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3265
3266 if !rebase_manager.is_rebase_in_progress() {
3267 println!("ℹ️ No rebase in progress");
3268 return Ok(());
3269 }
3270
3271 println!("⚠️ Aborting land operation...");
3272 match rebase_manager.abort_rebase() {
3273 Ok(_) => {
3274 println!("✅ Land operation aborted successfully");
3275 println!(" Repository restored to pre-land state");
3276 }
3277 Err(e) => {
3278 warn!("❌ Failed to abort land operation: {}", e);
3279 println!("⚠️ You may need to manually clean up the repository state");
3280 }
3281 }
3282
3283 Ok(())
3284}
3285
3286async fn land_status() -> Result<()> {
3287 let current_dir = env::current_dir()
3288 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3289
3290 let repo_root = find_repository_root(¤t_dir)
3291 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3292
3293 let stack_manager = StackManager::new(&repo_root)?;
3294 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3295
3296 println!("📊 Land Status");
3297
3298 let git_dir = repo_root.join(".git");
3300 let land_in_progress = git_dir.join("REBASE_HEAD").exists()
3301 || git_dir.join("rebase-merge").exists()
3302 || git_dir.join("rebase-apply").exists();
3303
3304 if land_in_progress {
3305 println!(" Status: 🔄 Land operation in progress");
3306 println!(
3307 "
3308📝 Actions available:"
3309 );
3310 println!(" - 'ca stack continue-land' to continue");
3311 println!(" - 'ca stack abort-land' to abort");
3312 println!(" - 'git status' to see conflicted files");
3313
3314 match git_repo.get_status() {
3316 Ok(statuses) => {
3317 let mut conflicts = Vec::new();
3318 for status in statuses.iter() {
3319 if status.status().contains(git2::Status::CONFLICTED) {
3320 if let Some(path) = status.path() {
3321 conflicts.push(path.to_string());
3322 }
3323 }
3324 }
3325
3326 if !conflicts.is_empty() {
3327 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
3328 for conflict in conflicts {
3329 println!(" - {conflict}");
3330 }
3331 println!(
3332 "
3333💡 To resolve conflicts:"
3334 );
3335 println!(" 1. Edit the conflicted files");
3336 println!(" 2. Stage resolved files: git add <file>");
3337 println!(" 3. Continue: ca stack continue-land");
3338 }
3339 }
3340 Err(e) => {
3341 warn!("Failed to get git status: {}", e);
3342 }
3343 }
3344 } else {
3345 println!(" Status: ✅ No land operation in progress");
3346
3347 if let Some(active_stack) = stack_manager.get_active_stack() {
3349 println!(" Active stack: {}", active_stack.name);
3350 println!(" Entries: {}", active_stack.entries.len());
3351 println!(" Base branch: {}", active_stack.base_branch);
3352 }
3353 }
3354
3355 Ok(())
3356}
3357
3358async fn repair_stack_data() -> Result<()> {
3359 let current_dir = env::current_dir()
3360 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3361
3362 let repo_root = find_repository_root(¤t_dir)
3363 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3364
3365 let mut stack_manager = StackManager::new(&repo_root)?;
3366
3367 println!("🔧 Repairing stack data consistency...");
3368
3369 stack_manager.repair_all_stacks()?;
3370
3371 println!("✅ Stack data consistency repaired successfully!");
3372 println!("💡 Run 'ca stack --mergeable' to see updated status");
3373
3374 Ok(())
3375}
3376
3377async fn cleanup_branches(
3379 dry_run: bool,
3380 force: bool,
3381 include_stale: bool,
3382 stale_days: u32,
3383 cleanup_remote: bool,
3384 include_non_stack: bool,
3385 verbose: bool,
3386) -> Result<()> {
3387 let current_dir = env::current_dir()
3388 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3389
3390 let repo_root = find_repository_root(¤t_dir)
3391 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3392
3393 let stack_manager = StackManager::new(&repo_root)?;
3394 let git_repo = GitRepository::open(&repo_root)?;
3395
3396 let result = perform_cleanup(
3397 &stack_manager,
3398 &git_repo,
3399 dry_run,
3400 force,
3401 include_stale,
3402 stale_days,
3403 cleanup_remote,
3404 include_non_stack,
3405 verbose,
3406 )
3407 .await?;
3408
3409 if result.total_candidates == 0 {
3411 Output::success("No branches found that need cleanup");
3412 return Ok(());
3413 }
3414
3415 Output::section("Cleanup Results");
3416
3417 if dry_run {
3418 Output::sub_item(format!(
3419 "Found {} branches that would be cleaned up",
3420 result.total_candidates
3421 ));
3422 } else {
3423 if !result.cleaned_branches.is_empty() {
3424 Output::success(format!(
3425 "Successfully cleaned up {} branches",
3426 result.cleaned_branches.len()
3427 ));
3428 for branch in &result.cleaned_branches {
3429 Output::sub_item(format!("🗑️ Deleted: {branch}"));
3430 }
3431 }
3432
3433 if !result.skipped_branches.is_empty() {
3434 Output::sub_item(format!(
3435 "Skipped {} branches",
3436 result.skipped_branches.len()
3437 ));
3438 if verbose {
3439 for (branch, reason) in &result.skipped_branches {
3440 Output::sub_item(format!("⏭️ {branch}: {reason}"));
3441 }
3442 }
3443 }
3444
3445 if !result.failed_branches.is_empty() {
3446 Output::warning(format!(
3447 "Failed to clean up {} branches",
3448 result.failed_branches.len()
3449 ));
3450 for (branch, error) in &result.failed_branches {
3451 Output::sub_item(format!("❌ {branch}: {error}"));
3452 }
3453 }
3454 }
3455
3456 Ok(())
3457}
3458
3459#[allow(clippy::too_many_arguments)]
3461async fn perform_cleanup(
3462 stack_manager: &StackManager,
3463 git_repo: &GitRepository,
3464 dry_run: bool,
3465 force: bool,
3466 include_stale: bool,
3467 stale_days: u32,
3468 cleanup_remote: bool,
3469 include_non_stack: bool,
3470 verbose: bool,
3471) -> Result<CleanupResult> {
3472 let options = CleanupOptions {
3473 dry_run,
3474 force,
3475 include_stale,
3476 cleanup_remote,
3477 stale_threshold_days: stale_days,
3478 cleanup_non_stack: include_non_stack,
3479 };
3480
3481 let stack_manager_copy = StackManager::new(stack_manager.repo_path())?;
3482 let git_repo_copy = GitRepository::open(git_repo.path())?;
3483 let mut cleanup_manager = CleanupManager::new(stack_manager_copy, git_repo_copy, options);
3484
3485 let candidates = cleanup_manager.find_cleanup_candidates()?;
3487
3488 if candidates.is_empty() {
3489 return Ok(CleanupResult {
3490 cleaned_branches: Vec::new(),
3491 failed_branches: Vec::new(),
3492 skipped_branches: Vec::new(),
3493 total_candidates: 0,
3494 });
3495 }
3496
3497 if verbose || dry_run {
3499 Output::section("Cleanup Candidates");
3500 for candidate in &candidates {
3501 let reason_icon = match candidate.reason {
3502 crate::stack::CleanupReason::FullyMerged => "🔀",
3503 crate::stack::CleanupReason::StackEntryMerged => "✅",
3504 crate::stack::CleanupReason::Stale => "⏰",
3505 crate::stack::CleanupReason::Orphaned => "👻",
3506 };
3507
3508 Output::sub_item(format!(
3509 "{} {} - {} ({})",
3510 reason_icon,
3511 candidate.branch_name,
3512 candidate.reason_to_string(),
3513 candidate.safety_info
3514 ));
3515 }
3516 }
3517
3518 if !force && !dry_run && !candidates.is_empty() {
3520 Output::warning(format!("About to delete {} branches", candidates.len()));
3521
3522 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
3524 .with_prompt("Continue with branch cleanup?")
3525 .default(false)
3526 .interact()
3527 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
3528
3529 if !should_continue {
3530 Output::sub_item("Cleanup cancelled");
3531 return Ok(CleanupResult {
3532 cleaned_branches: Vec::new(),
3533 failed_branches: Vec::new(),
3534 skipped_branches: Vec::new(),
3535 total_candidates: candidates.len(),
3536 });
3537 }
3538 }
3539
3540 cleanup_manager.perform_cleanup(&candidates)
3542}
3543
3544async fn perform_simple_cleanup(
3546 stack_manager: &StackManager,
3547 git_repo: &GitRepository,
3548 dry_run: bool,
3549) -> Result<CleanupResult> {
3550 perform_cleanup(
3551 stack_manager,
3552 git_repo,
3553 dry_run,
3554 false, false, 30, false, false, false, )
3561 .await
3562}
3563
3564async fn analyze_commits_for_safeguards(
3566 commits_to_push: &[String],
3567 repo: &GitRepository,
3568 dry_run: bool,
3569) -> Result<()> {
3570 const LARGE_COMMIT_THRESHOLD: usize = 10;
3571 const WEEK_IN_SECONDS: i64 = 7 * 24 * 3600;
3572
3573 if commits_to_push.len() > LARGE_COMMIT_THRESHOLD {
3575 println!(
3576 "⚠️ Warning: About to push {} commits to stack",
3577 commits_to_push.len()
3578 );
3579 println!(" This may indicate a merge commit issue or unexpected commit range.");
3580 println!(" Large commit counts often result from merging instead of rebasing.");
3581
3582 if !dry_run && !confirm_large_push(commits_to_push.len())? {
3583 return Err(CascadeError::config("Push cancelled by user"));
3584 }
3585 }
3586
3587 let commit_objects: Result<Vec<_>> = commits_to_push
3589 .iter()
3590 .map(|hash| repo.get_commit(hash))
3591 .collect();
3592 let commit_objects = commit_objects?;
3593
3594 let merge_commits: Vec<_> = commit_objects
3596 .iter()
3597 .filter(|c| c.parent_count() > 1)
3598 .collect();
3599
3600 if !merge_commits.is_empty() {
3601 println!(
3602 "⚠️ Warning: {} merge commits detected in push",
3603 merge_commits.len()
3604 );
3605 println!(" This often indicates you merged instead of rebased.");
3606 println!(" Consider using 'ca sync' to rebase on the base branch.");
3607 println!(" Merge commits in stacks can cause confusion and duplicate work.");
3608 }
3609
3610 if commit_objects.len() > 1 {
3612 let oldest_commit_time = commit_objects.first().unwrap().time().seconds();
3613 let newest_commit_time = commit_objects.last().unwrap().time().seconds();
3614 let time_span = newest_commit_time - oldest_commit_time;
3615
3616 if time_span > WEEK_IN_SECONDS {
3617 let days = time_span / (24 * 3600);
3618 println!("⚠️ Warning: Commits span {days} days");
3619 println!(" This may indicate merged history rather than new work.");
3620 println!(" Recent work should typically span hours or days, not weeks.");
3621 }
3622 }
3623
3624 if commits_to_push.len() > 5 {
3626 println!("💡 Tip: If you only want recent commits, use:");
3627 println!(
3628 " ca push --since HEAD~{} # pushes last {} commits",
3629 std::cmp::min(commits_to_push.len(), 5),
3630 std::cmp::min(commits_to_push.len(), 5)
3631 );
3632 println!(" ca push --commits <hash1>,<hash2> # pushes specific commits");
3633 println!(" ca push --dry-run # preview what would be pushed");
3634 }
3635
3636 if dry_run {
3638 println!("🔍 DRY RUN: Would push {} commits:", commits_to_push.len());
3639 for (i, (commit_hash, commit_obj)) in commits_to_push
3640 .iter()
3641 .zip(commit_objects.iter())
3642 .enumerate()
3643 {
3644 let summary = commit_obj.summary().unwrap_or("(no message)");
3645 let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 7)];
3646 println!(" {}: {} ({})", i + 1, summary, short_hash);
3647 }
3648 println!("💡 Run without --dry-run to actually push these commits.");
3649 }
3650
3651 Ok(())
3652}
3653
3654fn confirm_large_push(count: usize) -> Result<bool> {
3656 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
3658 .with_prompt(format!("Continue pushing {count} commits?"))
3659 .default(false)
3660 .interact()
3661 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
3662
3663 Ok(should_continue)
3664}
3665
3666#[cfg(test)]
3667mod tests {
3668 use super::*;
3669 use std::process::Command;
3670 use tempfile::TempDir;
3671
3672 fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
3673 let temp_dir = TempDir::new()
3674 .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
3675 let repo_path = temp_dir.path().to_path_buf();
3676
3677 let output = Command::new("git")
3679 .args(["init"])
3680 .current_dir(&repo_path)
3681 .output()
3682 .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
3683 if !output.status.success() {
3684 return Err(CascadeError::config("Git init failed".to_string()));
3685 }
3686
3687 let output = Command::new("git")
3688 .args(["config", "user.name", "Test User"])
3689 .current_dir(&repo_path)
3690 .output()
3691 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3692 if !output.status.success() {
3693 return Err(CascadeError::config(
3694 "Git config user.name failed".to_string(),
3695 ));
3696 }
3697
3698 let output = Command::new("git")
3699 .args(["config", "user.email", "test@example.com"])
3700 .current_dir(&repo_path)
3701 .output()
3702 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3703 if !output.status.success() {
3704 return Err(CascadeError::config(
3705 "Git config user.email failed".to_string(),
3706 ));
3707 }
3708
3709 std::fs::write(repo_path.join("README.md"), "# Test")
3711 .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
3712 let output = Command::new("git")
3713 .args(["add", "."])
3714 .current_dir(&repo_path)
3715 .output()
3716 .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
3717 if !output.status.success() {
3718 return Err(CascadeError::config("Git add failed".to_string()));
3719 }
3720
3721 let output = Command::new("git")
3722 .args(["commit", "-m", "Initial commit"])
3723 .current_dir(&repo_path)
3724 .output()
3725 .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
3726 if !output.status.success() {
3727 return Err(CascadeError::config("Git commit failed".to_string()));
3728 }
3729
3730 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
3732
3733 Ok((temp_dir, repo_path))
3734 }
3735
3736 #[tokio::test]
3737 async fn test_create_stack() {
3738 let (temp_dir, repo_path) = match create_test_repo() {
3739 Ok(repo) => repo,
3740 Err(_) => {
3741 println!("Skipping test due to git environment setup failure");
3742 return;
3743 }
3744 };
3745 let _ = &temp_dir;
3747
3748 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3752 match env::set_current_dir(&repo_path) {
3753 Ok(_) => {
3754 let result = create_stack(
3755 "test-stack".to_string(),
3756 None, Some("Test description".to_string()),
3758 )
3759 .await;
3760
3761 if let Ok(orig) = original_dir {
3763 let _ = env::set_current_dir(orig);
3764 }
3765
3766 assert!(
3767 result.is_ok(),
3768 "Stack creation should succeed in initialized repository"
3769 );
3770 }
3771 Err(_) => {
3772 println!("Skipping test due to directory access restrictions");
3774 }
3775 }
3776 }
3777
3778 #[tokio::test]
3779 async fn test_list_empty_stacks() {
3780 let (temp_dir, repo_path) = match create_test_repo() {
3781 Ok(repo) => repo,
3782 Err(_) => {
3783 println!("Skipping test due to git environment setup failure");
3784 return;
3785 }
3786 };
3787 let _ = &temp_dir;
3789
3790 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3794 match env::set_current_dir(&repo_path) {
3795 Ok(_) => {
3796 let result = list_stacks(false, false, None).await;
3797
3798 if let Ok(orig) = original_dir {
3800 let _ = env::set_current_dir(orig);
3801 }
3802
3803 assert!(
3804 result.is_ok(),
3805 "Listing stacks should succeed in initialized repository"
3806 );
3807 }
3808 Err(_) => {
3809 println!("Skipping test due to directory access restrictions");
3811 }
3812 }
3813 }
3814
3815 #[test]
3818 fn test_extract_feature_from_wip_basic() {
3819 let messages = vec![
3820 "WIP: add authentication".to_string(),
3821 "WIP: implement login flow".to_string(),
3822 ];
3823
3824 let result = extract_feature_from_wip(&messages);
3825 assert_eq!(result, "Add authentication");
3826 }
3827
3828 #[test]
3829 fn test_extract_feature_from_wip_capitalize() {
3830 let messages = vec!["WIP: fix user validation bug".to_string()];
3831
3832 let result = extract_feature_from_wip(&messages);
3833 assert_eq!(result, "Fix user validation bug");
3834 }
3835
3836 #[test]
3837 fn test_extract_feature_from_wip_fallback() {
3838 let messages = vec![
3839 "WIP user interface changes".to_string(),
3840 "wip: css styling".to_string(),
3841 ];
3842
3843 let result = extract_feature_from_wip(&messages);
3844 assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
3846 }
3847
3848 #[test]
3849 fn test_extract_feature_from_wip_empty() {
3850 let messages = vec![];
3851
3852 let result = extract_feature_from_wip(&messages);
3853 assert_eq!(result, "Squashed 0 commits");
3854 }
3855
3856 #[test]
3857 fn test_extract_feature_from_wip_short_message() {
3858 let messages = vec!["WIP: x".to_string()]; let result = extract_feature_from_wip(&messages);
3861 assert!(result.starts_with("Implement") || result.contains("Squashed"));
3862 }
3863
3864 #[test]
3867 fn test_squash_message_final_strategy() {
3868 let messages = [
3872 "Final: implement user authentication system".to_string(),
3873 "WIP: add tests".to_string(),
3874 "WIP: fix validation".to_string(),
3875 ];
3876
3877 assert!(messages[0].starts_with("Final:"));
3879
3880 let extracted = messages[0].trim_start_matches("Final:").trim();
3882 assert_eq!(extracted, "implement user authentication system");
3883 }
3884
3885 #[test]
3886 fn test_squash_message_wip_detection() {
3887 let messages = [
3888 "WIP: start feature".to_string(),
3889 "WIP: continue work".to_string(),
3890 "WIP: almost done".to_string(),
3891 "Regular commit message".to_string(),
3892 ];
3893
3894 let wip_count = messages
3895 .iter()
3896 .filter(|m| {
3897 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3898 })
3899 .count();
3900
3901 assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2); let non_wip: Vec<&String> = messages
3906 .iter()
3907 .filter(|m| {
3908 !m.to_lowercase().starts_with("wip")
3909 && !m.to_lowercase().contains("work in progress")
3910 })
3911 .collect();
3912
3913 assert_eq!(non_wip.len(), 1);
3914 assert_eq!(non_wip[0], "Regular commit message");
3915 }
3916
3917 #[test]
3918 fn test_squash_message_all_wip() {
3919 let messages = vec![
3920 "WIP: add feature A".to_string(),
3921 "WIP: add feature B".to_string(),
3922 "WIP: finish implementation".to_string(),
3923 ];
3924
3925 let result = extract_feature_from_wip(&messages);
3926 assert_eq!(result, "Add feature A");
3928 }
3929
3930 #[test]
3931 fn test_squash_message_edge_cases() {
3932 let empty_messages: Vec<String> = vec![];
3934 let result = extract_feature_from_wip(&empty_messages);
3935 assert_eq!(result, "Squashed 0 commits");
3936
3937 let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
3939 let result = extract_feature_from_wip(&whitespace_messages);
3940 assert!(result.contains("Squashed") || result.contains("Implement"));
3941
3942 let mixed_case = vec!["wip: Add Feature".to_string()];
3944 let result = extract_feature_from_wip(&mixed_case);
3945 assert_eq!(result, "Add Feature");
3946 }
3947
3948 #[tokio::test]
3951 async fn test_auto_land_wrapper() {
3952 let (temp_dir, repo_path) = match create_test_repo() {
3954 Ok(repo) => repo,
3955 Err(_) => {
3956 println!("Skipping test due to git environment setup failure");
3957 return;
3958 }
3959 };
3960 let _ = &temp_dir;
3962
3963 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
3965 .expect("Failed to initialize Cascade in test repo");
3966
3967 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3968 match env::set_current_dir(&repo_path) {
3969 Ok(_) => {
3970 let result = create_stack(
3972 "test-stack".to_string(),
3973 None,
3974 Some("Test stack for auto-land".to_string()),
3975 )
3976 .await;
3977
3978 if let Ok(orig) = original_dir {
3979 let _ = env::set_current_dir(orig);
3980 }
3981
3982 assert!(
3985 result.is_ok(),
3986 "Stack creation should succeed in initialized repository"
3987 );
3988 }
3989 Err(_) => {
3990 println!("Skipping test due to directory access restrictions");
3991 }
3992 }
3993 }
3994
3995 #[test]
3996 fn test_auto_land_action_enum() {
3997 use crate::cli::commands::stack::StackAction;
3999
4000 let _action = StackAction::AutoLand {
4002 force: false,
4003 dry_run: true,
4004 wait_for_builds: true,
4005 strategy: Some(MergeStrategyArg::Squash),
4006 build_timeout: 1800,
4007 };
4008
4009 }
4011
4012 #[test]
4013 fn test_merge_strategy_conversion() {
4014 let squash_strategy = MergeStrategyArg::Squash;
4016 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
4017
4018 match merge_strategy {
4019 crate::bitbucket::pull_request::MergeStrategy::Squash => {
4020 }
4022 _ => unreachable!("SquashStrategyArg only has Squash variant"),
4023 }
4024
4025 let merge_strategy = MergeStrategyArg::Merge;
4026 let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
4027
4028 match converted {
4029 crate::bitbucket::pull_request::MergeStrategy::Merge => {
4030 }
4032 _ => unreachable!("MergeStrategyArg::Merge maps to MergeStrategy::Merge"),
4033 }
4034 }
4035
4036 #[test]
4037 fn test_auto_merge_conditions_structure() {
4038 use std::time::Duration;
4040
4041 let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
4042 merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
4043 wait_for_builds: true,
4044 build_timeout: Duration::from_secs(1800),
4045 allowed_authors: None,
4046 };
4047
4048 assert!(conditions.wait_for_builds);
4050 assert_eq!(conditions.build_timeout.as_secs(), 1800);
4051 assert!(conditions.allowed_authors.is_none());
4052 assert!(matches!(
4053 conditions.merge_strategy,
4054 crate::bitbucket::pull_request::MergeStrategy::Squash
4055 ));
4056 }
4057
4058 #[test]
4059 fn test_polling_constants() {
4060 use std::time::Duration;
4062
4063 let expected_polling_interval = Duration::from_secs(30);
4065
4066 assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
4071
4072 #[test]
4073 fn test_build_timeout_defaults() {
4074 const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
4077 let timeout_value = 1800u64;
4079 assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
4082
4083 #[test]
4084 fn test_scattered_commit_detection() {
4085 use std::collections::HashSet;
4086
4087 let mut source_branches = HashSet::new();
4089 source_branches.insert("feature-branch-1".to_string());
4090 source_branches.insert("feature-branch-2".to_string());
4091 source_branches.insert("feature-branch-3".to_string());
4092
4093 let single_branch = HashSet::from(["main".to_string()]);
4095 assert_eq!(single_branch.len(), 1);
4096
4097 assert!(source_branches.len() > 1);
4099 assert_eq!(source_branches.len(), 3);
4100
4101 assert!(source_branches.contains("feature-branch-1"));
4103 assert!(source_branches.contains("feature-branch-2"));
4104 assert!(source_branches.contains("feature-branch-3"));
4105 }
4106
4107 #[test]
4108 fn test_source_branch_tracking() {
4109 let branch_a = "feature-work";
4113 let branch_b = "feature-work";
4114 assert_eq!(branch_a, branch_b);
4115
4116 let branch_1 = "feature-ui";
4118 let branch_2 = "feature-api";
4119 assert_ne!(branch_1, branch_2);
4120
4121 assert!(branch_1.starts_with("feature-"));
4123 assert!(branch_2.starts_with("feature-"));
4124 }
4125
4126 #[tokio::test]
4129 async fn test_push_default_behavior() {
4130 let (temp_dir, repo_path) = match create_test_repo() {
4132 Ok(repo) => repo,
4133 Err(_) => {
4134 println!("Skipping test due to git environment setup failure");
4135 return;
4136 }
4137 };
4138 let _ = &temp_dir;
4140
4141 if !repo_path.exists() {
4143 println!("Skipping test due to temporary directory creation issue");
4144 return;
4145 }
4146
4147 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
4149
4150 match env::set_current_dir(&repo_path) {
4151 Ok(_) => {
4152 let result = push_to_stack(
4154 None, None, None, None, None, None, None, false, false, false, )
4165 .await;
4166
4167 if let Ok(orig) = original_dir {
4169 let _ = env::set_current_dir(orig);
4170 }
4171
4172 match &result {
4174 Err(e) => {
4175 let error_msg = e.to_string();
4176 assert!(
4178 error_msg.contains("No active stack")
4179 || error_msg.contains("config")
4180 || error_msg.contains("current directory")
4181 || error_msg.contains("Not a git repository")
4182 || error_msg.contains("could not find repository"),
4183 "Expected 'No active stack' or repository error, got: {error_msg}"
4184 );
4185 }
4186 Ok(_) => {
4187 println!(
4189 "Push succeeded unexpectedly - test environment may have active stack"
4190 );
4191 }
4192 }
4193 }
4194 Err(_) => {
4195 println!("Skipping test due to directory access restrictions");
4197 }
4198 }
4199
4200 let push_action = StackAction::Push {
4202 branch: None,
4203 message: None,
4204 commit: None,
4205 since: None,
4206 commits: None,
4207 squash: None,
4208 squash_since: None,
4209 auto_branch: false,
4210 allow_base_branch: false,
4211 dry_run: false,
4212 };
4213
4214 assert!(matches!(
4215 push_action,
4216 StackAction::Push {
4217 branch: None,
4218 message: None,
4219 commit: None,
4220 since: None,
4221 commits: None,
4222 squash: None,
4223 squash_since: None,
4224 auto_branch: false,
4225 allow_base_branch: false,
4226 dry_run: false
4227 }
4228 ));
4229 }
4230
4231 #[tokio::test]
4232 async fn test_submit_default_behavior() {
4233 let (temp_dir, repo_path) = match create_test_repo() {
4235 Ok(repo) => repo,
4236 Err(_) => {
4237 println!("Skipping test due to git environment setup failure");
4238 return;
4239 }
4240 };
4241 let _ = &temp_dir;
4243
4244 if !repo_path.exists() {
4246 println!("Skipping test due to temporary directory creation issue");
4247 return;
4248 }
4249
4250 let original_dir = match env::current_dir() {
4252 Ok(dir) => dir,
4253 Err(_) => {
4254 println!("Skipping test due to current directory access restrictions");
4255 return;
4256 }
4257 };
4258
4259 match env::set_current_dir(&repo_path) {
4260 Ok(_) => {
4261 let result = submit_entry(
4263 None, None, None, None, false, )
4269 .await;
4270
4271 let _ = env::set_current_dir(original_dir);
4273
4274 match &result {
4276 Err(e) => {
4277 let error_msg = e.to_string();
4278 assert!(
4280 error_msg.contains("No active stack")
4281 || error_msg.contains("config")
4282 || error_msg.contains("current directory")
4283 || error_msg.contains("Not a git repository")
4284 || error_msg.contains("could not find repository"),
4285 "Expected 'No active stack' or repository error, got: {error_msg}"
4286 );
4287 }
4288 Ok(_) => {
4289 println!("Submit succeeded unexpectedly - test environment may have active stack");
4291 }
4292 }
4293 }
4294 Err(_) => {
4295 println!("Skipping test due to directory access restrictions");
4297 }
4298 }
4299
4300 let submit_action = StackAction::Submit {
4302 entry: None,
4303 title: None,
4304 description: None,
4305 range: None,
4306 draft: false,
4307 };
4308
4309 assert!(matches!(
4310 submit_action,
4311 StackAction::Submit {
4312 entry: None,
4313 title: None,
4314 description: None,
4315 range: None,
4316 draft: false
4317 }
4318 ));
4319 }
4320
4321 #[test]
4322 fn test_targeting_options_still_work() {
4323 let commits = "abc123,def456,ghi789";
4327 let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
4328 assert_eq!(parsed.len(), 3);
4329 assert_eq!(parsed[0], "abc123");
4330 assert_eq!(parsed[1], "def456");
4331 assert_eq!(parsed[2], "ghi789");
4332
4333 let range = "1-3";
4335 assert!(range.contains('-'));
4336 let parts: Vec<&str> = range.split('-').collect();
4337 assert_eq!(parts.len(), 2);
4338
4339 let since_ref = "HEAD~3";
4341 assert!(since_ref.starts_with("HEAD"));
4342 assert!(since_ref.contains('~'));
4343 }
4344
4345 #[test]
4346 fn test_command_flow_logic() {
4347 assert!(matches!(
4349 StackAction::Push {
4350 branch: None,
4351 message: None,
4352 commit: None,
4353 since: None,
4354 commits: None,
4355 squash: None,
4356 squash_since: None,
4357 auto_branch: false,
4358 allow_base_branch: false,
4359 dry_run: false
4360 },
4361 StackAction::Push { .. }
4362 ));
4363
4364 assert!(matches!(
4365 StackAction::Submit {
4366 entry: None,
4367 title: None,
4368 description: None,
4369 range: None,
4370 draft: false
4371 },
4372 StackAction::Submit { .. }
4373 ));
4374 }
4375
4376 #[tokio::test]
4377 async fn test_deactivate_command_structure() {
4378 let deactivate_action = StackAction::Deactivate { force: false };
4380
4381 assert!(matches!(
4383 deactivate_action,
4384 StackAction::Deactivate { force: false }
4385 ));
4386
4387 let force_deactivate = StackAction::Deactivate { force: true };
4389 assert!(matches!(
4390 force_deactivate,
4391 StackAction::Deactivate { force: true }
4392 ));
4393 }
4394}