cascade_cli/cli/
mod.rs

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