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