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