1use crate::bitbucket::BitbucketIntegration;
2use crate::cli::output::Output;
3use crate::errors::{CascadeError, Result};
4use crate::git::{find_repository_root, GitRepository};
5use crate::stack::{CleanupManager, CleanupOptions, CleanupResult, StackManager, StackStatus};
6use clap::{Subcommand, ValueEnum};
7use dialoguer::{theme::ColorfulTheme, Confirm};
8use std::env;
10use tracing::{debug, warn};
11use uuid::Uuid;
12
13#[derive(ValueEnum, Clone, Debug)]
15pub enum RebaseStrategyArg {
16 ForcePush,
18 Interactive,
20}
21
22#[derive(ValueEnum, Clone, Debug)]
23pub enum MergeStrategyArg {
24 Merge,
26 Squash,
28 FastForward,
30}
31
32impl From<MergeStrategyArg> for crate::bitbucket::pull_request::MergeStrategy {
33 fn from(arg: MergeStrategyArg) -> Self {
34 match arg {
35 MergeStrategyArg::Merge => Self::Merge,
36 MergeStrategyArg::Squash => Self::Squash,
37 MergeStrategyArg::FastForward => Self::FastForward,
38 }
39 }
40}
41
42#[derive(Debug, Subcommand)]
43pub enum StackAction {
44 Create {
46 name: String,
48 #[arg(long, short)]
50 base: Option<String>,
51 #[arg(long, short)]
53 description: Option<String>,
54 },
55
56 List {
58 #[arg(long, short)]
60 verbose: bool,
61 #[arg(long)]
63 active: bool,
64 #[arg(long)]
66 format: Option<String>,
67 },
68
69 Switch {
71 name: String,
73 },
74
75 Deactivate {
77 #[arg(long)]
79 force: bool,
80 },
81
82 Show {
84 #[arg(short, long)]
86 verbose: bool,
87 #[arg(short, long)]
89 mergeable: bool,
90 },
91
92 Push {
94 #[arg(long, short)]
96 branch: Option<String>,
97 #[arg(long, short)]
99 message: Option<String>,
100 #[arg(long)]
102 commit: Option<String>,
103 #[arg(long)]
105 since: Option<String>,
106 #[arg(long)]
108 commits: Option<String>,
109 #[arg(long, num_args = 0..=1, default_missing_value = "0")]
111 squash: Option<usize>,
112 #[arg(long)]
114 squash_since: Option<String>,
115 #[arg(long)]
117 auto_branch: bool,
118 #[arg(long)]
120 allow_base_branch: bool,
121 #[arg(long)]
123 dry_run: bool,
124 #[arg(long, short)]
126 yes: bool,
127 },
128
129 Pop {
131 #[arg(long)]
133 keep_branch: bool,
134 },
135
136 Submit {
138 entry: Option<usize>,
140 #[arg(long, short)]
142 title: Option<String>,
143 #[arg(long, short)]
145 description: Option<String>,
146 #[arg(long)]
148 range: Option<String>,
149 #[arg(long, default_value_t = true)]
151 draft: bool,
152 #[arg(long, default_value_t = true)]
154 open: bool,
155 },
156
157 Status {
159 name: Option<String>,
161 },
162
163 Prs {
165 #[arg(long)]
167 state: Option<String>,
168 #[arg(long, short)]
170 verbose: bool,
171 },
172
173 Check {
175 #[arg(long)]
177 force: bool,
178 },
179
180 Sync {
182 #[arg(long)]
184 force: bool,
185 #[arg(long)]
187 cleanup: bool,
188 #[arg(long, short)]
190 interactive: bool,
191 },
192
193 Rebase {
195 #[arg(long, short)]
197 interactive: bool,
198 #[arg(long)]
200 onto: Option<String>,
201 #[arg(long, value_enum)]
203 strategy: Option<RebaseStrategyArg>,
204 },
205
206 ContinueRebase,
208
209 AbortRebase,
211
212 RebaseStatus,
214
215 Delete {
217 name: String,
219 #[arg(long)]
221 force: bool,
222 },
223
224 Validate {
236 name: Option<String>,
238 #[arg(long)]
240 fix: Option<String>,
241 },
242
243 Land {
245 entry: Option<usize>,
247 #[arg(short, long)]
249 force: bool,
250 #[arg(short, long)]
252 dry_run: bool,
253 #[arg(long)]
255 auto: bool,
256 #[arg(long)]
258 wait_for_builds: bool,
259 #[arg(long, value_enum, default_value = "squash")]
261 strategy: Option<MergeStrategyArg>,
262 #[arg(long, default_value = "1800")]
264 build_timeout: u64,
265 },
266
267 AutoLand {
269 #[arg(short, long)]
271 force: bool,
272 #[arg(short, long)]
274 dry_run: bool,
275 #[arg(long)]
277 wait_for_builds: bool,
278 #[arg(long, value_enum, default_value = "squash")]
280 strategy: Option<MergeStrategyArg>,
281 #[arg(long, default_value = "1800")]
283 build_timeout: u64,
284 },
285
286 ListPrs {
288 #[arg(short, long)]
290 state: Option<String>,
291 #[arg(short, long)]
293 verbose: bool,
294 },
295
296 ContinueLand,
298
299 AbortLand,
301
302 LandStatus,
304
305 Cleanup {
307 #[arg(long)]
309 dry_run: bool,
310 #[arg(long)]
312 force: bool,
313 #[arg(long)]
315 include_stale: bool,
316 #[arg(long, default_value = "30")]
318 stale_days: u32,
319 #[arg(long)]
321 cleanup_remote: bool,
322 #[arg(long)]
324 include_non_stack: bool,
325 #[arg(long)]
327 verbose: bool,
328 },
329
330 Repair,
332
333 Drop {
335 entry: String,
337 #[arg(long)]
339 keep_branch: bool,
340 #[arg(long)]
342 keep_pr: bool,
343 #[arg(long, short)]
345 force: bool,
346 #[arg(long, short)]
348 yes: bool,
349 },
350}
351
352pub async fn run(action: StackAction) -> Result<()> {
353 match action {
354 StackAction::Create {
355 name,
356 base,
357 description,
358 } => create_stack(name, base, description).await,
359 StackAction::List {
360 verbose,
361 active,
362 format,
363 } => list_stacks(verbose, active, format).await,
364 StackAction::Switch { name } => switch_stack(name).await,
365 StackAction::Deactivate { force } => deactivate_stack(force).await,
366 StackAction::Show { verbose, mergeable } => show_stack(verbose, mergeable).await,
367 StackAction::Push {
368 branch,
369 message,
370 commit,
371 since,
372 commits,
373 squash,
374 squash_since,
375 auto_branch,
376 allow_base_branch,
377 dry_run,
378 yes,
379 } => {
380 push_to_stack(
381 branch,
382 message,
383 commit,
384 since,
385 commits,
386 squash,
387 squash_since,
388 auto_branch,
389 allow_base_branch,
390 dry_run,
391 yes,
392 )
393 .await
394 }
395 StackAction::Pop { keep_branch } => pop_from_stack(keep_branch).await,
396 StackAction::Submit {
397 entry,
398 title,
399 description,
400 range,
401 draft,
402 open,
403 } => submit_entry(entry, title, description, range, draft, open).await,
404 StackAction::Status { name } => check_stack_status(name).await,
405 StackAction::Prs { state, verbose } => list_pull_requests(state, verbose).await,
406 StackAction::Check { force } => check_stack(force).await,
407 StackAction::Sync {
408 force,
409 cleanup,
410 interactive,
411 } => sync_stack(force, cleanup, interactive).await,
412 StackAction::Rebase {
413 interactive,
414 onto,
415 strategy,
416 } => rebase_stack(interactive, onto, strategy).await,
417 StackAction::ContinueRebase => continue_rebase().await,
418 StackAction::AbortRebase => abort_rebase().await,
419 StackAction::RebaseStatus => rebase_status().await,
420 StackAction::Delete { name, force } => delete_stack(name, force).await,
421 StackAction::Validate { name, fix } => validate_stack(name, fix).await,
422 StackAction::Land {
423 entry,
424 force,
425 dry_run,
426 auto,
427 wait_for_builds,
428 strategy,
429 build_timeout,
430 } => {
431 land_stack(
432 entry,
433 force,
434 dry_run,
435 auto,
436 wait_for_builds,
437 strategy,
438 build_timeout,
439 )
440 .await
441 }
442 StackAction::AutoLand {
443 force,
444 dry_run,
445 wait_for_builds,
446 strategy,
447 build_timeout,
448 } => auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await,
449 StackAction::ListPrs { state, verbose } => list_pull_requests(state, verbose).await,
450 StackAction::ContinueLand => continue_land().await,
451 StackAction::AbortLand => abort_land().await,
452 StackAction::LandStatus => land_status().await,
453 StackAction::Cleanup {
454 dry_run,
455 force,
456 include_stale,
457 stale_days,
458 cleanup_remote,
459 include_non_stack,
460 verbose,
461 } => {
462 cleanup_branches(
463 dry_run,
464 force,
465 include_stale,
466 stale_days,
467 cleanup_remote,
468 include_non_stack,
469 verbose,
470 )
471 .await
472 }
473 StackAction::Repair => repair_stack_data().await,
474 StackAction::Drop {
475 entry,
476 keep_branch,
477 keep_pr,
478 force,
479 yes,
480 } => drop_entries(entry, keep_branch, keep_pr, force, yes).await,
481 }
482}
483
484pub async fn show(verbose: bool, mergeable: bool) -> Result<()> {
486 show_stack(verbose, mergeable).await
487}
488
489#[allow(clippy::too_many_arguments)]
490pub async fn push(
491 branch: Option<String>,
492 message: Option<String>,
493 commit: Option<String>,
494 since: Option<String>,
495 commits: Option<String>,
496 squash: Option<usize>,
497 squash_since: Option<String>,
498 auto_branch: bool,
499 allow_base_branch: bool,
500 dry_run: bool,
501 yes: bool,
502) -> Result<()> {
503 push_to_stack(
504 branch,
505 message,
506 commit,
507 since,
508 commits,
509 squash,
510 squash_since,
511 auto_branch,
512 allow_base_branch,
513 dry_run,
514 yes,
515 )
516 .await
517}
518
519pub async fn pop(keep_branch: bool) -> Result<()> {
520 pop_from_stack(keep_branch).await
521}
522
523pub async fn drop(
524 entry: String,
525 keep_branch: bool,
526 keep_pr: bool,
527 force: bool,
528 yes: bool,
529) -> Result<()> {
530 drop_entries(entry, keep_branch, keep_pr, force, yes).await
531}
532
533pub async fn land(
534 entry: Option<usize>,
535 force: bool,
536 dry_run: bool,
537 auto: bool,
538 wait_for_builds: bool,
539 strategy: Option<MergeStrategyArg>,
540 build_timeout: u64,
541) -> Result<()> {
542 land_stack(
543 entry,
544 force,
545 dry_run,
546 auto,
547 wait_for_builds,
548 strategy,
549 build_timeout,
550 )
551 .await
552}
553
554pub async fn autoland(
555 force: bool,
556 dry_run: bool,
557 wait_for_builds: bool,
558 strategy: Option<MergeStrategyArg>,
559 build_timeout: u64,
560) -> Result<()> {
561 auto_land_stack(force, dry_run, wait_for_builds, strategy, build_timeout).await
562}
563
564pub async fn sync(force: bool, skip_cleanup: bool, interactive: bool) -> Result<()> {
565 sync_stack(force, skip_cleanup, interactive).await
566}
567
568pub async fn rebase(
569 interactive: bool,
570 onto: Option<String>,
571 strategy: Option<RebaseStrategyArg>,
572) -> Result<()> {
573 rebase_stack(interactive, onto, strategy).await
574}
575
576pub async fn deactivate(force: bool) -> Result<()> {
577 deactivate_stack(force).await
578}
579
580pub async fn switch(name: String) -> Result<()> {
581 switch_stack(name).await
582}
583
584async fn create_stack(
585 name: String,
586 base: Option<String>,
587 description: Option<String>,
588) -> Result<()> {
589 let current_dir = env::current_dir()
590 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
591
592 let repo_root = find_repository_root(¤t_dir)
593 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
594
595 let mut manager = StackManager::new(&repo_root)?;
596 let stack_id = manager.create_stack(name.clone(), base.clone(), description.clone())?;
597
598 let stack = manager
600 .get_stack(&stack_id)
601 .ok_or_else(|| CascadeError::config("Failed to get created stack"))?;
602
603 Output::stack_info(
605 &name,
606 &stack_id.to_string(),
607 &stack.base_branch,
608 stack.working_branch.as_deref(),
609 true, );
611
612 if let Some(desc) = description {
613 Output::sub_item(format!("Description: {desc}"));
614 }
615
616 if stack.working_branch.is_none() {
618 Output::warning(format!(
619 "You're currently on the base branch '{}'",
620 stack.base_branch
621 ));
622 Output::next_steps(&[
623 &format!("Create a feature branch: git checkout -b {name}"),
624 "Make changes and commit them",
625 "Run 'ca push' to add commits to this stack",
626 ]);
627 } else {
628 Output::next_steps(&[
629 "Make changes and commit them",
630 "Run 'ca push' to add commits to this stack",
631 "Use 'ca submit' when ready to create pull requests",
632 ]);
633 }
634
635 Ok(())
636}
637
638async fn list_stacks(verbose: bool, active_only: bool, format: Option<String>) -> Result<()> {
639 let current_dir = env::current_dir()
640 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
641
642 let repo_root = find_repository_root(¤t_dir)
643 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
644
645 let manager = StackManager::new(&repo_root)?;
646 let mut stacks = manager.list_stacks();
647
648 if active_only {
649 stacks.retain(|(_, _, _, _, active_marker)| active_marker.is_some());
650 }
651
652 if let Some(ref format) = format {
653 match format.as_str() {
654 "json" => {
655 let mut json_stacks = Vec::new();
656
657 for (stack_id, name, status, entry_count, active_marker) in &stacks {
658 let (entries_json, working_branch, base_branch) =
659 if let Some(stack_obj) = manager.get_stack(stack_id) {
660 let entries_json = stack_obj
661 .entries
662 .iter()
663 .enumerate()
664 .map(|(idx, entry)| {
665 serde_json::json!({
666 "position": idx + 1,
667 "entry_id": entry.id.to_string(),
668 "branch_name": entry.branch.clone(),
669 "commit_hash": entry.commit_hash.clone(),
670 "short_hash": entry.short_hash(),
671 "is_submitted": entry.is_submitted,
672 "is_merged": entry.is_merged,
673 "pull_request_id": entry.pull_request_id.clone(),
674 })
675 })
676 .collect::<Vec<_>>();
677
678 (
679 entries_json,
680 stack_obj.working_branch.clone(),
681 Some(stack_obj.base_branch.clone()),
682 )
683 } else {
684 (Vec::new(), None, None)
685 };
686
687 let status_label = format!("{status:?}");
688
689 json_stacks.push(serde_json::json!({
690 "id": stack_id.to_string(),
691 "name": name,
692 "status": status_label,
693 "entry_count": entry_count,
694 "is_active": active_marker.is_some(),
695 "base_branch": base_branch,
696 "working_branch": working_branch,
697 "entries": entries_json,
698 }));
699 }
700
701 let json_output = serde_json::json!({ "stacks": json_stacks });
702 let serialized = serde_json::to_string_pretty(&json_output)?;
703 println!("{serialized}");
704 return Ok(());
705 }
706 "name" => {
707 for (_, name, _, _, _) in &stacks {
708 println!("{name}");
709 }
710 return Ok(());
711 }
712 "id" => {
713 for (stack_id, _, _, _, _) in &stacks {
714 println!("{}", stack_id);
715 }
716 return Ok(());
717 }
718 "status" => {
719 for (_, name, status, _, active_marker) in &stacks {
720 let status_label = format!("{status:?}");
721 let marker = if active_marker.is_some() {
722 " (active)"
723 } else {
724 ""
725 };
726 println!("{name}: {status_label}{marker}");
727 }
728 return Ok(());
729 }
730 other => {
731 return Err(CascadeError::config(format!(
732 "Unsupported format '{}'. Supported formats: name, id, status, json",
733 other
734 )));
735 }
736 }
737 }
738
739 if stacks.is_empty() {
740 if active_only {
741 Output::info("No active stack. Activate one with 'ca stack switch <name>'");
742 } else {
743 Output::info("No stacks found. Create one with: ca stack create <name>");
744 }
745 return Ok(());
746 }
747
748 println!("Stacks:");
749 for (stack_id, name, status, entry_count, active_marker) in stacks {
750 let status_icon = match status {
751 StackStatus::Clean => "✓",
752 StackStatus::Dirty => "~",
753 StackStatus::OutOfSync => "!",
754 StackStatus::Conflicted => "✗",
755 StackStatus::Rebasing => "↔",
756 StackStatus::NeedsSync => "~",
757 StackStatus::Corrupted => "✗",
758 };
759
760 let active_indicator = if active_marker.is_some() {
761 " (active)"
762 } else {
763 ""
764 };
765
766 let stack = manager.get_stack(&stack_id);
768
769 if verbose {
770 println!(" {status_icon} {name} [{entry_count}]{active_indicator}");
771 println!(" ID: {stack_id}");
772 if let Some(stack_meta) = manager.get_stack_metadata(&stack_id) {
773 println!(" Base: {}", stack_meta.base_branch);
774 if let Some(desc) = &stack_meta.description {
775 println!(" Description: {desc}");
776 }
777 println!(
778 " Commits: {} total, {} submitted",
779 stack_meta.total_commits, stack_meta.submitted_commits
780 );
781 if stack_meta.has_conflicts {
782 Output::warning(" Has conflicts");
783 }
784 }
785
786 if let Some(stack_obj) = stack {
788 if !stack_obj.entries.is_empty() {
789 println!(" Branches:");
790 for (i, entry) in stack_obj.entries.iter().enumerate() {
791 let entry_num = i + 1;
792 let submitted_indicator = if entry.is_submitted {
793 "[submitted]"
794 } else {
795 ""
796 };
797 let branch_name = &entry.branch;
798 let short_message = if entry.message.len() > 40 {
799 format!("{}...", &entry.message[..37])
800 } else {
801 entry.message.clone()
802 };
803 println!(" {entry_num}. {submitted_indicator} {branch_name} - {short_message}");
804 }
805 }
806 }
807 println!();
808 } else {
809 let branch_info = if let Some(stack_obj) = stack {
811 if stack_obj.entries.is_empty() {
812 String::new()
813 } else if stack_obj.entries.len() == 1 {
814 format!(" → {}", stack_obj.entries[0].branch)
815 } else {
816 let first_branch = &stack_obj.entries[0].branch;
817 let last_branch = &stack_obj.entries.last().unwrap().branch;
818 format!(" → {first_branch} … {last_branch}")
819 }
820 } else {
821 String::new()
822 };
823
824 println!(" {status_icon} {name} [{entry_count}]{branch_info}{active_indicator}");
825 }
826 }
827
828 if !verbose {
829 println!("\nUse --verbose for more details");
830 }
831
832 Ok(())
833}
834
835async fn switch_stack(name: String) -> Result<()> {
836 let current_dir = env::current_dir()
837 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
838
839 let repo_root = find_repository_root(¤t_dir)
840 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
841
842 let mut manager = StackManager::new(&repo_root)?;
843 let repo = GitRepository::open(&repo_root)?;
844
845 let stack = manager
847 .get_stack_by_name(&name)
848 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
849
850 if let Some(working_branch) = &stack.working_branch {
852 let current_branch = repo.get_current_branch().ok();
854
855 if current_branch.as_ref() != Some(working_branch) {
856 Output::progress(format!(
857 "Switching to stack working branch: {working_branch}"
858 ));
859
860 if repo.branch_exists(working_branch) {
862 match repo.checkout_branch(working_branch) {
863 Ok(_) => {
864 Output::success(format!("Checked out branch: {working_branch}"));
865 }
866 Err(e) => {
867 Output::warning(format!("Failed to checkout '{working_branch}': {e}"));
868 Output::sub_item("Stack activated but stayed on current branch");
869 Output::sub_item(format!(
870 "You can manually checkout with: git checkout {working_branch}"
871 ));
872 }
873 }
874 } else {
875 Output::warning(format!(
876 "Stack working branch '{working_branch}' doesn't exist locally"
877 ));
878 Output::sub_item("Stack activated but stayed on current branch");
879 Output::sub_item(format!(
880 "You may need to fetch from remote: git fetch origin {working_branch}"
881 ));
882 }
883 } else {
884 Output::success(format!("Already on stack working branch: {working_branch}"));
885 }
886 } else {
887 Output::warning(format!("Stack '{name}' has no working branch set"));
889 Output::sub_item(
890 "This typically happens when a stack was created while on the base branch",
891 );
892
893 Output::tip("To start working on this stack:");
894 Output::bullet(format!("Create a feature branch: git checkout -b {name}"));
895 Output::bullet("The stack will automatically track this as its working branch");
896 Output::bullet("Then use 'ca push' to add commits to the stack");
897
898 Output::sub_item(format!("Base branch: {}", stack.base_branch));
899 }
900
901 manager.set_active_stack_by_name(&name)?;
903 Output::success(format!("Switched to stack '{name}'"));
904
905 Ok(())
906}
907
908async fn deactivate_stack(force: bool) -> Result<()> {
909 let current_dir = env::current_dir()
910 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
911
912 let repo_root = find_repository_root(¤t_dir)
913 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
914
915 let mut manager = StackManager::new(&repo_root)?;
916
917 let active_stack = manager.get_active_stack();
918
919 if active_stack.is_none() {
920 Output::info("No active stack to deactivate");
921 return Ok(());
922 }
923
924 let stack_name = active_stack.unwrap().name.clone();
925
926 if !force {
927 Output::warning(format!(
928 "This will deactivate stack '{stack_name}' and return to normal Git workflow"
929 ));
930 Output::sub_item(format!(
931 "You can reactivate it later with 'ca stacks switch {stack_name}'"
932 ));
933 let should_deactivate = Confirm::with_theme(&ColorfulTheme::default())
935 .with_prompt("Continue with deactivation?")
936 .default(false)
937 .interact()
938 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
939
940 if !should_deactivate {
941 Output::info("Cancelled deactivation");
942 return Ok(());
943 }
944 }
945
946 manager.set_active_stack(None)?;
948
949 Output::success(format!("Deactivated stack '{stack_name}'"));
950 Output::sub_item("Stack management is now OFF - you can use normal Git workflow");
951 Output::sub_item(format!("To reactivate: ca stacks switch {stack_name}"));
952
953 Ok(())
954}
955
956async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
957 let current_dir = env::current_dir()
958 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
959
960 let repo_root = find_repository_root(¤t_dir)
961 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
962
963 let stack_manager = StackManager::new(&repo_root)?;
964
965 let (stack_id, stack_name, stack_base, stack_working, stack_entries) = {
967 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
968 CascadeError::config(
969 "No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
970 .to_string(),
971 )
972 })?;
973
974 (
975 active_stack.id,
976 active_stack.name.clone(),
977 active_stack.base_branch.clone(),
978 active_stack.working_branch.clone(),
979 active_stack.entries.clone(),
980 )
981 };
982
983 Output::stack_info(
985 &stack_name,
986 &stack_id.to_string(),
987 &stack_base,
988 stack_working.as_deref(),
989 true, );
991 Output::sub_item(format!("Total entries: {}", stack_entries.len()));
992
993 if stack_entries.is_empty() {
994 Output::info("No entries in this stack yet");
995 Output::tip("Use 'ca push' to add commits to this stack");
996 return Ok(());
997 }
998
999 let refreshed_entries = if let Ok(config_dir) = crate::config::get_repo_config_dir(&repo_root) {
1002 let config_path = config_dir.join("config.json");
1003 if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
1004 let cascade_config = crate::config::CascadeConfig {
1005 bitbucket: Some(settings.bitbucket.clone()),
1006 git: settings.git.clone(),
1007 auth: crate::config::AuthConfig::default(),
1008 cascade: settings.cascade.clone(),
1009 };
1010
1011 if let Ok(integration_stack_manager) = StackManager::new(&repo_root) {
1013 let mut integration = crate::bitbucket::BitbucketIntegration::new(
1014 integration_stack_manager,
1015 cascade_config,
1016 )
1017 .ok();
1018
1019 if let Some(ref mut integ) = integration {
1020 let spinner =
1022 crate::utils::spinner::Spinner::new("Checking PR status...".to_string());
1023
1024 let _ = integ.check_enhanced_stack_status(&stack_id).await;
1026
1027 spinner.stop();
1028
1029 if let Ok(updated_manager) = StackManager::new(&repo_root) {
1031 if let Some(updated_stack) = updated_manager.get_stack(&stack_id) {
1032 updated_stack.entries.clone()
1033 } else {
1034 stack_entries.clone()
1035 }
1036 } else {
1037 stack_entries.clone()
1038 }
1039 } else {
1040 stack_entries.clone()
1041 }
1042 } else {
1043 stack_entries.clone()
1044 }
1045 } else {
1046 stack_entries.clone()
1047 }
1048 } else {
1049 stack_entries.clone()
1050 };
1051
1052 Output::section("Stack Entries");
1054 for (i, entry) in refreshed_entries.iter().enumerate() {
1055 let entry_num = i + 1;
1056 let short_hash = entry.short_hash();
1057 let short_msg = entry.short_message(50);
1058
1059 let stack_manager_for_metadata = StackManager::new(&repo_root)?;
1063 let metadata = stack_manager_for_metadata.get_repository_metadata();
1064 let source_branch_info = if !entry.is_submitted {
1065 if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash) {
1066 if commit_meta.source_branch != commit_meta.branch
1067 && !commit_meta.source_branch.is_empty()
1068 {
1069 format!(" (from {})", commit_meta.source_branch)
1070 } else {
1071 String::new()
1072 }
1073 } else {
1074 String::new()
1075 }
1076 } else {
1077 String::new()
1078 };
1079
1080 let status_colored = Output::entry_status(entry.is_submitted, entry.is_merged);
1082
1083 Output::numbered_item(
1084 entry_num,
1085 format!("{short_hash} {status_colored} {short_msg}{source_branch_info}"),
1086 );
1087
1088 if verbose {
1089 Output::sub_item(format!("Branch: {}", entry.branch));
1090 Output::sub_item(format!(
1091 "Created: {}",
1092 entry.created_at.format("%Y-%m-%d %H:%M")
1093 ));
1094 if let Some(pr_id) = &entry.pull_request_id {
1095 Output::sub_item(format!("PR: #{pr_id}"));
1096 }
1097
1098 Output::sub_item("Commit Message:");
1100 let lines: Vec<&str> = entry.message.lines().collect();
1101 for line in lines {
1102 Output::sub_item(format!(" {line}"));
1103 }
1104 }
1105 }
1106
1107 if show_mergeable {
1109 Output::section("Mergeability Status");
1110
1111 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1113 let config_path = config_dir.join("config.json");
1114 let settings = crate::config::Settings::load_from_file(&config_path)?;
1115
1116 let cascade_config = crate::config::CascadeConfig {
1117 bitbucket: Some(settings.bitbucket.clone()),
1118 git: settings.git.clone(),
1119 auth: crate::config::AuthConfig::default(),
1120 cascade: settings.cascade.clone(),
1121 };
1122
1123 let mut integration =
1124 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1125
1126 let spinner =
1127 crate::utils::spinner::Spinner::new("Fetching detailed PR status...".to_string());
1128 let status_result = integration.check_enhanced_stack_status(&stack_id).await;
1129 spinner.stop();
1130
1131 match status_result {
1132 Ok(status) => {
1133 Output::bullet(format!("Total entries: {}", status.total_entries));
1134 Output::bullet(format!("Submitted: {}", status.submitted_entries));
1135 Output::bullet(format!("Open PRs: {}", status.open_prs));
1136 Output::bullet(format!("Merged PRs: {}", status.merged_prs));
1137 Output::bullet(format!("Declined PRs: {}", status.declined_prs));
1138 Output::bullet(format!(
1139 "Completion: {:.1}%",
1140 status.completion_percentage()
1141 ));
1142
1143 if !status.enhanced_statuses.is_empty() {
1144 Output::section("Pull Request Status");
1145 let mut ready_to_land = 0;
1146
1147 for enhanced in &status.enhanced_statuses {
1148 use console::style;
1150 let (ready_badge, show_details) = match enhanced.pr.state {
1151 crate::bitbucket::pull_request::PullRequestState::Merged => {
1152 (style("[MERGED]").green().bold().to_string(), false)
1154 }
1155 crate::bitbucket::pull_request::PullRequestState::Declined => {
1156 (style("[DECLINED]").red().bold().to_string(), false)
1158 }
1159 crate::bitbucket::pull_request::PullRequestState::Open => {
1160 if enhanced.is_ready_to_land() {
1161 ready_to_land += 1;
1162 (style("[READY]").cyan().bold().to_string(), true)
1164 } else {
1165 (style("[PENDING]").yellow().bold().to_string(), true)
1167 }
1168 }
1169 };
1170
1171 Output::bullet(format!(
1173 "{} PR #{}: {}",
1174 ready_badge, enhanced.pr.id, enhanced.pr.title
1175 ));
1176
1177 if show_details {
1179 let build_display = if let Some(build) = &enhanced.build_status {
1181 match build.state {
1182 crate::bitbucket::pull_request::BuildState::Successful => {
1183 style("Passing").green().to_string()
1184 }
1185 crate::bitbucket::pull_request::BuildState::Failed => {
1186 style("Failing").red().to_string()
1187 }
1188 crate::bitbucket::pull_request::BuildState::InProgress => {
1189 style("Running").yellow().to_string()
1190 }
1191 crate::bitbucket::pull_request::BuildState::Cancelled => {
1192 style("Cancelled").dim().to_string()
1193 }
1194 crate::bitbucket::pull_request::BuildState::Unknown => {
1195 style("Unknown").dim().to_string()
1196 }
1197 }
1198 } else {
1199 let blocking = enhanced.get_blocking_reasons();
1201 if blocking.iter().any(|r| {
1202 r.contains("required builds") || r.contains("Build Status")
1203 }) {
1204 style("Pending").yellow().to_string()
1206 } else if blocking.is_empty() && enhanced.mergeable.unwrap_or(false)
1207 {
1208 style("Passing").green().to_string()
1210 } else {
1211 style("Unknown").dim().to_string()
1213 }
1214 };
1215 println!(" Builds: {}", build_display);
1216
1217 let review_display = if enhanced.review_status.can_merge {
1219 style("Approved").green().to_string()
1220 } else if enhanced.review_status.needs_work_count > 0 {
1221 style("Changes Requested").red().to_string()
1222 } else if enhanced.review_status.current_approvals > 0
1223 && enhanced.review_status.required_approvals > 0
1224 {
1225 style(format!(
1226 "{}/{} approvals",
1227 enhanced.review_status.current_approvals,
1228 enhanced.review_status.required_approvals
1229 ))
1230 .yellow()
1231 .to_string()
1232 } else {
1233 style("Pending").yellow().to_string()
1234 };
1235 println!(" Reviews: {}", review_display);
1236
1237 if !enhanced.mergeable.unwrap_or(false) {
1239 let blocking = enhanced.get_blocking_reasons();
1241 if !blocking.is_empty() {
1242 let first_reason = &blocking[0];
1244 let simplified = if first_reason.contains("Code Owners") {
1245 "Waiting for Code Owners approval"
1246 } else if first_reason.contains("required builds")
1247 || first_reason.contains("Build Status")
1248 {
1249 "Waiting for required builds"
1250 } else if first_reason.contains("approvals")
1251 || first_reason.contains("Requires approvals")
1252 {
1253 "Waiting for approvals"
1254 } else if first_reason.contains("conflicts") {
1255 "Has merge conflicts"
1256 } else {
1257 "Blocked by repository policy"
1259 };
1260
1261 println!(" Merge: {}", style(simplified).red());
1262 }
1263 } else if enhanced.is_ready_to_land() {
1264 println!(" Merge: {}", style("Ready").green());
1265 }
1266 }
1267
1268 if verbose {
1269 println!(
1270 " {} -> {}",
1271 enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
1272 );
1273
1274 if !enhanced.is_ready_to_land() {
1276 let blocking = enhanced.get_blocking_reasons();
1277 if !blocking.is_empty() {
1278 println!(" Blocking: {}", blocking.join(", "));
1279 }
1280 }
1281
1282 println!(
1284 " Reviews: {} approval{}",
1285 enhanced.review_status.current_approvals,
1286 if enhanced.review_status.current_approvals == 1 {
1287 ""
1288 } else {
1289 "s"
1290 }
1291 );
1292
1293 if enhanced.review_status.needs_work_count > 0 {
1294 println!(
1295 " {} reviewers requested changes",
1296 enhanced.review_status.needs_work_count
1297 );
1298 }
1299
1300 if let Some(build) = &enhanced.build_status {
1302 let build_icon = match build.state {
1303 crate::bitbucket::pull_request::BuildState::Successful => "✓",
1304 crate::bitbucket::pull_request::BuildState::Failed => "✗",
1305 crate::bitbucket::pull_request::BuildState::InProgress => "~",
1306 _ => "○",
1307 };
1308 println!(" Build: {} {:?}", build_icon, build.state);
1309 }
1310
1311 if let Some(url) = enhanced.pr.web_url() {
1312 println!(" URL: {url}");
1313 }
1314 println!();
1315 }
1316 }
1317
1318 if ready_to_land > 0 {
1319 println!(
1320 "\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
1321 ready_to_land,
1322 if ready_to_land == 1 { " is" } else { "s are" }
1323 );
1324 }
1325 }
1326 }
1327 Err(e) => {
1328 tracing::debug!("Failed to get enhanced stack status: {}", e);
1329 Output::warning("Could not fetch mergability status");
1330 Output::sub_item("Use 'ca stack show --verbose' for basic PR information");
1331 }
1332 }
1333 } else {
1334 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1336 let config_path = config_dir.join("config.json");
1337 let settings = crate::config::Settings::load_from_file(&config_path)?;
1338
1339 let cascade_config = crate::config::CascadeConfig {
1340 bitbucket: Some(settings.bitbucket.clone()),
1341 git: settings.git.clone(),
1342 auth: crate::config::AuthConfig::default(),
1343 cascade: settings.cascade.clone(),
1344 };
1345
1346 let integration =
1347 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1348
1349 match integration.check_stack_status(&stack_id).await {
1350 Ok(status) => {
1351 println!("\nPull Request Status:");
1352 println!(" Total entries: {}", status.total_entries);
1353 println!(" Submitted: {}", status.submitted_entries);
1354 println!(" Open PRs: {}", status.open_prs);
1355 println!(" Merged PRs: {}", status.merged_prs);
1356 println!(" Declined PRs: {}", status.declined_prs);
1357 println!(" Completion: {:.1}%", status.completion_percentage());
1358
1359 if !status.pull_requests.is_empty() {
1360 println!("\nPull Requests:");
1361 for pr in &status.pull_requests {
1362 use console::style;
1363
1364 let state_icon = match pr.state {
1366 crate::bitbucket::PullRequestState::Open => {
1367 style("→").cyan().to_string()
1368 }
1369 crate::bitbucket::PullRequestState::Merged => {
1370 style("✓").green().to_string()
1371 }
1372 crate::bitbucket::PullRequestState::Declined => {
1373 style("✗").red().to_string()
1374 }
1375 };
1376
1377 println!(
1380 " {} PR {}: {} ({} {} {})",
1381 state_icon,
1382 style(format!("#{}", pr.id)).dim(),
1383 pr.title,
1384 style(&pr.from_ref.display_id).dim(),
1385 style("→").dim(),
1386 style(&pr.to_ref.display_id).dim()
1387 );
1388
1389 if let Some(url) = pr.web_url() {
1391 println!(" URL: {}", style(url).cyan().underlined());
1392 }
1393 }
1394 }
1395
1396 println!();
1397 Output::tip("Use 'ca stack --mergeable' to see detailed status including build and review information");
1398 }
1399 Err(e) => {
1400 tracing::debug!("Failed to check stack status: {}", e);
1401 }
1402 }
1403 }
1404
1405 Ok(())
1406}
1407
1408#[allow(clippy::too_many_arguments)]
1409async fn push_to_stack(
1410 branch: Option<String>,
1411 message: Option<String>,
1412 commit: Option<String>,
1413 since: Option<String>,
1414 commits: Option<String>,
1415 squash: Option<usize>,
1416 squash_since: Option<String>,
1417 auto_branch: bool,
1418 allow_base_branch: bool,
1419 dry_run: bool,
1420 yes: bool,
1421) -> Result<()> {
1422 let current_dir = env::current_dir()
1423 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1424
1425 let repo_root = find_repository_root(¤t_dir)
1426 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1427
1428 let mut manager = StackManager::new(&repo_root)?;
1429 let repo = GitRepository::open(&repo_root)?;
1430
1431 if !manager.check_for_branch_change()? {
1433 return Ok(()); }
1435
1436 let active_stack = manager.get_active_stack().ok_or_else(|| {
1438 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1439 })?;
1440
1441 let current_branch = repo.get_current_branch()?;
1443 let base_branch = &active_stack.base_branch;
1444
1445 if current_branch == *base_branch {
1446 Output::error(format!(
1447 "You're currently on the base branch '{base_branch}'"
1448 ));
1449 Output::sub_item("Making commits directly on the base branch is not recommended.");
1450 Output::sub_item("This can pollute the base branch with work-in-progress commits.");
1451
1452 if allow_base_branch {
1454 Output::warning("Proceeding anyway due to --allow-base-branch flag");
1455 } else {
1456 let has_changes = repo.is_dirty()?;
1458
1459 if has_changes {
1460 if auto_branch {
1461 let feature_branch = format!("feature/{}-work", active_stack.name);
1463 Output::progress(format!(
1464 "Auto-creating feature branch '{feature_branch}'..."
1465 ));
1466
1467 repo.create_branch(&feature_branch, None)?;
1468 repo.checkout_branch(&feature_branch)?;
1469
1470 Output::success(format!("Created and switched to '{feature_branch}'"));
1471 println!(" You can now commit and push your changes safely");
1472
1473 } else {
1475 println!("\nYou have uncommitted changes. Here are your options:");
1476 println!(" 1. Create a feature branch first:");
1477 println!(" git checkout -b feature/my-work");
1478 println!(" git commit -am \"your work\"");
1479 println!(" ca push");
1480 println!("\n 2. Auto-create a branch (recommended):");
1481 println!(" ca push --auto-branch");
1482 println!("\n 3. Force push to base branch (dangerous):");
1483 println!(" ca push --allow-base-branch");
1484
1485 return Err(CascadeError::config(
1486 "Refusing to push uncommitted changes from base branch. Use one of the options above."
1487 ));
1488 }
1489 } else {
1490 let commits_to_check = if let Some(commits_str) = &commits {
1492 commits_str
1493 .split(',')
1494 .map(|s| s.trim().to_string())
1495 .collect::<Vec<String>>()
1496 } else if let Some(since_ref) = &since {
1497 let since_commit = repo.resolve_reference(since_ref)?;
1498 let head_commit = repo.get_head_commit()?;
1499 let commits = repo.get_commits_between(
1500 &since_commit.id().to_string(),
1501 &head_commit.id().to_string(),
1502 )?;
1503 commits.into_iter().map(|c| c.id().to_string()).collect()
1504 } else if commit.is_none() {
1505 let mut unpushed = Vec::new();
1506 let head_commit = repo.get_head_commit()?;
1507 let mut current_commit = head_commit;
1508
1509 loop {
1510 let commit_hash = current_commit.id().to_string();
1511 let already_in_stack = active_stack
1512 .entries
1513 .iter()
1514 .any(|entry| entry.commit_hash == commit_hash);
1515
1516 if already_in_stack {
1517 break;
1518 }
1519
1520 unpushed.push(commit_hash);
1521
1522 if let Some(parent) = current_commit.parents().next() {
1523 current_commit = parent;
1524 } else {
1525 break;
1526 }
1527 }
1528
1529 unpushed.reverse();
1530 unpushed
1531 } else {
1532 vec![repo.get_head_commit()?.id().to_string()]
1533 };
1534
1535 if !commits_to_check.is_empty() {
1536 if auto_branch {
1537 let feature_branch = format!("feature/{}-work", active_stack.name);
1539 Output::progress(format!(
1540 "Auto-creating feature branch '{feature_branch}'..."
1541 ));
1542
1543 repo.create_branch(&feature_branch, Some(base_branch))?;
1544 repo.checkout_branch(&feature_branch)?;
1545
1546 println!(
1548 "🍒 Cherry-picking {} commit(s) to new branch...",
1549 commits_to_check.len()
1550 );
1551 for commit_hash in &commits_to_check {
1552 match repo.cherry_pick(commit_hash) {
1553 Ok(_) => println!(" ✅ Cherry-picked {}", &commit_hash[..8]),
1554 Err(e) => {
1555 Output::error(format!(
1556 "Failed to cherry-pick {}: {}",
1557 &commit_hash[..8],
1558 e
1559 ));
1560 Output::tip("You may need to resolve conflicts manually");
1561 return Err(CascadeError::branch(format!(
1562 "Failed to cherry-pick commit {commit_hash}: {e}"
1563 )));
1564 }
1565 }
1566 }
1567
1568 println!(
1569 "✅ Successfully moved {} commit(s) to '{feature_branch}'",
1570 commits_to_check.len()
1571 );
1572 println!(
1573 " You're now on the feature branch and can continue with 'ca push'"
1574 );
1575
1576 } else {
1578 println!(
1579 "\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
1580 commits_to_check.len()
1581 );
1582 println!(" These commits are currently ON the base branch, which may not be intended.");
1583 println!("\n Options:");
1584 println!(" 1. Auto-create feature branch and cherry-pick commits:");
1585 println!(" ca push --auto-branch");
1586 println!("\n 2. Manually create branch and move commits:");
1587 println!(" git checkout -b feature/my-work");
1588 println!(" ca push");
1589 println!("\n 3. Force push from base branch (not recommended):");
1590 println!(" ca push --allow-base-branch");
1591
1592 return Err(CascadeError::config(
1593 "Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
1594 ));
1595 }
1596 }
1597 }
1598 }
1599 }
1600
1601 if let Some(squash_count) = squash {
1603 if squash_count == 0 {
1604 let active_stack = manager.get_active_stack().ok_or_else(|| {
1606 CascadeError::config(
1607 "No active stack. Create a stack first with 'ca stacks create'",
1608 )
1609 })?;
1610
1611 let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
1612
1613 if unpushed_count == 0 {
1614 Output::info(" No unpushed commits to squash");
1615 } else if unpushed_count == 1 {
1616 Output::info(" Only 1 unpushed commit, no squashing needed");
1617 } else {
1618 println!(" Auto-detected {unpushed_count} unpushed commits, squashing...");
1619 squash_commits(&repo, unpushed_count, None).await?;
1620 Output::success(" Squashed {unpushed_count} unpushed commits into one");
1621 }
1622 } else {
1623 println!(" Squashing last {squash_count} commits...");
1624 squash_commits(&repo, squash_count, None).await?;
1625 Output::success(" Squashed {squash_count} commits into one");
1626 }
1627 } else if let Some(since_ref) = squash_since {
1628 println!(" Squashing commits since {since_ref}...");
1629 let since_commit = repo.resolve_reference(&since_ref)?;
1630 let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
1631 squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
1632 Output::success(" Squashed {commits_count} commits since {since_ref} into one");
1633 }
1634
1635 if commits.is_none() && since.is_none() && commit.is_none() {
1638 let active_stack_for_stale = manager.get_active_stack().ok_or_else(|| {
1639 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1640 })?;
1641 let stale_base = &active_stack_for_stale.base_branch;
1642 let stale_current = repo.get_current_branch()?;
1643
1644 if stale_current != *stale_base {
1645 match repo.get_commits_between(&stale_current, stale_base) {
1646 Ok(base_ahead_commits) if !base_ahead_commits.is_empty() => {
1647 let count = base_ahead_commits.len();
1648 Output::warning(format!(
1649 "Base branch '{}' has {} new commit(s) since your branch diverged",
1650 stale_base, count
1651 ));
1652 Output::sub_item("Commits from other developers may be included in your push.");
1653 Output::tip("Run 'ca sync' or 'ca stacks rebase' to rebase first.");
1654
1655 if !dry_run && !yes {
1656 let should_rebase = Confirm::with_theme(&ColorfulTheme::default())
1657 .with_prompt("Rebase before pushing?")
1658 .default(true)
1659 .interact()
1660 .map_err(|e| {
1661 CascadeError::config(format!(
1662 "Failed to get user confirmation: {e}"
1663 ))
1664 })?;
1665
1666 if should_rebase {
1667 Output::info(
1668 "Run 'ca sync' to rebase your stack on the updated base branch.",
1669 );
1670 return Ok(());
1671 }
1672 }
1673 }
1674 _ => {} }
1676 }
1677 }
1678
1679 let commits_to_push = if let Some(commits_str) = commits {
1681 commits_str
1683 .split(',')
1684 .map(|s| s.trim().to_string())
1685 .collect::<Vec<String>>()
1686 } else if let Some(since_ref) = since {
1687 let since_commit = repo.resolve_reference(&since_ref)?;
1689 let head_commit = repo.get_head_commit()?;
1690
1691 let commits = repo.get_commits_between(
1693 &since_commit.id().to_string(),
1694 &head_commit.id().to_string(),
1695 )?;
1696 commits.into_iter().map(|c| c.id().to_string()).collect()
1697 } else if let Some(hash) = commit {
1698 vec![hash]
1700 } else {
1701 let active_stack = manager.get_active_stack().ok_or_else(|| {
1703 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1704 })?;
1705
1706 let base_branch = &active_stack.base_branch;
1708 let current_branch = repo.get_current_branch()?;
1709
1710 if current_branch == *base_branch {
1712 let mut unpushed = Vec::new();
1713 let head_commit = repo.get_head_commit()?;
1714 let mut current_commit = head_commit;
1715
1716 loop {
1718 let commit_hash = current_commit.id().to_string();
1719 let already_in_stack = active_stack
1720 .entries
1721 .iter()
1722 .any(|entry| entry.commit_hash == commit_hash);
1723
1724 if already_in_stack {
1725 break;
1726 }
1727
1728 unpushed.push(commit_hash);
1729
1730 if let Some(parent) = current_commit.parents().next() {
1732 current_commit = parent;
1733 } else {
1734 break;
1735 }
1736 }
1737
1738 unpushed.reverse(); unpushed
1740 } else {
1741 match repo.get_commits_between(base_branch, ¤t_branch) {
1743 Ok(commits) => {
1744 let mut unpushed: Vec<String> =
1745 commits.into_iter().map(|c| c.id().to_string()).collect();
1746
1747 unpushed.retain(|commit_hash| {
1749 !active_stack
1750 .entries
1751 .iter()
1752 .any(|entry| entry.commit_hash == *commit_hash)
1753 });
1754
1755 unpushed.reverse(); unpushed
1757 }
1758 Err(e) => {
1759 return Err(CascadeError::branch(format!(
1760 "Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
1761 This usually means the branches have diverged or don't share common history."
1762 )));
1763 }
1764 }
1765 }
1766 };
1767
1768 if commits_to_push.is_empty() {
1769 Output::info(" No commits to push to stack");
1770 return Ok(());
1771 }
1772
1773 let (user_name, user_email) = repo.get_user_info();
1775 let mut has_foreign_commits = false;
1776
1777 Output::section(format!("Commits to push ({})", commits_to_push.len()));
1778
1779 for (i, commit_hash) in commits_to_push.iter().enumerate() {
1780 let commit_obj = repo.get_commit(commit_hash)?;
1781 let author = commit_obj.author();
1782 let author_name = author.name().unwrap_or("unknown").to_string();
1783 let author_email = author.email().unwrap_or("").to_string();
1784 let summary = commit_obj.summary().unwrap_or("(no message)");
1785 let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 8)];
1786
1787 let is_foreign = !matches!(
1788 (&user_name, &user_email),
1789 (Some(ref un), _) if *un == author_name
1790 ) && !matches!(
1791 (&user_name, &user_email),
1792 (_, Some(ref ue)) if *ue == author_email
1793 );
1794
1795 if is_foreign {
1796 has_foreign_commits = true;
1797 Output::numbered_item(
1798 i + 1,
1799 format!("{short_hash} {summary} [{author_name}] ← other author"),
1800 );
1801 } else {
1802 Output::numbered_item(i + 1, format!("{short_hash} {summary} [{author_name}]"));
1803 }
1804 }
1805
1806 if has_foreign_commits {
1807 let foreign_count = commits_to_push
1808 .iter()
1809 .filter(|hash| {
1810 if let Ok(c) = repo.get_commit(hash) {
1811 let a = c.author();
1812 let an = a.name().unwrap_or("").to_string();
1813 let ae = a.email().unwrap_or("").to_string();
1814 !matches!(&user_name, Some(ref un) if *un == an)
1815 && !matches!(&user_email, Some(ref ue) if *ue == ae)
1816 } else {
1817 false
1818 }
1819 })
1820 .count();
1821 Output::warning(format!(
1822 "{} commit(s) are from other authors — these may not be your changes.",
1823 foreign_count
1824 ));
1825 }
1826
1827 if dry_run {
1829 Output::tip("Run without --dry-run to actually push these commits.");
1830 return Ok(());
1831 }
1832
1833 if !yes {
1835 let default_confirm = !has_foreign_commits;
1836 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
1837 .with_prompt(format!(
1838 "Push {} commit(s) to stack?",
1839 commits_to_push.len()
1840 ))
1841 .default(default_confirm)
1842 .interact()
1843 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1844
1845 if !should_continue {
1846 Output::info("Push cancelled.");
1847 return Ok(());
1848 }
1849 }
1850
1851 analyze_commits_for_safeguards(&commits_to_push, &repo, dry_run).await?;
1853
1854 let mut pushed_count = 0;
1856 let mut source_branches = std::collections::HashSet::new();
1857
1858 for (i, commit_hash) in commits_to_push.iter().enumerate() {
1859 let commit_obj = repo.get_commit(commit_hash)?;
1860 let commit_msg = commit_obj.message().unwrap_or("").to_string();
1861
1862 let commit_source_branch = repo
1864 .find_branch_containing_commit(commit_hash)
1865 .unwrap_or_else(|_| current_branch.clone());
1866 source_branches.insert(commit_source_branch.clone());
1867
1868 let branch_name = if i == 0 && branch.is_some() {
1870 branch.clone().unwrap()
1871 } else {
1872 let temp_repo = GitRepository::open(&repo_root)?;
1874 let branch_mgr = crate::git::BranchManager::new(temp_repo);
1875 branch_mgr.generate_branch_name(&commit_msg)
1876 };
1877
1878 let final_message = if i == 0 && message.is_some() {
1880 message.clone().unwrap()
1881 } else {
1882 commit_msg.clone()
1883 };
1884
1885 let entry_id = manager.push_to_stack(
1886 branch_name.clone(),
1887 commit_hash.clone(),
1888 final_message.clone(),
1889 commit_source_branch.clone(),
1890 )?;
1891 pushed_count += 1;
1892
1893 Output::success(format!(
1894 "Pushed commit {}/{} to stack",
1895 i + 1,
1896 commits_to_push.len()
1897 ));
1898 Output::sub_item(format!(
1899 "Commit: {} ({})",
1900 &commit_hash[..8],
1901 commit_msg.split('\n').next().unwrap_or("")
1902 ));
1903 Output::sub_item(format!("Branch: {branch_name}"));
1904 Output::sub_item(format!("Source: {commit_source_branch}"));
1905 Output::sub_item(format!("Entry ID: {entry_id}"));
1906 println!();
1907 }
1908
1909 if source_branches.len() > 1 {
1911 Output::warning("Scattered Commit Detection");
1912 Output::sub_item(format!(
1913 "You've pushed commits from {} different Git branches:",
1914 source_branches.len()
1915 ));
1916 for branch in &source_branches {
1917 Output::bullet(branch.to_string());
1918 }
1919
1920 Output::section("This can lead to confusion because:");
1921 Output::bullet("Stack appears sequential but commits are scattered across branches");
1922 Output::bullet("Team members won't know which branch contains which work");
1923 Output::bullet("Branch cleanup becomes unclear after merge");
1924 Output::bullet("Rebase operations become more complex");
1925
1926 Output::tip("Consider consolidating work to a single feature branch:");
1927 Output::bullet("Create a new feature branch: git checkout -b feature/consolidated-work");
1928 Output::bullet("Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
1929 Output::bullet("Delete old scattered branches");
1930 Output::bullet("Push the consolidated branch to your stack");
1931 println!();
1932 }
1933
1934 Output::success(format!(
1935 "Successfully pushed {} commit{} to stack",
1936 pushed_count,
1937 if pushed_count == 1 { "" } else { "s" }
1938 ));
1939
1940 Ok(())
1941}
1942
1943async fn pop_from_stack(keep_branch: bool) -> Result<()> {
1944 let current_dir = env::current_dir()
1945 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1946
1947 let repo_root = find_repository_root(¤t_dir)
1948 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1949
1950 let mut manager = StackManager::new(&repo_root)?;
1951 let repo = GitRepository::open(&repo_root)?;
1952
1953 let entry = manager.pop_from_stack()?;
1954
1955 Output::success("Popped commit from stack");
1956 Output::sub_item(format!(
1957 "Commit: {} ({})",
1958 entry.short_hash(),
1959 entry.short_message(50)
1960 ));
1961 Output::sub_item(format!("Branch: {}", entry.branch));
1962
1963 if !keep_branch && entry.branch != repo.get_current_branch()? {
1965 match repo.delete_branch(&entry.branch) {
1966 Ok(_) => Output::sub_item(format!("Deleted branch: {}", entry.branch)),
1967 Err(e) => Output::warning(format!("Could not delete branch {}: {}", entry.branch, e)),
1968 }
1969 }
1970
1971 Ok(())
1972}
1973
1974async fn submit_entry(
1975 entry: Option<usize>,
1976 title: Option<String>,
1977 description: Option<String>,
1978 range: Option<String>,
1979 draft: bool,
1980 open: bool,
1981) -> Result<()> {
1982 let current_dir = env::current_dir()
1983 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1984
1985 let repo_root = find_repository_root(¤t_dir)
1986 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1987
1988 let mut stack_manager = StackManager::new(&repo_root)?;
1989
1990 if !stack_manager.check_for_branch_change()? {
1992 return Ok(()); }
1994
1995 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1997 let config_path = config_dir.join("config.json");
1998 let settings = crate::config::Settings::load_from_file(&config_path)?;
1999
2000 let cascade_config = crate::config::CascadeConfig {
2002 bitbucket: Some(settings.bitbucket.clone()),
2003 git: settings.git.clone(),
2004 auth: crate::config::AuthConfig::default(),
2005 cascade: settings.cascade.clone(),
2006 };
2007
2008 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2010 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2011 })?;
2012 let stack_id = active_stack.id;
2013
2014 let entries_to_submit = if let Some(range_str) = range {
2016 let mut entries = Vec::new();
2018
2019 if range_str.contains('-') {
2020 let parts: Vec<&str> = range_str.split('-').collect();
2022 if parts.len() != 2 {
2023 return Err(CascadeError::config(
2024 "Invalid range format. Use 'start-end' (e.g., '1-3')",
2025 ));
2026 }
2027
2028 let start: usize = parts[0]
2029 .parse()
2030 .map_err(|_| CascadeError::config("Invalid start number in range"))?;
2031 let end: usize = parts[1]
2032 .parse()
2033 .map_err(|_| CascadeError::config("Invalid end number in range"))?;
2034
2035 if start == 0
2036 || end == 0
2037 || start > active_stack.entries.len()
2038 || end > active_stack.entries.len()
2039 {
2040 return Err(CascadeError::config(format!(
2041 "Range out of bounds. Stack has {} entries",
2042 active_stack.entries.len()
2043 )));
2044 }
2045
2046 for i in start..=end {
2047 entries.push((i, active_stack.entries[i - 1].clone()));
2048 }
2049 } else {
2050 for entry_str in range_str.split(',') {
2052 let entry_num: usize = entry_str.trim().parse().map_err(|_| {
2053 CascadeError::config(format!("Invalid entry number: {entry_str}"))
2054 })?;
2055
2056 if entry_num == 0 || entry_num > active_stack.entries.len() {
2057 return Err(CascadeError::config(format!(
2058 "Entry {} out of bounds. Stack has {} entries",
2059 entry_num,
2060 active_stack.entries.len()
2061 )));
2062 }
2063
2064 entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
2065 }
2066 }
2067
2068 entries
2069 } else if let Some(entry_num) = entry {
2070 if entry_num == 0 || entry_num > active_stack.entries.len() {
2072 return Err(CascadeError::config(format!(
2073 "Invalid entry number: {}. Stack has {} entries",
2074 entry_num,
2075 active_stack.entries.len()
2076 )));
2077 }
2078 vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
2079 } else {
2080 active_stack
2082 .entries
2083 .iter()
2084 .enumerate()
2085 .filter(|(_, entry)| !entry.is_submitted)
2086 .map(|(i, entry)| (i + 1, entry.clone())) .collect::<Vec<(usize, _)>>()
2088 };
2089
2090 if entries_to_submit.is_empty() {
2091 Output::info("No entries to submit");
2092 return Ok(());
2093 }
2094
2095 Output::section(format!(
2097 "Submitting {} {}",
2098 entries_to_submit.len(),
2099 if entries_to_submit.len() == 1 {
2100 "entry"
2101 } else {
2102 "entries"
2103 }
2104 ));
2105 println!();
2106
2107 let integration_stack_manager = StackManager::new(&repo_root)?;
2109 let mut integration =
2110 BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
2111
2112 let mut submitted_count = 0;
2114 let mut failed_entries = Vec::new();
2115 let mut pr_urls = Vec::new(); let total_entries = entries_to_submit.len();
2117
2118 for (entry_num, entry_to_submit) in &entries_to_submit {
2119 let tree_char = if entries_to_submit.len() == 1 {
2121 "→"
2122 } else if entry_num == &entries_to_submit.len() {
2123 "└─"
2124 } else {
2125 "├─"
2126 };
2127 print!(
2128 " {} Entry {}: {}... ",
2129 tree_char, entry_num, entry_to_submit.branch
2130 );
2131 std::io::Write::flush(&mut std::io::stdout()).ok();
2132
2133 let entry_title = if total_entries == 1 {
2135 title.clone()
2136 } else {
2137 None
2138 };
2139 let entry_description = if total_entries == 1 {
2140 description.clone()
2141 } else {
2142 None
2143 };
2144
2145 match integration
2146 .submit_entry(
2147 &stack_id,
2148 &entry_to_submit.id,
2149 entry_title,
2150 entry_description,
2151 draft,
2152 )
2153 .await
2154 {
2155 Ok(pr) => {
2156 submitted_count += 1;
2157 Output::success(format!("PR #{}", pr.id));
2158 if let Some(url) = pr.web_url() {
2159 use console::style;
2160 Output::sub_item(format!(
2161 "{} {} {}",
2162 pr.from_ref.display_id,
2163 style("→").dim(),
2164 pr.to_ref.display_id
2165 ));
2166 Output::sub_item(format!("URL: {}", style(url.clone()).cyan().underlined()));
2167 pr_urls.push(url); }
2169 }
2170 Err(e) => {
2171 Output::error("Failed");
2172 let clean_error = if e.to_string().contains("non-fast-forward") {
2174 "Branch has diverged (was rebased after initial submission). Update to v0.1.41+ to auto force-push.".to_string()
2175 } else if e.to_string().contains("authentication") {
2176 "Authentication failed. Check your Bitbucket credentials.".to_string()
2177 } else {
2178 e.to_string()
2180 .lines()
2181 .filter(|l| !l.trim().starts_with("hint:") && !l.trim().is_empty())
2182 .take(1)
2183 .collect::<Vec<_>>()
2184 .join(" ")
2185 .trim()
2186 .to_string()
2187 };
2188 Output::sub_item(format!("Error: {}", clean_error));
2189 failed_entries.push((*entry_num, clean_error));
2190 }
2191 }
2192 }
2193
2194 println!();
2195
2196 let has_any_prs = active_stack
2198 .entries
2199 .iter()
2200 .any(|e| e.pull_request_id.is_some());
2201 if has_any_prs && submitted_count > 0 {
2202 match integration.update_all_pr_descriptions(&stack_id).await {
2203 Ok(updated_prs) => {
2204 if !updated_prs.is_empty() {
2205 Output::sub_item(format!(
2206 "Updated {} PR description{} with stack hierarchy",
2207 updated_prs.len(),
2208 if updated_prs.len() == 1 { "" } else { "s" }
2209 ));
2210 }
2211 }
2212 Err(e) => {
2213 let error_msg = e.to_string();
2216 if !error_msg.contains("409") && !error_msg.contains("out-of-date") {
2217 let clean_error = error_msg.lines().next().unwrap_or("Unknown error").trim();
2219 Output::warning(format!(
2220 "Could not update some PR descriptions: {}",
2221 clean_error
2222 ));
2223 Output::sub_item(
2224 "PRs were created successfully - descriptions can be updated manually",
2225 );
2226 }
2227 }
2228 }
2229 }
2230
2231 if failed_entries.is_empty() {
2233 Output::success(format!(
2234 "{} {} submitted successfully!",
2235 submitted_count,
2236 if submitted_count == 1 {
2237 "entry"
2238 } else {
2239 "entries"
2240 }
2241 ));
2242 } else {
2243 println!();
2244 Output::section("Submission Summary");
2245 Output::success(format!("Successful: {submitted_count}"));
2246 Output::error(format!("Failed: {}", failed_entries.len()));
2247
2248 if !failed_entries.is_empty() {
2249 println!();
2250 Output::tip("Retry failed entries:");
2251 for (entry_num, _) in &failed_entries {
2252 Output::bullet(format!("ca stack submit {entry_num}"));
2253 }
2254 }
2255 }
2256
2257 if open && !pr_urls.is_empty() {
2259 println!();
2260 for url in &pr_urls {
2261 if let Err(e) = open::that(url) {
2262 Output::warning(format!("Could not open browser: {}", e));
2263 Output::tip(format!("Open manually: {}", url));
2264 }
2265 }
2266 }
2267
2268 Ok(())
2269}
2270
2271async fn check_stack_status(name: Option<String>) -> Result<()> {
2272 let current_dir = env::current_dir()
2273 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2274
2275 let repo_root = find_repository_root(¤t_dir)
2276 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2277
2278 let stack_manager = StackManager::new(&repo_root)?;
2279
2280 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2282 let config_path = config_dir.join("config.json");
2283 let settings = crate::config::Settings::load_from_file(&config_path)?;
2284
2285 let cascade_config = crate::config::CascadeConfig {
2287 bitbucket: Some(settings.bitbucket.clone()),
2288 git: settings.git.clone(),
2289 auth: crate::config::AuthConfig::default(),
2290 cascade: settings.cascade.clone(),
2291 };
2292
2293 let stack = if let Some(name) = name {
2295 stack_manager
2296 .get_stack_by_name(&name)
2297 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
2298 } else {
2299 stack_manager.get_active_stack().ok_or_else(|| {
2300 CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
2301 })?
2302 };
2303 let stack_id = stack.id;
2304
2305 Output::section(format!("Stack: {}", stack.name));
2306 Output::sub_item(format!("ID: {}", stack.id));
2307 Output::sub_item(format!("Base: {}", stack.base_branch));
2308
2309 if let Some(description) = &stack.description {
2310 Output::sub_item(format!("Description: {description}"));
2311 }
2312
2313 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2315
2316 match integration.check_stack_status(&stack_id).await {
2318 Ok(status) => {
2319 Output::section("Pull Request Status");
2320 Output::sub_item(format!("Total entries: {}", status.total_entries));
2321 Output::sub_item(format!("Submitted: {}", status.submitted_entries));
2322 Output::sub_item(format!("Open PRs: {}", status.open_prs));
2323 Output::sub_item(format!("Merged PRs: {}", status.merged_prs));
2324 Output::sub_item(format!("Declined PRs: {}", status.declined_prs));
2325 Output::sub_item(format!(
2326 "Completion: {:.1}%",
2327 status.completion_percentage()
2328 ));
2329
2330 if !status.pull_requests.is_empty() {
2331 Output::section("Pull Requests");
2332 for pr in &status.pull_requests {
2333 use console::style;
2334
2335 let state_icon = match pr.state {
2337 crate::bitbucket::PullRequestState::Open => style("→").cyan().to_string(),
2338 crate::bitbucket::PullRequestState::Merged => {
2339 style("✓").green().to_string()
2340 }
2341 crate::bitbucket::PullRequestState::Declined => {
2342 style("✗").red().to_string()
2343 }
2344 };
2345
2346 Output::bullet(format!(
2349 "{} PR {}: {} ({} {} {})",
2350 state_icon,
2351 style(format!("#{}", pr.id)).dim(),
2352 pr.title,
2353 style(&pr.from_ref.display_id).dim(),
2354 style("→").dim(),
2355 style(&pr.to_ref.display_id).dim()
2356 ));
2357
2358 if let Some(url) = pr.web_url() {
2360 println!(" URL: {}", style(url).cyan().underlined());
2361 }
2362 }
2363 }
2364 }
2365 Err(e) => {
2366 tracing::debug!("Failed to check stack status: {}", e);
2367 return Err(e);
2368 }
2369 }
2370
2371 Ok(())
2372}
2373
2374async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
2375 let current_dir = env::current_dir()
2376 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2377
2378 let repo_root = find_repository_root(¤t_dir)
2379 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2380
2381 let stack_manager = StackManager::new(&repo_root)?;
2382
2383 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2385 let config_path = config_dir.join("config.json");
2386 let settings = crate::config::Settings::load_from_file(&config_path)?;
2387
2388 let cascade_config = crate::config::CascadeConfig {
2390 bitbucket: Some(settings.bitbucket.clone()),
2391 git: settings.git.clone(),
2392 auth: crate::config::AuthConfig::default(),
2393 cascade: settings.cascade.clone(),
2394 };
2395
2396 let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2398
2399 let pr_state = if let Some(state_str) = state {
2401 match state_str.to_lowercase().as_str() {
2402 "open" => Some(crate::bitbucket::PullRequestState::Open),
2403 "merged" => Some(crate::bitbucket::PullRequestState::Merged),
2404 "declined" => Some(crate::bitbucket::PullRequestState::Declined),
2405 _ => {
2406 return Err(CascadeError::config(format!(
2407 "Invalid state '{state_str}'. Use: open, merged, declined"
2408 )))
2409 }
2410 }
2411 } else {
2412 None
2413 };
2414
2415 match integration.list_pull_requests(pr_state).await {
2417 Ok(pr_page) => {
2418 if pr_page.values.is_empty() {
2419 Output::info("No pull requests found.");
2420 return Ok(());
2421 }
2422
2423 println!("Pull Requests ({} total):", pr_page.values.len());
2424 for pr in &pr_page.values {
2425 let state_icon = match pr.state {
2426 crate::bitbucket::PullRequestState::Open => "○",
2427 crate::bitbucket::PullRequestState::Merged => "✓",
2428 crate::bitbucket::PullRequestState::Declined => "✗",
2429 };
2430 println!(" {} PR #{}: {}", state_icon, pr.id, pr.title);
2431 if verbose {
2432 println!(
2433 " From: {} -> {}",
2434 pr.from_ref.display_id, pr.to_ref.display_id
2435 );
2436 println!(
2437 " Author: {}",
2438 pr.author
2439 .user
2440 .display_name
2441 .as_deref()
2442 .unwrap_or(&pr.author.user.name)
2443 );
2444 if let Some(url) = pr.web_url() {
2445 println!(" URL: {url}");
2446 }
2447 if let Some(desc) = &pr.description {
2448 if !desc.is_empty() {
2449 println!(" Description: {desc}");
2450 }
2451 }
2452 println!();
2453 }
2454 }
2455
2456 if !verbose {
2457 println!("\nUse --verbose for more details");
2458 }
2459 }
2460 Err(e) => {
2461 warn!("Failed to list pull requests: {}", e);
2462 return Err(e);
2463 }
2464 }
2465
2466 Ok(())
2467}
2468
2469async fn check_stack(_force: bool) -> Result<()> {
2470 let current_dir = env::current_dir()
2471 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2472
2473 let repo_root = find_repository_root(¤t_dir)
2474 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2475
2476 let mut manager = StackManager::new(&repo_root)?;
2477
2478 let active_stack = manager
2479 .get_active_stack()
2480 .ok_or_else(|| CascadeError::config("No active stack"))?;
2481 let stack_id = active_stack.id;
2482
2483 manager.sync_stack(&stack_id)?;
2484
2485 Output::success("Stack check completed successfully");
2486
2487 Ok(())
2488}
2489
2490pub async fn continue_sync() -> Result<()> {
2491 let current_dir = env::current_dir()
2492 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2493
2494 let repo_root = find_repository_root(¤t_dir)?;
2495
2496 Output::section("Continuing sync from where it left off");
2497 println!();
2498
2499 let cherry_pick_head = repo_root.join(".git").join("CHERRY_PICK_HEAD");
2501 if !cherry_pick_head.exists() {
2502 return Err(CascadeError::config(
2503 "No in-progress cherry-pick found. Nothing to continue.\n\n\
2504 Use 'ca sync' to start a new sync."
2505 .to_string(),
2506 ));
2507 }
2508
2509 Output::info("Staging all resolved files");
2510
2511 std::process::Command::new("git")
2513 .args(["add", "-A"])
2514 .current_dir(&repo_root)
2515 .output()
2516 .map_err(CascadeError::Io)?;
2517
2518 let sync_state = crate::stack::SyncState::load(&repo_root).ok();
2519
2520 Output::info("Continuing cherry-pick");
2521
2522 let continue_output = std::process::Command::new("git")
2524 .args(["cherry-pick", "--continue"])
2525 .current_dir(&repo_root)
2526 .output()
2527 .map_err(CascadeError::Io)?;
2528
2529 if !continue_output.status.success() {
2530 let stderr = String::from_utf8_lossy(&continue_output.stderr);
2531 return Err(CascadeError::Branch(format!(
2532 "Failed to continue cherry-pick: {}\n\n\
2533 Make sure all conflicts are resolved.",
2534 stderr
2535 )));
2536 }
2537
2538 Output::success("Cherry-pick continued successfully");
2539 println!();
2540
2541 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2548 let current_branch = git_repo.get_current_branch()?;
2549
2550 let stack_branch = if let Some(state) = &sync_state {
2551 if !state.current_entry_branch.is_empty() {
2552 if !state.current_temp_branch.is_empty() && current_branch != state.current_temp_branch
2553 {
2554 tracing::warn!(
2555 "Sync state temp branch '{}' differs from current branch '{}'",
2556 state.current_temp_branch,
2557 current_branch
2558 );
2559 }
2560 state.current_entry_branch.clone()
2561 } else if let Some(idx) = current_branch.rfind("-temp-") {
2562 current_branch[..idx].to_string()
2563 } else {
2564 return Err(CascadeError::config(format!(
2565 "Current branch '{}' doesn't appear to be a temp branch created by cascade.\n\
2566 Expected format: <branch>-temp-<timestamp>",
2567 current_branch
2568 )));
2569 }
2570 } else if let Some(idx) = current_branch.rfind("-temp-") {
2571 current_branch[..idx].to_string()
2572 } else {
2573 return Err(CascadeError::config(format!(
2574 "Current branch '{}' doesn't appear to be a temp branch created by cascade.\n\
2575 Expected format: <branch>-temp-<timestamp>",
2576 current_branch
2577 )));
2578 };
2579
2580 Output::info(format!("Updating stack branch: {}", stack_branch));
2581
2582 let output = std::process::Command::new("git")
2584 .args(["branch", "-f", &stack_branch])
2585 .current_dir(&repo_root)
2586 .output()
2587 .map_err(CascadeError::Io)?;
2588
2589 if !output.status.success() {
2590 let stderr = String::from_utf8_lossy(&output.stderr);
2591 return Err(CascadeError::validation(format!(
2592 "Failed to update branch '{}': {}\n\n\
2593 This could be due to:\n\
2594 • Git lock file (.git/index.lock or .git/refs/heads/{}.lock)\n\
2595 • Insufficient permissions\n\
2596 • Branch is checked out in another worktree\n\n\
2597 Recovery:\n\
2598 1. Check for lock files: find .git -name '*.lock'\n\
2599 2. Remove stale lock files if safe\n\
2600 3. Run 'ca sync' to retry",
2601 stack_branch,
2602 stderr.trim(),
2603 stack_branch
2604 )));
2605 }
2606
2607 let mut manager = crate::stack::StackManager::new(&repo_root)?;
2609
2610 let new_commit_hash = git_repo.get_branch_head(&stack_branch)?;
2613
2614 let (stack_id, entry_id_opt, working_branch) = if let Some(state) = &sync_state {
2615 let stack_uuid = Uuid::parse_str(&state.stack_id)
2616 .map_err(|e| CascadeError::config(format!("Invalid stack ID in sync state: {e}")))?;
2617
2618 let stack_snapshot = manager
2619 .get_stack(&stack_uuid)
2620 .cloned()
2621 .ok_or_else(|| CascadeError::config("Stack not found in sync state".to_string()))?;
2622
2623 let working_branch = stack_snapshot
2624 .working_branch
2625 .clone()
2626 .ok_or_else(|| CascadeError::config("Stack has no working branch".to_string()))?;
2627
2628 let entry_id = if !state.current_entry_id.is_empty() {
2629 Uuid::parse_str(&state.current_entry_id).ok()
2630 } else {
2631 stack_snapshot
2632 .entries
2633 .iter()
2634 .find(|e| e.branch == stack_branch)
2635 .map(|e| e.id)
2636 };
2637
2638 (stack_uuid, entry_id, working_branch)
2639 } else {
2640 let active_stack = manager
2641 .get_active_stack()
2642 .ok_or_else(|| CascadeError::config("No active stack found"))?;
2643
2644 let entry_id = active_stack
2645 .entries
2646 .iter()
2647 .find(|e| e.branch == stack_branch)
2648 .map(|e| e.id);
2649
2650 let working_branch = active_stack
2651 .working_branch
2652 .as_ref()
2653 .ok_or_else(|| CascadeError::config("Active stack has no working branch"))?
2654 .clone();
2655
2656 (active_stack.id, entry_id, working_branch)
2657 };
2658
2659 let entry_id = entry_id_opt.ok_or_else(|| {
2661 CascadeError::config(format!(
2662 "Could not find stack entry for branch '{}'",
2663 stack_branch
2664 ))
2665 })?;
2666
2667 let stack = manager
2668 .get_stack_mut(&stack_id)
2669 .ok_or_else(|| CascadeError::config("Could not get mutable stack reference"))?;
2670
2671 stack
2672 .update_entry_commit_hash(&entry_id, new_commit_hash.clone())
2673 .map_err(CascadeError::config)?;
2674
2675 manager.save_to_disk()?;
2676
2677 let top_commit = {
2679 let active_stack = manager
2680 .get_active_stack()
2681 .ok_or_else(|| CascadeError::config("No active stack found"))?;
2682
2683 if let Some(last_entry) = active_stack.entries.last() {
2684 git_repo.get_branch_head(&last_entry.branch)?
2685 } else {
2686 new_commit_hash.clone()
2687 }
2688 };
2689
2690 Output::info(format!(
2691 "Checking out to working branch: {}",
2692 working_branch
2693 ));
2694
2695 git_repo.checkout_branch_unsafe(&working_branch)?;
2697
2698 if let Ok(working_head) = git_repo.get_branch_head(&working_branch) {
2707 if working_head != top_commit {
2708 git_repo.update_branch_to_commit(&working_branch, &top_commit)?;
2709
2710 git_repo.reset_to_head()?;
2712 }
2713 }
2714
2715 if sync_state.is_some() {
2716 crate::stack::SyncState::delete(&repo_root)?;
2717 }
2718
2719 println!();
2720 Output::info("Resuming sync to complete the rebase...");
2721 println!();
2722
2723 sync_stack(false, false, false).await
2725}
2726
2727pub async fn abort_sync() -> Result<()> {
2728 let current_dir = env::current_dir()
2729 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2730
2731 let repo_root = find_repository_root(¤t_dir)?;
2732
2733 Output::section("Aborting sync");
2734 println!();
2735
2736 let cherry_pick_head = repo_root.join(".git").join("CHERRY_PICK_HEAD");
2738 if !cherry_pick_head.exists() {
2739 return Err(CascadeError::config(
2740 "No in-progress cherry-pick found. Nothing to abort.\n\n\
2741 The sync may have already completed or been aborted."
2742 .to_string(),
2743 ));
2744 }
2745
2746 Output::info("Aborting cherry-pick");
2747
2748 let abort_output = std::process::Command::new("git")
2750 .args(["cherry-pick", "--abort"])
2751 .env("CASCADE_SKIP_HOOKS", "1")
2752 .current_dir(&repo_root)
2753 .output()
2754 .map_err(CascadeError::Io)?;
2755
2756 if !abort_output.status.success() {
2757 let stderr = String::from_utf8_lossy(&abort_output.stderr);
2758 return Err(CascadeError::Branch(format!(
2759 "Failed to abort cherry-pick: {}",
2760 stderr
2761 )));
2762 }
2763
2764 Output::success("Cherry-pick aborted");
2765
2766 let git_repo = crate::git::GitRepository::open(&repo_root)?;
2768
2769 if let Ok(state) = crate::stack::SyncState::load(&repo_root) {
2770 println!();
2771 Output::info("Cleaning up temporary branches");
2772
2773 for temp_branch in &state.temp_branches {
2775 if let Err(e) = git_repo.delete_branch_unsafe(temp_branch) {
2776 tracing::warn!("Could not delete temp branch '{}': {}", temp_branch, e);
2777 }
2778 }
2779
2780 Output::info(format!(
2782 "Returning to original branch: {}",
2783 state.original_branch
2784 ));
2785 if let Err(e) = git_repo.checkout_branch_unsafe(&state.original_branch) {
2786 tracing::warn!("Could not checkout original branch: {}", e);
2788 if let Err(e2) = git_repo.checkout_branch_unsafe(&state.target_base) {
2789 tracing::warn!("Could not checkout base branch: {}", e2);
2790 }
2791 }
2792
2793 crate::stack::SyncState::delete(&repo_root)?;
2795 } else {
2796 let current_branch = git_repo.get_current_branch()?;
2798
2799 if let Some(idx) = current_branch.rfind("-temp-") {
2801 let original_branch = ¤t_branch[..idx];
2802 Output::info(format!("Returning to branch: {}", original_branch));
2803
2804 if let Err(e) = git_repo.checkout_branch_unsafe(original_branch) {
2805 tracing::warn!("Could not checkout original branch: {}", e);
2806 }
2807
2808 if let Err(e) = git_repo.delete_branch_unsafe(¤t_branch) {
2810 tracing::warn!("Could not delete temp branch: {}", e);
2811 }
2812 }
2813 }
2814
2815 println!();
2816 Output::success("Sync aborted");
2817 println!();
2818 Output::tip("You can start a fresh sync with: ca sync");
2819
2820 Ok(())
2821}
2822
2823async fn sync_stack(force: bool, cleanup: bool, interactive: bool) -> Result<()> {
2824 let current_dir = env::current_dir()
2825 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2826
2827 let repo_root = find_repository_root(¤t_dir)
2828 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2829
2830 let mut stack_manager = StackManager::new(&repo_root)?;
2831
2832 if stack_manager.is_in_edit_mode() {
2835 debug!("Exiting edit mode before sync (commit SHAs will change)");
2836 stack_manager.exit_edit_mode()?;
2837 }
2838
2839 let git_repo = GitRepository::open(&repo_root)?;
2840
2841 if git_repo.is_dirty()? {
2842 return Err(CascadeError::branch(
2843 "Working tree has uncommitted changes. Commit or stash them before running 'ca sync'."
2844 .to_string(),
2845 ));
2846 }
2847
2848 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2850 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2851 })?;
2852
2853 let base_branch = active_stack.base_branch.clone();
2854 let _stack_name = active_stack.name.clone();
2855
2856 let original_branch = git_repo.get_current_branch().ok();
2858
2859 match git_repo.checkout_branch_silent(&base_branch) {
2863 Ok(_) => {
2864 match git_repo.pull(&base_branch) {
2865 Ok(_) => {
2866 }
2868 Err(e) => {
2869 if force {
2870 Output::warning(format!("Pull failed: {e} (continuing due to --force)"));
2871 } else {
2872 Output::error(format!("Failed to pull latest changes: {e}"));
2873 Output::tip("Use --force to skip pull and continue with rebase");
2874 if let Some(ref branch) = original_branch {
2875 if branch != &base_branch {
2876 if let Err(restore_err) = git_repo.checkout_branch_silent(branch) {
2877 Output::warning(format!(
2878 "Could not restore original branch '{}': {}",
2879 branch, restore_err
2880 ));
2881 }
2882 }
2883 }
2884 return Err(CascadeError::branch(format!(
2885 "Failed to pull latest changes from '{base_branch}': {e}. Use --force to continue anyway."
2886 )));
2887 }
2888 }
2889 }
2890 }
2891 Err(e) => {
2892 if force {
2893 Output::warning(format!(
2894 "Failed to checkout '{base_branch}': {e} (continuing due to --force)"
2895 ));
2896 } else {
2897 Output::error(format!(
2898 "Failed to checkout base branch '{base_branch}': {e}"
2899 ));
2900 Output::tip("Use --force to bypass checkout issues and continue anyway");
2901 if let Some(ref branch) = original_branch {
2902 if branch != &base_branch {
2903 if let Err(restore_err) = git_repo.checkout_branch_silent(branch) {
2904 Output::warning(format!(
2905 "Could not restore original branch '{}': {}",
2906 branch, restore_err
2907 ));
2908 }
2909 }
2910 }
2911 return Err(CascadeError::branch(format!(
2912 "Failed to checkout base branch '{base_branch}': {e}. Use --force to continue anyway."
2913 )));
2914 }
2915 }
2916 }
2917
2918 let mut updated_stack_manager = StackManager::new(&repo_root)?;
2921 let stack_id = active_stack.id;
2922
2923 if let Some(stack) = updated_stack_manager.get_stack_mut(&stack_id) {
2926 let mut updates = Vec::new();
2927 for entry in &stack.entries {
2928 if let Ok(current_commit) = git_repo.get_branch_head(&entry.branch) {
2929 if entry.commit_hash != current_commit {
2930 let is_safe_descendant = match git_repo.commit_exists(&entry.commit_hash) {
2931 Ok(true) => {
2932 match git_repo.is_descendant_of(¤t_commit, &entry.commit_hash) {
2933 Ok(result) => result,
2934 Err(e) => {
2935 warn!(
2936 "Cannot verify ancestry for '{}': {} - treating as unsafe to prevent potential data loss",
2937 entry.branch, e
2938 );
2939 false
2940 }
2941 }
2942 }
2943 Ok(false) => {
2944 debug!(
2945 "Recorded commit {} for '{}' no longer exists in repository",
2946 &entry.commit_hash[..8],
2947 entry.branch
2948 );
2949 false
2950 }
2951 Err(e) => {
2952 warn!(
2953 "Cannot verify commit existence for '{}': {} - treating as unsafe to prevent potential data loss",
2954 entry.branch, e
2955 );
2956 false
2957 }
2958 };
2959
2960 if is_safe_descendant {
2961 debug!(
2962 "Reconciling entry '{}': updating hash from {} to {} (current branch HEAD)",
2963 entry.branch,
2964 &entry.commit_hash[..8],
2965 ¤t_commit[..8]
2966 );
2967 updates.push((entry.id, current_commit));
2968 } else {
2969 warn!(
2970 "Skipped automatic reconciliation for entry '{}' because local HEAD ({}) does not descend from recorded commit ({})",
2971 entry.branch,
2972 ¤t_commit[..8],
2973 &entry.commit_hash[..8]
2974 );
2975 }
2978 }
2979 }
2980 }
2981
2982 for (entry_id, new_hash) in updates {
2984 stack
2985 .update_entry_commit_hash(&entry_id, new_hash)
2986 .map_err(CascadeError::config)?;
2987 }
2988
2989 updated_stack_manager.save_to_disk()?;
2991 }
2992
2993 match updated_stack_manager.sync_stack(&stack_id) {
2994 Ok(_) => {
2995 if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
2997 if updated_stack.entries.is_empty() {
2999 println!(); Output::info("Stack has no entries yet");
3001 Output::tip("Use 'ca push' to add commits to this stack");
3002 return Ok(());
3003 }
3004
3005 match &updated_stack.status {
3006 crate::stack::StackStatus::NeedsSync => {
3007 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
3009 let config_path = config_dir.join("config.json");
3010 let settings = crate::config::Settings::load_from_file(&config_path)?;
3011
3012 let cascade_config = crate::config::CascadeConfig {
3013 bitbucket: Some(settings.bitbucket.clone()),
3014 git: settings.git.clone(),
3015 auth: crate::config::AuthConfig::default(),
3016 cascade: settings.cascade.clone(),
3017 };
3018
3019 println!(); let options = crate::stack::RebaseOptions {
3024 strategy: crate::stack::RebaseStrategy::ForcePush,
3025 interactive,
3026 target_base: Some(base_branch.clone()),
3027 preserve_merges: true,
3028 auto_resolve: !interactive, max_retries: 3,
3030 skip_pull: Some(true), original_working_branch: original_branch.clone(), };
3033
3034 let mut rebase_manager = crate::stack::RebaseManager::new(
3035 updated_stack_manager,
3036 git_repo,
3037 options,
3038 );
3039
3040 let rebase_result = rebase_manager.rebase_stack(&stack_id);
3042
3043 match rebase_result {
3044 Ok(result) => {
3045 if !result.branch_mapping.is_empty() {
3046 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
3048 let integration_stack_manager =
3050 StackManager::new(&repo_root)?;
3051 let mut integration =
3052 crate::bitbucket::BitbucketIntegration::new(
3053 integration_stack_manager,
3054 cascade_config,
3055 )?;
3056
3057 let pr_result = integration
3059 .update_prs_after_rebase(
3060 &stack_id,
3061 &result.branch_mapping,
3062 )
3063 .await;
3064
3065 match pr_result {
3066 Ok(updated_prs) => {
3067 if !updated_prs.is_empty() {
3068 Output::success(format!(
3069 "Updated {} pull request{}",
3070 updated_prs.len(),
3071 if updated_prs.len() == 1 {
3072 ""
3073 } else {
3074 "s"
3075 }
3076 ));
3077 }
3078 }
3079 Err(e) => {
3080 Output::warning(format!(
3081 "Failed to update pull requests: {e}"
3082 ));
3083 }
3084 }
3085 }
3086 }
3087 }
3088 Err(e) => {
3089 return Err(e);
3091 }
3092 }
3093 }
3094 crate::stack::StackStatus::Clean => {
3095 }
3097 other => {
3098 Output::info(format!("Stack status: {other:?}"));
3100 }
3101 }
3102 }
3103 }
3104 Err(e) => {
3105 if force {
3106 Output::warning(format!(
3107 "Failed to check stack status: {e} (continuing due to --force)"
3108 ));
3109 } else {
3110 if let Some(ref branch) = original_branch {
3111 if branch != &base_branch {
3112 if let Err(restore_err) = git_repo.checkout_branch_silent(branch) {
3113 Output::warning(format!(
3114 "Could not restore original branch '{}': {}",
3115 branch, restore_err
3116 ));
3117 }
3118 }
3119 }
3120 return Err(e);
3121 }
3122 }
3123 }
3124
3125 if cleanup {
3127 let git_repo_for_cleanup = GitRepository::open(&repo_root)?;
3128 match perform_simple_cleanup(&stack_manager, &git_repo_for_cleanup, false).await {
3129 Ok(result) => {
3130 if result.total_candidates > 0 {
3131 Output::section("Cleanup Summary");
3132 if !result.cleaned_branches.is_empty() {
3133 Output::success(format!(
3134 "Cleaned up {} merged branches",
3135 result.cleaned_branches.len()
3136 ));
3137 for branch in &result.cleaned_branches {
3138 Output::sub_item(format!("🗑️ Deleted: {branch}"));
3139 }
3140 }
3141 if !result.skipped_branches.is_empty() {
3142 Output::sub_item(format!(
3143 "Skipped {} branches",
3144 result.skipped_branches.len()
3145 ));
3146 }
3147 if !result.failed_branches.is_empty() {
3148 for (branch, error) in &result.failed_branches {
3149 Output::warning(format!("Failed to clean up {branch}: {error}"));
3150 }
3151 }
3152 }
3153 }
3154 Err(e) => {
3155 Output::warning(format!("Branch cleanup failed: {e}"));
3156 }
3157 }
3158 }
3159
3160 Output::success("Sync completed successfully!");
3168
3169 Ok(())
3170}
3171
3172async fn rebase_stack(
3173 interactive: bool,
3174 onto: Option<String>,
3175 strategy: Option<RebaseStrategyArg>,
3176) -> Result<()> {
3177 let current_dir = env::current_dir()
3178 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3179
3180 let repo_root = find_repository_root(¤t_dir)
3181 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3182
3183 let stack_manager = StackManager::new(&repo_root)?;
3184 let git_repo = GitRepository::open(&repo_root)?;
3185
3186 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
3188 let config_path = config_dir.join("config.json");
3189 let settings = crate::config::Settings::load_from_file(&config_path)?;
3190
3191 let cascade_config = crate::config::CascadeConfig {
3193 bitbucket: Some(settings.bitbucket.clone()),
3194 git: settings.git.clone(),
3195 auth: crate::config::AuthConfig::default(),
3196 cascade: settings.cascade.clone(),
3197 };
3198
3199 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
3201 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
3202 })?;
3203 let stack_id = active_stack.id;
3204
3205 let active_stack = stack_manager
3206 .get_stack(&stack_id)
3207 .ok_or_else(|| CascadeError::config("Active stack not found"))?
3208 .clone();
3209
3210 if active_stack.entries.is_empty() {
3211 Output::info("Stack is empty. Nothing to rebase.");
3212 return Ok(());
3213 }
3214
3215 let rebase_strategy = if let Some(cli_strategy) = strategy {
3217 match cli_strategy {
3218 RebaseStrategyArg::ForcePush => crate::stack::RebaseStrategy::ForcePush,
3219 RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
3220 }
3221 } else {
3222 crate::stack::RebaseStrategy::ForcePush
3224 };
3225
3226 let original_branch = git_repo.get_current_branch().ok();
3228
3229 debug!(" Strategy: {:?}", rebase_strategy);
3230 debug!(" Interactive: {}", interactive);
3231 debug!(" Target base: {:?}", onto);
3232 debug!(" Entries: {}", active_stack.entries.len());
3233
3234 println!(); let rebase_spinner = crate::utils::spinner::Spinner::new_with_output_below(format!(
3238 "Rebasing stack: {}",
3239 active_stack.name
3240 ));
3241
3242 let options = crate::stack::RebaseOptions {
3244 strategy: rebase_strategy.clone(),
3245 interactive,
3246 target_base: onto,
3247 preserve_merges: true,
3248 auto_resolve: !interactive, max_retries: 3,
3250 skip_pull: None, original_working_branch: original_branch,
3252 };
3253
3254 let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3256
3257 if rebase_manager.is_rebase_in_progress() {
3258 Output::warning("Rebase already in progress!");
3259 Output::tip("Use 'git status' to check the current state");
3260 Output::next_steps(&[
3261 "Run 'ca stack continue-rebase' to continue",
3262 "Run 'ca stack abort-rebase' to abort",
3263 ]);
3264 rebase_spinner.stop();
3265 return Ok(());
3266 }
3267
3268 let rebase_result = rebase_manager.rebase_stack(&stack_id);
3270
3271 rebase_spinner.stop();
3273 println!(); match rebase_result {
3276 Ok(result) => {
3277 Output::success("Rebase completed!");
3278 Output::sub_item(result.get_summary());
3279
3280 if result.has_conflicts() {
3281 Output::warning(format!(
3282 "{} conflicts were resolved",
3283 result.conflicts.len()
3284 ));
3285 for conflict in &result.conflicts {
3286 Output::bullet(&conflict[..8.min(conflict.len())]);
3287 }
3288 }
3289
3290 if !result.branch_mapping.is_empty() {
3291 Output::section("Branch mapping");
3292 for (old, new) in &result.branch_mapping {
3293 Output::bullet(format!("{old} -> {new}"));
3294 }
3295
3296 if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
3298 let integration_stack_manager = StackManager::new(&repo_root)?;
3300 let mut integration = BitbucketIntegration::new(
3301 integration_stack_manager,
3302 cascade_config.clone(),
3303 )?;
3304
3305 match integration
3306 .update_prs_after_rebase(&stack_id, &result.branch_mapping)
3307 .await
3308 {
3309 Ok(updated_prs) => {
3310 if !updated_prs.is_empty() {
3311 println!(" 🔄 Preserved pull request history:");
3312 for pr_update in updated_prs {
3313 println!(" ✅ {pr_update}");
3314 }
3315 }
3316 }
3317 Err(e) => {
3318 Output::warning(format!("Failed to update pull requests: {e}"));
3319 Output::sub_item("You may need to manually update PRs in Bitbucket");
3320 }
3321 }
3322 }
3323 }
3324
3325 Output::success(format!(
3326 "{} commits successfully rebased",
3327 result.success_count()
3328 ));
3329
3330 if matches!(rebase_strategy, crate::stack::RebaseStrategy::ForcePush) {
3332 println!();
3333 Output::section("Next steps");
3334 if !result.branch_mapping.is_empty() {
3335 Output::numbered_item(1, "Branches have been rebased and force-pushed");
3336 Output::numbered_item(
3337 2,
3338 "Pull requests updated automatically (history preserved)",
3339 );
3340 Output::numbered_item(3, "Review the updated PRs in Bitbucket");
3341 Output::numbered_item(4, "Test your changes");
3342 } else {
3343 println!(" 1. Review the rebased stack");
3344 println!(" 2. Test your changes");
3345 println!(" 3. Submit new pull requests with 'ca stack submit'");
3346 }
3347 }
3348 }
3349 Err(e) => {
3350 warn!("❌ Rebase failed: {}", e);
3351 Output::tip(" Tips for resolving rebase issues:");
3352 println!(" - Check for uncommitted changes with 'git status'");
3353 println!(" - Ensure base branch is up to date");
3354 println!(" - Try interactive mode: 'ca stack rebase --interactive'");
3355 return Err(e);
3356 }
3357 }
3358
3359 Ok(())
3360}
3361
3362pub async fn continue_rebase() -> Result<()> {
3363 let current_dir = env::current_dir()
3364 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3365
3366 let repo_root = find_repository_root(¤t_dir)
3367 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3368
3369 let stack_manager = StackManager::new(&repo_root)?;
3370 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3371 let options = crate::stack::RebaseOptions::default();
3372 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3373
3374 if !rebase_manager.is_rebase_in_progress() {
3375 Output::info(" No rebase in progress");
3376 return Ok(());
3377 }
3378
3379 println!(" Continuing rebase...");
3380 match rebase_manager.continue_rebase() {
3381 Ok(_) => {
3382 Output::success(" Rebase continued successfully");
3383 println!(" Check 'ca stack rebase-status' for current state");
3384 }
3385 Err(e) => {
3386 warn!("❌ Failed to continue rebase: {}", e);
3387 Output::tip(" You may need to resolve conflicts first:");
3388 println!(" 1. Edit conflicted files");
3389 println!(" 2. Stage resolved files with 'git add'");
3390 println!(" 3. Run 'ca stack continue-rebase' again");
3391 }
3392 }
3393
3394 Ok(())
3395}
3396
3397pub async fn abort_rebase() -> Result<()> {
3398 let current_dir = env::current_dir()
3399 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3400
3401 let repo_root = find_repository_root(¤t_dir)
3402 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3403
3404 let stack_manager = StackManager::new(&repo_root)?;
3405 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3406 let options = crate::stack::RebaseOptions::default();
3407 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3408
3409 if !rebase_manager.is_rebase_in_progress() {
3410 Output::info(" No rebase in progress");
3411 return Ok(());
3412 }
3413
3414 Output::warning("Aborting rebase...");
3415 match rebase_manager.abort_rebase() {
3416 Ok(_) => {
3417 Output::success(" Rebase aborted successfully");
3418 println!(" Repository restored to pre-rebase state");
3419 }
3420 Err(e) => {
3421 warn!("❌ Failed to abort rebase: {}", e);
3422 println!("⚠️ You may need to manually clean up the repository state");
3423 }
3424 }
3425
3426 Ok(())
3427}
3428
3429async fn rebase_status() -> Result<()> {
3430 let current_dir = env::current_dir()
3431 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3432
3433 let repo_root = find_repository_root(¤t_dir)
3434 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3435
3436 let stack_manager = StackManager::new(&repo_root)?;
3437 let git_repo = crate::git::GitRepository::open(&repo_root)?;
3438
3439 println!("Rebase Status");
3440
3441 let git_dir = current_dir.join(".git");
3443 let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
3444 || git_dir.join("rebase-merge").exists()
3445 || git_dir.join("rebase-apply").exists();
3446
3447 if rebase_in_progress {
3448 println!(" Status: 🔄 Rebase in progress");
3449 println!(
3450 "
3451📝 Actions available:"
3452 );
3453 println!(" - 'ca stack continue-rebase' to continue");
3454 println!(" - 'ca stack abort-rebase' to abort");
3455 println!(" - 'git status' to see conflicted files");
3456
3457 match git_repo.get_status() {
3459 Ok(statuses) => {
3460 let mut conflicts = Vec::new();
3461 for status in statuses.iter() {
3462 if status.status().contains(git2::Status::CONFLICTED) {
3463 if let Some(path) = status.path() {
3464 conflicts.push(path.to_string());
3465 }
3466 }
3467 }
3468
3469 if !conflicts.is_empty() {
3470 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
3471 for conflict in conflicts {
3472 println!(" - {conflict}");
3473 }
3474 println!(
3475 "
3476💡 To resolve conflicts:"
3477 );
3478 println!(" 1. Edit the conflicted files");
3479 println!(" 2. Stage resolved files: git add <file>");
3480 println!(" 3. Continue: ca stack continue-rebase");
3481 }
3482 }
3483 Err(e) => {
3484 warn!("Failed to get git status: {}", e);
3485 }
3486 }
3487 } else {
3488 println!(" Status: ✅ No rebase in progress");
3489
3490 if let Some(active_stack) = stack_manager.get_active_stack() {
3492 println!(" Active stack: {}", active_stack.name);
3493 println!(" Entries: {}", active_stack.entries.len());
3494 println!(" Base branch: {}", active_stack.base_branch);
3495 }
3496 }
3497
3498 Ok(())
3499}
3500
3501async fn delete_stack(name: String, force: bool) -> Result<()> {
3502 let current_dir = env::current_dir()
3503 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3504
3505 let repo_root = find_repository_root(¤t_dir)
3506 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3507
3508 let mut manager = StackManager::new(&repo_root)?;
3509
3510 let stack = manager
3511 .get_stack_by_name(&name)
3512 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
3513 let stack_id = stack.id;
3514
3515 if !force && !stack.entries.is_empty() {
3516 return Err(CascadeError::config(format!(
3517 "Stack '{}' has {} entries. Use --force to delete anyway",
3518 name,
3519 stack.entries.len()
3520 )));
3521 }
3522
3523 let deleted = manager.delete_stack(&stack_id)?;
3524
3525 Output::success(format!("Deleted stack '{}'", deleted.name));
3526 if !deleted.entries.is_empty() {
3527 Output::warning(format!("{} entries were removed", deleted.entries.len()));
3528 }
3529
3530 Ok(())
3531}
3532
3533async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
3534 let current_dir = env::current_dir()
3535 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3536
3537 let repo_root = find_repository_root(¤t_dir)
3538 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3539
3540 let mut manager = StackManager::new(&repo_root)?;
3541
3542 if let Some(name) = name {
3543 let stack = manager
3545 .get_stack_by_name(&name)
3546 .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
3547
3548 let stack_id = stack.id;
3549
3550 match stack.validate() {
3552 Ok(_message) => {
3553 Output::success(format!("Stack '{}' structure validation passed", name));
3554 }
3555 Err(e) => {
3556 Output::error(format!(
3557 "Stack '{}' structure validation failed: {}",
3558 name, e
3559 ));
3560 return Err(CascadeError::config(e));
3561 }
3562 }
3563
3564 manager.handle_branch_modifications(&stack_id, fix_mode)?;
3566
3567 println!();
3568 Output::success(format!("Stack '{name}' validation completed"));
3569 Ok(())
3570 } else {
3571 Output::section("Validating all stacks");
3573 println!();
3574
3575 let all_stacks = manager.get_all_stacks();
3577 let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
3578
3579 if stack_ids.is_empty() {
3580 Output::info("No stacks found");
3581 return Ok(());
3582 }
3583
3584 let mut all_valid = true;
3585 for stack_id in stack_ids {
3586 let stack = manager.get_stack(&stack_id).unwrap();
3587 let stack_name = &stack.name;
3588
3589 println!("Checking stack '{stack_name}':");
3590
3591 match stack.validate() {
3593 Ok(message) => {
3594 Output::sub_item(format!("Structure: {message}"));
3595 }
3596 Err(e) => {
3597 Output::sub_item(format!("Structure: {e}"));
3598 all_valid = false;
3599 continue;
3600 }
3601 }
3602
3603 match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
3605 Ok(_) => {
3606 Output::sub_item("Git integrity: OK");
3607 }
3608 Err(e) => {
3609 Output::sub_item(format!("Git integrity: {e}"));
3610 all_valid = false;
3611 }
3612 }
3613 println!();
3614 }
3615
3616 if all_valid {
3617 Output::success("All stacks passed validation");
3618 } else {
3619 Output::warning("Some stacks have validation issues");
3620 return Err(CascadeError::config("Stack validation failed".to_string()));
3621 }
3622
3623 Ok(())
3624 }
3625}
3626
3627#[allow(dead_code)]
3629fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
3630 let mut unpushed = Vec::new();
3631 let head_commit = repo.get_head_commit()?;
3632 let mut current_commit = head_commit;
3633
3634 loop {
3636 let commit_hash = current_commit.id().to_string();
3637 let already_in_stack = stack
3638 .entries
3639 .iter()
3640 .any(|entry| entry.commit_hash == commit_hash);
3641
3642 if already_in_stack {
3643 break;
3644 }
3645
3646 unpushed.push(commit_hash);
3647
3648 if let Some(parent) = current_commit.parents().next() {
3650 current_commit = parent;
3651 } else {
3652 break;
3653 }
3654 }
3655
3656 unpushed.reverse(); Ok(unpushed)
3658}
3659
3660pub async fn squash_commits(
3662 repo: &GitRepository,
3663 count: usize,
3664 since_ref: Option<String>,
3665) -> Result<()> {
3666 if count <= 1 {
3667 return Ok(()); }
3669
3670 let _current_branch = repo.get_current_branch()?;
3672
3673 let rebase_range = if let Some(ref since) = since_ref {
3675 since.clone()
3676 } else {
3677 format!("HEAD~{count}")
3678 };
3679
3680 println!(" Analyzing {count} commits to create smart squash message...");
3681
3682 let head_commit = repo.get_head_commit()?;
3684 let mut commits_to_squash = Vec::new();
3685 let mut current = head_commit;
3686
3687 for _ in 0..count {
3689 commits_to_squash.push(current.clone());
3690 if current.parent_count() > 0 {
3691 current = current.parent(0).map_err(CascadeError::Git)?;
3692 } else {
3693 break;
3694 }
3695 }
3696
3697 let smart_message = generate_squash_message(&commits_to_squash)?;
3699 println!(
3700 " Smart message: {}",
3701 smart_message.lines().next().unwrap_or("")
3702 );
3703
3704 let reset_target = if since_ref.is_some() {
3706 format!("{rebase_range}~1")
3708 } else {
3709 format!("HEAD~{count}")
3711 };
3712
3713 repo.reset_soft(&reset_target)?;
3715
3716 repo.stage_all()?;
3718
3719 let new_commit_hash = repo.commit(&smart_message)?;
3721
3722 println!(
3723 " Created squashed commit: {} ({})",
3724 &new_commit_hash[..8],
3725 smart_message.lines().next().unwrap_or("")
3726 );
3727 println!(" 💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
3728
3729 Ok(())
3730}
3731
3732pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
3734 if commits.is_empty() {
3735 return Ok("Squashed commits".to_string());
3736 }
3737
3738 let messages: Vec<String> = commits
3740 .iter()
3741 .map(|c| c.message().unwrap_or("").trim().to_string())
3742 .filter(|m| !m.is_empty())
3743 .collect();
3744
3745 if messages.is_empty() {
3746 return Ok("Squashed commits".to_string());
3747 }
3748
3749 if let Some(last_msg) = messages.first() {
3751 if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
3753 return Ok(last_msg
3754 .trim_start_matches("Final:")
3755 .trim_start_matches("final:")
3756 .trim()
3757 .to_string());
3758 }
3759 }
3760
3761 let wip_count = messages
3763 .iter()
3764 .filter(|m| {
3765 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3766 })
3767 .count();
3768
3769 if wip_count > messages.len() / 2 {
3770 let non_wip: Vec<&String> = messages
3772 .iter()
3773 .filter(|m| {
3774 !m.to_lowercase().starts_with("wip")
3775 && !m.to_lowercase().contains("work in progress")
3776 })
3777 .collect();
3778
3779 if let Some(best_msg) = non_wip.first() {
3780 return Ok(best_msg.to_string());
3781 }
3782
3783 let feature = extract_feature_from_wip(&messages);
3785 return Ok(feature);
3786 }
3787
3788 Ok(messages.first().unwrap().clone())
3790}
3791
3792pub fn extract_feature_from_wip(messages: &[String]) -> String {
3794 for msg in messages {
3796 if msg.to_lowercase().starts_with("wip:") {
3798 if let Some(rest) = msg
3799 .strip_prefix("WIP:")
3800 .or_else(|| msg.strip_prefix("wip:"))
3801 {
3802 let feature = rest.trim();
3803 if !feature.is_empty() && feature.len() > 3 {
3804 let mut chars: Vec<char> = feature.chars().collect();
3806 if let Some(first) = chars.first_mut() {
3807 *first = first.to_uppercase().next().unwrap_or(*first);
3808 }
3809 return chars.into_iter().collect();
3810 }
3811 }
3812 }
3813 }
3814
3815 if let Some(first) = messages.first() {
3817 let cleaned = first
3818 .trim_start_matches("WIP:")
3819 .trim_start_matches("wip:")
3820 .trim_start_matches("WIP")
3821 .trim_start_matches("wip")
3822 .trim();
3823
3824 if !cleaned.is_empty() {
3825 return format!("Implement {cleaned}");
3826 }
3827 }
3828
3829 format!("Squashed {} commits", messages.len())
3830}
3831
3832pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
3834 let head_commit = repo.get_head_commit()?;
3835 let since_commit = repo.get_commit(since_commit_hash)?;
3836
3837 let mut count = 0;
3838 let mut current = head_commit;
3839
3840 loop {
3842 if current.id() == since_commit.id() {
3843 break;
3844 }
3845
3846 count += 1;
3847
3848 if current.parent_count() == 0 {
3850 break; }
3852
3853 current = current.parent(0).map_err(CascadeError::Git)?;
3854 }
3855
3856 Ok(count)
3857}
3858
3859async fn land_stack(
3861 entry: Option<usize>,
3862 force: bool,
3863 dry_run: bool,
3864 auto: bool,
3865 wait_for_builds: bool,
3866 strategy: Option<MergeStrategyArg>,
3867 build_timeout: u64,
3868) -> Result<()> {
3869 let current_dir = env::current_dir()
3870 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3871
3872 let repo_root = find_repository_root(¤t_dir)
3873 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3874
3875 let stack_manager = StackManager::new(&repo_root)?;
3876
3877 let stack_id = stack_manager
3879 .get_active_stack()
3880 .map(|s| s.id)
3881 .ok_or_else(|| {
3882 CascadeError::config(
3883 "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
3884 .to_string(),
3885 )
3886 })?;
3887
3888 let active_stack = stack_manager
3889 .get_active_stack()
3890 .cloned()
3891 .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
3892
3893 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
3895 let config_path = config_dir.join("config.json");
3896 let settings = crate::config::Settings::load_from_file(&config_path)?;
3897
3898 let cascade_config = crate::config::CascadeConfig {
3899 bitbucket: Some(settings.bitbucket.clone()),
3900 git: settings.git.clone(),
3901 auth: crate::config::AuthConfig::default(),
3902 cascade: settings.cascade.clone(),
3903 };
3904
3905 let mut integration =
3906 crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
3907
3908 let status = integration.check_enhanced_stack_status(&stack_id).await?;
3910
3911 if status.enhanced_statuses.is_empty() {
3912 Output::error("No pull requests found to land");
3913 return Ok(());
3914 }
3915
3916 let ready_prs: Vec<_> = status
3918 .enhanced_statuses
3919 .iter()
3920 .filter(|pr_status| {
3921 if let Some(entry_num) = entry {
3923 if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
3925 if pr_status.pr.from_ref.display_id != stack_entry.branch {
3927 return false;
3928 }
3929 } else {
3930 return false; }
3932 }
3933
3934 if force {
3935 pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
3937 } else {
3938 pr_status.is_ready_to_land()
3939 }
3940 })
3941 .collect();
3942
3943 if ready_prs.is_empty() {
3944 if let Some(entry_num) = entry {
3945 Output::error(format!(
3946 "Entry {entry_num} is not ready to land or doesn't exist"
3947 ));
3948 } else {
3949 Output::error("No pull requests are ready to land");
3950 }
3951
3952 println!();
3954 Output::section("Blocking Issues");
3955 for pr_status in &status.enhanced_statuses {
3956 if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
3957 let blocking = pr_status.get_blocking_reasons();
3958 if !blocking.is_empty() {
3959 Output::sub_item(format!("PR #{}: {}", pr_status.pr.id, blocking.join(", ")));
3960 }
3961 }
3962 }
3963
3964 if !force {
3965 println!();
3966 Output::tip("Use --force to land PRs with blocking issues (dangerous!)");
3967 }
3968 return Ok(());
3969 }
3970
3971 if dry_run {
3972 if let Some(entry_num) = entry {
3973 Output::section(format!("Dry Run - Entry {entry_num} that would be landed"));
3974 } else {
3975 Output::section("Dry Run - PRs that would be landed");
3976 }
3977 for pr_status in &ready_prs {
3978 Output::sub_item(format!("PR #{}: {}", pr_status.pr.id, pr_status.pr.title));
3979 if !pr_status.is_ready_to_land() && force {
3980 let blocking = pr_status.get_blocking_reasons();
3981 Output::warning(format!("Would force land despite: {}", blocking.join(", ")));
3982 }
3983 }
3984 return Ok(());
3985 }
3986
3987 if let Some(entry_num) = entry {
3990 if ready_prs.len() > 1 {
3991 Output::info(format!(
3992 "{} PRs are ready to land, but landing only entry #{}",
3993 ready_prs.len(),
3994 entry_num
3995 ));
3996 }
3997 }
3998
3999 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
4001 strategy.unwrap_or(MergeStrategyArg::Squash).into();
4002 let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
4003 merge_strategy: merge_strategy.clone(),
4004 wait_for_builds,
4005 build_timeout: std::time::Duration::from_secs(build_timeout),
4006 allowed_authors: None, };
4008
4009 println!();
4011 Output::section(format!(
4012 "Landing {} PR{}",
4013 ready_prs.len(),
4014 if ready_prs.len() == 1 { "" } else { "s" }
4015 ));
4016
4017 let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
4018 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
4019 );
4020
4021 let mut landed_count = 0;
4023 let mut failed_count = 0;
4024 let total_ready_prs = ready_prs.len();
4025
4026 for pr_status in ready_prs {
4027 let pr_id = pr_status.pr.id;
4028
4029 Output::progress(format!("Landing PR #{}: {}", pr_id, pr_status.pr.title));
4030
4031 let land_result = if auto {
4032 pr_manager
4034 .auto_merge_if_ready(pr_id, &auto_merge_conditions)
4035 .await
4036 } else {
4037 pr_manager
4039 .merge_pull_request(pr_id, merge_strategy.clone())
4040 .await
4041 .map(
4042 |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
4043 pr: Box::new(pr),
4044 merge_strategy: merge_strategy.clone(),
4045 },
4046 )
4047 };
4048
4049 match land_result {
4050 Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
4051 Output::success_inline();
4052 landed_count += 1;
4053
4054 if landed_count < total_ready_prs {
4056 Output::sub_item("Retargeting remaining PRs to latest base");
4057
4058 let base_branch = active_stack.base_branch.clone();
4060 let git_repo = crate::git::GitRepository::open(&repo_root)?;
4061
4062 Output::sub_item(format!("Updating base branch: {base_branch}"));
4063 match git_repo.pull(&base_branch) {
4064 Ok(_) => Output::sub_item("Base branch updated"),
4065 Err(e) => {
4066 Output::warning(format!("Failed to update base branch: {e}"));
4067 Output::tip(format!(
4068 "You may want to manually run: git pull origin {base_branch}"
4069 ));
4070 }
4071 }
4072
4073 let temp_manager = StackManager::new(&repo_root)?;
4075 let stack_for_count = temp_manager
4076 .get_stack(&stack_id)
4077 .ok_or_else(|| CascadeError::config("Stack not found"))?;
4078 let entry_count = stack_for_count.entries.len();
4079 let plural = if entry_count == 1 { "entry" } else { "entries" };
4080
4081 println!(); let rebase_spinner = crate::utils::spinner::Spinner::new(format!(
4083 "Retargeting {} {}",
4084 entry_count, plural
4085 ));
4086
4087 let mut rebase_manager = crate::stack::RebaseManager::new(
4088 StackManager::new(&repo_root)?,
4089 git_repo,
4090 crate::stack::RebaseOptions {
4091 strategy: crate::stack::RebaseStrategy::ForcePush,
4092 target_base: Some(base_branch.clone()),
4093 ..Default::default()
4094 },
4095 );
4096
4097 let rebase_result = rebase_manager.rebase_stack(&stack_id);
4098
4099 rebase_spinner.stop();
4100 println!(); match rebase_result {
4103 Ok(rebase_result) => {
4104 if !rebase_result.branch_mapping.is_empty() {
4105 let retarget_config = crate::config::CascadeConfig {
4107 bitbucket: Some(settings.bitbucket.clone()),
4108 git: settings.git.clone(),
4109 auth: crate::config::AuthConfig::default(),
4110 cascade: settings.cascade.clone(),
4111 };
4112 let mut retarget_integration = BitbucketIntegration::new(
4113 StackManager::new(&repo_root)?,
4114 retarget_config,
4115 )?;
4116
4117 match retarget_integration
4118 .update_prs_after_rebase(
4119 &stack_id,
4120 &rebase_result.branch_mapping,
4121 )
4122 .await
4123 {
4124 Ok(updated_prs) => {
4125 if !updated_prs.is_empty() {
4126 Output::sub_item(format!(
4127 "Updated {} PRs with new targets",
4128 updated_prs.len()
4129 ));
4130 }
4131 }
4132 Err(e) => {
4133 Output::warning(format!(
4134 "Failed to update remaining PRs: {e}"
4135 ));
4136 Output::tip(format!("You may need to run: ca stack rebase --onto {base_branch}"));
4137 }
4138 }
4139 }
4140 }
4141 Err(e) => {
4142 println!();
4144 Output::error("Auto-retargeting conflicts detected!");
4145 println!();
4146 Output::section("To resolve conflicts and continue landing");
4147 Output::numbered_item(1, "Resolve conflicts in the affected files");
4148 Output::numbered_item(2, "Stage resolved files: git add <files>");
4149 Output::numbered_item(
4150 3,
4151 "Continue the process: ca stack continue-land",
4152 );
4153 Output::numbered_item(4, "Or abort the operation: ca stack abort-land");
4154 println!();
4155 Output::tip("Check current status: ca stack land-status");
4156 Output::sub_item(format!("Error details: {e}"));
4157
4158 break;
4160 }
4161 }
4162 }
4163 }
4164 Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
4165 Output::error_inline(format!("Not ready: {}", blocking_reasons.join(", ")));
4166 failed_count += 1;
4167 if !force {
4168 break;
4169 }
4170 }
4171 Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
4172 Output::error_inline(format!("Failed: {error}"));
4173 failed_count += 1;
4174 if !force {
4175 break;
4176 }
4177 }
4178 Err(e) => {
4179 Output::error_inline("");
4180 Output::error(format!("Failed to land PR #{pr_id}: {e}"));
4181 failed_count += 1;
4182
4183 if !force {
4184 break;
4185 }
4186 }
4187 }
4188 }
4189
4190 println!();
4192 Output::section("Landing Summary");
4193 Output::sub_item(format!("Successfully landed: {landed_count}"));
4194 if failed_count > 0 {
4195 Output::sub_item(format!("Failed to land: {failed_count}"));
4196 }
4197
4198 if landed_count > 0 {
4199 Output::success("Landing operation completed!");
4200
4201 let final_stack_manager = StackManager::new(&repo_root)?;
4203 if let Some(final_stack) = final_stack_manager.get_stack(&stack_id) {
4204 let all_merged = final_stack.entries.iter().all(|entry| entry.is_merged);
4205
4206 if all_merged && !final_stack.entries.is_empty() {
4207 println!();
4208 Output::success("All PRs in stack merged!");
4209 println!();
4210
4211 let mut deactivate_manager = StackManager::new(&repo_root)?;
4213 match deactivate_manager.set_active_stack(None) {
4214 Ok(_) => {
4215 Output::sub_item("Stack deactivated");
4216 }
4217 Err(e) => {
4218 Output::warning(format!("Could not deactivate stack: {}", e));
4219 }
4220 }
4221
4222 if !dry_run {
4224 let should_cleanup = Confirm::with_theme(&ColorfulTheme::default())
4225 .with_prompt("Clean up merged branches?")
4226 .default(true)
4227 .interact()
4228 .unwrap_or(false);
4229
4230 if should_cleanup {
4231 let cleanup_git_repo = GitRepository::open(&repo_root)?;
4232 let mut cleanup_manager = CleanupManager::new(
4233 StackManager::new(&repo_root)?,
4234 cleanup_git_repo,
4235 CleanupOptions {
4236 dry_run: false,
4237 force: true,
4238 include_stale: false,
4239 cleanup_remote: false,
4240 stale_threshold_days: 30,
4241 cleanup_non_stack: false,
4242 },
4243 );
4244
4245 match cleanup_manager.find_cleanup_candidates() {
4247 Ok(candidates) => {
4248 let stack_candidates: Vec<_> = candidates
4249 .into_iter()
4250 .filter(|c| c.stack_id == Some(stack_id))
4251 .collect();
4252
4253 if !stack_candidates.is_empty() {
4254 match cleanup_manager.perform_cleanup(&stack_candidates) {
4255 Ok(cleanup_result) => {
4256 if !cleanup_result.cleaned_branches.is_empty() {
4257 for branch in &cleanup_result.cleaned_branches {
4258 Output::sub_item(format!(
4259 "🗑️ Deleted: {}",
4260 branch
4261 ));
4262 }
4263 }
4264 }
4265 Err(e) => {
4266 Output::warning(format!(
4267 "Branch cleanup failed: {}",
4268 e
4269 ));
4270 }
4271 }
4272 }
4273 }
4274 Err(e) => {
4275 Output::warning(format!(
4276 "Could not find cleanup candidates: {}",
4277 e
4278 ));
4279 }
4280 }
4281 }
4282
4283 let should_delete_stack = Confirm::with_theme(&ColorfulTheme::default())
4285 .with_prompt(format!("Delete stack '{}'?", final_stack.name))
4286 .default(true)
4287 .interact()
4288 .unwrap_or(false);
4289
4290 if should_delete_stack {
4291 let mut delete_manager = StackManager::new(&repo_root)?;
4292 match delete_manager.delete_stack(&stack_id) {
4293 Ok(_) => {
4294 Output::sub_item(format!("Stack '{}' deleted", final_stack.name));
4295 }
4296 Err(e) => {
4297 Output::warning(format!("Could not delete stack: {}", e));
4298 }
4299 }
4300 }
4301 }
4302 }
4303 }
4304 } else {
4305 Output::error("No PRs were successfully landed");
4306 }
4307
4308 Ok(())
4309}
4310
4311async fn auto_land_stack(
4313 force: bool,
4314 dry_run: bool,
4315 wait_for_builds: bool,
4316 strategy: Option<MergeStrategyArg>,
4317 build_timeout: u64,
4318) -> Result<()> {
4319 land_stack(
4321 None,
4322 force,
4323 dry_run,
4324 true, wait_for_builds,
4326 strategy,
4327 build_timeout,
4328 )
4329 .await
4330}
4331
4332async fn continue_land() -> Result<()> {
4333 use crate::cli::output::Output;
4334
4335 let current_dir = env::current_dir()
4336 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4337
4338 let repo_root = find_repository_root(¤t_dir)
4339 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4340
4341 let git_repo = crate::git::GitRepository::open(&repo_root)?;
4343 let git_dir = repo_root.join(".git");
4344 let has_cherry_pick = git_dir.join("CHERRY_PICK_HEAD").exists();
4345 let has_rebase = git_dir.join("REBASE_HEAD").exists()
4346 || git_dir.join("rebase-merge").exists()
4347 || git_dir.join("rebase-apply").exists();
4348
4349 if !has_cherry_pick && !has_rebase {
4350 Output::info("No land operation in progress");
4351 Output::tip("Use 'ca land' to start landing PRs");
4352 return Ok(());
4353 }
4354
4355 Output::section("Continuing land operation");
4356 println!();
4357
4358 Output::info("Completing conflict resolution...");
4360 let stack_manager = StackManager::new(&repo_root)?;
4361 let options = crate::stack::RebaseOptions::default();
4362 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
4363
4364 match rebase_manager.continue_rebase() {
4365 Ok(_) => {
4366 Output::success("Conflict resolution completed");
4367 }
4368 Err(e) => {
4369 Output::error("Failed to complete conflict resolution");
4370 Output::tip("You may need to resolve conflicts first:");
4371 Output::bullet("Edit conflicted files");
4372 Output::bullet("Stage resolved files: git add <files>");
4373 Output::bullet("Run 'ca land continue' again");
4374 return Err(e);
4375 }
4376 }
4377
4378 println!();
4379
4380 let stack_manager = StackManager::new(&repo_root)?;
4382 let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
4383 CascadeError::config("No active stack found. Cannot continue land operation.")
4384 })?;
4385 let stack_id = active_stack.id;
4386 let base_branch = active_stack.base_branch.clone();
4387
4388 Output::info("Rebasing remaining stack entries...");
4390 println!();
4391
4392 let git_repo_for_rebase = crate::git::GitRepository::open(&repo_root)?;
4393 let mut rebase_manager = crate::stack::RebaseManager::new(
4394 StackManager::new(&repo_root)?,
4395 git_repo_for_rebase,
4396 crate::stack::RebaseOptions {
4397 strategy: crate::stack::RebaseStrategy::ForcePush,
4398 target_base: Some(base_branch.clone()),
4399 ..Default::default()
4400 },
4401 );
4402
4403 let rebase_result = rebase_manager.rebase_stack(&stack_id)?;
4404
4405 if !rebase_result.success {
4406 if !rebase_result.conflicts.is_empty() {
4408 println!();
4409 Output::error("Additional conflicts detected during rebase");
4410 println!();
4411 Output::tip("To resolve and continue:");
4412 Output::bullet("Resolve conflicts in your editor");
4413 Output::bullet("Stage resolved files: git add <files>");
4414 Output::bullet("Continue landing: ca land continue");
4415 println!();
4416 Output::tip("Or abort the land operation:");
4417 Output::bullet("Abort landing: ca land abort");
4418
4419 return Ok(());
4421 }
4422
4423 Output::error("Failed to rebase remaining entries");
4425 if let Some(error) = &rebase_result.error {
4426 Output::sub_item(format!("Error: {}", error));
4427 }
4428 return Err(CascadeError::invalid_operation(
4429 "Failed to rebase stack after conflict resolution",
4430 ));
4431 }
4432
4433 println!();
4434 Output::success(format!(
4435 "Rebased {} remaining entries",
4436 rebase_result.branch_mapping.len()
4437 ));
4438
4439 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
4441 let config_path = config_dir.join("config.json");
4442
4443 if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
4444 if !rebase_result.branch_mapping.is_empty() {
4445 println!();
4446 Output::info("Updating pull requests...");
4447
4448 let cascade_config = crate::config::CascadeConfig {
4449 bitbucket: Some(settings.bitbucket.clone()),
4450 git: settings.git.clone(),
4451 auth: crate::config::AuthConfig::default(),
4452 cascade: settings.cascade.clone(),
4453 };
4454
4455 let mut integration = crate::bitbucket::BitbucketIntegration::new(
4456 StackManager::new(&repo_root)?,
4457 cascade_config,
4458 )?;
4459
4460 match integration
4461 .update_prs_after_rebase(&stack_id, &rebase_result.branch_mapping)
4462 .await
4463 {
4464 Ok(updated_prs) => {
4465 if !updated_prs.is_empty() {
4466 Output::success(format!("Updated {} pull requests", updated_prs.len()));
4467 }
4468 }
4469 Err(e) => {
4470 Output::warning(format!("Failed to update some PRs: {}", e));
4471 Output::tip("PRs may need manual updates in Bitbucket");
4472 }
4473 }
4474 }
4475 }
4476
4477 println!();
4478 Output::success("Land operation continued successfully");
4479 println!();
4480 Output::tip("Next steps:");
4481 Output::bullet("Wait for builds to pass on rebased PRs");
4482 Output::bullet("Once builds are green, run: ca land");
4483
4484 Ok(())
4485}
4486
4487async fn abort_land() -> Result<()> {
4488 let current_dir = env::current_dir()
4489 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4490
4491 let repo_root = find_repository_root(¤t_dir)
4492 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4493
4494 let stack_manager = StackManager::new(&repo_root)?;
4495 let git_repo = crate::git::GitRepository::open(&repo_root)?;
4496 let options = crate::stack::RebaseOptions::default();
4497 let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
4498
4499 if !rebase_manager.is_rebase_in_progress() {
4500 Output::info(" No rebase in progress");
4501 return Ok(());
4502 }
4503
4504 println!("⚠️ Aborting land operation...");
4505 match rebase_manager.abort_rebase() {
4506 Ok(_) => {
4507 Output::success(" Land operation aborted successfully");
4508 println!(" Repository restored to pre-land state");
4509 }
4510 Err(e) => {
4511 warn!("❌ Failed to abort land operation: {}", e);
4512 println!("⚠️ You may need to manually clean up the repository state");
4513 }
4514 }
4515
4516 Ok(())
4517}
4518
4519async fn land_status() -> Result<()> {
4520 let current_dir = env::current_dir()
4521 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4522
4523 let repo_root = find_repository_root(¤t_dir)
4524 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4525
4526 let stack_manager = StackManager::new(&repo_root)?;
4527 let git_repo = crate::git::GitRepository::open(&repo_root)?;
4528
4529 println!("Land Status");
4530
4531 let git_dir = repo_root.join(".git");
4533 let land_in_progress = git_dir.join("REBASE_HEAD").exists()
4534 || git_dir.join("rebase-merge").exists()
4535 || git_dir.join("rebase-apply").exists();
4536
4537 if land_in_progress {
4538 println!(" Status: 🔄 Land operation in progress");
4539 println!(
4540 "
4541📝 Actions available:"
4542 );
4543 println!(" - 'ca stack continue-land' to continue");
4544 println!(" - 'ca stack abort-land' to abort");
4545 println!(" - 'git status' to see conflicted files");
4546
4547 match git_repo.get_status() {
4549 Ok(statuses) => {
4550 let mut conflicts = Vec::new();
4551 for status in statuses.iter() {
4552 if status.status().contains(git2::Status::CONFLICTED) {
4553 if let Some(path) = status.path() {
4554 conflicts.push(path.to_string());
4555 }
4556 }
4557 }
4558
4559 if !conflicts.is_empty() {
4560 println!(" ⚠️ Conflicts in {} files:", conflicts.len());
4561 for conflict in conflicts {
4562 println!(" - {conflict}");
4563 }
4564 println!(
4565 "
4566💡 To resolve conflicts:"
4567 );
4568 println!(" 1. Edit the conflicted files");
4569 println!(" 2. Stage resolved files: git add <file>");
4570 println!(" 3. Continue: ca stack continue-land");
4571 }
4572 }
4573 Err(e) => {
4574 warn!("Failed to get git status: {}", e);
4575 }
4576 }
4577 } else {
4578 println!(" Status: ✅ No land operation in progress");
4579
4580 if let Some(active_stack) = stack_manager.get_active_stack() {
4582 println!(" Active stack: {}", active_stack.name);
4583 println!(" Entries: {}", active_stack.entries.len());
4584 println!(" Base branch: {}", active_stack.base_branch);
4585 }
4586 }
4587
4588 Ok(())
4589}
4590
4591async fn repair_stack_data() -> Result<()> {
4592 let current_dir = env::current_dir()
4593 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4594
4595 let repo_root = find_repository_root(¤t_dir)
4596 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4597
4598 let mut stack_manager = StackManager::new(&repo_root)?;
4599
4600 println!("🔧 Repairing stack data consistency...");
4601
4602 stack_manager.repair_all_stacks()?;
4603
4604 Output::success(" Stack data consistency repaired successfully!");
4605 Output::tip(" Run 'ca stack --mergeable' to see updated status");
4606
4607 Ok(())
4608}
4609
4610async fn cleanup_branches(
4612 dry_run: bool,
4613 force: bool,
4614 include_stale: bool,
4615 stale_days: u32,
4616 cleanup_remote: bool,
4617 include_non_stack: bool,
4618 verbose: bool,
4619) -> Result<()> {
4620 let current_dir = env::current_dir()
4621 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4622
4623 let repo_root = find_repository_root(¤t_dir)
4624 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4625
4626 let stack_manager = StackManager::new(&repo_root)?;
4627 let git_repo = GitRepository::open(&repo_root)?;
4628
4629 let result = perform_cleanup(
4630 &stack_manager,
4631 &git_repo,
4632 dry_run,
4633 force,
4634 include_stale,
4635 stale_days,
4636 cleanup_remote,
4637 include_non_stack,
4638 verbose,
4639 )
4640 .await?;
4641
4642 if result.total_candidates == 0 {
4644 Output::success("No branches found that need cleanup");
4645 return Ok(());
4646 }
4647
4648 Output::section("Cleanup Results");
4649
4650 if dry_run {
4651 Output::sub_item(format!(
4652 "Found {} branches that would be cleaned up",
4653 result.total_candidates
4654 ));
4655 } else {
4656 if !result.cleaned_branches.is_empty() {
4657 Output::success(format!(
4658 "Successfully cleaned up {} branches",
4659 result.cleaned_branches.len()
4660 ));
4661 for branch in &result.cleaned_branches {
4662 Output::sub_item(format!("🗑️ Deleted: {branch}"));
4663 }
4664 }
4665
4666 if !result.skipped_branches.is_empty() {
4667 Output::sub_item(format!(
4668 "Skipped {} branches",
4669 result.skipped_branches.len()
4670 ));
4671 if verbose {
4672 for (branch, reason) in &result.skipped_branches {
4673 Output::sub_item(format!("⏭️ {branch}: {reason}"));
4674 }
4675 }
4676 }
4677
4678 if !result.failed_branches.is_empty() {
4679 Output::warning(format!(
4680 "Failed to clean up {} branches",
4681 result.failed_branches.len()
4682 ));
4683 for (branch, error) in &result.failed_branches {
4684 Output::sub_item(format!("❌ {branch}: {error}"));
4685 }
4686 }
4687 }
4688
4689 Ok(())
4690}
4691
4692#[allow(clippy::too_many_arguments)]
4694async fn perform_cleanup(
4695 stack_manager: &StackManager,
4696 git_repo: &GitRepository,
4697 dry_run: bool,
4698 force: bool,
4699 include_stale: bool,
4700 stale_days: u32,
4701 cleanup_remote: bool,
4702 include_non_stack: bool,
4703 verbose: bool,
4704) -> Result<CleanupResult> {
4705 let options = CleanupOptions {
4706 dry_run,
4707 force,
4708 include_stale,
4709 cleanup_remote,
4710 stale_threshold_days: stale_days,
4711 cleanup_non_stack: include_non_stack,
4712 };
4713
4714 let stack_manager_copy = StackManager::new(stack_manager.repo_path())?;
4715 let git_repo_copy = GitRepository::open(git_repo.path())?;
4716 let mut cleanup_manager = CleanupManager::new(stack_manager_copy, git_repo_copy, options);
4717
4718 let candidates = cleanup_manager.find_cleanup_candidates()?;
4720
4721 if candidates.is_empty() {
4722 return Ok(CleanupResult {
4723 cleaned_branches: Vec::new(),
4724 failed_branches: Vec::new(),
4725 skipped_branches: Vec::new(),
4726 total_candidates: 0,
4727 });
4728 }
4729
4730 if verbose || dry_run {
4732 Output::section("Cleanup Candidates");
4733 for candidate in &candidates {
4734 let reason_icon = match candidate.reason {
4735 crate::stack::CleanupReason::FullyMerged => "🔀",
4736 crate::stack::CleanupReason::StackEntryMerged => "✅",
4737 crate::stack::CleanupReason::Stale => "⏰",
4738 crate::stack::CleanupReason::Orphaned => "👻",
4739 };
4740
4741 Output::sub_item(format!(
4742 "{} {} - {} ({})",
4743 reason_icon,
4744 candidate.branch_name,
4745 candidate.reason_to_string(),
4746 candidate.safety_info
4747 ));
4748 }
4749 }
4750
4751 if !force && !dry_run && !candidates.is_empty() {
4753 Output::warning(format!("About to delete {} branches", candidates.len()));
4754
4755 let preview_count = 5.min(candidates.len());
4757 for candidate in candidates.iter().take(preview_count) {
4758 println!(" • {}", candidate.branch_name);
4759 }
4760 if candidates.len() > preview_count {
4761 println!(" ... and {} more", candidates.len() - preview_count);
4762 }
4763 println!(); let should_continue = Confirm::with_theme(&ColorfulTheme::default())
4767 .with_prompt("Continue with branch cleanup?")
4768 .default(false)
4769 .interact()
4770 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
4771
4772 if !should_continue {
4773 Output::sub_item("Cleanup cancelled");
4774 return Ok(CleanupResult {
4775 cleaned_branches: Vec::new(),
4776 failed_branches: Vec::new(),
4777 skipped_branches: Vec::new(),
4778 total_candidates: candidates.len(),
4779 });
4780 }
4781 }
4782
4783 cleanup_manager.perform_cleanup(&candidates)
4785}
4786
4787async fn perform_simple_cleanup(
4789 stack_manager: &StackManager,
4790 git_repo: &GitRepository,
4791 dry_run: bool,
4792) -> Result<CleanupResult> {
4793 perform_cleanup(
4794 stack_manager,
4795 git_repo,
4796 dry_run,
4797 false, false, 30, false, false, false, )
4804 .await
4805}
4806
4807async fn analyze_commits_for_safeguards(
4809 commits_to_push: &[String],
4810 repo: &GitRepository,
4811 dry_run: bool,
4812) -> Result<()> {
4813 const LARGE_COMMIT_THRESHOLD: usize = 10;
4814 const WEEK_IN_SECONDS: i64 = 7 * 24 * 3600;
4815
4816 if commits_to_push.len() > LARGE_COMMIT_THRESHOLD {
4818 println!(
4819 "⚠️ Warning: About to push {} commits to stack",
4820 commits_to_push.len()
4821 );
4822 println!(" This may indicate a merge commit issue or unexpected commit range.");
4823 println!(" Large commit counts often result from merging instead of rebasing.");
4824
4825 if !dry_run && !confirm_large_push(commits_to_push.len())? {
4826 return Err(CascadeError::config("Push cancelled by user"));
4827 }
4828 }
4829
4830 let commit_objects: Result<Vec<_>> = commits_to_push
4832 .iter()
4833 .map(|hash| repo.get_commit(hash))
4834 .collect();
4835 let commit_objects = commit_objects?;
4836
4837 let merge_commits: Vec<_> = commit_objects
4839 .iter()
4840 .filter(|c| c.parent_count() > 1)
4841 .collect();
4842
4843 if !merge_commits.is_empty() {
4844 println!(
4845 "⚠️ Warning: {} merge commits detected in push",
4846 merge_commits.len()
4847 );
4848 println!(" This often indicates you merged instead of rebased.");
4849 println!(" Consider using 'ca sync' to rebase on the base branch.");
4850 println!(" Merge commits in stacks can cause confusion and duplicate work.");
4851 }
4852
4853 if commit_objects.len() > 1 {
4855 let oldest_commit_time = commit_objects.first().unwrap().time().seconds();
4856 let newest_commit_time = commit_objects.last().unwrap().time().seconds();
4857 let time_span = newest_commit_time - oldest_commit_time;
4858
4859 if time_span > WEEK_IN_SECONDS {
4860 let days = time_span / (24 * 3600);
4861 println!("⚠️ Warning: Commits span {days} days");
4862 println!(" This may indicate merged history rather than new work.");
4863 println!(" Recent work should typically span hours or days, not weeks.");
4864 }
4865 }
4866
4867 if commits_to_push.len() > 5 {
4869 Output::tip(" Tip: If you only want recent commits, use:");
4870 println!(
4871 " ca push --since HEAD~{} # pushes last {} commits",
4872 std::cmp::min(commits_to_push.len(), 5),
4873 std::cmp::min(commits_to_push.len(), 5)
4874 );
4875 println!(" ca push --commits <hash1>,<hash2> # pushes specific commits");
4876 println!(" ca push --dry-run # preview what would be pushed");
4877 }
4878
4879 if dry_run {
4881 println!("🔍 DRY RUN: Would push {} commits:", commits_to_push.len());
4882 for (i, (commit_hash, commit_obj)) in commits_to_push
4883 .iter()
4884 .zip(commit_objects.iter())
4885 .enumerate()
4886 {
4887 let summary = commit_obj.summary().unwrap_or("(no message)");
4888 let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 7)];
4889 println!(" {}: {} ({})", i + 1, summary, short_hash);
4890 }
4891 Output::tip(" Run without --dry-run to actually push these commits.");
4892 }
4893
4894 Ok(())
4895}
4896
4897fn confirm_large_push(count: usize) -> Result<bool> {
4899 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
4901 .with_prompt(format!("Continue pushing {count} commits?"))
4902 .default(false)
4903 .interact()
4904 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
4905
4906 Ok(should_continue)
4907}
4908
4909fn parse_entry_spec(spec: &str, max_entries: usize) -> Result<Vec<usize>> {
4911 let mut indices: Vec<usize> = Vec::new();
4912
4913 if spec.contains('-') && !spec.contains(',') {
4914 let parts: Vec<&str> = spec.split('-').collect();
4916 if parts.len() != 2 {
4917 return Err(CascadeError::config(
4918 "Invalid range format. Use 'start-end' (e.g., '1-5')",
4919 ));
4920 }
4921
4922 let start: usize = parts[0]
4923 .trim()
4924 .parse()
4925 .map_err(|_| CascadeError::config("Invalid start number in range"))?;
4926 let end: usize = parts[1]
4927 .trim()
4928 .parse()
4929 .map_err(|_| CascadeError::config("Invalid end number in range"))?;
4930
4931 if start == 0 || end == 0 {
4932 return Err(CascadeError::config("Entry numbers are 1-based"));
4933 }
4934 if start > max_entries || end > max_entries {
4935 return Err(CascadeError::config(format!(
4936 "Entry number out of bounds. Stack has {max_entries} entries"
4937 )));
4938 }
4939
4940 let (lo, hi) = if start <= end {
4941 (start, end)
4942 } else {
4943 (end, start)
4944 };
4945 for i in lo..=hi {
4946 indices.push(i);
4947 }
4948 } else if spec.contains(',') {
4949 for part in spec.split(',') {
4951 let num: usize = part.trim().parse().map_err(|_| {
4952 CascadeError::config(format!("Invalid entry number: {}", part.trim()))
4953 })?;
4954 if num == 0 {
4955 return Err(CascadeError::config("Entry numbers are 1-based"));
4956 }
4957 if num > max_entries {
4958 return Err(CascadeError::config(format!(
4959 "Entry {num} out of bounds. Stack has {max_entries} entries"
4960 )));
4961 }
4962 indices.push(num);
4963 }
4964 } else {
4965 let num: usize = spec
4967 .trim()
4968 .parse()
4969 .map_err(|_| CascadeError::config(format!("Invalid entry number: {spec}")))?;
4970 if num == 0 {
4971 return Err(CascadeError::config("Entry numbers are 1-based"));
4972 }
4973 if num > max_entries {
4974 return Err(CascadeError::config(format!(
4975 "Entry {num} out of bounds. Stack has {max_entries} entries"
4976 )));
4977 }
4978 indices.push(num);
4979 }
4980
4981 indices.sort();
4982 indices.dedup();
4983 Ok(indices)
4984}
4985
4986async fn drop_entries(
4987 entry_spec: String,
4988 keep_branch: bool,
4989 keep_pr: bool,
4990 force: bool,
4991 yes: bool,
4992) -> Result<()> {
4993 let current_dir = env::current_dir()
4994 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4995
4996 let repo_root = find_repository_root(¤t_dir)
4997 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4998
4999 let mut manager = StackManager::new(&repo_root)?;
5000 let repo = GitRepository::open(&repo_root)?;
5001
5002 let active_stack = manager.get_active_stack().ok_or_else(|| {
5003 CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
5004 })?;
5005 let stack_id = active_stack.id;
5006 let entry_count = active_stack.entries.len();
5007
5008 if entry_count == 0 {
5009 Output::info("Stack is empty, nothing to drop.");
5010 return Ok(());
5011 }
5012
5013 let indices = parse_entry_spec(&entry_spec, entry_count)?;
5014
5015 for &idx in &indices {
5017 let entry = &active_stack.entries[idx - 1];
5018 if entry.is_merged {
5019 return Err(CascadeError::config(format!(
5020 "Entry {} ('{}') is already merged. Use 'ca stacks cleanup' to remove merged entries.",
5021 idx,
5022 entry.short_message(40)
5023 )));
5024 }
5025 }
5026
5027 let has_submitted = indices
5029 .iter()
5030 .any(|&idx| active_stack.entries[idx - 1].is_submitted);
5031
5032 Output::section(format!("Entries to drop ({})", indices.len()));
5033 for &idx in &indices {
5034 let entry = &active_stack.entries[idx - 1];
5035 let pr_status = if entry.is_submitted {
5036 format!(" [PR #{}]", entry.pull_request_id.as_deref().unwrap_or("?"))
5037 } else {
5038 String::new()
5039 };
5040 Output::numbered_item(
5041 idx,
5042 format!(
5043 "{} {} (branch: {}){}",
5044 entry.short_hash(),
5045 entry.short_message(40),
5046 entry.branch,
5047 pr_status
5048 ),
5049 );
5050 }
5051
5052 if !force && !yes {
5054 if has_submitted {
5055 Output::warning("Some entries have associated pull requests.");
5056 }
5057
5058 let default_confirm = !has_submitted;
5059 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
5060 .with_prompt(format!("Drop {} entry/entries from stack?", indices.len()))
5061 .default(default_confirm)
5062 .interact()
5063 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
5064
5065 if !should_continue {
5066 Output::info("Drop cancelled.");
5067 return Ok(());
5068 }
5069 }
5070
5071 let entries_info: Vec<(String, Option<String>, bool)> = indices
5073 .iter()
5074 .map(|&idx| {
5075 let entry = &active_stack.entries[idx - 1];
5076 (
5077 entry.branch.clone(),
5078 entry.pull_request_id.clone(),
5079 entry.is_submitted,
5080 )
5081 })
5082 .collect();
5083
5084 let current_branch = repo.get_current_branch()?;
5085
5086 let mut pr_manager = None;
5088
5089 for (i, &idx) in indices.iter().enumerate().rev() {
5091 let zero_idx = idx - 1;
5092 match manager.remove_stack_entry_at(&stack_id, zero_idx)? {
5093 Some(removed) => {
5094 Output::success(format!(
5095 "Dropped entry {}: {} {}",
5096 idx,
5097 removed.short_hash(),
5098 removed.short_message(40)
5099 ));
5100 }
5101 None => {
5102 Output::warning(format!("Could not remove entry {idx}"));
5103 continue;
5104 }
5105 }
5106
5107 let (ref branch_name, ref pr_id, is_submitted) = entries_info[i];
5108
5109 if !keep_branch && *branch_name != current_branch {
5111 match repo.delete_branch(branch_name) {
5112 Ok(_) => Output::sub_item(format!("Deleted branch: {branch_name}")),
5113 Err(e) => Output::warning(format!("Could not delete branch {branch_name}: {e}")),
5114 }
5115 }
5116
5117 if is_submitted && !keep_pr {
5119 if let Some(pr_id_str) = pr_id {
5120 if let Ok(pr_id_num) = pr_id_str.parse::<u64>() {
5121 let should_decline = if force {
5122 true
5123 } else {
5124 Confirm::with_theme(&ColorfulTheme::default())
5125 .with_prompt(format!("Decline PR #{pr_id_num} on Bitbucket?"))
5126 .default(true)
5127 .interact()
5128 .unwrap_or(false)
5129 };
5130
5131 if should_decline {
5132 if pr_manager.is_none() {
5134 let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
5135 let config_path = config_dir.join("config.json");
5136 let settings = crate::config::Settings::load_from_file(&config_path)?;
5137 let client =
5138 crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?;
5139 pr_manager = Some(crate::bitbucket::PullRequestManager::new(client));
5140 }
5141
5142 if let Some(ref mgr) = pr_manager {
5143 match mgr
5144 .decline_pull_request(pr_id_num, "Dropped from stack")
5145 .await
5146 {
5147 Ok(_) => Output::sub_item(format!(
5148 "Declined PR #{pr_id_num} on Bitbucket"
5149 )),
5150 Err(e) => Output::warning(format!(
5151 "Failed to decline PR #{pr_id_num}: {e}"
5152 )),
5153 }
5154 }
5155 }
5156 }
5157 }
5158 }
5159 }
5160
5161 Output::success(format!(
5162 "Dropped {} entry/entries from stack",
5163 indices.len()
5164 ));
5165
5166 Ok(())
5167}
5168
5169#[cfg(test)]
5170mod tests {
5171 use super::*;
5172 use std::process::Command;
5173 use tempfile::TempDir;
5174
5175 fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
5176 let temp_dir = TempDir::new()
5177 .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
5178 let repo_path = temp_dir.path().to_path_buf();
5179
5180 let output = Command::new("git")
5182 .args(["init"])
5183 .current_dir(&repo_path)
5184 .output()
5185 .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
5186 if !output.status.success() {
5187 return Err(CascadeError::config("Git init failed".to_string()));
5188 }
5189
5190 let output = Command::new("git")
5191 .args(["config", "user.name", "Test User"])
5192 .current_dir(&repo_path)
5193 .output()
5194 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
5195 if !output.status.success() {
5196 return Err(CascadeError::config(
5197 "Git config user.name failed".to_string(),
5198 ));
5199 }
5200
5201 let output = Command::new("git")
5202 .args(["config", "user.email", "test@example.com"])
5203 .current_dir(&repo_path)
5204 .output()
5205 .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
5206 if !output.status.success() {
5207 return Err(CascadeError::config(
5208 "Git config user.email failed".to_string(),
5209 ));
5210 }
5211
5212 std::fs::write(repo_path.join("README.md"), "# Test")
5214 .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
5215 let output = Command::new("git")
5216 .args(["add", "."])
5217 .current_dir(&repo_path)
5218 .output()
5219 .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
5220 if !output.status.success() {
5221 return Err(CascadeError::config("Git add failed".to_string()));
5222 }
5223
5224 let output = Command::new("git")
5225 .args(["commit", "-m", "Initial commit"])
5226 .current_dir(&repo_path)
5227 .output()
5228 .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
5229 if !output.status.success() {
5230 return Err(CascadeError::config("Git commit failed".to_string()));
5231 }
5232
5233 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
5235
5236 Ok((temp_dir, repo_path))
5237 }
5238
5239 #[tokio::test]
5240 async fn test_create_stack() {
5241 let (temp_dir, repo_path) = match create_test_repo() {
5242 Ok(repo) => repo,
5243 Err(_) => {
5244 println!("Skipping test due to git environment setup failure");
5245 return;
5246 }
5247 };
5248 let _ = &temp_dir;
5250
5251 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5255 match env::set_current_dir(&repo_path) {
5256 Ok(_) => {
5257 let result = create_stack(
5258 "test-stack".to_string(),
5259 None, Some("Test description".to_string()),
5261 )
5262 .await;
5263
5264 if let Ok(orig) = original_dir {
5266 let _ = env::set_current_dir(orig);
5267 }
5268
5269 assert!(
5270 result.is_ok(),
5271 "Stack creation should succeed in initialized repository"
5272 );
5273 }
5274 Err(_) => {
5275 println!("Skipping test due to directory access restrictions");
5277 }
5278 }
5279 }
5280
5281 #[tokio::test]
5282 async fn test_list_empty_stacks() {
5283 let (temp_dir, repo_path) = match create_test_repo() {
5284 Ok(repo) => repo,
5285 Err(_) => {
5286 println!("Skipping test due to git environment setup failure");
5287 return;
5288 }
5289 };
5290 let _ = &temp_dir;
5292
5293 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5297 match env::set_current_dir(&repo_path) {
5298 Ok(_) => {
5299 let result = list_stacks(false, false, None).await;
5300
5301 if let Ok(orig) = original_dir {
5303 let _ = env::set_current_dir(orig);
5304 }
5305
5306 assert!(
5307 result.is_ok(),
5308 "Listing stacks should succeed in initialized repository"
5309 );
5310 }
5311 Err(_) => {
5312 println!("Skipping test due to directory access restrictions");
5314 }
5315 }
5316 }
5317
5318 #[test]
5321 fn test_extract_feature_from_wip_basic() {
5322 let messages = vec![
5323 "WIP: add authentication".to_string(),
5324 "WIP: implement login flow".to_string(),
5325 ];
5326
5327 let result = extract_feature_from_wip(&messages);
5328 assert_eq!(result, "Add authentication");
5329 }
5330
5331 #[test]
5332 fn test_extract_feature_from_wip_capitalize() {
5333 let messages = vec!["WIP: fix user validation bug".to_string()];
5334
5335 let result = extract_feature_from_wip(&messages);
5336 assert_eq!(result, "Fix user validation bug");
5337 }
5338
5339 #[test]
5340 fn test_extract_feature_from_wip_fallback() {
5341 let messages = vec![
5342 "WIP user interface changes".to_string(),
5343 "wip: css styling".to_string(),
5344 ];
5345
5346 let result = extract_feature_from_wip(&messages);
5347 assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
5349 }
5350
5351 #[test]
5352 fn test_extract_feature_from_wip_empty() {
5353 let messages = vec![];
5354
5355 let result = extract_feature_from_wip(&messages);
5356 assert_eq!(result, "Squashed 0 commits");
5357 }
5358
5359 #[test]
5360 fn test_extract_feature_from_wip_short_message() {
5361 let messages = vec!["WIP: x".to_string()]; let result = extract_feature_from_wip(&messages);
5364 assert!(result.starts_with("Implement") || result.contains("Squashed"));
5365 }
5366
5367 #[test]
5370 fn test_squash_message_final_strategy() {
5371 let messages = [
5375 "Final: implement user authentication system".to_string(),
5376 "WIP: add tests".to_string(),
5377 "WIP: fix validation".to_string(),
5378 ];
5379
5380 assert!(messages[0].starts_with("Final:"));
5382
5383 let extracted = messages[0].trim_start_matches("Final:").trim();
5385 assert_eq!(extracted, "implement user authentication system");
5386 }
5387
5388 #[test]
5389 fn test_squash_message_wip_detection() {
5390 let messages = [
5391 "WIP: start feature".to_string(),
5392 "WIP: continue work".to_string(),
5393 "WIP: almost done".to_string(),
5394 "Regular commit message".to_string(),
5395 ];
5396
5397 let wip_count = messages
5398 .iter()
5399 .filter(|m| {
5400 m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
5401 })
5402 .count();
5403
5404 assert_eq!(wip_count, 3); assert!(wip_count > messages.len() / 2); let non_wip: Vec<&String> = messages
5409 .iter()
5410 .filter(|m| {
5411 !m.to_lowercase().starts_with("wip")
5412 && !m.to_lowercase().contains("work in progress")
5413 })
5414 .collect();
5415
5416 assert_eq!(non_wip.len(), 1);
5417 assert_eq!(non_wip[0], "Regular commit message");
5418 }
5419
5420 #[test]
5421 fn test_squash_message_all_wip() {
5422 let messages = vec![
5423 "WIP: add feature A".to_string(),
5424 "WIP: add feature B".to_string(),
5425 "WIP: finish implementation".to_string(),
5426 ];
5427
5428 let result = extract_feature_from_wip(&messages);
5429 assert_eq!(result, "Add feature A");
5431 }
5432
5433 #[test]
5434 fn test_squash_message_edge_cases() {
5435 let empty_messages: Vec<String> = vec![];
5437 let result = extract_feature_from_wip(&empty_messages);
5438 assert_eq!(result, "Squashed 0 commits");
5439
5440 let whitespace_messages = vec![" ".to_string(), "\t\n".to_string()];
5442 let result = extract_feature_from_wip(&whitespace_messages);
5443 assert!(result.contains("Squashed") || result.contains("Implement"));
5444
5445 let mixed_case = vec!["wip: Add Feature".to_string()];
5447 let result = extract_feature_from_wip(&mixed_case);
5448 assert_eq!(result, "Add Feature");
5449 }
5450
5451 #[tokio::test]
5454 async fn test_auto_land_wrapper() {
5455 let (temp_dir, repo_path) = match create_test_repo() {
5457 Ok(repo) => repo,
5458 Err(_) => {
5459 println!("Skipping test due to git environment setup failure");
5460 return;
5461 }
5462 };
5463 let _ = &temp_dir;
5465
5466 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
5468 .expect("Failed to initialize Cascade in test repo");
5469
5470 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5471 match env::set_current_dir(&repo_path) {
5472 Ok(_) => {
5473 let result = create_stack(
5475 "test-stack".to_string(),
5476 None,
5477 Some("Test stack for auto-land".to_string()),
5478 )
5479 .await;
5480
5481 if let Ok(orig) = original_dir {
5482 let _ = env::set_current_dir(orig);
5483 }
5484
5485 assert!(
5488 result.is_ok(),
5489 "Stack creation should succeed in initialized repository"
5490 );
5491 }
5492 Err(_) => {
5493 println!("Skipping test due to directory access restrictions");
5494 }
5495 }
5496 }
5497
5498 #[test]
5499 fn test_auto_land_action_enum() {
5500 use crate::cli::commands::stack::StackAction;
5502
5503 let _action = StackAction::AutoLand {
5505 force: false,
5506 dry_run: true,
5507 wait_for_builds: true,
5508 strategy: Some(MergeStrategyArg::Squash),
5509 build_timeout: 1800,
5510 };
5511
5512 }
5514
5515 #[test]
5516 fn test_merge_strategy_conversion() {
5517 let squash_strategy = MergeStrategyArg::Squash;
5519 let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
5520
5521 match merge_strategy {
5522 crate::bitbucket::pull_request::MergeStrategy::Squash => {
5523 }
5525 _ => unreachable!("SquashStrategyArg only has Squash variant"),
5526 }
5527
5528 let merge_strategy = MergeStrategyArg::Merge;
5529 let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
5530
5531 match converted {
5532 crate::bitbucket::pull_request::MergeStrategy::Merge => {
5533 }
5535 _ => unreachable!("MergeStrategyArg::Merge maps to MergeStrategy::Merge"),
5536 }
5537 }
5538
5539 #[test]
5540 fn test_auto_merge_conditions_structure() {
5541 use std::time::Duration;
5543
5544 let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
5545 merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
5546 wait_for_builds: true,
5547 build_timeout: Duration::from_secs(1800),
5548 allowed_authors: None,
5549 };
5550
5551 assert!(conditions.wait_for_builds);
5553 assert_eq!(conditions.build_timeout.as_secs(), 1800);
5554 assert!(conditions.allowed_authors.is_none());
5555 assert!(matches!(
5556 conditions.merge_strategy,
5557 crate::bitbucket::pull_request::MergeStrategy::Squash
5558 ));
5559 }
5560
5561 #[test]
5562 fn test_polling_constants() {
5563 use std::time::Duration;
5565
5566 let expected_polling_interval = Duration::from_secs(30);
5568
5569 assert!(expected_polling_interval.as_secs() >= 10); assert!(expected_polling_interval.as_secs() <= 60); assert_eq!(expected_polling_interval.as_secs(), 30); }
5574
5575 #[test]
5576 fn test_build_timeout_defaults() {
5577 const DEFAULT_TIMEOUT: u64 = 1800; assert_eq!(DEFAULT_TIMEOUT, 1800);
5580 let timeout_value = 1800u64;
5582 assert!(timeout_value >= 300); assert!(timeout_value <= 3600); }
5585
5586 #[test]
5587 fn test_scattered_commit_detection() {
5588 use std::collections::HashSet;
5589
5590 let mut source_branches = HashSet::new();
5592 source_branches.insert("feature-branch-1".to_string());
5593 source_branches.insert("feature-branch-2".to_string());
5594 source_branches.insert("feature-branch-3".to_string());
5595
5596 let single_branch = HashSet::from(["main".to_string()]);
5598 assert_eq!(single_branch.len(), 1);
5599
5600 assert!(source_branches.len() > 1);
5602 assert_eq!(source_branches.len(), 3);
5603
5604 assert!(source_branches.contains("feature-branch-1"));
5606 assert!(source_branches.contains("feature-branch-2"));
5607 assert!(source_branches.contains("feature-branch-3"));
5608 }
5609
5610 #[test]
5611 fn test_source_branch_tracking() {
5612 let branch_a = "feature-work";
5616 let branch_b = "feature-work";
5617 assert_eq!(branch_a, branch_b);
5618
5619 let branch_1 = "feature-ui";
5621 let branch_2 = "feature-api";
5622 assert_ne!(branch_1, branch_2);
5623
5624 assert!(branch_1.starts_with("feature-"));
5626 assert!(branch_2.starts_with("feature-"));
5627 }
5628
5629 #[tokio::test]
5632 async fn test_push_default_behavior() {
5633 let (temp_dir, repo_path) = match create_test_repo() {
5635 Ok(repo) => repo,
5636 Err(_) => {
5637 println!("Skipping test due to git environment setup failure");
5638 return;
5639 }
5640 };
5641 let _ = &temp_dir;
5643
5644 if !repo_path.exists() {
5646 println!("Skipping test due to temporary directory creation issue");
5647 return;
5648 }
5649
5650 let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5652
5653 match env::set_current_dir(&repo_path) {
5654 Ok(_) => {
5655 let result = push_to_stack(
5657 None, None, None, None, None, None, None, false, false, false, true, )
5669 .await;
5670
5671 if let Ok(orig) = original_dir {
5673 let _ = env::set_current_dir(orig);
5674 }
5675
5676 match &result {
5678 Err(e) => {
5679 let error_msg = e.to_string();
5680 assert!(
5682 error_msg.contains("No active stack")
5683 || error_msg.contains("config")
5684 || error_msg.contains("current directory")
5685 || error_msg.contains("Not a git repository")
5686 || error_msg.contains("could not find repository"),
5687 "Expected 'No active stack' or repository error, got: {error_msg}"
5688 );
5689 }
5690 Ok(_) => {
5691 println!(
5693 "Push succeeded unexpectedly - test environment may have active stack"
5694 );
5695 }
5696 }
5697 }
5698 Err(_) => {
5699 println!("Skipping test due to directory access restrictions");
5701 }
5702 }
5703
5704 let push_action = StackAction::Push {
5706 branch: None,
5707 message: None,
5708 commit: None,
5709 since: None,
5710 commits: None,
5711 squash: None,
5712 squash_since: None,
5713 auto_branch: false,
5714 allow_base_branch: false,
5715 dry_run: false,
5716 yes: false,
5717 };
5718
5719 assert!(matches!(
5720 push_action,
5721 StackAction::Push {
5722 branch: None,
5723 message: None,
5724 commit: None,
5725 since: None,
5726 commits: None,
5727 squash: None,
5728 squash_since: None,
5729 auto_branch: false,
5730 allow_base_branch: false,
5731 dry_run: false,
5732 yes: false
5733 }
5734 ));
5735 }
5736
5737 #[tokio::test]
5738 async fn test_submit_default_behavior() {
5739 let (temp_dir, repo_path) = match create_test_repo() {
5741 Ok(repo) => repo,
5742 Err(_) => {
5743 println!("Skipping test due to git environment setup failure");
5744 return;
5745 }
5746 };
5747 let _ = &temp_dir;
5749
5750 if !repo_path.exists() {
5752 println!("Skipping test due to temporary directory creation issue");
5753 return;
5754 }
5755
5756 let original_dir = match env::current_dir() {
5758 Ok(dir) => dir,
5759 Err(_) => {
5760 println!("Skipping test due to current directory access restrictions");
5761 return;
5762 }
5763 };
5764
5765 match env::set_current_dir(&repo_path) {
5766 Ok(_) => {
5767 let result = submit_entry(
5769 None, None, None, None, false, true, )
5776 .await;
5777
5778 let _ = env::set_current_dir(original_dir);
5780
5781 match &result {
5783 Err(e) => {
5784 let error_msg = e.to_string();
5785 assert!(
5787 error_msg.contains("No active stack")
5788 || error_msg.contains("config")
5789 || error_msg.contains("current directory")
5790 || error_msg.contains("Not a git repository")
5791 || error_msg.contains("could not find repository"),
5792 "Expected 'No active stack' or repository error, got: {error_msg}"
5793 );
5794 }
5795 Ok(_) => {
5796 println!("Submit succeeded unexpectedly - test environment may have active stack");
5798 }
5799 }
5800 }
5801 Err(_) => {
5802 println!("Skipping test due to directory access restrictions");
5804 }
5805 }
5806
5807 let submit_action = StackAction::Submit {
5809 entry: None,
5810 title: None,
5811 description: None,
5812 range: None,
5813 draft: true, open: true,
5815 };
5816
5817 assert!(matches!(
5818 submit_action,
5819 StackAction::Submit {
5820 entry: None,
5821 title: None,
5822 description: None,
5823 range: None,
5824 draft: true, open: true
5826 }
5827 ));
5828 }
5829
5830 #[test]
5831 fn test_targeting_options_still_work() {
5832 let commits = "abc123,def456,ghi789";
5836 let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
5837 assert_eq!(parsed.len(), 3);
5838 assert_eq!(parsed[0], "abc123");
5839 assert_eq!(parsed[1], "def456");
5840 assert_eq!(parsed[2], "ghi789");
5841
5842 let range = "1-3";
5844 assert!(range.contains('-'));
5845 let parts: Vec<&str> = range.split('-').collect();
5846 assert_eq!(parts.len(), 2);
5847
5848 let since_ref = "HEAD~3";
5850 assert!(since_ref.starts_with("HEAD"));
5851 assert!(since_ref.contains('~'));
5852 }
5853
5854 #[test]
5855 fn test_command_flow_logic() {
5856 assert!(matches!(
5858 StackAction::Push {
5859 branch: None,
5860 message: None,
5861 commit: None,
5862 since: None,
5863 commits: None,
5864 squash: None,
5865 squash_since: None,
5866 auto_branch: false,
5867 allow_base_branch: false,
5868 dry_run: false,
5869 yes: false
5870 },
5871 StackAction::Push { .. }
5872 ));
5873
5874 assert!(matches!(
5875 StackAction::Submit {
5876 entry: None,
5877 title: None,
5878 description: None,
5879 range: None,
5880 draft: false,
5881 open: true
5882 },
5883 StackAction::Submit { .. }
5884 ));
5885 }
5886
5887 #[tokio::test]
5888 async fn test_deactivate_command_structure() {
5889 let deactivate_action = StackAction::Deactivate { force: false };
5891
5892 assert!(matches!(
5894 deactivate_action,
5895 StackAction::Deactivate { force: false }
5896 ));
5897
5898 let force_deactivate = StackAction::Deactivate { force: true };
5900 assert!(matches!(
5901 force_deactivate,
5902 StackAction::Deactivate { force: true }
5903 ));
5904 }
5905}