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