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