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