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