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