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