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