cascade_cli/cli/
mod.rs

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