Skip to main content

cascade_cli/cli/commands/
stack.rs

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};
8// Progress bars removed - using professional Output module instead
9use std::env;
10use tracing::{debug, warn};
11use uuid::Uuid;
12
13/// CLI argument version of RebaseStrategy
14#[derive(ValueEnum, Clone, Debug)]
15pub enum RebaseStrategyArg {
16    /// Force-push rebased commits to original branches (preserves PR history)
17    ForcePush,
18    /// Interactive rebase with conflict resolution
19    Interactive,
20}
21
22#[derive(ValueEnum, Clone, Debug)]
23pub enum MergeStrategyArg {
24    /// Create a merge commit
25    Merge,
26    /// Squash all commits into one
27    Squash,
28    /// Fast-forward merge when possible
29    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 a new stack
45    Create {
46        /// Name of the stack
47        name: String,
48        /// Base branch for the stack
49        #[arg(long, short)]
50        base: Option<String>,
51        /// Description of the stack
52        #[arg(long, short)]
53        description: Option<String>,
54    },
55
56    /// List all stacks
57    List {
58        /// Show detailed information
59        #[arg(long, short)]
60        verbose: bool,
61        /// Show only active stack
62        #[arg(long)]
63        active: bool,
64        /// Output format (name, id, status)
65        #[arg(long)]
66        format: Option<String>,
67    },
68
69    /// Switch to a different stack
70    Switch {
71        /// Name of the stack to switch to
72        name: String,
73    },
74
75    /// Deactivate the current stack (turn off stack mode)
76    Deactivate {
77        /// Force deactivation without confirmation
78        #[arg(long)]
79        force: bool,
80    },
81
82    /// Show the current stack status  
83    Show {
84        /// Show detailed pull request information
85        #[arg(short, long)]
86        verbose: bool,
87        /// Show mergability status for all PRs
88        #[arg(short, long)]
89        mergeable: bool,
90    },
91
92    /// Push current commit to the top of the stack
93    Push {
94        /// Branch name for this commit
95        #[arg(long, short)]
96        branch: Option<String>,
97        /// Commit message (if creating a new commit)
98        #[arg(long, short)]
99        message: Option<String>,
100        /// Use specific commit hash instead of HEAD
101        #[arg(long)]
102        commit: Option<String>,
103        /// Push commits since this reference (e.g., HEAD~3)
104        #[arg(long)]
105        since: Option<String>,
106        /// Push multiple specific commits (comma-separated)
107        #[arg(long)]
108        commits: Option<String>,
109        /// Squash unpushed commits before pushing (optional: specify count)
110        #[arg(long, num_args = 0..=1, default_missing_value = "0")]
111        squash: Option<usize>,
112        /// Squash all commits since this reference (e.g., HEAD~5)
113        #[arg(long)]
114        squash_since: Option<String>,
115        /// Auto-create feature branch when pushing from base branch
116        #[arg(long)]
117        auto_branch: bool,
118        /// Allow pushing commits from base branch (not recommended)
119        #[arg(long)]
120        allow_base_branch: bool,
121        /// Show what would be pushed without actually pushing
122        #[arg(long)]
123        dry_run: bool,
124        /// Skip confirmation prompts
125        #[arg(long, short)]
126        yes: bool,
127    },
128
129    /// Pop the top commit from the stack
130    Pop {
131        /// Keep the branch (don't delete it)
132        #[arg(long)]
133        keep_branch: bool,
134    },
135
136    /// Submit a stack entry for review
137    Submit {
138        /// Stack entry number (1-based, defaults to all unsubmitted)
139        entry: Option<usize>,
140        /// Pull request title
141        #[arg(long, short)]
142        title: Option<String>,
143        /// Pull request description
144        #[arg(long, short)]
145        description: Option<String>,
146        /// Submit range of entries (e.g., "1-3" or "2,4,6")
147        #[arg(long)]
148        range: Option<String>,
149        /// Create draft pull requests (default: true, use --no-draft to create ready PRs)
150        #[arg(long, default_value_t = true)]
151        draft: bool,
152        /// Open the PR(s) in your default browser after submission (default: true, use --no-open to disable)
153        #[arg(long, default_value_t = true)]
154        open: bool,
155    },
156
157    /// Check status of all pull requests in a stack
158    Status {
159        /// Name of the stack (defaults to active stack)
160        name: Option<String>,
161    },
162
163    /// List all pull requests for the repository
164    Prs {
165        /// Filter by state (open, merged, declined)
166        #[arg(long)]
167        state: Option<String>,
168        /// Show detailed information
169        #[arg(long, short)]
170        verbose: bool,
171    },
172
173    /// Check stack status with remote repository (read-only)
174    Check {
175        /// Force check even if there are issues
176        #[arg(long)]
177        force: bool,
178    },
179
180    /// Sync stack with remote repository (pull + rebase + cleanup)
181    Sync {
182        /// Force sync even if there are conflicts
183        #[arg(long)]
184        force: bool,
185        /// Also cleanup merged branches after sync
186        #[arg(long)]
187        cleanup: bool,
188        /// Interactive mode for conflict resolution
189        #[arg(long, short)]
190        interactive: bool,
191    },
192
193    /// Rebase stack on updated base branch
194    Rebase {
195        /// Interactive rebase
196        #[arg(long, short)]
197        interactive: bool,
198        /// Target base branch (defaults to stack's base branch)
199        #[arg(long)]
200        onto: Option<String>,
201        /// Rebase strategy to use
202        #[arg(long, value_enum)]
203        strategy: Option<RebaseStrategyArg>,
204    },
205
206    /// Continue an in-progress rebase after resolving conflicts
207    ContinueRebase,
208
209    /// Abort an in-progress rebase
210    AbortRebase,
211
212    /// Show rebase status and conflict resolution guidance
213    RebaseStatus,
214
215    /// Delete a stack
216    Delete {
217        /// Name of the stack to delete
218        name: String,
219        /// Force deletion without confirmation
220        #[arg(long)]
221        force: bool,
222    },
223
224    /// Validate stack integrity and handle branch modifications
225    ///
226    /// Checks that stack branches match their expected commit hashes.
227    /// Detects when branches have been manually modified (extra commits added).
228    ///
229    /// Available --fix modes:
230    /// • incorporate: Update stack entry to include extra commits
231    /// • split: Create new stack entry for extra commits  
232    /// • reset: Remove extra commits (DESTRUCTIVE - loses work)
233    ///
234    /// Without --fix, runs interactively asking for each modification.
235    Validate {
236        /// Name of the stack (defaults to active stack)
237        name: Option<String>,
238        /// Auto-fix mode: incorporate, split, or reset
239        #[arg(long)]
240        fix: Option<String>,
241    },
242
243    /// Land (merge) approved stack entries
244    Land {
245        /// Stack entry number to land (1-based index, optional)
246        entry: Option<usize>,
247        /// Force land even with blocking issues (dangerous)
248        #[arg(short, long)]
249        force: bool,
250        /// Dry run - show what would be landed without doing it
251        #[arg(short, long)]
252        dry_run: bool,
253        /// Use server-side validation (safer, checks approvals/builds)
254        #[arg(long)]
255        auto: bool,
256        /// Wait for builds to complete before merging
257        #[arg(long)]
258        wait_for_builds: bool,
259        /// Merge strategy to use
260        #[arg(long, value_enum, default_value = "squash")]
261        strategy: Option<MergeStrategyArg>,
262        /// Maximum time to wait for builds (seconds)
263        #[arg(long, default_value = "1800")]
264        build_timeout: u64,
265    },
266
267    /// Auto-land all ready PRs (shorthand for land --auto)
268    AutoLand {
269        /// Force land even with blocking issues (dangerous)
270        #[arg(short, long)]
271        force: bool,
272        /// Dry run - show what would be landed without doing it
273        #[arg(short, long)]
274        dry_run: bool,
275        /// Wait for builds to complete before merging
276        #[arg(long)]
277        wait_for_builds: bool,
278        /// Merge strategy to use
279        #[arg(long, value_enum, default_value = "squash")]
280        strategy: Option<MergeStrategyArg>,
281        /// Maximum time to wait for builds (seconds)
282        #[arg(long, default_value = "1800")]
283        build_timeout: u64,
284    },
285
286    /// List pull requests from Bitbucket
287    ListPrs {
288        /// Filter by state (open, merged, declined)
289        #[arg(short, long)]
290        state: Option<String>,
291        /// Show detailed information
292        #[arg(short, long)]
293        verbose: bool,
294    },
295
296    /// Continue an in-progress land operation after resolving conflicts
297    ContinueLand,
298
299    /// Abort an in-progress land operation  
300    AbortLand,
301
302    /// Show status of in-progress land operation
303    LandStatus,
304
305    /// Clean up merged and stale branches
306    Cleanup {
307        /// Show what would be cleaned up without actually deleting
308        #[arg(long)]
309        dry_run: bool,
310        /// Skip confirmation prompts
311        #[arg(long)]
312        force: bool,
313        /// Include stale branches in cleanup
314        #[arg(long)]
315        include_stale: bool,
316        /// Age threshold for stale branches (days)
317        #[arg(long, default_value = "30")]
318        stale_days: u32,
319        /// Also cleanup remote tracking branches
320        #[arg(long)]
321        cleanup_remote: bool,
322        /// Include non-stack branches in cleanup
323        #[arg(long)]
324        include_non_stack: bool,
325        /// Show detailed information about cleanup candidates
326        #[arg(long)]
327        verbose: bool,
328    },
329
330    /// Repair data consistency issues in stack metadata
331    Repair,
332
333    /// Drop (remove) stack entries by position
334    Drop {
335        /// Entry position or range (e.g., "3", "1-5", "1,3,5")
336        entry: String,
337        /// Keep the branch (don't delete it)
338        #[arg(long)]
339        keep_branch: bool,
340        /// Keep the PR open on Bitbucket (don't decline it)
341        #[arg(long)]
342        keep_pr: bool,
343        /// Skip all confirmation prompts
344        #[arg(long, short)]
345        force: bool,
346        /// Skip confirmation prompts
347        #[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
484// Public functions for shortcut commands
485pub 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(&current_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    // Get the created stack to check its working branch
599    let stack = manager
600        .get_stack(&stack_id)
601        .ok_or_else(|| CascadeError::config("Failed to get created stack"))?;
602
603    // Use the new output format
604    Output::stack_info(
605        &name,
606        &stack_id.to_string(),
607        &stack.base_branch,
608        stack.working_branch.as_deref(),
609        true, // is_active
610    );
611
612    if let Some(desc) = description {
613        Output::sub_item(format!("Description: {desc}"));
614    }
615
616    // Provide helpful guidance based on the working branch situation
617    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(&current_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        // Get the actual stack object to access branch information
767        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            // Show branch information in verbose mode
787            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            // Show compact branch info in non-verbose mode
810            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(&current_dir)
840        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
841
842    let manager = StackManager::new(&repo_root)?;
843    let repo = GitRepository::open(&repo_root)?;
844
845    // Get stack information before switching
846    let stack = manager
847        .get_stack_by_name(&name)
848        .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
849
850    // Determine the target branch and provide appropriate messaging
851    if let Some(working_branch) = &stack.working_branch {
852        // Stack has a working branch - try to switch to it
853        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            // Check if target branch exists
861            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        // No working branch - provide guidance
888        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    Output::success(format!("Switched to stack '{name}'"));
902
903    Ok(())
904}
905
906async fn deactivate_stack(_force: bool) -> Result<()> {
907    Output::warning("'ca deactivate' is no longer needed.");
908    Output::sub_item("Active stack is now resolved from your current branch.");
909    Output::sub_item("To leave a stack, just switch to your base branch: git checkout main");
910
911    Ok(())
912}
913
914async fn show_stack(verbose: bool, show_mergeable: bool) -> Result<()> {
915    let current_dir = env::current_dir()
916        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
917
918    let repo_root = find_repository_root(&current_dir)
919        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
920
921    let stack_manager = StackManager::new(&repo_root)?;
922
923    // Get stack information first to avoid borrow conflicts
924    let (stack_id, stack_name, stack_base, stack_working, stack_entries) = {
925        let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
926            CascadeError::config(
927                "No active stack. Use 'ca stacks create' or 'ca stacks switch' to select a stack"
928                    .to_string(),
929            )
930        })?;
931
932        (
933            active_stack.id,
934            active_stack.name.clone(),
935            active_stack.base_branch.clone(),
936            active_stack.working_branch.clone(),
937            active_stack.entries.clone(),
938        )
939    };
940
941    // Use the new output format for stack info
942    Output::stack_info(
943        &stack_name,
944        &stack_id.to_string(),
945        &stack_base,
946        stack_working.as_deref(),
947        true, // is_active
948    );
949    Output::sub_item(format!("Total entries: {}", stack_entries.len()));
950
951    if stack_entries.is_empty() {
952        Output::info("No entries in this stack yet");
953        Output::tip("Use 'ca push' to add commits to this stack");
954        return Ok(());
955    }
956
957    // 🔄 ALWAYS refresh PR status from Bitbucket to show accurate merge state
958    // This ensures [merged] badges are up-to-date even for regular `ca stack`
959    let refreshed_entries = if let Ok(config_dir) = crate::config::get_repo_config_dir(&repo_root) {
960        let config_path = config_dir.join("config.json");
961        if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
962            let cascade_config = crate::config::CascadeConfig {
963                bitbucket: Some(settings.bitbucket.clone()),
964                git: settings.git.clone(),
965                auth: crate::config::AuthConfig::default(),
966                cascade: settings.cascade.clone(),
967            };
968
969            // Create a separate stack_manager for the integration
970            if let Ok(integration_stack_manager) = StackManager::new(&repo_root) {
971                let mut integration = crate::bitbucket::BitbucketIntegration::new(
972                    integration_stack_manager,
973                    cascade_config,
974                )
975                .ok();
976
977                if let Some(ref mut integ) = integration {
978                    // Show spinner while checking PR status
979                    let spinner =
980                        crate::utils::spinner::Spinner::new("Checking PR status...".to_string());
981
982                    // Silently refresh merge status (don't show errors to user)
983                    let _ = integ.check_enhanced_stack_status(&stack_id).await;
984
985                    spinner.stop();
986
987                    // Reload stack to get updated is_merged flags
988                    if let Ok(updated_manager) = StackManager::new(&repo_root) {
989                        if let Some(updated_stack) = updated_manager.get_stack(&stack_id) {
990                            updated_stack.entries.clone()
991                        } else {
992                            stack_entries.clone()
993                        }
994                    } else {
995                        stack_entries.clone()
996                    }
997                } else {
998                    stack_entries.clone()
999                }
1000            } else {
1001                stack_entries.clone()
1002            }
1003        } else {
1004            stack_entries.clone()
1005        }
1006    } else {
1007        stack_entries.clone()
1008    };
1009
1010    // Show entries with refreshed merge status
1011    Output::section("Stack Entries");
1012    for (i, entry) in refreshed_entries.iter().enumerate() {
1013        let entry_num = i + 1;
1014        let short_hash = entry.short_hash();
1015        let short_msg = entry.short_message(50);
1016
1017        // Get source branch information for pending entries only
1018        // (submitted entries have their own branch, so source is no longer relevant)
1019        // Note: We need to create a new stack_manager here since the original was moved
1020        let stack_manager_for_metadata = StackManager::new(&repo_root)?;
1021        let metadata = stack_manager_for_metadata.get_repository_metadata();
1022        let source_branch_info = if !entry.is_submitted {
1023            if let Some(commit_meta) = metadata.get_commit(&entry.commit_hash) {
1024                if commit_meta.source_branch != commit_meta.branch
1025                    && !commit_meta.source_branch.is_empty()
1026                {
1027                    format!(" (from {})", commit_meta.source_branch)
1028                } else {
1029                    String::new()
1030                }
1031            } else {
1032                String::new()
1033            }
1034        } else {
1035            String::new()
1036        };
1037
1038        // Use colored status: pending (yellow), submitted (muted green), merged (bright green)
1039        let status_colored = Output::entry_status(entry.is_submitted, entry.is_merged);
1040
1041        Output::numbered_item(
1042            entry_num,
1043            format!("{short_hash} {status_colored} {short_msg}{source_branch_info}"),
1044        );
1045
1046        if verbose {
1047            Output::sub_item(format!("Branch: {}", entry.branch));
1048            Output::sub_item(format!(
1049                "Created: {}",
1050                entry.created_at.format("%Y-%m-%d %H:%M")
1051            ));
1052            if let Some(pr_id) = &entry.pull_request_id {
1053                Output::sub_item(format!("PR: #{pr_id}"));
1054            }
1055
1056            // Display full commit message
1057            Output::sub_item("Commit Message:");
1058            let lines: Vec<&str> = entry.message.lines().collect();
1059            for line in lines {
1060                Output::sub_item(format!("  {line}"));
1061            }
1062        }
1063    }
1064
1065    // Enhanced PR status if requested and available
1066    if show_mergeable {
1067        Output::section("Mergeability Status");
1068
1069        // Load configuration and create Bitbucket integration
1070        let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1071        let config_path = config_dir.join("config.json");
1072        let settings = crate::config::Settings::load_from_file(&config_path)?;
1073
1074        let cascade_config = crate::config::CascadeConfig {
1075            bitbucket: Some(settings.bitbucket.clone()),
1076            git: settings.git.clone(),
1077            auth: crate::config::AuthConfig::default(),
1078            cascade: settings.cascade.clone(),
1079        };
1080
1081        let mut integration =
1082            crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1083
1084        let spinner =
1085            crate::utils::spinner::Spinner::new("Fetching detailed PR status...".to_string());
1086        let status_result = integration.check_enhanced_stack_status(&stack_id).await;
1087        spinner.stop();
1088
1089        match status_result {
1090            Ok(mut status) => {
1091                // Apply advisory merge check filters from config
1092                let advisory_patterns = &settings.cascade.advisory_merge_checks;
1093                if !advisory_patterns.is_empty() {
1094                    for enhanced in &mut status.enhanced_statuses {
1095                        enhanced.apply_advisory_filters(advisory_patterns);
1096                    }
1097                }
1098                Output::bullet(format!("Total entries: {}", status.total_entries));
1099                Output::bullet(format!("Submitted: {}", status.submitted_entries));
1100                Output::bullet(format!("Open PRs: {}", status.open_prs));
1101                Output::bullet(format!("Merged PRs: {}", status.merged_prs));
1102                Output::bullet(format!("Declined PRs: {}", status.declined_prs));
1103                Output::bullet(format!(
1104                    "Completion: {:.1}%",
1105                    status.completion_percentage()
1106                ));
1107
1108                if !status.enhanced_statuses.is_empty() {
1109                    Output::section("Pull Request Status");
1110                    let mut ready_to_land = 0;
1111
1112                    for enhanced in &status.enhanced_statuses {
1113                        // Determine status badge with color
1114                        use console::style;
1115                        let (ready_badge, show_details) = match enhanced.pr.state {
1116                            crate::bitbucket::pull_request::PullRequestState::Merged => {
1117                                // Bright green for merged
1118                                (style("[MERGED]").green().bold().to_string(), false)
1119                            }
1120                            crate::bitbucket::pull_request::PullRequestState::Declined => {
1121                                // Red for declined
1122                                (style("[DECLINED]").red().bold().to_string(), false)
1123                            }
1124                            crate::bitbucket::pull_request::PullRequestState::Open => {
1125                                if enhanced.is_ready_to_land() {
1126                                    ready_to_land += 1;
1127                                    // Cyan for ready
1128                                    (style("[READY]").cyan().bold().to_string(), true)
1129                                } else {
1130                                    // Yellow for pending
1131                                    (style("[PENDING]").yellow().bold().to_string(), true)
1132                                }
1133                            }
1134                        };
1135
1136                        // Main PR line
1137                        Output::bullet(format!(
1138                            "{} PR #{}: {}",
1139                            ready_badge, enhanced.pr.id, enhanced.pr.title
1140                        ));
1141
1142                        // Show detailed status on separate lines for readability
1143                        if show_details {
1144                            // Build status - infer from blocking reasons if not explicitly available
1145                            let build_display = if let Some(build) = &enhanced.build_status {
1146                                match build.state {
1147                                    crate::bitbucket::pull_request::BuildState::Successful => {
1148                                        style("Passing").green().to_string()
1149                                    }
1150                                    crate::bitbucket::pull_request::BuildState::Failed => {
1151                                        style("Failing").red().to_string()
1152                                    }
1153                                    crate::bitbucket::pull_request::BuildState::InProgress => {
1154                                        style("Running").yellow().to_string()
1155                                    }
1156                                    crate::bitbucket::pull_request::BuildState::Cancelled => {
1157                                        style("Cancelled").dim().to_string()
1158                                    }
1159                                    crate::bitbucket::pull_request::BuildState::Unknown => {
1160                                        style("Unknown").dim().to_string()
1161                                    }
1162                                }
1163                            } else {
1164                                // Infer build status from server blocking reasons
1165                                let blocking = enhanced.get_blocking_reasons();
1166                                if blocking.iter().any(|r| {
1167                                    r.contains("required builds") || r.contains("Build Status")
1168                                }) {
1169                                    // Server says builds are blocking
1170                                    style("Pending").yellow().to_string()
1171                                } else if blocking.is_empty() && enhanced.mergeable.unwrap_or(false)
1172                                {
1173                                    // No blockers and mergeable = builds must be passing
1174                                    style("Passing").green().to_string()
1175                                } else {
1176                                    // Truly unknown
1177                                    style("Unknown").dim().to_string()
1178                                }
1179                            };
1180                            println!("      Builds: {}", build_display);
1181
1182                            // Review status
1183                            let review_display = if enhanced.review_status.can_merge {
1184                                style("Approved").green().to_string()
1185                            } else if enhanced.review_status.needs_work_count > 0 {
1186                                style("Changes Requested").red().to_string()
1187                            } else if enhanced.review_status.current_approvals > 0
1188                                && enhanced.review_status.required_approvals > 0
1189                            {
1190                                style(format!(
1191                                    "{}/{} approvals",
1192                                    enhanced.review_status.current_approvals,
1193                                    enhanced.review_status.required_approvals
1194                                ))
1195                                .yellow()
1196                                .to_string()
1197                            } else {
1198                                style("Pending").yellow().to_string()
1199                            };
1200                            println!("      Reviews: {}", review_display);
1201
1202                            // Merge status
1203                            if !enhanced.mergeable.unwrap_or(false) {
1204                                // Show simplified blocking reason (just the first/most important one)
1205                                let blocking = enhanced.get_blocking_reasons();
1206                                if !blocking.is_empty() {
1207                                    // Take first reason and simplify it
1208                                    let first_reason = &blocking[0];
1209                                    let simplified = if first_reason.contains("Code Owners") {
1210                                        "Waiting for Code Owners approval"
1211                                    } else if first_reason.contains("required builds")
1212                                        || first_reason.contains("Build Status")
1213                                    {
1214                                        "Waiting for required builds"
1215                                    } else if first_reason.contains("approvals")
1216                                        || first_reason.contains("Requires approvals")
1217                                    {
1218                                        "Waiting for approvals"
1219                                    } else if first_reason.contains("conflicts") {
1220                                        "Has merge conflicts"
1221                                    } else {
1222                                        // Generic fallback
1223                                        "Blocked by repository policy"
1224                                    };
1225
1226                                    println!("      Merge: {}", style(simplified).red());
1227                                }
1228                            } else if enhanced.is_ready_to_land() {
1229                                println!("      Merge: {}", style("Ready").green());
1230                            }
1231                        }
1232
1233                        if verbose {
1234                            println!(
1235                                "      {} -> {}",
1236                                enhanced.pr.from_ref.display_id, enhanced.pr.to_ref.display_id
1237                            );
1238
1239                            // Show blocking reasons if not ready
1240                            if !enhanced.is_ready_to_land() {
1241                                let blocking = enhanced.get_blocking_reasons();
1242                                if !blocking.is_empty() {
1243                                    println!("      Blocking: {}", blocking.join(", "));
1244                                }
1245                            }
1246
1247                            // Show review details (actual count from Bitbucket)
1248                            println!(
1249                                "      Reviews: {} approval{}",
1250                                enhanced.review_status.current_approvals,
1251                                if enhanced.review_status.current_approvals == 1 {
1252                                    ""
1253                                } else {
1254                                    "s"
1255                                }
1256                            );
1257
1258                            if enhanced.review_status.needs_work_count > 0 {
1259                                println!(
1260                                    "      {} reviewers requested changes",
1261                                    enhanced.review_status.needs_work_count
1262                                );
1263                            }
1264
1265                            // Show build status
1266                            if let Some(build) = &enhanced.build_status {
1267                                let build_icon = match build.state {
1268                                    crate::bitbucket::pull_request::BuildState::Successful => "✓",
1269                                    crate::bitbucket::pull_request::BuildState::Failed => "✗",
1270                                    crate::bitbucket::pull_request::BuildState::InProgress => "~",
1271                                    _ => "○",
1272                                };
1273                                println!("      Build: {} {:?}", build_icon, build.state);
1274                            }
1275
1276                            if let Some(url) = enhanced.pr.web_url() {
1277                                println!("      URL: {url}");
1278                            }
1279                            println!();
1280                        }
1281                    }
1282
1283                    if ready_to_land > 0 {
1284                        println!(
1285                            "\n🎯 {} PR{} ready to land! Use 'ca land' to land them all.",
1286                            ready_to_land,
1287                            if ready_to_land == 1 { " is" } else { "s are" }
1288                        );
1289                    }
1290                }
1291            }
1292            Err(e) => {
1293                tracing::debug!("Failed to get enhanced stack status: {}", e);
1294                Output::warning("Could not fetch mergability status");
1295                Output::sub_item("Use 'ca stack show --verbose' for basic PR information");
1296            }
1297        }
1298    } else {
1299        // Original PR status display for compatibility
1300        let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1301        let config_path = config_dir.join("config.json");
1302        let settings = crate::config::Settings::load_from_file(&config_path)?;
1303
1304        let cascade_config = crate::config::CascadeConfig {
1305            bitbucket: Some(settings.bitbucket.clone()),
1306            git: settings.git.clone(),
1307            auth: crate::config::AuthConfig::default(),
1308            cascade: settings.cascade.clone(),
1309        };
1310
1311        let integration =
1312            crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
1313
1314        match integration.check_stack_status(&stack_id).await {
1315            Ok(status) => {
1316                println!("\nPull Request Status:");
1317                println!("   Total entries: {}", status.total_entries);
1318                println!("   Submitted: {}", status.submitted_entries);
1319                println!("   Open PRs: {}", status.open_prs);
1320                println!("   Merged PRs: {}", status.merged_prs);
1321                println!("   Declined PRs: {}", status.declined_prs);
1322                println!("   Completion: {:.1}%", status.completion_percentage());
1323
1324                if !status.pull_requests.is_empty() {
1325                    println!("\nPull Requests:");
1326                    for pr in &status.pull_requests {
1327                        use console::style;
1328
1329                        // Color-coded state icons
1330                        let state_icon = match pr.state {
1331                            crate::bitbucket::PullRequestState::Open => {
1332                                style("→").cyan().to_string()
1333                            }
1334                            crate::bitbucket::PullRequestState::Merged => {
1335                                style("✓").green().to_string()
1336                            }
1337                            crate::bitbucket::PullRequestState::Declined => {
1338                                style("✗").red().to_string()
1339                            }
1340                        };
1341
1342                        // Format: icon PR #123: title (from -> to)
1343                        // Dim the PR number and branch arrows for less visual noise
1344                        println!(
1345                            "   {} PR {}: {} ({} {} {})",
1346                            state_icon,
1347                            style(format!("#{}", pr.id)).dim(),
1348                            pr.title,
1349                            style(&pr.from_ref.display_id).dim(),
1350                            style("→").dim(),
1351                            style(&pr.to_ref.display_id).dim()
1352                        );
1353
1354                        // Make URL stand out with cyan/blue hyperlink color
1355                        if let Some(url) = pr.web_url() {
1356                            println!("      URL: {}", style(url).cyan().underlined());
1357                        }
1358                    }
1359                }
1360
1361                println!();
1362                Output::tip("Use 'ca stack --mergeable' to see detailed status including build and review information");
1363            }
1364            Err(e) => {
1365                tracing::debug!("Failed to check stack status: {}", e);
1366            }
1367        }
1368    }
1369
1370    Ok(())
1371}
1372
1373#[allow(clippy::too_many_arguments)]
1374async fn push_to_stack(
1375    branch: Option<String>,
1376    message: Option<String>,
1377    commit: Option<String>,
1378    since: Option<String>,
1379    commits: Option<String>,
1380    squash: Option<usize>,
1381    squash_since: Option<String>,
1382    auto_branch: bool,
1383    allow_base_branch: bool,
1384    dry_run: bool,
1385    yes: bool,
1386) -> Result<()> {
1387    let current_dir = env::current_dir()
1388        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1389
1390    let repo_root = find_repository_root(&current_dir)
1391        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1392
1393    let mut manager = StackManager::new(&repo_root)?;
1394    let repo = GitRepository::open(&repo_root)?;
1395
1396    // Get the active stack to check base branch
1397    let active_stack = manager.get_active_stack().ok_or_else(|| {
1398        CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1399    })?;
1400
1401    // 🛡️ BASE BRANCH PROTECTION
1402    let current_branch = repo.get_current_branch()?;
1403    let base_branch = &active_stack.base_branch;
1404
1405    if current_branch == *base_branch {
1406        Output::error(format!(
1407            "You're currently on the base branch '{base_branch}'"
1408        ));
1409        Output::sub_item("Making commits directly on the base branch is not recommended.");
1410        Output::sub_item("This can pollute the base branch with work-in-progress commits.");
1411
1412        // Check if user explicitly allowed base branch work
1413        if allow_base_branch {
1414            Output::warning("Proceeding anyway due to --allow-base-branch flag");
1415        } else {
1416            // Check if we have uncommitted changes
1417            let has_changes = repo.is_dirty()?;
1418
1419            if has_changes {
1420                if auto_branch {
1421                    // Auto-create branch and commit changes
1422                    let feature_branch = format!("feature/{}-work", active_stack.name);
1423                    Output::progress(format!(
1424                        "Auto-creating feature branch '{feature_branch}'..."
1425                    ));
1426
1427                    repo.create_branch(&feature_branch, None)?;
1428                    repo.checkout_branch(&feature_branch)?;
1429
1430                    Output::success(format!("Created and switched to '{feature_branch}'"));
1431                    println!("   You can now commit and push your changes safely");
1432
1433                    // Continue with normal flow
1434                } else {
1435                    println!("\nYou have uncommitted changes. Here are your options:");
1436                    println!("   1. Create a feature branch first:");
1437                    println!("      git checkout -b feature/my-work");
1438                    println!("      git commit -am \"your work\"");
1439                    println!("      ca push");
1440                    println!("\n   2. Auto-create a branch (recommended):");
1441                    println!("      ca push --auto-branch");
1442                    println!("\n   3. Force push to base branch (dangerous):");
1443                    println!("      ca push --allow-base-branch");
1444
1445                    return Err(CascadeError::config(
1446                        "Refusing to push uncommitted changes from base branch. Use one of the options above."
1447                    ));
1448                }
1449            } else {
1450                // Check if there are existing commits to push
1451                let commits_to_check = if let Some(commits_str) = &commits {
1452                    commits_str
1453                        .split(',')
1454                        .map(|s| s.trim().to_string())
1455                        .collect::<Vec<String>>()
1456                } else if let Some(since_ref) = &since {
1457                    let since_commit = repo.resolve_reference(since_ref)?;
1458                    let head_commit = repo.get_head_commit()?;
1459                    let commits = repo.get_commits_between(
1460                        &since_commit.id().to_string(),
1461                        &head_commit.id().to_string(),
1462                    )?;
1463                    commits.into_iter().map(|c| c.id().to_string()).collect()
1464                } else if commit.is_none() {
1465                    let mut unpushed = Vec::new();
1466                    let head_commit = repo.get_head_commit()?;
1467                    let mut current_commit = head_commit;
1468
1469                    loop {
1470                        let commit_hash = current_commit.id().to_string();
1471                        let already_in_stack = active_stack
1472                            .entries
1473                            .iter()
1474                            .any(|entry| entry.commit_hash == commit_hash);
1475
1476                        if already_in_stack {
1477                            break;
1478                        }
1479
1480                        unpushed.push(commit_hash);
1481
1482                        if let Some(parent) = current_commit.parents().next() {
1483                            current_commit = parent;
1484                        } else {
1485                            break;
1486                        }
1487                    }
1488
1489                    unpushed.reverse();
1490                    unpushed
1491                } else {
1492                    vec![repo.get_head_commit()?.id().to_string()]
1493                };
1494
1495                if !commits_to_check.is_empty() {
1496                    if auto_branch {
1497                        // Auto-create feature branch and cherry-pick commits
1498                        let feature_branch = format!("feature/{}-work", active_stack.name);
1499                        Output::progress(format!(
1500                            "Auto-creating feature branch '{feature_branch}'..."
1501                        ));
1502
1503                        repo.create_branch(&feature_branch, Some(base_branch))?;
1504                        repo.checkout_branch(&feature_branch)?;
1505
1506                        // Cherry-pick the commits to the new branch
1507                        println!(
1508                            "🍒 Cherry-picking {} commit(s) to new branch...",
1509                            commits_to_check.len()
1510                        );
1511                        for commit_hash in &commits_to_check {
1512                            match repo.cherry_pick(commit_hash) {
1513                                Ok(_) => println!("   ✅ Cherry-picked {}", &commit_hash[..8]),
1514                                Err(e) => {
1515                                    Output::error(format!(
1516                                        "Failed to cherry-pick {}: {}",
1517                                        &commit_hash[..8],
1518                                        e
1519                                    ));
1520                                    Output::tip("You may need to resolve conflicts manually");
1521                                    return Err(CascadeError::branch(format!(
1522                                        "Failed to cherry-pick commit {commit_hash}: {e}"
1523                                    )));
1524                                }
1525                            }
1526                        }
1527
1528                        println!(
1529                            "✅ Successfully moved {} commit(s) to '{feature_branch}'",
1530                            commits_to_check.len()
1531                        );
1532                        println!(
1533                            "   You're now on the feature branch and can continue with 'ca push'"
1534                        );
1535
1536                        // Continue with normal flow
1537                    } else {
1538                        println!(
1539                            "\n💡 Found {} commit(s) to push from base branch '{base_branch}'",
1540                            commits_to_check.len()
1541                        );
1542                        println!("   These commits are currently ON the base branch, which may not be intended.");
1543                        println!("\n   Options:");
1544                        println!("   1. Auto-create feature branch and cherry-pick commits:");
1545                        println!("      ca push --auto-branch");
1546                        println!("\n   2. Manually create branch and move commits:");
1547                        println!("      git checkout -b feature/my-work");
1548                        println!("      ca push");
1549                        println!("\n   3. Force push from base branch (not recommended):");
1550                        println!("      ca push --allow-base-branch");
1551
1552                        return Err(CascadeError::config(
1553                            "Refusing to push commits from base branch. Use --auto-branch or create a feature branch manually."
1554                        ));
1555                    }
1556                }
1557            }
1558        }
1559    }
1560
1561    // Handle squash operations first
1562    if let Some(squash_count) = squash {
1563        if squash_count == 0 {
1564            // User used --squash without specifying count, auto-detect unpushed commits
1565            let active_stack = manager.get_active_stack().ok_or_else(|| {
1566                CascadeError::config(
1567                    "No active stack. Create a stack first with 'ca stacks create'",
1568                )
1569            })?;
1570
1571            let unpushed_count = get_unpushed_commits(&repo, active_stack)?.len();
1572
1573            if unpushed_count == 0 {
1574                Output::info("  No unpushed commits to squash");
1575            } else if unpushed_count == 1 {
1576                Output::info("  Only 1 unpushed commit, no squashing needed");
1577            } else {
1578                println!(" Auto-detected {unpushed_count} unpushed commits, squashing...");
1579                squash_commits(&repo, unpushed_count, None).await?;
1580                Output::success(" Squashed {unpushed_count} unpushed commits into one");
1581            }
1582        } else {
1583            println!(" Squashing last {squash_count} commits...");
1584            squash_commits(&repo, squash_count, None).await?;
1585            Output::success(" Squashed {squash_count} commits into one");
1586        }
1587    } else if let Some(since_ref) = squash_since {
1588        println!(" Squashing commits since {since_ref}...");
1589        let since_commit = repo.resolve_reference(&since_ref)?;
1590        let commits_count = count_commits_since(&repo, &since_commit.id().to_string())?;
1591        squash_commits(&repo, commits_count, Some(since_ref.clone())).await?;
1592        Output::success(" Squashed {commits_count} commits since {since_ref} into one");
1593    }
1594
1595    // 🛡️ STALE BASE DETECTION
1596    // Only check when user didn't specify explicit commits
1597    if commits.is_none() && since.is_none() && commit.is_none() {
1598        let active_stack_for_stale = manager.get_active_stack().ok_or_else(|| {
1599            CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1600        })?;
1601        let stale_base = &active_stack_for_stale.base_branch;
1602        let stale_current = repo.get_current_branch()?;
1603
1604        if stale_current != *stale_base {
1605            match repo.get_commits_between(&stale_current, stale_base) {
1606                Ok(base_ahead_commits) if !base_ahead_commits.is_empty() => {
1607                    let count = base_ahead_commits.len();
1608                    Output::warning(format!(
1609                        "Base branch '{}' has {} new commit(s) since your branch diverged",
1610                        stale_base, count
1611                    ));
1612                    Output::sub_item("Commits from other developers may be included in your push.");
1613                    Output::tip("Run 'ca sync' or 'ca stacks rebase' to rebase first.");
1614
1615                    if !dry_run && !yes {
1616                        let should_rebase = Confirm::with_theme(&ColorfulTheme::default())
1617                            .with_prompt("Rebase before pushing?")
1618                            .default(true)
1619                            .interact()
1620                            .map_err(|e| {
1621                                CascadeError::config(format!(
1622                                    "Failed to get user confirmation: {e}"
1623                                ))
1624                            })?;
1625
1626                        if should_rebase {
1627                            Output::info(
1628                                "Run 'ca sync' to rebase your stack on the updated base branch.",
1629                            );
1630                            return Ok(());
1631                        }
1632                    }
1633                }
1634                _ => {} // Branch resolution failed or no stale commits — silently skip
1635            }
1636        }
1637    }
1638
1639    // Determine which commits to push
1640    let commits_to_push = if let Some(commits_str) = commits {
1641        // Parse comma-separated commit hashes
1642        commits_str
1643            .split(',')
1644            .map(|s| s.trim().to_string())
1645            .collect::<Vec<String>>()
1646    } else if let Some(since_ref) = since {
1647        // Get commits since the specified reference
1648        let since_commit = repo.resolve_reference(&since_ref)?;
1649        let head_commit = repo.get_head_commit()?;
1650
1651        // Get commits between since_ref and HEAD
1652        let commits = repo.get_commits_between(
1653            &since_commit.id().to_string(),
1654            &head_commit.id().to_string(),
1655        )?;
1656        commits.into_iter().map(|c| c.id().to_string()).collect()
1657    } else if let Some(hash) = commit {
1658        // Single specific commit
1659        vec![hash]
1660    } else {
1661        // Default: Get all unpushed commits (commits on current branch but not on base branch)
1662        let active_stack = manager.get_active_stack().ok_or_else(|| {
1663            CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
1664        })?;
1665
1666        // Get commits that are on current branch but not on the base branch
1667        let base_branch = &active_stack.base_branch;
1668        let current_branch = repo.get_current_branch()?;
1669
1670        // If we're on the base branch, only include commits that aren't already in the stack
1671        if current_branch == *base_branch {
1672            let mut unpushed = Vec::new();
1673            let head_commit = repo.get_head_commit()?;
1674            let mut current_commit = head_commit;
1675
1676            // Walk back from HEAD until we find a commit that's already in the stack
1677            loop {
1678                let commit_hash = current_commit.id().to_string();
1679                let already_in_stack = active_stack
1680                    .entries
1681                    .iter()
1682                    .any(|entry| entry.commit_hash == commit_hash);
1683
1684                if already_in_stack {
1685                    break;
1686                }
1687
1688                unpushed.push(commit_hash);
1689
1690                // Move to parent commit
1691                if let Some(parent) = current_commit.parents().next() {
1692                    current_commit = parent;
1693                } else {
1694                    break;
1695                }
1696            }
1697
1698            unpushed.reverse(); // Reverse to get chronological order
1699            unpushed
1700        } else {
1701            // Use git's commit range calculation to find commits on current branch but not on base
1702            match repo.get_commits_between(base_branch, &current_branch) {
1703                Ok(commits) => {
1704                    let mut unpushed: Vec<String> =
1705                        commits.into_iter().map(|c| c.id().to_string()).collect();
1706
1707                    // Filter out commits that are already in the stack
1708                    unpushed.retain(|commit_hash| {
1709                        !active_stack
1710                            .entries
1711                            .iter()
1712                            .any(|entry| entry.commit_hash == *commit_hash)
1713                    });
1714
1715                    unpushed.reverse(); // Reverse to get chronological order (oldest first)
1716                    unpushed
1717                }
1718                Err(e) => {
1719                    return Err(CascadeError::branch(format!(
1720                            "Failed to calculate commits between '{base_branch}' and '{current_branch}': {e}. \
1721                             This usually means the branches have diverged or don't share common history."
1722                        )));
1723                }
1724            }
1725        }
1726    };
1727
1728    if commits_to_push.is_empty() {
1729        Output::info("  No commits to push to stack");
1730        return Ok(());
1731    }
1732
1733    // 🛡️ COMMIT CONFIRMATION: Show commits with authors before pushing
1734    let (user_name, user_email) = repo.get_user_info();
1735    let mut has_foreign_commits = false;
1736
1737    Output::section(format!("Commits to push ({})", commits_to_push.len()));
1738
1739    for (i, commit_hash) in commits_to_push.iter().enumerate() {
1740        let commit_obj = repo.get_commit(commit_hash)?;
1741        let author = commit_obj.author();
1742        let author_name = author.name().unwrap_or("unknown").to_string();
1743        let author_email = author.email().unwrap_or("").to_string();
1744        let summary = commit_obj.summary().unwrap_or("(no message)");
1745        let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 8)];
1746
1747        let is_foreign = !matches!(
1748            (&user_name, &user_email),
1749            (Some(ref un), _) if *un == author_name
1750        ) && !matches!(
1751            (&user_name, &user_email),
1752            (_, Some(ref ue)) if *ue == author_email
1753        );
1754
1755        if is_foreign {
1756            has_foreign_commits = true;
1757            Output::numbered_item(
1758                i + 1,
1759                format!("{short_hash} {summary} [{author_name}] ← other author"),
1760            );
1761        } else {
1762            Output::numbered_item(i + 1, format!("{short_hash} {summary} [{author_name}]"));
1763        }
1764    }
1765
1766    if has_foreign_commits {
1767        let foreign_count = commits_to_push
1768            .iter()
1769            .filter(|hash| {
1770                if let Ok(c) = repo.get_commit(hash) {
1771                    let a = c.author();
1772                    let an = a.name().unwrap_or("").to_string();
1773                    let ae = a.email().unwrap_or("").to_string();
1774                    !matches!(&user_name, Some(ref un) if *un == an)
1775                        && !matches!(&user_email, Some(ref ue) if *ue == ae)
1776                } else {
1777                    false
1778                }
1779            })
1780            .count();
1781        Output::warning(format!(
1782            "{} commit(s) are from other authors — these may not be your changes.",
1783            foreign_count
1784        ));
1785    }
1786
1787    // Early return for dry run mode
1788    if dry_run {
1789        Output::tip("Run without --dry-run to actually push these commits.");
1790        return Ok(());
1791    }
1792
1793    // Confirmation prompt (unless --yes)
1794    if !yes {
1795        let default_confirm = !has_foreign_commits;
1796        let should_continue = Confirm::with_theme(&ColorfulTheme::default())
1797            .with_prompt(format!(
1798                "Push {} commit(s) to stack?",
1799                commits_to_push.len()
1800            ))
1801            .default(default_confirm)
1802            .interact()
1803            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1804
1805        if !should_continue {
1806            Output::info("Push cancelled.");
1807            return Ok(());
1808        }
1809    }
1810
1811    // 🛡️ SAFEGUARDS: Analyze commits for merge commits and age checks
1812    analyze_commits_for_safeguards(&commits_to_push, &repo, dry_run).await?;
1813
1814    // Push each commit to the stack
1815    let mut pushed_count = 0;
1816    let mut source_branches = std::collections::HashSet::new();
1817
1818    for (i, commit_hash) in commits_to_push.iter().enumerate() {
1819        let commit_obj = repo.get_commit(commit_hash)?;
1820        let commit_msg = commit_obj.message().unwrap_or("").to_string();
1821
1822        // Check which branch this commit belongs to
1823        let commit_source_branch = repo
1824            .find_branch_containing_commit(commit_hash)
1825            .unwrap_or_else(|_| current_branch.clone());
1826        source_branches.insert(commit_source_branch.clone());
1827
1828        // Generate branch name (use provided branch for first commit, generate for others)
1829        let branch_name = if i == 0 && branch.is_some() {
1830            branch.clone().unwrap()
1831        } else {
1832            // Create a temporary GitRepository for branch name generation
1833            let temp_repo = GitRepository::open(&repo_root)?;
1834            let branch_mgr = crate::git::BranchManager::new(temp_repo);
1835            branch_mgr.generate_branch_name(&commit_msg)
1836        };
1837
1838        // Use provided message for first commit, original message for others
1839        let final_message = if i == 0 && message.is_some() {
1840            message.clone().unwrap()
1841        } else {
1842            commit_msg.clone()
1843        };
1844
1845        let entry_id = manager.push_to_stack(
1846            branch_name.clone(),
1847            commit_hash.clone(),
1848            final_message.clone(),
1849            commit_source_branch.clone(),
1850        )?;
1851        pushed_count += 1;
1852
1853        Output::success(format!(
1854            "Pushed commit {}/{} to stack",
1855            i + 1,
1856            commits_to_push.len()
1857        ));
1858        Output::sub_item(format!(
1859            "Commit: {} ({})",
1860            &commit_hash[..8],
1861            commit_msg.split('\n').next().unwrap_or("")
1862        ));
1863        Output::sub_item(format!("Branch: {branch_name}"));
1864        Output::sub_item(format!("Source: {commit_source_branch}"));
1865        Output::sub_item(format!("Entry ID: {entry_id}"));
1866        println!();
1867    }
1868
1869    // 🚨 SCATTERED COMMIT WARNING
1870    if source_branches.len() > 1 {
1871        Output::warning("Scattered Commit Detection");
1872        Output::sub_item(format!(
1873            "You've pushed commits from {} different Git branches:",
1874            source_branches.len()
1875        ));
1876        for branch in &source_branches {
1877            Output::bullet(branch.to_string());
1878        }
1879
1880        Output::section("This can lead to confusion because:");
1881        Output::bullet("Stack appears sequential but commits are scattered across branches");
1882        Output::bullet("Team members won't know which branch contains which work");
1883        Output::bullet("Branch cleanup becomes unclear after merge");
1884        Output::bullet("Rebase operations become more complex");
1885
1886        Output::tip("Consider consolidating work to a single feature branch:");
1887        Output::bullet("Create a new feature branch: git checkout -b feature/consolidated-work");
1888        Output::bullet("Cherry-pick commits in order: git cherry-pick <commit1> <commit2> ...");
1889        Output::bullet("Delete old scattered branches");
1890        Output::bullet("Push the consolidated branch to your stack");
1891        println!();
1892    }
1893
1894    Output::success(format!(
1895        "Successfully pushed {} commit{} to stack",
1896        pushed_count,
1897        if pushed_count == 1 { "" } else { "s" }
1898    ));
1899
1900    Ok(())
1901}
1902
1903async fn pop_from_stack(keep_branch: bool) -> Result<()> {
1904    let current_dir = env::current_dir()
1905        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1906
1907    let repo_root = find_repository_root(&current_dir)
1908        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1909
1910    let mut manager = StackManager::new(&repo_root)?;
1911    let repo = GitRepository::open(&repo_root)?;
1912
1913    let entry = manager.pop_from_stack()?;
1914
1915    Output::success("Popped commit from stack");
1916    Output::sub_item(format!(
1917        "Commit: {} ({})",
1918        entry.short_hash(),
1919        entry.short_message(50)
1920    ));
1921    Output::sub_item(format!("Branch: {}", entry.branch));
1922
1923    // Delete branch if requested and it's not the current branch
1924    if !keep_branch && entry.branch != repo.get_current_branch()? {
1925        match repo.delete_branch(&entry.branch) {
1926            Ok(_) => Output::sub_item(format!("Deleted branch: {}", entry.branch)),
1927            Err(e) => Output::warning(format!("Could not delete branch {}: {}", entry.branch, e)),
1928        }
1929    }
1930
1931    Ok(())
1932}
1933
1934async fn submit_entry(
1935    entry: Option<usize>,
1936    title: Option<String>,
1937    description: Option<String>,
1938    range: Option<String>,
1939    draft: bool,
1940    open: bool,
1941) -> Result<()> {
1942    let current_dir = env::current_dir()
1943        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1944
1945    let repo_root = find_repository_root(&current_dir)
1946        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1947
1948    let stack_manager = StackManager::new(&repo_root)?;
1949
1950    // Load configuration first
1951    let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
1952    let config_path = config_dir.join("config.json");
1953    let settings = crate::config::Settings::load_from_file(&config_path)?;
1954
1955    // Create the main config structure
1956    let cascade_config = crate::config::CascadeConfig {
1957        bitbucket: Some(settings.bitbucket.clone()),
1958        git: settings.git.clone(),
1959        auth: crate::config::AuthConfig::default(),
1960        cascade: settings.cascade.clone(),
1961    };
1962
1963    // Get the active stack
1964    let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
1965        CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
1966    })?;
1967    let stack_id = active_stack.id;
1968
1969    // Determine which entries to submit
1970    let entries_to_submit = if let Some(range_str) = range {
1971        // Parse range (e.g., "1-3" or "2,4,6")
1972        let mut entries = Vec::new();
1973
1974        if range_str.contains('-') {
1975            // Handle range like "1-3"
1976            let parts: Vec<&str> = range_str.split('-').collect();
1977            if parts.len() != 2 {
1978                return Err(CascadeError::config(
1979                    "Invalid range format. Use 'start-end' (e.g., '1-3')",
1980                ));
1981            }
1982
1983            let start: usize = parts[0]
1984                .parse()
1985                .map_err(|_| CascadeError::config("Invalid start number in range"))?;
1986            let end: usize = parts[1]
1987                .parse()
1988                .map_err(|_| CascadeError::config("Invalid end number in range"))?;
1989
1990            if start == 0
1991                || end == 0
1992                || start > active_stack.entries.len()
1993                || end > active_stack.entries.len()
1994            {
1995                return Err(CascadeError::config(format!(
1996                    "Range out of bounds. Stack has {} entries",
1997                    active_stack.entries.len()
1998                )));
1999            }
2000
2001            for i in start..=end {
2002                entries.push((i, active_stack.entries[i - 1].clone()));
2003            }
2004        } else {
2005            // Handle comma-separated list like "2,4,6"
2006            for entry_str in range_str.split(',') {
2007                let entry_num: usize = entry_str.trim().parse().map_err(|_| {
2008                    CascadeError::config(format!("Invalid entry number: {entry_str}"))
2009                })?;
2010
2011                if entry_num == 0 || entry_num > active_stack.entries.len() {
2012                    return Err(CascadeError::config(format!(
2013                        "Entry {} out of bounds. Stack has {} entries",
2014                        entry_num,
2015                        active_stack.entries.len()
2016                    )));
2017                }
2018
2019                entries.push((entry_num, active_stack.entries[entry_num - 1].clone()));
2020            }
2021        }
2022
2023        entries
2024    } else if let Some(entry_num) = entry {
2025        // Single entry specified
2026        if entry_num == 0 || entry_num > active_stack.entries.len() {
2027            return Err(CascadeError::config(format!(
2028                "Invalid entry number: {}. Stack has {} entries",
2029                entry_num,
2030                active_stack.entries.len()
2031            )));
2032        }
2033        vec![(entry_num, active_stack.entries[entry_num - 1].clone())]
2034    } else {
2035        // Default: Submit all unsubmitted entries
2036        active_stack
2037            .entries
2038            .iter()
2039            .enumerate()
2040            .filter(|(_, entry)| !entry.is_submitted)
2041            .map(|(i, entry)| (i + 1, entry.clone())) // Convert to 1-based indexing
2042            .collect::<Vec<(usize, _)>>()
2043    };
2044
2045    if entries_to_submit.is_empty() {
2046        Output::info("No entries to submit");
2047        return Ok(());
2048    }
2049
2050    // Professional output for submission
2051    Output::section(format!(
2052        "Submitting {} {}",
2053        entries_to_submit.len(),
2054        if entries_to_submit.len() == 1 {
2055            "entry"
2056        } else {
2057            "entries"
2058        }
2059    ));
2060    println!();
2061
2062    // Create a new StackManager for the integration (since the original was moved)
2063    let integration_stack_manager = StackManager::new(&repo_root)?;
2064    let mut integration =
2065        BitbucketIntegration::new(integration_stack_manager, cascade_config.clone())?;
2066
2067    // Submit each entry
2068    let mut submitted_count = 0;
2069    let mut failed_entries = Vec::new();
2070    let mut pr_urls = Vec::new(); // Collect URLs to open
2071    let total_entries = entries_to_submit.len();
2072
2073    for (entry_num, entry_to_submit) in &entries_to_submit {
2074        // Show what we're submitting
2075        let tree_char = if entries_to_submit.len() == 1 {
2076            "→"
2077        } else if entry_num == &entries_to_submit.len() {
2078            "└─"
2079        } else {
2080            "├─"
2081        };
2082        print!(
2083            "   {} Entry {}: {}... ",
2084            tree_char, entry_num, entry_to_submit.branch
2085        );
2086        std::io::Write::flush(&mut std::io::stdout()).ok();
2087
2088        // Use provided title/description only for first entry or single entry submissions
2089        let entry_title = if total_entries == 1 {
2090            title.clone()
2091        } else {
2092            None
2093        };
2094        let entry_description = if total_entries == 1 {
2095            description.clone()
2096        } else {
2097            None
2098        };
2099
2100        match integration
2101            .submit_entry(
2102                &stack_id,
2103                &entry_to_submit.id,
2104                entry_title,
2105                entry_description,
2106                draft,
2107            )
2108            .await
2109        {
2110            Ok(pr) => {
2111                submitted_count += 1;
2112                Output::success(format!("PR #{}", pr.id));
2113                if let Some(url) = pr.web_url() {
2114                    use console::style;
2115                    Output::sub_item(format!(
2116                        "{} {} {}",
2117                        pr.from_ref.display_id,
2118                        style("→").dim(),
2119                        pr.to_ref.display_id
2120                    ));
2121                    Output::sub_item(format!("URL: {}", style(url.clone()).cyan().underlined()));
2122                    pr_urls.push(url); // Collect for opening later
2123                }
2124            }
2125            Err(e) => {
2126                Output::error("Failed");
2127                // Extract clean error message (remove git stderr noise)
2128                let clean_error = if e.to_string().contains("non-fast-forward") {
2129                    "Branch has diverged (was rebased after initial submission). Update to v0.1.41+ to auto force-push.".to_string()
2130                } else if e.to_string().contains("authentication") {
2131                    "Authentication failed. Check your Bitbucket credentials.".to_string()
2132                } else {
2133                    // Extract first meaningful line, skip git hints
2134                    e.to_string()
2135                        .lines()
2136                        .filter(|l| !l.trim().starts_with("hint:") && !l.trim().is_empty())
2137                        .take(1)
2138                        .collect::<Vec<_>>()
2139                        .join(" ")
2140                        .trim()
2141                        .to_string()
2142                };
2143                Output::sub_item(format!("Error: {}", clean_error));
2144                failed_entries.push((*entry_num, clean_error));
2145            }
2146        }
2147    }
2148
2149    println!();
2150
2151    // Update all PR descriptions in the stack if any PRs were created/exist
2152    let has_any_prs = active_stack
2153        .entries
2154        .iter()
2155        .any(|e| e.pull_request_id.is_some());
2156    if has_any_prs && submitted_count > 0 {
2157        match integration.update_all_pr_descriptions(&stack_id).await {
2158            Ok(updated_prs) => {
2159                if !updated_prs.is_empty() {
2160                    Output::sub_item(format!(
2161                        "Updated {} PR description{} with stack hierarchy",
2162                        updated_prs.len(),
2163                        if updated_prs.len() == 1 { "" } else { "s" }
2164                    ));
2165                }
2166            }
2167            Err(e) => {
2168                // Suppress benign 409 "out of date" errors - these happen when PR was just created
2169                // and Bitbucket's version hasn't propagated yet. The PR still gets created successfully.
2170                let error_msg = e.to_string();
2171                if !error_msg.contains("409") && !error_msg.contains("out-of-date") {
2172                    // Only show non-409 errors, and make them concise
2173                    let clean_error = error_msg.lines().next().unwrap_or("Unknown error").trim();
2174                    Output::warning(format!(
2175                        "Could not update some PR descriptions: {}",
2176                        clean_error
2177                    ));
2178                    Output::sub_item(
2179                        "PRs were created successfully - descriptions can be updated manually",
2180                    );
2181                }
2182            }
2183        }
2184    }
2185
2186    // Summary
2187    if failed_entries.is_empty() {
2188        Output::success(format!(
2189            "{} {} submitted successfully!",
2190            submitted_count,
2191            if submitted_count == 1 {
2192                "entry"
2193            } else {
2194                "entries"
2195            }
2196        ));
2197    } else {
2198        println!();
2199        Output::section("Submission Summary");
2200        Output::success(format!("Successful: {submitted_count}"));
2201        Output::error(format!("Failed: {}", failed_entries.len()));
2202
2203        if !failed_entries.is_empty() {
2204            println!();
2205            Output::tip("Retry failed entries:");
2206            for (entry_num, _) in &failed_entries {
2207                Output::bullet(format!("ca stack submit {entry_num}"));
2208            }
2209        }
2210    }
2211
2212    // Open PRs in browser if requested (default: true)
2213    if open && !pr_urls.is_empty() {
2214        println!();
2215        for url in &pr_urls {
2216            if let Err(e) = open::that(url) {
2217                Output::warning(format!("Could not open browser: {}", e));
2218                Output::tip(format!("Open manually: {}", url));
2219            }
2220        }
2221    }
2222
2223    Ok(())
2224}
2225
2226async fn check_stack_status(name: Option<String>) -> Result<()> {
2227    let current_dir = env::current_dir()
2228        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2229
2230    let repo_root = find_repository_root(&current_dir)
2231        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2232
2233    let stack_manager = StackManager::new(&repo_root)?;
2234
2235    // Load configuration
2236    let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2237    let config_path = config_dir.join("config.json");
2238    let settings = crate::config::Settings::load_from_file(&config_path)?;
2239
2240    // Create the main config structure
2241    let cascade_config = crate::config::CascadeConfig {
2242        bitbucket: Some(settings.bitbucket.clone()),
2243        git: settings.git.clone(),
2244        auth: crate::config::AuthConfig::default(),
2245        cascade: settings.cascade.clone(),
2246    };
2247
2248    // Get stack information BEFORE moving stack_manager
2249    let stack = if let Some(name) = name {
2250        stack_manager
2251            .get_stack_by_name(&name)
2252            .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
2253    } else {
2254        stack_manager.get_active_stack().ok_or_else(|| {
2255            CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
2256        })?
2257    };
2258    let stack_id = stack.id;
2259
2260    Output::section(format!("Stack: {}", stack.name));
2261    Output::sub_item(format!("ID: {}", stack.id));
2262    Output::sub_item(format!("Base: {}", stack.base_branch));
2263
2264    if let Some(description) = &stack.description {
2265        Output::sub_item(format!("Description: {description}"));
2266    }
2267
2268    // Create Bitbucket integration (this takes ownership of stack_manager)
2269    let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2270
2271    // Check stack status
2272    match integration.check_stack_status(&stack_id).await {
2273        Ok(status) => {
2274            Output::section("Pull Request Status");
2275            Output::sub_item(format!("Total entries: {}", status.total_entries));
2276            Output::sub_item(format!("Submitted: {}", status.submitted_entries));
2277            Output::sub_item(format!("Open PRs: {}", status.open_prs));
2278            Output::sub_item(format!("Merged PRs: {}", status.merged_prs));
2279            Output::sub_item(format!("Declined PRs: {}", status.declined_prs));
2280            Output::sub_item(format!(
2281                "Completion: {:.1}%",
2282                status.completion_percentage()
2283            ));
2284
2285            if !status.pull_requests.is_empty() {
2286                Output::section("Pull Requests");
2287                for pr in &status.pull_requests {
2288                    use console::style;
2289
2290                    // Color-coded state icons
2291                    let state_icon = match pr.state {
2292                        crate::bitbucket::PullRequestState::Open => style("→").cyan().to_string(),
2293                        crate::bitbucket::PullRequestState::Merged => {
2294                            style("✓").green().to_string()
2295                        }
2296                        crate::bitbucket::PullRequestState::Declined => {
2297                            style("✗").red().to_string()
2298                        }
2299                    };
2300
2301                    // Format: icon PR #123: title (from -> to)
2302                    // Dim the PR number and branch arrows for less visual noise
2303                    Output::bullet(format!(
2304                        "{} PR {}: {} ({} {} {})",
2305                        state_icon,
2306                        style(format!("#{}", pr.id)).dim(),
2307                        pr.title,
2308                        style(&pr.from_ref.display_id).dim(),
2309                        style("→").dim(),
2310                        style(&pr.to_ref.display_id).dim()
2311                    ));
2312
2313                    // Make URL stand out with cyan/blue hyperlink color
2314                    if let Some(url) = pr.web_url() {
2315                        println!("      URL: {}", style(url).cyan().underlined());
2316                    }
2317                }
2318            }
2319        }
2320        Err(e) => {
2321            tracing::debug!("Failed to check stack status: {}", e);
2322            return Err(e);
2323        }
2324    }
2325
2326    Ok(())
2327}
2328
2329async fn list_pull_requests(state: Option<String>, verbose: bool) -> Result<()> {
2330    let current_dir = env::current_dir()
2331        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2332
2333    let repo_root = find_repository_root(&current_dir)
2334        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2335
2336    let stack_manager = StackManager::new(&repo_root)?;
2337
2338    // Load configuration
2339    let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2340    let config_path = config_dir.join("config.json");
2341    let settings = crate::config::Settings::load_from_file(&config_path)?;
2342
2343    // Create the main config structure
2344    let cascade_config = crate::config::CascadeConfig {
2345        bitbucket: Some(settings.bitbucket.clone()),
2346        git: settings.git.clone(),
2347        auth: crate::config::AuthConfig::default(),
2348        cascade: settings.cascade.clone(),
2349    };
2350
2351    // Create Bitbucket integration
2352    let integration = crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
2353
2354    // Parse state filter
2355    let pr_state = if let Some(state_str) = state {
2356        match state_str.to_lowercase().as_str() {
2357            "open" => Some(crate::bitbucket::PullRequestState::Open),
2358            "merged" => Some(crate::bitbucket::PullRequestState::Merged),
2359            "declined" => Some(crate::bitbucket::PullRequestState::Declined),
2360            _ => {
2361                return Err(CascadeError::config(format!(
2362                    "Invalid state '{state_str}'. Use: open, merged, declined"
2363                )))
2364            }
2365        }
2366    } else {
2367        None
2368    };
2369
2370    // Get pull requests
2371    match integration.list_pull_requests(pr_state).await {
2372        Ok(pr_page) => {
2373            if pr_page.values.is_empty() {
2374                Output::info("No pull requests found.");
2375                return Ok(());
2376            }
2377
2378            println!("Pull Requests ({} total):", pr_page.values.len());
2379            for pr in &pr_page.values {
2380                let state_icon = match pr.state {
2381                    crate::bitbucket::PullRequestState::Open => "○",
2382                    crate::bitbucket::PullRequestState::Merged => "✓",
2383                    crate::bitbucket::PullRequestState::Declined => "✗",
2384                };
2385                println!("   {} PR #{}: {}", state_icon, pr.id, pr.title);
2386                if verbose {
2387                    println!(
2388                        "      From: {} -> {}",
2389                        pr.from_ref.display_id, pr.to_ref.display_id
2390                    );
2391                    println!(
2392                        "      Author: {}",
2393                        pr.author
2394                            .user
2395                            .display_name
2396                            .as_deref()
2397                            .unwrap_or(&pr.author.user.name)
2398                    );
2399                    if let Some(url) = pr.web_url() {
2400                        println!("      URL: {url}");
2401                    }
2402                    if let Some(desc) = &pr.description {
2403                        if !desc.is_empty() {
2404                            println!("      Description: {desc}");
2405                        }
2406                    }
2407                    println!();
2408                }
2409            }
2410
2411            if !verbose {
2412                println!("\nUse --verbose for more details");
2413            }
2414        }
2415        Err(e) => {
2416            warn!("Failed to list pull requests: {}", e);
2417            return Err(e);
2418        }
2419    }
2420
2421    Ok(())
2422}
2423
2424async fn check_stack(_force: bool) -> Result<()> {
2425    let current_dir = env::current_dir()
2426        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2427
2428    let repo_root = find_repository_root(&current_dir)
2429        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2430
2431    let mut manager = StackManager::new(&repo_root)?;
2432
2433    let active_stack = manager
2434        .get_active_stack()
2435        .ok_or_else(|| CascadeError::config("No active stack"))?;
2436    let stack_id = active_stack.id;
2437
2438    manager.sync_stack(&stack_id)?;
2439
2440    Output::success("Stack check completed successfully");
2441
2442    Ok(())
2443}
2444
2445pub async fn continue_sync() -> Result<()> {
2446    let current_dir = env::current_dir()
2447        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2448
2449    let repo_root = find_repository_root(&current_dir)?;
2450
2451    Output::section("Continuing sync from where it left off");
2452    println!();
2453
2454    // Check if there's an in-progress cherry-pick
2455    let cherry_pick_head = crate::git::resolve_git_dir(&repo_root)?.join("CHERRY_PICK_HEAD");
2456    if !cherry_pick_head.exists() {
2457        return Err(CascadeError::config(
2458            "No in-progress cherry-pick found. Nothing to continue.\n\n\
2459             Use 'ca sync' to start a new sync."
2460                .to_string(),
2461        ));
2462    }
2463
2464    Output::info("Staging all resolved files");
2465
2466    // Stage all resolved files
2467    std::process::Command::new("git")
2468        .args(["add", "-A"])
2469        .current_dir(&repo_root)
2470        .output()
2471        .map_err(CascadeError::Io)?;
2472
2473    let sync_state = crate::stack::SyncState::load(&repo_root).ok();
2474
2475    Output::info("Continuing cherry-pick");
2476
2477    // Continue the cherry-pick
2478    let continue_output = std::process::Command::new("git")
2479        .args(["cherry-pick", "--continue"])
2480        .current_dir(&repo_root)
2481        .output()
2482        .map_err(CascadeError::Io)?;
2483
2484    if !continue_output.status.success() {
2485        let stderr = String::from_utf8_lossy(&continue_output.stderr);
2486        return Err(CascadeError::Branch(format!(
2487            "Failed to continue cherry-pick: {}\n\n\
2488             Make sure all conflicts are resolved.",
2489            stderr
2490        )));
2491    }
2492
2493    Output::success("Cherry-pick continued successfully");
2494    println!();
2495
2496    // Now we need to:
2497    // 1. Figure out which stack branch this temp branch belongs to
2498    // 2. Force-push the temp branch to the actual stack branch
2499    // 3. Checkout to the working branch
2500    // 4. Continue with sync_stack() to process remaining entries
2501
2502    let git_repo = crate::git::GitRepository::open(&repo_root)?;
2503    let current_branch = git_repo.get_current_branch()?;
2504
2505    let stack_branch = if let Some(state) = &sync_state {
2506        if !state.current_entry_branch.is_empty() {
2507            if !state.current_temp_branch.is_empty() && current_branch != state.current_temp_branch
2508            {
2509                tracing::warn!(
2510                    "Sync state temp branch '{}' differs from current branch '{}'",
2511                    state.current_temp_branch,
2512                    current_branch
2513                );
2514            }
2515            state.current_entry_branch.clone()
2516        } else if let Some(idx) = current_branch.rfind("-temp-") {
2517            current_branch[..idx].to_string()
2518        } else {
2519            return Err(CascadeError::config(format!(
2520                "Current branch '{}' doesn't appear to be a temp branch created by cascade.\n\
2521                 Expected format: <branch>-temp-<timestamp>",
2522                current_branch
2523            )));
2524        }
2525    } else if let Some(idx) = current_branch.rfind("-temp-") {
2526        current_branch[..idx].to_string()
2527    } else {
2528        return Err(CascadeError::config(format!(
2529            "Current branch '{}' doesn't appear to be a temp branch created by cascade.\n\
2530             Expected format: <branch>-temp-<timestamp>",
2531            current_branch
2532        )));
2533    };
2534
2535    Output::info(format!("Updating stack branch: {}", stack_branch));
2536
2537    // Force-push temp branch to stack branch
2538    let output = std::process::Command::new("git")
2539        .args(["branch", "-f", &stack_branch])
2540        .current_dir(&repo_root)
2541        .output()
2542        .map_err(CascadeError::Io)?;
2543
2544    if !output.status.success() {
2545        let stderr = String::from_utf8_lossy(&output.stderr);
2546        return Err(CascadeError::validation(format!(
2547            "Failed to update branch '{}': {}\n\n\
2548            This could be due to:\n\
2549            • Git lock file (.git/index.lock or .git/refs/heads/{}.lock)\n\
2550            • Insufficient permissions\n\
2551            • Branch is checked out in another worktree\n\n\
2552            Recovery:\n\
2553            1. Check for lock files: find .git -name '*.lock'\n\
2554            2. Remove stale lock files if safe\n\
2555            3. Run 'ca sync' to retry",
2556            stack_branch,
2557            stderr.trim(),
2558            stack_branch
2559        )));
2560    }
2561
2562    // Load stack to get working branch and update metadata
2563    let mut manager = crate::stack::StackManager::new(&repo_root)?;
2564
2565    // CRITICAL: Update the stack entry's commit hash to the new rebased commit
2566    // This prevents sync_stack() from thinking the working branch has "untracked" commits
2567    let new_commit_hash = git_repo.get_branch_head(&stack_branch)?;
2568
2569    let (stack_id, entry_id_opt, working_branch) = if let Some(state) = &sync_state {
2570        let stack_uuid = Uuid::parse_str(&state.stack_id)
2571            .map_err(|e| CascadeError::config(format!("Invalid stack ID in sync state: {e}")))?;
2572
2573        let stack_snapshot = manager
2574            .get_stack(&stack_uuid)
2575            .cloned()
2576            .ok_or_else(|| CascadeError::config("Stack not found in sync state".to_string()))?;
2577
2578        let working_branch = stack_snapshot
2579            .working_branch
2580            .clone()
2581            .ok_or_else(|| CascadeError::config("Stack has no working branch".to_string()))?;
2582
2583        let entry_id = if !state.current_entry_id.is_empty() {
2584            Uuid::parse_str(&state.current_entry_id).ok()
2585        } else {
2586            stack_snapshot
2587                .entries
2588                .iter()
2589                .find(|e| e.branch == stack_branch)
2590                .map(|e| e.id)
2591        };
2592
2593        (stack_uuid, entry_id, working_branch)
2594    } else {
2595        let active_stack = manager
2596            .get_active_stack()
2597            .ok_or_else(|| CascadeError::config("No active stack found"))?;
2598
2599        let entry_id = active_stack
2600            .entries
2601            .iter()
2602            .find(|e| e.branch == stack_branch)
2603            .map(|e| e.id);
2604
2605        let working_branch = active_stack
2606            .working_branch
2607            .as_ref()
2608            .ok_or_else(|| CascadeError::config("Active stack has no working branch"))?
2609            .clone();
2610
2611        (active_stack.id, entry_id, working_branch)
2612    };
2613
2614    // Now we can mutably borrow manager to update the entry
2615    let entry_id = entry_id_opt.ok_or_else(|| {
2616        CascadeError::config(format!(
2617            "Could not find stack entry for branch '{}'",
2618            stack_branch
2619        ))
2620    })?;
2621
2622    let stack = manager
2623        .get_stack_mut(&stack_id)
2624        .ok_or_else(|| CascadeError::config("Could not get mutable stack reference"))?;
2625
2626    stack
2627        .update_entry_commit_hash(&entry_id, new_commit_hash.clone())
2628        .map_err(CascadeError::config)?;
2629
2630    manager.save_to_disk()?;
2631
2632    // Get the top of the stack to update the working branch
2633    let top_commit = {
2634        let active_stack = manager
2635            .get_active_stack()
2636            .ok_or_else(|| CascadeError::config("No active stack found"))?;
2637
2638        if let Some(last_entry) = active_stack.entries.last() {
2639            git_repo.get_branch_head(&last_entry.branch)?
2640        } else {
2641            new_commit_hash.clone()
2642        }
2643    };
2644
2645    Output::info(format!(
2646        "Checking out to working branch: {}",
2647        working_branch
2648    ));
2649
2650    // Checkout to working branch
2651    git_repo.checkout_branch_unsafe(&working_branch)?;
2652
2653    // Update working branch to point to the top of the rebased stack
2654    // In the continue_sync() context, we KNOW we just finished rebasing, so the
2655    // working branch's old commits are pre-rebase versions that should be replaced.
2656    // We unconditionally update here because:
2657    // 1. The user just resolved conflicts and continued the rebase
2658    // 2. The stack entry metadata has been updated to the new rebased commits
2659    // 3. Any "old" commits on the working branch are the pre-rebase versions
2660    // 4. The subsequent sync_stack() call will do final safety checks
2661    if let Ok(working_head) = git_repo.get_branch_head(&working_branch) {
2662        if working_head != top_commit {
2663            git_repo.update_branch_to_commit(&working_branch, &top_commit)?;
2664
2665            // Reset the working tree to the updated commit so the branch is clean
2666            git_repo.reset_to_head()?;
2667        }
2668    }
2669
2670    if sync_state.is_some() {
2671        crate::stack::SyncState::delete(&repo_root)?;
2672    }
2673
2674    println!();
2675    Output::info("Resuming sync to complete the rebase...");
2676    println!();
2677
2678    // Continue with the full sync to process remaining entries
2679    sync_stack(false, false, false).await
2680}
2681
2682pub async fn abort_sync() -> Result<()> {
2683    let current_dir = env::current_dir()
2684        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2685
2686    let repo_root = find_repository_root(&current_dir)?;
2687
2688    Output::section("Aborting sync");
2689    println!();
2690
2691    // Check if there's an in-progress cherry-pick
2692    let cherry_pick_head = crate::git::resolve_git_dir(&repo_root)?.join("CHERRY_PICK_HEAD");
2693    if !cherry_pick_head.exists() {
2694        return Err(CascadeError::config(
2695            "No in-progress cherry-pick found. Nothing to abort.\n\n\
2696             The sync may have already completed or been aborted."
2697                .to_string(),
2698        ));
2699    }
2700
2701    Output::info("Aborting cherry-pick");
2702
2703    // Abort the cherry-pick with CASCADE_SKIP_HOOKS to avoid hook interference
2704    let abort_output = std::process::Command::new("git")
2705        .args(["cherry-pick", "--abort"])
2706        .env("CASCADE_SKIP_HOOKS", "1")
2707        .current_dir(&repo_root)
2708        .output()
2709        .map_err(CascadeError::Io)?;
2710
2711    if !abort_output.status.success() {
2712        let stderr = String::from_utf8_lossy(&abort_output.stderr);
2713        return Err(CascadeError::Branch(format!(
2714            "Failed to abort cherry-pick: {}",
2715            stderr
2716        )));
2717    }
2718
2719    Output::success("Cherry-pick aborted");
2720
2721    // Load sync state if it exists to clean up temp branches
2722    let git_repo = crate::git::GitRepository::open(&repo_root)?;
2723
2724    if let Ok(state) = crate::stack::SyncState::load(&repo_root) {
2725        println!();
2726        Output::info("Cleaning up temporary branches");
2727
2728        // Clean up all temp branches
2729        for temp_branch in &state.temp_branches {
2730            if let Err(e) = git_repo.delete_branch_unsafe(temp_branch) {
2731                tracing::warn!("Could not delete temp branch '{}': {}", temp_branch, e);
2732            }
2733        }
2734
2735        // Return to original branch
2736        Output::info(format!(
2737            "Returning to original branch: {}",
2738            state.original_branch
2739        ));
2740        if let Err(e) = git_repo.checkout_branch_unsafe(&state.original_branch) {
2741            // Fall back to base branch if original branch checkout fails
2742            tracing::warn!("Could not checkout original branch: {}", e);
2743            if let Err(e2) = git_repo.checkout_branch_unsafe(&state.target_base) {
2744                tracing::warn!("Could not checkout base branch: {}", e2);
2745            }
2746        }
2747
2748        // Delete sync state file
2749        crate::stack::SyncState::delete(&repo_root)?;
2750    } else {
2751        // No state file - try to figure out where to go
2752        let current_branch = git_repo.get_current_branch()?;
2753
2754        // If on a temp branch, try to parse and return to original
2755        if let Some(idx) = current_branch.rfind("-temp-") {
2756            let original_branch = &current_branch[..idx];
2757            Output::info(format!("Returning to branch: {}", original_branch));
2758
2759            if let Err(e) = git_repo.checkout_branch_unsafe(original_branch) {
2760                tracing::warn!("Could not checkout original branch: {}", e);
2761            }
2762
2763            // Try to delete the temp branch
2764            if let Err(e) = git_repo.delete_branch_unsafe(&current_branch) {
2765                tracing::warn!("Could not delete temp branch: {}", e);
2766            }
2767        }
2768    }
2769
2770    println!();
2771    Output::success("Sync aborted");
2772    println!();
2773    Output::tip("You can start a fresh sync with: ca sync");
2774
2775    Ok(())
2776}
2777
2778async fn sync_stack(force: bool, cleanup: bool, interactive: bool) -> Result<()> {
2779    let current_dir = env::current_dir()
2780        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
2781
2782    let repo_root = find_repository_root(&current_dir)
2783        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
2784
2785    let mut stack_manager = StackManager::new(&repo_root)?;
2786
2787    // Exit edit mode if active (sync will invalidate commit SHAs)
2788    // TODO: Add error recovery to restore edit mode if sync fails
2789    if stack_manager.is_in_edit_mode() {
2790        debug!("Exiting edit mode before sync (commit SHAs will change)");
2791        stack_manager.exit_edit_mode()?;
2792    }
2793
2794    let git_repo = GitRepository::open(&repo_root)?;
2795
2796    if git_repo.is_dirty()? {
2797        return Err(CascadeError::branch(
2798            "Working tree has uncommitted changes. Commit or stash them before running 'ca sync'."
2799                .to_string(),
2800        ));
2801    }
2802
2803    // Get active stack
2804    let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
2805        CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
2806    })?;
2807
2808    let base_branch = active_stack.base_branch.clone();
2809    let _stack_name = active_stack.name.clone();
2810
2811    // Save the original working branch before any checkouts
2812    let original_branch = git_repo.get_current_branch().ok();
2813
2814    // Sync starts silently - user will see the rebase output
2815
2816    // Step 1: Update base branch ref from remote (worktree-safe, no checkout needed)
2817    match git_repo.update_local_branch_from_remote(&base_branch) {
2818        Ok(_) => {}
2819        Err(e) => {
2820            if force {
2821                Output::warning(format!(
2822                    "Failed to update base branch: {e} (continuing due to --force)"
2823                ));
2824            } else {
2825                let err_str = e.to_string();
2826                let is_locked = err_str.contains("Locked") || err_str.contains("index is locked");
2827                if is_locked {
2828                    Output::error(
2829                        "Git index is locked by another process (e.g. an IDE or Git GUI)",
2830                    );
2831                    Output::tip("Close it and re-run 'ca sync'");
2832                    return Err(CascadeError::branch(
2833                        "Git index locked — close the other process and retry".to_string(),
2834                    ));
2835                } else {
2836                    Output::error(format!("Failed to update base branch '{base_branch}': {e}"));
2837                    Output::tip("Use --force to skip update and continue with local state");
2838                    return Err(CascadeError::branch(format!(
2839                        "Failed to update '{base_branch}' from remote: {e}. Use --force to continue anyway."
2840                    )));
2841                }
2842            }
2843        }
2844    }
2845
2846    // Step 2: Reconcile metadata with current Git state before checking integrity
2847    // This fixes stale metadata from previous bugs or interrupted operations
2848    let mut updated_stack_manager = StackManager::new(&repo_root)?;
2849    let stack_id = active_stack.id;
2850
2851    // Update entry commit hashes to match current branch HEADs
2852    // This prevents false "branch modification" errors from stale metadata
2853    if let Some(stack) = updated_stack_manager.get_stack_mut(&stack_id) {
2854        let mut updates = Vec::new();
2855        for entry in &stack.entries {
2856            if let Ok(current_commit) = git_repo.get_branch_head(&entry.branch) {
2857                if entry.commit_hash != current_commit {
2858                    let is_safe_descendant = match git_repo.commit_exists(&entry.commit_hash) {
2859                        Ok(true) => {
2860                            match git_repo.is_descendant_of(&current_commit, &entry.commit_hash) {
2861                                Ok(result) => result,
2862                                Err(e) => {
2863                                    warn!(
2864                                    "Cannot verify ancestry for '{}': {} - treating as unsafe to prevent potential data loss",
2865                                    entry.branch, e
2866                                );
2867                                    false
2868                                }
2869                            }
2870                        }
2871                        Ok(false) => {
2872                            debug!(
2873                                "Recorded commit {} for '{}' no longer exists in repository",
2874                                &entry.commit_hash[..8],
2875                                entry.branch
2876                            );
2877                            false
2878                        }
2879                        Err(e) => {
2880                            warn!(
2881                                "Cannot verify commit existence for '{}': {} - treating as unsafe to prevent potential data loss",
2882                                entry.branch, e
2883                            );
2884                            false
2885                        }
2886                    };
2887
2888                    if is_safe_descendant {
2889                        debug!(
2890                            "Reconciling entry '{}': updating hash from {} to {} (current branch HEAD)",
2891                            entry.branch,
2892                            &entry.commit_hash[..8],
2893                            &current_commit[..8]
2894                        );
2895                        updates.push((entry.id, current_commit));
2896                    } else {
2897                        warn!(
2898                            "Skipped automatic reconciliation for entry '{}' because local HEAD ({}) does not descend from recorded commit ({})",
2899                            entry.branch,
2900                            &current_commit[..8],
2901                            &entry.commit_hash[..8]
2902                        );
2903                        // This commonly happens after 'ca entry amend' without --restack
2904                        // The amended commit replaces the old one (not a descendant)
2905                    }
2906                }
2907            }
2908        }
2909
2910        // Apply updates using safe wrapper
2911        for (entry_id, new_hash) in updates {
2912            stack
2913                .update_entry_commit_hash(&entry_id, new_hash)
2914                .map_err(CascadeError::config)?;
2915        }
2916
2917        // Save reconciled metadata
2918        updated_stack_manager.save_to_disk()?;
2919    }
2920
2921    // Step 2b: Detect merged PRs from Bitbucket before rebase
2922    // This ensures the rebase skips entries that were merged remotely (e.g. via ca land)
2923    {
2924        let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2925        let config_path = config_dir.join("config.json");
2926        if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
2927            let cascade_config = crate::config::CascadeConfig {
2928                bitbucket: Some(settings.bitbucket.clone()),
2929                git: settings.git.clone(),
2930                auth: crate::config::AuthConfig::default(),
2931                cascade: settings.cascade.clone(),
2932            };
2933            if let Ok(mut integration) = crate::bitbucket::BitbucketIntegration::new(
2934                StackManager::new(&repo_root)?,
2935                cascade_config,
2936            ) {
2937                // This queries Bitbucket and marks merged entries via set_entry_merged()
2938                let _ = integration.check_enhanced_stack_status(&stack_id).await;
2939                // Reload manager to pick up the merged flags
2940                updated_stack_manager = StackManager::new(&repo_root)?;
2941            }
2942        }
2943    }
2944
2945    match updated_stack_manager.sync_stack(&stack_id) {
2946        Ok(_) => {
2947            // Check the updated status
2948            if let Some(updated_stack) = updated_stack_manager.get_stack(&stack_id) {
2949                // Check for empty stack first
2950                if updated_stack.entries.is_empty() {
2951                    println!(); // Spacing
2952                    Output::info("Stack has no entries yet");
2953                    Output::tip("Use 'ca push' to add commits to this stack");
2954                    return Ok(());
2955                }
2956
2957                match &updated_stack.status {
2958                    crate::stack::StackStatus::NeedsSync => {
2959                        // Load configuration for Bitbucket integration
2960                        let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
2961                        let config_path = config_dir.join("config.json");
2962                        let settings = crate::config::Settings::load_from_file(&config_path)?;
2963
2964                        let cascade_config = crate::config::CascadeConfig {
2965                            bitbucket: Some(settings.bitbucket.clone()),
2966                            git: settings.git.clone(),
2967                            auth: crate::config::AuthConfig::default(),
2968                            cascade: settings.cascade.clone(),
2969                        };
2970
2971                        println!(); // Spacing
2972
2973                        // Use the existing rebase system with force-push strategy
2974                        // This preserves PR history by force-pushing to original branches
2975                        let options = crate::stack::RebaseOptions {
2976                            strategy: crate::stack::RebaseStrategy::ForcePush,
2977                            interactive,
2978                            target_base: Some(base_branch.clone()),
2979                            preserve_merges: true,
2980                            auto_resolve: !interactive, // Re-enabled with safety checks
2981                            max_retries: 3,
2982                            skip_pull: Some(true), // Skip pull since we already pulled above
2983                            original_working_branch: original_branch.clone(), // Pass the saved working branch
2984                        };
2985
2986                        let mut rebase_manager = crate::stack::RebaseManager::new(
2987                            updated_stack_manager,
2988                            git_repo,
2989                            options,
2990                        );
2991
2992                        // Rebase all entries (static output)
2993                        let rebase_result = rebase_manager.rebase_stack(&stack_id);
2994
2995                        match rebase_result {
2996                            Ok(result) => {
2997                                if !result.branch_mapping.is_empty() {
2998                                    // Update PRs if Bitbucket is configured
2999                                    if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
3000                                        let integration_stack_manager =
3001                                            StackManager::new(&repo_root)?;
3002                                        match crate::bitbucket::BitbucketIntegration::new(
3003                                            integration_stack_manager,
3004                                            cascade_config,
3005                                        ) {
3006                                            Ok(mut integration) => {
3007                                                let pr_result = integration
3008                                                    .update_prs_after_rebase(
3009                                                        &stack_id,
3010                                                        &result.branch_mapping,
3011                                                    )
3012                                                    .await;
3013
3014                                                match pr_result {
3015                                                    Ok(updated_prs) => {
3016                                                        if !updated_prs.is_empty() {
3017                                                            Output::success(format!(
3018                                                                "Updated {} pull request{}",
3019                                                                updated_prs.len(),
3020                                                                if updated_prs.len() == 1 {
3021                                                                    ""
3022                                                                } else {
3023                                                                    "s"
3024                                                                }
3025                                                            ));
3026                                                        }
3027                                                    }
3028                                                    Err(e) => {
3029                                                        Output::warning(format!(
3030                                                            "Failed to update pull requests: {e}"
3031                                                        ));
3032                                                    }
3033                                                }
3034                                            }
3035                                            Err(e) => {
3036                                                tracing::debug!(
3037                                                    "Skipping PR updates (Bitbucket not configured): {e}"
3038                                                );
3039                                            }
3040                                        }
3041                                    }
3042                                }
3043                            }
3044                            Err(e) => {
3045                                // Error already contains instructions, just propagate it
3046                                return Err(e);
3047                            }
3048                        }
3049                    }
3050                    crate::stack::StackStatus::Clean => {
3051                        // Already up to date - silent success
3052                    }
3053                    other => {
3054                        // Only show unexpected status
3055                        Output::info(format!("Stack status: {other:?}"));
3056                    }
3057                }
3058            }
3059        }
3060        Err(e) => {
3061            if force {
3062                Output::warning(format!(
3063                    "Failed to check stack status: {e} (continuing due to --force)"
3064                ));
3065            } else {
3066                if let Some(ref branch) = original_branch {
3067                    if branch != &base_branch {
3068                        if let Err(restore_err) = git_repo.checkout_branch_silent(branch) {
3069                            Output::warning(format!(
3070                                "Could not restore original branch '{}': {}",
3071                                branch, restore_err
3072                            ));
3073                        }
3074                    }
3075                }
3076                return Err(e);
3077            }
3078        }
3079    }
3080
3081    // Step 3: Cleanup merged branches (optional) - only if explicitly requested
3082    if cleanup {
3083        let git_repo_for_cleanup = GitRepository::open(&repo_root)?;
3084        match perform_simple_cleanup(&stack_manager, &git_repo_for_cleanup, false).await {
3085            Ok(result) => {
3086                if result.total_candidates > 0 {
3087                    Output::section("Cleanup Summary");
3088                    if !result.cleaned_branches.is_empty() {
3089                        Output::success(format!(
3090                            "Cleaned up {} merged branches",
3091                            result.cleaned_branches.len()
3092                        ));
3093                        for branch in &result.cleaned_branches {
3094                            Output::sub_item(format!("🗑️  Deleted: {branch}"));
3095                        }
3096                    }
3097                    if !result.skipped_branches.is_empty() {
3098                        Output::sub_item(format!(
3099                            "Skipped {} branches",
3100                            result.skipped_branches.len()
3101                        ));
3102                    }
3103                    if !result.failed_branches.is_empty() {
3104                        for (branch, error) in &result.failed_branches {
3105                            Output::warning(format!("Failed to clean up {branch}: {error}"));
3106                        }
3107                    }
3108                }
3109            }
3110            Err(e) => {
3111                Output::warning(format!("Branch cleanup failed: {e}"));
3112            }
3113        }
3114    }
3115
3116    // NOTE: Don't checkout the original branch here!
3117    // The rebase_with_force_push() function already returned us to the working branch
3118    // using checkout_branch_unsafe(). If we try to checkout again with the safe version,
3119    // it will see the rebased working tree and think there are staged changes
3120    // (because the working branch HEAD was updated during rebase but we're still on the old tree).
3121    // Trust that the rebase left us in the correct state.
3122
3123    Output::success("Sync completed successfully!");
3124
3125    Ok(())
3126}
3127
3128async fn rebase_stack(
3129    interactive: bool,
3130    onto: Option<String>,
3131    strategy: Option<RebaseStrategyArg>,
3132) -> Result<()> {
3133    let current_dir = env::current_dir()
3134        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3135
3136    let repo_root = find_repository_root(&current_dir)
3137        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3138
3139    let stack_manager = StackManager::new(&repo_root)?;
3140    let git_repo = GitRepository::open(&repo_root)?;
3141
3142    // Load configuration for potential Bitbucket integration
3143    let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
3144    let config_path = config_dir.join("config.json");
3145    let settings = crate::config::Settings::load_from_file(&config_path)?;
3146
3147    // Create the main config structure
3148    let cascade_config = crate::config::CascadeConfig {
3149        bitbucket: Some(settings.bitbucket.clone()),
3150        git: settings.git.clone(),
3151        auth: crate::config::AuthConfig::default(),
3152        cascade: settings.cascade.clone(),
3153    };
3154
3155    // Get active stack
3156    let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
3157        CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
3158    })?;
3159    let stack_id = active_stack.id;
3160
3161    let active_stack = stack_manager
3162        .get_stack(&stack_id)
3163        .ok_or_else(|| CascadeError::config("Active stack not found"))?
3164        .clone();
3165
3166    if active_stack.entries.is_empty() {
3167        Output::info("Stack is empty. Nothing to rebase.");
3168        return Ok(());
3169    }
3170
3171    // Determine rebase strategy (force-push is the industry standard for stacked diffs)
3172    let rebase_strategy = if let Some(cli_strategy) = strategy {
3173        match cli_strategy {
3174            RebaseStrategyArg::ForcePush => crate::stack::RebaseStrategy::ForcePush,
3175            RebaseStrategyArg::Interactive => crate::stack::RebaseStrategy::Interactive,
3176        }
3177    } else {
3178        // Default to force-push (industry standard for preserving PR history)
3179        crate::stack::RebaseStrategy::ForcePush
3180    };
3181
3182    // Save original branch before any operations
3183    let original_branch = git_repo.get_current_branch().ok();
3184
3185    debug!("   Strategy: {:?}", rebase_strategy);
3186    debug!("   Interactive: {}", interactive);
3187    debug!("   Target base: {:?}", onto);
3188    debug!("   Entries: {}", active_stack.entries.len());
3189
3190    println!(); // Spacing
3191
3192    // Start spinner for rebase
3193    let rebase_spinner = crate::utils::spinner::Spinner::new_with_output_below(format!(
3194        "Rebasing stack: {}",
3195        active_stack.name
3196    ));
3197
3198    // Create rebase options
3199    let options = crate::stack::RebaseOptions {
3200        strategy: rebase_strategy.clone(),
3201        interactive,
3202        target_base: onto,
3203        preserve_merges: true,
3204        auto_resolve: !interactive, // Re-enabled with safety checks
3205        max_retries: 3,
3206        skip_pull: None, // Normal rebase should pull latest changes
3207        original_working_branch: original_branch,
3208    };
3209
3210    // Check if there's already a rebase in progress
3211    let mut rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3212
3213    if rebase_manager.is_rebase_in_progress() {
3214        Output::warning("Rebase already in progress!");
3215        Output::tip("Use 'git status' to check the current state");
3216        Output::next_steps(&[
3217            "Run 'ca stack continue-rebase' to continue",
3218            "Run 'ca stack abort-rebase' to abort",
3219        ]);
3220        rebase_spinner.stop();
3221        return Ok(());
3222    }
3223
3224    // Perform the rebase
3225    let rebase_result = rebase_manager.rebase_stack(&stack_id);
3226
3227    // Stop spinner before showing results
3228    rebase_spinner.stop();
3229    println!(); // Spacing
3230
3231    match rebase_result {
3232        Ok(result) => {
3233            Output::success("Rebase completed!");
3234            Output::sub_item(result.get_summary());
3235
3236            if result.has_conflicts() {
3237                Output::warning(format!(
3238                    "{} conflicts were resolved",
3239                    result.conflicts.len()
3240                ));
3241                for conflict in &result.conflicts {
3242                    Output::bullet(&conflict[..8.min(conflict.len())]);
3243                }
3244            }
3245
3246            if !result.branch_mapping.is_empty() {
3247                Output::section("Branch mapping");
3248                for (old, new) in &result.branch_mapping {
3249                    Output::bullet(format!("{old} -> {new}"));
3250                }
3251
3252                // Handle PR updates if enabled
3253                if let Some(ref _bitbucket_config) = cascade_config.bitbucket {
3254                    // Create a new StackManager for the integration (since the original was moved)
3255                    let integration_stack_manager = StackManager::new(&repo_root)?;
3256                    let mut integration = BitbucketIntegration::new(
3257                        integration_stack_manager,
3258                        cascade_config.clone(),
3259                    )?;
3260
3261                    match integration
3262                        .update_prs_after_rebase(&stack_id, &result.branch_mapping)
3263                        .await
3264                    {
3265                        Ok(updated_prs) => {
3266                            if !updated_prs.is_empty() {
3267                                println!("   🔄 Preserved pull request history:");
3268                                for pr_update in updated_prs {
3269                                    println!("      ✅ {pr_update}");
3270                                }
3271                            }
3272                        }
3273                        Err(e) => {
3274                            Output::warning(format!("Failed to update pull requests: {e}"));
3275                            Output::sub_item("You may need to manually update PRs in Bitbucket");
3276                        }
3277                    }
3278                }
3279            }
3280
3281            Output::success(format!(
3282                "{} commits successfully rebased",
3283                result.success_count()
3284            ));
3285
3286            // Show next steps
3287            if matches!(rebase_strategy, crate::stack::RebaseStrategy::ForcePush) {
3288                println!();
3289                Output::section("Next steps");
3290                if !result.branch_mapping.is_empty() {
3291                    Output::numbered_item(1, "Branches have been rebased and force-pushed");
3292                    Output::numbered_item(
3293                        2,
3294                        "Pull requests updated automatically (history preserved)",
3295                    );
3296                    Output::numbered_item(3, "Review the updated PRs in Bitbucket");
3297                    Output::numbered_item(4, "Test your changes");
3298                } else {
3299                    println!("   1. Review the rebased stack");
3300                    println!("   2. Test your changes");
3301                    println!("   3. Submit new pull requests with 'ca stack submit'");
3302                }
3303            }
3304        }
3305        Err(e) => {
3306            warn!("❌ Rebase failed: {}", e);
3307            Output::tip(" Tips for resolving rebase issues:");
3308            println!("   - Check for uncommitted changes with 'git status'");
3309            println!("   - Ensure base branch is up to date");
3310            println!("   - Try interactive mode: 'ca stack rebase --interactive'");
3311            return Err(e);
3312        }
3313    }
3314
3315    Ok(())
3316}
3317
3318pub async fn continue_rebase() -> Result<()> {
3319    let current_dir = env::current_dir()
3320        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3321
3322    let repo_root = find_repository_root(&current_dir)
3323        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3324
3325    let stack_manager = StackManager::new(&repo_root)?;
3326    let git_repo = crate::git::GitRepository::open(&repo_root)?;
3327    let options = crate::stack::RebaseOptions::default();
3328    let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3329
3330    if !rebase_manager.is_rebase_in_progress() {
3331        Output::info("  No rebase in progress");
3332        return Ok(());
3333    }
3334
3335    println!(" Continuing rebase...");
3336    match rebase_manager.continue_rebase() {
3337        Ok(_) => {
3338            Output::success(" Rebase continued successfully");
3339            println!("   Check 'ca stack rebase-status' for current state");
3340        }
3341        Err(e) => {
3342            warn!("❌ Failed to continue rebase: {}", e);
3343            Output::tip(" You may need to resolve conflicts first:");
3344            println!("   1. Edit conflicted files");
3345            println!("   2. Stage resolved files with 'git add'");
3346            println!("   3. Run 'ca stack continue-rebase' again");
3347        }
3348    }
3349
3350    Ok(())
3351}
3352
3353pub async fn abort_rebase() -> Result<()> {
3354    let current_dir = env::current_dir()
3355        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3356
3357    let repo_root = find_repository_root(&current_dir)
3358        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3359
3360    let stack_manager = StackManager::new(&repo_root)?;
3361    let git_repo = crate::git::GitRepository::open(&repo_root)?;
3362    let options = crate::stack::RebaseOptions::default();
3363    let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
3364
3365    if !rebase_manager.is_rebase_in_progress() {
3366        Output::info("  No rebase in progress");
3367        return Ok(());
3368    }
3369
3370    Output::warning("Aborting rebase...");
3371    match rebase_manager.abort_rebase() {
3372        Ok(_) => {
3373            Output::success(" Rebase aborted successfully");
3374            println!("   Repository restored to pre-rebase state");
3375        }
3376        Err(e) => {
3377            warn!("❌ Failed to abort rebase: {}", e);
3378            println!("⚠️  You may need to manually clean up the repository state");
3379        }
3380    }
3381
3382    Ok(())
3383}
3384
3385async fn rebase_status() -> Result<()> {
3386    let current_dir = env::current_dir()
3387        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3388
3389    let repo_root = find_repository_root(&current_dir)
3390        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3391
3392    let stack_manager = StackManager::new(&repo_root)?;
3393    let git_repo = crate::git::GitRepository::open(&repo_root)?;
3394
3395    println!("Rebase Status");
3396
3397    // Check if rebase is in progress by checking git state directly
3398    let git_dir = git_repo.git_dir();
3399    let rebase_in_progress = git_dir.join("REBASE_HEAD").exists()
3400        || git_dir.join("rebase-merge").exists()
3401        || git_dir.join("rebase-apply").exists();
3402
3403    if rebase_in_progress {
3404        println!("   Status: 🔄 Rebase in progress");
3405        println!(
3406            "   
3407📝 Actions available:"
3408        );
3409        println!("     - 'ca stack continue-rebase' to continue");
3410        println!("     - 'ca stack abort-rebase' to abort");
3411        println!("     - 'git status' to see conflicted files");
3412
3413        // Check for conflicts
3414        match git_repo.get_status() {
3415            Ok(statuses) => {
3416                let mut conflicts = Vec::new();
3417                for status in statuses.iter() {
3418                    if status.status().contains(git2::Status::CONFLICTED) {
3419                        if let Some(path) = status.path() {
3420                            conflicts.push(path.to_string());
3421                        }
3422                    }
3423                }
3424
3425                if !conflicts.is_empty() {
3426                    println!("   ⚠️  Conflicts in {} files:", conflicts.len());
3427                    for conflict in conflicts {
3428                        println!("      - {conflict}");
3429                    }
3430                    println!(
3431                        "   
3432💡 To resolve conflicts:"
3433                    );
3434                    println!("     1. Edit the conflicted files");
3435                    println!("     2. Stage resolved files: git add <file>");
3436                    println!("     3. Continue: ca stack continue-rebase");
3437                }
3438            }
3439            Err(e) => {
3440                warn!("Failed to get git status: {}", e);
3441            }
3442        }
3443    } else {
3444        println!("   Status: ✅ No rebase in progress");
3445
3446        // Show stack status instead
3447        if let Some(active_stack) = stack_manager.get_active_stack() {
3448            println!("   Active stack: {}", active_stack.name);
3449            println!("   Entries: {}", active_stack.entries.len());
3450            println!("   Base branch: {}", active_stack.base_branch);
3451        }
3452    }
3453
3454    Ok(())
3455}
3456
3457async fn delete_stack(name: String, force: bool) -> Result<()> {
3458    let current_dir = env::current_dir()
3459        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3460
3461    let repo_root = find_repository_root(&current_dir)
3462        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3463
3464    let mut manager = StackManager::new(&repo_root)?;
3465
3466    let stack = manager
3467        .get_stack_by_name(&name)
3468        .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
3469    let stack_id = stack.id;
3470
3471    if !force && !stack.entries.is_empty() {
3472        return Err(CascadeError::config(format!(
3473            "Stack '{}' has {} entries. Use --force to delete anyway",
3474            name,
3475            stack.entries.len()
3476        )));
3477    }
3478
3479    let deleted = manager.delete_stack(&stack_id)?;
3480
3481    Output::success(format!("Deleted stack '{}'", deleted.name));
3482    if !deleted.entries.is_empty() {
3483        Output::warning(format!("{} entries were removed", deleted.entries.len()));
3484    }
3485
3486    Ok(())
3487}
3488
3489async fn validate_stack(name: Option<String>, fix_mode: Option<String>) -> Result<()> {
3490    let current_dir = env::current_dir()
3491        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3492
3493    let repo_root = find_repository_root(&current_dir)
3494        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3495
3496    let mut manager = StackManager::new(&repo_root)?;
3497
3498    if let Some(name) = name {
3499        // Validate specific stack
3500        let stack = manager
3501            .get_stack_by_name(&name)
3502            .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?;
3503
3504        let stack_id = stack.id;
3505
3506        // Basic structure validation first
3507        match stack.validate() {
3508            Ok(_message) => {
3509                Output::success(format!("Stack '{}' structure validation passed", name));
3510            }
3511            Err(e) => {
3512                Output::error(format!(
3513                    "Stack '{}' structure validation failed: {}",
3514                    name, e
3515                ));
3516                return Err(CascadeError::config(e));
3517            }
3518        }
3519
3520        // Handle branch modifications (includes Git integrity checks)
3521        manager.handle_branch_modifications(&stack_id, fix_mode)?;
3522
3523        println!();
3524        Output::success(format!("Stack '{name}' validation completed"));
3525        Ok(())
3526    } else {
3527        // Validate all stacks
3528        Output::section("Validating all stacks");
3529        println!();
3530
3531        // Get all stack IDs through public method
3532        let all_stacks = manager.get_all_stacks();
3533        let stack_ids: Vec<uuid::Uuid> = all_stacks.iter().map(|s| s.id).collect();
3534
3535        if stack_ids.is_empty() {
3536            Output::info("No stacks found");
3537            return Ok(());
3538        }
3539
3540        let mut all_valid = true;
3541        for stack_id in stack_ids {
3542            let stack = manager.get_stack(&stack_id).unwrap();
3543            let stack_name = &stack.name;
3544
3545            println!("Checking stack '{stack_name}':");
3546
3547            // Basic structure validation
3548            match stack.validate() {
3549                Ok(message) => {
3550                    Output::sub_item(format!("Structure: {message}"));
3551                }
3552                Err(e) => {
3553                    Output::sub_item(format!("Structure: {e}"));
3554                    all_valid = false;
3555                    continue;
3556                }
3557            }
3558
3559            // Handle branch modifications
3560            match manager.handle_branch_modifications(&stack_id, fix_mode.clone()) {
3561                Ok(_) => {
3562                    Output::sub_item("Git integrity: OK");
3563                }
3564                Err(e) => {
3565                    Output::sub_item(format!("Git integrity: {e}"));
3566                    all_valid = false;
3567                }
3568            }
3569            println!();
3570        }
3571
3572        if all_valid {
3573            Output::success("All stacks passed validation");
3574        } else {
3575            Output::warning("Some stacks have validation issues");
3576            return Err(CascadeError::config("Stack validation failed".to_string()));
3577        }
3578
3579        Ok(())
3580    }
3581}
3582
3583/// Get commits that are not yet in any stack entry
3584#[allow(dead_code)]
3585fn get_unpushed_commits(repo: &GitRepository, stack: &crate::stack::Stack) -> Result<Vec<String>> {
3586    let mut unpushed = Vec::new();
3587    let head_commit = repo.get_head_commit()?;
3588    let mut current_commit = head_commit;
3589
3590    // Walk back from HEAD until we find a commit that's already in the stack
3591    loop {
3592        let commit_hash = current_commit.id().to_string();
3593        let already_in_stack = stack
3594            .entries
3595            .iter()
3596            .any(|entry| entry.commit_hash == commit_hash);
3597
3598        if already_in_stack {
3599            break;
3600        }
3601
3602        unpushed.push(commit_hash);
3603
3604        // Move to parent commit
3605        if let Some(parent) = current_commit.parents().next() {
3606            current_commit = parent;
3607        } else {
3608            break;
3609        }
3610    }
3611
3612    unpushed.reverse(); // Reverse to get chronological order
3613    Ok(unpushed)
3614}
3615
3616/// Squash the last N commits into a single commit
3617pub async fn squash_commits(
3618    repo: &GitRepository,
3619    count: usize,
3620    since_ref: Option<String>,
3621) -> Result<()> {
3622    if count <= 1 {
3623        return Ok(()); // Nothing to squash
3624    }
3625
3626    // Get the current branch
3627    let _current_branch = repo.get_current_branch()?;
3628
3629    // Determine the range for interactive rebase
3630    let rebase_range = if let Some(ref since) = since_ref {
3631        since.clone()
3632    } else {
3633        format!("HEAD~{count}")
3634    };
3635
3636    println!("   Analyzing {count} commits to create smart squash message...");
3637
3638    // Get the commits that will be squashed to create a smart message
3639    let head_commit = repo.get_head_commit()?;
3640    let mut commits_to_squash = Vec::new();
3641    let mut current = head_commit;
3642
3643    // Collect the last N commits
3644    for _ in 0..count {
3645        commits_to_squash.push(current.clone());
3646        if current.parent_count() > 0 {
3647            current = current.parent(0).map_err(CascadeError::Git)?;
3648        } else {
3649            break;
3650        }
3651    }
3652
3653    // Generate smart commit message from the squashed commits
3654    let smart_message = generate_squash_message(&commits_to_squash)?;
3655    println!(
3656        "   Smart message: {}",
3657        smart_message.lines().next().unwrap_or("")
3658    );
3659
3660    // Get the commit we want to reset to (the commit before our range)
3661    let reset_target = if since_ref.is_some() {
3662        // If squashing since a reference, reset to that reference
3663        format!("{rebase_range}~1")
3664    } else {
3665        // If squashing last N commits, reset to N commits before
3666        format!("HEAD~{count}")
3667    };
3668
3669    // Soft reset to preserve changes in staging area
3670    repo.reset_soft(&reset_target)?;
3671
3672    // Stage all changes (they should already be staged from the reset --soft)
3673    repo.stage_all()?;
3674
3675    // Create the new commit with the smart message
3676    let new_commit_hash = repo.commit(&smart_message)?;
3677
3678    println!(
3679        "   Created squashed commit: {} ({})",
3680        &new_commit_hash[..8],
3681        smart_message.lines().next().unwrap_or("")
3682    );
3683    println!("   💡 Tip: Use 'git commit --amend' to edit the commit message if needed");
3684
3685    Ok(())
3686}
3687
3688/// Generate a smart commit message from multiple commits being squashed
3689pub fn generate_squash_message(commits: &[git2::Commit]) -> Result<String> {
3690    if commits.is_empty() {
3691        return Ok("Squashed commits".to_string());
3692    }
3693
3694    // Get all commit messages
3695    let messages: Vec<String> = commits
3696        .iter()
3697        .map(|c| c.message().unwrap_or("").trim().to_string())
3698        .filter(|m| !m.is_empty())
3699        .collect();
3700
3701    if messages.is_empty() {
3702        return Ok("Squashed commits".to_string());
3703    }
3704
3705    // Strategy 1: If the last commit looks like a "Final:" commit, use it
3706    if let Some(last_msg) = messages.first() {
3707        // first() because we're in reverse chronological order
3708        if last_msg.starts_with("Final:") || last_msg.starts_with("final:") {
3709            return Ok(last_msg
3710                .trim_start_matches("Final:")
3711                .trim_start_matches("final:")
3712                .trim()
3713                .to_string());
3714        }
3715    }
3716
3717    // Strategy 2: If most commits are WIP, find the most descriptive non-WIP message
3718    let wip_count = messages
3719        .iter()
3720        .filter(|m| {
3721            m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
3722        })
3723        .count();
3724
3725    if wip_count > messages.len() / 2 {
3726        // Mostly WIP commits, find the best non-WIP one or create a summary
3727        let non_wip: Vec<&String> = messages
3728            .iter()
3729            .filter(|m| {
3730                !m.to_lowercase().starts_with("wip")
3731                    && !m.to_lowercase().contains("work in progress")
3732            })
3733            .collect();
3734
3735        if let Some(best_msg) = non_wip.first() {
3736            return Ok(best_msg.to_string());
3737        }
3738
3739        // All are WIP, try to extract the feature being worked on
3740        let feature = extract_feature_from_wip(&messages);
3741        return Ok(feature);
3742    }
3743
3744    // Strategy 3: Use the last (most recent) commit message
3745    Ok(messages.first().unwrap().clone())
3746}
3747
3748/// Extract feature name from WIP commit messages
3749pub fn extract_feature_from_wip(messages: &[String]) -> String {
3750    // Look for patterns like "WIP: add authentication" -> "Add authentication"
3751    for msg in messages {
3752        // Check both case variations, but preserve original case
3753        if msg.to_lowercase().starts_with("wip:") {
3754            if let Some(rest) = msg
3755                .strip_prefix("WIP:")
3756                .or_else(|| msg.strip_prefix("wip:"))
3757            {
3758                let feature = rest.trim();
3759                if !feature.is_empty() && feature.len() > 3 {
3760                    // Capitalize first letter only, preserve rest
3761                    let mut chars: Vec<char> = feature.chars().collect();
3762                    if let Some(first) = chars.first_mut() {
3763                        *first = first.to_uppercase().next().unwrap_or(*first);
3764                    }
3765                    return chars.into_iter().collect();
3766                }
3767            }
3768        }
3769    }
3770
3771    // Fallback: Use the latest commit without WIP prefix
3772    if let Some(first) = messages.first() {
3773        let cleaned = first
3774            .trim_start_matches("WIP:")
3775            .trim_start_matches("wip:")
3776            .trim_start_matches("WIP")
3777            .trim_start_matches("wip")
3778            .trim();
3779
3780        if !cleaned.is_empty() {
3781            return format!("Implement {cleaned}");
3782        }
3783    }
3784
3785    format!("Squashed {} commits", messages.len())
3786}
3787
3788/// Count commits since a given reference
3789pub fn count_commits_since(repo: &GitRepository, since_commit_hash: &str) -> Result<usize> {
3790    let head_commit = repo.get_head_commit()?;
3791    let since_commit = repo.get_commit(since_commit_hash)?;
3792
3793    let mut count = 0;
3794    let mut current = head_commit;
3795
3796    // Walk backwards from HEAD until we reach the since commit
3797    loop {
3798        if current.id() == since_commit.id() {
3799            break;
3800        }
3801
3802        count += 1;
3803
3804        // Get parent commit
3805        if current.parent_count() == 0 {
3806            break; // Reached root commit
3807        }
3808
3809        current = current.parent(0).map_err(CascadeError::Git)?;
3810    }
3811
3812    Ok(count)
3813}
3814
3815/// Land (merge) approved stack entries
3816async fn land_stack(
3817    entry: Option<usize>,
3818    force: bool,
3819    dry_run: bool,
3820    auto: bool,
3821    wait_for_builds: bool,
3822    strategy: Option<MergeStrategyArg>,
3823    build_timeout: u64,
3824) -> Result<()> {
3825    let current_dir = env::current_dir()
3826        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
3827
3828    let repo_root = find_repository_root(&current_dir)
3829        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
3830
3831    let stack_manager = StackManager::new(&repo_root)?;
3832
3833    // Get stack ID and active stack before moving stack_manager
3834    let stack_id = stack_manager
3835        .get_active_stack()
3836        .map(|s| s.id)
3837        .ok_or_else(|| {
3838            CascadeError::config(
3839                "No active stack. Use 'ca stack create' or 'ca stack switch' to select a stack"
3840                    .to_string(),
3841            )
3842        })?;
3843
3844    let active_stack = stack_manager
3845        .get_active_stack()
3846        .cloned()
3847        .ok_or_else(|| CascadeError::config("No active stack found".to_string()))?;
3848
3849    // Load configuration and create Bitbucket integration
3850    let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
3851    let config_path = config_dir.join("config.json");
3852    let settings = crate::config::Settings::load_from_file(&config_path)?;
3853
3854    let cascade_config = crate::config::CascadeConfig {
3855        bitbucket: Some(settings.bitbucket.clone()),
3856        git: settings.git.clone(),
3857        auth: crate::config::AuthConfig::default(),
3858        cascade: settings.cascade.clone(),
3859    };
3860
3861    let mut integration =
3862        crate::bitbucket::BitbucketIntegration::new(stack_manager, cascade_config)?;
3863
3864    // Get enhanced status
3865    let mut status = integration.check_enhanced_stack_status(&stack_id).await?;
3866
3867    // Apply advisory merge check filters from config
3868    let advisory_patterns = &settings.cascade.advisory_merge_checks;
3869    if !advisory_patterns.is_empty() {
3870        for enhanced in &mut status.enhanced_statuses {
3871            enhanced.apply_advisory_filters(advisory_patterns);
3872        }
3873    }
3874
3875    if status.enhanced_statuses.is_empty() {
3876        Output::error("No pull requests found to land");
3877        return Ok(());
3878    }
3879
3880    // Filter PRs that are ready to land
3881    let ready_prs: Vec<_> = status
3882        .enhanced_statuses
3883        .iter()
3884        .filter(|pr_status| {
3885            // If specific entry requested, only include that one
3886            if let Some(entry_num) = entry {
3887                // Find the corresponding stack entry for this PR
3888                if let Some(stack_entry) = active_stack.entries.get(entry_num.saturating_sub(1)) {
3889                    // Check if this PR corresponds to the requested entry
3890                    if pr_status.pr.from_ref.display_id != stack_entry.branch {
3891                        return false;
3892                    }
3893                } else {
3894                    return false; // Invalid entry number
3895                }
3896            }
3897
3898            if force {
3899                // If force is enabled, include any open PR
3900                pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open
3901            } else {
3902                pr_status.is_ready_to_land()
3903            }
3904        })
3905        .collect();
3906
3907    if ready_prs.is_empty() {
3908        if let Some(entry_num) = entry {
3909            Output::error(format!(
3910                "Entry {entry_num} is not ready to land or doesn't exist"
3911            ));
3912        } else {
3913            Output::error("No pull requests are ready to land");
3914        }
3915
3916        // Show what's blocking them
3917        println!();
3918        Output::section("Blocking Issues");
3919        for pr_status in &status.enhanced_statuses {
3920            if pr_status.pr.state == crate::bitbucket::pull_request::PullRequestState::Open {
3921                let blocking = pr_status.get_blocking_reasons();
3922                if !blocking.is_empty() {
3923                    Output::sub_item(format!("PR #{}: {}", pr_status.pr.id, blocking.join(", ")));
3924                }
3925            }
3926        }
3927
3928        if !force {
3929            println!();
3930            Output::tip("Use --force to land PRs with blocking issues (dangerous!)");
3931        }
3932        return Ok(());
3933    }
3934
3935    if dry_run {
3936        if let Some(entry_num) = entry {
3937            Output::section(format!("Dry Run - Entry {entry_num} that would be landed"));
3938        } else {
3939            Output::section("Dry Run - PRs that would be landed");
3940        }
3941        for pr_status in &ready_prs {
3942            Output::sub_item(format!("PR #{}: {}", pr_status.pr.id, pr_status.pr.title));
3943            if !pr_status.is_ready_to_land() && force {
3944                let blocking = pr_status.get_blocking_reasons();
3945                Output::warning(format!("Would force land despite: {}", blocking.join(", ")));
3946            }
3947        }
3948        return Ok(());
3949    }
3950
3951    // Default behavior: land all ready PRs (safest approach)
3952    // Only land specific entry if explicitly requested
3953    if let Some(entry_num) = entry {
3954        if ready_prs.len() > 1 {
3955            Output::info(format!(
3956                "{} PRs are ready to land, but landing only entry #{}",
3957                ready_prs.len(),
3958                entry_num
3959            ));
3960        }
3961    }
3962
3963    // Setup auto-merge conditions
3964    let merge_strategy: crate::bitbucket::pull_request::MergeStrategy =
3965        strategy.unwrap_or(MergeStrategyArg::Squash).into();
3966    let auto_merge_conditions = crate::bitbucket::pull_request::AutoMergeConditions {
3967        merge_strategy: merge_strategy.clone(),
3968        wait_for_builds,
3969        build_timeout: std::time::Duration::from_secs(build_timeout),
3970        allowed_authors: None, // Allow all authors for now
3971    };
3972
3973    // Land the PRs
3974    println!();
3975    Output::section(format!(
3976        "Landing {} PR{}",
3977        ready_prs.len(),
3978        if ready_prs.len() == 1 { "" } else { "s" }
3979    ));
3980
3981    let pr_manager = crate::bitbucket::pull_request::PullRequestManager::new(
3982        crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?,
3983    );
3984
3985    // Land PRs in dependency order
3986    let mut landed_count = 0;
3987    let mut failed_count = 0;
3988    let total_ready_prs = ready_prs.len();
3989
3990    for pr_status in ready_prs {
3991        let pr_id = pr_status.pr.id;
3992
3993        Output::progress(format!("Landing PR #{}: {}", pr_id, pr_status.pr.title));
3994
3995        let land_result = if auto {
3996            // Use auto-merge with conditions checking
3997            pr_manager
3998                .auto_merge_if_ready(pr_id, &auto_merge_conditions)
3999                .await
4000        } else {
4001            // Manual merge without auto-conditions
4002            pr_manager
4003                .merge_pull_request(pr_id, merge_strategy.clone())
4004                .await
4005                .map(
4006                    |pr| crate::bitbucket::pull_request::AutoMergeResult::Merged {
4007                        pr: Box::new(pr),
4008                        merge_strategy: merge_strategy.clone(),
4009                    },
4010                )
4011        };
4012
4013        match land_result {
4014            Ok(crate::bitbucket::pull_request::AutoMergeResult::Merged { .. }) => {
4015                Output::success_inline();
4016                landed_count += 1;
4017
4018                // Mark the landed entry as merged so the retarget rebase skips it
4019                let merged_branch = &pr_status.pr.from_ref.display_id;
4020                if let Some(landed_entry) = active_stack
4021                    .entries
4022                    .iter()
4023                    .find(|e| e.branch == *merged_branch)
4024                {
4025                    let landed_entry_id = landed_entry.id;
4026                    let mut mark_manager = StackManager::new(&repo_root)?;
4027                    let _ = mark_manager.set_entry_merged(&stack_id, &landed_entry_id, true);
4028                }
4029
4030                // AUTO-RETARGETING: After each merge, retarget remaining PRs
4031                if landed_count < total_ready_prs {
4032                    Output::sub_item("Retargeting remaining PRs to latest base");
4033
4034                    // Update base branch to get latest merged state
4035                    let base_branch = active_stack.base_branch.clone();
4036                    let git_repo = crate::git::GitRepository::open(&repo_root)?;
4037
4038                    Output::sub_item(format!("Updating base branch: {base_branch}"));
4039                    match git_repo.pull(&base_branch) {
4040                        Ok(_) => Output::sub_item("Base branch updated"),
4041                        Err(e) => {
4042                            Output::warning(format!("Failed to update base branch: {e}"));
4043                            Output::tip(format!(
4044                                "You may want to manually run: git pull origin {base_branch}"
4045                            ));
4046                        }
4047                    }
4048
4049                    // 2️⃣ Use rebase system to retarget remaining PRs
4050                    let temp_manager = StackManager::new(&repo_root)?;
4051                    let stack_for_count = temp_manager
4052                        .get_stack(&stack_id)
4053                        .ok_or_else(|| CascadeError::config("Stack not found"))?;
4054                    let entry_count = stack_for_count.entries.len();
4055                    let plural = if entry_count == 1 { "entry" } else { "entries" };
4056
4057                    println!(); // Spacing
4058                    let rebase_spinner = crate::utils::spinner::Spinner::new(format!(
4059                        "Retargeting {} {}",
4060                        entry_count, plural
4061                    ));
4062
4063                    let mut rebase_manager = crate::stack::RebaseManager::new(
4064                        StackManager::new(&repo_root)?,
4065                        git_repo,
4066                        crate::stack::RebaseOptions {
4067                            strategy: crate::stack::RebaseStrategy::ForcePush,
4068                            target_base: Some(base_branch.clone()),
4069                            ..Default::default()
4070                        },
4071                    );
4072
4073                    let rebase_result = rebase_manager.rebase_stack(&stack_id);
4074
4075                    rebase_spinner.stop();
4076                    println!(); // Spacing
4077
4078                    match rebase_result {
4079                        Ok(rebase_result) => {
4080                            if !rebase_result.branch_mapping.is_empty() {
4081                                // Update PRs using the rebase result
4082                                let retarget_config = crate::config::CascadeConfig {
4083                                    bitbucket: Some(settings.bitbucket.clone()),
4084                                    git: settings.git.clone(),
4085                                    auth: crate::config::AuthConfig::default(),
4086                                    cascade: settings.cascade.clone(),
4087                                };
4088                                let mut retarget_integration = BitbucketIntegration::new(
4089                                    StackManager::new(&repo_root)?,
4090                                    retarget_config,
4091                                )?;
4092
4093                                match retarget_integration
4094                                    .update_prs_after_rebase(
4095                                        &stack_id,
4096                                        &rebase_result.branch_mapping,
4097                                    )
4098                                    .await
4099                                {
4100                                    Ok(updated_prs) => {
4101                                        if !updated_prs.is_empty() {
4102                                            Output::sub_item(format!(
4103                                                "Updated {} PRs with new targets",
4104                                                updated_prs.len()
4105                                            ));
4106                                        }
4107                                    }
4108                                    Err(e) => {
4109                                        Output::warning(format!(
4110                                            "Failed to update remaining PRs: {e}"
4111                                        ));
4112                                        Output::tip(format!("You may need to run: ca stack rebase --onto {base_branch}"));
4113                                    }
4114                                }
4115                            }
4116                        }
4117                        Err(e) => {
4118                            // CONFLICTS DETECTED - Give clear next steps
4119                            println!();
4120                            Output::error("Rebase conflict while retargeting remaining PRs");
4121                            println!();
4122                            Output::section("To resolve and continue");
4123                            Output::numbered_item(1, "Resolve conflicts in the affected files");
4124                            Output::numbered_item(2, "Stage resolved files: git add <files>");
4125                            Output::numbered_item(3, "Finish the rebase: ca sync continue");
4126                            Output::numbered_item(4, "Or abort the rebase: ca sync abort");
4127                            println!();
4128                            Output::tip(
4129                                "Once the rebase is complete, re-run 'ca land' to continue merging remaining PRs",
4130                            );
4131                            Output::sub_item(format!("Error details: {e}"));
4132
4133                            // Stop the land operation here - user needs to resolve the rebase
4134                            break;
4135                        }
4136                    }
4137                }
4138            }
4139            Ok(crate::bitbucket::pull_request::AutoMergeResult::NotReady { blocking_reasons }) => {
4140                Output::error_inline(format!("Not ready: {}", blocking_reasons.join(", ")));
4141                failed_count += 1;
4142                if !force {
4143                    break;
4144                }
4145            }
4146            Ok(crate::bitbucket::pull_request::AutoMergeResult::Failed { error }) => {
4147                Output::error_inline(format!("Failed: {error}"));
4148                failed_count += 1;
4149                if !force {
4150                    break;
4151                }
4152            }
4153            Err(e) => {
4154                Output::error_inline("");
4155                Output::error(format!("Failed to land PR #{pr_id}: {e}"));
4156                failed_count += 1;
4157
4158                if !force {
4159                    break;
4160                }
4161            }
4162        }
4163    }
4164
4165    // Show summary
4166    println!();
4167    Output::section("Landing Summary");
4168    Output::sub_item(format!("Successfully landed: {landed_count}"));
4169    if failed_count > 0 {
4170        Output::sub_item(format!("Failed to land: {failed_count}"));
4171    }
4172
4173    if landed_count > 0 {
4174        Output::success("Landing operation completed!");
4175
4176        // Check if all entries in the stack are now merged
4177        let final_stack_manager = StackManager::new(&repo_root)?;
4178        if let Some(final_stack) = final_stack_manager.get_stack(&stack_id) {
4179            let all_merged = final_stack.entries.iter().all(|entry| entry.is_merged);
4180
4181            if all_merged && !final_stack.entries.is_empty() {
4182                println!();
4183                Output::success("All PRs in stack merged!");
4184                println!();
4185
4186                // Switch back to base branch so no stack is active
4187                let base_branch = active_stack.base_branch.clone();
4188                let deactivate_repo = GitRepository::open(&repo_root)?;
4189                match deactivate_repo.checkout_branch(&base_branch) {
4190                    Ok(_) => {
4191                        Output::sub_item(format!("Checked out base branch: {base_branch}"));
4192                    }
4193                    Err(e) => {
4194                        Output::warning(format!(
4195                            "Could not checkout base branch '{base_branch}': {e}"
4196                        ));
4197                    }
4198                }
4199
4200                // Prompt to clean up merged branches
4201                if !dry_run {
4202                    let should_cleanup = Confirm::with_theme(&ColorfulTheme::default())
4203                        .with_prompt("Clean up merged branches?")
4204                        .default(true)
4205                        .interact()
4206                        .unwrap_or(false);
4207
4208                    if should_cleanup {
4209                        let cleanup_git_repo = GitRepository::open(&repo_root)?;
4210                        let mut cleanup_manager = CleanupManager::new(
4211                            StackManager::new(&repo_root)?,
4212                            cleanup_git_repo,
4213                            CleanupOptions {
4214                                dry_run: false,
4215                                force: true,
4216                                include_stale: false,
4217                                cleanup_remote: false,
4218                                stale_threshold_days: 30,
4219                                cleanup_non_stack: false,
4220                            },
4221                        );
4222
4223                        // Find candidates and filter to only this stack
4224                        match cleanup_manager.find_cleanup_candidates() {
4225                            Ok(candidates) => {
4226                                let stack_candidates: Vec<_> = candidates
4227                                    .into_iter()
4228                                    .filter(|c| c.stack_id == Some(stack_id))
4229                                    .collect();
4230
4231                                if !stack_candidates.is_empty() {
4232                                    match cleanup_manager.perform_cleanup(&stack_candidates) {
4233                                        Ok(cleanup_result) => {
4234                                            if !cleanup_result.cleaned_branches.is_empty() {
4235                                                for branch in &cleanup_result.cleaned_branches {
4236                                                    Output::sub_item(format!(
4237                                                        "🗑️  Deleted: {}",
4238                                                        branch
4239                                                    ));
4240                                                }
4241                                            }
4242                                        }
4243                                        Err(e) => {
4244                                            Output::warning(format!(
4245                                                "Branch cleanup failed: {}",
4246                                                e
4247                                            ));
4248                                        }
4249                                    }
4250                                }
4251                            }
4252                            Err(e) => {
4253                                Output::warning(format!(
4254                                    "Could not find cleanup candidates: {}",
4255                                    e
4256                                ));
4257                            }
4258                        }
4259                    }
4260
4261                    // Prompt to delete the stack metadata
4262                    let should_delete_stack = Confirm::with_theme(&ColorfulTheme::default())
4263                        .with_prompt(format!("Delete stack '{}'?", final_stack.name))
4264                        .default(true)
4265                        .interact()
4266                        .unwrap_or(false);
4267
4268                    if should_delete_stack {
4269                        let mut delete_manager = StackManager::new(&repo_root)?;
4270                        match delete_manager.delete_stack(&stack_id) {
4271                            Ok(_) => {
4272                                Output::sub_item(format!("Stack '{}' deleted", final_stack.name));
4273                            }
4274                            Err(e) => {
4275                                Output::warning(format!("Could not delete stack: {}", e));
4276                            }
4277                        }
4278                    }
4279                }
4280            }
4281        }
4282    } else {
4283        Output::error("No PRs were successfully landed");
4284    }
4285
4286    Ok(())
4287}
4288
4289/// Auto-land all ready PRs (shorthand for land --auto)
4290async fn auto_land_stack(
4291    force: bool,
4292    dry_run: bool,
4293    wait_for_builds: bool,
4294    strategy: Option<MergeStrategyArg>,
4295    build_timeout: u64,
4296) -> Result<()> {
4297    // This is a shorthand for land with --auto
4298    land_stack(
4299        None,
4300        force,
4301        dry_run,
4302        true, // auto = true
4303        wait_for_builds,
4304        strategy,
4305        build_timeout,
4306    )
4307    .await
4308}
4309
4310async fn continue_land() -> Result<()> {
4311    use crate::cli::output::Output;
4312
4313    Output::warning("'ca stack continue-land' is deprecated");
4314    Output::tip("Use 'ca sync continue' to finish the rebase, then 'ca land' to continue merging");
4315    println!();
4316
4317    let current_dir = env::current_dir()
4318        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4319
4320    let repo_root = find_repository_root(&current_dir)
4321        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4322
4323    // Check if there's a rebase in progress
4324    let git_repo = crate::git::GitRepository::open(&repo_root)?;
4325    let git_dir = git_repo.git_dir();
4326    let has_cherry_pick = git_dir.join("CHERRY_PICK_HEAD").exists();
4327    let has_rebase = git_dir.join("REBASE_HEAD").exists()
4328        || git_dir.join("rebase-merge").exists()
4329        || git_dir.join("rebase-apply").exists();
4330
4331    if !has_cherry_pick && !has_rebase {
4332        Output::info("No land operation in progress");
4333        Output::tip("Use 'ca land' to start landing PRs");
4334        return Ok(());
4335    }
4336
4337    Output::section("Continuing land operation");
4338    println!();
4339
4340    // Step 1: Complete the cherry-pick/rebase
4341    Output::info("Completing conflict resolution...");
4342    let stack_manager = StackManager::new(&repo_root)?;
4343    let options = crate::stack::RebaseOptions::default();
4344    let rebase_manager = crate::stack::RebaseManager::new(stack_manager, git_repo, options);
4345
4346    match rebase_manager.continue_rebase() {
4347        Ok(_) => {
4348            Output::success("Conflict resolution completed");
4349        }
4350        Err(e) => {
4351            Output::error("Failed to complete conflict resolution");
4352            Output::tip("You may need to resolve conflicts first:");
4353            Output::bullet("Edit conflicted files");
4354            Output::bullet("Stage resolved files: git add <files>");
4355            Output::bullet("Run 'ca land continue' again");
4356            return Err(e);
4357        }
4358    }
4359
4360    println!();
4361
4362    // Step 2: Get the active stack
4363    let stack_manager = StackManager::new(&repo_root)?;
4364    let active_stack = stack_manager.get_active_stack().ok_or_else(|| {
4365        CascadeError::config("No active stack found. Cannot continue land operation.")
4366    })?;
4367    let stack_id = active_stack.id;
4368    let base_branch = active_stack.base_branch.clone();
4369
4370    // Step 3: Rebase remaining stack entries (restack children)
4371    Output::info("Rebasing remaining stack entries...");
4372    println!();
4373
4374    let git_repo_for_rebase = crate::git::GitRepository::open(&repo_root)?;
4375    let mut rebase_manager = crate::stack::RebaseManager::new(
4376        StackManager::new(&repo_root)?,
4377        git_repo_for_rebase,
4378        crate::stack::RebaseOptions {
4379            strategy: crate::stack::RebaseStrategy::ForcePush,
4380            target_base: Some(base_branch.clone()),
4381            ..Default::default()
4382        },
4383    );
4384
4385    let rebase_result = rebase_manager.rebase_stack(&stack_id)?;
4386
4387    if !rebase_result.success {
4388        // Check if this is a conflict that needs resolution
4389        if !rebase_result.conflicts.is_empty() {
4390            println!();
4391            Output::error("Additional conflicts detected during rebase");
4392            println!();
4393            Output::tip("To resolve and continue:");
4394            Output::bullet("Resolve conflicts in your editor");
4395            Output::bullet("Stage resolved files: git add <files>");
4396            Output::bullet("Finish the rebase: ca sync continue");
4397            println!();
4398            Output::tip("Or abort the rebase:");
4399            Output::bullet("ca sync abort");
4400            println!();
4401            Output::tip("Once the rebase is complete, re-run 'ca land' to continue merging");
4402
4403            // Leave state intact for user to continue
4404            return Ok(());
4405        }
4406
4407        // Non-conflict error - this is a real failure
4408        Output::error("Failed to rebase remaining entries");
4409        if let Some(error) = &rebase_result.error {
4410            Output::sub_item(format!("Error: {}", error));
4411        }
4412        return Err(CascadeError::invalid_operation(
4413            "Failed to rebase stack after conflict resolution",
4414        ));
4415    }
4416
4417    println!();
4418    Output::success(format!(
4419        "Rebased {} remaining entries",
4420        rebase_result.branch_mapping.len()
4421    ));
4422
4423    // Step 4: Update PRs if we have Bitbucket configured
4424    let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
4425    let config_path = config_dir.join("config.json");
4426
4427    if let Ok(settings) = crate::config::Settings::load_from_file(&config_path) {
4428        if !rebase_result.branch_mapping.is_empty() {
4429            println!();
4430            Output::info("Updating pull requests...");
4431
4432            let cascade_config = crate::config::CascadeConfig {
4433                bitbucket: Some(settings.bitbucket.clone()),
4434                git: settings.git.clone(),
4435                auth: crate::config::AuthConfig::default(),
4436                cascade: settings.cascade.clone(),
4437            };
4438
4439            let mut integration = crate::bitbucket::BitbucketIntegration::new(
4440                StackManager::new(&repo_root)?,
4441                cascade_config,
4442            )?;
4443
4444            match integration
4445                .update_prs_after_rebase(&stack_id, &rebase_result.branch_mapping)
4446                .await
4447            {
4448                Ok(updated_prs) => {
4449                    if !updated_prs.is_empty() {
4450                        Output::success(format!("Updated {} pull requests", updated_prs.len()));
4451                    }
4452                }
4453                Err(e) => {
4454                    Output::warning(format!("Failed to update some PRs: {}", e));
4455                    Output::tip("PRs may need manual updates in Bitbucket");
4456                }
4457            }
4458        }
4459    }
4460
4461    println!();
4462    Output::success("Land operation continued successfully");
4463    println!();
4464    Output::tip("Next steps:");
4465    Output::bullet("Wait for builds to pass on rebased PRs");
4466    Output::bullet("Once builds are green, run: ca land");
4467
4468    Ok(())
4469}
4470
4471async fn abort_land() -> Result<()> {
4472    Output::warning("'ca stack abort-land' is deprecated");
4473    Output::tip("Use 'ca sync abort' to abort the rebase");
4474    println!();
4475
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(&current_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 rebase...");
4493    match rebase_manager.abort_rebase() {
4494        Ok(_) => {
4495            Output::success("Rebase aborted successfully");
4496            Output::tip("Run 'ca sync' to re-sync the stack, or 'ca land' when ready to merge");
4497        }
4498        Err(e) => {
4499            warn!("❌ Failed to abort rebase: {}", 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(&current_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    // Check if land operation is in progress by checking git state directly
4520    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 sync continue' to finish the rebase");
4532        println!("     - 'ca sync abort' to abort the rebase");
4533        println!("     - 'ca land' to continue merging (after rebase is complete)");
4534        println!("     - 'git status' to see conflicted files");
4535
4536        // Check for conflicts
4537        match git_repo.get_status() {
4538            Ok(statuses) => {
4539                let mut conflicts = Vec::new();
4540                for status in statuses.iter() {
4541                    if status.status().contains(git2::Status::CONFLICTED) {
4542                        if let Some(path) = status.path() {
4543                            conflicts.push(path.to_string());
4544                        }
4545                    }
4546                }
4547
4548                if !conflicts.is_empty() {
4549                    println!("   ⚠️  Conflicts in {} files:", conflicts.len());
4550                    for conflict in conflicts {
4551                        println!("      - {conflict}");
4552                    }
4553                    println!(
4554                        "   
4555💡 To resolve conflicts:"
4556                    );
4557                    println!("     1. Edit the conflicted files");
4558                    println!("     2. Stage resolved files: git add <file>");
4559                    println!("     3. Finish the rebase: ca sync continue");
4560                    println!("     4. Then re-run: ca land");
4561                }
4562            }
4563            Err(e) => {
4564                warn!("Failed to get git status: {}", e);
4565            }
4566        }
4567    } else {
4568        println!("   Status: ✅ No land operation in progress");
4569
4570        // Show stack status instead
4571        if let Some(active_stack) = stack_manager.get_active_stack() {
4572            println!("   Active stack: {}", active_stack.name);
4573            println!("   Entries: {}", active_stack.entries.len());
4574            println!("   Base branch: {}", active_stack.base_branch);
4575        }
4576    }
4577
4578    Ok(())
4579}
4580
4581async fn repair_stack_data() -> Result<()> {
4582    let current_dir = env::current_dir()
4583        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4584
4585    let repo_root = find_repository_root(&current_dir)
4586        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4587
4588    let mut stack_manager = StackManager::new(&repo_root)?;
4589
4590    println!("🔧 Repairing stack data consistency...");
4591
4592    stack_manager.repair_all_stacks()?;
4593
4594    Output::success(" Stack data consistency repaired successfully!");
4595    Output::tip(" Run 'ca stack --mergeable' to see updated status");
4596
4597    Ok(())
4598}
4599
4600/// Clean up merged and stale branches
4601async fn cleanup_branches(
4602    dry_run: bool,
4603    force: bool,
4604    include_stale: bool,
4605    stale_days: u32,
4606    cleanup_remote: bool,
4607    include_non_stack: bool,
4608    verbose: bool,
4609) -> Result<()> {
4610    let current_dir = env::current_dir()
4611        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4612
4613    let repo_root = find_repository_root(&current_dir)
4614        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4615
4616    let stack_manager = StackManager::new(&repo_root)?;
4617    let git_repo = GitRepository::open(&repo_root)?;
4618
4619    let result = perform_cleanup(
4620        &stack_manager,
4621        &git_repo,
4622        dry_run,
4623        force,
4624        include_stale,
4625        stale_days,
4626        cleanup_remote,
4627        include_non_stack,
4628        verbose,
4629    )
4630    .await?;
4631
4632    // Display results
4633    if result.total_candidates == 0 {
4634        Output::success("No branches found that need cleanup");
4635        return Ok(());
4636    }
4637
4638    Output::section("Cleanup Results");
4639
4640    if dry_run {
4641        Output::sub_item(format!(
4642            "Found {} branches that would be cleaned up",
4643            result.total_candidates
4644        ));
4645    } else {
4646        if !result.cleaned_branches.is_empty() {
4647            Output::success(format!(
4648                "Successfully cleaned up {} branches",
4649                result.cleaned_branches.len()
4650            ));
4651            for branch in &result.cleaned_branches {
4652                Output::sub_item(format!("🗑️  Deleted: {branch}"));
4653            }
4654        }
4655
4656        if !result.skipped_branches.is_empty() {
4657            Output::sub_item(format!(
4658                "Skipped {} branches",
4659                result.skipped_branches.len()
4660            ));
4661            if verbose {
4662                for (branch, reason) in &result.skipped_branches {
4663                    Output::sub_item(format!("⏭️  {branch}: {reason}"));
4664                }
4665            }
4666        }
4667
4668        if !result.failed_branches.is_empty() {
4669            Output::warning(format!(
4670                "Failed to clean up {} branches",
4671                result.failed_branches.len()
4672            ));
4673            for (branch, error) in &result.failed_branches {
4674                Output::sub_item(format!("❌ {branch}: {error}"));
4675            }
4676        }
4677    }
4678
4679    Ok(())
4680}
4681
4682/// Perform cleanup with the given options
4683#[allow(clippy::too_many_arguments)]
4684async fn perform_cleanup(
4685    stack_manager: &StackManager,
4686    git_repo: &GitRepository,
4687    dry_run: bool,
4688    force: bool,
4689    include_stale: bool,
4690    stale_days: u32,
4691    cleanup_remote: bool,
4692    include_non_stack: bool,
4693    verbose: bool,
4694) -> Result<CleanupResult> {
4695    let options = CleanupOptions {
4696        dry_run,
4697        force,
4698        include_stale,
4699        cleanup_remote,
4700        stale_threshold_days: stale_days,
4701        cleanup_non_stack: include_non_stack,
4702    };
4703
4704    let stack_manager_copy = StackManager::new(stack_manager.repo_path())?;
4705    let git_repo_copy = GitRepository::open(git_repo.path())?;
4706    let mut cleanup_manager = CleanupManager::new(stack_manager_copy, git_repo_copy, options);
4707
4708    // Find candidates
4709    let candidates = cleanup_manager.find_cleanup_candidates()?;
4710
4711    if candidates.is_empty() {
4712        return Ok(CleanupResult {
4713            cleaned_branches: Vec::new(),
4714            failed_branches: Vec::new(),
4715            skipped_branches: Vec::new(),
4716            total_candidates: 0,
4717        });
4718    }
4719
4720    // Show candidates if verbose or dry run
4721    if verbose || dry_run {
4722        Output::section("Cleanup Candidates");
4723        for candidate in &candidates {
4724            let reason_icon = match candidate.reason {
4725                crate::stack::CleanupReason::FullyMerged => "🔀",
4726                crate::stack::CleanupReason::StackEntryMerged => "✅",
4727                crate::stack::CleanupReason::Stale => "⏰",
4728                crate::stack::CleanupReason::Orphaned => "👻",
4729            };
4730
4731            Output::sub_item(format!(
4732                "{} {} - {} ({})",
4733                reason_icon,
4734                candidate.branch_name,
4735                candidate.reason_to_string(),
4736                candidate.safety_info
4737            ));
4738        }
4739    }
4740
4741    // If not force and not dry run, ask for confirmation
4742    if !force && !dry_run && !candidates.is_empty() {
4743        Output::warning(format!("About to delete {} branches", candidates.len()));
4744
4745        // Show first few branch names for context
4746        let preview_count = 5.min(candidates.len());
4747        for candidate in candidates.iter().take(preview_count) {
4748            println!("  • {}", candidate.branch_name);
4749        }
4750        if candidates.len() > preview_count {
4751            println!("  ... and {} more", candidates.len() - preview_count);
4752        }
4753        println!(); // Spacing before prompt
4754
4755        // Interactive confirmation to proceed with cleanup
4756        let should_continue = Confirm::with_theme(&ColorfulTheme::default())
4757            .with_prompt("Continue with branch cleanup?")
4758            .default(false)
4759            .interact()
4760            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
4761
4762        if !should_continue {
4763            Output::sub_item("Cleanup cancelled");
4764            return Ok(CleanupResult {
4765                cleaned_branches: Vec::new(),
4766                failed_branches: Vec::new(),
4767                skipped_branches: Vec::new(),
4768                total_candidates: candidates.len(),
4769            });
4770        }
4771    }
4772
4773    // Perform cleanup
4774    cleanup_manager.perform_cleanup(&candidates)
4775}
4776
4777/// Simple perform_cleanup for sync command
4778async fn perform_simple_cleanup(
4779    stack_manager: &StackManager,
4780    git_repo: &GitRepository,
4781    dry_run: bool,
4782) -> Result<CleanupResult> {
4783    perform_cleanup(
4784        stack_manager,
4785        git_repo,
4786        dry_run,
4787        false, // force
4788        false, // include_stale
4789        30,    // stale_days
4790        false, // cleanup_remote
4791        false, // include_non_stack
4792        false, // verbose
4793    )
4794    .await
4795}
4796
4797/// Analyze commits for various safeguards before pushing
4798async fn analyze_commits_for_safeguards(
4799    commits_to_push: &[String],
4800    repo: &GitRepository,
4801    dry_run: bool,
4802) -> Result<()> {
4803    const LARGE_COMMIT_THRESHOLD: usize = 10;
4804    const WEEK_IN_SECONDS: i64 = 7 * 24 * 3600;
4805
4806    // 🛡️ SAFEGUARD 1: Large commit count warning
4807    if commits_to_push.len() > LARGE_COMMIT_THRESHOLD {
4808        println!(
4809            "⚠️  Warning: About to push {} commits to stack",
4810            commits_to_push.len()
4811        );
4812        println!("   This may indicate a merge commit issue or unexpected commit range.");
4813        println!("   Large commit counts often result from merging instead of rebasing.");
4814
4815        if !dry_run && !confirm_large_push(commits_to_push.len())? {
4816            return Err(CascadeError::config("Push cancelled by user"));
4817        }
4818    }
4819
4820    // Get commit objects for further analysis
4821    let commit_objects: Result<Vec<_>> = commits_to_push
4822        .iter()
4823        .map(|hash| repo.get_commit(hash))
4824        .collect();
4825    let commit_objects = commit_objects?;
4826
4827    // 🛡️ SAFEGUARD 2: Merge commit detection
4828    let merge_commits: Vec<_> = commit_objects
4829        .iter()
4830        .filter(|c| c.parent_count() > 1)
4831        .collect();
4832
4833    if !merge_commits.is_empty() {
4834        println!(
4835            "⚠️  Warning: {} merge commits detected in push",
4836            merge_commits.len()
4837        );
4838        println!("   This often indicates you merged instead of rebased.");
4839        println!("   Consider using 'ca sync' to rebase on the base branch.");
4840        println!("   Merge commits in stacks can cause confusion and duplicate work.");
4841    }
4842
4843    // 🛡️ SAFEGUARD 3: Commit age warning
4844    if commit_objects.len() > 1 {
4845        let oldest_commit_time = commit_objects.first().unwrap().time().seconds();
4846        let newest_commit_time = commit_objects.last().unwrap().time().seconds();
4847        let time_span = newest_commit_time - oldest_commit_time;
4848
4849        if time_span > WEEK_IN_SECONDS {
4850            let days = time_span / (24 * 3600);
4851            println!("⚠️  Warning: Commits span {days} days");
4852            println!("   This may indicate merged history rather than new work.");
4853            println!("   Recent work should typically span hours or days, not weeks.");
4854        }
4855    }
4856
4857    // 🛡️ SAFEGUARD 4: Better range detection suggestions
4858    if commits_to_push.len() > 5 {
4859        Output::tip(" Tip: If you only want recent commits, use:");
4860        println!(
4861            "   ca push --since HEAD~{}  # pushes last {} commits",
4862            std::cmp::min(commits_to_push.len(), 5),
4863            std::cmp::min(commits_to_push.len(), 5)
4864        );
4865        println!("   ca push --commits <hash1>,<hash2>  # pushes specific commits");
4866        println!("   ca push --dry-run  # preview what would be pushed");
4867    }
4868
4869    // 🛡️ SAFEGUARD 5: Dry run mode
4870    if dry_run {
4871        println!("🔍 DRY RUN: Would push {} commits:", commits_to_push.len());
4872        for (i, (commit_hash, commit_obj)) in commits_to_push
4873            .iter()
4874            .zip(commit_objects.iter())
4875            .enumerate()
4876        {
4877            let summary = commit_obj.summary().unwrap_or("(no message)");
4878            let short_hash = &commit_hash[..std::cmp::min(commit_hash.len(), 7)];
4879            println!("  {}: {} ({})", i + 1, summary, short_hash);
4880        }
4881        Output::tip(" Run without --dry-run to actually push these commits.");
4882    }
4883
4884    Ok(())
4885}
4886
4887/// Prompt user for confirmation when pushing large number of commits
4888fn confirm_large_push(count: usize) -> Result<bool> {
4889    // Interactive confirmation for large push
4890    let should_continue = Confirm::with_theme(&ColorfulTheme::default())
4891        .with_prompt(format!("Continue pushing {count} commits?"))
4892        .default(false)
4893        .interact()
4894        .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
4895
4896    Ok(should_continue)
4897}
4898
4899/// Parse an entry spec string like "3", "1-5", or "1,3,5" into sorted, deduplicated 1-based indices
4900fn parse_entry_spec(spec: &str, max_entries: usize) -> Result<Vec<usize>> {
4901    let mut indices: Vec<usize> = Vec::new();
4902
4903    if spec.contains('-') && !spec.contains(',') {
4904        // Range like "1-5"
4905        let parts: Vec<&str> = spec.split('-').collect();
4906        if parts.len() != 2 {
4907            return Err(CascadeError::config(
4908                "Invalid range format. Use 'start-end' (e.g., '1-5')",
4909            ));
4910        }
4911
4912        let start: usize = parts[0]
4913            .trim()
4914            .parse()
4915            .map_err(|_| CascadeError::config("Invalid start number in range"))?;
4916        let end: usize = parts[1]
4917            .trim()
4918            .parse()
4919            .map_err(|_| CascadeError::config("Invalid end number in range"))?;
4920
4921        if start == 0 || end == 0 {
4922            return Err(CascadeError::config("Entry numbers are 1-based"));
4923        }
4924        if start > max_entries || end > max_entries {
4925            return Err(CascadeError::config(format!(
4926                "Entry number out of bounds. Stack has {max_entries} entries"
4927            )));
4928        }
4929
4930        let (lo, hi) = if start <= end {
4931            (start, end)
4932        } else {
4933            (end, start)
4934        };
4935        for i in lo..=hi {
4936            indices.push(i);
4937        }
4938    } else if spec.contains(',') {
4939        // Comma-separated like "1,3,5"
4940        for part in spec.split(',') {
4941            let num: usize = part.trim().parse().map_err(|_| {
4942                CascadeError::config(format!("Invalid entry number: {}", part.trim()))
4943            })?;
4944            if num == 0 {
4945                return Err(CascadeError::config("Entry numbers are 1-based"));
4946            }
4947            if num > max_entries {
4948                return Err(CascadeError::config(format!(
4949                    "Entry {num} out of bounds. Stack has {max_entries} entries"
4950                )));
4951            }
4952            indices.push(num);
4953        }
4954    } else {
4955        // Single number like "3"
4956        let num: usize = spec
4957            .trim()
4958            .parse()
4959            .map_err(|_| CascadeError::config(format!("Invalid entry number: {spec}")))?;
4960        if num == 0 {
4961            return Err(CascadeError::config("Entry numbers are 1-based"));
4962        }
4963        if num > max_entries {
4964            return Err(CascadeError::config(format!(
4965                "Entry {num} out of bounds. Stack has {max_entries} entries"
4966            )));
4967        }
4968        indices.push(num);
4969    }
4970
4971    indices.sort();
4972    indices.dedup();
4973    Ok(indices)
4974}
4975
4976async fn drop_entries(
4977    entry_spec: String,
4978    keep_branch: bool,
4979    keep_pr: bool,
4980    force: bool,
4981    yes: bool,
4982) -> Result<()> {
4983    let current_dir = env::current_dir()
4984        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
4985
4986    let repo_root = find_repository_root(&current_dir)
4987        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
4988
4989    let mut manager = StackManager::new(&repo_root)?;
4990    let repo = GitRepository::open(&repo_root)?;
4991
4992    let active_stack = manager.get_active_stack().ok_or_else(|| {
4993        CascadeError::config("No active stack. Create a stack first with 'ca stacks create'")
4994    })?;
4995    let stack_id = active_stack.id;
4996    let entry_count = active_stack.entries.len();
4997
4998    if entry_count == 0 {
4999        Output::info("Stack is empty, nothing to drop.");
5000        return Ok(());
5001    }
5002
5003    let indices = parse_entry_spec(&entry_spec, entry_count)?;
5004
5005    // Validate: refuse entries that are merged
5006    for &idx in &indices {
5007        let entry = &active_stack.entries[idx - 1];
5008        if entry.is_merged {
5009            return Err(CascadeError::config(format!(
5010                "Entry {} ('{}') is already merged. Use 'ca stacks cleanup' to remove merged entries.",
5011                idx,
5012                entry.short_message(40)
5013            )));
5014        }
5015    }
5016
5017    // Display entries to drop
5018    let has_submitted = indices
5019        .iter()
5020        .any(|&idx| active_stack.entries[idx - 1].is_submitted);
5021
5022    Output::section(format!("Entries to drop ({})", indices.len()));
5023    for &idx in &indices {
5024        let entry = &active_stack.entries[idx - 1];
5025        let pr_status = if entry.is_submitted {
5026            format!(" [PR #{}]", entry.pull_request_id.as_deref().unwrap_or("?"))
5027        } else {
5028            String::new()
5029        };
5030        Output::numbered_item(
5031            idx,
5032            format!(
5033                "{} {} (branch: {}){}",
5034                entry.short_hash(),
5035                entry.short_message(40),
5036                entry.branch,
5037                pr_status
5038            ),
5039        );
5040    }
5041
5042    // Confirmation
5043    if !force && !yes {
5044        if has_submitted {
5045            Output::warning("Some entries have associated pull requests.");
5046        }
5047
5048        let default_confirm = !has_submitted;
5049        let should_continue = Confirm::with_theme(&ColorfulTheme::default())
5050            .with_prompt(format!("Drop {} entry/entries from stack?", indices.len()))
5051            .default(default_confirm)
5052            .interact()
5053            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
5054
5055        if !should_continue {
5056            Output::info("Drop cancelled.");
5057            return Ok(());
5058        }
5059    }
5060
5061    // Collect entry info before removal (we need branch names and PR info)
5062    let entries_info: Vec<(String, Option<String>, bool)> = indices
5063        .iter()
5064        .map(|&idx| {
5065            let entry = &active_stack.entries[idx - 1];
5066            (
5067                entry.branch.clone(),
5068                entry.pull_request_id.clone(),
5069                entry.is_submitted,
5070            )
5071        })
5072        .collect();
5073
5074    let current_branch = repo.get_current_branch()?;
5075
5076    // Lazily construct Bitbucket client only if needed
5077    let mut pr_manager = None;
5078
5079    // Remove entries in reverse index order to preserve indices
5080    for (i, &idx) in indices.iter().enumerate().rev() {
5081        let zero_idx = idx - 1;
5082        match manager.remove_stack_entry_at(&stack_id, zero_idx)? {
5083            Some(removed) => {
5084                Output::success(format!(
5085                    "Dropped entry {}: {} {}",
5086                    idx,
5087                    removed.short_hash(),
5088                    removed.short_message(40)
5089                ));
5090            }
5091            None => {
5092                Output::warning(format!("Could not remove entry {idx}"));
5093                continue;
5094            }
5095        }
5096
5097        let (ref branch_name, ref pr_id, is_submitted) = entries_info[i];
5098
5099        // Delete branch unless --keep-branch or it's the current branch
5100        if !keep_branch && *branch_name != current_branch {
5101            match repo.delete_branch(branch_name) {
5102                Ok(_) => Output::sub_item(format!("Deleted branch: {branch_name}")),
5103                Err(e) => Output::warning(format!("Could not delete branch {branch_name}: {e}")),
5104            }
5105        }
5106
5107        // Decline PR if entry was submitted (unless --keep-pr)
5108        if is_submitted && !keep_pr {
5109            if let Some(pr_id_str) = pr_id {
5110                if let Ok(pr_id_num) = pr_id_str.parse::<u64>() {
5111                    let should_decline = if force {
5112                        true
5113                    } else {
5114                        Confirm::with_theme(&ColorfulTheme::default())
5115                            .with_prompt(format!("Decline PR #{pr_id_num} on Bitbucket?"))
5116                            .default(true)
5117                            .interact()
5118                            .unwrap_or(false)
5119                    };
5120
5121                    if should_decline {
5122                        // Lazily create PR manager
5123                        if pr_manager.is_none() {
5124                            let config_dir = crate::config::get_repo_config_dir(&repo_root)?;
5125                            let config_path = config_dir.join("config.json");
5126                            let settings = crate::config::Settings::load_from_file(&config_path)?;
5127                            let client =
5128                                crate::bitbucket::BitbucketClient::new(&settings.bitbucket)?;
5129                            pr_manager = Some(crate::bitbucket::PullRequestManager::new(client));
5130                        }
5131
5132                        if let Some(ref mgr) = pr_manager {
5133                            match mgr
5134                                .decline_pull_request(pr_id_num, "Dropped from stack")
5135                                .await
5136                            {
5137                                Ok(_) => Output::sub_item(format!(
5138                                    "Declined PR #{pr_id_num} on Bitbucket"
5139                                )),
5140                                Err(e) => Output::warning(format!(
5141                                    "Failed to decline PR #{pr_id_num}: {e}"
5142                                )),
5143                            }
5144                        }
5145                    }
5146                }
5147            }
5148        }
5149    }
5150
5151    Output::success(format!(
5152        "Dropped {} entry/entries from stack",
5153        indices.len()
5154    ));
5155
5156    Ok(())
5157}
5158
5159#[cfg(test)]
5160mod tests {
5161    use super::*;
5162    use std::process::Command;
5163    use tempfile::TempDir;
5164
5165    fn create_test_repo() -> Result<(TempDir, std::path::PathBuf)> {
5166        let temp_dir = TempDir::new()
5167            .map_err(|e| CascadeError::config(format!("Failed to create temp directory: {e}")))?;
5168        let repo_path = temp_dir.path().to_path_buf();
5169
5170        // Initialize git repository
5171        let output = Command::new("git")
5172            .args(["init"])
5173            .current_dir(&repo_path)
5174            .output()
5175            .map_err(|e| CascadeError::config(format!("Failed to run git init: {e}")))?;
5176        if !output.status.success() {
5177            return Err(CascadeError::config("Git init failed".to_string()));
5178        }
5179
5180        let output = Command::new("git")
5181            .args(["config", "user.name", "Test User"])
5182            .current_dir(&repo_path)
5183            .output()
5184            .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
5185        if !output.status.success() {
5186            return Err(CascadeError::config(
5187                "Git config user.name failed".to_string(),
5188            ));
5189        }
5190
5191        let output = Command::new("git")
5192            .args(["config", "user.email", "test@example.com"])
5193            .current_dir(&repo_path)
5194            .output()
5195            .map_err(|e| CascadeError::config(format!("Failed to run git config: {e}")))?;
5196        if !output.status.success() {
5197            return Err(CascadeError::config(
5198                "Git config user.email failed".to_string(),
5199            ));
5200        }
5201
5202        // Create initial commit
5203        std::fs::write(repo_path.join("README.md"), "# Test")
5204            .map_err(|e| CascadeError::config(format!("Failed to write file: {e}")))?;
5205        let output = Command::new("git")
5206            .args(["add", "."])
5207            .current_dir(&repo_path)
5208            .output()
5209            .map_err(|e| CascadeError::config(format!("Failed to run git add: {e}")))?;
5210        if !output.status.success() {
5211            return Err(CascadeError::config("Git add failed".to_string()));
5212        }
5213
5214        let output = Command::new("git")
5215            .args(["commit", "-m", "Initial commit"])
5216            .current_dir(&repo_path)
5217            .output()
5218            .map_err(|e| CascadeError::config(format!("Failed to run git commit: {e}")))?;
5219        if !output.status.success() {
5220            return Err(CascadeError::config("Git commit failed".to_string()));
5221        }
5222
5223        // Initialize cascade
5224        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))?;
5225
5226        Ok((temp_dir, repo_path))
5227    }
5228
5229    #[tokio::test]
5230    async fn test_create_stack() {
5231        let (temp_dir, repo_path) = match create_test_repo() {
5232            Ok(repo) => repo,
5233            Err(_) => {
5234                println!("Skipping test due to git environment setup failure");
5235                return;
5236            }
5237        };
5238        // IMPORTANT: temp_dir must stay in scope to prevent early cleanup of test directory
5239        let _ = &temp_dir;
5240
5241        // Note: create_test_repo() already initializes Cascade configuration
5242
5243        // Change to the repo directory (with proper error handling)
5244        let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5245        match env::set_current_dir(&repo_path) {
5246            Ok(_) => {
5247                let result = create_stack(
5248                    "test-stack".to_string(),
5249                    None, // Use default branch
5250                    Some("Test description".to_string()),
5251                )
5252                .await;
5253
5254                // Restore original directory (best effort)
5255                if let Ok(orig) = original_dir {
5256                    let _ = env::set_current_dir(orig);
5257                }
5258
5259                assert!(
5260                    result.is_ok(),
5261                    "Stack creation should succeed in initialized repository"
5262                );
5263            }
5264            Err(_) => {
5265                // Skip test if we can't change directories (CI environment issue)
5266                println!("Skipping test due to directory access restrictions");
5267            }
5268        }
5269    }
5270
5271    #[tokio::test]
5272    async fn test_list_empty_stacks() {
5273        let (temp_dir, repo_path) = match create_test_repo() {
5274            Ok(repo) => repo,
5275            Err(_) => {
5276                println!("Skipping test due to git environment setup failure");
5277                return;
5278            }
5279        };
5280        // IMPORTANT: temp_dir must stay in scope to prevent early cleanup of test directory
5281        let _ = &temp_dir;
5282
5283        // Note: create_test_repo() already initializes Cascade configuration
5284
5285        // Change to the repo directory (with proper error handling)
5286        let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5287        match env::set_current_dir(&repo_path) {
5288            Ok(_) => {
5289                let result = list_stacks(false, false, None).await;
5290
5291                // Restore original directory (best effort)
5292                if let Ok(orig) = original_dir {
5293                    let _ = env::set_current_dir(orig);
5294                }
5295
5296                assert!(
5297                    result.is_ok(),
5298                    "Listing stacks should succeed in initialized repository"
5299                );
5300            }
5301            Err(_) => {
5302                // Skip test if we can't change directories (CI environment issue)
5303                println!("Skipping test due to directory access restrictions");
5304            }
5305        }
5306    }
5307
5308    // Tests for squashing functionality
5309
5310    #[test]
5311    fn test_extract_feature_from_wip_basic() {
5312        let messages = vec![
5313            "WIP: add authentication".to_string(),
5314            "WIP: implement login flow".to_string(),
5315        ];
5316
5317        let result = extract_feature_from_wip(&messages);
5318        assert_eq!(result, "Add authentication");
5319    }
5320
5321    #[test]
5322    fn test_extract_feature_from_wip_capitalize() {
5323        let messages = vec!["WIP: fix user validation bug".to_string()];
5324
5325        let result = extract_feature_from_wip(&messages);
5326        assert_eq!(result, "Fix user validation bug");
5327    }
5328
5329    #[test]
5330    fn test_extract_feature_from_wip_fallback() {
5331        let messages = vec![
5332            "WIP user interface changes".to_string(),
5333            "wip: css styling".to_string(),
5334        ];
5335
5336        let result = extract_feature_from_wip(&messages);
5337        // Should create a fallback message since no "WIP:" prefix found
5338        assert!(result.contains("Implement") || result.contains("Squashed") || result.len() > 5);
5339    }
5340
5341    #[test]
5342    fn test_extract_feature_from_wip_empty() {
5343        let messages = vec![];
5344
5345        let result = extract_feature_from_wip(&messages);
5346        assert_eq!(result, "Squashed 0 commits");
5347    }
5348
5349    #[test]
5350    fn test_extract_feature_from_wip_short_message() {
5351        let messages = vec!["WIP: x".to_string()]; // Too short
5352
5353        let result = extract_feature_from_wip(&messages);
5354        assert!(result.starts_with("Implement") || result.contains("Squashed"));
5355    }
5356
5357    // Integration tests for squashing that don't require real git commits
5358
5359    #[test]
5360    fn test_squash_message_final_strategy() {
5361        // This test would need real git2::Commit objects, so we'll test the logic indirectly
5362        // through the extract_feature_from_wip function which handles the core logic
5363
5364        let messages = [
5365            "Final: implement user authentication system".to_string(),
5366            "WIP: add tests".to_string(),
5367            "WIP: fix validation".to_string(),
5368        ];
5369
5370        // Test that we can identify final commits
5371        assert!(messages[0].starts_with("Final:"));
5372
5373        // Test message extraction
5374        let extracted = messages[0].trim_start_matches("Final:").trim();
5375        assert_eq!(extracted, "implement user authentication system");
5376    }
5377
5378    #[test]
5379    fn test_squash_message_wip_detection() {
5380        let messages = [
5381            "WIP: start feature".to_string(),
5382            "WIP: continue work".to_string(),
5383            "WIP: almost done".to_string(),
5384            "Regular commit message".to_string(),
5385        ];
5386
5387        let wip_count = messages
5388            .iter()
5389            .filter(|m| {
5390                m.to_lowercase().starts_with("wip") || m.to_lowercase().contains("work in progress")
5391            })
5392            .count();
5393
5394        assert_eq!(wip_count, 3); // Should detect 3 WIP commits
5395        assert!(wip_count > messages.len() / 2); // Majority are WIP
5396
5397        // Should find the non-WIP message
5398        let non_wip: Vec<&String> = messages
5399            .iter()
5400            .filter(|m| {
5401                !m.to_lowercase().starts_with("wip")
5402                    && !m.to_lowercase().contains("work in progress")
5403            })
5404            .collect();
5405
5406        assert_eq!(non_wip.len(), 1);
5407        assert_eq!(non_wip[0], "Regular commit message");
5408    }
5409
5410    #[test]
5411    fn test_squash_message_all_wip() {
5412        let messages = vec![
5413            "WIP: add feature A".to_string(),
5414            "WIP: add feature B".to_string(),
5415            "WIP: finish implementation".to_string(),
5416        ];
5417
5418        let result = extract_feature_from_wip(&messages);
5419        // Should use the first message as the main feature
5420        assert_eq!(result, "Add feature A");
5421    }
5422
5423    #[test]
5424    fn test_squash_message_edge_cases() {
5425        // Test empty messages
5426        let empty_messages: Vec<String> = vec![];
5427        let result = extract_feature_from_wip(&empty_messages);
5428        assert_eq!(result, "Squashed 0 commits");
5429
5430        // Test messages with only whitespace
5431        let whitespace_messages = vec!["   ".to_string(), "\t\n".to_string()];
5432        let result = extract_feature_from_wip(&whitespace_messages);
5433        assert!(result.contains("Squashed") || result.contains("Implement"));
5434
5435        // Test case sensitivity
5436        let mixed_case = vec!["wip: Add Feature".to_string()];
5437        let result = extract_feature_from_wip(&mixed_case);
5438        assert_eq!(result, "Add Feature");
5439    }
5440
5441    // Tests for auto-land functionality
5442
5443    #[tokio::test]
5444    async fn test_auto_land_wrapper() {
5445        // Test that auto_land_stack correctly calls land_stack with auto=true
5446        let (temp_dir, repo_path) = match create_test_repo() {
5447            Ok(repo) => repo,
5448            Err(_) => {
5449                println!("Skipping test due to git environment setup failure");
5450                return;
5451            }
5452        };
5453        // IMPORTANT: temp_dir must stay in scope to prevent early cleanup of test directory
5454        let _ = &temp_dir;
5455
5456        // Initialize cascade in the test repo
5457        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
5458            .expect("Failed to initialize Cascade in test repo");
5459
5460        let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5461        match env::set_current_dir(&repo_path) {
5462            Ok(_) => {
5463                // Create a stack first
5464                let result = create_stack(
5465                    "test-stack".to_string(),
5466                    None,
5467                    Some("Test stack for auto-land".to_string()),
5468                )
5469                .await;
5470
5471                if let Ok(orig) = original_dir {
5472                    let _ = env::set_current_dir(orig);
5473                }
5474
5475                // For now, just test that the function can be called without panic
5476                // (It will fail due to missing Bitbucket config, but that's expected)
5477                assert!(
5478                    result.is_ok(),
5479                    "Stack creation should succeed in initialized repository"
5480                );
5481            }
5482            Err(_) => {
5483                println!("Skipping test due to directory access restrictions");
5484            }
5485        }
5486    }
5487
5488    #[test]
5489    fn test_auto_land_action_enum() {
5490        // Test that AutoLand action is properly defined
5491        use crate::cli::commands::stack::StackAction;
5492
5493        // This ensures the AutoLand variant exists and has the expected fields
5494        let _action = StackAction::AutoLand {
5495            force: false,
5496            dry_run: true,
5497            wait_for_builds: true,
5498            strategy: Some(MergeStrategyArg::Squash),
5499            build_timeout: 1800,
5500        };
5501
5502        // Test passes if we reach this point without errors
5503    }
5504
5505    #[test]
5506    fn test_merge_strategy_conversion() {
5507        // Test that MergeStrategyArg converts properly
5508        let squash_strategy = MergeStrategyArg::Squash;
5509        let merge_strategy: crate::bitbucket::pull_request::MergeStrategy = squash_strategy.into();
5510
5511        match merge_strategy {
5512            crate::bitbucket::pull_request::MergeStrategy::Squash => {
5513                // Correct conversion
5514            }
5515            _ => unreachable!("SquashStrategyArg only has Squash variant"),
5516        }
5517
5518        let merge_strategy = MergeStrategyArg::Merge;
5519        let converted: crate::bitbucket::pull_request::MergeStrategy = merge_strategy.into();
5520
5521        match converted {
5522            crate::bitbucket::pull_request::MergeStrategy::Merge => {
5523                // Correct conversion
5524            }
5525            _ => unreachable!("MergeStrategyArg::Merge maps to MergeStrategy::Merge"),
5526        }
5527    }
5528
5529    #[test]
5530    fn test_auto_merge_conditions_structure() {
5531        // Test that AutoMergeConditions can be created with expected values
5532        use std::time::Duration;
5533
5534        let conditions = crate::bitbucket::pull_request::AutoMergeConditions {
5535            merge_strategy: crate::bitbucket::pull_request::MergeStrategy::Squash,
5536            wait_for_builds: true,
5537            build_timeout: Duration::from_secs(1800),
5538            allowed_authors: None,
5539        };
5540
5541        // Verify the conditions are set as expected for auto-land
5542        assert!(conditions.wait_for_builds);
5543        assert_eq!(conditions.build_timeout.as_secs(), 1800);
5544        assert!(conditions.allowed_authors.is_none());
5545        assert!(matches!(
5546            conditions.merge_strategy,
5547            crate::bitbucket::pull_request::MergeStrategy::Squash
5548        ));
5549    }
5550
5551    #[test]
5552    fn test_polling_constants() {
5553        // Test that polling frequency is documented and reasonable
5554        use std::time::Duration;
5555
5556        // The polling frequency should be 30 seconds as mentioned in documentation
5557        let expected_polling_interval = Duration::from_secs(30);
5558
5559        // Verify it's a reasonable value (not too frequent, not too slow)
5560        assert!(expected_polling_interval.as_secs() >= 10); // At least 10 seconds
5561        assert!(expected_polling_interval.as_secs() <= 60); // At most 1 minute
5562        assert_eq!(expected_polling_interval.as_secs(), 30); // Exactly 30 seconds
5563    }
5564
5565    #[test]
5566    fn test_build_timeout_defaults() {
5567        // Verify build timeout default is reasonable
5568        const DEFAULT_TIMEOUT: u64 = 1800; // 30 minutes
5569        assert_eq!(DEFAULT_TIMEOUT, 1800);
5570        // Test that our default timeout value is within reasonable bounds
5571        let timeout_value = 1800u64;
5572        assert!(timeout_value >= 300); // At least 5 minutes
5573        assert!(timeout_value <= 3600); // At most 1 hour
5574    }
5575
5576    #[test]
5577    fn test_scattered_commit_detection() {
5578        use std::collections::HashSet;
5579
5580        // Test scattered commit detection logic
5581        let mut source_branches = HashSet::new();
5582        source_branches.insert("feature-branch-1".to_string());
5583        source_branches.insert("feature-branch-2".to_string());
5584        source_branches.insert("feature-branch-3".to_string());
5585
5586        // Single branch should not trigger warning
5587        let single_branch = HashSet::from(["main".to_string()]);
5588        assert_eq!(single_branch.len(), 1);
5589
5590        // Multiple branches should trigger warning
5591        assert!(source_branches.len() > 1);
5592        assert_eq!(source_branches.len(), 3);
5593
5594        // Verify branch names are preserved correctly
5595        assert!(source_branches.contains("feature-branch-1"));
5596        assert!(source_branches.contains("feature-branch-2"));
5597        assert!(source_branches.contains("feature-branch-3"));
5598    }
5599
5600    #[test]
5601    fn test_source_branch_tracking() {
5602        // Test that source branch tracking correctly handles different scenarios
5603
5604        // Same branch should be consistent
5605        let branch_a = "feature-work";
5606        let branch_b = "feature-work";
5607        assert_eq!(branch_a, branch_b);
5608
5609        // Different branches should be detected
5610        let branch_1 = "feature-ui";
5611        let branch_2 = "feature-api";
5612        assert_ne!(branch_1, branch_2);
5613
5614        // Branch naming patterns
5615        assert!(branch_1.starts_with("feature-"));
5616        assert!(branch_2.starts_with("feature-"));
5617    }
5618
5619    // Tests for new default behavior (removing --all flag)
5620
5621    #[tokio::test]
5622    async fn test_push_default_behavior() {
5623        // Test the push_to_stack function structure and error handling in an isolated environment
5624        let (temp_dir, repo_path) = match create_test_repo() {
5625            Ok(repo) => repo,
5626            Err(_) => {
5627                println!("Skipping test due to git environment setup failure");
5628                return;
5629            }
5630        };
5631        // IMPORTANT: temp_dir must stay in scope to prevent early cleanup of test directory
5632        let _ = &temp_dir;
5633
5634        // Verify directory exists before changing to it
5635        if !repo_path.exists() {
5636            println!("Skipping test due to temporary directory creation issue");
5637            return;
5638        }
5639
5640        // Change to the test repository directory to ensure isolation
5641        let original_dir = env::current_dir().map_err(|_| "Failed to get current dir");
5642
5643        match env::set_current_dir(&repo_path) {
5644            Ok(_) => {
5645                // Test that push_to_stack properly handles the case when no stack is active
5646                let result = push_to_stack(
5647                    None,  // branch
5648                    None,  // message
5649                    None,  // commit
5650                    None,  // since
5651                    None,  // commits
5652                    None,  // squash
5653                    None,  // squash_since
5654                    false, // auto_branch
5655                    false, // allow_base_branch
5656                    false, // dry_run
5657                    true,  // yes (skip prompts in test)
5658                )
5659                .await;
5660
5661                // Restore original directory (best effort)
5662                if let Ok(orig) = original_dir {
5663                    let _ = env::set_current_dir(orig);
5664                }
5665
5666                // Should fail gracefully with appropriate error message when no stack is active
5667                match &result {
5668                    Err(e) => {
5669                        let error_msg = e.to_string();
5670                        // This is the expected behavior - no active stack should produce this error
5671                        assert!(
5672                            error_msg.contains("No active stack")
5673                                || error_msg.contains("config")
5674                                || error_msg.contains("current directory")
5675                                || error_msg.contains("Not a git repository")
5676                                || error_msg.contains("could not find repository"),
5677                            "Expected 'No active stack' or repository error, got: {error_msg}"
5678                        );
5679                    }
5680                    Ok(_) => {
5681                        // If it somehow succeeds, that's also fine (e.g., if environment is set up differently)
5682                        println!(
5683                            "Push succeeded unexpectedly - test environment may have active stack"
5684                        );
5685                    }
5686                }
5687            }
5688            Err(_) => {
5689                // Skip test if we can't change directories (CI environment issue)
5690                println!("Skipping test due to directory access restrictions");
5691            }
5692        }
5693
5694        // Verify we can construct the command structure correctly
5695        let push_action = StackAction::Push {
5696            branch: None,
5697            message: None,
5698            commit: None,
5699            since: None,
5700            commits: None,
5701            squash: None,
5702            squash_since: None,
5703            auto_branch: false,
5704            allow_base_branch: false,
5705            dry_run: false,
5706            yes: false,
5707        };
5708
5709        assert!(matches!(
5710            push_action,
5711            StackAction::Push {
5712                branch: None,
5713                message: None,
5714                commit: None,
5715                since: None,
5716                commits: None,
5717                squash: None,
5718                squash_since: None,
5719                auto_branch: false,
5720                allow_base_branch: false,
5721                dry_run: false,
5722                yes: false
5723            }
5724        ));
5725    }
5726
5727    #[tokio::test]
5728    async fn test_submit_default_behavior() {
5729        // Test the submit_entry function structure and error handling in an isolated environment
5730        let (temp_dir, repo_path) = match create_test_repo() {
5731            Ok(repo) => repo,
5732            Err(_) => {
5733                println!("Skipping test due to git environment setup failure");
5734                return;
5735            }
5736        };
5737        // IMPORTANT: temp_dir must stay in scope to prevent early cleanup of test directory
5738        let _ = &temp_dir;
5739
5740        // Verify directory exists before changing to it
5741        if !repo_path.exists() {
5742            println!("Skipping test due to temporary directory creation issue");
5743            return;
5744        }
5745
5746        // Change to the test repository directory to ensure isolation
5747        let original_dir = match env::current_dir() {
5748            Ok(dir) => dir,
5749            Err(_) => {
5750                println!("Skipping test due to current directory access restrictions");
5751                return;
5752            }
5753        };
5754
5755        match env::set_current_dir(&repo_path) {
5756            Ok(_) => {
5757                // Test that submit_entry properly handles the case when no stack is active
5758                let result = submit_entry(
5759                    None,  // entry (should default to all unsubmitted)
5760                    None,  // title
5761                    None,  // description
5762                    None,  // range
5763                    false, // draft
5764                    true,  // open
5765                )
5766                .await;
5767
5768                // Restore original directory
5769                let _ = env::set_current_dir(original_dir);
5770
5771                // Should fail gracefully with appropriate error message when no stack is active
5772                match &result {
5773                    Err(e) => {
5774                        let error_msg = e.to_string();
5775                        // This is the expected behavior - no active stack should produce this error
5776                        assert!(
5777                            error_msg.contains("No active stack")
5778                                || error_msg.contains("config")
5779                                || error_msg.contains("current directory")
5780                                || error_msg.contains("Not a git repository")
5781                                || error_msg.contains("could not find repository"),
5782                            "Expected 'No active stack' or repository error, got: {error_msg}"
5783                        );
5784                    }
5785                    Ok(_) => {
5786                        // If it somehow succeeds, that's also fine (e.g., if environment is set up differently)
5787                        println!("Submit succeeded unexpectedly - test environment may have active stack");
5788                    }
5789                }
5790            }
5791            Err(_) => {
5792                // Skip test if we can't change directories (CI environment issue)
5793                println!("Skipping test due to directory access restrictions");
5794            }
5795        }
5796
5797        // Verify we can construct the command structure correctly
5798        let submit_action = StackAction::Submit {
5799            entry: None,
5800            title: None,
5801            description: None,
5802            range: None,
5803            draft: true, // Default changed to true
5804            open: true,
5805        };
5806
5807        assert!(matches!(
5808            submit_action,
5809            StackAction::Submit {
5810                entry: None,
5811                title: None,
5812                description: None,
5813                range: None,
5814                draft: true, // Default changed to true
5815                open: true
5816            }
5817        ));
5818    }
5819
5820    #[test]
5821    fn test_targeting_options_still_work() {
5822        // Test that specific targeting options still work correctly
5823
5824        // Test commit list parsing
5825        let commits = "abc123,def456,ghi789";
5826        let parsed: Vec<&str> = commits.split(',').map(|s| s.trim()).collect();
5827        assert_eq!(parsed.len(), 3);
5828        assert_eq!(parsed[0], "abc123");
5829        assert_eq!(parsed[1], "def456");
5830        assert_eq!(parsed[2], "ghi789");
5831
5832        // Test range parsing would work
5833        let range = "1-3";
5834        assert!(range.contains('-'));
5835        let parts: Vec<&str> = range.split('-').collect();
5836        assert_eq!(parts.len(), 2);
5837
5838        // Test since reference pattern
5839        let since_ref = "HEAD~3";
5840        assert!(since_ref.starts_with("HEAD"));
5841        assert!(since_ref.contains('~'));
5842    }
5843
5844    #[test]
5845    fn test_command_flow_logic() {
5846        // These just test the command structure exists
5847        assert!(matches!(
5848            StackAction::Push {
5849                branch: None,
5850                message: None,
5851                commit: None,
5852                since: None,
5853                commits: None,
5854                squash: None,
5855                squash_since: None,
5856                auto_branch: false,
5857                allow_base_branch: false,
5858                dry_run: false,
5859                yes: false
5860            },
5861            StackAction::Push { .. }
5862        ));
5863
5864        assert!(matches!(
5865            StackAction::Submit {
5866                entry: None,
5867                title: None,
5868                description: None,
5869                range: None,
5870                draft: false,
5871                open: true
5872            },
5873            StackAction::Submit { .. }
5874        ));
5875    }
5876
5877    #[tokio::test]
5878    async fn test_deactivate_command_structure() {
5879        // Test that deactivate command structure exists (deprecated, shows guidance)
5880        let deactivate_action = StackAction::Deactivate { force: false };
5881        assert!(matches!(
5882            deactivate_action,
5883            StackAction::Deactivate { force: false }
5884        ));
5885    }
5886}