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