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