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 } => commands::cleanup::run(execute, force).await,
483
484 Commands::Hooks { action } => match action {
485 HooksAction::Install {
486 all,
487 skip_checks,
488 allow_main_branch,
489 yes,
490 force,
491 } => {
492 if all {
493 // Install all hooks including post-commit
494 commands::hooks::install_with_options(
495 skip_checks,
496 allow_main_branch,
497 yes,
498 force,
499 )
500 .await
501 } else {
502 // Install essential hooks by default (excludes post-commit)
503 // Users can install post-commit separately with 'ca hooks add post-commit'
504 commands::hooks::install_essential().await
505 }
506 }
507 HooksAction::Uninstall => commands::hooks::uninstall().await,
508 HooksAction::Status => commands::hooks::status().await,
509 HooksAction::Add {
510 hook,
511 skip_checks,
512 force,
513 } => commands::hooks::install_hook_with_options(&hook, skip_checks, force).await,
514 HooksAction::Remove { hook } => commands::hooks::uninstall_hook(&hook).await,
515 },
516
517 Commands::Viz { action } => match action {
518 VizAction::Stack {
519 name,
520 format,
521 output,
522 compact,
523 no_colors,
524 } => {
525 commands::viz::show_stack(
526 name.clone(),
527 format.clone(),
528 output.clone(),
529 compact,
530 no_colors,
531 )
532 .await
533 }
534 VizAction::Deps {
535 format,
536 output,
537 compact,
538 no_colors,
539 } => {
540 commands::viz::show_dependencies(
541 format.clone(),
542 output.clone(),
543 compact,
544 no_colors,
545 )
546 .await
547 }
548 },
549
550 Commands::Stack { verbose, mergeable } => {
551 commands::stack::show(verbose, mergeable).await
552 }
553
554 Commands::Push {
555 branch,
556 message,
557 commit,
558 since,
559 commits,
560 squash,
561 squash_since,
562 auto_branch,
563 allow_base_branch,
564 dry_run,
565 } => {
566 commands::stack::push(
567 branch,
568 message,
569 commit,
570 since,
571 commits,
572 squash,
573 squash_since,
574 auto_branch,
575 allow_base_branch,
576 dry_run,
577 )
578 .await
579 }
580
581 Commands::Pop { keep_branch } => commands::stack::pop(keep_branch).await,
582
583 Commands::Land {
584 entry,
585 force,
586 dry_run,
587 auto,
588 wait_for_builds,
589 strategy,
590 build_timeout,
591 } => {
592 commands::stack::land(
593 entry,
594 force,
595 dry_run,
596 auto,
597 wait_for_builds,
598 strategy,
599 build_timeout,
600 )
601 .await
602 }
603
604 Commands::Autoland {
605 force,
606 dry_run,
607 wait_for_builds,
608 strategy,
609 build_timeout,
610 } => {
611 commands::stack::autoland(force, dry_run, wait_for_builds, strategy, build_timeout)
612 .await
613 }
614
615 Commands::Sync {
616 force,
617 skip_cleanup,
618 interactive,
619 } => commands::stack::sync(force, skip_cleanup, interactive).await,
620
621 Commands::Rebase {
622 interactive,
623 onto,
624 strategy,
625 } => commands::stack::rebase(interactive, onto, strategy).await,
626
627 Commands::Switch { name } => commands::stack::switch(name).await,
628
629 Commands::Conflicts {
630 detailed,
631 auto_only,
632 manual_only,
633 files,
634 } => {
635 commands::conflicts::run(commands::conflicts::ConflictsArgs {
636 detailed,
637 auto_only,
638 manual_only,
639 files,
640 })
641 .await
642 }
643
644 Commands::Deactivate { force } => commands::stack::deactivate(force).await,
645
646 Commands::Submit {
647 entry,
648 title,
649 description,
650 range,
651 draft,
652 } => {
653 // Delegate to the stacks submit functionality
654 let submit_action = StackAction::Submit {
655 entry,
656 title,
657 description,
658 range,
659 draft,
660 };
661 commands::stack::run(submit_action).await
662 }
663
664 Commands::Validate { name, fix } => {
665 // Delegate to the stacks validate functionality
666 let validate_action = StackAction::Validate { name, fix };
667 commands::stack::run(validate_action).await
668 }
669
670 Commands::CompletionHelper { action } => handle_completion_helper(action).await,
671 }
672 }
673
674 /// Initialize git2 to use system certificates by default
675 /// This makes Cascade work like git CLI in corporate environments
676 fn init_git2_ssl(&self) -> Result<()> {
677 // Only import SSL functions on platforms that use them
678 #[cfg(any(target_os = "macos", target_os = "linux"))]
679 use git2::opts::{set_ssl_cert_dir, set_ssl_cert_file};
680
681 // Configure git2 to use system certificate store
682 // This matches behavior of git CLI and tools like Graphite/Sapling
683 tracing::debug!("Initializing git2 SSL configuration with system certificates");
684
685 // Try to use system certificate locations
686 // On macOS: /etc/ssl/cert.pem, /usr/local/etc/ssl/cert.pem
687 // On Linux: /etc/ssl/certs/ca-certificates.crt, /etc/ssl/certs/ca-bundle.crt
688 // On Windows: Uses Windows certificate store automatically
689
690 #[cfg(target_os = "macos")]
691 {
692 // macOS certificate locations (certificate files)
693 let cert_files = [
694 "/etc/ssl/cert.pem",
695 "/usr/local/etc/ssl/cert.pem",
696 "/opt/homebrew/etc/ca-certificates/cert.pem",
697 ];
698
699 for cert_path in &cert_files {
700 if std::path::Path::new(cert_path).exists() {
701 tracing::debug!("Using macOS system certificates from: {}", cert_path);
702 if let Err(e) = unsafe { set_ssl_cert_file(cert_path) } {
703 tracing::trace!(
704 "SSL cert file {} not supported by TLS backend: {}",
705 cert_path,
706 e
707 );
708 } else {
709 return Ok(());
710 }
711 }
712 }
713
714 // Fallback to certificate directories
715 let cert_dirs = ["/etc/ssl/certs", "/usr/local/etc/ssl/certs"];
716
717 for cert_dir in &cert_dirs {
718 if std::path::Path::new(cert_dir).exists() {
719 tracing::debug!("Using macOS system certificate directory: {}", cert_dir);
720 if let Err(e) = unsafe { set_ssl_cert_dir(cert_dir) } {
721 tracing::trace!(
722 "SSL cert directory {} not supported by TLS backend: {}",
723 cert_dir,
724 e
725 );
726 } else {
727 return Ok(());
728 }
729 }
730 }
731 }
732
733 #[cfg(target_os = "linux")]
734 {
735 // Linux certificate files
736 let cert_files = [
737 "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
738 "/etc/ssl/certs/ca-bundle.crt", // RHEL/CentOS
739 "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL
740 "/etc/ssl/ca-bundle.pem", // OpenSUSE
741 ];
742
743 for cert_path in &cert_files {
744 if std::path::Path::new(cert_path).exists() {
745 tracing::debug!("Using Linux system certificates from: {}", cert_path);
746 if let Err(e) = unsafe { set_ssl_cert_file(cert_path) } {
747 tracing::trace!(
748 "SSL cert file {} not supported by TLS backend: {}",
749 cert_path,
750 e
751 );
752 } else {
753 return Ok(());
754 }
755 }
756 }
757
758 // Fallback to certificate directories
759 let cert_dirs = ["/etc/ssl/certs", "/etc/pki/tls/certs"];
760
761 for cert_dir in &cert_dirs {
762 if std::path::Path::new(cert_dir).exists() {
763 tracing::debug!("Using Linux system certificate directory: {}", cert_dir);
764 if let Err(e) = unsafe { set_ssl_cert_dir(cert_dir) } {
765 tracing::trace!(
766 "SSL cert directory {} not supported by TLS backend: {}",
767 cert_dir,
768 e
769 );
770 } else {
771 return Ok(());
772 }
773 }
774 }
775 }
776
777 #[cfg(target_os = "windows")]
778 {
779 // Windows uses system certificate store automatically via git2's default configuration
780 tracing::debug!("Using Windows system certificate store (automatic)");
781 }
782
783 tracing::debug!("System SSL certificate configuration complete");
784 tracing::debug!(
785 "Note: SSL warnings from libgit2 are normal - git CLI fallback will be used if needed"
786 );
787 Ok(())
788 }
789
790 fn setup_logging(&self) {
791 let level = if self.verbose {
792 tracing::Level::DEBUG
793 } else {
794 tracing::Level::INFO
795 };
796
797 let subscriber = tracing_subscriber::fmt()
798 .with_max_level(level)
799 .with_target(false)
800 .without_time();
801
802 if self.no_color {
803 subscriber.with_ansi(false).init();
804 } else {
805 subscriber.init();
806 }
807 }
808}
809
810/// Handle completion helper commands
811async fn handle_completion_helper(action: CompletionHelperAction) -> Result<()> {
812 match action {
813 CompletionHelperAction::StackNames => {
814 use crate::git::find_repository_root;
815 use crate::stack::StackManager;
816 use std::env;
817
818 // Try to get stack names, but silently fail if not in a repository
819 if let Ok(current_dir) = env::current_dir() {
820 if let Ok(repo_root) = find_repository_root(¤t_dir) {
821 if let Ok(manager) = StackManager::new(&repo_root) {
822 for (_, name, _, _, _) in manager.list_stacks() {
823 println!("{name}");
824 }
825 }
826 }
827 }
828 Ok(())
829 }
830 }
831}