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