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 info!("✅ Created stack '{}'", name);
502 if let Some(base_branch) = base {
503 info!(" Base branch: {}", base_branch);
504 }
505 if let Some(desc) = description {
506 info!(" Description: {}", desc);
507 }
508 info!(" Stack ID: {}", stack_id);
509 info!(" Stack is now active");
510
511 Ok(())
512}
513
514async fn list_stacks(verbose: bool, _active: bool, _format: Option<String>) -> Result<()> {
515 let current_dir = env::current_dir()
516 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
517
518 let repo_root = find_repository_root(¤t_dir)
519 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
520
521 let manager = StackManager::new(&repo_root)?;
522 let stacks = manager.list_stacks();
523
524 if stacks.is_empty() {
525 info!("No stacks found. Create one with: ca stack create <name>");
526 return Ok(());
527 }
528
529 println!("📚 Stacks:");
530 for (stack_id, name, status, entry_count, active_marker) in stacks {
531 let status_icon = match status {
532 StackStatus::Clean => "✅",
533 StackStatus::Dirty => "🔄",
534 StackStatus::OutOfSync => "⚠️",
535 StackStatus::Conflicted => "❌",
536 StackStatus::Rebasing => "🔀",
537 StackStatus::NeedsSync => "🔄",
538 StackStatus::Corrupted => "💥",
539 };
540
541 let active_indicator = if active_marker.is_some() {
542 " (active)"
543 } else {
544 ""
545 };
546
547 let stack = manager.get_stack(&stack_id);
549
550 if verbose {
551 println!(" {status_icon} {name} [{entry_count}]{active_indicator}");
552 println!(" ID: {stack_id}");
553 if let Some(stack_meta) = manager.get_stack_metadata(&stack_id) {
554 println!(" Base: {}", stack_meta.base_branch);
555 if let Some(desc) = &stack_meta.description {
556 println!(" Description: {desc}");
557 }
558 println!(
559 " Commits: {} total, {} submitted",
560 stack_meta.total_commits, stack_meta.submitted_commits
561 );
562 if stack_meta.has_conflicts {
563 println!(" ⚠️ Has conflicts");
564 }
565 }
566
567 if let Some(stack_obj) = stack {
569 if !stack_obj.entries.is_empty() {
570 println!(" Branches:");
571 for (i, entry) in stack_obj.entries.iter().enumerate() {
572 let entry_num = i + 1;
573 let submitted_indicator = if entry.is_submitted { "📤" } else { "📝" };
574 let branch_name = &entry.branch;
575 let short_message = if entry.message.len() > 40 {
576 format!("{}...", &entry.message[..37])
577 } else {
578 entry.message.clone()
579 };
580 println!(" {entry_num}. {submitted_indicator} {branch_name} - {short_message}");
581 }
582 }
583 }
584 println!();
585 } else {
586 let branch_info = if let Some(stack_obj) = stack {
588 if stack_obj.entries.is_empty() {
589 String::new()
590 } else if stack_obj.entries.len() == 1 {
591 format!(" → {}", stack_obj.entries[0].branch)
592 } else {
593 let first_branch = &stack_obj.entries[0].branch;
594 let last_branch = &stack_obj.entries.last().unwrap().branch;
595 format!(" → {first_branch} … {last_branch}")
596 }
597 } else {
598 String::new()
599 };
600
601 println!(" {status_icon} {name} [{entry_count}]{branch_info}{active_indicator}");
602 }
603 }
604
605 if !verbose {
606 println!("\nUse --verbose for more details");
607 }
608
609 Ok(())
610}
611
612async fn switch_stack(name: String) -> Result<()> {
613 let current_dir = env::current_dir()
614 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
615
616 let repo_root = find_repository_root(¤t_dir)
617 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
618
619 let mut manager = StackManager::new(&repo_root)?;
620 let repo = GitRepository::open(&repo_root)?;
621
622 let stack = manager
624 .get_stack_by_name(&name)
625 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
626
627 let target_branch = &stack.base_branch;
629
630 let current_branch = repo.get_current_branch().ok();
632
633 if current_branch.as_ref() != Some(target_branch) {
635 println!("🔄 Switching to stack base branch: {target_branch}");
636
637 if repo.branch_exists(target_branch) {
639 match repo.checkout_branch(target_branch) {
640 Ok(_) => {
641 println!("✅ Checked out branch: {target_branch}");
642 }
643 Err(e) => {
644 println!("⚠️ Failed to checkout '{target_branch}': {e}");
645 println!(" Stack activated but stayed on current branch");
646 println!(" You can manually checkout with: git checkout {target_branch}");
647 }
648 }
649 } else {
650 println!("⚠️ Stack base branch '{target_branch}' doesn't exist locally");
651 println!(" Stack activated but stayed on current branch");
652 println!(" You may need to create the branch or fetch from remote");
653 }
654 } else {
655 println!("✅ Already on stack base branch: {target_branch}");
656 }
657
658 manager.set_active_stack_by_name(&name)?;
660 info!("✅ Switched to stack '{}'", name);
661
662 Ok(())
663}
664
665async fn deactivate_stack(force: bool) -> Result<()> {
666 let current_dir = env::current_dir()
667 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
668
669 let repo_root = find_repository_root(¤t_dir)
670 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
671
672 let mut manager = StackManager::new(&repo_root)?;
673
674 let active_stack = manager.get_active_stack();
675
676 if active_stack.is_none() {
677 println!("ℹ️ No active stack to deactivate");
678 return Ok(());
679 }
680
681 let stack_name = active_stack.unwrap().name.clone();
682
683 if !force {
684 println!("⚠️ This will deactivate stack '{stack_name}' and return to normal Git workflow");
685 println!(" You can reactivate it later with 'ca stacks switch {stack_name}'");
686 print!(" Continue? (y/N): ");
687
688 use std::io::{self, Write};
689 io::stdout().flush().unwrap();
690
691 let mut input = String::new();
692 io::stdin().read_line(&mut input).unwrap();
693
694 if !input.trim().to_lowercase().starts_with('y') {
695 println!("Cancelled deactivation");
696 return Ok(());
697 }
698 }
699
700 manager.set_active_stack(None)?;
702
703 println!("✅ Deactivated stack '{stack_name}'");
704 println!(" Stack management is now OFF - you can use normal Git workflow");
705 println!(" To reactivate: ca stacks switch {stack_name}");
706
707 Ok(())
708}
709
710async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
711 let current_dir = env::current_dir()
712 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
713
714 let repo_root = find_repository_root(¤t_dir)
715 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
716
717 let stack_manager = StackManager::new(&repo_root)?;
718
719 let (stack_id, stack_name, stack_base, stack_entries) = {
721 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
722 CascadeError::config(
723 "No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
724 .to_string(),
725 )
726 })?;
727
728 (
729 active_stack.id,
730 active_stack.name.clone(),
731 active_stack.base_branch.clone(),
732 active_stack.entries.clone(),
733 )
734 };
735
736 println!("📊 Stack: {stack_name}");
737 println!(" Base branch: {stack_base}");
738 println!(" Total entries: {}", stack_entries.len());
739
740 if stack_entries.is_empty() {
741 println!(" No entries in this stack yet");
742 println!(" Use 'ca push' to add commits to this stack");
743 return Ok(());
744 }
745
746 println!("\n📚 Stack Entries:");
748 for (i, entry) in stack_entries.iter().enumerate() {
749 let entry_num = i + 1;
750 let short_hash = entry.short_hash();
751 let short_msg = entry.short_message(50);
752
753 let metadata = stack_manager.get_repository_metadata();
755 let source_branch_info = if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash)
756 {
757 if commit_meta.source_branch != commit_meta.branch
758 && !commit_meta.source_branch.is_empty()
759 {
760 format!(" (from {})", commit_meta.source_branch)
761 } else {
762 String::new()
763 }
764 } else {
765 String::new()
766 };
767
768 println!(
769 " {entry_num}. {} {} {}{}",
770 short_hash,
771 if entry.is_submitted { "📤" } else { "📝" },
772 short_msg,
773 source_branch_info
774 );
775
776 if verbose {
777 println!(" Branch: {}", entry.branch);
778 println!(
779 " Created: {}",
780 entry.created_at.format("%Y-%m-%d %H:%M")
781 );
782 if let Some(pr_id) = &entry.pull_request_id {
783 println!(" PR: #{pr_id}");
784 }
785 }
786 }
787
788 if show_mergeable {
790 println!("\n🔍 Mergability Status:");
791
792 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
794 let config_path = config_dir.join("config.json");
795 let settings = crate::config::Settings::load_from_file(&config_path)?;
796
797 let cascade_config = crate::config::CascadeConfig {
798 bitbucket: Some(settings.bitbucket.clone()),
799 git: settings.git.clone(),
800 auth: crate::config::AuthConfig::default(),
801 cascade: settings.cascade.clone(),
802 };
803
804 let integration =
805 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
806
807 match integration.check_enhanced_stack_status(&stack_id).await {
808 Ok(status) => {
809 println!(" Total entries: {}", status.total_entries);
810 println!(" Submitted: {}", status.submitted_entries);
811 println!(" Open PRs: {}", status.open_prs);
812 println!(" Merged PRs: {}", status.merged_prs);
813 println!(" Declined PRs: {}", status.declined_prs);
814 println!(" Completion: {:.1}%", status.completion_percentage());
815
816 if !status.enhanced_statuses.is_empty() {
817 println!("\n📋 Pull Request Status:");
818 let mut ready_to_land = 0;
819
820 for enhanced in &status.enhanced_statuses {
821 let status_display = enhanced.get_display_status();
822 let ready_icon = if enhanced.is_ready_to_land() {
823 ready_to_land += 1;
824 "🚀"
825 } else {
826 "⏳"
827 };
828
829 println!(
830 " {} PR #{}: {} ({})",
831 ready_icon, enhanced.pr.id, enhanced.pr.title, status_display
832 );
833
834 if verbose {
835 println!(
836 " {} -> {}",
837 enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
838 );
839
840 if !enhanced.is_ready_to_land() {
842 let blocking = enhanced.get_blocking_reasons();
843 if !blocking.is_empty() {
844 println!(" Blocking: {}", blocking.join(", "));
845 }
846 }
847
848 println!(
850 " Reviews: {}/{} approvals",
851 enhanced.review_status.current_approvals,
852 enhanced.review_status.required_approvals
853 );
854
855 if enhanced.review_status.needs_work_count > 0 {
856 println!(
857 " {} reviewers requested changes",
858 enhanced.review_status.needs_work_count
859 );
860 }
861
862 if let Some(build) = &enhanced.build_status {
864 let build_icon = match build.state {
865 crate::bitbucket::pull_request::BuildState::Successful => "✅",
866 crate::bitbucket::pull_request::BuildState::Failed => "❌",
867 crate::bitbucket::pull_request::BuildState::InProgress => "🔄",
868 _ => "⚪",
869 };
870 println!(" Build: {} {:?}", build_icon, build.state);
871 }
872
873 if let Some(url) = enhanced.pr.web_url() {
874 println!(" URL: {url}");
875 }
876 println!();
877 }
878 }
879
880 if ready_to_land > 0 {
881 println!(
882 "\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
883 ready_to_land,
884 if ready_to_land == 1 { " is" } else { "s are" }
885 );
886 }
887 }
888 }
889 Err(e) => {
890 warn!("Failed to get enhanced stack status: {}", e);
891 println!(" ⚠️ Could not fetch mergability status");
892 println!(" Use 'ca stack show --verbose' for basic PR information");
893 }
894 }
895 } else {
896 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
898 let config_path = config_dir.join("config.json");
899 let settings = crate::config::Settings::load_from_file(&config_path)?;
900
901 let cascade_config = crate::config::CascadeConfig {
902 bitbucket: Some(settings.bitbucket.clone()),
903 git: settings.git.clone(),
904 auth: crate::config::AuthConfig::default(),
905 cascade: settings.cascade.clone(),
906 };
907
908 let integration =
909 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
910
911 match integration.check_stack_status(&stack_id).await {
912 Ok(status) => {
913 println!("\n📊 Pull Request Status:");
914 println!(" Total entries: {}", status.total_entries);
915 println!(" Submitted: {}", status.submitted_entries);
916 println!(" Open PRs: {}", status.open_prs);
917 println!(" Merged PRs: {}", status.merged_prs);
918 println!(" Declined PRs: {}", status.declined_prs);
919 println!(" Completion: {:.1}%", status.completion_percentage());
920
921 if !status.pull_requests.is_empty() {
922 println!("\n📋 Pull Requests:");
923 for pr in &status.pull_requests {
924 let state_icon = match pr.state {
925 crate::bitbucket::PullRequestState::Open => "🔄",
926 crate::bitbucket::PullRequestState::Merged => "✅",
927 crate::bitbucket::PullRequestState::Declined => "❌",
928 };
929 println!(
930 " {} PR #{}: {} ({} -> {})",
931 state_icon,
932 pr.id,
933 pr.title,
934 pr.from_ref.display_id,
935 pr.to_ref.display_id
936 );
937 if let Some(url) = pr.web_url() {
938 println!(" URL: {url}");
939 }
940 }
941 }
942
943 println!("\n💡 Use 'ca stack --mergeable' to see detailed status including build and review information");
944 }
945 Err(e) => {
946 warn!("Failed to check stack status: {}", e);
947 }
948 }
949 }
950
951 Ok(())
952}
953
954#[allow(clippy::too_many_arguments)]
955async fn push_to_stack(
956 branch: Option<String>,
957 message: Option<String>,
958 commit: Option<String>,
959 since: Option<String>,
960 commits: Option<String>,
961 squash: Option<usize>,
962 squash_since: Option<String>,
963 auto_branch: bool,
964 allow_base_branch: bool,
965) -> Result<()> {
966 let current_dir = env::current_dir()
967 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
968
969 let repo_root = find_repository_root(¤t_dir)
970 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
971
972 let mut manager = StackManager::new(&repo_root)?;
973 let repo = GitRepository::open(&repo_root)?;
974
975 if !manager.check_for_branch_change()? {
977 return Ok(()); }
979
980 let active_stack = manager.get_active_stack().ok_or_else(|| {
982 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
983 })?;
984
985 let current_branch = repo.get_current_branch()?;
987 let base_branch = &active_stack.base_branch;
988
989 if current_branch == *base_branch {
990 println!("🚨 WARNING: You're currently on the base branch '{base_branch}'");
991 println!(" Making commits directly on the base branch is not recommended.");
992 println!(" This can pollute the base branch with work-in-progress commits.");
993
994 if allow_base_branch {
996 println!(" ⚠️ Proceeding anyway due to --allow-base-branch flag");
997 } else {
998 let has_changes = repo.is_dirty()?;
1000
1001 if has_changes {
1002 if auto_branch {
1003 let feature_branch = format!("feature/{}-work", active_stack.name);
1005 println!("🚀 Auto-creating feature branch '{feature_branch}'...");
1006
1007 repo.create_branch(&feature_branch, None)?;
1008 repo.checkout_branch(&feature_branch)?;
1009
1010 println!("✅ Created and switched to '{feature_branch}'");
1011 println!(" You can now commit and push your changes safely");
1012
1013 } else {
1015 println!("\n💡 You have uncommitted changes. Here are your options:");
1016 println!(" 1. Create a feature branch first:");
1017 println!(" git checkout -b feature/my-work");
1018 println!(" git commit -am \"your work\"");
1019 println!(" ca push");
1020 println!("\n 2. Auto-create a branch (recommended):");
1021 println!(" ca push --auto-branch");
1022 println!("\n 3. Force push to base branch (dangerous):");
1023 println!(" ca push --allow-base-branch");
1024
1025 return Err(CascadeError::config(
1026 "Refusing to push uncommitted changes from base branch. Use one of the options above."
1027 ));
1028 }
1029 } else {
1030 let commits_to_check = if let Some(commits_str) = &commits {
1032 commits_str
1033 .split(',')
1034 .map(|s| s.trim().to_string())
1035 .collect::<Vec<String>>()
1036 } else if let Some(since_ref) = &since {
1037 let since_commit = repo.resolve_reference(since_ref)?;
1038 let head_commit = repo.get_head_commit()?;
1039 let commits = repo.get_commits_between(
1040 &since_commit.id().to_string(),
1041 &head_commit.id().to_string(),
1042 )?;
1043 commits.into_iter().map(|c| c.id().to_string()).collect()
1044 } else if commit.is_none() {
1045 let mut unpushed = Vec::new();
1046 let head_commit = repo.get_head_commit()?;
1047 let mut current_commit = head_commit;
1048
1049 loop {
1050 let commit_hash = current_commit.id().to_string();
1051 let already_in_stack = active_stack
1052 .entries
1053 .iter()
1054 .any(|entry| entry.commit_hash == commit_hash);
1055
1056 if already_in_stack {
1057 break;
1058 }
1059
1060 unpushed.push(commit_hash);
1061
1062 if let Some(parent) = current_commit.parents().next() {
1063 current_commit = parent;
1064 } else {
1065 break;
1066 }
1067 }
1068
1069 unpushed.reverse();
1070 unpushed
1071 } else {
1072 vec![repo.get_head_commit()?.id().to_string()]
1073 };
1074
1075 if !commits_to_check.is_empty() {
1076 if auto_branch {
1077 let feature_branch = format!("feature/{}-work", active_stack.name);
1079 println!("🚀 Auto-creating feature branch '{feature_branch}'...");
1080
1081 repo.create_branch(&feature_branch, Some(base_branch))?;
1082 repo.checkout_branch(&feature_branch)?;
1083
1084 println!(
1086 "🍒 Cherry-picking {} commit(s) to new branch...",
1087 commits_to_check.len()
1088 );
1089 for commit_hash in &commits_to_check {
1090 match repo.cherry_pick(commit_hash) {
1091 Ok(_) => println!(" ✅ Cherry-picked {}", &commit_hash[..8]),
1092 Err(e) => {
1093 println!(
1094 " ❌ Failed to cherry-pick {}: {}",
1095 &commit_hash[..8],
1096 e
1097 );
1098 println!(" 💡 You may need to resolve conflicts manually");
1099 return Err(CascadeError::branch(format!(
1100 "Failed to cherry-pick commit {commit_hash}: {e}"
1101 )));
1102 }
1103 }
1104 }
1105
1106 println!(
1107 "✅ Successfully moved {} commit(s) to '{feature_branch}'",
1108 commits_to_check.len()
1109 );
1110 println!(
1111 " You're now on the feature branch and can continue with 'ca push'"
1112 );
1113
1114 } else {
1116 println!(
1117 "\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
1118 commits_to_check.len()
1119 );
1120 println!(" These commits are currently ON the base branch, which may not be intended.");
1121 println!("\n Options:");
1122 println!(" 1. Auto-create feature branch and cherry-pick commits:");
1123 println!(" ca push --auto-branch");
1124 println!("\n 2. Manually create branch and move commits:");
1125 println!(" git checkout -b feature/my-work");
1126 println!(" ca push");
1127 println!("\n 3. Force push from base branch (not recommended):");
1128 println!(" ca push --allow-base-branch");
1129
1130 return Err(CascadeError::config(
1131 "Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
1132 ));
1133 }
1134 }
1135 }
1136 }
1137 }
1138
1139 if let Some(squash_count) = squash {
1141 if squash_count == 0 {
1142 let active_stack = manager.get_active_stack().ok_or_else(|| {
1144 CascadeError::config(
1145 "No active stack. Create a stack first with 'ca stacks create'",
1146 )
1147 })?;
1148
1149 let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
1150
1151 if unpushed_count == 0 {
1152 println!("ℹ️ No unpushed commits to squash");
1153 } else if unpushed_count == 1 {
1154 println!("ℹ️ Only 1 unpushed commit, no squashing needed");
1155 } else {
1156 println!("🔄 Auto-detected {unpushed_count} unpushed commits, squashing...");
1157 squash_commits(&repo, unpushed_count, None).await?;
1158 println!("✅ Squashed {unpushed_count} unpushed commits into one");
1159 }
1160 } else {
1161 println!("🔄 Squashing last {squash_count} commits...");
1162 squash_commits(&repo, squash_count, None).await?;
1163 println!("✅ Squashed {squash_count} commits into one");
1164 }
1165 } else if let Some(since_ref) = squash_since {
1166 println!("🔄 Squashing commits since {since_ref}...");
1167 let since_commit = repo.resolve_reference(&since_ref)?;
1168 let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
1169 squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
1170 println!("✅ Squashed {commits_count} commits since {since_ref} into one");
1171 }
1172
1173 let commits_to_push = if let Some(commits_str) = commits {
1175 commits_str
1177 .split(',')
1178 .map(|s| s.trim().to_string())
1179 .collect::<Vec<String>>()
1180 } else if let Some(since_ref) = since {
1181 let since_commit = repo.resolve_reference(&since_ref)?;
1183 let head_commit = repo.get_head_commit()?;
1184
1185 let commits = repo.get_commits_between(
1187 &since_commit.id().to_string(),
1188 &head_commit.id().to_string(),
1189 )?;
1190 commits.into_iter().map(|c| c.id().to_string()).collect()
1191 } else if let Some(hash) = commit {
1192 vec![hash]
1194 } else {
1195 let active_stack = manager.get_active_stack().ok_or_else(|| {
1197 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1198 })?;
1199
1200 let base_branch = &active_stack.base_branch;
1202 let current_branch = repo.get_current_branch()?;
1203
1204 if current_branch == *base_branch {
1206 let mut unpushed = Vec::new();
1207 let head_commit = repo.get_head_commit()?;
1208 let mut current_commit = head_commit;
1209
1210 loop {
1212 let commit_hash = current_commit.id().to_string();
1213 let already_in_stack = active_stack
1214 .entries
1215 .iter()
1216 .any(|entry| entry.commit_hash == commit_hash);
1217
1218 if already_in_stack {
1219 break;
1220 }
1221
1222 unpushed.push(commit_hash);
1223
1224 if let Some(parent) = current_commit.parents().next() {
1226 current_commit = parent;
1227 } else {
1228 break;
1229 }
1230 }
1231
1232 unpushed.reverse(); unpushed
1234 } else {
1235 match repo.get_commits_between(base_branch, ¤t_branch) {
1237 Ok(commits) => {
1238 let mut unpushed: Vec<String> =
1239 commits.into_iter().map(|c| c.id().to_string()).collect();
1240
1241 unpushed.retain(|commit_hash| {
1243 !active_stack
1244 .entries
1245 .iter()
1246 .any(|entry| entry.commit_hash == *commit_hash)
1247 });
1248
1249 unpushed.reverse(); unpushed
1251 }
1252 Err(e) => {
1253 return Err(CascadeError::branch(format!(
1254 "Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
1255 This usually means the branches have diverged or don't share common history."
1256 )));
1257 }
1258 }
1259 }
1260 };
1261
1262 if commits_to_push.is_empty() {
1263 println!("ℹ️ No commits to push to stack");
1264 return Ok(());
1265 }
1266
1267 let mut pushed_count = 0;
1269 let mut source_branches = std::collections::HashSet::new();
1270
1271 for (i, commit_hash) in commits_to_push.iter().enumerate() {
1272 let commit_obj = repo.get_commit(commit_hash)?;
1273 let commit_msg = commit_obj.message().unwrap_or("").to_string();
1274
1275 let commit_source_branch = repo
1277 .find_branch_containing_commit(commit_hash)
1278 .unwrap_or_else(|_| current_branch.clone());
1279 source_branches.insert(commit_source_branch.clone());
1280
1281 let branch_name = if i == 0 && branch.is_some() {
1283 branch.clone().unwrap()
1284 } else {
1285 let temp_repo = GitRepository::open(&repo_root)?;
1287 let branch_mgr = crate::git::BranchManager::new(temp_repo);
1288 branch_mgr.generate_branch_name(&commit_msg)
1289 };
1290
1291 let final_message = if i == 0 && message.is_some() {
1293 message.clone().unwrap()
1294 } else {
1295 commit_msg.clone()
1296 };
1297
1298 let entry_id = manager.push_to_stack(
1299 branch_name.clone(),
1300 commit_hash.clone(),
1301 final_message.clone(),
1302 commit_source_branch.clone(),
1303 )?;
1304 pushed_count += 1;
1305
1306 println!(
1307 "✅ Pushed commit {}/{} to stack",
1308 i + 1,
1309 commits_to_push.len()
1310 );
1311 println!(
1312 " Commit: {} ({})",
1313 &commit_hash[..8],
1314 commit_msg.split('\n').next().unwrap_or("")
1315 );
1316 println!(" Branch: {branch_name}");
1317 println!(" Source: {commit_source_branch}");
1318 println!(" Entry ID: {entry_id}");
1319 println!();
1320 }
1321
1322 if source_branches.len() > 1 {
1324 println!("⚠️ WARNING: Scattered Commit Detection");
1325 println!(
1326 " You've pushed commits from {} different Git branches:",
1327 source_branches.len()
1328 );
1329 for branch in &source_branches {
1330 println!(" • {branch}");
1331 }
1332 println!();
1333 println!(" This can lead to confusion because:");
1334 println!(" • Stack appears sequential but commits are scattered across branches");
1335 println!(" • Team members won't know which branch contains which work");
1336 println!(" • Branch cleanup becomes unclear after merge");
1337 println!(" • Rebase operations become more complex");
1338 println!();
1339 println!(" 💡 Consider consolidating work to a single feature branch:");
1340 println!(" 1. Create a new feature branch: git checkout -b feature/consolidated-work");
1341 println!(" 2. Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
1342 println!(" 3. Delete old scattered branches");
1343 println!(" 4. Push the consolidated branch to your stack");
1344 println!();
1345 }
1346
1347 println!(
1348 "🎉 Successfully pushed {} commit{} to stack",
1349 pushed_count,
1350 if pushed_count == 1 { "" } else { "s" }
1351 );
1352
1353 Ok(())
1354}
1355
1356async fn pop_from_stack(keep_branch: bool) -> Result<()> {
1357 let current_dir = env::current_dir()
1358 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1359
1360 let repo_root = find_repository_root(¤t_dir)
1361 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1362
1363 let mut manager = StackManager::new(&repo_root)?;
1364 let repo = GitRepository::open(&repo_root)?;
1365
1366 let entry = manager.pop_from_stack()?;
1367
1368 info!("✅ Popped commit from stack");
1369 info!(
1370 " Commit: {} ({})",
1371 entry.short_hash(),
1372 entry.short_message(50)
1373 );
1374 info!(" Branch: {}", entry.branch);
1375
1376 if !keep_branch && entry.branch != repo.get_current_branch()? {
1378 match repo.delete_branch(&entry.branch) {
1379 Ok(_) => info!(" Deleted branch: {}", entry.branch),
1380 Err(e) => warn!(" Could not delete branch {}: {}", entry.branch, e),
1381 }
1382 }
1383
1384 Ok(())
1385}
1386
1387async fn submit_entry(
1388 entry: Option<usize>,
1389 title: Option<String>,
1390 description: Option<String>,
1391 range: Option<String>,
1392 draft: bool,
1393) -> Result<()> {
1394 let current_dir = env::current_dir()
1395 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1396
1397 let repo_root = find_repository_root(¤t_dir)
1398 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1399
1400 let mut stack_manager = StackManager::new(&repo_root)?;
1401
1402 if !stack_manager.check_for_branch_change()? {
1404 return Ok(()); }
1406
1407 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1409 let config_path = config_dir.join("config.json");
1410 let settings = crate::config::Settings::load_from_file(&config_path)?;
1411
1412 let cascade_config = crate::config::CascadeConfig {
1414 bitbucket: Some(settings.bitbucket.clone()),
1415 git: settings.git.clone(),
1416 auth: crate::config::AuthConfig::default(),
1417 cascade: settings.cascade.clone(),
1418 };
1419
1420 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1422 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1423 })?;
1424 let stack_id = active_stack.id;
1425
1426 let entries_to_submit = if let Some(range_str) = range {
1428 let mut entries = Vec::new();
1430
1431 if range_str.contains('-') {
1432 let parts: Vec<&str> = range_str.split('-').collect();
1434 if parts.len() != 2 {
1435 return Err(CascadeError::config(
1436 "Invalid range format. Use 'start-end' (e.g., '1-3')",
1437 ));
1438 }
1439
1440 let start: usize = parts[0]
1441 .parse()
1442 .map_err(|_| CascadeError::config("Invalid start number in range"))?;
1443 let end: usize = parts[1]
1444 .parse()
1445 .map_err(|_| CascadeError::config("Invalid end number in range"))?;
1446
1447 if start == 0
1448 || end == 0
1449 || start > active_stack.entries.len()
1450 || end > active_stack.entries.len()
1451 {
1452 return Err(CascadeError::config(format!(
1453 "Range out of bounds. Stack has {} entries",
1454 active_stack.entries.len()
1455 )));
1456 }
1457
1458 for i in start..=end {
1459 entries.push((i, active_stack.entries[i - 1].clone()));
1460 }
1461 } else {
1462 for entry_str in range_str.split(',') {
1464 let entry_num: usize = entry_str.trim().parse().map_err(|_| {
1465 CascadeError::config(format!("Invalid entry number: {entry_str}"))
1466 })?;
1467
1468 if entry_num == 0 || entry_num > active_stack.entries.len() {
1469 return Err(CascadeError::config(format!(
1470 "Entry {} out of bounds. Stack has {} entries",
1471 entry_num,
1472 active_stack.entries.len()
1473 )));
1474 }
1475
1476 entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
1477 }
1478 }
1479
1480 entries
1481 } else if let Some(entry_num) = entry {
1482 if entry_num == 0 || entry_num > active_stack.entries.len() {
1484 return Err(CascadeError::config(format!(
1485 "Invalid entry number: {}. Stack has {} entries",
1486 entry_num,
1487 active_stack.entries.len()
1488 )));
1489 }
1490 vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
1491 } else {
1492 active_stack
1494 .entries
1495 .iter()
1496 .enumerate()
1497 .filter(|(_, entry)| !entry.is_submitted)
1498 .map(|(i, entry)| (i + 1, entry.clone())) .collect::<Vec<(usize, _)>>()
1500 };
1501
1502 if entries_to_submit.is_empty() {
1503 println!("ℹ️ No entries to submit");
1504 return Ok(());
1505 }
1506
1507 let total_operations = entries_to_submit.len() + 2; let pb = ProgressBar::new(total_operations as u64);
1510 pb.set_style(
1511 ProgressStyle::default_bar()
1512 .template("📤 {msg} [{bar:40.cyan/blue}] {pos}/{len}")
1513 .map_err(|e| CascadeError::config(format!("Progress bar template error: {e}")))?,
1514 );
1515
1516 pb.set_message("Connecting to Bitbucket");
1517 pb.inc(1);
1518
1519 let integration_stack_manager = StackManager::new(&repo_root)?;
1521 let mut integration =
1522 BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
1523
1524 pb.set_message("Starting batch submission");
1525 pb.inc(1);
1526
1527 let mut submitted_count = 0;
1529 let mut failed_entries = Vec::new();
1530 let total_entries = entries_to_submit.len();
1531
1532 for (entry_num, entry_to_submit) in &entries_to_submit {
1533 pb.set_message("Submitting entries...");
1534
1535 let entry_title = if total_entries == 1 {
1537 title.clone()
1538 } else {
1539 None
1540 };
1541 let entry_description = if total_entries == 1 {
1542 description.clone()
1543 } else {
1544 None
1545 };
1546
1547 match integration
1548 .submit_entry(
1549 &stack_id,
1550 &entry_to_submit.id,
1551 entry_title,
1552 entry_description,
1553 draft,
1554 )
1555 .await
1556 {
1557 Ok(pr) => {
1558 submitted_count += 1;
1559 println!("✅ Entry {} - PR #{}: {}", entry_num, pr.id, pr.title);
1560 if let Some(url) = pr.web_url() {
1561 println!(" URL: {url}");
1562 }
1563 println!(
1564 " From: {} -> {}",
1565 pr.from_ref.display_id, pr.to_ref.display_id
1566 );
1567 println!();
1568 }
1569 Err(e) => {
1570 failed_entries.push((*entry_num, e.to_string()));
1571 println!("❌ Entry {entry_num} failed: {e}");
1572 }
1573 }
1574
1575 pb.inc(1);
1576 }
1577
1578 if failed_entries.is_empty() {
1579 pb.finish_with_message("✅ All pull requests created successfully");
1580 println!(
1581 "🎉 Successfully submitted {} entr{}",
1582 submitted_count,
1583 if submitted_count == 1 { "y" } else { "ies" }
1584 );
1585 } else {
1586 pb.abandon_with_message("⚠️ Some submissions failed");
1587 println!("📊 Submission Summary:");
1588 println!(" ✅ Successful: {submitted_count}");
1589 println!(" ❌ Failed: {}", failed_entries.len());
1590 println!();
1591 println!("💡 Failed entries:");
1592 for (entry_num, error) in failed_entries {
1593 println!(" - Entry {entry_num}: {error}");
1594 }
1595 println!();
1596 println!("💡 You can retry failed entries individually:");
1597 println!(" ca stack submit <ENTRY_NUMBER>");
1598 }
1599
1600 Ok(())
1601}
1602
1603async fn check_stack_status(name: Option<String>) -> Result<()> {
1604 let current_dir = env::current_dir()
1605 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1606
1607 let repo_root = find_repository_root(¤t_dir)
1608 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1609
1610 let stack_manager = StackManager::new(&repo_root)?;
1611
1612 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1614 let config_path = config_dir.join("config.json");
1615 let settings = crate::config::Settings::load_from_file(&config_path)?;
1616
1617 let cascade_config = crate::config::CascadeConfig {
1619 bitbucket: Some(settings.bitbucket.clone()),
1620 git: settings.git.clone(),
1621 auth: crate::config::AuthConfig::default(),
1622 cascade: settings.cascade.clone(),
1623 };
1624
1625 let stack = if let Some(name) = name {
1627 stack_manager
1628 .get_stack_by_name(&name)
1629 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
1630 } else {
1631 stack_manager.get_active_stack().ok_or_else(|| {
1632 CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
1633 })?
1634 };
1635 let stack_id = stack.id;
1636
1637 println!("📋 Stack: {}", stack.name);
1638 println!(" ID: {}", stack.id);
1639 println!(" Base: {}", stack.base_branch);
1640
1641 if let Some(description) = &stack.description {
1642 println!(" Description: {description}");
1643 }
1644
1645 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1647
1648 match integration.check_stack_status(&stack_id).await {
1650 Ok(status) => {
1651 println!("\n📊 Pull Request Status:");
1652 println!(" Total entries: {}", status.total_entries);
1653 println!(" Submitted: {}", status.submitted_entries);
1654 println!(" Open PRs: {}", status.open_prs);
1655 println!(" Merged PRs: {}", status.merged_prs);
1656 println!(" Declined PRs: {}", status.declined_prs);
1657 println!(" Completion: {:.1}%", status.completion_percentage());
1658
1659 if !status.pull_requests.is_empty() {
1660 println!("\n📋 Pull Requests:");
1661 for pr in &status.pull_requests {
1662 let state_icon = match pr.state {
1663 crate::bitbucket::PullRequestState::Open => "🔄",
1664 crate::bitbucket::PullRequestState::Merged => "✅",
1665 crate::bitbucket::PullRequestState::Declined => "❌",
1666 };
1667 println!(
1668 " {} PR #{}: {} ({} -> {})",
1669 state_icon, pr.id, pr.title, pr.from_ref.display_id, pr.to_ref.display_id
1670 );
1671 if let Some(url) = pr.web_url() {
1672 println!(" URL: {url}");
1673 }
1674 }
1675 }
1676 }
1677 Err(e) => {
1678 warn!("Failed to check stack status: {}", e);
1679 return Err(e);
1680 }
1681 }
1682
1683 Ok(())
1684}
1685
1686async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
1687 let current_dir = env::current_dir()
1688 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1689
1690 let repo_root = find_repository_root(¤t_dir)
1691 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1692
1693 let stack_manager = StackManager::new(&repo_root)?;
1694
1695 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1697 let config_path = config_dir.join("config.json");
1698 let settings = crate::config::Settings::load_from_file(&config_path)?;
1699
1700 let cascade_config = crate::config::CascadeConfig {
1702 bitbucket: Some(settings.bitbucket.clone()),
1703 git: settings.git.clone(),
1704 auth: crate::config::AuthConfig::default(),
1705 cascade: settings.cascade.clone(),
1706 };
1707
1708 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1710
1711 let pr_state = if let Some(state_str) = state {
1713 match state_str.to_lowercase().as_str() {
1714 "open" => Some(crate::bitbucket::PullRequestState::Open),
1715 "merged" => Some(crate::bitbucket::PullRequestState::Merged),
1716 "declined" => Some(crate::bitbucket::PullRequestState::Declined),
1717 _ => {
1718 return Err(CascadeError::config(format!(
1719 "Invalid state '{state_str}'. Use: open, merged, declined"
1720 )))
1721 }
1722 }
1723 } else {
1724 None
1725 };
1726
1727 match integration.list_pull_requests(pr_state).await {
1729 Ok(pr_page) => {
1730 if pr_page.values.is_empty() {
1731 info!("No pull requests found.");
1732 return Ok(());
1733 }
1734
1735 println!("📋 Pull Requests ({} total):", pr_page.values.len());
1736 for pr in &pr_page.values {
1737 let state_icon = match pr.state {
1738 crate::bitbucket::PullRequestState::Open => "🔄",
1739 crate::bitbucket::PullRequestState::Merged => "✅",
1740 crate::bitbucket::PullRequestState::Declined => "❌",
1741 };
1742 println!(" {} PR #{}: {}", state_icon, pr.id, pr.title);
1743 if verbose {
1744 println!(
1745 " From: {} -> {}",
1746 pr.from_ref.display_id, pr.to_ref.display_id
1747 );
1748 println!(" Author: {}", pr.author.user.display_name);
1749 if let Some(url) = pr.web_url() {
1750 println!(" URL: {url}");
1751 }
1752 if let Some(desc) = &pr.description {
1753 if !desc.is_empty() {
1754 println!(" Description: {desc}");
1755 }
1756 }
1757 println!();
1758 }
1759 }
1760
1761 if !verbose {
1762 println!("\nUse --verbose for more details");
1763 }
1764 }
1765 Err(e) => {
1766 warn!("Failed to list pull requests: {}", e);
1767 return Err(e);
1768 }
1769 }
1770
1771 Ok(())
1772}
1773
1774async fn check_stack(_force: bool) -> Result<()> {
1775 let current_dir = env::current_dir()
1776 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1777
1778 let repo_root = find_repository_root(¤t_dir)
1779 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1780
1781 let mut manager = StackManager::new(&repo_root)?;
1782
1783 let active_stack = manager
1784 .get_active_stack()
1785 .ok_or_else(|| CascadeError::config("No active stack"))?;
1786 let stack_id = active_stack.id;
1787
1788 manager.sync_stack(&stack_id)?;
1789
1790 info!("✅ Stack check completed successfully");
1791
1792 Ok(())
1793}
1794
1795async fn sync_stack(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
1796 let current_dir = env::current_dir()
1797 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1798
1799 let repo_root = find_repository_root(¤t_dir)
1800 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1801
1802 let stack_manager = StackManager::new(&repo_root)?;
1803 let git_repo = GitRepository::open(&repo_root)?;
1804
1805 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1807 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1808 })?;
1809
1810 let base_branch = active_stack.base_branch.clone();
1811 let stack_name = active_stack.name.clone();
1812
1813 println!("🔄 Syncing stack '{stack_name}' with remote...");
1814
1815 println!("📥 Pulling latest changes from '{base_branch}'...");
1817
1818 match git_repo.checkout_branch(&base_branch) {
1820 Ok(_) => {
1821 println!(" ✅ Switched to '{base_branch}'");
1822
1823 match git_repo.pull(&base_branch) {
1825 Ok(_) => {
1826 println!(" ✅ Successfully pulled latest changes");
1827 }
1828 Err(e) => {
1829 if force {
1830 println!(" ⚠️ Failed to pull: {e} (continuing due to --force)");
1831 } else {
1832 return Err(CascadeError::branch(format!(
1833 "Failed to pull latest changes from '{base_branch}': {e}. Use --force to continue anyway."
1834 )));
1835 }
1836 }
1837 }
1838 }
1839 Err(e) => {
1840 if force {
1841 println!(
1842 " ⚠️ Failed to checkout '{base_branch}': {e} (continuing due to --force)"
1843 );
1844 } else {
1845 return Err(CascadeError::branch(format!(
1846 "Failed to checkout base branch '{base_branch}': {e}. Use --force to continue anyway."
1847 )));
1848 }
1849 }
1850 }
1851
1852 println!("🔍 Checking if stack needs rebase...");
1854
1855 let mut updated_stack_manager = StackManager::new(&repo_root)?;
1856 let stack_id = active_stack.id;
1857
1858 match updated_stack_manager.sync_stack(&stack_id) {
1859 Ok(_) => {
1860 if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
1862 match &updated_stack.status {
1863 crate::stack::StackStatus::NeedsSync => {
1864 println!(" 🔄 Stack needs rebase due to new commits on '{base_branch}'");
1865
1866 println!("🔀 Rebasing stack onto updated '{base_branch}'...");
1868
1869 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1871 let config_path = config_dir.join("config.json");
1872 let settings = crate::config::Settings::load_from_file(&config_path)?;
1873
1874 let cascade_config = crate::config::CascadeConfig {
1875 bitbucket: Some(settings.bitbucket.clone()),
1876 git: settings.git.clone(),
1877 auth: crate::config::AuthConfig::default(),
1878 cascade: settings.cascade.clone(),
1879 };
1880
1881 let options = crate::stack::RebaseOptions {
1883 strategy: crate::stack::RebaseStrategy::BranchVersioning,
1884 interactive,
1885 target_base: Some(base_branch.clone()),
1886 preserve_merges: true,
1887 auto_resolve: !interactive,
1888 max_retries: 3,
1889 skip_pull: Some(true), };
1891
1892 let mut rebase_manager = crate::stack::RebaseManager::new(
1893 updated_stack_manager,
1894 git_repo,
1895 options,
1896 );
1897
1898 match rebase_manager.rebase_stack(&stack_id) {
1899 Ok(result) => {
1900 println!(" ✅ Rebase completed successfully!");
1901
1902 if !result.branch_mapping.is_empty() {
1903 println!(" 📋 Updated branches:");
1904 for (old, new) in &result.branch_mapping {
1905 println!(" {old} → {new}");
1906 }
1907
1908 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
1910 println!(" 🔄 Updating pull requests...");
1911
1912 let integration_stack_manager =
1913 StackManager::new(&repo_root)?;
1914 let mut integration =
1915 crate::bitbucket::BitbucketIntegration::new(
1916 integration_stack_manager,
1917 cascade_config,
1918 )?;
1919
1920 match integration
1921 .update_prs_after_rebase(
1922 &stack_id,
1923 &result.branch_mapping,
1924 )
1925 .await
1926 {
1927 Ok(updated_prs) => {
1928 if !updated_prs.is_empty() {
1929 println!(
1930 " ✅ Updated {} pull requests",
1931 updated_prs.len()
1932 );
1933 }
1934 }
1935 Err(e) => {
1936 println!(
1937 " ⚠️ Failed to update pull requests: {e}"
1938 );
1939 }
1940 }
1941 }
1942 }
1943 }
1944 Err(e) => {
1945 println!(" ❌ Rebase failed: {e}");
1946 println!(" 💡 To resolve conflicts:");
1947 println!(" 1. Fix conflicts in the affected files");
1948 println!(" 2. Stage resolved files: git add <files>");
1949 println!(" 3. Continue: ca stack continue-rebase");
1950 return Err(e);
1951 }
1952 }
1953 }
1954 crate::stack::StackStatus::Clean => {
1955 println!(" ✅ Stack is already up to date");
1956 }
1957 other => {
1958 println!(" ℹ️ Stack status: {other:?}");
1959 }
1960 }
1961 }
1962 }
1963 Err(e) => {
1964 if force {
1965 println!(" ⚠️ Failed to check stack status: {e} (continuing due to --force)");
1966 } else {
1967 return Err(e);
1968 }
1969 }
1970 }
1971
1972 if !skip_cleanup {
1974 println!("🧹 Checking for merged branches to clean up...");
1975 println!(" ℹ️ Branch cleanup not yet implemented");
1981 } else {
1982 println!("⏭️ Skipping branch cleanup");
1983 }
1984
1985 println!("🎉 Sync completed successfully!");
1986 println!(" Base branch: {base_branch}");
1987 println!(" 💡 Next steps:");
1988 println!(" • Review your updated stack: ca stack show");
1989 println!(" • Check PR status: ca stack status");
1990
1991 Ok(())
1992}
1993
1994async fn rebase_stack(
1995 interactive: bool,
1996 onto: Option<String>,
1997 strategy: Option<RebaseStrategyArg>,
1998) -> Result<()> {
1999 let current_dir = env::current_dir()
2000 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2001
2002 let repo_root = find_repository_root(¤t_dir)
2003 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2004
2005 let stack_manager = StackManager::new(&repo_root)?;
2006 let git_repo = GitRepository::open(&repo_root)?;
2007
2008 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2010 let config_path = config_dir.join("config.json");
2011 let settings = crate::config::Settings::load_from_file(&config_path)?;
2012
2013 let cascade_config = crate::config::CascadeConfig {
2015 bitbucket: Some(settings.bitbucket.clone()),
2016 git: settings.git.clone(),
2017 auth: crate::config::AuthConfig::default(),
2018 cascade: settings.cascade.clone(),
2019 };
2020
2021 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2023 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2024 })?;
2025 let stack_id = active_stack.id;
2026
2027 let active_stack = stack_manager
2028 .get_stack(&stack_id)
2029 .ok_or_else(|| CascadeError::config("Active stack not found"))?
2030 .clone();
2031
2032 if active_stack.entries.is_empty() {
2033 println!("ℹ️ Stack is empty. Nothing to rebase.");
2034 return Ok(());
2035 }
2036
2037 println!("🔄 Rebasing stack: {}", active_stack.name);
2038 println!(" Base: {}", active_stack.base_branch);
2039
2040 let rebase_strategy = if let Some(cli_strategy) = strategy {
2042 match cli_strategy {
2043 RebaseStrategyArg::BranchVersioning => crate::stack::RebaseStrategy::BranchVersioning,
2044 RebaseStrategyArg::CherryPick => crate::stack::RebaseStrategy::CherryPick,
2045 RebaseStrategyArg::ThreeWayMerge => crate::stack::RebaseStrategy::ThreeWayMerge,
2046 RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
2047 }
2048 } else {
2049 match settings.cascade.default_sync_strategy.as_str() {
2051 "branch-versioning" => crate::stack::RebaseStrategy::BranchVersioning,
2052 "cherry-pick" => crate::stack::RebaseStrategy::CherryPick,
2053 "three-way-merge" => crate::stack::RebaseStrategy::ThreeWayMerge,
2054 "rebase" => crate::stack::RebaseStrategy::Interactive,
2055 _ => crate::stack::RebaseStrategy::BranchVersioning, }
2057 };
2058
2059 let options = crate::stack::RebaseOptions {
2061 strategy: rebase_strategy.clone(),
2062 interactive,
2063 target_base: onto,
2064 preserve_merges: true,
2065 auto_resolve: !interactive, max_retries: 3,
2067 skip_pull: None, };
2069
2070 info!(" Strategy: {:?}", rebase_strategy);
2071 info!(" Interactive: {}", interactive);
2072 info!(" Target base: {:?}", options.target_base);
2073 info!(" Entries: {}", active_stack.entries.len());
2074
2075 let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2077
2078 if rebase_manager.is_rebase_in_progress() {
2079 println!("⚠️ Rebase already in progress!");
2080 println!(" Use 'git status' to check the current state");
2081 println!(" Use 'ca stack continue-rebase' to continue");
2082 println!(" Use 'ca stack abort-rebase' to abort");
2083 return Ok(());
2084 }
2085
2086 match rebase_manager.rebase_stack(&stack_id) {
2088 Ok(result) => {
2089 println!("🎉 Rebase completed!");
2090 println!(" {}", result.get_summary());
2091
2092 if result.has_conflicts() {
2093 println!(" ⚠️ {} conflicts were resolved", result.conflicts.len());
2094 for conflict in &result.conflicts {
2095 println!(" - {}", &conflict[..8.min(conflict.len())]);
2096 }
2097 }
2098
2099 if !result.branch_mapping.is_empty() {
2100 println!(" 📋 Branch mapping:");
2101 for (old, new) in &result.branch_mapping {
2102 println!(" {old} -> {new}");
2103 }
2104
2105 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
2107 let integration_stack_manager = StackManager::new(&repo_root)?;
2109 let mut integration = BitbucketIntegration::new(
2110 integration_stack_manager,
2111 cascade_config.clone(),
2112 )?;
2113
2114 match integration
2115 .update_prs_after_rebase(&stack_id, &result.branch_mapping)
2116 .await
2117 {
2118 Ok(updated_prs) => {
2119 if !updated_prs.is_empty() {
2120 println!(" 🔄 Preserved pull request history:");
2121 for pr_update in updated_prs {
2122 println!(" ✅ {pr_update}");
2123 }
2124 }
2125 }
2126 Err(e) => {
2127 eprintln!(" ⚠️ Failed to update pull requests: {e}");
2128 eprintln!(" You may need to manually update PRs in Bitbucket");
2129 }
2130 }
2131 }
2132 }
2133
2134 println!(
2135 " ✅ {} commits successfully rebased",
2136 result.success_count()
2137 );
2138
2139 if matches!(
2141 rebase_strategy,
2142 crate::stack::RebaseStrategy::BranchVersioning
2143 ) {
2144 println!("\n📝 Next steps:");
2145 if !result.branch_mapping.is_empty() {
2146 println!(" 1. ✅ New versioned branches have been created");
2147 println!(" 2. ✅ Pull requests have been updated automatically");
2148 println!(" 3. 🔍 Review the updated PRs in Bitbucket");
2149 println!(" 4. 🧪 Test your changes on the new branches");
2150 println!(
2151 " 5. 🗑️ Old branches are preserved for safety (can be deleted later)"
2152 );
2153 } else {
2154 println!(" 1. Review the rebased stack");
2155 println!(" 2. Test your changes");
2156 println!(" 3. Submit new pull requests with 'ca stack submit'");
2157 }
2158 }
2159 }
2160 Err(e) => {
2161 warn!("❌ Rebase failed: {}", e);
2162 println!("💡 Tips for resolving rebase issues:");
2163 println!(" - Check for uncommitted changes with 'git status'");
2164 println!(" - Ensure base branch is up to date");
2165 println!(" - Try interactive mode: 'ca stack rebase --interactive'");
2166 return Err(e);
2167 }
2168 }
2169
2170 Ok(())
2171}
2172
2173async fn continue_rebase() -> Result<()> {
2174 let current_dir = env::current_dir()
2175 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2176
2177 let repo_root = find_repository_root(¤t_dir)
2178 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2179
2180 let stack_manager = StackManager::new(&repo_root)?;
2181 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2182 let options = crate::stack::RebaseOptions::default();
2183 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2184
2185 if !rebase_manager.is_rebase_in_progress() {
2186 println!("ℹ️ No rebase in progress");
2187 return Ok(());
2188 }
2189
2190 println!("🔄 Continuing rebase...");
2191 match rebase_manager.continue_rebase() {
2192 Ok(_) => {
2193 println!("✅ Rebase continued successfully");
2194 println!(" Check 'ca stack rebase-status' for current state");
2195 }
2196 Err(e) => {
2197 warn!("❌ Failed to continue rebase: {}", e);
2198 println!("💡 You may need to resolve conflicts first:");
2199 println!(" 1. Edit conflicted files");
2200 println!(" 2. Stage resolved files with 'git add'");
2201 println!(" 3. Run 'ca stack continue-rebase' again");
2202 }
2203 }
2204
2205 Ok(())
2206}
2207
2208async fn abort_rebase() -> Result<()> {
2209 let current_dir = env::current_dir()
2210 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2211
2212 let repo_root = find_repository_root(¤t_dir)
2213 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2214
2215 let stack_manager = StackManager::new(&repo_root)?;
2216 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2217 let options = crate::stack::RebaseOptions::default();
2218 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
2219
2220 if !rebase_manager.is_rebase_in_progress() {
2221 println!("ℹ️ No rebase in progress");
2222 return Ok(());
2223 }
2224
2225 println!("⚠️ Aborting rebase...");
2226 match rebase_manager.abort_rebase() {
2227 Ok(_) => {
2228 println!("✅ Rebase aborted successfully");
2229 println!(" Repository restored to pre-rebase state");
2230 }
2231 Err(e) => {
2232 warn!("❌ Failed to abort rebase: {}", e);
2233 println!("⚠️ You may need to manually clean up the repository state");
2234 }
2235 }
2236
2237 Ok(())
2238}
2239
2240async fn rebase_status() -> Result<()> {
2241 let current_dir = env::current_dir()
2242 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2243
2244 let repo_root = find_repository_root(¤t_dir)
2245 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2246
2247 let stack_manager = StackManager::new(&repo_root)?;
2248 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2249
2250 println!("📊 Rebase Status");
2251
2252 let git_dir = current_dir.join(".git");
2254 let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
2255 || git_dir.join("rebase-merge").exists()
2256 || git_dir.join("rebase-apply").exists();
2257
2258 if rebase_in_progress {
2259 println!(" Status: 🔄 Rebase in progress");
2260 println!(
2261 "
2262📝 Actions available:"
2263 );
2264 println!(" - 'ca stack continue-rebase' to continue");
2265 println!(" - 'ca stack abort-rebase' to abort");
2266 println!(" - 'git status' to see conflicted files");
2267
2268 match git_repo.get_status() {
2270 Ok(statuses) => {
2271 let mut conflicts = Vec::new();
2272 for status in statuses.iter() {
2273 if status.status().contains(git2::Status::CONFLICTED) {
2274 if let Some(path) = status.path() {
2275 conflicts.push(path.to_string());
2276 }
2277 }
2278 }
2279
2280 if !conflicts.is_empty() {
2281 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
2282 for conflict in conflicts {
2283 println!(" - {conflict}");
2284 }
2285 println!(
2286 "
2287💡 To resolve conflicts:"
2288 );
2289 println!(" 1. Edit the conflicted files");
2290 println!(" 2. Stage resolved files: git add <file>");
2291 println!(" 3. Continue: ca stack continue-rebase");
2292 }
2293 }
2294 Err(e) => {
2295 warn!("Failed to get git status: {}", e);
2296 }
2297 }
2298 } else {
2299 println!(" Status: ✅ No rebase in progress");
2300
2301 if let Some(active_stack) = stack_manager.get_active_stack() {
2303 println!(" Active stack: {}", active_stack.name);
2304 println!(" Entries: {}", active_stack.entries.len());
2305 println!(" Base branch: {}", active_stack.base_branch);
2306 }
2307 }
2308
2309 Ok(())
2310}
2311
2312async fn delete_stack(name: String, force: bool) -> Result<()> {
2313 let current_dir = env::current_dir()
2314 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2315
2316 let repo_root = find_repository_root(¤t_dir)
2317 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2318
2319 let mut manager = StackManager::new(&repo_root)?;
2320
2321 let stack = manager
2322 .get_stack_by_name(&name)
2323 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2324 let stack_id = stack.id;
2325
2326 if !force && !stack.entries.is_empty() {
2327 return Err(CascadeError::config(format!(
2328 "Stack '{}' has {} entries. Use --force to delete anyway",
2329 name,
2330 stack.entries.len()
2331 )));
2332 }
2333
2334 let deleted = manager.delete_stack(&stack_id)?;
2335
2336 info!("✅ Deleted stack '{}'", deleted.name);
2337 if !deleted.entries.is_empty() {
2338 warn!(" {} entries were removed", deleted.entries.len());
2339 }
2340
2341 Ok(())
2342}
2343
2344async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
2345 let current_dir = env::current_dir()
2346 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2347
2348 let repo_root = find_repository_root(¤t_dir)
2349 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2350
2351 let mut manager = StackManager::new(&repo_root)?;
2352
2353 if let Some(name) = name {
2354 let stack = manager
2356 .get_stack_by_name(&name)
2357 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
2358
2359 let stack_id = stack.id;
2360
2361 match stack.validate() {
2363 Ok(message) => {
2364 println!("✅ Stack '{name}' structure validation: {message}");
2365 }
2366 Err(e) => {
2367 println!("❌ Stack '{name}' structure validation failed: {e}");
2368 return Err(CascadeError::config(e));
2369 }
2370 }
2371
2372 manager.handle_branch_modifications(&stack_id, fix_mode)?;
2374
2375 println!("🎉 Stack '{name}' validation completed");
2376 Ok(())
2377 } else {
2378 println!("🔍 Validating all stacks...");
2380
2381 let all_stacks = manager.get_all_stacks();
2383 let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
2384
2385 if stack_ids.is_empty() {
2386 println!("📭 No stacks found");
2387 return Ok(());
2388 }
2389
2390 let mut all_valid = true;
2391 for stack_id in stack_ids {
2392 let stack = manager.get_stack(&stack_id).unwrap();
2393 let stack_name = &stack.name;
2394
2395 println!("\n📋 Checking stack '{stack_name}':");
2396
2397 match stack.validate() {
2399 Ok(message) => {
2400 println!(" ✅ Structure: {message}");
2401 }
2402 Err(e) => {
2403 println!(" ❌ Structure: {e}");
2404 all_valid = false;
2405 continue;
2406 }
2407 }
2408
2409 match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
2411 Ok(_) => {
2412 println!(" ✅ Git integrity: OK");
2413 }
2414 Err(e) => {
2415 println!(" ❌ Git integrity: {e}");
2416 all_valid = false;
2417 }
2418 }
2419 }
2420
2421 if all_valid {
2422 println!("\n🎉 All stacks passed validation");
2423 } else {
2424 println!("\n⚠️ Some stacks have validation issues");
2425 return Err(CascadeError::config("Stack validation failed".to_string()));
2426 }
2427
2428 Ok(())
2429 }
2430}
2431
2432#[allow(dead_code)]
2434fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
2435 let mut unpushed = Vec::new();
2436 let head_commit = repo.get_head_commit()?;
2437 let mut current_commit = head_commit;
2438
2439 loop {
2441 let commit_hash = current_commit.id().to_string();
2442 let already_in_stack = stack
2443 .entries
2444 .iter()
2445 .any(|entry| entry.commit_hash == commit_hash);
2446
2447 if already_in_stack {
2448 break;
2449 }
2450
2451 unpushed.push(commit_hash);
2452
2453 if let Some(parent) = current_commit.parents().next() {
2455 current_commit = parent;
2456 } else {
2457 break;
2458 }
2459 }
2460
2461 unpushed.reverse(); Ok(unpushed)
2463}
2464
2465pub async fn squash_commits(
2467 repo: &GitRepository,
2468 count: usize,
2469 since_ref: Option<String>,
2470) -> Result<()> {
2471 if count <= 1 {
2472 return Ok(()); }
2474
2475 let _current_branch = repo.get_current_branch()?;
2477
2478 let rebase_range = if let Some(ref since) = since_ref {
2480 since.clone()
2481 } else {
2482 format!("HEAD~{count}")
2483 };
2484
2485 println!(" Analyzing {count} commits to create smart squash message...");
2486
2487 let head_commit = repo.get_head_commit()?;
2489 let mut commits_to_squash = Vec::new();
2490 let mut current = head_commit;
2491
2492 for _ in 0..count {
2494 commits_to_squash.push(current.clone());
2495 if current.parent_count() > 0 {
2496 current = current.parent(0).map_err(CascadeError::Git)?;
2497 } else {
2498 break;
2499 }
2500 }
2501
2502 let smart_message = generate_squash_message(&commits_to_squash)?;
2504 println!(
2505 " Smart message: {}",
2506 smart_message.lines().next().unwrap_or("")
2507 );
2508
2509 let reset_target = if since_ref.is_some() {
2511 format!("{rebase_range}~1")
2513 } else {
2514 format!("HEAD~{count}")
2516 };
2517
2518 repo.reset_soft(&reset_target)?;
2520
2521 repo.stage_all()?;
2523
2524 let new_commit_hash = repo.commit(&smart_message)?;
2526
2527 println!(
2528 " Created squashed commit: {} ({})",
2529 &new_commit_hash[..8],
2530 smart_message.lines().next().unwrap_or("")
2531 );
2532 println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
2533
2534 Ok(())
2535}
2536
2537pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
2539 if commits.is_empty() {
2540 return Ok("Squashed commits".to_string());
2541 }
2542
2543 let messages: Vec<String> = commits
2545 .iter()
2546 .map(|c| c.message().unwrap_or("").trim().to_string())
2547 .filter(|m| !m.is_empty())
2548 .collect();
2549
2550 if messages.is_empty() {
2551 return Ok("Squashed commits".to_string());
2552 }
2553
2554 if let Some(last_msg) = messages.first() {
2556 if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
2558 return Ok(last_msg
2559 .trim_start_matches("Final:")
2560 .trim_start_matches("final:")
2561 .trim()
2562 .to_string());
2563 }
2564 }
2565
2566 let wip_count = messages
2568 .iter()
2569 .filter(|m| {
2570 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
2571 })
2572 .count();
2573
2574 if wip_count > messages.len() / 2 {
2575 let non_wip: Vec<&String> = messages
2577 .iter()
2578 .filter(|m| {
2579 !m.to_lowercase().starts_with("wip")
2580 && !m.to_lowercase().contains("work in progress")
2581 })
2582 .collect();
2583
2584 if let Some(best_msg) = non_wip.first() {
2585 return Ok(best_msg.to_string());
2586 }
2587
2588 let feature = extract_feature_from_wip(&messages);
2590 return Ok(feature);
2591 }
2592
2593 Ok(messages.first().unwrap().clone())
2595}
2596
2597pub fn extract_feature_from_wip(messages: &[String]) -> String {
2599 for msg in messages {
2601 if msg.to_lowercase().starts_with("wip:") {
2603 if let Some(rest) = msg
2604 .strip_prefix("WIP:")
2605 .or_else(|| msg.strip_prefix("wip:"))
2606 {
2607 let feature = rest.trim();
2608 if !feature.is_empty() && feature.len() > 3 {
2609 let mut chars: Vec<char> = feature.chars().collect();
2611 if let Some(first) = chars.first_mut() {
2612 *first = first.to_uppercase().next().unwrap_or(*first);
2613 }
2614 return chars.into_iter().collect();
2615 }
2616 }
2617 }
2618 }
2619
2620 if let Some(first) = messages.first() {
2622 let cleaned = first
2623 .trim_start_matches("WIP:")
2624 .trim_start_matches("wip:")
2625 .trim_start_matches("WIP")
2626 .trim_start_matches("wip")
2627 .trim();
2628
2629 if !cleaned.is_empty() {
2630 return format!("Implement {cleaned}");
2631 }
2632 }
2633
2634 format!("Squashed {} commits", messages.len())
2635}
2636
2637pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
2639 let head_commit = repo.get_head_commit()?;
2640 let since_commit = repo.get_commit(since_commit_hash)?;
2641
2642 let mut count = 0;
2643 let mut current = head_commit;
2644
2645 loop {
2647 if current.id() == since_commit.id() {
2648 break;
2649 }
2650
2651 count += 1;
2652
2653 if current.parent_count() == 0 {
2655 break; }
2657
2658 current = current.parent(0).map_err(CascadeError::Git)?;
2659 }
2660
2661 Ok(count)
2662}
2663
2664async fn land_stack(
2666 entry: Option<usize>,
2667 force: bool,
2668 dry_run: bool,
2669 auto: bool,
2670 wait_for_builds: bool,
2671 strategy: Option<MergeStrategyArg>,
2672 build_timeout: u64,
2673) -> Result<()> {
2674 let current_dir = env::current_dir()
2675 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2676
2677 let repo_root = find_repository_root(¤t_dir)
2678 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2679
2680 let stack_manager = StackManager::new(&repo_root)?;
2681
2682 let stack_id = stack_manager
2684 .get_active_stack()
2685 .map(|s| s.id)
2686 .ok_or_else(|| {
2687 CascadeError::config(
2688 "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
2689 .to_string(),
2690 )
2691 })?;
2692
2693 let active_stack = stack_manager
2694 .get_active_stack()
2695 .cloned()
2696 .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
2697
2698 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2700 let config_path = config_dir.join("config.json");
2701 let settings = crate::config::Settings::load_from_file(&config_path)?;
2702
2703 let cascade_config = crate::config::CascadeConfig {
2704 bitbucket: Some(settings.bitbucket.clone()),
2705 git: settings.git.clone(),
2706 auth: crate::config::AuthConfig::default(),
2707 cascade: settings.cascade.clone(),
2708 };
2709
2710 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2711
2712 let status = integration.check_enhanced_stack_status(&stack_id).await?;
2714
2715 if status.enhanced_statuses.is_empty() {
2716 println!("❌ No pull requests found to land");
2717 return Ok(());
2718 }
2719
2720 let ready_prs: Vec<_> = status
2722 .enhanced_statuses
2723 .iter()
2724 .filter(|pr_status| {
2725 if let Some(entry_num) = entry {
2727 if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
2729 if pr_status.pr.from_ref.display_id != stack_entry.branch {
2731 return false;
2732 }
2733 } else {
2734 return false; }
2736 }
2737
2738 if force {
2739 pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
2741 } else {
2742 pr_status.is_ready_to_land()
2743 }
2744 })
2745 .collect();
2746
2747 if ready_prs.is_empty() {
2748 if let Some(entry_num) = entry {
2749 println!("❌ Entry {entry_num} is not ready to land or doesn't exist");
2750 } else {
2751 println!("❌ No pull requests are ready to land");
2752 }
2753
2754 println!("\n🚫 Blocking Issues:");
2756 for pr_status in &status.enhanced_statuses {
2757 if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
2758 let blocking = pr_status.get_blocking_reasons();
2759 if !blocking.is_empty() {
2760 println!(" PR #{}: {}", pr_status.pr.id, blocking.join(", "));
2761 }
2762 }
2763 }
2764
2765 if !force {
2766 println!("\n💡 Use --force to land PRs with blocking issues (dangerous!)");
2767 }
2768 return Ok(());
2769 }
2770
2771 if dry_run {
2772 if let Some(entry_num) = entry {
2773 println!("🏃 Dry Run - Entry {entry_num} that would be landed:");
2774 } else {
2775 println!("🏃 Dry Run - PRs that would be landed:");
2776 }
2777 for pr_status in &ready_prs {
2778 println!(" ✅ PR #{}: {}", pr_status.pr.id, pr_status.pr.title);
2779 if !pr_status.is_ready_to_land() && force {
2780 let blocking = pr_status.get_blocking_reasons();
2781 println!(
2782 " ⚠️ Would force land despite: {}",
2783 blocking.join(", ")
2784 );
2785 }
2786 }
2787 return Ok(());
2788 }
2789
2790 if entry.is_some() && ready_prs.len() > 1 {
2793 println!(
2794 "🎯 {} PRs are ready to land, but landing only entry #{}",
2795 ready_prs.len(),
2796 entry.unwrap()
2797 );
2798 }
2799
2800 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
2802 strategy.unwrap_or(MergeStrategyArg::Squash).into();
2803 let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
2804 merge_strategy: merge_strategy.clone(),
2805 wait_for_builds,
2806 build_timeout: std::time::Duration::from_secs(build_timeout),
2807 allowed_authors: None, };
2809
2810 println!(
2812 "🚀 Landing {} PR{}...",
2813 ready_prs.len(),
2814 if ready_prs.len() == 1 { "" } else { "s" }
2815 );
2816
2817 let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
2818 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
2819 );
2820
2821 let mut landed_count = 0;
2823 let mut failed_count = 0;
2824 let total_ready_prs = ready_prs.len();
2825
2826 for pr_status in ready_prs {
2827 let pr_id = pr_status.pr.id;
2828
2829 print!("🚀 Landing PR #{}: {}", pr_id, pr_status.pr.title);
2830
2831 let land_result = if auto {
2832 pr_manager
2834 .auto_merge_if_ready(pr_id, &auto_merge_conditions)
2835 .await
2836 } else {
2837 pr_manager
2839 .merge_pull_request(pr_id, merge_strategy.clone())
2840 .await
2841 .map(
2842 |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
2843 pr: Box::new(pr),
2844 merge_strategy: merge_strategy.clone(),
2845 },
2846 )
2847 };
2848
2849 match land_result {
2850 Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
2851 println!(" ✅");
2852 landed_count += 1;
2853
2854 if landed_count < total_ready_prs {
2856 println!("🔄 Retargeting remaining PRs to latest base...");
2857
2858 let base_branch = active_stack.base_branch.clone();
2860 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2861
2862 println!(" 📥 Updating base branch: {base_branch}");
2863 match git_repo.pull(&base_branch) {
2864 Ok(_) => println!(" ✅ Base branch updated successfully"),
2865 Err(e) => {
2866 println!(" ⚠️ Warning: Failed to update base branch: {e}");
2867 println!(
2868 " 💡 You may want to manually run: git pull origin {base_branch}"
2869 );
2870 }
2871 }
2872
2873 let mut rebase_manager = crate::stack::RebaseManager::new(
2875 StackManager::new(&repo_root)?,
2876 git_repo,
2877 crate::stack::RebaseOptions {
2878 strategy: crate::stack::RebaseStrategy::BranchVersioning,
2879 target_base: Some(base_branch.clone()),
2880 ..Default::default()
2881 },
2882 );
2883
2884 match rebase_manager.rebase_stack(&stack_id) {
2885 Ok(rebase_result) => {
2886 if !rebase_result.branch_mapping.is_empty() {
2887 let retarget_config = crate::config::CascadeConfig {
2889 bitbucket: Some(settings.bitbucket.clone()),
2890 git: settings.git.clone(),
2891 auth: crate::config::AuthConfig::default(),
2892 cascade: settings.cascade.clone(),
2893 };
2894 let mut retarget_integration = BitbucketIntegration::new(
2895 StackManager::new(&repo_root)?,
2896 retarget_config,
2897 )?;
2898
2899 match retarget_integration
2900 .update_prs_after_rebase(
2901 &stack_id,
2902 &rebase_result.branch_mapping,
2903 )
2904 .await
2905 {
2906 Ok(updated_prs) => {
2907 if !updated_prs.is_empty() {
2908 println!(
2909 " ✅ Updated {} PRs with new targets",
2910 updated_prs.len()
2911 );
2912 }
2913 }
2914 Err(e) => {
2915 println!(" ⚠️ Failed to update remaining PRs: {e}");
2916 println!(
2917 " 💡 You may need to run: ca stack rebase --onto {base_branch}"
2918 );
2919 }
2920 }
2921 }
2922 }
2923 Err(e) => {
2924 println!(" ❌ Auto-retargeting conflicts detected!");
2926 println!(" 📝 To resolve conflicts and continue landing:");
2927 println!(" 1. Resolve conflicts in the affected files");
2928 println!(" 2. Stage resolved files: git add <files>");
2929 println!(" 3. Continue the process: ca stack continue-land");
2930 println!(" 4. Or abort the operation: ca stack abort-land");
2931 println!();
2932 println!(" 💡 Check current status: ca stack land-status");
2933 println!(" ⚠️ Error details: {e}");
2934
2935 break;
2937 }
2938 }
2939 }
2940 }
2941 Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
2942 println!(" ❌ Not ready: {}", blocking_reasons.join(", "));
2943 failed_count += 1;
2944 if !force {
2945 break;
2946 }
2947 }
2948 Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
2949 println!(" ❌ Failed: {error}");
2950 failed_count += 1;
2951 if !force {
2952 break;
2953 }
2954 }
2955 Err(e) => {
2956 println!(" ❌");
2957 eprintln!("Failed to land PR #{pr_id}: {e}");
2958 failed_count += 1;
2959
2960 if !force {
2961 break;
2962 }
2963 }
2964 }
2965 }
2966
2967 println!("\n🎯 Landing Summary:");
2969 println!(" ✅ Successfully landed: {landed_count}");
2970 if failed_count > 0 {
2971 println!(" ❌ Failed to land: {failed_count}");
2972 }
2973
2974 if landed_count > 0 {
2975 println!("✅ Landing operation completed!");
2976 } else {
2977 println!("❌ No PRs were successfully landed");
2978 }
2979
2980 Ok(())
2981}
2982
2983async fn auto_land_stack(
2985 force: bool,
2986 dry_run: bool,
2987 wait_for_builds: bool,
2988 strategy: Option<MergeStrategyArg>,
2989 build_timeout: u64,
2990) -> Result<()> {
2991 land_stack(
2993 None,
2994 force,
2995 dry_run,
2996 true, wait_for_builds,
2998 strategy,
2999 build_timeout,
3000 )
3001 .await
3002}
3003
3004async fn continue_land() -> Result<()> {
3005 let current_dir = env::current_dir()
3006 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3007
3008 let repo_root = find_repository_root(¤t_dir)
3009 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3010
3011 let stack_manager = StackManager::new(&repo_root)?;
3012 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3013 let options = crate::stack::RebaseOptions::default();
3014 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3015
3016 if !rebase_manager.is_rebase_in_progress() {
3017 println!("ℹ️ No rebase in progress");
3018 return Ok(());
3019 }
3020
3021 println!("🔄 Continuing land operation...");
3022 match rebase_manager.continue_rebase() {
3023 Ok(_) => {
3024 println!("✅ Land operation continued successfully");
3025 println!(" Check 'ca stack land-status' for current state");
3026 }
3027 Err(e) => {
3028 warn!("❌ Failed to continue land operation: {}", e);
3029 println!("💡 You may need to resolve conflicts first:");
3030 println!(" 1. Edit conflicted files");
3031 println!(" 2. Stage resolved files with 'git add'");
3032 println!(" 3. Run 'ca stack continue-land' again");
3033 }
3034 }
3035
3036 Ok(())
3037}
3038
3039async fn abort_land() -> Result<()> {
3040 let current_dir = env::current_dir()
3041 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3042
3043 let repo_root = find_repository_root(¤t_dir)
3044 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3045
3046 let stack_manager = StackManager::new(&repo_root)?;
3047 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3048 let options = crate::stack::RebaseOptions::default();
3049 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3050
3051 if !rebase_manager.is_rebase_in_progress() {
3052 println!("ℹ️ No rebase in progress");
3053 return Ok(());
3054 }
3055
3056 println!("⚠️ Aborting land operation...");
3057 match rebase_manager.abort_rebase() {
3058 Ok(_) => {
3059 println!("✅ Land operation aborted successfully");
3060 println!(" Repository restored to pre-land state");
3061 }
3062 Err(e) => {
3063 warn!("❌ Failed to abort land operation: {}", e);
3064 println!("⚠️ You may need to manually clean up the repository state");
3065 }
3066 }
3067
3068 Ok(())
3069}
3070
3071async fn land_status() -> Result<()> {
3072 let current_dir = env::current_dir()
3073 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3074
3075 let repo_root = find_repository_root(¤t_dir)
3076 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3077
3078 let stack_manager = StackManager::new(&repo_root)?;
3079 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3080
3081 println!("📊 Land Status");
3082
3083 let git_dir = repo_root.join(".git");
3085 let land_in_progress = git_dir.join("REBASE_HEAD").exists()
3086 || git_dir.join("rebase-merge").exists()
3087 || git_dir.join("rebase-apply").exists();
3088
3089 if land_in_progress {
3090 println!(" Status: 🔄 Land operation in progress");
3091 println!(
3092 "
3093📝 Actions available:"
3094 );
3095 println!(" - 'ca stack continue-land' to continue");
3096 println!(" - 'ca stack abort-land' to abort");
3097 println!(" - 'git status' to see conflicted files");
3098
3099 match git_repo.get_status() {
3101 Ok(statuses) => {
3102 let mut conflicts = Vec::new();
3103 for status in statuses.iter() {
3104 if status.status().contains(git2::Status::CONFLICTED) {
3105 if let Some(path) = status.path() {
3106 conflicts.push(path.to_string());
3107 }
3108 }
3109 }
3110
3111 if !conflicts.is_empty() {
3112 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
3113 for conflict in conflicts {
3114 println!(" - {conflict}");
3115 }
3116 println!(
3117 "
3118💡 To resolve conflicts:"
3119 );
3120 println!(" 1. Edit the conflicted files");
3121 println!(" 2. Stage resolved files: git add <file>");
3122 println!(" 3. Continue: ca stack continue-land");
3123 }
3124 }
3125 Err(e) => {
3126 warn!("Failed to get git status: {}", e);
3127 }
3128 }
3129 } else {
3130 println!(" Status: ✅ No land operation in progress");
3131
3132 if let Some(active_stack) = stack_manager.get_active_stack() {
3134 println!(" Active stack: {}", active_stack.name);
3135 println!(" Entries: {}", active_stack.entries.len());
3136 println!(" Base branch: {}", active_stack.base_branch);
3137 }
3138 }
3139
3140 Ok(())
3141}
3142
3143async fn repair_stack_data() -> Result<()> {
3144 let current_dir = env::current_dir()
3145 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3146
3147 let repo_root = find_repository_root(¤t_dir)
3148 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3149
3150 let mut stack_manager = StackManager::new(&repo_root)?;
3151
3152 println!("🔧 Repairing stack data consistency...");
3153
3154 stack_manager.repair_all_stacks()?;
3155
3156 println!("✅ Stack data consistency repaired successfully!");
3157 println!("💡 Run 'ca stack --mergeable' to see updated status");
3158
3159 Ok(())
3160}
3161
3162#[cfg(test)]
3163mod tests {
3164 use super::*;
3165 use std::process::Command;
3166 use tempfile::TempDir;
3167
3168 fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
3169 let temp_dir = TempDir::new()
3170 .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
3171 let repo_path = temp_dir.path().to_path_buf();
3172
3173 let output = Command::new("git")
3175 .args(["init"])
3176 .current_dir(&repo_path)
3177 .output()
3178 .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
3179 if !output.status.success() {
3180 return Err(CascadeError::config("Git init failed".to_string()));
3181 }
3182
3183 let output = Command::new("git")
3184 .args(["config", "user.name", "Test User"])
3185 .current_dir(&repo_path)
3186 .output()
3187 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3188 if !output.status.success() {
3189 return Err(CascadeError::config(
3190 "Git config user.name failed".to_string(),
3191 ));
3192 }
3193
3194 let output = Command::new("git")
3195 .args(["config", "user.email", "test@example.com"])
3196 .current_dir(&repo_path)
3197 .output()
3198 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
3199 if !output.status.success() {
3200 return Err(CascadeError::config(
3201 "Git config user.email failed".to_string(),
3202 ));
3203 }
3204
3205 std::fs::write(repo_path.join("README.md"), "# Test")
3207 .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
3208 let output = Command::new("git")
3209 .args(["add", "."])
3210 .current_dir(&repo_path)
3211 .output()
3212 .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
3213 if !output.status.success() {
3214 return Err(CascadeError::config("Git add failed".to_string()));
3215 }
3216
3217 let output = Command::new("git")
3218 .args(["commit", "-m", "Initial commit"])
3219 .current_dir(&repo_path)
3220 .output()
3221 .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
3222 if !output.status.success() {
3223 return Err(CascadeError::config("Git commit failed".to_string()));
3224 }
3225
3226 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
3228
3229 Ok((temp_dir, repo_path))
3230 }
3231
3232 #[tokio::test]
3233 async fn test_create_stack() {
3234 let (temp_dir, repo_path) = match create_test_repo() {
3235 Ok(repo) => repo,
3236 Err(_) => {
3237 println!("Skipping test due to git environment setup failure");
3238 return;
3239 }
3240 };
3241 let _ = &temp_dir;
3243
3244 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3248 match env::set_current_dir(&repo_path) {
3249 Ok(_) => {
3250 let result = create_stack(
3251 "test-stack".to_string(),
3252 None, Some("Test description".to_string()),
3254 )
3255 .await;
3256
3257 if let Ok(orig) = original_dir {
3259 let _ = env::set_current_dir(orig);
3260 }
3261
3262 assert!(
3263 result.is_ok(),
3264 "Stack creation should succeed in initialized repository"
3265 );
3266 }
3267 Err(_) => {
3268 println!("Skipping test due to directory access restrictions");
3270 }
3271 }
3272 }
3273
3274 #[tokio::test]
3275 async fn test_list_empty_stacks() {
3276 let (temp_dir, repo_path) = match create_test_repo() {
3277 Ok(repo) => repo,
3278 Err(_) => {
3279 println!("Skipping test due to git environment setup failure");
3280 return;
3281 }
3282 };
3283 let _ = &temp_dir;
3285
3286 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3290 match env::set_current_dir(&repo_path) {
3291 Ok(_) => {
3292 let result = list_stacks(false, false, None).await;
3293
3294 if let Ok(orig) = original_dir {
3296 let _ = env::set_current_dir(orig);
3297 }
3298
3299 assert!(
3300 result.is_ok(),
3301 "Listing stacks should succeed in initialized repository"
3302 );
3303 }
3304 Err(_) => {
3305 println!("Skipping test due to directory access restrictions");
3307 }
3308 }
3309 }
3310
3311 #[test]
3314 fn test_extract_feature_from_wip_basic() {
3315 let messages = vec![
3316 "WIP: add authentication".to_string(),
3317 "WIP: implement login flow".to_string(),
3318 ];
3319
3320 let result = extract_feature_from_wip(&messages);
3321 assert_eq!(result, "Add authentication");
3322 }
3323
3324 #[test]
3325 fn test_extract_feature_from_wip_capitalize() {
3326 let messages = vec!["WIP: fix user validation bug".to_string()];
3327
3328 let result = extract_feature_from_wip(&messages);
3329 assert_eq!(result, "Fix user validation bug");
3330 }
3331
3332 #[test]
3333 fn test_extract_feature_from_wip_fallback() {
3334 let messages = vec![
3335 "WIP user interface changes".to_string(),
3336 "wip: css styling".to_string(),
3337 ];
3338
3339 let result = extract_feature_from_wip(&messages);
3340 assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
3342 }
3343
3344 #[test]
3345 fn test_extract_feature_from_wip_empty() {
3346 let messages = vec![];
3347
3348 let result = extract_feature_from_wip(&messages);
3349 assert_eq!(result, "Squashed 0 commits");
3350 }
3351
3352 #[test]
3353 fn test_extract_feature_from_wip_short_message() {
3354 let messages = vec!["WIP: x".to_string()]; let result = extract_feature_from_wip(&messages);
3357 assert!(result.starts_with("Implement") || result.contains("Squashed"));
3358 }
3359
3360 #[test]
3363 fn test_squash_message_final_strategy() {
3364 let messages = [
3368 "Final: implement user authentication system".to_string(),
3369 "WIP: add tests".to_string(),
3370 "WIP: fix validation".to_string(),
3371 ];
3372
3373 assert!(messages[0].starts_with("Final:"));
3375
3376 let extracted = messages[0].trim_start_matches("Final:").trim();
3378 assert_eq!(extracted, "implement user authentication system");
3379 }
3380
3381 #[test]
3382 fn test_squash_message_wip_detection() {
3383 let messages = [
3384 "WIP: start feature".to_string(),
3385 "WIP: continue work".to_string(),
3386 "WIP: almost done".to_string(),
3387 "Regular commit message".to_string(),
3388 ];
3389
3390 let wip_count = messages
3391 .iter()
3392 .filter(|m| {
3393 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3394 })
3395 .count();
3396
3397 assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2); let non_wip: Vec<&String> = messages
3402 .iter()
3403 .filter(|m| {
3404 !m.to_lowercase().starts_with("wip")
3405 && !m.to_lowercase().contains("work in progress")
3406 })
3407 .collect();
3408
3409 assert_eq!(non_wip.len(), 1);
3410 assert_eq!(non_wip[0], "Regular commit message");
3411 }
3412
3413 #[test]
3414 fn test_squash_message_all_wip() {
3415 let messages = vec![
3416 "WIP: add feature A".to_string(),
3417 "WIP: add feature B".to_string(),
3418 "WIP: finish implementation".to_string(),
3419 ];
3420
3421 let result = extract_feature_from_wip(&messages);
3422 assert_eq!(result, "Add feature A");
3424 }
3425
3426 #[test]
3427 fn test_squash_message_edge_cases() {
3428 let empty_messages: Vec<String> = vec![];
3430 let result = extract_feature_from_wip(&empty_messages);
3431 assert_eq!(result, "Squashed 0 commits");
3432
3433 let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
3435 let result = extract_feature_from_wip(&whitespace_messages);
3436 assert!(result.contains("Squashed") || result.contains("Implement"));
3437
3438 let mixed_case = vec!["wip: Add Feature".to_string()];
3440 let result = extract_feature_from_wip(&mixed_case);
3441 assert_eq!(result, "Add Feature");
3442 }
3443
3444 #[tokio::test]
3447 async fn test_auto_land_wrapper() {
3448 let (temp_dir, repo_path) = match create_test_repo() {
3450 Ok(repo) => repo,
3451 Err(_) => {
3452 println!("Skipping test due to git environment setup failure");
3453 return;
3454 }
3455 };
3456 let _ = &temp_dir;
3458
3459 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
3461 .expect("Failed to initialize Cascade in test repo");
3462
3463 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3464 match env::set_current_dir(&repo_path) {
3465 Ok(_) => {
3466 let result = create_stack(
3468 "test-stack".to_string(),
3469 None,
3470 Some("Test stack for auto-land".to_string()),
3471 )
3472 .await;
3473
3474 if let Ok(orig) = original_dir {
3475 let _ = env::set_current_dir(orig);
3476 }
3477
3478 assert!(
3481 result.is_ok(),
3482 "Stack creation should succeed in initialized repository"
3483 );
3484 }
3485 Err(_) => {
3486 println!("Skipping test due to directory access restrictions");
3487 }
3488 }
3489 }
3490
3491 #[test]
3492 fn test_auto_land_action_enum() {
3493 use crate::cli::commands::stack::StackAction;
3495
3496 let _action = StackAction::AutoLand {
3498 force: false,
3499 dry_run: true,
3500 wait_for_builds: true,
3501 strategy: Some(MergeStrategyArg::Squash),
3502 build_timeout: 1800,
3503 };
3504
3505 }
3507
3508 #[test]
3509 fn test_merge_strategy_conversion() {
3510 let squash_strategy = MergeStrategyArg::Squash;
3512 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
3513
3514 match merge_strategy {
3515 crate::bitbucket::pull_request::MergeStrategy::Squash => {
3516 }
3518 _ => panic!("Expected Squash strategy"),
3519 }
3520
3521 let merge_strategy = MergeStrategyArg::Merge;
3522 let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
3523
3524 match converted {
3525 crate::bitbucket::pull_request::MergeStrategy::Merge => {
3526 }
3528 _ => panic!("Expected Merge strategy"),
3529 }
3530 }
3531
3532 #[test]
3533 fn test_auto_merge_conditions_structure() {
3534 use std::time::Duration;
3536
3537 let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
3538 merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
3539 wait_for_builds: true,
3540 build_timeout: Duration::from_secs(1800),
3541 allowed_authors: None,
3542 };
3543
3544 assert!(conditions.wait_for_builds);
3546 assert_eq!(conditions.build_timeout.as_secs(), 1800);
3547 assert!(conditions.allowed_authors.is_none());
3548 assert!(matches!(
3549 conditions.merge_strategy,
3550 crate::bitbucket::pull_request::MergeStrategy::Squash
3551 ));
3552 }
3553
3554 #[test]
3555 fn test_polling_constants() {
3556 use std::time::Duration;
3558
3559 let expected_polling_interval = Duration::from_secs(30);
3561
3562 assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
3567
3568 #[test]
3569 fn test_build_timeout_defaults() {
3570 const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
3573 let timeout_value = 1800u64;
3575 assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
3578
3579 #[test]
3580 fn test_scattered_commit_detection() {
3581 use std::collections::HashSet;
3582
3583 let mut source_branches = HashSet::new();
3585 source_branches.insert("feature-branch-1".to_string());
3586 source_branches.insert("feature-branch-2".to_string());
3587 source_branches.insert("feature-branch-3".to_string());
3588
3589 let single_branch = HashSet::from(["main".to_string()]);
3591 assert_eq!(single_branch.len(), 1);
3592
3593 assert!(source_branches.len() > 1);
3595 assert_eq!(source_branches.len(), 3);
3596
3597 assert!(source_branches.contains("feature-branch-1"));
3599 assert!(source_branches.contains("feature-branch-2"));
3600 assert!(source_branches.contains("feature-branch-3"));
3601 }
3602
3603 #[test]
3604 fn test_source_branch_tracking() {
3605 let branch_a = "feature-work";
3609 let branch_b = "feature-work";
3610 assert_eq!(branch_a, branch_b);
3611
3612 let branch_1 = "feature-ui";
3614 let branch_2 = "feature-api";
3615 assert_ne!(branch_1, branch_2);
3616
3617 assert!(branch_1.starts_with("feature-"));
3619 assert!(branch_2.starts_with("feature-"));
3620 }
3621
3622 #[tokio::test]
3625 async fn test_push_default_behavior() {
3626 let (temp_dir, repo_path) = match create_test_repo() {
3628 Ok(repo) => repo,
3629 Err(_) => {
3630 println!("Skipping test due to git environment setup failure");
3631 return;
3632 }
3633 };
3634 let _ = &temp_dir;
3636
3637 if !repo_path.exists() {
3639 println!("Skipping test due to temporary directory creation issue");
3640 return;
3641 }
3642
3643 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
3645
3646 match env::set_current_dir(&repo_path) {
3647 Ok(_) => {
3648 let result = push_to_stack(
3650 None, None, None, None, None, None, None, false, false, )
3660 .await;
3661
3662 if let Ok(orig) = original_dir {
3664 let _ = env::set_current_dir(orig);
3665 }
3666
3667 match &result {
3669 Err(e) => {
3670 let error_msg = e.to_string();
3671 assert!(
3673 error_msg.contains("No active stack")
3674 || error_msg.contains("config")
3675 || error_msg.contains("current directory")
3676 || error_msg.contains("Not a git repository")
3677 || error_msg.contains("could not find repository"),
3678 "Expected 'No active stack' or repository error, got: {error_msg}"
3679 );
3680 }
3681 Ok(_) => {
3682 println!(
3684 "Push succeeded unexpectedly - test environment may have active stack"
3685 );
3686 }
3687 }
3688 }
3689 Err(_) => {
3690 println!("Skipping test due to directory access restrictions");
3692 }
3693 }
3694
3695 let push_action = StackAction::Push {
3697 branch: None,
3698 message: None,
3699 commit: None,
3700 since: None,
3701 commits: None,
3702 squash: None,
3703 squash_since: None,
3704 auto_branch: false,
3705 allow_base_branch: false,
3706 };
3707
3708 assert!(matches!(
3709 push_action,
3710 StackAction::Push {
3711 branch: None,
3712 message: None,
3713 commit: None,
3714 since: None,
3715 commits: None,
3716 squash: None,
3717 squash_since: None,
3718 auto_branch: false,
3719 allow_base_branch: false
3720 }
3721 ));
3722 }
3723
3724 #[tokio::test]
3725 async fn test_submit_default_behavior() {
3726 let (temp_dir, repo_path) = match create_test_repo() {
3728 Ok(repo) => repo,
3729 Err(_) => {
3730 println!("Skipping test due to git environment setup failure");
3731 return;
3732 }
3733 };
3734 let _ = &temp_dir;
3736
3737 if !repo_path.exists() {
3739 println!("Skipping test due to temporary directory creation issue");
3740 return;
3741 }
3742
3743 let original_dir = match env::current_dir() {
3745 Ok(dir) => dir,
3746 Err(_) => {
3747 println!("Skipping test due to current directory access restrictions");
3748 return;
3749 }
3750 };
3751
3752 match env::set_current_dir(&repo_path) {
3753 Ok(_) => {
3754 let result = submit_entry(
3756 None, None, None, None, false, )
3762 .await;
3763
3764 let _ = env::set_current_dir(original_dir);
3766
3767 match &result {
3769 Err(e) => {
3770 let error_msg = e.to_string();
3771 assert!(
3773 error_msg.contains("No active stack")
3774 || error_msg.contains("config")
3775 || error_msg.contains("current directory")
3776 || error_msg.contains("Not a git repository")
3777 || error_msg.contains("could not find repository"),
3778 "Expected 'No active stack' or repository error, got: {error_msg}"
3779 );
3780 }
3781 Ok(_) => {
3782 println!("Submit succeeded unexpectedly - test environment may have active stack");
3784 }
3785 }
3786 }
3787 Err(_) => {
3788 println!("Skipping test due to directory access restrictions");
3790 }
3791 }
3792
3793 let submit_action = StackAction::Submit {
3795 entry: None,
3796 title: None,
3797 description: None,
3798 range: None,
3799 draft: false,
3800 };
3801
3802 assert!(matches!(
3803 submit_action,
3804 StackAction::Submit {
3805 entry: None,
3806 title: None,
3807 description: None,
3808 range: None,
3809 draft: false
3810 }
3811 ));
3812 }
3813
3814 #[test]
3815 fn test_targeting_options_still_work() {
3816 let commits = "abc123,def456,ghi789";
3820 let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
3821 assert_eq!(parsed.len(), 3);
3822 assert_eq!(parsed[0], "abc123");
3823 assert_eq!(parsed[1], "def456");
3824 assert_eq!(parsed[2], "ghi789");
3825
3826 let range = "1-3";
3828 assert!(range.contains('-'));
3829 let parts: Vec<&str> = range.split('-').collect();
3830 assert_eq!(parts.len(), 2);
3831
3832 let since_ref = "HEAD~3";
3834 assert!(since_ref.starts_with("HEAD"));
3835 assert!(since_ref.contains('~'));
3836 }
3837
3838 #[test]
3839 fn test_command_flow_logic() {
3840 assert!(matches!(
3842 StackAction::Push {
3843 branch: None,
3844 message: None,
3845 commit: None,
3846 since: None,
3847 commits: None,
3848 squash: None,
3849 squash_since: None,
3850 auto_branch: false,
3851 allow_base_branch: false
3852 },
3853 StackAction::Push { .. }
3854 ));
3855
3856 assert!(matches!(
3857 StackAction::Submit {
3858 entry: None,
3859 title: None,
3860 description: None,
3861 range: None,
3862 draft: false
3863 },
3864 StackAction::Submit { .. }
3865 ));
3866 }
3867
3868 #[tokio::test]
3869 async fn test_deactivate_command_structure() {
3870 let deactivate_action = StackAction::Deactivate { force: false };
3872
3873 assert!(matches!(
3875 deactivate_action,
3876 StackAction::Deactivate { force: false }
3877 ));
3878
3879 let force_deactivate = StackAction::Deactivate { force: true };
3881 assert!(matches!(
3882 force_deactivate,
3883 StackAction::Deactivate { force: true }
3884 ));
3885 }
3886}