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