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