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), };
2135
2136 let mut rebase_manager = crate::stack::RebaseManager::new(
2137 updated_stack_manager,
2138 git_repo,
2139 options,
2140 );
2141
2142 match rebase_manager.rebase_stack(&stack_id) {
2143 Ok(result) => {
2144 if !result.branch_mapping.is_empty() {
2145 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2147 let integration_stack_manager =
2148 StackManager::new(&repo_root)?;
2149 let mut integration =
2150 crate::bitbucket::BitbucketIntegration::new(
2151 integration_stack_manager,
2152 cascade_config,
2153 )?;
2154
2155 match integration
2156 .update_prs_after_rebase(
2157 &stack_id,
2158 &result.branch_mapping,
2159 )
2160 .await
2161 {
2162 Ok(updated_prs) => {
2163 if !updated_prs.is_empty() {
2164 println!(
2165 "Updated {} pull requests",
2166 updated_prs.len()
2167 );
2168 }
2169 }
2170 Err(e) => {
2171 Output::warning(format!(
2172 "Failed to update pull requests: {e}"
2173 ));
2174 }
2175 }
2176 }
2177 }
2178 }
2179 Err(e) => {
2180 Output::error(format!("Rebase failed: {e}"));
2181 Output::tip("To resolve conflicts:");
2182 Output::bullet("Fix conflicts in the affected files");
2183 Output::bullet("Stage resolved files: git add <files>");
2184 Output::bullet("Continue: ca stack continue-rebase");
2185 return Err(e);
2186 }
2187 }
2188 }
2189 crate::stack::StackStatus::Clean => {
2190 }
2192 other => {
2193 Output::info(format!("Stack status: {other:?}"));
2195 }
2196 }
2197 }
2198 }
2199 Err(e) => {
2200 if force {
2201 Output::warning(format!(
2202 "Failed to check stack status: {e} (continuing due to --force)"
2203 ));
2204 } else {
2205 return Err(e);
2206 }
2207 }
2208 }
2209
2210 if cleanup {
2212 let git_repo_for_cleanup = GitRepository::open(&repo_root)?;
2213 match perform_simple_cleanup(&stack_manager, &git_repo_for_cleanup, false).await {
2214 Ok(result) => {
2215 if result.total_candidates > 0 {
2216 Output::section("Cleanup Summary");
2217 if !result.cleaned_branches.is_empty() {
2218 Output::success(format!(
2219 "Cleaned up {} merged branches",
2220 result.cleaned_branches.len()
2221 ));
2222 for branch in &result.cleaned_branches {
2223 Output::sub_item(format!("🗑️ Deleted: {branch}"));
2224 }
2225 }
2226 if !result.skipped_branches.is_empty() {
2227 Output::sub_item(format!(
2228 "Skipped {} branches",
2229 result.skipped_branches.len()
2230 ));
2231 }
2232 if !result.failed_branches.is_empty() {
2233 for (branch, error) in &result.failed_branches {
2234 Output::warning(format!("Failed to clean up {branch}: {error}"));
2235 }
2236 }
2237 }
2238 }
2239 Err(e) => {
2240 Output::warning(format!("Branch cleanup failed: {e}"));
2241 }
2242 }
2243 }
2244
2245 if let Some(orig_branch) = original_branch {
2247 if orig_branch != base_branch {
2248 if let Ok(git_repo) = GitRepository::open(&repo_root) {
2250 if let Err(e) = git_repo.checkout_branch(&orig_branch) {
2251 Output::warning(format!(
2252 "Could not return to original branch '{}': {}",
2253 orig_branch, e
2254 ));
2255 }
2256 }
2257 }
2258 }
2259
2260 Output::success("Sync completed successfully!");
2261
2262 Ok(())
2263}
2264
2265async fn rebase_stack(
2266 interactive: bool,
2267 onto: Option<String>,
2268 strategy: Option<RebaseStrategyArg>,
2269) -> Result<()> {
2270 let current_dir = env::current_dir()
2271 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2272
2273 let repo_root = find_repository_root(¤t_dir)
2274 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2275
2276 let stack_manager = StackManager::new(&repo_root)?;
2277 let git_repo = GitRepository::open(&repo_root)?;
2278
2279 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2281 let config_path = config_dir.join("config.json");
2282 let settings = crate::config::Settings::load_from_file(&config_path)?;
2283
2284 let cascade_config = crate::config::CascadeConfig {
2286 bitbucket: Some(settings.bitbucket.clone()),
2287 git: settings.git.clone(),
2288 auth: crate::config::AuthConfig::default(),
2289 cascade: settings.cascade.clone(),
2290 };
2291
2292 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2294 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2295 })?;
2296 let stack_id = active_stack.id;
2297
2298 let active_stack = stack_manager
2299 .get_stack(&stack_id)
2300 .ok_or_else(|| CascadeError::config("Active stack not found"))?
2301 .clone();
2302
2303 if active_stack.entries.is_empty() {
2304 Output::info("Stack is empty. Nothing to rebase.");
2305 return Ok(());
2306 }
2307
2308 Output::progress(format!("Rebasing stack: {}", active_stack.name));
2309 Output::sub_item(format!("Base: {}", active_stack.base_branch));
2310
2311 let rebase_strategy = if let Some(cli_strategy) = strategy {
2313 match cli_strategy {
2314 RebaseStrategyArg::ForcePush => crate::stack::RebaseStrategy::ForcePush,
2315 RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
2316 }
2317 } else {
2318 crate::stack::RebaseStrategy::ForcePush
2320 };
2321
2322 let options = crate::stack::RebaseOptions {
2324 strategy: rebase_strategy.clone(),
2325 interactive,
2326 target_base: onto,
2327 preserve_merges: true,
2328 auto_resolve: !interactive, max_retries: 3,
2330 skip_pull: None, };
2332
2333 debug!(" Strategy: {:?}", rebase_strategy);
2334 debug!(" Interactive: {}", interactive);
2335 debug!(" Target base: {:?}", options.target_base);
2336 debug!(" Entries: {}", active_stack.entries.len());
2337
2338 let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2340
2341 if rebase_manager.is_rebase_in_progress() {
2342 Output::warning("Rebase already in progress!");
2343 Output::tip("Use 'git status' to check the current state");
2344 Output::next_steps(&[
2345 "Run 'ca stack continue-rebase' to continue",
2346 "Run 'ca stack abort-rebase' to abort",
2347 ]);
2348 return Ok(());
2349 }
2350
2351 match rebase_manager.rebase_stack(&stack_id) {
2353 Ok(result) => {
2354 Output::success("Rebase completed!");
2355 Output::sub_item(result.get_summary());
2356
2357 if result.has_conflicts() {
2358 Output::warning(format!(
2359 "{} conflicts were resolved",
2360 result.conflicts.len()
2361 ));
2362 for conflict in &result.conflicts {
2363 Output::bullet(&conflict[..8.min(conflict.len())]);
2364 }
2365 }
2366
2367 if !result.branch_mapping.is_empty() {
2368 Output::section("Branch mapping");
2369 for (old, new) in &result.branch_mapping {
2370 Output::bullet(format!("{old} -> {new}"));
2371 }
2372
2373 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2375 let integration_stack_manager = StackManager::new(&repo_root)?;
2377 let mut integration = BitbucketIntegration::new(
2378 integration_stack_manager,
2379 cascade_config.clone(),
2380 )?;
2381
2382 match integration
2383 .update_prs_after_rebase(&stack_id, &result.branch_mapping)
2384 .await
2385 {
2386 Ok(updated_prs) => {
2387 if !updated_prs.is_empty() {
2388 println!(" 🔄 Preserved pull request history:");
2389 for pr_update in updated_prs {
2390 println!(" ✅ {pr_update}");
2391 }
2392 }
2393 }
2394 Err(e) => {
2395 eprintln!(" ⚠️ Failed to update pull requests: {e}");
2396 eprintln!(" You may need to manually update PRs in Bitbucket");
2397 }
2398 }
2399 }
2400 }
2401
2402 println!(
2403 " ✅ {} commits successfully rebased",
2404 result.success_count()
2405 );
2406
2407 if matches!(rebase_strategy, crate::stack::RebaseStrategy::ForcePush) {
2409 println!("\n📝 Next steps:");
2410 if !result.branch_mapping.is_empty() {
2411 println!(" 1. ✅ Branches have been rebased and force-pushed");
2412 println!(" 2. ✅ Pull requests updated automatically (history preserved)");
2413 println!(" 3. 🔍 Review the updated PRs in Bitbucket");
2414 println!(" 4. 🧪 Test your changes");
2415 } else {
2416 println!(" 1. Review the rebased stack");
2417 println!(" 2. Test your changes");
2418 println!(" 3. Submit new pull requests with 'ca stack submit'");
2419 }
2420 }
2421 }
2422 Err(e) => {
2423 warn!("❌ Rebase failed: {}", e);
2424 println!("💡 Tips for resolving rebase issues:");
2425 println!(" - Check for uncommitted changes with 'git status'");
2426 println!(" - Ensure base branch is up to date");
2427 println!(" - Try interactive mode: 'ca stack rebase --interactive'");
2428 return Err(e);
2429 }
2430 }
2431
2432 Ok(())
2433}
2434
2435async fn continue_rebase() -> Result<()> {
2436 let current_dir = env::current_dir()
2437 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2438
2439 let repo_root = find_repository_root(¤t_dir)
2440 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2441
2442 let stack_manager = StackManager::new(&repo_root)?;
2443 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2444 let options = crate::stack::RebaseOptions::default();
2445 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2446
2447 if !rebase_manager.is_rebase_in_progress() {
2448 println!("ℹ️ No rebase in progress");
2449 return Ok(());
2450 }
2451
2452 println!("🔄 Continuing rebase...");
2453 match rebase_manager.continue_rebase() {
2454 Ok(_) => {
2455 println!("✅ Rebase continued successfully");
2456 println!(" Check 'ca stack rebase-status' for current state");
2457 }
2458 Err(e) => {
2459 warn!("❌ Failed to continue rebase: {}", e);
2460 println!("💡 You may need to resolve conflicts first:");
2461 println!(" 1. Edit conflicted files");
2462 println!(" 2. Stage resolved files with 'git add'");
2463 println!(" 3. Run 'ca stack continue-rebase' again");
2464 }
2465 }
2466
2467 Ok(())
2468}
2469
2470async fn abort_rebase() -> Result<()> {
2471 let current_dir = env::current_dir()
2472 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2473
2474 let repo_root = find_repository_root(¤t_dir)
2475 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2476
2477 let stack_manager = StackManager::new(&repo_root)?;
2478 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2479 let options = crate::stack::RebaseOptions::default();
2480 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2481
2482 if !rebase_manager.is_rebase_in_progress() {
2483 println!("ℹ️ No rebase in progress");
2484 return Ok(());
2485 }
2486
2487 println!("⚠️ Aborting rebase...");
2488 match rebase_manager.abort_rebase() {
2489 Ok(_) => {
2490 println!("✅ Rebase aborted successfully");
2491 println!(" Repository restored to pre-rebase state");
2492 }
2493 Err(e) => {
2494 warn!("❌ Failed to abort rebase: {}", e);
2495 println!("⚠️ You may need to manually clean up the repository state");
2496 }
2497 }
2498
2499 Ok(())
2500}
2501
2502async fn rebase_status() -> Result<()> {
2503 let current_dir = env::current_dir()
2504 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2505
2506 let repo_root = find_repository_root(¤t_dir)
2507 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2508
2509 let stack_manager = StackManager::new(&repo_root)?;
2510 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2511
2512 println!("📊 Rebase Status");
2513
2514 let git_dir = current_dir.join(".git");
2516 let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
2517 || git_dir.join("rebase-merge").exists()
2518 || git_dir.join("rebase-apply").exists();
2519
2520 if rebase_in_progress {
2521 println!(" Status: 🔄 Rebase in progress");
2522 println!(
2523 "
2524📝 Actions available:"
2525 );
2526 println!(" - 'ca stack continue-rebase' to continue");
2527 println!(" - 'ca stack abort-rebase' to abort");
2528 println!(" - 'git status' to see conflicted files");
2529
2530 match git_repo.get_status() {
2532 Ok(statuses) => {
2533 let mut conflicts = Vec::new();
2534 for status in statuses.iter() {
2535 if status.status().contains(git2::Status::CONFLICTED) {
2536 if let Some(path) = status.path() {
2537 conflicts.push(path.to_string());
2538 }
2539 }
2540 }
2541
2542 if !conflicts.is_empty() {
2543 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
2544 for conflict in conflicts {
2545 println!(" - {conflict}");
2546 }
2547 println!(
2548 "
2549💡 To resolve conflicts:"
2550 );
2551 println!(" 1. Edit the conflicted files");
2552 println!(" 2. Stage resolved files: git add <file>");
2553 println!(" 3. Continue: ca stack continue-rebase");
2554 }
2555 }
2556 Err(e) => {
2557 warn!("Failed to get git status: {}", e);
2558 }
2559 }
2560 } else {
2561 println!(" Status: ✅ No rebase in progress");
2562
2563 if let Some(active_stack) = stack_manager.get_active_stack() {
2565 println!(" Active stack: {}", active_stack.name);
2566 println!(" Entries: {}", active_stack.entries.len());
2567 println!(" Base branch: {}", active_stack.base_branch);
2568 }
2569 }
2570
2571 Ok(())
2572}
2573
2574async fn delete_stack(name: String, force: bool) -> Result<()> {
2575 let current_dir = env::current_dir()
2576 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2577
2578 let repo_root = find_repository_root(¤t_dir)
2579 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2580
2581 let mut manager = StackManager::new(&repo_root)?;
2582
2583 let stack = manager
2584 .get_stack_by_name(&name)
2585 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2586 let stack_id = stack.id;
2587
2588 if !force && !stack.entries.is_empty() {
2589 return Err(CascadeError::config(format!(
2590 "Stack '{}' has {} entries. Use --force to delete anyway",
2591 name,
2592 stack.entries.len()
2593 )));
2594 }
2595
2596 let deleted = manager.delete_stack(&stack_id)?;
2597
2598 Output::success(format!("Deleted stack '{}'", deleted.name));
2599 if !deleted.entries.is_empty() {
2600 Output::warning(format!("{} entries were removed", deleted.entries.len()));
2601 }
2602
2603 Ok(())
2604}
2605
2606async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
2607 let current_dir = env::current_dir()
2608 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2609
2610 let repo_root = find_repository_root(¤t_dir)
2611 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2612
2613 let mut manager = StackManager::new(&repo_root)?;
2614
2615 if let Some(name) = name {
2616 let stack = manager
2618 .get_stack_by_name(&name)
2619 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2620
2621 let stack_id = stack.id;
2622
2623 match stack.validate() {
2625 Ok(message) => {
2626 println!("✅ Stack '{name}' structure validation: {message}");
2627 }
2628 Err(e) => {
2629 println!("❌ Stack '{name}' structure validation failed: {e}");
2630 return Err(CascadeError::config(e));
2631 }
2632 }
2633
2634 manager.handle_branch_modifications(&stack_id, fix_mode)?;
2636
2637 println!("🎉 Stack '{name}' validation completed");
2638 Ok(())
2639 } else {
2640 println!("🔍 Validating all stacks...");
2642
2643 let all_stacks = manager.get_all_stacks();
2645 let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
2646
2647 if stack_ids.is_empty() {
2648 println!("📭 No stacks found");
2649 return Ok(());
2650 }
2651
2652 let mut all_valid = true;
2653 for stack_id in stack_ids {
2654 let stack = manager.get_stack(&stack_id).unwrap();
2655 let stack_name = &stack.name;
2656
2657 println!("\n📋 Checking stack '{stack_name}':");
2658
2659 match stack.validate() {
2661 Ok(message) => {
2662 println!(" ✅ Structure: {message}");
2663 }
2664 Err(e) => {
2665 println!(" ❌ Structure: {e}");
2666 all_valid = false;
2667 continue;
2668 }
2669 }
2670
2671 match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
2673 Ok(_) => {
2674 println!(" ✅ Git integrity: OK");
2675 }
2676 Err(e) => {
2677 println!(" ❌ Git integrity: {e}");
2678 all_valid = false;
2679 }
2680 }
2681 }
2682
2683 if all_valid {
2684 println!("\n🎉 All stacks passed validation");
2685 } else {
2686 println!("\n⚠️ Some stacks have validation issues");
2687 return Err(CascadeError::config("Stack validation failed".to_string()));
2688 }
2689
2690 Ok(())
2691 }
2692}
2693
2694#[allow(dead_code)]
2696fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
2697 let mut unpushed = Vec::new();
2698 let head_commit = repo.get_head_commit()?;
2699 let mut current_commit = head_commit;
2700
2701 loop {
2703 let commit_hash = current_commit.id().to_string();
2704 let already_in_stack = stack
2705 .entries
2706 .iter()
2707 .any(|entry| entry.commit_hash == commit_hash);
2708
2709 if already_in_stack {
2710 break;
2711 }
2712
2713 unpushed.push(commit_hash);
2714
2715 if let Some(parent) = current_commit.parents().next() {
2717 current_commit = parent;
2718 } else {
2719 break;
2720 }
2721 }
2722
2723 unpushed.reverse(); Ok(unpushed)
2725}
2726
2727pub async fn squash_commits(
2729 repo: &GitRepository,
2730 count: usize,
2731 since_ref: Option<String>,
2732) -> Result<()> {
2733 if count <= 1 {
2734 return Ok(()); }
2736
2737 let _current_branch = repo.get_current_branch()?;
2739
2740 let rebase_range = if let Some(ref since) = since_ref {
2742 since.clone()
2743 } else {
2744 format!("HEAD~{count}")
2745 };
2746
2747 println!(" Analyzing {count} commits to create smart squash message...");
2748
2749 let head_commit = repo.get_head_commit()?;
2751 let mut commits_to_squash = Vec::new();
2752 let mut current = head_commit;
2753
2754 for _ in 0..count {
2756 commits_to_squash.push(current.clone());
2757 if current.parent_count() > 0 {
2758 current = current.parent(0).map_err(CascadeError::Git)?;
2759 } else {
2760 break;
2761 }
2762 }
2763
2764 let smart_message = generate_squash_message(&commits_to_squash)?;
2766 println!(
2767 " Smart message: {}",
2768 smart_message.lines().next().unwrap_or("")
2769 );
2770
2771 let reset_target = if since_ref.is_some() {
2773 format!("{rebase_range}~1")
2775 } else {
2776 format!("HEAD~{count}")
2778 };
2779
2780 repo.reset_soft(&reset_target)?;
2782
2783 repo.stage_all()?;
2785
2786 let new_commit_hash = repo.commit(&smart_message)?;
2788
2789 println!(
2790 " Created squashed commit: {} ({})",
2791 &new_commit_hash[..8],
2792 smart_message.lines().next().unwrap_or("")
2793 );
2794 println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
2795
2796 Ok(())
2797}
2798
2799pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
2801 if commits.is_empty() {
2802 return Ok("Squashed commits".to_string());
2803 }
2804
2805 let messages: Vec<String> = commits
2807 .iter()
2808 .map(|c| c.message().unwrap_or("").trim().to_string())
2809 .filter(|m| !m.is_empty())
2810 .collect();
2811
2812 if messages.is_empty() {
2813 return Ok("Squashed commits".to_string());
2814 }
2815
2816 if let Some(last_msg) = messages.first() {
2818 if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
2820 return Ok(last_msg
2821 .trim_start_matches("Final:")
2822 .trim_start_matches("final:")
2823 .trim()
2824 .to_string());
2825 }
2826 }
2827
2828 let wip_count = messages
2830 .iter()
2831 .filter(|m| {
2832 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
2833 })
2834 .count();
2835
2836 if wip_count > messages.len() / 2 {
2837 let non_wip: Vec<&String> = messages
2839 .iter()
2840 .filter(|m| {
2841 !m.to_lowercase().starts_with("wip")
2842 && !m.to_lowercase().contains("work in progress")
2843 })
2844 .collect();
2845
2846 if let Some(best_msg) = non_wip.first() {
2847 return Ok(best_msg.to_string());
2848 }
2849
2850 let feature = extract_feature_from_wip(&messages);
2852 return Ok(feature);
2853 }
2854
2855 Ok(messages.first().unwrap().clone())
2857}
2858
2859pub fn extract_feature_from_wip(messages: &[String]) -> String {
2861 for msg in messages {
2863 if msg.to_lowercase().starts_with("wip:") {
2865 if let Some(rest) = msg
2866 .strip_prefix("WIP:")
2867 .or_else(|| msg.strip_prefix("wip:"))
2868 {
2869 let feature = rest.trim();
2870 if !feature.is_empty() && feature.len() > 3 {
2871 let mut chars: Vec<char> = feature.chars().collect();
2873 if let Some(first) = chars.first_mut() {
2874 *first = first.to_uppercase().next().unwrap_or(*first);
2875 }
2876 return chars.into_iter().collect();
2877 }
2878 }
2879 }
2880 }
2881
2882 if let Some(first) = messages.first() {
2884 let cleaned = first
2885 .trim_start_matches("WIP:")
2886 .trim_start_matches("wip:")
2887 .trim_start_matches("WIP")
2888 .trim_start_matches("wip")
2889 .trim();
2890
2891 if !cleaned.is_empty() {
2892 return format!("Implement {cleaned}");
2893 }
2894 }
2895
2896 format!("Squashed {} commits", messages.len())
2897}
2898
2899pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
2901 let head_commit = repo.get_head_commit()?;
2902 let since_commit = repo.get_commit(since_commit_hash)?;
2903
2904 let mut count = 0;
2905 let mut current = head_commit;
2906
2907 loop {
2909 if current.id() == since_commit.id() {
2910 break;
2911 }
2912
2913 count += 1;
2914
2915 if current.parent_count() == 0 {
2917 break; }
2919
2920 current = current.parent(0).map_err(CascadeError::Git)?;
2921 }
2922
2923 Ok(count)
2924}
2925
2926async fn land_stack(
2928 entry: Option<usize>,
2929 force: bool,
2930 dry_run: bool,
2931 auto: bool,
2932 wait_for_builds: bool,
2933 strategy: Option<MergeStrategyArg>,
2934 build_timeout: u64,
2935) -> Result<()> {
2936 let current_dir = env::current_dir()
2937 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2938
2939 let repo_root = find_repository_root(¤t_dir)
2940 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2941
2942 let stack_manager = StackManager::new(&repo_root)?;
2943
2944 let stack_id = stack_manager
2946 .get_active_stack()
2947 .map(|s| s.id)
2948 .ok_or_else(|| {
2949 CascadeError::config(
2950 "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
2951 .to_string(),
2952 )
2953 })?;
2954
2955 let active_stack = stack_manager
2956 .get_active_stack()
2957 .cloned()
2958 .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
2959
2960 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2962 let config_path = config_dir.join("config.json");
2963 let settings = crate::config::Settings::load_from_file(&config_path)?;
2964
2965 let cascade_config = crate::config::CascadeConfig {
2966 bitbucket: Some(settings.bitbucket.clone()),
2967 git: settings.git.clone(),
2968 auth: crate::config::AuthConfig::default(),
2969 cascade: settings.cascade.clone(),
2970 };
2971
2972 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2973
2974 let status = integration.check_enhanced_stack_status(&stack_id).await?;
2976
2977 if status.enhanced_statuses.is_empty() {
2978 println!("❌ No pull requests found to land");
2979 return Ok(());
2980 }
2981
2982 let ready_prs: Vec<_> = status
2984 .enhanced_statuses
2985 .iter()
2986 .filter(|pr_status| {
2987 if let Some(entry_num) = entry {
2989 if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
2991 if pr_status.pr.from_ref.display_id != stack_entry.branch {
2993 return false;
2994 }
2995 } else {
2996 return false; }
2998 }
2999
3000 if force {
3001 pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
3003 } else {
3004 pr_status.is_ready_to_land()
3005 }
3006 })
3007 .collect();
3008
3009 if ready_prs.is_empty() {
3010 if let Some(entry_num) = entry {
3011 println!("❌ Entry {entry_num} is not ready to land or doesn't exist");
3012 } else {
3013 println!("❌ No pull requests are ready to land");
3014 }
3015
3016 println!("\n🚫 Blocking Issues:");
3018 for pr_status in &status.enhanced_statuses {
3019 if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
3020 let blocking = pr_status.get_blocking_reasons();
3021 if !blocking.is_empty() {
3022 println!(" PR #{}: {}", pr_status.pr.id, blocking.join(", "));
3023 }
3024 }
3025 }
3026
3027 if !force {
3028 println!("\n💡 Use --force to land PRs with blocking issues (dangerous!)");
3029 }
3030 return Ok(());
3031 }
3032
3033 if dry_run {
3034 if let Some(entry_num) = entry {
3035 println!("🏃 Dry Run - Entry {entry_num} that would be landed:");
3036 } else {
3037 println!("🏃 Dry Run - PRs that would be landed:");
3038 }
3039 for pr_status in &ready_prs {
3040 println!(" ✅ PR #{}: {}", pr_status.pr.id, pr_status.pr.title);
3041 if !pr_status.is_ready_to_land() && force {
3042 let blocking = pr_status.get_blocking_reasons();
3043 println!(
3044 " ⚠️ Would force land despite: {}",
3045 blocking.join(", ")
3046 );
3047 }
3048 }
3049 return Ok(());
3050 }
3051
3052 if entry.is_some() && ready_prs.len() > 1 {
3055 println!(
3056 "🎯 {} PRs are ready to land, but landing only entry #{}",
3057 ready_prs.len(),
3058 entry.unwrap()
3059 );
3060 }
3061
3062 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
3064 strategy.unwrap_or(MergeStrategyArg::Squash).into();
3065 let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
3066 merge_strategy: merge_strategy.clone(),
3067 wait_for_builds,
3068 build_timeout: std::time::Duration::from_secs(build_timeout),
3069 allowed_authors: None, };
3071
3072 println!(
3074 "🚀 Landing {} PR{}...",
3075 ready_prs.len(),
3076 if ready_prs.len() == 1 { "" } else { "s" }
3077 );
3078
3079 let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
3080 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
3081 );
3082
3083 let mut landed_count = 0;
3085 let mut failed_count = 0;
3086 let total_ready_prs = ready_prs.len();
3087
3088 for pr_status in ready_prs {
3089 let pr_id = pr_status.pr.id;
3090
3091 print!("🚀 Landing PR #{}: {}", pr_id, pr_status.pr.title);
3092
3093 let land_result = if auto {
3094 pr_manager
3096 .auto_merge_if_ready(pr_id, &auto_merge_conditions)
3097 .await
3098 } else {
3099 pr_manager
3101 .merge_pull_request(pr_id, merge_strategy.clone())
3102 .await
3103 .map(
3104 |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
3105 pr: Box::new(pr),
3106 merge_strategy: merge_strategy.clone(),
3107 },
3108 )
3109 };
3110
3111 match land_result {
3112 Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
3113 println!(" ✅");
3114 landed_count += 1;
3115
3116 if landed_count < total_ready_prs {
3118 println!("🔄 Retargeting remaining PRs to latest base...");
3119
3120 let base_branch = active_stack.base_branch.clone();
3122 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3123
3124 println!(" 📥 Updating base branch: {base_branch}");
3125 match git_repo.pull(&base_branch) {
3126 Ok(_) => println!(" ✅ Base branch updated successfully"),
3127 Err(e) => {
3128 println!(" ⚠️ Warning: Failed to update base branch: {e}");
3129 println!(
3130 " 💡 You may want to manually run: git pull origin {base_branch}"
3131 );
3132 }
3133 }
3134
3135 let mut rebase_manager = crate::stack::RebaseManager::new(
3137 StackManager::new(&repo_root)?,
3138 git_repo,
3139 crate::stack::RebaseOptions {
3140 strategy: crate::stack::RebaseStrategy::ForcePush,
3141 target_base: Some(base_branch.clone()),
3142 ..Default::default()
3143 },
3144 );
3145
3146 match rebase_manager.rebase_stack(&stack_id) {
3147 Ok(rebase_result) => {
3148 if !rebase_result.branch_mapping.is_empty() {
3149 let retarget_config = crate::config::CascadeConfig {
3151 bitbucket: Some(settings.bitbucket.clone()),
3152 git: settings.git.clone(),
3153 auth: crate::config::AuthConfig::default(),
3154 cascade: settings.cascade.clone(),
3155 };
3156 let mut retarget_integration = BitbucketIntegration::new(
3157 StackManager::new(&repo_root)?,
3158 retarget_config,
3159 )?;
3160
3161 match retarget_integration
3162 .update_prs_after_rebase(
3163 &stack_id,
3164 &rebase_result.branch_mapping,
3165 )
3166 .await
3167 {
3168 Ok(updated_prs) => {
3169 if !updated_prs.is_empty() {
3170 println!(
3171 " ✅ Updated {} PRs with new targets",
3172 updated_prs.len()
3173 );
3174 }
3175 }
3176 Err(e) => {
3177 println!(" ⚠️ Failed to update remaining PRs: {e}");
3178 println!(
3179 " 💡 You may need to run: ca stack rebase --onto {base_branch}"
3180 );
3181 }
3182 }
3183 }
3184 }
3185 Err(e) => {
3186 println!(" ❌ Auto-retargeting conflicts detected!");
3188 println!(" 📝 To resolve conflicts and continue landing:");
3189 println!(" 1. Resolve conflicts in the affected files");
3190 println!(" 2. Stage resolved files: git add <files>");
3191 println!(" 3. Continue the process: ca stack continue-land");
3192 println!(" 4. Or abort the operation: ca stack abort-land");
3193 println!();
3194 println!(" 💡 Check current status: ca stack land-status");
3195 println!(" ⚠️ Error details: {e}");
3196
3197 break;
3199 }
3200 }
3201 }
3202 }
3203 Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
3204 println!(" ❌ Not ready: {}", blocking_reasons.join(", "));
3205 failed_count += 1;
3206 if !force {
3207 break;
3208 }
3209 }
3210 Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
3211 println!(" ❌ Failed: {error}");
3212 failed_count += 1;
3213 if !force {
3214 break;
3215 }
3216 }
3217 Err(e) => {
3218 println!(" ❌");
3219 eprintln!("Failed to land PR #{pr_id}: {e}");
3220 failed_count += 1;
3221
3222 if !force {
3223 break;
3224 }
3225 }
3226 }
3227 }
3228
3229 println!("\n🎯 Landing Summary:");
3231 println!(" ✅ Successfully landed: {landed_count}");
3232 if failed_count > 0 {
3233 println!(" ❌ Failed to land: {failed_count}");
3234 }
3235
3236 if landed_count > 0 {
3237 println!("✅ Landing operation completed!");
3238 } else {
3239 println!("❌ No PRs were successfully landed");
3240 }
3241
3242 Ok(())
3243}
3244
3245async fn auto_land_stack(
3247 force: bool,
3248 dry_run: bool,
3249 wait_for_builds: bool,
3250 strategy: Option<MergeStrategyArg>,
3251 build_timeout: u64,
3252) -> Result<()> {
3253 land_stack(
3255 None,
3256 force,
3257 dry_run,
3258 true, wait_for_builds,
3260 strategy,
3261 build_timeout,
3262 )
3263 .await
3264}
3265
3266async fn continue_land() -> Result<()> {
3267 let current_dir = env::current_dir()
3268 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3269
3270 let repo_root = find_repository_root(¤t_dir)
3271 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3272
3273 let stack_manager = StackManager::new(&repo_root)?;
3274 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3275 let options = crate::stack::RebaseOptions::default();
3276 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3277
3278 if !rebase_manager.is_rebase_in_progress() {
3279 println!("ℹ️ No rebase in progress");
3280 return Ok(());
3281 }
3282
3283 println!("🔄 Continuing land operation...");
3284 match rebase_manager.continue_rebase() {
3285 Ok(_) => {
3286 println!("✅ Land operation continued successfully");
3287 println!(" Check 'ca stack land-status' for current state");
3288 }
3289 Err(e) => {
3290 warn!("❌ Failed to continue land operation: {}", e);
3291 println!("💡 You may need to resolve conflicts first:");
3292 println!(" 1. Edit conflicted files");
3293 println!(" 2. Stage resolved files with 'git add'");
3294 println!(" 3. Run 'ca stack continue-land' again");
3295 }
3296 }
3297
3298 Ok(())
3299}
3300
3301async fn abort_land() -> Result<()> {
3302 let current_dir = env::current_dir()
3303 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3304
3305 let repo_root = find_repository_root(¤t_dir)
3306 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3307
3308 let stack_manager = StackManager::new(&repo_root)?;
3309 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3310 let options = crate::stack::RebaseOptions::default();
3311 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3312
3313 if !rebase_manager.is_rebase_in_progress() {
3314 println!("ℹ️ No rebase in progress");
3315 return Ok(());
3316 }
3317
3318 println!("⚠️ Aborting land operation...");
3319 match rebase_manager.abort_rebase() {
3320 Ok(_) => {
3321 println!("✅ Land operation aborted successfully");
3322 println!(" Repository restored to pre-land state");
3323 }
3324 Err(e) => {
3325 warn!("❌ Failed to abort land operation: {}", e);
3326 println!("⚠️ You may need to manually clean up the repository state");
3327 }
3328 }
3329
3330 Ok(())
3331}
3332
3333async fn land_status() -> Result<()> {
3334 let current_dir = env::current_dir()
3335 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3336
3337 let repo_root = find_repository_root(¤t_dir)
3338 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3339
3340 let stack_manager = StackManager::new(&repo_root)?;
3341 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3342
3343 println!("📊 Land Status");
3344
3345 let git_dir = repo_root.join(".git");
3347 let land_in_progress = git_dir.join("REBASE_HEAD").exists()
3348 || git_dir.join("rebase-merge").exists()
3349 || git_dir.join("rebase-apply").exists();
3350
3351 if land_in_progress {
3352 println!(" Status: 🔄 Land operation in progress");
3353 println!(
3354 "
3355📝 Actions available:"
3356 );
3357 println!(" - 'ca stack continue-land' to continue");
3358 println!(" - 'ca stack abort-land' to abort");
3359 println!(" - 'git status' to see conflicted files");
3360
3361 match git_repo.get_status() {
3363 Ok(statuses) => {
3364 let mut conflicts = Vec::new();
3365 for status in statuses.iter() {
3366 if status.status().contains(git2::Status::CONFLICTED) {
3367 if let Some(path) = status.path() {
3368 conflicts.push(path.to_string());
3369 }
3370 }
3371 }
3372
3373 if !conflicts.is_empty() {
3374 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
3375 for conflict in conflicts {
3376 println!(" - {conflict}");
3377 }
3378 println!(
3379 "
3380💡 To resolve conflicts:"
3381 );
3382 println!(" 1. Edit the conflicted files");
3383 println!(" 2. Stage resolved files: git add <file>");
3384 println!(" 3. Continue: ca stack continue-land");
3385 }
3386 }
3387 Err(e) => {
3388 warn!("Failed to get git status: {}", e);
3389 }
3390 }
3391 } else {
3392 println!(" Status: ✅ No land operation in progress");
3393
3394 if let Some(active_stack) = stack_manager.get_active_stack() {
3396 println!(" Active stack: {}", active_stack.name);
3397 println!(" Entries: {}", active_stack.entries.len());
3398 println!(" Base branch: {}", active_stack.base_branch);
3399 }
3400 }
3401
3402 Ok(())
3403}
3404
3405async fn repair_stack_data() -> Result<()> {
3406 let current_dir = env::current_dir()
3407 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3408
3409 let repo_root = find_repository_root(¤t_dir)
3410 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3411
3412 let mut stack_manager = StackManager::new(&repo_root)?;
3413
3414 println!("🔧 Repairing stack data consistency...");
3415
3416 stack_manager.repair_all_stacks()?;
3417
3418 println!("✅ Stack data consistency repaired successfully!");
3419 println!("💡 Run 'ca stack --mergeable' to see updated status");
3420
3421 Ok(())
3422}
3423
3424async fn cleanup_branches(
3426 dry_run: bool,
3427 force: bool,
3428 include_stale: bool,
3429 stale_days: u32,
3430 cleanup_remote: bool,
3431 include_non_stack: bool,
3432 verbose: bool,
3433) -> Result<()> {
3434 let current_dir = env::current_dir()
3435 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3436
3437 let repo_root = find_repository_root(¤t_dir)
3438 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3439
3440 let stack_manager = StackManager::new(&repo_root)?;
3441 let git_repo = GitRepository::open(&repo_root)?;
3442
3443 let result = perform_cleanup(
3444 &stack_manager,
3445 &git_repo,
3446 dry_run,
3447 force,
3448 include_stale,
3449 stale_days,
3450 cleanup_remote,
3451 include_non_stack,
3452 verbose,
3453 )
3454 .await?;
3455
3456 if result.total_candidates == 0 {
3458 Output::success("No branches found that need cleanup");
3459 return Ok(());
3460 }
3461
3462 Output::section("Cleanup Results");
3463
3464 if dry_run {
3465 Output::sub_item(format!(
3466 "Found {} branches that would be cleaned up",
3467 result.total_candidates
3468 ));
3469 } else {
3470 if !result.cleaned_branches.is_empty() {
3471 Output::success(format!(
3472 "Successfully cleaned up {} branches",
3473 result.cleaned_branches.len()
3474 ));
3475 for branch in &result.cleaned_branches {
3476 Output::sub_item(format!("🗑️ Deleted: {branch}"));
3477 }
3478 }
3479
3480 if !result.skipped_branches.is_empty() {
3481 Output::sub_item(format!(
3482 "Skipped {} branches",
3483 result.skipped_branches.len()
3484 ));
3485 if verbose {
3486 for (branch, reason) in &result.skipped_branches {
3487 Output::sub_item(format!("⏭️ {branch}: {reason}"));
3488 }
3489 }
3490 }
3491
3492 if !result.failed_branches.is_empty() {
3493 Output::warning(format!(
3494 "Failed to clean up {} branches",
3495 result.failed_branches.len()
3496 ));
3497 for (branch, error) in &result.failed_branches {
3498 Output::sub_item(format!("❌ {branch}: {error}"));
3499 }
3500 }
3501 }
3502
3503 Ok(())
3504}
3505
3506#[allow(clippy::too_many_arguments)]
3508async fn perform_cleanup(
3509 stack_manager: &StackManager,
3510 git_repo: &GitRepository,
3511 dry_run: bool,
3512 force: bool,
3513 include_stale: bool,
3514 stale_days: u32,
3515 cleanup_remote: bool,
3516 include_non_stack: bool,
3517 verbose: bool,
3518) -> Result<CleanupResult> {
3519 let options = CleanupOptions {
3520 dry_run,
3521 force,
3522 include_stale,
3523 cleanup_remote,
3524 stale_threshold_days: stale_days,
3525 cleanup_non_stack: include_non_stack,
3526 };
3527
3528 let stack_manager_copy = StackManager::new(stack_manager.repo_path())?;
3529 let git_repo_copy = GitRepository::open(git_repo.path())?;
3530 let mut cleanup_manager = CleanupManager::new(stack_manager_copy, git_repo_copy, options);
3531
3532 let candidates = cleanup_manager.find_cleanup_candidates()?;
3534
3535 if candidates.is_empty() {
3536 return Ok(CleanupResult {
3537 cleaned_branches: Vec::new(),
3538 failed_branches: Vec::new(),
3539 skipped_branches: Vec::new(),
3540 total_candidates: 0,
3541 });
3542 }
3543
3544 if verbose || dry_run {
3546 Output::section("Cleanup Candidates");
3547 for candidate in &candidates {
3548 let reason_icon = match candidate.reason {
3549 crate::stack::CleanupReason::FullyMerged => "🔀",
3550 crate::stack::CleanupReason::StackEntryMerged => "✅",
3551 crate::stack::CleanupReason::Stale => "⏰",
3552 crate::stack::CleanupReason::Orphaned => "👻",
3553 };
3554
3555 Output::sub_item(format!(
3556 "{} {} - {} ({})",
3557 reason_icon,
3558 candidate.branch_name,
3559 candidate.reason_to_string(),
3560 candidate.safety_info
3561 ));
3562 }
3563 }
3564
3565 if !force && !dry_run && !candidates.is_empty() {
3567 Output::warning(format!("About to delete {} branches", candidates.len()));
3568
3569 let preview_count = 5.min(candidates.len());
3571 for candidate in candidates.iter().take(preview_count) {
3572 println!(" • {}", candidate.branch_name);
3573 }
3574 if candidates.len() > preview_count {
3575 println!(" ... and {} more", candidates.len() - preview_count);
3576 }
3577 println!(); let should_continue = Confirm::with_theme(&ColorfulTheme::default())
3581 .with_prompt("Continue with branch cleanup?")
3582 .default(false)
3583 .interact()
3584 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
3585
3586 if !should_continue {
3587 Output::sub_item("Cleanup cancelled");
3588 return Ok(CleanupResult {
3589 cleaned_branches: Vec::new(),
3590 failed_branches: Vec::new(),
3591 skipped_branches: Vec::new(),
3592 total_candidates: candidates.len(),
3593 });
3594 }
3595 }
3596
3597 cleanup_manager.perform_cleanup(&candidates)
3599}
3600
3601async fn perform_simple_cleanup(
3603 stack_manager: &StackManager,
3604 git_repo: &GitRepository,
3605 dry_run: bool,
3606) -> Result<CleanupResult> {
3607 perform_cleanup(
3608 stack_manager,
3609 git_repo,
3610 dry_run,
3611 false, false, 30, false, false, false, )
3618 .await
3619}
3620
3621async fn analyze_commits_for_safeguards(
3623 commits_to_push: &[String],
3624 repo: &GitRepository,
3625 dry_run: bool,
3626) -> Result<()> {
3627 const LARGE_COMMIT_THRESHOLD: usize = 10;
3628 const WEEK_IN_SECONDS: i64 = 7 * 24 * 3600;
3629
3630 if commits_to_push.len() > LARGE_COMMIT_THRESHOLD {
3632 println!(
3633 "⚠️ Warning: About to push {} commits to stack",
3634 commits_to_push.len()
3635 );
3636 println!(" This may indicate a merge commit issue or unexpected commit range.");
3637 println!(" Large commit counts often result from merging instead of rebasing.");
3638
3639 if !dry_run && !confirm_large_push(commits_to_push.len())? {
3640 return Err(CascadeError::config("Push cancelled by user"));
3641 }
3642 }
3643
3644 let commit_objects: Result<Vec<_>> = commits_to_push
3646 .iter()
3647 .map(|hash| repo.get_commit(hash))
3648 .collect();
3649 let commit_objects = commit_objects?;
3650
3651 let merge_commits: Vec<_> = commit_objects
3653 .iter()
3654 .filter(|c| c.parent_count() > 1)
3655 .collect();
3656
3657 if !merge_commits.is_empty() {
3658 println!(
3659 "⚠️ Warning: {} merge commits detected in push",
3660 merge_commits.len()
3661 );
3662 println!(" This often indicates you merged instead of rebased.");
3663 println!(" Consider using 'ca sync' to rebase on the base branch.");
3664 println!(" Merge commits in stacks can cause confusion and duplicate work.");
3665 }
3666
3667 if commit_objects.len() > 1 {
3669 let oldest_commit_time = commit_objects.first().unwrap().time().seconds();
3670 let newest_commit_time = commit_objects.last().unwrap().time().seconds();
3671 let time_span = newest_commit_time - oldest_commit_time;
3672
3673 if time_span > WEEK_IN_SECONDS {
3674 let days = time_span / (24 * 3600);
3675 println!("⚠️ Warning: Commits span {days} days");
3676 println!(" This may indicate merged history rather than new work.");
3677 println!(" Recent work should typically span hours or days, not weeks.");
3678 }
3679 }
3680
3681 if commits_to_push.len() > 5 {
3683 println!("💡 Tip: If you only want recent commits, use:");
3684 println!(
3685 " ca push --since HEAD~{} # pushes last {} commits",
3686 std::cmp::min(commits_to_push.len(), 5),
3687 std::cmp::min(commits_to_push.len(), 5)
3688 );
3689 println!(" ca push --commits <hash1>,<hash2> # pushes specific commits");
3690 println!(" ca push --dry-run # preview what would be pushed");
3691 }
3692
3693 if dry_run {
3695 println!("🔍 DRY RUN: Would push {} commits:", commits_to_push.len());
3696 for (i, (commit_hash, commit_obj)) in commits_to_push
3697 .iter()
3698 .zip(commit_objects.iter())
3699 .enumerate()
3700 {
3701 let summary = commit_obj.summary().unwrap_or("(no message)");
3702 let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 7)];
3703 println!(" {}: {} ({})", i + 1, summary, short_hash);
3704 }
3705 println!("💡 Run without --dry-run to actually push these commits.");
3706 }
3707
3708 Ok(())
3709}
3710
3711fn confirm_large_push(count: usize) -> Result<bool> {
3713 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
3715 .with_prompt(format!("Continue pushing {count} commits?"))
3716 .default(false)
3717 .interact()
3718 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
3719
3720 Ok(should_continue)
3721}
3722
3723#[cfg(test)]
3724mod tests {
3725 use super::*;
3726 use std::process::Command;
3727 use tempfile::TempDir;
3728
3729 fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
3730 let temp_dir = TempDir::new()
3731 .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
3732 let repo_path = temp_dir.path().to_path_buf();
3733
3734 let output = Command::new("git")
3736 .args(["init"])
3737 .current_dir(&repo_path)
3738 .output()
3739 .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
3740 if !output.status.success() {
3741 return Err(CascadeError::config("Git init failed".to_string()));
3742 }
3743
3744 let output = Command::new("git")
3745 .args(["config", "user.name", "Test User"])
3746 .current_dir(&repo_path)
3747 .output()
3748 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3749 if !output.status.success() {
3750 return Err(CascadeError::config(
3751 "Git config user.name failed".to_string(),
3752 ));
3753 }
3754
3755 let output = Command::new("git")
3756 .args(["config", "user.email", "test@example.com"])
3757 .current_dir(&repo_path)
3758 .output()
3759 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3760 if !output.status.success() {
3761 return Err(CascadeError::config(
3762 "Git config user.email failed".to_string(),
3763 ));
3764 }
3765
3766 std::fs::write(repo_path.join("README.md"), "# Test")
3768 .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
3769 let output = Command::new("git")
3770 .args(["add", "."])
3771 .current_dir(&repo_path)
3772 .output()
3773 .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
3774 if !output.status.success() {
3775 return Err(CascadeError::config("Git add failed".to_string()));
3776 }
3777
3778 let output = Command::new("git")
3779 .args(["commit", "-m", "Initial commit"])
3780 .current_dir(&repo_path)
3781 .output()
3782 .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
3783 if !output.status.success() {
3784 return Err(CascadeError::config("Git commit failed".to_string()));
3785 }
3786
3787 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
3789
3790 Ok((temp_dir, repo_path))
3791 }
3792
3793 #[tokio::test]
3794 async fn test_create_stack() {
3795 let (temp_dir, repo_path) = match create_test_repo() {
3796 Ok(repo) => repo,
3797 Err(_) => {
3798 println!("Skipping test due to git environment setup failure");
3799 return;
3800 }
3801 };
3802 let _ = &temp_dir;
3804
3805 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3809 match env::set_current_dir(&repo_path) {
3810 Ok(_) => {
3811 let result = create_stack(
3812 "test-stack".to_string(),
3813 None, Some("Test description".to_string()),
3815 )
3816 .await;
3817
3818 if let Ok(orig) = original_dir {
3820 let _ = env::set_current_dir(orig);
3821 }
3822
3823 assert!(
3824 result.is_ok(),
3825 "Stack creation should succeed in initialized repository"
3826 );
3827 }
3828 Err(_) => {
3829 println!("Skipping test due to directory access restrictions");
3831 }
3832 }
3833 }
3834
3835 #[tokio::test]
3836 async fn test_list_empty_stacks() {
3837 let (temp_dir, repo_path) = match create_test_repo() {
3838 Ok(repo) => repo,
3839 Err(_) => {
3840 println!("Skipping test due to git environment setup failure");
3841 return;
3842 }
3843 };
3844 let _ = &temp_dir;
3846
3847 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3851 match env::set_current_dir(&repo_path) {
3852 Ok(_) => {
3853 let result = list_stacks(false, false, None).await;
3854
3855 if let Ok(orig) = original_dir {
3857 let _ = env::set_current_dir(orig);
3858 }
3859
3860 assert!(
3861 result.is_ok(),
3862 "Listing stacks should succeed in initialized repository"
3863 );
3864 }
3865 Err(_) => {
3866 println!("Skipping test due to directory access restrictions");
3868 }
3869 }
3870 }
3871
3872 #[test]
3875 fn test_extract_feature_from_wip_basic() {
3876 let messages = vec![
3877 "WIP: add authentication".to_string(),
3878 "WIP: implement login flow".to_string(),
3879 ];
3880
3881 let result = extract_feature_from_wip(&messages);
3882 assert_eq!(result, "Add authentication");
3883 }
3884
3885 #[test]
3886 fn test_extract_feature_from_wip_capitalize() {
3887 let messages = vec!["WIP: fix user validation bug".to_string()];
3888
3889 let result = extract_feature_from_wip(&messages);
3890 assert_eq!(result, "Fix user validation bug");
3891 }
3892
3893 #[test]
3894 fn test_extract_feature_from_wip_fallback() {
3895 let messages = vec![
3896 "WIP user interface changes".to_string(),
3897 "wip: css styling".to_string(),
3898 ];
3899
3900 let result = extract_feature_from_wip(&messages);
3901 assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
3903 }
3904
3905 #[test]
3906 fn test_extract_feature_from_wip_empty() {
3907 let messages = vec![];
3908
3909 let result = extract_feature_from_wip(&messages);
3910 assert_eq!(result, "Squashed 0 commits");
3911 }
3912
3913 #[test]
3914 fn test_extract_feature_from_wip_short_message() {
3915 let messages = vec!["WIP: x".to_string()]; let result = extract_feature_from_wip(&messages);
3918 assert!(result.starts_with("Implement") || result.contains("Squashed"));
3919 }
3920
3921 #[test]
3924 fn test_squash_message_final_strategy() {
3925 let messages = [
3929 "Final: implement user authentication system".to_string(),
3930 "WIP: add tests".to_string(),
3931 "WIP: fix validation".to_string(),
3932 ];
3933
3934 assert!(messages[0].starts_with("Final:"));
3936
3937 let extracted = messages[0].trim_start_matches("Final:").trim();
3939 assert_eq!(extracted, "implement user authentication system");
3940 }
3941
3942 #[test]
3943 fn test_squash_message_wip_detection() {
3944 let messages = [
3945 "WIP: start feature".to_string(),
3946 "WIP: continue work".to_string(),
3947 "WIP: almost done".to_string(),
3948 "Regular commit message".to_string(),
3949 ];
3950
3951 let wip_count = messages
3952 .iter()
3953 .filter(|m| {
3954 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3955 })
3956 .count();
3957
3958 assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2); let non_wip: Vec<&String> = messages
3963 .iter()
3964 .filter(|m| {
3965 !m.to_lowercase().starts_with("wip")
3966 && !m.to_lowercase().contains("work in progress")
3967 })
3968 .collect();
3969
3970 assert_eq!(non_wip.len(), 1);
3971 assert_eq!(non_wip[0], "Regular commit message");
3972 }
3973
3974 #[test]
3975 fn test_squash_message_all_wip() {
3976 let messages = vec![
3977 "WIP: add feature A".to_string(),
3978 "WIP: add feature B".to_string(),
3979 "WIP: finish implementation".to_string(),
3980 ];
3981
3982 let result = extract_feature_from_wip(&messages);
3983 assert_eq!(result, "Add feature A");
3985 }
3986
3987 #[test]
3988 fn test_squash_message_edge_cases() {
3989 let empty_messages: Vec<String> = vec![];
3991 let result = extract_feature_from_wip(&empty_messages);
3992 assert_eq!(result, "Squashed 0 commits");
3993
3994 let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
3996 let result = extract_feature_from_wip(&whitespace_messages);
3997 assert!(result.contains("Squashed") || result.contains("Implement"));
3998
3999 let mixed_case = vec!["wip: Add Feature".to_string()];
4001 let result = extract_feature_from_wip(&mixed_case);
4002 assert_eq!(result, "Add Feature");
4003 }
4004
4005 #[tokio::test]
4008 async fn test_auto_land_wrapper() {
4009 let (temp_dir, repo_path) = match create_test_repo() {
4011 Ok(repo) => repo,
4012 Err(_) => {
4013 println!("Skipping test due to git environment setup failure");
4014 return;
4015 }
4016 };
4017 let _ = &temp_dir;
4019
4020 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
4022 .expect("Failed to initialize Cascade in test repo");
4023
4024 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
4025 match env::set_current_dir(&repo_path) {
4026 Ok(_) => {
4027 let result = create_stack(
4029 "test-stack".to_string(),
4030 None,
4031 Some("Test stack for auto-land".to_string()),
4032 )
4033 .await;
4034
4035 if let Ok(orig) = original_dir {
4036 let _ = env::set_current_dir(orig);
4037 }
4038
4039 assert!(
4042 result.is_ok(),
4043 "Stack creation should succeed in initialized repository"
4044 );
4045 }
4046 Err(_) => {
4047 println!("Skipping test due to directory access restrictions");
4048 }
4049 }
4050 }
4051
4052 #[test]
4053 fn test_auto_land_action_enum() {
4054 use crate::cli::commands::stack::StackAction;
4056
4057 let _action = StackAction::AutoLand {
4059 force: false,
4060 dry_run: true,
4061 wait_for_builds: true,
4062 strategy: Some(MergeStrategyArg::Squash),
4063 build_timeout: 1800,
4064 };
4065
4066 }
4068
4069 #[test]
4070 fn test_merge_strategy_conversion() {
4071 let squash_strategy = MergeStrategyArg::Squash;
4073 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
4074
4075 match merge_strategy {
4076 crate::bitbucket::pull_request::MergeStrategy::Squash => {
4077 }
4079 _ => unreachable!("SquashStrategyArg only has Squash variant"),
4080 }
4081
4082 let merge_strategy = MergeStrategyArg::Merge;
4083 let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
4084
4085 match converted {
4086 crate::bitbucket::pull_request::MergeStrategy::Merge => {
4087 }
4089 _ => unreachable!("MergeStrategyArg::Merge maps to MergeStrategy::Merge"),
4090 }
4091 }
4092
4093 #[test]
4094 fn test_auto_merge_conditions_structure() {
4095 use std::time::Duration;
4097
4098 let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
4099 merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
4100 wait_for_builds: true,
4101 build_timeout: Duration::from_secs(1800),
4102 allowed_authors: None,
4103 };
4104
4105 assert!(conditions.wait_for_builds);
4107 assert_eq!(conditions.build_timeout.as_secs(), 1800);
4108 assert!(conditions.allowed_authors.is_none());
4109 assert!(matches!(
4110 conditions.merge_strategy,
4111 crate::bitbucket::pull_request::MergeStrategy::Squash
4112 ));
4113 }
4114
4115 #[test]
4116 fn test_polling_constants() {
4117 use std::time::Duration;
4119
4120 let expected_polling_interval = Duration::from_secs(30);
4122
4123 assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
4128
4129 #[test]
4130 fn test_build_timeout_defaults() {
4131 const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
4134 let timeout_value = 1800u64;
4136 assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
4139
4140 #[test]
4141 fn test_scattered_commit_detection() {
4142 use std::collections::HashSet;
4143
4144 let mut source_branches = HashSet::new();
4146 source_branches.insert("feature-branch-1".to_string());
4147 source_branches.insert("feature-branch-2".to_string());
4148 source_branches.insert("feature-branch-3".to_string());
4149
4150 let single_branch = HashSet::from(["main".to_string()]);
4152 assert_eq!(single_branch.len(), 1);
4153
4154 assert!(source_branches.len() > 1);
4156 assert_eq!(source_branches.len(), 3);
4157
4158 assert!(source_branches.contains("feature-branch-1"));
4160 assert!(source_branches.contains("feature-branch-2"));
4161 assert!(source_branches.contains("feature-branch-3"));
4162 }
4163
4164 #[test]
4165 fn test_source_branch_tracking() {
4166 let branch_a = "feature-work";
4170 let branch_b = "feature-work";
4171 assert_eq!(branch_a, branch_b);
4172
4173 let branch_1 = "feature-ui";
4175 let branch_2 = "feature-api";
4176 assert_ne!(branch_1, branch_2);
4177
4178 assert!(branch_1.starts_with("feature-"));
4180 assert!(branch_2.starts_with("feature-"));
4181 }
4182
4183 #[tokio::test]
4186 async fn test_push_default_behavior() {
4187 let (temp_dir, repo_path) = match create_test_repo() {
4189 Ok(repo) => repo,
4190 Err(_) => {
4191 println!("Skipping test due to git environment setup failure");
4192 return;
4193 }
4194 };
4195 let _ = &temp_dir;
4197
4198 if !repo_path.exists() {
4200 println!("Skipping test due to temporary directory creation issue");
4201 return;
4202 }
4203
4204 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
4206
4207 match env::set_current_dir(&repo_path) {
4208 Ok(_) => {
4209 let result = push_to_stack(
4211 None, None, None, None, None, None, None, false, false, false, )
4222 .await;
4223
4224 if let Ok(orig) = original_dir {
4226 let _ = env::set_current_dir(orig);
4227 }
4228
4229 match &result {
4231 Err(e) => {
4232 let error_msg = e.to_string();
4233 assert!(
4235 error_msg.contains("No active stack")
4236 || error_msg.contains("config")
4237 || error_msg.contains("current directory")
4238 || error_msg.contains("Not a git repository")
4239 || error_msg.contains("could not find repository"),
4240 "Expected 'No active stack' or repository error, got: {error_msg}"
4241 );
4242 }
4243 Ok(_) => {
4244 println!(
4246 "Push succeeded unexpectedly - test environment may have active stack"
4247 );
4248 }
4249 }
4250 }
4251 Err(_) => {
4252 println!("Skipping test due to directory access restrictions");
4254 }
4255 }
4256
4257 let push_action = StackAction::Push {
4259 branch: None,
4260 message: None,
4261 commit: None,
4262 since: None,
4263 commits: None,
4264 squash: None,
4265 squash_since: None,
4266 auto_branch: false,
4267 allow_base_branch: false,
4268 dry_run: false,
4269 };
4270
4271 assert!(matches!(
4272 push_action,
4273 StackAction::Push {
4274 branch: None,
4275 message: None,
4276 commit: None,
4277 since: None,
4278 commits: None,
4279 squash: None,
4280 squash_since: None,
4281 auto_branch: false,
4282 allow_base_branch: false,
4283 dry_run: false
4284 }
4285 ));
4286 }
4287
4288 #[tokio::test]
4289 async fn test_submit_default_behavior() {
4290 let (temp_dir, repo_path) = match create_test_repo() {
4292 Ok(repo) => repo,
4293 Err(_) => {
4294 println!("Skipping test due to git environment setup failure");
4295 return;
4296 }
4297 };
4298 let _ = &temp_dir;
4300
4301 if !repo_path.exists() {
4303 println!("Skipping test due to temporary directory creation issue");
4304 return;
4305 }
4306
4307 let original_dir = match env::current_dir() {
4309 Ok(dir) => dir,
4310 Err(_) => {
4311 println!("Skipping test due to current directory access restrictions");
4312 return;
4313 }
4314 };
4315
4316 match env::set_current_dir(&repo_path) {
4317 Ok(_) => {
4318 let result = submit_entry(
4320 None, None, None, None, false, true, )
4327 .await;
4328
4329 let _ = env::set_current_dir(original_dir);
4331
4332 match &result {
4334 Err(e) => {
4335 let error_msg = e.to_string();
4336 assert!(
4338 error_msg.contains("No active stack")
4339 || error_msg.contains("config")
4340 || error_msg.contains("current directory")
4341 || error_msg.contains("Not a git repository")
4342 || error_msg.contains("could not find repository"),
4343 "Expected 'No active stack' or repository error, got: {error_msg}"
4344 );
4345 }
4346 Ok(_) => {
4347 println!("Submit succeeded unexpectedly - test environment may have active stack");
4349 }
4350 }
4351 }
4352 Err(_) => {
4353 println!("Skipping test due to directory access restrictions");
4355 }
4356 }
4357
4358 let submit_action = StackAction::Submit {
4360 entry: None,
4361 title: None,
4362 description: None,
4363 range: None,
4364 draft: false,
4365 open: true,
4366 };
4367
4368 assert!(matches!(
4369 submit_action,
4370 StackAction::Submit {
4371 entry: None,
4372 title: None,
4373 description: None,
4374 range: None,
4375 draft: false,
4376 open: true
4377 }
4378 ));
4379 }
4380
4381 #[test]
4382 fn test_targeting_options_still_work() {
4383 let commits = "abc123,def456,ghi789";
4387 let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
4388 assert_eq!(parsed.len(), 3);
4389 assert_eq!(parsed[0], "abc123");
4390 assert_eq!(parsed[1], "def456");
4391 assert_eq!(parsed[2], "ghi789");
4392
4393 let range = "1-3";
4395 assert!(range.contains('-'));
4396 let parts: Vec<&str> = range.split('-').collect();
4397 assert_eq!(parts.len(), 2);
4398
4399 let since_ref = "HEAD~3";
4401 assert!(since_ref.starts_with("HEAD"));
4402 assert!(since_ref.contains('~'));
4403 }
4404
4405 #[test]
4406 fn test_command_flow_logic() {
4407 assert!(matches!(
4409 StackAction::Push {
4410 branch: None,
4411 message: None,
4412 commit: None,
4413 since: None,
4414 commits: None,
4415 squash: None,
4416 squash_since: None,
4417 auto_branch: false,
4418 allow_base_branch: false,
4419 dry_run: false
4420 },
4421 StackAction::Push { .. }
4422 ));
4423
4424 assert!(matches!(
4425 StackAction::Submit {
4426 entry: None,
4427 title: None,
4428 description: None,
4429 range: None,
4430 draft: false,
4431 open: true
4432 },
4433 StackAction::Submit { .. }
4434 ));
4435 }
4436
4437 #[tokio::test]
4438 async fn test_deactivate_command_structure() {
4439 let deactivate_action = StackAction::Deactivate { force: false };
4441
4442 assert!(matches!(
4444 deactivate_action,
4445 StackAction::Deactivate { force: false }
4446 ));
4447
4448 let force_deactivate = StackAction::Deactivate { force: true };
4450 assert!(matches!(
4451 force_deactivate,
4452 StackAction::Deactivate { force: true }
4453 ));
4454 }
4455}