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