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