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