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 },
141
142 /// Pop the top commit from the stack (shortcut for 'stack pop')
143 Pop {
144 /// Keep the branch (don't delete it)
145 #[arg(long)]
146 keep_branch: bool,
147 },
148
149 /// Land (merge) approved stack entries (shortcut for 'stack land')
150 Land {
151 /// Stack entry number to land (1-based index, optional)
152 entry: Option<usize>,
153 /// Force land even with blocking issues (dangerous)
154 #[arg(short, long)]
155 force: bool,
156 /// Dry run - show what would be landed without doing it
157 #[arg(short, long)]
158 dry_run: bool,
159 /// Use server-side validation (safer, checks approvals/builds)
160 #[arg(long)]
161 auto: bool,
162 /// Wait for builds to complete before merging
163 #[arg(long)]
164 wait_for_builds: bool,
165 /// Merge strategy to use
166 #[arg(long, value_enum, default_value = "squash")]
167 strategy: Option<MergeStrategyArg>,
168 /// Maximum time to wait for builds (seconds)
169 #[arg(long, default_value = "1800")]
170 build_timeout: u64,
171 },
172
173 /// Auto-land all ready PRs (shortcut for 'stack autoland')
174 Autoland {
175 /// Force land even with blocking issues (dangerous)
176 #[arg(short, long)]
177 force: bool,
178 /// Dry run - show what would be landed without doing it
179 #[arg(short, long)]
180 dry_run: bool,
181 /// Wait for builds to complete before merging
182 #[arg(long)]
183 wait_for_builds: bool,
184 /// Merge strategy to use
185 #[arg(long, value_enum, default_value = "squash")]
186 strategy: Option<MergeStrategyArg>,
187 /// Maximum time to wait for builds (seconds)
188 #[arg(long, default_value = "1800")]
189 build_timeout: u64,
190 },
191
192 /// Sync stack with remote repository (shortcut for 'stack sync')
193 Sync {
194 /// Force sync even if there are conflicts
195 #[arg(long)]
196 force: bool,
197 /// Skip cleanup of merged branches
198 #[arg(long)]
199 skip_cleanup: bool,
200 /// Interactive mode for conflict resolution
201 #[arg(long, short)]
202 interactive: bool,
203 },
204
205 /// Rebase stack on updated base branch (shortcut for 'stack rebase')
206 Rebase {
207 /// Interactive rebase
208 #[arg(long, short)]
209 interactive: bool,
210 /// Target base branch (defaults to stack's base branch)
211 #[arg(long)]
212 onto: Option<String>,
213 /// Rebase strategy to use
214 #[arg(long, value_enum)]
215 strategy: Option<RebaseStrategyArg>,
216 },
217
218 /// Switch to a different stack (shortcut for 'stacks switch')
219 Switch {
220 /// Name of the stack to switch to
221 name: String,
222 },
223
224 /// Deactivate the current stack - turn off stack mode (shortcut for 'stacks deactivate')
225 Deactivate {
226 /// Force deactivation without confirmation
227 #[arg(long)]
228 force: bool,
229 },
230
231 /// Submit a stack entry for review (shortcut for 'stacks submit')
232 Submit {
233 /// Stack entry number (1-based, defaults to all unsubmitted)
234 entry: Option<usize>,
235 /// Pull request title
236 #[arg(long, short)]
237 title: Option<String>,
238 /// Pull request description
239 #[arg(long, short)]
240 description: Option<String>,
241 /// Submit range of entries (e.g., "1-3" or "2,4,6")
242 #[arg(long)]
243 range: Option<String>,
244 /// Create draft pull requests (can be edited later)
245 #[arg(long)]
246 draft: bool,
247 },
248
249 /// Validate stack integrity and handle branch modifications (shortcut for 'stacks validate')
250 Validate {
251 /// Name of the stack (defaults to active stack)
252 name: Option<String>,
253 /// Auto-fix mode: incorporate, split, or reset
254 #[arg(long)]
255 fix: Option<String>,
256 },
257}
258
259/// Git hooks actions
260#[derive(Debug, Subcommand)]
261pub enum HooksAction {
262 /// Install Cascade Git hooks
263 Install {
264 /// Install all hooks including post-commit (default: essential hooks only)
265 #[arg(long)]
266 all: bool,
267
268 /// Skip prerequisite checks (repository type, configuration validation)
269 #[arg(long)]
270 skip_checks: bool,
271
272 /// Allow installation on main/master branches (not recommended)
273 #[arg(long)]
274 allow_main_branch: bool,
275
276 /// Skip confirmation prompt
277 #[arg(long, short)]
278 yes: bool,
279
280 /// Force installation even if checks fail (not recommended)
281 #[arg(long)]
282 force: bool,
283 },
284
285 /// Uninstall all Cascade Git hooks
286 Uninstall,
287
288 /// Show Git hooks status
289 Status,
290
291 /// Install a specific hook
292 Add {
293 /// Hook name (post-commit, pre-push, commit-msg, prepare-commit-msg)
294 hook: String,
295
296 /// Skip prerequisite checks
297 #[arg(long)]
298 skip_checks: bool,
299
300 /// Force installation even if checks fail
301 #[arg(long)]
302 force: bool,
303 },
304
305 /// Remove a specific hook
306 Remove {
307 /// Hook name (post-commit, pre-push, commit-msg, prepare-commit-msg)
308 hook: String,
309 },
310}
311
312/// Visualization actions
313#[derive(Debug, Subcommand)]
314pub enum VizAction {
315 /// Show stack diagram
316 Stack {
317 /// Stack name (defaults to active stack)
318 name: Option<String>,
319 /// Output format (ascii, mermaid, dot, plantuml)
320 #[arg(long, short)]
321 format: Option<String>,
322 /// Output file path
323 #[arg(long, short)]
324 output: Option<String>,
325 /// Compact mode (less details)
326 #[arg(long)]
327 compact: bool,
328 /// Disable colors
329 #[arg(long)]
330 no_colors: bool,
331 },
332
333 /// Show dependency graph of all stacks
334 Deps {
335 /// Output format (ascii, mermaid, dot, plantuml)
336 #[arg(long, short)]
337 format: Option<String>,
338 /// Output file path
339 #[arg(long, short)]
340 output: Option<String>,
341 /// Compact mode (less details)
342 #[arg(long)]
343 compact: bool,
344 /// Disable colors
345 #[arg(long)]
346 no_colors: bool,
347 },
348}
349
350/// Shell completion actions
351#[derive(Debug, Subcommand)]
352pub enum CompletionsAction {
353 /// Generate completions for a shell
354 Generate {
355 /// Shell to generate completions for
356 #[arg(value_enum)]
357 shell: Shell,
358 },
359
360 /// Install completions for available shells
361 Install {
362 /// Specific shell to install for
363 #[arg(long, value_enum)]
364 shell: Option<Shell>,
365 },
366
367 /// Show completion installation status
368 Status,
369}
370
371#[derive(Debug, Subcommand)]
372pub enum ConfigAction {
373 /// Set a configuration value
374 Set {
375 /// Configuration key (e.g., bitbucket.url)
376 key: String,
377 /// Configuration value
378 value: String,
379 },
380
381 /// Get a configuration value
382 Get {
383 /// Configuration key
384 key: String,
385 },
386
387 /// List all configuration values
388 List,
389
390 /// Remove a configuration value
391 Unset {
392 /// Configuration key
393 key: String,
394 },
395}
396
397impl Cli {
398 pub async fn run(self) -> Result<()> {
399 // Set up logging based on verbosity
400 self.setup_logging();
401
402 // Initialize git2 to use system certificates by default
403 // This ensures we work out-of-the-box in corporate environments
404 // just like git CLI and other modern dev tools (Graphite, Sapling, Phabricator)
405 self.init_git2_ssl()?;
406
407 match self.command {
408 Commands::Init {
409 bitbucket_url,
410 force,
411 } => commands::init::run(bitbucket_url, force).await,
412 Commands::Config { action } => commands::config::run(action).await,
413 Commands::Stacks { action } => commands::stack::run(action).await,
414 Commands::Entry { action } => commands::entry::run(action).await,
415 Commands::Repo => commands::status::run().await,
416 Commands::Version => commands::version::run().await,
417 Commands::Doctor => commands::doctor::run().await,
418 Commands::Diagnose => commands::diagnose::run().await,
419
420 Commands::Completions { action } => match action {
421 CompletionsAction::Generate { shell } => {
422 commands::completions::generate_completions(shell)
423 }
424 CompletionsAction::Install { shell } => {
425 commands::completions::install_completions(shell)
426 }
427 CompletionsAction::Status => commands::completions::show_completions_status(),
428 },
429
430 Commands::Setup { force } => commands::setup::run(force).await,
431
432 Commands::Tui => commands::tui::run().await,
433
434 Commands::Hooks { action } => match action {
435 HooksAction::Install {
436 all,
437 skip_checks,
438 allow_main_branch,
439 yes,
440 force,
441 } => {
442 if all {
443 // Install all hooks including post-commit
444 commands::hooks::install_with_options(
445 skip_checks,
446 allow_main_branch,
447 yes,
448 force,
449 )
450 .await
451 } else {
452 // Install essential hooks by default (excludes post-commit)
453 // Users can install post-commit separately with 'ca hooks add post-commit'
454 commands::hooks::install_essential().await
455 }
456 }
457 HooksAction::Uninstall => commands::hooks::uninstall().await,
458 HooksAction::Status => commands::hooks::status().await,
459 HooksAction::Add {
460 hook,
461 skip_checks,
462 force,
463 } => commands::hooks::install_hook_with_options(&hook, skip_checks, force).await,
464 HooksAction::Remove { hook } => commands::hooks::uninstall_hook(&hook).await,
465 },
466
467 Commands::Viz { action } => match action {
468 VizAction::Stack {
469 name,
470 format,
471 output,
472 compact,
473 no_colors,
474 } => {
475 commands::viz::show_stack(
476 name.clone(),
477 format.clone(),
478 output.clone(),
479 compact,
480 no_colors,
481 )
482 .await
483 }
484 VizAction::Deps {
485 format,
486 output,
487 compact,
488 no_colors,
489 } => {
490 commands::viz::show_dependencies(
491 format.clone(),
492 output.clone(),
493 compact,
494 no_colors,
495 )
496 .await
497 }
498 },
499
500 Commands::Stack { verbose, mergeable } => {
501 commands::stack::show(verbose, mergeable).await
502 }
503
504 Commands::Push {
505 branch,
506 message,
507 commit,
508 since,
509 commits,
510 squash,
511 squash_since,
512 auto_branch,
513 allow_base_branch,
514 } => {
515 commands::stack::push(
516 branch,
517 message,
518 commit,
519 since,
520 commits,
521 squash,
522 squash_since,
523 auto_branch,
524 allow_base_branch,
525 )
526 .await
527 }
528
529 Commands::Pop { keep_branch } => commands::stack::pop(keep_branch).await,
530
531 Commands::Land {
532 entry,
533 force,
534 dry_run,
535 auto,
536 wait_for_builds,
537 strategy,
538 build_timeout,
539 } => {
540 commands::stack::land(
541 entry,
542 force,
543 dry_run,
544 auto,
545 wait_for_builds,
546 strategy,
547 build_timeout,
548 )
549 .await
550 }
551
552 Commands::Autoland {
553 force,
554 dry_run,
555 wait_for_builds,
556 strategy,
557 build_timeout,
558 } => {
559 commands::stack::autoland(force, dry_run, wait_for_builds, strategy, build_timeout)
560 .await
561 }
562
563 Commands::Sync {
564 force,
565 skip_cleanup,
566 interactive,
567 } => commands::stack::sync(force, skip_cleanup, interactive).await,
568
569 Commands::Rebase {
570 interactive,
571 onto,
572 strategy,
573 } => commands::stack::rebase(interactive, onto, strategy).await,
574
575 Commands::Switch { name } => commands::stack::switch(name).await,
576
577 Commands::Deactivate { force } => commands::stack::deactivate(force).await,
578
579 Commands::Submit {
580 entry,
581 title,
582 description,
583 range,
584 draft,
585 } => {
586 // Delegate to the stacks submit functionality
587 let submit_action = StackAction::Submit {
588 entry,
589 title,
590 description,
591 range,
592 draft,
593 };
594 commands::stack::run(submit_action).await
595 }
596
597 Commands::Validate { name, fix } => {
598 // Delegate to the stacks validate functionality
599 let validate_action = StackAction::Validate { name, fix };
600 commands::stack::run(validate_action).await
601 }
602 }
603 }
604
605 /// Initialize git2 to use system certificates by default
606 /// This makes Cascade work like git CLI in corporate environments
607 fn init_git2_ssl(&self) -> Result<()> {
608 // Only import SSL functions on platforms that use them
609 #[cfg(any(target_os = "macos", target_os = "linux"))]
610 use git2::opts::{set_ssl_cert_dir, set_ssl_cert_file};
611
612 // Configure git2 to use system certificate store
613 // This matches behavior of git CLI and tools like Graphite/Sapling
614 tracing::debug!("Initializing git2 SSL configuration with system certificates");
615
616 // Try to use system certificate locations
617 // On macOS: /etc/ssl/cert.pem, /usr/local/etc/ssl/cert.pem
618 // On Linux: /etc/ssl/certs/ca-certificates.crt, /etc/ssl/certs/ca-bundle.crt
619 // On Windows: Uses Windows certificate store automatically
620
621 #[cfg(target_os = "macos")]
622 {
623 // macOS certificate locations (certificate files)
624 let cert_files = [
625 "/etc/ssl/cert.pem",
626 "/usr/local/etc/ssl/cert.pem",
627 "/opt/homebrew/etc/ca-certificates/cert.pem",
628 ];
629
630 for cert_path in &cert_files {
631 if std::path::Path::new(cert_path).exists() {
632 tracing::debug!("Using macOS system certificates from: {}", cert_path);
633 if let Err(e) = unsafe { set_ssl_cert_file(cert_path) } {
634 tracing::trace!(
635 "SSL cert file {} not supported by TLS backend: {}",
636 cert_path,
637 e
638 );
639 } else {
640 return Ok(());
641 }
642 }
643 }
644
645 // Fallback to certificate directories
646 let cert_dirs = ["/etc/ssl/certs", "/usr/local/etc/ssl/certs"];
647
648 for cert_dir in &cert_dirs {
649 if std::path::Path::new(cert_dir).exists() {
650 tracing::debug!("Using macOS system certificate directory: {}", cert_dir);
651 if let Err(e) = unsafe { set_ssl_cert_dir(cert_dir) } {
652 tracing::trace!(
653 "SSL cert directory {} not supported by TLS backend: {}",
654 cert_dir,
655 e
656 );
657 } else {
658 return Ok(());
659 }
660 }
661 }
662 }
663
664 #[cfg(target_os = "linux")]
665 {
666 // Linux certificate files
667 let cert_files = [
668 "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
669 "/etc/ssl/certs/ca-bundle.crt", // RHEL/CentOS
670 "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL
671 "/etc/ssl/ca-bundle.pem", // OpenSUSE
672 ];
673
674 for cert_path in &cert_files {
675 if std::path::Path::new(cert_path).exists() {
676 tracing::debug!("Using Linux system certificates from: {}", cert_path);
677 if let Err(e) = unsafe { set_ssl_cert_file(cert_path) } {
678 tracing::trace!(
679 "SSL cert file {} not supported by TLS backend: {}",
680 cert_path,
681 e
682 );
683 } else {
684 return Ok(());
685 }
686 }
687 }
688
689 // Fallback to certificate directories
690 let cert_dirs = ["/etc/ssl/certs", "/etc/pki/tls/certs"];
691
692 for cert_dir in &cert_dirs {
693 if std::path::Path::new(cert_dir).exists() {
694 tracing::debug!("Using Linux system certificate directory: {}", cert_dir);
695 if let Err(e) = unsafe { set_ssl_cert_dir(cert_dir) } {
696 tracing::trace!(
697 "SSL cert directory {} not supported by TLS backend: {}",
698 cert_dir,
699 e
700 );
701 } else {
702 return Ok(());
703 }
704 }
705 }
706 }
707
708 #[cfg(target_os = "windows")]
709 {
710 // Windows uses system certificate store automatically via git2's default configuration
711 tracing::debug!("Using Windows system certificate store (automatic)");
712 }
713
714 tracing::debug!("System SSL certificate configuration complete");
715 tracing::debug!(
716 "Note: SSL warnings from libgit2 are normal - git CLI fallback will be used if needed"
717 );
718 Ok(())
719 }
720
721 fn setup_logging(&self) {
722 let level = if self.verbose {
723 tracing::Level::DEBUG
724 } else {
725 tracing::Level::INFO
726 };
727
728 let subscriber = tracing_subscriber::fmt()
729 .with_max_level(level)
730 .with_target(false)
731 .without_time();
732
733 if self.no_color {
734 subscriber.with_ansi(false).init();
735 } else {
736 subscriber.init();
737 }
738 }
739}