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