cascade_cli/cli/
mod.rs

1pub mod commands;
2pub mod output;
3
4use crate::errors::Result;
5use clap::{Parser, Subcommand};
6use clap_complete::Shell;
7use commands::entry::EntryAction;
8use commands::stack::StackAction;
9use commands::{MergeStrategyArg, RebaseStrategyArg};
10
11#[derive(Debug, Subcommand)]
12pub enum SyncAction {
13    /// Continue after resolving conflicts
14    Continue,
15    /// Abort in-progress sync
16    Abort,
17}
18
19#[derive(Debug, Subcommand)]
20pub enum RebaseAction {
21    /// Continue after resolving conflicts
22    Continue,
23    /// Abort in-progress rebase
24    Abort,
25}
26
27#[derive(Parser)]
28#[command(name = "ca")]
29#[command(about = "Cascade CLI - Stacked Diffs for Bitbucket")]
30#[command(version)]
31pub struct Cli {
32    #[command(subcommand)]
33    pub command: Commands,
34
35    /// Enable verbose logging
36    #[arg(long, short, global = true)]
37    pub verbose: bool,
38
39    /// Disable colored output
40    #[arg(long, global = true)]
41    pub no_color: bool,
42}
43
44/// Commands available in the CLI
45#[derive(Debug, Subcommand)]
46pub enum Commands {
47    /// Initialize repository for Cascade
48    Init {
49        /// Bitbucket Server URL
50        #[arg(long)]
51        bitbucket_url: Option<String>,
52
53        /// Force initialization even if already initialized
54        #[arg(long)]
55        force: bool,
56    },
57
58    /// Configuration management
59    Config {
60        #[command(subcommand)]
61        action: ConfigAction,
62    },
63
64    /// Stack management
65    Stacks {
66        #[command(subcommand)]
67        action: StackAction,
68    },
69
70    /// Entry management and editing
71    Entry {
72        #[command(subcommand)]
73        action: EntryAction,
74    },
75
76    /// Show repository overview and all stacks
77    Repo,
78
79    /// Show version information  
80    Version,
81
82    /// Check repository health and configuration
83    Doctor,
84
85    /// Diagnose git2 TLS/SSH support issues
86    Diagnose,
87
88    /// Generate shell completions
89    Completions {
90        #[command(subcommand)]
91        action: CompletionsAction,
92    },
93
94    /// Interactive setup wizard
95    Setup {
96        /// Force reconfiguration if already initialized
97        #[arg(long)]
98        force: bool,
99    },
100
101    /// Launch interactive TUI for stack management
102    Tui,
103
104    /// Git hooks management
105    Hooks {
106        #[command(subcommand)]
107        action: HooksAction,
108    },
109
110    /// Visualize stacks and dependencies
111    Viz {
112        #[command(subcommand)]
113        action: VizAction,
114    },
115
116    /// Clean up orphaned temporary branches
117    Cleanup {
118        /// Actually delete branches (default is dry-run)
119        #[arg(long)]
120        execute: bool,
121
122        /// Force deletion even if branches have unmerged commits
123        #[arg(long)]
124        force: bool,
125    },
126
127    // Stack command shortcuts for commonly used operations
128    /// Show current stack details
129    Stack {
130        /// Show detailed pull request information
131        #[arg(short, long)]
132        verbose: bool,
133        /// Show mergability status for all PRs
134        #[arg(short, long)]
135        mergeable: bool,
136    },
137
138    /// Push current commit to the top of the stack (shortcut for 'stack push')
139    Push {
140        /// Branch name for this commit
141        #[arg(long, short)]
142        branch: Option<String>,
143        /// Commit message (if creating a new commit)
144        #[arg(long, short)]
145        message: Option<String>,
146        /// Use specific commit hash instead of HEAD
147        #[arg(long)]
148        commit: Option<String>,
149        /// Push commits since this reference (e.g., HEAD~3)
150        #[arg(long)]
151        since: Option<String>,
152        /// Push multiple specific commits (comma-separated)
153        #[arg(long)]
154        commits: Option<String>,
155        /// Squash last N commits into one before pushing
156        #[arg(long)]
157        squash: Option<usize>,
158        /// Squash all commits since this reference (e.g., HEAD~5)
159        #[arg(long)]
160        squash_since: Option<String>,
161        /// Auto-create feature branch when pushing from base branch
162        #[arg(long)]
163        auto_branch: bool,
164        /// Allow pushing commits from base branch (not recommended)
165        #[arg(long)]
166        allow_base_branch: bool,
167        /// Show what would be pushed without actually pushing
168        #[arg(long)]
169        dry_run: bool,
170    },
171
172    /// Pop the top commit from the stack (shortcut for 'stack pop')
173    Pop {
174        /// Keep the branch (don't delete it)
175        #[arg(long)]
176        keep_branch: bool,
177    },
178
179    /// Land (merge) approved stack entries (shortcut for 'stack land')
180    Land {
181        /// Stack entry number to land (1-based index, optional)
182        entry: Option<usize>,
183        /// Force land even with blocking issues (dangerous)
184        #[arg(short, long)]
185        force: bool,
186        /// Dry run - show what would be landed without doing it
187        #[arg(short, long)]
188        dry_run: bool,
189        /// Use server-side validation (safer, checks approvals/builds)
190        #[arg(long)]
191        auto: bool,
192        /// Wait for builds to complete before merging
193        #[arg(long)]
194        wait_for_builds: bool,
195        /// Merge strategy to use
196        #[arg(long, value_enum, default_value = "squash")]
197        strategy: Option<MergeStrategyArg>,
198        /// Maximum time to wait for builds (seconds)
199        #[arg(long, default_value = "1800")]
200        build_timeout: u64,
201    },
202
203    /// Auto-land all ready PRs (shortcut for 'stack autoland')
204    Autoland {
205        /// Force land even with blocking issues (dangerous)
206        #[arg(short, long)]
207        force: bool,
208        /// Dry run - show what would be landed without doing it
209        #[arg(short, long)]
210        dry_run: bool,
211        /// Wait for builds to complete before merging
212        #[arg(long)]
213        wait_for_builds: bool,
214        /// Merge strategy to use
215        #[arg(long, value_enum, default_value = "squash")]
216        strategy: Option<MergeStrategyArg>,
217        /// Maximum time to wait for builds (seconds)
218        #[arg(long, default_value = "1800")]
219        build_timeout: u64,
220    },
221
222    /// Sync operations (shortcut for 'stack sync')
223    Sync {
224        #[command(subcommand)]
225        action: Option<SyncAction>,
226
227        /// Force sync even if there are conflicts
228        #[arg(long, global = true)]
229        force: bool,
230        /// Also cleanup merged branches after sync
231        #[arg(long, global = true)]
232        cleanup: bool,
233        /// Interactive mode for conflict resolution
234        #[arg(long, short, global = true)]
235        interactive: bool,
236    },
237
238    /// Rebase operations (shortcut for 'stack rebase')
239    Rebase {
240        #[command(subcommand)]
241        action: Option<RebaseAction>,
242
243        /// Interactive rebase
244        #[arg(long, short, global = true)]
245        interactive: bool,
246        /// Target base branch (defaults to stack's base branch)
247        #[arg(long, global = true)]
248        onto: Option<String>,
249        /// Rebase strategy to use
250        #[arg(long, value_enum, global = true)]
251        strategy: Option<RebaseStrategyArg>,
252    },
253
254    /// Switch to a different stack (shortcut for 'stacks switch')
255    Switch {
256        /// Name of the stack to switch to
257        #[arg(value_hint = clap::ValueHint::Other)]
258        name: String,
259    },
260
261    /// Analyze conflicts in the repository
262    Conflicts {
263        /// Show detailed information about each conflict
264        #[arg(long)]
265        detailed: bool,
266
267        /// Only show conflicts that can be auto-resolved
268        #[arg(long)]
269        auto_only: bool,
270
271        /// Only show conflicts that require manual resolution
272        #[arg(long)]
273        manual_only: bool,
274
275        /// Analyze specific files (if not provided, analyzes all conflicted files)
276        #[arg(value_name = "FILE")]
277        files: Vec<String>,
278    },
279
280    /// Deactivate the current stack - turn off stack mode (shortcut for 'stacks deactivate')
281    Deactivate {
282        /// Force deactivation without confirmation
283        #[arg(long)]
284        force: bool,
285    },
286
287    /// Submit a stack entry for review (shortcut for 'stacks submit')
288    Submit {
289        /// Stack entry number (1-based, defaults to all unsubmitted)
290        entry: Option<usize>,
291        /// Pull request title
292        #[arg(long, short)]
293        title: Option<String>,
294        /// Pull request description
295        #[arg(long, short)]
296        description: Option<String>,
297        /// Submit range of entries (e.g., "1-3" or "2,4,6")
298        #[arg(long)]
299        range: Option<String>,
300        /// Create draft pull requests (default: true, use --no-draft to create ready PRs)
301        #[arg(long, default_value_t = true)]
302        draft: bool,
303        /// Open the PR(s) in your default browser after submission (default: true, use --no-open to disable)
304        #[arg(long, default_value_t = true)]
305        open: bool,
306    },
307
308    /// Validate stack integrity and handle branch modifications (shortcut for 'stacks validate')
309    Validate {
310        /// Name of the stack (defaults to active stack)
311        name: Option<String>,
312        /// Auto-fix mode: incorporate, split, or reset
313        #[arg(long)]
314        fix: Option<String>,
315    },
316
317    /// Internal command for shell completion (hidden)
318    #[command(hide = true)]
319    CompletionHelper {
320        #[command(subcommand)]
321        action: CompletionHelperAction,
322    },
323}
324
325/// Git hooks actions
326#[derive(Debug, Subcommand)]
327pub enum HooksAction {
328    /// Install Cascade Git hooks
329    Install {
330        /// Install all hooks including post-commit (default: essential hooks only)
331        #[arg(long)]
332        all: bool,
333
334        /// Skip prerequisite checks (repository type, configuration validation)
335        #[arg(long)]
336        skip_checks: bool,
337
338        /// Allow installation on main/master branches (not recommended)
339        #[arg(long)]
340        allow_main_branch: bool,
341
342        /// Skip confirmation prompt
343        #[arg(long, short)]
344        yes: bool,
345
346        /// Force installation even if checks fail (not recommended)
347        #[arg(long)]
348        force: bool,
349    },
350
351    /// Uninstall all Cascade Git hooks
352    Uninstall,
353
354    /// Show Git hooks status
355    Status,
356
357    /// Install a specific hook
358    Add {
359        /// Hook name (post-commit, pre-push, commit-msg, prepare-commit-msg)
360        hook: String,
361
362        /// Skip prerequisite checks
363        #[arg(long)]
364        skip_checks: bool,
365
366        /// Force installation even if checks fail
367        #[arg(long)]
368        force: bool,
369    },
370
371    /// Remove a specific hook
372    Remove {
373        /// Hook name (post-commit, pre-push, commit-msg, prepare-commit-msg)
374        hook: String,
375    },
376}
377
378/// Visualization actions
379#[derive(Debug, Subcommand)]
380pub enum VizAction {
381    /// Show stack diagram
382    Stack {
383        /// Stack name (defaults to active stack)
384        name: Option<String>,
385        /// Output format (ascii, mermaid, dot, plantuml)
386        #[arg(long, short)]
387        format: Option<String>,
388        /// Output file path
389        #[arg(long, short)]
390        output: Option<String>,
391        /// Compact mode (less details)
392        #[arg(long)]
393        compact: bool,
394        /// Disable colors
395        #[arg(long)]
396        no_colors: bool,
397    },
398
399    /// Show dependency graph of all stacks
400    Deps {
401        /// Output format (ascii, mermaid, dot, plantuml)
402        #[arg(long, short)]
403        format: Option<String>,
404        /// Output file path
405        #[arg(long, short)]
406        output: Option<String>,
407        /// Compact mode (less details)
408        #[arg(long)]
409        compact: bool,
410        /// Disable colors
411        #[arg(long)]
412        no_colors: bool,
413    },
414}
415
416/// Shell completion actions
417#[derive(Debug, Subcommand)]
418pub enum CompletionsAction {
419    /// Generate completions for a shell
420    Generate {
421        /// Shell to generate completions for
422        #[arg(value_enum)]
423        shell: Shell,
424    },
425
426    /// Install completions for available shells
427    Install {
428        /// Specific shell to install for
429        #[arg(long, value_enum)]
430        shell: Option<Shell>,
431    },
432
433    /// Show completion installation status
434    Status,
435}
436
437/// Hidden completion helper actions
438#[derive(Debug, Subcommand)]
439pub enum CompletionHelperAction {
440    /// List available stack names
441    StackNames,
442}
443
444#[derive(Debug, Subcommand)]
445pub enum ConfigAction {
446    /// Set a configuration value
447    Set {
448        /// Configuration key (e.g., bitbucket.url)
449        key: String,
450        /// Configuration value
451        value: String,
452    },
453
454    /// Get a configuration value
455    Get {
456        /// Configuration key
457        key: String,
458    },
459
460    /// List all configuration values
461    List,
462
463    /// Remove a configuration value
464    Unset {
465        /// Configuration key
466        key: String,
467    },
468}
469
470impl Cli {
471    pub async fn run(self) -> Result<()> {
472        // Set up logging based on verbosity
473        self.setup_logging();
474
475        // Initialize git2 to use system certificates by default
476        // This ensures we work out-of-the-box in corporate environments
477        // just like git CLI and other modern dev tools (Graphite, Sapling, Phabricator)
478        self.init_git2_ssl()?;
479
480        match self.command {
481            Commands::Init {
482                bitbucket_url,
483                force,
484            } => commands::init::run(bitbucket_url, force).await,
485            Commands::Config { action } => commands::config::run(action).await,
486            Commands::Stacks { action } => commands::stack::run(action).await,
487            Commands::Entry { action } => commands::entry::run(action).await,
488            Commands::Repo => commands::status::run().await,
489            Commands::Version => commands::version::run().await,
490            Commands::Doctor => commands::doctor::run().await,
491            Commands::Diagnose => commands::diagnose::run().await,
492
493            Commands::Completions { action } => match action {
494                CompletionsAction::Generate { shell } => {
495                    commands::completions::generate_completions(shell)
496                }
497                CompletionsAction::Install { shell } => {
498                    commands::completions::install_completions(shell)
499                }
500                CompletionsAction::Status => commands::completions::show_completions_status(),
501            },
502
503            Commands::Setup { force } => commands::setup::run(force).await,
504
505            Commands::Tui => commands::tui::run().await,
506
507            Commands::Cleanup { execute, force } => commands::cleanup::run(execute, force).await,
508
509            Commands::Hooks { action } => match action {
510                HooksAction::Install {
511                    all,
512                    skip_checks,
513                    allow_main_branch,
514                    yes,
515                    force,
516                } => {
517                    if all {
518                        // Install all hooks including post-commit
519                        commands::hooks::install_with_options(
520                            skip_checks,
521                            allow_main_branch,
522                            yes,
523                            force,
524                        )
525                        .await
526                    } else {
527                        // Install essential hooks by default (excludes post-commit)
528                        // Users can install post-commit separately with 'ca hooks add post-commit'
529                        commands::hooks::install_essential().await
530                    }
531                }
532                HooksAction::Uninstall => commands::hooks::uninstall().await,
533                HooksAction::Status => commands::hooks::status().await,
534                HooksAction::Add {
535                    hook,
536                    skip_checks,
537                    force,
538                } => commands::hooks::install_hook_with_options(&hook, skip_checks, force).await,
539                HooksAction::Remove { hook } => commands::hooks::uninstall_hook(&hook).await,
540            },
541
542            Commands::Viz { action } => match action {
543                VizAction::Stack {
544                    name,
545                    format,
546                    output,
547                    compact,
548                    no_colors,
549                } => {
550                    commands::viz::show_stack(
551                        name.clone(),
552                        format.clone(),
553                        output.clone(),
554                        compact,
555                        no_colors,
556                    )
557                    .await
558                }
559                VizAction::Deps {
560                    format,
561                    output,
562                    compact,
563                    no_colors,
564                } => {
565                    commands::viz::show_dependencies(
566                        format.clone(),
567                        output.clone(),
568                        compact,
569                        no_colors,
570                    )
571                    .await
572                }
573            },
574
575            Commands::Stack { verbose, mergeable } => {
576                commands::stack::show(verbose, mergeable).await
577            }
578
579            Commands::Push {
580                branch,
581                message,
582                commit,
583                since,
584                commits,
585                squash,
586                squash_since,
587                auto_branch,
588                allow_base_branch,
589                dry_run,
590            } => {
591                commands::stack::push(
592                    branch,
593                    message,
594                    commit,
595                    since,
596                    commits,
597                    squash,
598                    squash_since,
599                    auto_branch,
600                    allow_base_branch,
601                    dry_run,
602                )
603                .await
604            }
605
606            Commands::Pop { keep_branch } => commands::stack::pop(keep_branch).await,
607
608            Commands::Land {
609                entry,
610                force,
611                dry_run,
612                auto,
613                wait_for_builds,
614                strategy,
615                build_timeout,
616            } => {
617                commands::stack::land(
618                    entry,
619                    force,
620                    dry_run,
621                    auto,
622                    wait_for_builds,
623                    strategy,
624                    build_timeout,
625                )
626                .await
627            }
628
629            Commands::Autoland {
630                force,
631                dry_run,
632                wait_for_builds,
633                strategy,
634                build_timeout,
635            } => {
636                commands::stack::autoland(force, dry_run, wait_for_builds, strategy, build_timeout)
637                    .await
638            }
639
640            Commands::Sync {
641                action,
642                force,
643                cleanup,
644                interactive,
645            } => match action {
646                Some(SyncAction::Continue) => commands::stack::continue_sync().await,
647                Some(SyncAction::Abort) => commands::stack::abort_sync().await,
648                None => commands::stack::sync(force, cleanup, interactive).await,
649            },
650
651            Commands::Rebase {
652                action,
653                interactive,
654                onto,
655                strategy,
656            } => match action {
657                Some(RebaseAction::Continue) => commands::stack::continue_rebase().await,
658                Some(RebaseAction::Abort) => commands::stack::abort_rebase().await,
659                None => commands::stack::rebase(interactive, onto, strategy).await,
660            },
661
662            Commands::Switch { name } => commands::stack::switch(name).await,
663
664            Commands::Conflicts {
665                detailed,
666                auto_only,
667                manual_only,
668                files,
669            } => {
670                commands::conflicts::run(commands::conflicts::ConflictsArgs {
671                    detailed,
672                    auto_only,
673                    manual_only,
674                    files,
675                })
676                .await
677            }
678
679            Commands::Deactivate { force } => commands::stack::deactivate(force).await,
680
681            Commands::Submit {
682                entry,
683                title,
684                description,
685                range,
686                draft,
687                open,
688            } => {
689                // Delegate to the stacks submit functionality
690                let submit_action = StackAction::Submit {
691                    entry,
692                    title,
693                    description,
694                    range,
695                    draft,
696                    open,
697                };
698                commands::stack::run(submit_action).await
699            }
700
701            Commands::Validate { name, fix } => {
702                // Delegate to the stacks validate functionality
703                let validate_action = StackAction::Validate { name, fix };
704                commands::stack::run(validate_action).await
705            }
706
707            Commands::CompletionHelper { action } => handle_completion_helper(action).await,
708        }
709    }
710
711    /// Initialize git2 to use system certificates by default
712    /// This makes Cascade work like git CLI in corporate environments
713    fn init_git2_ssl(&self) -> Result<()> {
714        // Only import SSL functions on platforms that use them
715        #[cfg(any(target_os = "macos", target_os = "linux"))]
716        use git2::opts::{set_ssl_cert_dir, set_ssl_cert_file};
717
718        // Configure git2 to use system certificate store
719        // This matches behavior of git CLI and tools like Graphite/Sapling
720        tracing::debug!("Initializing git2 SSL configuration with system certificates");
721
722        // Try to use system certificate locations
723        // On macOS: /etc/ssl/cert.pem, /usr/local/etc/ssl/cert.pem
724        // On Linux: /etc/ssl/certs/ca-certificates.crt, /etc/ssl/certs/ca-bundle.crt
725        // On Windows: Uses Windows certificate store automatically
726
727        #[cfg(target_os = "macos")]
728        {
729            // macOS certificate locations (certificate files)
730            let cert_files = [
731                "/etc/ssl/cert.pem",
732                "/usr/local/etc/ssl/cert.pem",
733                "/opt/homebrew/etc/ca-certificates/cert.pem",
734            ];
735
736            for cert_path in &cert_files {
737                if std::path::Path::new(cert_path).exists() {
738                    tracing::debug!("Using macOS system certificates from: {}", cert_path);
739                    if let Err(e) = unsafe { set_ssl_cert_file(cert_path) } {
740                        tracing::trace!(
741                            "SSL cert file {} not supported by TLS backend: {}",
742                            cert_path,
743                            e
744                        );
745                    } else {
746                        return Ok(());
747                    }
748                }
749            }
750
751            // Fallback to certificate directories
752            let cert_dirs = ["/etc/ssl/certs", "/usr/local/etc/ssl/certs"];
753
754            for cert_dir in &cert_dirs {
755                if std::path::Path::new(cert_dir).exists() {
756                    tracing::debug!("Using macOS system certificate directory: {}", cert_dir);
757                    if let Err(e) = unsafe { set_ssl_cert_dir(cert_dir) } {
758                        tracing::trace!(
759                            "SSL cert directory {} not supported by TLS backend: {}",
760                            cert_dir,
761                            e
762                        );
763                    } else {
764                        return Ok(());
765                    }
766                }
767            }
768        }
769
770        #[cfg(target_os = "linux")]
771        {
772            // Linux certificate files
773            let cert_files = [
774                "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
775                "/etc/ssl/certs/ca-bundle.crt",       // RHEL/CentOS
776                "/etc/pki/tls/certs/ca-bundle.crt",   // Fedora/RHEL
777                "/etc/ssl/ca-bundle.pem",             // OpenSUSE
778            ];
779
780            for cert_path in &cert_files {
781                if std::path::Path::new(cert_path).exists() {
782                    tracing::debug!("Using Linux system certificates from: {}", cert_path);
783                    if let Err(e) = unsafe { set_ssl_cert_file(cert_path) } {
784                        tracing::trace!(
785                            "SSL cert file {} not supported by TLS backend: {}",
786                            cert_path,
787                            e
788                        );
789                    } else {
790                        return Ok(());
791                    }
792                }
793            }
794
795            // Fallback to certificate directories
796            let cert_dirs = ["/etc/ssl/certs", "/etc/pki/tls/certs"];
797
798            for cert_dir in &cert_dirs {
799                if std::path::Path::new(cert_dir).exists() {
800                    tracing::debug!("Using Linux system certificate directory: {}", cert_dir);
801                    if let Err(e) = unsafe { set_ssl_cert_dir(cert_dir) } {
802                        tracing::trace!(
803                            "SSL cert directory {} not supported by TLS backend: {}",
804                            cert_dir,
805                            e
806                        );
807                    } else {
808                        return Ok(());
809                    }
810                }
811            }
812        }
813
814        #[cfg(target_os = "windows")]
815        {
816            // Windows uses system certificate store automatically via git2's default configuration
817            tracing::debug!("Using Windows system certificate store (automatic)");
818        }
819
820        tracing::debug!("System SSL certificate configuration complete");
821        tracing::debug!(
822            "Note: SSL warnings from libgit2 are normal - git CLI fallback will be used if needed"
823        );
824        Ok(())
825    }
826
827    fn setup_logging(&self) {
828        let level = if self.verbose {
829            tracing::Level::DEBUG
830        } else {
831            tracing::Level::INFO
832        };
833
834        let subscriber = tracing_subscriber::fmt()
835            .with_max_level(level)
836            .with_target(false)
837            .without_time();
838
839        if self.no_color {
840            subscriber.with_ansi(false).init();
841        } else {
842            subscriber.init();
843        }
844    }
845}
846
847/// Handle completion helper commands
848async fn handle_completion_helper(action: CompletionHelperAction) -> Result<()> {
849    match action {
850        CompletionHelperAction::StackNames => {
851            use crate::git::find_repository_root;
852            use crate::stack::StackManager;
853            use std::env;
854
855            // Try to get stack names, but silently fail if not in a repository
856            if let Ok(current_dir) = env::current_dir() {
857                if let Ok(repo_root) = find_repository_root(&current_dir) {
858                    if let Ok(manager) = StackManager::new(&repo_root) {
859                        for (_, name, _, _, _) in manager.list_stacks() {
860                            println!("{name}");
861                        }
862                    }
863                }
864            }
865            Ok(())
866        }
867    }
868}