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